feat: Reaction system for Quips feed + fix groups 500 + reduce jank
- Replace heart/like in Quips sidebar with full reaction system:
tap = quick ❤️, long-press = full ReactionPicker dialog
- Add reactionPackageProvider (CDN → local assets → emoji fallback)
- Switch ReactionPicker to ConsumerStatefulWidget using provider
- Add CachedNetworkImage support in ReactionPicker + _ReactionIcon
- Fix CreateGroup handler: use 'privacy' column, drop non-existent
'is_private'/'banner_url' columns (were causing 500 on group creation)
- Cache overlayJson parsing in QuipVideoItem initState/didUpdateWidget
to eliminate double jsonDecode per build frame (was causing 174ms jank)
- Add post_hides table + HidePost handler + feed filtering
- Add showNavActions param to TraditionalQuipsSheet for clean Quips header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5b5e89e383
commit
93a2c45a92
|
|
@ -343,6 +343,7 @@ func main() {
|
||||||
authorized.DELETE("/posts/:id", postHandler.DeletePost)
|
authorized.DELETE("/posts/:id", postHandler.DeletePost)
|
||||||
authorized.POST("/posts/:id/pin", postHandler.PinPost)
|
authorized.POST("/posts/:id/pin", postHandler.PinPost)
|
||||||
authorized.PATCH("/posts/:id/visibility", postHandler.UpdateVisibility)
|
authorized.PATCH("/posts/:id/visibility", postHandler.UpdateVisibility)
|
||||||
|
authorized.POST("/posts/:id/hide", postHandler.HidePost)
|
||||||
authorized.POST("/posts/:id/like", postHandler.LikePost)
|
authorized.POST("/posts/:id/like", postHandler.LikePost)
|
||||||
authorized.DELETE("/posts/:id/like", postHandler.UnlikePost)
|
authorized.DELETE("/posts/:id/like", postHandler.UnlikePost)
|
||||||
authorized.POST("/posts/:id/save", postHandler.SavePost)
|
authorized.POST("/posts/:id/save", postHandler.SavePost)
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,6 @@ func (h *GroupsHandler) CreateGroup(c *gin.Context) {
|
||||||
Category string `json:"category" binding:"required"`
|
Category string `json:"category" binding:"required"`
|
||||||
IsPrivate bool `json:"is_private"`
|
IsPrivate bool `json:"is_private"`
|
||||||
AvatarURL *string `json:"avatar_url"`
|
AvatarURL *string `json:"avatar_url"`
|
||||||
BannerURL *string `json:"banner_url"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
|
@ -229,6 +228,11 @@ func (h *GroupsHandler) CreateGroup(c *gin.Context) {
|
||||||
// Normalize name for uniqueness check
|
// Normalize name for uniqueness check
|
||||||
req.Name = strings.TrimSpace(req.Name)
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
|
||||||
|
privacy := "public"
|
||||||
|
if req.IsPrivate {
|
||||||
|
privacy = "private"
|
||||||
|
}
|
||||||
|
|
||||||
tx, err := h.db.Begin(c.Request.Context())
|
tx, err := h.db.Begin(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create group"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create group"})
|
||||||
|
|
@ -239,10 +243,10 @@ func (h *GroupsHandler) CreateGroup(c *gin.Context) {
|
||||||
// Create group
|
// Create group
|
||||||
var groupID string
|
var groupID string
|
||||||
err = tx.QueryRow(c.Request.Context(), `
|
err = tx.QueryRow(c.Request.Context(), `
|
||||||
INSERT INTO groups (name, description, category, is_private, created_by, avatar_url, banner_url)
|
INSERT INTO groups (name, description, category, privacy, created_by, avatar_url)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, req.Name, req.Description, req.Category, req.IsPrivate, userID, req.AvatarURL, req.BannerURL).Scan(&groupID)
|
`, req.Name, req.Description, req.Category, privacy, userID, req.AvatarURL).Scan(&groupID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
|
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
|
||||||
|
|
|
||||||
|
|
@ -1170,6 +1170,22 @@ func (h *PostHandler) UnlikePost(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Post unliked"})
|
c.JSON(http.StatusOK, gin.H{"message": "Post unliked"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HidePost records a "Not Interested" signal for a post.
|
||||||
|
// The post will be excluded from all subsequent feed queries for this user,
|
||||||
|
// and repeated hides of the same author trigger algorithmic suppression.
|
||||||
|
func (h *PostHandler) HidePost(c *gin.Context) {
|
||||||
|
postID := c.Param("id")
|
||||||
|
userIDStr, _ := c.Get("user_id")
|
||||||
|
|
||||||
|
err := h.postRepo.HidePost(c.Request.Context(), postID, userIDStr.(string))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hide post", "details": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Post hidden"})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *PostHandler) SavePost(c *gin.Context) {
|
func (h *PostHandler) SavePost(c *gin.Context) {
|
||||||
postID := c.Param("id")
|
postID := c.Param("id")
|
||||||
userIDStr, _ := c.Get("user_id")
|
userIDStr, _ := c.Get("user_id")
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,10 @@ func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlu
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
AND NOT public.has_block_between(p.author_id, CASE WHEN $4::text != '' THEN $4::text::uuid ELSE NULL END)
|
AND NOT public.has_block_between(p.author_id, CASE WHEN $4::text != '' THEN $4::text::uuid ELSE NULL END)
|
||||||
|
AND ($4::text = '' OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM public.post_hides ph
|
||||||
|
WHERE ph.post_id = p.id AND ph.user_id = $4::text::uuid
|
||||||
|
))
|
||||||
AND ($3 = FALSE OR (COALESCE(p.video_url, '') <> '' OR (COALESCE(p.image_url, '') ILIKE '%.mp4')))
|
AND ($3 = FALSE OR (COALESCE(p.video_url, '') <> '' OR (COALESCE(p.image_url, '') ILIKE '%.mp4')))
|
||||||
AND ($5 = '' OR c.slug = $5)
|
AND ($5 = '' OR c.slug = $5)
|
||||||
AND (
|
AND (
|
||||||
|
|
@ -497,6 +501,18 @@ func (r *PostRepository) UnlikePost(ctx context.Context, postID string, userID s
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HidePost records a "Not Interested" signal.
|
||||||
|
// Denormalises author_id so feeds can suppress prolific-hide authors without a JOIN.
|
||||||
|
func (r *PostRepository) HidePost(ctx context.Context, postID, userID string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO public.post_hides (user_id, post_id, author_id)
|
||||||
|
SELECT $2::uuid, $1::uuid, author_id
|
||||||
|
FROM public.posts WHERE id = $1::uuid
|
||||||
|
ON CONFLICT (user_id, post_id) DO NOTHING
|
||||||
|
`, postID, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *PostRepository) SavePost(ctx context.Context, postID string, userID string) error {
|
func (r *PostRepository) SavePost(ctx context.Context, postID string, userID string) error {
|
||||||
query := `
|
query := `
|
||||||
WITH inserted AS (
|
WITH inserted AS (
|
||||||
|
|
|
||||||
|
|
@ -465,6 +465,11 @@ func (s *FeedAlgorithmService) GetAlgorithmicFeed(ctx context.Context, viewerID
|
||||||
LEFT JOIN user_feed_impressions ufi
|
LEFT JOIN user_feed_impressions ufi
|
||||||
ON ufi.post_id = pfs.post_id AND ufi.user_id = $1
|
ON ufi.post_id = pfs.post_id AND ufi.user_id = $1
|
||||||
WHERE p.status = 'active'
|
WHERE p.status = 'active'
|
||||||
|
AND pfs.post_id NOT IN (SELECT post_id FROM public.post_hides WHERE user_id = $1::uuid)
|
||||||
|
AND p.user_id NOT IN (
|
||||||
|
SELECT author_id FROM public.post_hides
|
||||||
|
WHERE user_id = $1::uuid GROUP BY author_id HAVING COUNT(*) >= 2
|
||||||
|
)
|
||||||
`
|
`
|
||||||
personalArgs := []interface{}{viewerID}
|
personalArgs := []interface{}{viewerID}
|
||||||
argIdx := 2
|
argIdx := 2
|
||||||
|
|
@ -567,6 +572,11 @@ func (s *FeedAlgorithmService) GetAlgorithmicFeed(ctx context.Context, viewerID
|
||||||
JOIN post_feed_scores pfs ON pfs.post_id = p.id
|
JOIN post_feed_scores pfs ON pfs.post_id = p.id
|
||||||
WHERE p.status = 'active'
|
WHERE p.status = 'active'
|
||||||
AND p.category NOT IN (%s)
|
AND p.category NOT IN (%s)
|
||||||
|
AND p.id NOT IN (SELECT post_id FROM public.post_hides WHERE user_id = $1)
|
||||||
|
AND p.user_id NOT IN (
|
||||||
|
SELECT author_id FROM public.post_hides
|
||||||
|
WHERE user_id = $1 GROUP BY author_id HAVING COUNT(*) >= 2
|
||||||
|
)
|
||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
`, placeholders)
|
`, placeholders)
|
||||||
|
|
|
||||||
134
sojorn_app/lib/providers/reactions_provider.dart
Normal file
134
sojorn_app/lib/providers/reactions_provider.dart
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
const _cdnBase = 'https://reactions.sojorn.net';
|
||||||
|
|
||||||
|
/// Parsed reaction package ready for use by [ReactionPicker].
|
||||||
|
class ReactionPackage {
|
||||||
|
final List<String> tabOrder;
|
||||||
|
final Map<String, List<String>> reactionSets; // tabId → list of identifiers (URL or emoji)
|
||||||
|
final Map<String, String> folderCredits; // tabId → credit markdown
|
||||||
|
|
||||||
|
const ReactionPackage({
|
||||||
|
required this.tabOrder,
|
||||||
|
required this.reactionSets,
|
||||||
|
required this.folderCredits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Riverpod provider that loads reaction sets once per app session.
|
||||||
|
/// Priority: CDN index.json → local assets → hardcoded emoji.
|
||||||
|
final reactionPackageProvider = FutureProvider<ReactionPackage>((ref) async {
|
||||||
|
// 1. Try CDN
|
||||||
|
try {
|
||||||
|
final response = await http
|
||||||
|
.get(Uri.parse('$_cdnBase/index.json'))
|
||||||
|
.timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final tabsRaw =
|
||||||
|
(data['tabs'] as List? ?? []).whereType<Map<String, dynamic>>();
|
||||||
|
|
||||||
|
final tabOrder = <String>['emoji'];
|
||||||
|
final reactionSets = <String, List<String>>{'emoji': _defaultEmoji};
|
||||||
|
final folderCredits = <String, String>{};
|
||||||
|
|
||||||
|
for (final tab in tabsRaw) {
|
||||||
|
final id = tab['id'] as String? ?? '';
|
||||||
|
if (id.isEmpty || id == 'emoji') continue;
|
||||||
|
|
||||||
|
final credit = tab['credit'] as String?;
|
||||||
|
final files =
|
||||||
|
(tab['reactions'] as List? ?? []).whereType<String>().toList();
|
||||||
|
final urls = files.map((f) => '$_cdnBase/$id/$f').toList();
|
||||||
|
|
||||||
|
tabOrder.add(id);
|
||||||
|
reactionSets[id] = urls;
|
||||||
|
if (credit != null && credit.isNotEmpty) {
|
||||||
|
folderCredits[id] = credit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only return CDN result if we got actual image tabs (not just emoji)
|
||||||
|
if (tabOrder.length > 1) {
|
||||||
|
return ReactionPackage(
|
||||||
|
tabOrder: tabOrder,
|
||||||
|
reactionSets: reactionSets,
|
||||||
|
folderCredits: folderCredits,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// 2. Fallback: local assets
|
||||||
|
try {
|
||||||
|
final manifest = await AssetManifest.loadFromAssetBundle(rootBundle);
|
||||||
|
final assetPaths = manifest.listAssets();
|
||||||
|
final reactionAssets = assetPaths.where((path) {
|
||||||
|
final lp = path.toLowerCase();
|
||||||
|
return lp.startsWith('assets/reactions/') &&
|
||||||
|
(lp.endsWith('.png') ||
|
||||||
|
lp.endsWith('.svg') ||
|
||||||
|
lp.endsWith('.webp') ||
|
||||||
|
lp.endsWith('.jpg') ||
|
||||||
|
lp.endsWith('.jpeg') ||
|
||||||
|
lp.endsWith('.gif'));
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
if (reactionAssets.isNotEmpty) {
|
||||||
|
final tabOrder = <String>['emoji'];
|
||||||
|
final reactionSets = <String, List<String>>{'emoji': _defaultEmoji};
|
||||||
|
final folderCredits = <String, String>{};
|
||||||
|
|
||||||
|
for (final path in reactionAssets) {
|
||||||
|
final parts = path.split('/');
|
||||||
|
if (parts.length >= 4) {
|
||||||
|
final folder = parts[2];
|
||||||
|
if (!reactionSets.containsKey(folder)) {
|
||||||
|
tabOrder.add(folder);
|
||||||
|
reactionSets[folder] = [];
|
||||||
|
try {
|
||||||
|
final creditPath = 'assets/reactions/$folder/credit.md';
|
||||||
|
if (assetPaths.contains(creditPath)) {
|
||||||
|
folderCredits[folder] =
|
||||||
|
await rootBundle.loadString(creditPath);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
reactionSets[folder]!.add(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final key in reactionSets.keys) {
|
||||||
|
if (key != 'emoji') {
|
||||||
|
reactionSets[key]!
|
||||||
|
.sort((a, b) => a.split('/').last.compareTo(b.split('/').last));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReactionPackage(
|
||||||
|
tabOrder: tabOrder,
|
||||||
|
reactionSets: reactionSets,
|
||||||
|
folderCredits: folderCredits,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// 3. Hardcoded emoji fallback
|
||||||
|
return ReactionPackage(
|
||||||
|
tabOrder: ['emoji'],
|
||||||
|
reactionSets: {'emoji': _defaultEmoji},
|
||||||
|
folderCredits: {},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const _defaultEmoji = [
|
||||||
|
'❤️', '👍', '😂', '😮', '😢', '😡',
|
||||||
|
'🎉', '🔥', '👏', '🙏', '💯', '🤔',
|
||||||
|
'😍', '🤣', '😊', '👌', '🙌', '💪',
|
||||||
|
'🎯', '⭐', '✨', '🌟', '💫', '☀️',
|
||||||
|
];
|
||||||
|
|
@ -1,69 +1,172 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
import '../../../models/quip_text_overlay.dart';
|
import '../../../models/quip_text_overlay.dart';
|
||||||
import '../../../widgets/media/signed_media_image.dart';
|
import '../../../widgets/media/signed_media_image.dart';
|
||||||
import '../../../widgets/video_player_with_comments.dart';
|
|
||||||
import '../../../models/post.dart';
|
|
||||||
import '../../../models/profile.dart';
|
|
||||||
import '../../../theme/tokens.dart';
|
import '../../../theme/tokens.dart';
|
||||||
|
|
||||||
import 'quips_feed_screen.dart';
|
import 'quips_feed_screen.dart';
|
||||||
|
|
||||||
class QuipVideoItem extends StatelessWidget {
|
class QuipVideoItem extends StatefulWidget {
|
||||||
final Quip quip;
|
final Quip quip;
|
||||||
final VideoPlayerController? controller;
|
final VideoPlayerController? controller;
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
final bool isLiked;
|
final Map<String, int> reactions;
|
||||||
final int likeCount;
|
final Set<String> myReactions;
|
||||||
|
final int commentCount;
|
||||||
final bool isUserPaused;
|
final bool isUserPaused;
|
||||||
final VoidCallback onLike;
|
final Function(String emoji) onReact;
|
||||||
|
final VoidCallback onOpenReactionPicker;
|
||||||
final VoidCallback onComment;
|
final VoidCallback onComment;
|
||||||
final VoidCallback onShare;
|
final VoidCallback onShare;
|
||||||
final VoidCallback onTogglePause;
|
final VoidCallback onTogglePause;
|
||||||
|
final VoidCallback onNotInterested;
|
||||||
|
|
||||||
const QuipVideoItem({
|
const QuipVideoItem({
|
||||||
super.key,
|
super.key,
|
||||||
required this.quip,
|
required this.quip,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.isActive,
|
required this.isActive,
|
||||||
required this.isLiked,
|
this.reactions = const {},
|
||||||
required this.likeCount,
|
this.myReactions = const {},
|
||||||
|
this.commentCount = 0,
|
||||||
required this.isUserPaused,
|
required this.isUserPaused,
|
||||||
required this.onLike,
|
required this.onReact,
|
||||||
|
required this.onOpenReactionPicker,
|
||||||
required this.onComment,
|
required this.onComment,
|
||||||
required this.onShare,
|
required this.onShare,
|
||||||
required this.onTogglePause,
|
required this.onTogglePause,
|
||||||
|
required this.onNotInterested,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Convert Quip to Post for use with VideoPlayerWithComments
|
@override
|
||||||
Post _toPost() {
|
State<QuipVideoItem> createState() => _QuipVideoItemState();
|
||||||
return Post(
|
}
|
||||||
id: quip.id,
|
|
||||||
authorId: quip.username, // This would need to be the actual user ID
|
class _QuipVideoItemState extends State<QuipVideoItem>
|
||||||
body: quip.caption,
|
with SingleTickerProviderStateMixin {
|
||||||
status: PostStatus.active,
|
late final AnimationController _heartController;
|
||||||
detectedTone: ToneLabel.neutral,
|
late final Animation<double> _heartScale;
|
||||||
contentIntegrityScore: 0.8,
|
late final Animation<double> _heartOpacity;
|
||||||
createdAt: DateTime.now(), // Would need actual timestamp
|
Offset _heartPosition = Offset.zero;
|
||||||
videoUrl: quip.videoUrl,
|
bool _showHeart = false;
|
||||||
thumbnailUrl: quip.thumbnailUrl,
|
bool _isCaptionExpanded = false;
|
||||||
likeCount: likeCount,
|
|
||||||
commentCount: 0, // Would need to be fetched separately
|
// Cached overlay data — parsed once, not on every build
|
||||||
author: Profile(
|
late String _audioLabel;
|
||||||
id: quip.username,
|
late List<QuipOverlayItem> _overlayItems;
|
||||||
handle: quip.username,
|
|
||||||
displayName: quip.displayName ?? '',
|
static const _quickReactEmoji = '❤️';
|
||||||
createdAt: DateTime.now(),
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_cacheOverlayData();
|
||||||
|
_heartController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 800),
|
||||||
|
);
|
||||||
|
_heartScale = TweenSequence([
|
||||||
|
TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.4), weight: 35),
|
||||||
|
TweenSequenceItem(tween: Tween(begin: 1.4, end: 1.0), weight: 25),
|
||||||
|
TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.0), weight: 20),
|
||||||
|
TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.0), weight: 20),
|
||||||
|
]).animate(_heartController);
|
||||||
|
_heartOpacity = TweenSequence([
|
||||||
|
TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 10),
|
||||||
|
TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.0), weight: 60),
|
||||||
|
TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.0), weight: 30),
|
||||||
|
]).animate(_heartController);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(QuipVideoItem oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.quip.overlayJson != widget.quip.overlayJson) {
|
||||||
|
_cacheOverlayData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cacheOverlayData() {
|
||||||
|
_audioLabel = _computeAudioLabel();
|
||||||
|
_overlayItems = _parseOverlayItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _computeAudioLabel() {
|
||||||
|
final json = widget.quip.overlayJson;
|
||||||
|
if (json != null && json.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(json) as Map<String, dynamic>;
|
||||||
|
final soundId = decoded['sound_id'];
|
||||||
|
if (soundId is String && soundId.isNotEmpty) {
|
||||||
|
return soundId.split('/').last.split('.').first;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return 'Original Sound';
|
||||||
|
}
|
||||||
|
|
||||||
|
List<QuipOverlayItem> _parseOverlayItems() {
|
||||||
|
final json = widget.quip.overlayJson;
|
||||||
|
if (json == null || json.isEmpty) return [];
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(json) as Map<String, dynamic>;
|
||||||
|
return (decoded['overlays'] as List<dynamic>? ?? [])
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(QuipOverlayItem.fromJson)
|
||||||
|
.toList();
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_heartController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDoubleTap(TapDownDetails details) {
|
||||||
|
// Double-tap quick-reacts with ❤️ (only if not already reacted)
|
||||||
|
if (!widget.myReactions.contains(_quickReactEmoji)) {
|
||||||
|
widget.onReact(_quickReactEmoji);
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_heartPosition = details.localPosition;
|
||||||
|
_showHeart = true;
|
||||||
|
});
|
||||||
|
_heartController.forward(from: 0).then((_) {
|
||||||
|
if (mounted) setState(() => _showHeart = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToProfile() {
|
||||||
|
context.push('/u/${widget.quip.username}');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showMoreSheet() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => _MoreOptionsSheet(
|
||||||
|
quipId: widget.quip.id,
|
||||||
|
onNotInterested: widget.onNotInterested,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Audio label is cached in _audioLabel — do not call jsonDecode here
|
||||||
|
|
||||||
Widget _buildVideo() {
|
Widget _buildVideo() {
|
||||||
final initialized = controller?.value.isInitialized ?? false;
|
final ctrl = widget.controller;
|
||||||
|
final initialized = ctrl?.value.isInitialized ?? false;
|
||||||
|
|
||||||
if (initialized) {
|
if (initialized) {
|
||||||
final size = controller!.value.size;
|
final size = ctrl!.value.size;
|
||||||
return Container(
|
return Container(
|
||||||
color: SojornColors.basicBlack,
|
color: SojornColors.basicBlack,
|
||||||
child: Center(
|
child: Center(
|
||||||
|
|
@ -72,124 +175,344 @@ class QuipVideoItem extends StatelessWidget {
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: size.width,
|
width: size.width,
|
||||||
height: size.height,
|
height: size.height,
|
||||||
child: VideoPlayer(controller!),
|
child: VideoPlayer(ctrl),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quip.thumbnailUrl.isNotEmpty) {
|
if (widget.quip.thumbnailUrl.isNotEmpty) {
|
||||||
return SignedMediaImage(
|
return SignedMediaImage(
|
||||||
url: quip.thumbnailUrl,
|
url: widget.quip.thumbnailUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => Container(color: SojornColors.basicBlack),
|
errorBuilder: (_, __, ___) => Container(color: SojornColors.basicBlack),
|
||||||
loadingBuilder: (context) {
|
loadingBuilder: (_) => Container(color: SojornColors.basicBlack),
|
||||||
return Container(
|
|
||||||
color: SojornColors.basicBlack,
|
|
||||||
child: const Center(
|
|
||||||
child: CircularProgressIndicator(color: SojornColors.basicWhite),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Container(color: SojornColors.basicBlack);
|
return Container(color: SojornColors.basicBlack);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildActions() {
|
Widget _buildProgressBar() {
|
||||||
final actions = [
|
final ctrl = widget.controller;
|
||||||
_QuipAction(
|
if (ctrl == null || !ctrl.value.isInitialized) return const SizedBox.shrink();
|
||||||
icon: isLiked ? Icons.favorite : Icons.favorite_border,
|
return Positioned(
|
||||||
label: likeCount > 0 ? likeCount.toString() : '',
|
left: 0,
|
||||||
onTap: onLike,
|
right: 0,
|
||||||
color: isLiked ? SojornColors.destructive : SojornColors.basicWhite,
|
bottom: 0,
|
||||||
|
child: VideoProgressIndicator(
|
||||||
|
ctrl,
|
||||||
|
allowScrubbing: false,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
colors: const VideoProgressColors(
|
||||||
|
playedColor: Color(0xCCFFFFFF),
|
||||||
|
bufferedColor: Color(0x44FFFFFF),
|
||||||
|
backgroundColor: Color(0x22FFFFFF),
|
||||||
),
|
),
|
||||||
_QuipAction(
|
|
||||||
icon: Icons.chat_bubble_outline,
|
|
||||||
onTap: onComment,
|
|
||||||
),
|
),
|
||||||
_QuipAction(
|
);
|
||||||
icon: Icons.send_outlined,
|
}
|
||||||
onTap: onShare,
|
|
||||||
|
Widget _buildAvatar() {
|
||||||
|
final avatarUrl = widget.quip.avatarUrl;
|
||||||
|
final letter = widget.quip.username.isNotEmpty
|
||||||
|
? widget.quip.username[0].toUpperCase()
|
||||||
|
: '?';
|
||||||
|
|
||||||
|
Widget inner;
|
||||||
|
if (avatarUrl != null && avatarUrl.isNotEmpty) {
|
||||||
|
inner = SignedMediaImage(
|
||||||
|
url: avatarUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => _fallbackAvatarInner(letter),
|
||||||
|
loadingBuilder: (_) => _fallbackAvatarInner(letter),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
inner = _fallbackAvatarInner(letter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _navigateToProfile,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: SojornColors.basicWhite, width: 2),
|
||||||
),
|
),
|
||||||
_QuipAction(
|
child: ClipOval(child: inner),
|
||||||
icon: Icons.more_horiz,
|
|
||||||
onTap: () {},
|
|
||||||
),
|
),
|
||||||
];
|
// "+" badge
|
||||||
|
Positioned(
|
||||||
|
bottom: -4,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFF2979FF),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.add, color: Colors.white, size: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _fallbackAvatarInner(String letter) {
|
||||||
|
return Container(
|
||||||
|
color: const Color(0xFF2A2A2A),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
letter,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: SojornColors.basicWhite,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSideActions() {
|
||||||
|
final commentLabel =
|
||||||
|
widget.commentCount > 0 ? _formatCount(widget.commentCount) : null;
|
||||||
|
final totalReactions =
|
||||||
|
widget.reactions.values.fold(0, (sum, c) => sum + c);
|
||||||
|
final reactionLabel =
|
||||||
|
totalReactions > 0 ? _formatCount(totalReactions) : null;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: actions
|
children: [
|
||||||
.map((action) => Padding(
|
_buildAvatar(),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
const SizedBox(height: 24),
|
||||||
child: action,
|
// Reaction button — tap to quick-react ❤️, long-press to open full picker
|
||||||
))
|
_buildActionBtn(
|
||||||
.toList(),
|
child: _buildReactionIcon(),
|
||||||
|
onTap: () => widget.onReact(_quickReactEmoji),
|
||||||
|
onLongPress: widget.onOpenReactionPicker,
|
||||||
|
label: reactionLabel,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// Comment
|
||||||
|
_buildActionBtn(
|
||||||
|
child: const Icon(Icons.chat_bubble_outline,
|
||||||
|
color: SojornColors.basicWhite, size: 28),
|
||||||
|
onTap: widget.onComment,
|
||||||
|
label: commentLabel,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// Share
|
||||||
|
_buildActionBtn(
|
||||||
|
child: const Icon(Icons.send_outlined,
|
||||||
|
color: SojornColors.basicWhite, size: 28),
|
||||||
|
onTap: widget.onShare,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// More (three dots)
|
||||||
|
_buildActionBtn(
|
||||||
|
child: const Icon(Icons.more_horiz,
|
||||||
|
color: SojornColors.basicWhite, size: 28),
|
||||||
|
onTap: _showMoreSheet,
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildOverlay() {
|
/// Shows the user's own top reaction, the post's top reaction, or a generic
|
||||||
|
/// add-reaction icon — in the right sidebar.
|
||||||
|
Widget _buildReactionIcon() {
|
||||||
|
String? reactionId;
|
||||||
|
if (widget.myReactions.isNotEmpty) {
|
||||||
|
reactionId = widget.myReactions.first;
|
||||||
|
} else if (widget.reactions.isNotEmpty) {
|
||||||
|
reactionId = widget.reactions.entries
|
||||||
|
.reduce((a, b) => a.value > b.value ? a : b)
|
||||||
|
.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reactionId == null) {
|
||||||
|
return const Icon(Icons.add_reaction_outlined,
|
||||||
|
color: SojornColors.basicWhite, size: 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emoji
|
||||||
|
if (!reactionId.startsWith('https://') &&
|
||||||
|
!reactionId.startsWith('assets/') &&
|
||||||
|
!reactionId.startsWith('asset:')) {
|
||||||
|
return Text(reactionId, style: const TextStyle(fontSize: 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
// CDN URL
|
||||||
|
if (reactionId.startsWith('https://')) {
|
||||||
|
return CachedNetworkImage(
|
||||||
|
imageUrl: reactionId,
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
placeholder: (_, __) => const Icon(Icons.add_reaction_outlined,
|
||||||
|
color: SojornColors.basicWhite, size: 30),
|
||||||
|
errorWidget: (_, __, ___) => const Icon(Icons.add_reaction_outlined,
|
||||||
|
color: SojornColors.basicWhite, size: 30),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local asset
|
||||||
|
final assetPath = reactionId.startsWith('asset:')
|
||||||
|
? reactionId.replaceFirst('asset:', '')
|
||||||
|
: reactionId;
|
||||||
|
if (assetPath.endsWith('.svg')) {
|
||||||
|
return SvgPicture.asset(assetPath,
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
colorFilter: const ColorFilter.mode(
|
||||||
|
SojornColors.basicWhite, BlendMode.srcIn));
|
||||||
|
}
|
||||||
|
return Image.asset(assetPath, width: 30, height: 30, fit: BoxFit.contain);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActionBtn({
|
||||||
|
required Widget child,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
VoidCallback? onLongPress,
|
||||||
|
String? label,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Padding(padding: const EdgeInsets.all(4), child: child),
|
||||||
|
),
|
||||||
|
if (label != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: SojornColors.basicWhite,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
shadows: [
|
||||||
|
Shadow(
|
||||||
|
color: Color(0x8A000000),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: Offset(0, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUserInfo() {
|
||||||
|
final audioLabel =
|
||||||
|
'\u266b $_audioLabel \u2022 @${widget.quip.username}';
|
||||||
|
final hasCaption = widget.quip.caption.isNotEmpty;
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: 16,
|
left: 16,
|
||||||
right: 16,
|
right: 80,
|
||||||
bottom: 28,
|
bottom: 28,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
// Username
|
||||||
'@${quip.username}',
|
GestureDetector(
|
||||||
|
onTap: _navigateToProfile,
|
||||||
|
child: Text(
|
||||||
|
'@${widget.quip.username}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: SojornColors.basicWhite,
|
color: SojornColors.basicWhite,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
shadows: [
|
shadows: [
|
||||||
Shadow(
|
Shadow(
|
||||||
color: const Color(0x8A000000),
|
color: Color(0x8A000000),
|
||||||
offset: Offset(0, 1),
|
offset: Offset(0, 1),
|
||||||
blurRadius: 6,
|
blurRadius: 6,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
),
|
||||||
Text(
|
// Caption with "...more" expand
|
||||||
quip.caption,
|
if (hasCaption) ...[
|
||||||
maxLines: 2,
|
const SizedBox(height: 4),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => setState(
|
||||||
|
() => _isCaptionExpanded = !_isCaptionExpanded),
|
||||||
|
child: RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
style: const TextStyle(
|
||||||
|
color: SojornColors.basicWhite,
|
||||||
|
fontSize: 13,
|
||||||
|
shadows: [
|
||||||
|
Shadow(
|
||||||
|
color: Color(0x8A000000),
|
||||||
|
offset: Offset(0, 1),
|
||||||
|
blurRadius: 6,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TextSpan(text: widget.quip.caption),
|
||||||
|
if (!_isCaptionExpanded &&
|
||||||
|
widget.quip.caption.length > 60)
|
||||||
|
const TextSpan(
|
||||||
|
text: ' ...more',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xCCFFFFFF),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
maxLines: _isCaptionExpanded ? null : 2,
|
||||||
|
overflow: _isCaptionExpanded
|
||||||
|
? TextOverflow.visible
|
||||||
|
: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
// Audio ticker row
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
audioLabel,
|
||||||
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: SojornColors.basicWhite,
|
color: SojornColors.basicWhite,
|
||||||
fontSize: 14,
|
fontSize: 12,
|
||||||
shadows: [
|
|
||||||
Shadow(
|
|
||||||
color: const Color(0x8A000000),
|
|
||||||
offset: Offset(0, 1),
|
|
||||||
blurRadius: 6,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Row(
|
|
||||||
children: const [
|
|
||||||
Icon(Icons.music_note, color: SojornColors.basicWhite, size: 18),
|
|
||||||
SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
'Original Audio',
|
|
||||||
style: TextStyle(
|
|
||||||
color: SojornColors.basicWhite,
|
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
shadows: [
|
shadows: [
|
||||||
Shadow(
|
Shadow(
|
||||||
color: const Color(0x73000000),
|
color: Color(0x73000000),
|
||||||
offset: Offset(0, 1),
|
offset: Offset(0, 1),
|
||||||
blurRadius: 6,
|
blurRadius: 4,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -197,30 +520,57 @@ class QuipVideoItem extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses overlay_json and returns a list of non-interactive overlay widgets
|
Widget _buildPauseOverlay() {
|
||||||
/// rendered on top of the video during feed playback.
|
if (!widget.isActive || !widget.isUserPaused) return const SizedBox.shrink();
|
||||||
List<Widget> _buildOverlayWidgets(BoxConstraints constraints) {
|
return Center(
|
||||||
final json = quip.overlayJson;
|
child: Container(
|
||||||
if (json == null || json.isEmpty) return [];
|
padding: const EdgeInsets.all(16),
|
||||||
try {
|
decoration: const BoxDecoration(
|
||||||
final decoded = jsonDecode(json) as Map<String, dynamic>;
|
color: Color(0x8A000000),
|
||||||
final items = (decoded['overlays'] as List<dynamic>? ?? [])
|
shape: BoxShape.circle,
|
||||||
.whereType<Map<String, dynamic>>()
|
),
|
||||||
.map(QuipOverlayItem.fromJson)
|
child: const Icon(
|
||||||
.toList();
|
Icons.play_arrow,
|
||||||
|
color: SojornColors.basicWhite,
|
||||||
|
size: 52,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeartBurst() {
|
||||||
|
if (!_showHeart) return const SizedBox.shrink();
|
||||||
|
return Positioned(
|
||||||
|
left: _heartPosition.dx - 50,
|
||||||
|
top: _heartPosition.dy - 50,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _heartController,
|
||||||
|
builder: (_, __) => Opacity(
|
||||||
|
opacity: _heartOpacity.value,
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: _heartScale.value,
|
||||||
|
child: const Icon(Icons.favorite, color: Colors.white, size: 100),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildOverlayWidgets(BoxConstraints constraints) {
|
||||||
|
if (_overlayItems.isEmpty) return [];
|
||||||
final w = constraints.maxWidth;
|
final w = constraints.maxWidth;
|
||||||
final h = constraints.maxHeight;
|
final h = constraints.maxHeight;
|
||||||
|
|
||||||
return items.map((item) {
|
return _overlayItems.map((item) {
|
||||||
final absX = item.position.dx * w;
|
final absX = item.position.dx * w;
|
||||||
final absY = item.position.dy * h;
|
final absY = item.position.dy * h;
|
||||||
final isSticker = item.type == QuipOverlayType.sticker;
|
final isSticker = item.type == QuipOverlayType.sticker;
|
||||||
|
|
||||||
Widget child;
|
Widget child;
|
||||||
if (isSticker) {
|
if (isSticker) {
|
||||||
final isEmoji = item.content.runes.length == 1 ||
|
final isEmoji = item.content.runes.length == 1 || item.content.length <= 2;
|
||||||
item.content.length <= 2;
|
|
||||||
if (isEmoji) {
|
if (isEmoji) {
|
||||||
child = Text(item.content,
|
child = Text(item.content,
|
||||||
style: TextStyle(fontSize: 42 * item.scale));
|
style: TextStyle(fontSize: 42 * item.scale));
|
||||||
|
|
@ -267,120 +617,63 @@ class QuipVideoItem extends StatelessWidget {
|
||||||
child: Transform.rotate(angle: item.rotation, child: child),
|
child: Transform.rotate(angle: item.rotation, child: child),
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
} catch (_) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPauseOverlay() {
|
String _formatCount(int n) {
|
||||||
if (!isActive || !isUserPaused) return const SizedBox.shrink();
|
if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M';
|
||||||
|
if (n >= 1000) return '${(n / 1000).toStringAsFixed(1)}K';
|
||||||
return Center(
|
return '$n';
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: SojornColors.overlayDark,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.play_arrow,
|
|
||||||
color: SojornColors.basicWhite,
|
|
||||||
size: 48,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTogglePause,
|
onTap: widget.onTogglePause,
|
||||||
|
onDoubleTapDown: _handleDoubleTap,
|
||||||
|
onDoubleTap: () {}, // consume event so single-tap doesn't fire on double-tap
|
||||||
child: Container(
|
child: Container(
|
||||||
color: SojornColors.basicBlack,
|
color: SojornColors.basicBlack,
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) => Stack(
|
builder: (context, constraints) => Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
AnimatedOpacity(
|
_buildVideo(),
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
opacity: isActive ? 1 : 0.6,
|
|
||||||
child: _buildVideo(),
|
|
||||||
),
|
|
||||||
// Quip overlays (text + stickers, non-interactive in feed)
|
// Quip overlays (text + stickers, non-interactive in feed)
|
||||||
..._buildOverlayWidgets(constraints),
|
..._buildOverlayWidgets(constraints),
|
||||||
Container(
|
// Gradient scrim: strong at bottom for text legibility
|
||||||
decoration: const BoxDecoration(
|
const DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.bottomCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.topCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: [
|
||||||
const Color(0x8A000000),
|
Color(0x55000000), // subtle top vignette
|
||||||
SojornColors.transparent,
|
Colors.transparent,
|
||||||
const Color(0x73000000),
|
Colors.transparent,
|
||||||
|
Color(0xB0000000), // strong bottom scrim
|
||||||
],
|
],
|
||||||
stops: [0, 0.4, 1],
|
stops: [0, 0.15, 0.55, 1],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_buildOverlay(),
|
// User info — bottom-left
|
||||||
|
_buildUserInfo(),
|
||||||
|
// Side actions — right
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 16,
|
right: 12,
|
||||||
bottom: 80,
|
bottom: 90,
|
||||||
child: _buildActions(),
|
child: _buildSideActions(),
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
top: 36,
|
|
||||||
left: 16,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0x73000000),
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: const Color(0x66000000),
|
|
||||||
blurRadius: 6,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'Quips',
|
|
||||||
style: TextStyle(
|
|
||||||
color: SojornColors.basicWhite,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (quip.durationMs != null)
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.only(left: 8),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10,
|
|
||||||
vertical: 6,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0x73000000),
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'${(quip.durationMs! / 1000).toStringAsFixed(1)}s',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: SojornColors.basicWhite,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
// Pause overlay
|
||||||
_buildPauseOverlay(),
|
_buildPauseOverlay(),
|
||||||
if (!(controller?.value.isInitialized ?? false))
|
// Double-tap heart burst
|
||||||
Center(
|
_buildHeartBurst(),
|
||||||
child: const CircularProgressIndicator(color: SojornColors.basicWhite),
|
// Thin video progress bar at very bottom
|
||||||
|
_buildProgressBar(),
|
||||||
|
// Buffering spinner
|
||||||
|
if (!(widget.controller?.value.isInitialized ?? false))
|
||||||
|
const Center(
|
||||||
|
child: CircularProgressIndicator(color: SojornColors.basicWhite),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -388,73 +681,95 @@ class QuipVideoItem extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the enhanced video player with comments (for fullscreen view)
|
|
||||||
Widget buildEnhancedVideoPlayer(BuildContext context) {
|
|
||||||
return VideoPlayerWithComments(
|
|
||||||
post: _toPost(),
|
|
||||||
onLike: onLike,
|
|
||||||
onShare: onShare,
|
|
||||||
onCommentTap: () {
|
|
||||||
// Comments are handled within the VideoPlayerWithComments widget
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _QuipAction extends StatelessWidget {
|
/// Bottom sheet shown when the user taps the "..." (more) button on a quip.
|
||||||
final IconData icon;
|
class _MoreOptionsSheet extends StatelessWidget {
|
||||||
final String? label;
|
final String quipId;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onNotInterested;
|
||||||
final Color color;
|
|
||||||
|
|
||||||
const _QuipAction({
|
const _MoreOptionsSheet({
|
||||||
required this.icon,
|
required this.quipId,
|
||||||
required this.onTap,
|
required this.onNotInterested,
|
||||||
this.label,
|
|
||||||
this.color = SojornColors.basicWhite,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFF1A1A1A),
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.fromLTRB(0, 8, 0, 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
// Drag handle
|
||||||
Container(
|
Container(
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0x8A000000),
|
color: Colors.white.withValues(alpha: 0.2),
|
||||||
shape: BoxShape.circle,
|
borderRadius: BorderRadius.circular(2),
|
||||||
boxShadow: [
|
),
|
||||||
BoxShadow(
|
),
|
||||||
color: const Color(0x66000000),
|
_buildOption(
|
||||||
blurRadius: 8,
|
context,
|
||||||
offset: const Offset(0, 2),
|
icon: Icons.thumb_down_outlined,
|
||||||
|
label: 'Not Interested',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
onNotInterested();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_buildOption(
|
||||||
|
context,
|
||||||
|
icon: Icons.flag_outlined,
|
||||||
|
label: 'Report',
|
||||||
|
color: const Color(0xFFFF5252),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
// TODO: Wire to SanctuarySheet.showForPostId(context, quipId)
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Report submitted. Thank you.'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: IconButton(
|
);
|
||||||
onPressed: onTap,
|
}
|
||||||
icon: Icon(icon, color: color),
|
|
||||||
),
|
Widget _buildOption(
|
||||||
),
|
BuildContext context, {
|
||||||
if (label != null && label!.isNotEmpty) ...[
|
required IconData icon,
|
||||||
const SizedBox(height: 4),
|
required String label,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
Color color = Colors.white,
|
||||||
|
}) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 24),
|
||||||
|
const SizedBox(width: 16),
|
||||||
Text(
|
Text(
|
||||||
label!,
|
label,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: SojornColors.basicWhite,
|
color: color,
|
||||||
fontSize: 12,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w500,
|
||||||
shadows: [
|
),
|
||||||
Shadow(
|
|
||||||
color: const Color(0x8A000000),
|
|
||||||
blurRadius: 4,
|
|
||||||
offset: Offset(0, 1),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@ import '../../../providers/feed_refresh_provider.dart';
|
||||||
import '../../../routes/app_routes.dart';
|
import '../../../routes/app_routes.dart';
|
||||||
import '../../../theme/app_theme.dart';
|
import '../../../theme/app_theme.dart';
|
||||||
import '../../../theme/tokens.dart';
|
import '../../../theme/tokens.dart';
|
||||||
import '../../post/post_detail_screen.dart';
|
|
||||||
import 'quip_video_item.dart';
|
import 'quip_video_item.dart';
|
||||||
import '../../home/home_shell.dart';
|
import '../../home/home_shell.dart';
|
||||||
|
import '../../../widgets/reactions/reaction_picker.dart';
|
||||||
import '../../../widgets/video_comments_sheet.dart';
|
import '../../../widgets/video_comments_sheet.dart';
|
||||||
|
|
||||||
class Quip {
|
class Quip {
|
||||||
|
|
@ -23,8 +23,10 @@ class Quip {
|
||||||
final String? displayName;
|
final String? displayName;
|
||||||
final String? avatarUrl;
|
final String? avatarUrl;
|
||||||
final int? durationMs;
|
final int? durationMs;
|
||||||
final int? likeCount;
|
final int commentCount;
|
||||||
final String? overlayJson;
|
final String? overlayJson;
|
||||||
|
final Map<String, int> reactions;
|
||||||
|
final Set<String> myReactions;
|
||||||
|
|
||||||
const Quip({
|
const Quip({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -35,8 +37,10 @@ class Quip {
|
||||||
this.displayName,
|
this.displayName,
|
||||||
this.avatarUrl,
|
this.avatarUrl,
|
||||||
this.durationMs,
|
this.durationMs,
|
||||||
this.likeCount,
|
this.commentCount = 0,
|
||||||
this.overlayJson,
|
this.overlayJson,
|
||||||
|
this.reactions = const {},
|
||||||
|
this.myReactions = const {},
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Quip.fromMap(Map<String, dynamic> map) {
|
factory Quip.fromMap(Map<String, dynamic> map) {
|
||||||
|
|
@ -54,18 +58,29 @@ class Quip {
|
||||||
displayName: author?['display_name'] as String?,
|
displayName: author?['display_name'] as String?,
|
||||||
avatarUrl: author?['avatar_url'] as String?,
|
avatarUrl: author?['avatar_url'] as String?,
|
||||||
durationMs: map['duration_ms'] as int?,
|
durationMs: map['duration_ms'] as int?,
|
||||||
likeCount: _parseLikeCount(map['metrics']),
|
commentCount: _parseCount(map['comment_count']),
|
||||||
overlayJson: map['overlay_json'] as String?,
|
overlayJson: map['overlay_json'] as String?,
|
||||||
|
reactions: _parseReactions(map['reactions']),
|
||||||
|
myReactions: _parseMyReactions(map['my_reactions']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static int? _parseLikeCount(dynamic metrics) {
|
static Map<String, int> _parseReactions(dynamic v) {
|
||||||
if (metrics is Map<String, dynamic>) {
|
if (v is Map<String, dynamic>) {
|
||||||
final val = metrics['like_count'];
|
return v.map((k, val) => MapEntry(k, val is int ? val : (val is num ? val.toInt() : 0)));
|
||||||
if (val is int) return val;
|
|
||||||
if (val is num) return val.toInt();
|
|
||||||
}
|
}
|
||||||
return null;
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
static Set<String> _parseMyReactions(dynamic v) {
|
||||||
|
if (v is List) return v.whereType<String>().toSet();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _parseCount(dynamic v) {
|
||||||
|
if (v is int) return v;
|
||||||
|
if (v is num) return v.toInt();
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,8 +101,8 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
final List<Quip> _quips = [];
|
final List<Quip> _quips = [];
|
||||||
final Map<int, VideoPlayerController> _controllers = {};
|
final Map<int, VideoPlayerController> _controllers = {};
|
||||||
final Map<int, Future<void>> _controllerFutures = {};
|
final Map<int, Future<void>> _controllerFutures = {};
|
||||||
final Map<String, bool> _liked = {};
|
final Map<String, Map<String, int>> _reactionCounts = {};
|
||||||
final Map<String, int> _likeCounts = {};
|
final Map<String, Set<String>> _myReactions = {};
|
||||||
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _hasMore = true;
|
bool _hasMore = true;
|
||||||
|
|
@ -268,7 +283,8 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
|
// Ignore — initial post will just not appear at top
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -297,7 +313,10 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
_quips.addAll(items);
|
_quips.addAll(items);
|
||||||
_hasMore = items.length == _pageSize;
|
_hasMore = items.length == _pageSize;
|
||||||
for (final item in items) {
|
for (final item in items) {
|
||||||
_likeCounts.putIfAbsent(item.id, () => item.likeCount ?? 0);
|
_reactionCounts.putIfAbsent(
|
||||||
|
item.id, () => Map<String, int>.from(item.reactions));
|
||||||
|
_myReactions.putIfAbsent(
|
||||||
|
item.id, () => Set<String>.from(item.myReactions));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -409,41 +428,79 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
await _fetchQuips();
|
await _fetchQuips();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleLike(Quip quip) async {
|
Future<void> _toggleReaction(Quip quip, String emoji) async {
|
||||||
final api = ref.read(apiServiceProvider);
|
final api = ref.read(apiServiceProvider);
|
||||||
final currentlyLiked = _liked[quip.id] ?? false;
|
final currentCounts =
|
||||||
|
Map<String, int>.from(_reactionCounts[quip.id] ?? quip.reactions);
|
||||||
|
final currentMine =
|
||||||
|
Set<String>.from(_myReactions[quip.id] ?? quip.myReactions);
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
final isRemoving = currentMine.contains(emoji);
|
||||||
setState(() {
|
setState(() {
|
||||||
_liked[quip.id] = !currentlyLiked;
|
if (isRemoving) {
|
||||||
final currentCount = _likeCounts[quip.id] ?? 0;
|
currentMine.remove(emoji);
|
||||||
final next = currentlyLiked ? currentCount - 1 : currentCount + 1;
|
final newCount = (currentCounts[emoji] ?? 1) - 1;
|
||||||
_likeCounts[quip.id] = next < 0 ? 0 : next;
|
if (newCount <= 0) {
|
||||||
|
currentCounts.remove(emoji);
|
||||||
|
} else {
|
||||||
|
currentCounts[emoji] = newCount;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentMine.add(emoji);
|
||||||
|
currentCounts[emoji] = (currentCounts[emoji] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
_reactionCounts[quip.id] = currentCounts;
|
||||||
|
_myReactions[quip.id] = currentMine;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (currentlyLiked) {
|
await api.toggleReaction(quip.id, emoji);
|
||||||
await api.unappreciatePost(quip.id);
|
|
||||||
} else {
|
|
||||||
await api.appreciatePost(quip.id);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// revert on failure
|
// Revert on failure
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_liked[quip.id] = currentlyLiked;
|
_reactionCounts[quip.id] = Map<String, int>.from(quip.reactions);
|
||||||
_likeCounts[quip.id] =
|
_myReactions[quip.id] = Set<String>.from(quip.myReactions);
|
||||||
(_likeCounts[quip.id] ?? 0) + (currentlyLiked ? 1 : -1);
|
|
||||||
if ((_likeCounts[quip.id] ?? 0) < 0) {
|
|
||||||
_likeCounts[quip.id] = 0;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (mounted) {
|
}
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
}
|
||||||
const SnackBar(
|
|
||||||
content: Text('Could not update like. Please try again.'),
|
void _openReactionPicker(Quip quip) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => ReactionPicker(
|
||||||
|
onReactionSelected: (emoji) => _toggleReaction(quip, emoji),
|
||||||
|
reactionCounts: _reactionCounts[quip.id] ?? quip.reactions,
|
||||||
|
myReactions: _myReactions[quip.id] ?? quip.myReactions,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleNotInterested(Quip quip) async {
|
||||||
|
final index = _quips.indexOf(quip);
|
||||||
|
if (index == -1) return;
|
||||||
|
|
||||||
|
// Optimistic removal — user sees it gone immediately
|
||||||
|
setState(() {
|
||||||
|
_quips.removeAt(index);
|
||||||
|
final ctrl = _controllers.remove(index);
|
||||||
|
ctrl?.dispose();
|
||||||
|
// Remap controllers above the removed index
|
||||||
|
final remapped = <int, VideoPlayerController>{};
|
||||||
|
_controllers.forEach((k, v) {
|
||||||
|
remapped[k > index ? k - 1 : k] = v;
|
||||||
|
});
|
||||||
|
_controllers
|
||||||
|
..clear()
|
||||||
|
..addAll(remapped);
|
||||||
|
if (_currentIndex >= _quips.length && _currentIndex > 0) {
|
||||||
|
_currentIndex = _quips.length - 1;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire-and-forget to backend — no revert on failure (signal still valuable)
|
||||||
|
ref.read(apiServiceProvider).hidePost(quip.id).catchError((_) {});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openComments(Quip quip) async {
|
Future<void> _openComments(Quip quip) async {
|
||||||
|
|
@ -453,10 +510,9 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
backgroundColor: SojornColors.transparent,
|
backgroundColor: SojornColors.transparent,
|
||||||
builder: (context) => VideoCommentsSheet(
|
builder: (context) => VideoCommentsSheet(
|
||||||
postId: quip.id,
|
postId: quip.id,
|
||||||
initialCommentCount: 0,
|
initialCommentCount: quip.commentCount,
|
||||||
onCommentPosted: () {
|
showNavActions: false,
|
||||||
// Optional: handle reload if needed
|
onCommentPosted: () {},
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -528,8 +584,7 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
child: PageView.builder(
|
child: PageView.builder(
|
||||||
controller: _pageController,
|
controller: _pageController,
|
||||||
scrollDirection: Axis.vertical,
|
scrollDirection: Axis.vertical,
|
||||||
// Ensure physics allows scrolling to trigger refresh
|
physics: const PageScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
|
||||||
itemCount: _quips.length,
|
itemCount: _quips.length,
|
||||||
onPageChanged: (index) {
|
onPageChanged: (index) {
|
||||||
_currentIndex = index;
|
_currentIndex = index;
|
||||||
|
|
@ -542,8 +597,6 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final quip = _quips[index];
|
final quip = _quips[index];
|
||||||
final controller = _controllers[index];
|
final controller = _controllers[index];
|
||||||
final isLiked = _liked[quip.id] ?? false;
|
|
||||||
final likeCount = _likeCounts[quip.id] ?? quip.likeCount ?? 0;
|
|
||||||
return VisibilityDetector(
|
return VisibilityDetector(
|
||||||
key: ValueKey('quip-${quip.id}'),
|
key: ValueKey('quip-${quip.id}'),
|
||||||
onVisibilityChanged: (info) =>
|
onVisibilityChanged: (info) =>
|
||||||
|
|
@ -552,13 +605,16 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
quip: quip,
|
quip: quip,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
isActive: index == _currentIndex,
|
isActive: index == _currentIndex,
|
||||||
isLiked: isLiked,
|
reactions: _reactionCounts[quip.id] ?? quip.reactions,
|
||||||
likeCount: likeCount,
|
myReactions: _myReactions[quip.id] ?? quip.myReactions,
|
||||||
|
commentCount: quip.commentCount,
|
||||||
isUserPaused: _isUserPaused,
|
isUserPaused: _isUserPaused,
|
||||||
onLike: () => _toggleLike(quip),
|
onReact: (emoji) => _toggleReaction(quip, emoji),
|
||||||
|
onOpenReactionPicker: () => _openReactionPicker(quip),
|
||||||
onComment: () => _openComments(quip),
|
onComment: () => _openComments(quip),
|
||||||
onShare: () => _shareQuip(quip),
|
onShare: () => _shareQuip(quip),
|
||||||
onTogglePause: _toggleUserPause,
|
onTogglePause: _toggleUserPause,
|
||||||
|
onNotInterested: () => _handleNotInterested(quip),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1089,6 +1089,10 @@ class ApiService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> hidePost(String postId) async {
|
||||||
|
await _callGoApi('/posts/$postId/hide', method: 'POST');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> appreciatePost(String postId) async {
|
Future<void> appreciatePost(String postId) async {
|
||||||
await _callGoApi(
|
await _callGoApi(
|
||||||
'/posts/$postId/like',
|
'/posts/$postId/like',
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'dart:convert';
|
import '../../providers/reactions_provider.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../../theme/tokens.dart';
|
import '../../theme/tokens.dart';
|
||||||
|
|
||||||
class ReactionPicker extends StatefulWidget {
|
class ReactionPicker extends ConsumerStatefulWidget {
|
||||||
final Function(String) onReactionSelected;
|
final Function(String) onReactionSelected;
|
||||||
final VoidCallback? onClosed;
|
final VoidCallback? onClosed;
|
||||||
final List<String>? reactions;
|
final List<String>? reactions;
|
||||||
|
|
@ -26,134 +26,45 @@ class ReactionPicker extends StatefulWidget {
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ReactionPicker> createState() => _ReactionPickerState();
|
ConsumerState<ReactionPicker> createState() => _ReactionPickerState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProviderStateMixin {
|
class _ReactionPickerState extends ConsumerState<ReactionPicker>
|
||||||
late TabController _tabController;
|
with SingleTickerProviderStateMixin {
|
||||||
|
TabController? _tabController;
|
||||||
int _currentTabIndex = 0;
|
int _currentTabIndex = 0;
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
bool _isSearching = false;
|
bool _isSearching = false;
|
||||||
List<String> _filteredReactions = [];
|
List<String> _filteredReactions = [];
|
||||||
|
|
||||||
// Dynamic reaction sets
|
|
||||||
Map<String, List<String>> _reactionSets = {};
|
|
||||||
Map<String, String> _folderCredits = {};
|
|
||||||
List<String> _tabOrder = [];
|
|
||||||
bool _isLoading = true;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_searchController.addListener(_onSearchChanged);
|
_searchController.addListener(_onSearchChanged);
|
||||||
_loadReactionSets();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
Future<void> _loadReactionSets() async {
|
_tabController?.dispose();
|
||||||
try {
|
_searchController.dispose();
|
||||||
final reactionSets = <String, List<String>>{
|
super.dispose();
|
||||||
'emoji': [
|
|
||||||
'❤️', '👍', '😂', '😮', '😢', '😡',
|
|
||||||
'🎉', '🔥', '👏', '🙏', '💯', '🤔',
|
|
||||||
'😍', '🤣', '😊', '👌', '🙌', '💪',
|
|
||||||
'🎯', '⭐', '✨', '🌟', '💫', '☀️',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
final folderCredits = <String, String>{};
|
|
||||||
final tabOrder = ['emoji'];
|
|
||||||
|
|
||||||
// Load the manifest to discover assets
|
|
||||||
final manifest = await AssetManifest.loadFromAssetBundle(rootBundle);
|
|
||||||
final assetPaths = manifest.listAssets();
|
|
||||||
|
|
||||||
// Filter for reaction assets
|
|
||||||
final reactionAssets = assetPaths.where((path) {
|
|
||||||
final lowerPath = path.toLowerCase();
|
|
||||||
return lowerPath.startsWith('assets/reactions/') &&
|
|
||||||
(lowerPath.endsWith('.png') ||
|
|
||||||
lowerPath.endsWith('.svg') ||
|
|
||||||
lowerPath.endsWith('.webp') ||
|
|
||||||
lowerPath.endsWith('.jpg') ||
|
|
||||||
lowerPath.endsWith('.jpeg') ||
|
|
||||||
lowerPath.endsWith('.gif'));
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
for (final path in reactionAssets) {
|
|
||||||
// Path format: assets/reactions/FOLDER_NAME/FILE_NAME.ext
|
|
||||||
final parts = path.split('/');
|
|
||||||
if (parts.length >= 4) {
|
|
||||||
final folderName = parts[2];
|
|
||||||
|
|
||||||
if (!reactionSets.containsKey(folderName)) {
|
|
||||||
reactionSets[folderName] = [];
|
|
||||||
tabOrder.add(folderName);
|
|
||||||
|
|
||||||
// Try to load credit file if it's the first time we see this folder
|
|
||||||
try {
|
|
||||||
final creditPath = 'assets/reactions/$folderName/credit.md';
|
|
||||||
// Check if credit file exists in manifest too
|
|
||||||
if (assetPaths.contains(creditPath)) {
|
|
||||||
final creditData = await rootBundle.loadString(creditPath);
|
|
||||||
folderCredits[folderName] = creditData;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore missing credit files
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reactionSets[folderName]!.add(path);
|
void _ensureTabController(ReactionPackage package) {
|
||||||
|
final neededLength = package.tabOrder.length;
|
||||||
|
if (_tabController != null && _tabController!.length == neededLength) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
_tabController?.dispose();
|
||||||
|
_tabController = TabController(length: neededLength, vsync: this);
|
||||||
// Sort reactions within each set by file name
|
_tabController!.addListener(() {
|
||||||
for (final key in reactionSets.keys) {
|
|
||||||
if (key != 'emoji') {
|
|
||||||
reactionSets[key]!.sort((a, b) => a.split('/').last.compareTo(b.split('/').last));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_reactionSets = reactionSets;
|
_currentTabIndex = _tabController!.index;
|
||||||
_folderCredits = folderCredits;
|
|
||||||
_tabOrder = tabOrder;
|
|
||||||
_isLoading = false;
|
|
||||||
|
|
||||||
_tabController = TabController(length: _tabOrder.length, vsync: this);
|
|
||||||
_tabController.addListener(() {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_currentTabIndex = _tabController.index;
|
|
||||||
_clearSearch();
|
_clearSearch();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_reactionSets = {
|
|
||||||
'emoji': ['❤️', '👍', '😂', '😮', '😢', '😡']
|
|
||||||
};
|
|
||||||
_tabOrder = ['emoji'];
|
|
||||||
_isLoading = false;
|
|
||||||
_tabController = TabController(length: 1, vsync: this);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_tabController.dispose();
|
|
||||||
_searchController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearSearch() {
|
void _clearSearch() {
|
||||||
|
|
@ -180,29 +91,41 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> _filterReactions(String query) {
|
List<String> _filterReactions(String query) {
|
||||||
final reactions = _currentReactions;
|
final reactions = _filterCurrentTab();
|
||||||
return reactions.where((reaction) {
|
return reactions.where((reaction) {
|
||||||
// For image reactions, search by filename
|
if (reaction.startsWith('assets/reactions/') ||
|
||||||
if (reaction.startsWith('assets/reactions/')) {
|
reaction.startsWith('https://')) {
|
||||||
final fileName = reaction.split('/').last.toLowerCase();
|
final fileName = reaction.split('/').last.toLowerCase();
|
||||||
return fileName.contains(query);
|
return fileName.contains(query);
|
||||||
}
|
}
|
||||||
// For emoji, search by description (you could add a mapping)
|
|
||||||
return reaction.toLowerCase().contains(query);
|
return reaction.toLowerCase().contains(query);
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> get _currentReactions {
|
List<String> _filterCurrentTab() {
|
||||||
if (_tabOrder.isEmpty || _currentTabIndex >= _tabOrder.length) {
|
final package = ref.read(reactionPackageProvider).value;
|
||||||
return [];
|
if (package == null) return [];
|
||||||
}
|
final tabOrder = package.tabOrder;
|
||||||
final currentTab = _tabOrder[_currentTabIndex];
|
if (_currentTabIndex >= tabOrder.length) return [];
|
||||||
return _reactionSets[currentTab] ?? [];
|
return package.reactionSets[tabOrder[_currentTabIndex]] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_isLoading) {
|
final packageAsync = ref.watch(reactionPackageProvider);
|
||||||
|
|
||||||
|
return packageAsync.when(
|
||||||
|
loading: () => _buildLoadingDialog(),
|
||||||
|
error: (_, __) => _buildLoadingDialog(),
|
||||||
|
data: (package) {
|
||||||
|
_ensureTabController(package);
|
||||||
|
if (_tabController == null) return _buildLoadingDialog();
|
||||||
|
return _buildPicker(package);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoadingDialog() {
|
||||||
return Dialog(
|
return Dialog(
|
||||||
backgroundColor: SojornColors.transparent,
|
backgroundColor: SojornColors.transparent,
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|
@ -211,16 +134,18 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.cardSurface,
|
color: AppTheme.cardSurface,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.1)),
|
border:
|
||||||
),
|
Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.1)),
|
||||||
child: const Center(
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
),
|
||||||
|
child: const Center(child: CircularProgressIndicator()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final reactions = widget.reactions ?? (_isSearching ? _filteredReactions : _currentReactions);
|
Widget _buildPicker(ReactionPackage package) {
|
||||||
|
final tabOrder = package.tabOrder;
|
||||||
|
final reactionSets = package.reactionSets;
|
||||||
|
|
||||||
final reactionCounts = widget.reactionCounts ?? {};
|
final reactionCounts = widget.reactionCounts ?? {};
|
||||||
final myReactions = widget.myReactions ?? {};
|
final myReactions = widget.myReactions ?? {};
|
||||||
|
|
||||||
|
|
@ -247,8 +172,6 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Header with search
|
// Header with search
|
||||||
Column(
|
|
||||||
children: [
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -308,9 +231,7 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
|
||||||
color: AppTheme.textSecondary,
|
color: AppTheme.textSecondary,
|
||||||
size: 18,
|
size: 18,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () => _searchController.clear(),
|
||||||
_searchController.clear();
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
|
|
@ -321,8 +242,6 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Tabs
|
// Tabs
|
||||||
|
|
@ -333,7 +252,7 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: TabBar(
|
child: TabBar(
|
||||||
controller: _tabController,
|
controller: _tabController!,
|
||||||
onTap: (index) {
|
onTap: (index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentTabIndex = index;
|
_currentTabIndex = index;
|
||||||
|
|
@ -353,16 +272,14 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
indicatorSize: TabBarIndicatorSize.tab,
|
indicatorSize: TabBarIndicatorSize.tab,
|
||||||
tabs: _tabOrder.map((tabName) {
|
tabs: tabOrder
|
||||||
return Tab(
|
.map((name) => Tab(text: name.toUpperCase()))
|
||||||
text: tabName.toUpperCase(),
|
.toList(),
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Search results info
|
// No results message
|
||||||
if (_isSearching && _filteredReactions.isEmpty)
|
if (_isSearching && _filteredReactions.isEmpty)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
|
|
@ -382,25 +299,28 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
|
||||||
|
|
||||||
// Reaction grid
|
// Reaction grid
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 420, // Increased height to show more rows at once
|
height: 420,
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
controller: _tabController,
|
controller: _tabController!,
|
||||||
children: _tabOrder.map((tabName) {
|
children: tabOrder.map((tabName) {
|
||||||
final reactions = _reactionSets[tabName] ?? [];
|
final tabReactions = reactionSets[tabName] ?? [];
|
||||||
final isEmoji = tabName == 'emoji';
|
final isEmoji = tabName == 'emoji';
|
||||||
final credit = _folderCredits[tabName];
|
final credit = package.folderCredits[tabName];
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Reaction grid
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildReactionGrid(reactions, widget.reactionCounts ?? {}, widget.myReactions ?? {}, !isEmoji),
|
child: _buildReactionGrid(
|
||||||
|
_isSearching ? _filteredReactions : tabReactions,
|
||||||
|
reactionCounts,
|
||||||
|
myReactions,
|
||||||
|
!isEmoji,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Credit section (only for non-emoji tabs)
|
|
||||||
if (credit != null && credit.isNotEmpty)
|
if (credit != null && credit.isNotEmpty)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -415,7 +335,6 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
// Parse and display credit markdown
|
|
||||||
_buildCreditDisplay(credit),
|
_buildCreditDisplay(credit),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -437,18 +356,32 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
|
||||||
data: credit,
|
data: credit,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
onTapLink: (text, href, title) {
|
onTapLink: (text, href, title) {
|
||||||
if (href != null) {
|
if (href != null) launchUrl(Uri.parse(href));
|
||||||
launchUrl(Uri.parse(href));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
styleSheet: MarkdownStyleSheet(
|
styleSheet: MarkdownStyleSheet(
|
||||||
p: GoogleFonts.inter(fontSize: 10, color: AppTheme.textPrimary),
|
p: GoogleFonts.inter(fontSize: 10, color: AppTheme.textPrimary),
|
||||||
h1: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.bold, color: AppTheme.textPrimary),
|
h1: GoogleFonts.inter(
|
||||||
h2: GoogleFonts.inter(fontSize: 11, fontWeight: FontWeight.bold, color: AppTheme.textPrimary),
|
fontSize: 12,
|
||||||
listBullet: GoogleFonts.inter(fontSize: 10, color: AppTheme.textPrimary),
|
fontWeight: FontWeight.bold,
|
||||||
strong: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.bold, color: AppTheme.textPrimary),
|
color: AppTheme.textPrimary),
|
||||||
em: GoogleFonts.inter(fontSize: 10, fontStyle: FontStyle.italic, color: AppTheme.textPrimary),
|
h2: GoogleFonts.inter(
|
||||||
a: GoogleFonts.inter(fontSize: 10, color: AppTheme.brightNavy, decoration: TextDecoration.underline),
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.textPrimary),
|
||||||
|
listBullet:
|
||||||
|
GoogleFonts.inter(fontSize: 10, color: AppTheme.textPrimary),
|
||||||
|
strong: GoogleFonts.inter(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.textPrimary),
|
||||||
|
em: GoogleFonts.inter(
|
||||||
|
fontSize: 10,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
color: AppTheme.textPrimary),
|
||||||
|
a: GoogleFonts.inter(
|
||||||
|
fontSize: 10,
|
||||||
|
color: AppTheme.brightNavy,
|
||||||
|
decoration: TextDecoration.underline),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -479,9 +412,9 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
final result = reaction.startsWith('assets/')
|
// CDN URLs and emoji are passed as-is; local assets get 'asset:' prefix
|
||||||
? 'asset:$reaction'
|
final result =
|
||||||
: reaction;
|
reaction.startsWith('assets/') ? 'asset:$reaction' : reaction;
|
||||||
widget.onReactionSelected(result);
|
widget.onReactionSelected(result);
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
|
@ -510,7 +443,8 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
|
||||||
right: 2,
|
right: 2,
|
||||||
bottom: 2,
|
bottom: 2,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4, vertical: 1),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.brightNavy,
|
color: AppTheme.brightNavy,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
|
@ -535,13 +469,27 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEmojiReaction(String emoji) {
|
Widget _buildEmojiReaction(String emoji) {
|
||||||
return Text(
|
return Text(emoji, style: const TextStyle(fontSize: 24));
|
||||||
emoji,
|
|
||||||
style: const TextStyle(fontSize: 24),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildImageReaction(String reaction) {
|
Widget _buildImageReaction(String reaction) {
|
||||||
|
// CDN URL
|
||||||
|
if (reaction.startsWith('https://')) {
|
||||||
|
return CachedNetworkImage(
|
||||||
|
imageUrl: reaction,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
placeholder: (_, __) => const SizedBox(width: 32, height: 32),
|
||||||
|
errorWidget: (_, __, ___) => Icon(
|
||||||
|
Icons.image_not_supported,
|
||||||
|
size: 24,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local asset (with or without 'asset:' prefix)
|
||||||
final imagePath = reaction.startsWith('asset:')
|
final imagePath = reaction.startsWith('asset:')
|
||||||
? reaction.replaceFirst('asset:', '')
|
? reaction.replaceFirst('asset:', '')
|
||||||
: reaction;
|
: reaction;
|
||||||
|
|
@ -560,13 +508,11 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
|
||||||
color: AppTheme.textSecondary,
|
color: AppTheme.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) => Icon(
|
||||||
return Icon(
|
|
||||||
Icons.image_not_supported,
|
Icons.image_not_supported,
|
||||||
size: 24,
|
size: 24,
|
||||||
color: AppTheme.textSecondary,
|
color: AppTheme.textSecondary,
|
||||||
);
|
),
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -574,13 +520,11 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
|
||||||
imagePath,
|
imagePath,
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) => Icon(
|
||||||
return Icon(
|
|
||||||
Icons.image_not_supported,
|
Icons.image_not_supported,
|
||||||
size: 24,
|
size: 24,
|
||||||
color: AppTheme.textSecondary,
|
color: AppTheme.textSecondary,
|
||||||
);
|
),
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
@ -245,17 +246,27 @@ class _ReactionIcon extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// CDN URL
|
||||||
|
if (reactionId.startsWith('https://')) {
|
||||||
|
return CachedNetworkImage(
|
||||||
|
imageUrl: reactionId,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
placeholder: (_, __) => SizedBox(width: size, height: size),
|
||||||
|
errorWidget: (_, __, ___) =>
|
||||||
|
Icon(Icons.image_not_supported, size: size * 0.8),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local asset
|
||||||
if (reactionId.startsWith('assets/') || reactionId.startsWith('asset:')) {
|
if (reactionId.startsWith('assets/') || reactionId.startsWith('asset:')) {
|
||||||
final assetPath = reactionId.startsWith('asset:')
|
final assetPath = reactionId.startsWith('asset:')
|
||||||
? reactionId.replaceFirst('asset:', '')
|
? reactionId.replaceFirst('asset:', '')
|
||||||
: reactionId;
|
: reactionId;
|
||||||
|
|
||||||
if (assetPath.endsWith('.svg')) {
|
if (assetPath.endsWith('.svg')) {
|
||||||
return SvgPicture.asset(
|
return SvgPicture.asset(assetPath, width: size, height: size);
|
||||||
assetPath,
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return Image.asset(
|
return Image.asset(
|
||||||
assetPath,
|
assetPath,
|
||||||
|
|
@ -264,9 +275,8 @@ class _ReactionIcon extends StatelessWidget {
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Text(
|
|
||||||
reactionId,
|
// Emoji
|
||||||
style: TextStyle(fontSize: size),
|
return Text(reactionId, style: TextStyle(fontSize: size));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,16 @@ class TraditionalQuipsSheet extends ConsumerStatefulWidget {
|
||||||
final String postId;
|
final String postId;
|
||||||
final int initialQuipCount;
|
final int initialQuipCount;
|
||||||
final VoidCallback? onQuipPosted;
|
final VoidCallback? onQuipPosted;
|
||||||
|
/// When false (e.g. Quips video feed), shows only "X Comments" + close button
|
||||||
|
/// with no Home/Chat/Search navigation icons.
|
||||||
|
final bool showNavActions;
|
||||||
|
|
||||||
const TraditionalQuipsSheet({
|
const TraditionalQuipsSheet({
|
||||||
super.key,
|
super.key,
|
||||||
required this.postId,
|
required this.postId,
|
||||||
this.initialQuipCount = 0,
|
this.initialQuipCount = 0,
|
||||||
this.onQuipPosted,
|
this.onQuipPosted,
|
||||||
|
this.showNavActions = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -301,7 +305,7 @@ class _TraditionalQuipsSheetState extends ConsumerState<TraditionalQuipsSheet> {
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(
|
bottom: BorderSide(
|
||||||
color: AppTheme.egyptianBlue.withValues(alpha: 0.1),
|
color: AppTheme.egyptianBlue.withValues(alpha: 0.1),
|
||||||
width: 1
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -321,44 +325,7 @@ class _TraditionalQuipsSheetState extends ConsumerState<TraditionalQuipsSheet> {
|
||||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (!_isSelectionMode) ...[
|
if (_isSelectionMode) ...[
|
||||||
IconButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
icon: Icon(Icons.arrow_back, color: AppTheme.navyBlue),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
'Thread',
|
|
||||||
style: GoogleFonts.inter(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
fontSize: 18,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
IconButton(
|
|
||||||
onPressed: () => context.go(AppRoutes.homeAlias),
|
|
||||||
icon: Icon(Icons.home_outlined, color: AppTheme.navyBlue),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
onPressed: () {}, // Search - to be implemented or consistent with ThreadedConversationScreen
|
|
||||||
icon: Icon(Icons.search, color: AppTheme.navyBlue),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
onPressed: () => context.go(AppRoutes.secureChat),
|
|
||||||
icon: Consumer(
|
|
||||||
builder: (context, ref, child) {
|
|
||||||
final badge = ref.watch(currentBadgeProvider);
|
|
||||||
return Badge(
|
|
||||||
label: Text(badge.messageCount.toString()),
|
|
||||||
isLabelVisible: badge.messageCount > 0,
|
|
||||||
backgroundColor: AppTheme.brightNavy,
|
|
||||||
child: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
] else ...[
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => setState(() {
|
onPressed: () => setState(() {
|
||||||
_isSelectionMode = false;
|
_isSelectionMode = false;
|
||||||
|
|
@ -380,7 +347,64 @@ class _TraditionalQuipsSheetState extends ConsumerState<TraditionalQuipsSheet> {
|
||||||
icon: const Icon(Icons.delete_outline, color: SojornColors.destructive),
|
icon: const Icon(Icons.delete_outline, color: SojornColors.destructive),
|
||||||
onPressed: _bulkDelete,
|
onPressed: _bulkDelete,
|
||||||
),
|
),
|
||||||
]
|
] else if (widget.showNavActions) ...[
|
||||||
|
// Full thread header with nav buttons (used in regular post view)
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
icon: Icon(Icons.arrow_back, color: AppTheme.navyBlue),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'Thread',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 18,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => context.go(AppRoutes.homeAlias),
|
||||||
|
icon: Icon(Icons.home_outlined, color: AppTheme.navyBlue),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {},
|
||||||
|
icon: Icon(Icons.search, color: AppTheme.navyBlue),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => context.go(AppRoutes.secureChat),
|
||||||
|
icon: Consumer(
|
||||||
|
builder: (context, ref, child) {
|
||||||
|
final badge = ref.watch(currentBadgeProvider);
|
||||||
|
return Badge(
|
||||||
|
label: Text(badge.messageCount.toString()),
|
||||||
|
isLabelVisible: badge.messageCount > 0,
|
||||||
|
backgroundColor: AppTheme.brightNavy,
|
||||||
|
child: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
// Clean Quips-style header: "X Comments" + close X
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 12),
|
||||||
|
child: Text(
|
||||||
|
'$_commentCount Comment${_commentCount == 1 ? '' : 's'}',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 17,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
icon: Icon(Icons.close, color: AppTheme.navyBlue),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,15 @@ class VideoCommentsSheet extends StatefulWidget {
|
||||||
final String postId;
|
final String postId;
|
||||||
final int initialCommentCount;
|
final int initialCommentCount;
|
||||||
final VoidCallback? onCommentPosted;
|
final VoidCallback? onCommentPosted;
|
||||||
|
/// Set to false for Quips feed (hides Home/Chat/Search nav icons in header)
|
||||||
|
final bool showNavActions;
|
||||||
|
|
||||||
const VideoCommentsSheet({
|
const VideoCommentsSheet({
|
||||||
super.key,
|
super.key,
|
||||||
required this.postId,
|
required this.postId,
|
||||||
this.initialCommentCount = 0,
|
this.initialCommentCount = 0,
|
||||||
this.onCommentPosted,
|
this.onCommentPosted,
|
||||||
|
this.showNavActions = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -31,6 +34,7 @@ class _VideoCommentsSheetState extends State<VideoCommentsSheet> {
|
||||||
postId: widget.postId,
|
postId: widget.postId,
|
||||||
initialQuipCount: widget.initialCommentCount,
|
initialQuipCount: widget.initialCommentCount,
|
||||||
onQuipPosted: widget.onCommentPosted,
|
onQuipPosted: widget.onCommentPosted,
|
||||||
|
showNavActions: widget.showNavActions,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue