diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index 65dc428..4dc20ed 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -343,6 +343,7 @@ func main() { authorized.DELETE("/posts/:id", postHandler.DeletePost) authorized.POST("/posts/:id/pin", postHandler.PinPost) authorized.PATCH("/posts/:id/visibility", postHandler.UpdateVisibility) + authorized.POST("/posts/:id/hide", postHandler.HidePost) authorized.POST("/posts/:id/like", postHandler.LikePost) authorized.DELETE("/posts/:id/like", postHandler.UnlikePost) authorized.POST("/posts/:id/save", postHandler.SavePost) diff --git a/go-backend/internal/handlers/groups_handler.go b/go-backend/internal/handlers/groups_handler.go index 4c0fae1..c6e1bc5 100644 --- a/go-backend/internal/handlers/groups_handler.go +++ b/go-backend/internal/handlers/groups_handler.go @@ -218,7 +218,6 @@ func (h *GroupsHandler) CreateGroup(c *gin.Context) { Category string `json:"category" binding:"required"` IsPrivate bool `json:"is_private"` AvatarURL *string `json:"avatar_url"` - BannerURL *string `json:"banner_url"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -229,6 +228,11 @@ func (h *GroupsHandler) CreateGroup(c *gin.Context) { // Normalize name for uniqueness check req.Name = strings.TrimSpace(req.Name) + privacy := "public" + if req.IsPrivate { + privacy = "private" + } + tx, err := h.db.Begin(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create group"}) @@ -239,10 +243,10 @@ func (h *GroupsHandler) CreateGroup(c *gin.Context) { // Create group var groupID string err = tx.QueryRow(c.Request.Context(), ` - INSERT INTO groups (name, description, category, is_private, created_by, avatar_url, banner_url) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO groups (name, description, category, privacy, created_by, avatar_url) + VALUES ($1, $2, $3, $4, $5, $6) 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 strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") { diff --git a/go-backend/internal/handlers/post_handler.go b/go-backend/internal/handlers/post_handler.go index 2769c33..1d41fb9 100644 --- a/go-backend/internal/handlers/post_handler.go +++ b/go-backend/internal/handlers/post_handler.go @@ -1170,6 +1170,22 @@ func (h *PostHandler) UnlikePost(c *gin.Context) { 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) { postID := c.Param("id") userIDStr, _ := c.Get("user_id") diff --git a/go-backend/internal/repository/post_repository.go b/go-backend/internal/repository/post_repository.go index 69d2fa3..cda6cb8 100644 --- a/go-backend/internal/repository/post_repository.go +++ b/go-backend/internal/repository/post_repository.go @@ -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 ($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 ($5 = '' OR c.slug = $5) AND ( @@ -497,6 +501,18 @@ func (r *PostRepository) UnlikePost(ctx context.Context, postID string, userID s 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 { query := ` WITH inserted AS ( diff --git a/go-backend/internal/services/feed_algorithm_service.go b/go-backend/internal/services/feed_algorithm_service.go index 6f8fad2..ef1c4ad 100644 --- a/go-backend/internal/services/feed_algorithm_service.go +++ b/go-backend/internal/services/feed_algorithm_service.go @@ -465,6 +465,11 @@ func (s *FeedAlgorithmService) GetAlgorithmicFeed(ctx context.Context, viewerID LEFT JOIN user_feed_impressions ufi ON ufi.post_id = pfs.post_id AND ufi.user_id = $1 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} 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 WHERE p.status = 'active' 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() LIMIT $2 `, placeholders) diff --git a/sojorn_app/lib/providers/reactions_provider.dart b/sojorn_app/lib/providers/reactions_provider.dart new file mode 100644 index 0000000..d8f8134 --- /dev/null +++ b/sojorn_app/lib/providers/reactions_provider.dart @@ -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 tabOrder; + final Map> reactionSets; // tabId → list of identifiers (URL or emoji) + final Map 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((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; + final tabsRaw = + (data['tabs'] as List? ?? []).whereType>(); + + final tabOrder = ['emoji']; + final reactionSets = >{'emoji': _defaultEmoji}; + final folderCredits = {}; + + 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().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 = ['emoji']; + final reactionSets = >{'emoji': _defaultEmoji}; + final folderCredits = {}; + + 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 = [ + '❤️', '👍', '😂', '😮', '😢', '😡', + '🎉', '🔥', '👏', '🙏', '💯', '🤔', + '😍', '🤣', '😊', '👌', '🙌', '💪', + '🎯', '⭐', '✨', '🌟', '💫', '☀️', +]; diff --git a/sojorn_app/lib/screens/quips/feed/quip_video_item.dart b/sojorn_app/lib/screens/quips/feed/quip_video_item.dart index 919fe49..757c57d 100644 --- a/sojorn_app/lib/screens/quips/feed/quip_video_item.dart +++ b/sojorn_app/lib/screens/quips/feed/quip_video_item.dart @@ -1,69 +1,172 @@ import 'dart:convert'; +import 'package:cached_network_image/cached_network_image.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 '../../../models/quip_text_overlay.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 'quips_feed_screen.dart'; -class QuipVideoItem extends StatelessWidget { +class QuipVideoItem extends StatefulWidget { final Quip quip; final VideoPlayerController? controller; final bool isActive; - final bool isLiked; - final int likeCount; + final Map reactions; + final Set myReactions; + final int commentCount; final bool isUserPaused; - final VoidCallback onLike; + final Function(String emoji) onReact; + final VoidCallback onOpenReactionPicker; final VoidCallback onComment; final VoidCallback onShare; final VoidCallback onTogglePause; + final VoidCallback onNotInterested; const QuipVideoItem({ super.key, required this.quip, required this.controller, required this.isActive, - required this.isLiked, - required this.likeCount, + this.reactions = const {}, + this.myReactions = const {}, + this.commentCount = 0, required this.isUserPaused, - required this.onLike, + required this.onReact, + required this.onOpenReactionPicker, required this.onComment, required this.onShare, required this.onTogglePause, + required this.onNotInterested, }); - /// Convert Quip to Post for use with VideoPlayerWithComments - Post _toPost() { - return Post( - id: quip.id, - authorId: quip.username, // This would need to be the actual user ID - body: quip.caption, - status: PostStatus.active, - detectedTone: ToneLabel.neutral, - contentIntegrityScore: 0.8, - createdAt: DateTime.now(), // Would need actual timestamp - videoUrl: quip.videoUrl, - thumbnailUrl: quip.thumbnailUrl, - likeCount: likeCount, - commentCount: 0, // Would need to be fetched separately - author: Profile( - id: quip.username, - handle: quip.username, - displayName: quip.displayName ?? '', - createdAt: DateTime.now(), + @override + State createState() => _QuipVideoItemState(); +} + +class _QuipVideoItemState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _heartController; + late final Animation _heartScale; + late final Animation _heartOpacity; + Offset _heartPosition = Offset.zero; + bool _showHeart = false; + bool _isCaptionExpanded = false; + + // Cached overlay data — parsed once, not on every build + late String _audioLabel; + late List _overlayItems; + + static const _quickReactEmoji = '❤️'; + + @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; + final soundId = decoded['sound_id']; + if (soundId is String && soundId.isNotEmpty) { + return soundId.split('/').last.split('.').first; + } + } catch (_) {} + } + return 'Original Sound'; + } + + List _parseOverlayItems() { + final json = widget.quip.overlayJson; + if (json == null || json.isEmpty) return []; + try { + final decoded = jsonDecode(json) as Map; + return (decoded['overlays'] as List? ?? []) + .whereType>() + .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() { - final initialized = controller?.value.isInitialized ?? false; + final ctrl = widget.controller; + final initialized = ctrl?.value.isInitialized ?? false; + if (initialized) { - final size = controller!.value.size; + final size = ctrl!.value.size; return Container( color: SojornColors.basicBlack, child: Center( @@ -72,123 +175,343 @@ class QuipVideoItem extends StatelessWidget { child: SizedBox( width: size.width, height: size.height, - child: VideoPlayer(controller!), + child: VideoPlayer(ctrl), ), ), ), ); } - if (quip.thumbnailUrl.isNotEmpty) { + if (widget.quip.thumbnailUrl.isNotEmpty) { return SignedMediaImage( - url: quip.thumbnailUrl, + url: widget.quip.thumbnailUrl, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container(color: SojornColors.basicBlack), - loadingBuilder: (context) { - return Container( - color: SojornColors.basicBlack, - child: const Center( - child: CircularProgressIndicator(color: SojornColors.basicWhite), - ), - ); - }, + loadingBuilder: (_) => Container(color: SojornColors.basicBlack), ); } return Container(color: SojornColors.basicBlack); } - Widget _buildActions() { - final actions = [ - _QuipAction( - icon: isLiked ? Icons.favorite : Icons.favorite_border, - label: likeCount > 0 ? likeCount.toString() : '', - onTap: onLike, - color: isLiked ? SojornColors.destructive : SojornColors.basicWhite, + Widget _buildProgressBar() { + final ctrl = widget.controller; + if (ctrl == null || !ctrl.value.isInitialized) return const SizedBox.shrink(); + return Positioned( + left: 0, + right: 0, + 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, - ), - _QuipAction( - icon: Icons.more_horiz, - onTap: () {}, - ), - ]; - - return Column( - mainAxisSize: MainAxisSize.min, - children: actions - .map((action) => Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: action, - )) - .toList(), ); } - Widget _buildOverlay() { + 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), + ), + child: ClipOval(child: inner), + ), + // "+" 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( + mainAxisSize: MainAxisSize.min, + children: [ + _buildAvatar(), + const SizedBox(height: 24), + // Reaction button — tap to quick-react ❤️, long-press to open full picker + _buildActionBtn( + 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, + ), + ], + ); + } + + /// 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( left: 16, - right: 16, + right: 80, bottom: 28, child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - Text( - '@${quip.username}', - style: const TextStyle( - color: SojornColors.basicWhite, - fontWeight: FontWeight.w700, - fontSize: 16, - shadows: [ - Shadow( - color: const Color(0x8A000000), - offset: Offset(0, 1), - blurRadius: 6, - ), - ], + // Username + GestureDetector( + onTap: _navigateToProfile, + child: Text( + '@${widget.quip.username}', + style: const TextStyle( + color: SojornColors.basicWhite, + fontWeight: FontWeight.w700, + fontSize: 15, + shadows: [ + Shadow( + color: Color(0x8A000000), + offset: Offset(0, 1), + blurRadius: 6, + ), + ], + ), ), ), - const SizedBox(height: 6), - Text( - quip.caption, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: SojornColors.basicWhite, - fontSize: 14, - 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, - shadows: [ - Shadow( - color: const Color(0x73000000), - offset: Offset(0, 1), - blurRadius: 6, - ), + // Caption with "...more" expand + if (hasCaption) ...[ + 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, + style: const TextStyle( + color: SojornColors.basicWhite, + fontSize: 12, + fontWeight: FontWeight.w500, + shadows: [ + Shadow( + color: Color(0x73000000), + offset: Offset(0, 1), + blurRadius: 4, + ), + ], + ), + ), ), ], ), @@ -197,30 +520,57 @@ class QuipVideoItem extends StatelessWidget { ); } - /// Parses overlay_json and returns a list of non-interactive overlay widgets - /// rendered on top of the video during feed playback. + Widget _buildPauseOverlay() { + if (!widget.isActive || !widget.isUserPaused) return const SizedBox.shrink(); + return Center( + child: Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Color(0x8A000000), + shape: BoxShape.circle, + ), + child: const Icon( + 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 _buildOverlayWidgets(BoxConstraints constraints) { - final json = quip.overlayJson; - if (json == null || json.isEmpty) return []; - try { - final decoded = jsonDecode(json) as Map; - final items = (decoded['overlays'] as List? ?? []) - .whereType>() - .map(QuipOverlayItem.fromJson) - .toList(); + if (_overlayItems.isEmpty) return []; + final w = constraints.maxWidth; + final h = constraints.maxHeight; - final w = constraints.maxWidth; - final h = constraints.maxHeight; - - return items.map((item) { + return _overlayItems.map((item) { final absX = item.position.dx * w; final absY = item.position.dy * h; final isSticker = item.type == QuipOverlayType.sticker; Widget child; if (isSticker) { - final isEmoji = item.content.runes.length == 1 || - item.content.length <= 2; + final isEmoji = item.content.runes.length == 1 || item.content.length <= 2; if (isEmoji) { child = Text(item.content, style: TextStyle(fontSize: 42 * item.scale)); @@ -267,194 +617,159 @@ class QuipVideoItem extends StatelessWidget { child: Transform.rotate(angle: item.rotation, child: child), ); }).toList(); - } catch (_) { - return []; - } } - Widget _buildPauseOverlay() { - if (!isActive || !isUserPaused) return const SizedBox.shrink(); - - return Center( - 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, - ), - ), - ); + String _formatCount(int n) { + if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M'; + if (n >= 1000) return '${(n / 1000).toStringAsFixed(1)}K'; + return '$n'; } @override Widget build(BuildContext context) { return GestureDetector( - onTap: onTogglePause, + onTap: widget.onTogglePause, + onDoubleTapDown: _handleDoubleTap, + onDoubleTap: () {}, // consume event so single-tap doesn't fire on double-tap child: Container( color: SojornColors.basicBlack, child: LayoutBuilder( builder: (context, constraints) => Stack( - fit: StackFit.expand, - children: [ - AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: isActive ? 1 : 0.6, - child: _buildVideo(), - ), - // Quip overlays (text + stickers, non-interactive in feed) - ..._buildOverlayWidgets(constraints), - Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [ - const Color(0x8A000000), - SojornColors.transparent, - const Color(0x73000000), - ], - stops: [0, 0.4, 1], + fit: StackFit.expand, + children: [ + _buildVideo(), + // Quip overlays (text + stickers, non-interactive in feed) + ..._buildOverlayWidgets(constraints), + // Gradient scrim: strong at bottom for text legibility + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0x55000000), // subtle top vignette + Colors.transparent, + Colors.transparent, + Color(0xB0000000), // strong bottom scrim + ], + stops: [0, 0.15, 0.55, 1], + ), ), ), - ), - _buildOverlay(), - Positioned( - right: 16, - bottom: 80, - child: _buildActions(), - ), - 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, - ), - ), - ), - ], + // User info — bottom-left + _buildUserInfo(), + // Side actions — right + Positioned( + right: 12, + bottom: 90, + child: _buildSideActions(), ), - ), - _buildPauseOverlay(), - if (!(controller?.value.isInitialized ?? false)) - Center( - child: const CircularProgressIndicator(color: SojornColors.basicWhite), - ), - ], - ), + // Pause overlay + _buildPauseOverlay(), + // Double-tap heart burst + _buildHeartBurst(), + // Thin video progress bar at very bottom + _buildProgressBar(), + // Buffering spinner + if (!(widget.controller?.value.isInitialized ?? false)) + const Center( + child: CircularProgressIndicator(color: SojornColors.basicWhite), + ), + ], + ), ), ), ); } - - /// 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 { - final IconData icon; - final String? label; - final VoidCallback onTap; - final Color color; +/// Bottom sheet shown when the user taps the "..." (more) button on a quip. +class _MoreOptionsSheet extends StatelessWidget { + final String quipId; + final VoidCallback onNotInterested; - const _QuipAction({ - required this.icon, - required this.onTap, - this.label, - this.color = SojornColors.basicWhite, + const _MoreOptionsSheet({ + required this.quipId, + required this.onNotInterested, }); @override Widget build(BuildContext context) { - return Column( - children: [ - Container( - decoration: BoxDecoration( - color: const Color(0x8A000000), - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: const Color(0x66000000), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: IconButton( - onPressed: onTap, - icon: Icon(icon, color: color), - ), - ), - if (label != null && label!.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - label!, - style: const TextStyle( - color: SojornColors.basicWhite, - fontSize: 12, - fontWeight: FontWeight.w600, - shadows: [ - Shadow( - color: const Color(0x8A000000), - blurRadius: 4, - offset: Offset(0, 1), - ), - ], + 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: [ + // Drag handle + Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(2), ), ), + _buildOption( + context, + 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, + ), + ); + }, + ), ], - ], + ), + ); + } + + Widget _buildOption( + BuildContext context, { + required IconData icon, + 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( + label, + style: TextStyle( + color: color, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), ); } } diff --git a/sojorn_app/lib/screens/quips/feed/quips_feed_screen.dart b/sojorn_app/lib/screens/quips/feed/quips_feed_screen.dart index f294495..44723e9 100644 --- a/sojorn_app/lib/screens/quips/feed/quips_feed_screen.dart +++ b/sojorn_app/lib/screens/quips/feed/quips_feed_screen.dart @@ -9,9 +9,9 @@ import '../../../providers/feed_refresh_provider.dart'; import '../../../routes/app_routes.dart'; import '../../../theme/app_theme.dart'; import '../../../theme/tokens.dart'; -import '../../post/post_detail_screen.dart'; import 'quip_video_item.dart'; import '../../home/home_shell.dart'; +import '../../../widgets/reactions/reaction_picker.dart'; import '../../../widgets/video_comments_sheet.dart'; class Quip { @@ -23,8 +23,10 @@ class Quip { final String? displayName; final String? avatarUrl; final int? durationMs; - final int? likeCount; + final int commentCount; final String? overlayJson; + final Map reactions; + final Set myReactions; const Quip({ required this.id, @@ -35,8 +37,10 @@ class Quip { this.displayName, this.avatarUrl, this.durationMs, - this.likeCount, + this.commentCount = 0, this.overlayJson, + this.reactions = const {}, + this.myReactions = const {}, }); factory Quip.fromMap(Map map) { @@ -54,18 +58,29 @@ class Quip { displayName: author?['display_name'] as String?, avatarUrl: author?['avatar_url'] as String?, durationMs: map['duration_ms'] as int?, - likeCount: _parseLikeCount(map['metrics']), + commentCount: _parseCount(map['comment_count']), overlayJson: map['overlay_json'] as String?, + reactions: _parseReactions(map['reactions']), + myReactions: _parseMyReactions(map['my_reactions']), ); } - static int? _parseLikeCount(dynamic metrics) { - if (metrics is Map) { - final val = metrics['like_count']; - if (val is int) return val; - if (val is num) return val.toInt(); + static Map _parseReactions(dynamic v) { + if (v is Map) { + return v.map((k, val) => MapEntry(k, val is int ? val : (val is num ? val.toInt() : 0))); } - return null; + return {}; + } + + static Set _parseMyReactions(dynamic v) { + if (v is List) return v.whereType().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 final List _quips = []; final Map _controllers = {}; final Map> _controllerFutures = {}; - final Map _liked = {}; - final Map _likeCounts = {}; + final Map> _reactionCounts = {}; + final Map> _myReactions = {}; bool _isLoading = false; bool _hasMore = true; @@ -268,7 +283,8 @@ class _QuipsFeedScreenState extends ConsumerState } } else { } - } catch (e) { + } catch (_) { + // Ignore — initial post will just not appear at top } } } @@ -297,7 +313,10 @@ class _QuipsFeedScreenState extends ConsumerState _quips.addAll(items); _hasMore = items.length == _pageSize; for (final item in items) { - _likeCounts.putIfAbsent(item.id, () => item.likeCount ?? 0); + _reactionCounts.putIfAbsent( + item.id, () => Map.from(item.reactions)); + _myReactions.putIfAbsent( + item.id, () => Set.from(item.myReactions)); } }); @@ -409,43 +428,81 @@ class _QuipsFeedScreenState extends ConsumerState await _fetchQuips(); } - Future _toggleLike(Quip quip) async { + Future _toggleReaction(Quip quip, String emoji) async { final api = ref.read(apiServiceProvider); - final currentlyLiked = _liked[quip.id] ?? false; + final currentCounts = + Map.from(_reactionCounts[quip.id] ?? quip.reactions); + final currentMine = + Set.from(_myReactions[quip.id] ?? quip.myReactions); + + // Optimistic update + final isRemoving = currentMine.contains(emoji); setState(() { - _liked[quip.id] = !currentlyLiked; - final currentCount = _likeCounts[quip.id] ?? 0; - final next = currentlyLiked ? currentCount - 1 : currentCount + 1; - _likeCounts[quip.id] = next < 0 ? 0 : next; + if (isRemoving) { + currentMine.remove(emoji); + final newCount = (currentCounts[emoji] ?? 1) - 1; + 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 { - if (currentlyLiked) { - await api.unappreciatePost(quip.id); - } else { - await api.appreciatePost(quip.id); - } + await api.toggleReaction(quip.id, emoji); } catch (_) { - // revert on failure + // Revert on failure if (!mounted) return; setState(() { - _liked[quip.id] = currentlyLiked; - _likeCounts[quip.id] = - (_likeCounts[quip.id] ?? 0) + (currentlyLiked ? 1 : -1); - if ((_likeCounts[quip.id] ?? 0) < 0) { - _likeCounts[quip.id] = 0; - } + _reactionCounts[quip.id] = Map.from(quip.reactions); + _myReactions[quip.id] = Set.from(quip.myReactions); }); - 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 _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 = {}; + _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 _openComments(Quip quip) async { showModalBottomSheet( context: context, @@ -453,10 +510,9 @@ class _QuipsFeedScreenState extends ConsumerState backgroundColor: SojornColors.transparent, builder: (context) => VideoCommentsSheet( postId: quip.id, - initialCommentCount: 0, - onCommentPosted: () { - // Optional: handle reload if needed - }, + initialCommentCount: quip.commentCount, + showNavActions: false, + onCommentPosted: () {}, ), ); } @@ -528,8 +584,7 @@ class _QuipsFeedScreenState extends ConsumerState child: PageView.builder( controller: _pageController, scrollDirection: Axis.vertical, - // Ensure physics allows scrolling to trigger refresh - physics: const AlwaysScrollableScrollPhysics(), + physics: const PageScrollPhysics(parent: AlwaysScrollableScrollPhysics()), itemCount: _quips.length, onPageChanged: (index) { _currentIndex = index; @@ -542,8 +597,6 @@ class _QuipsFeedScreenState extends ConsumerState itemBuilder: (context, index) { final quip = _quips[index]; final controller = _controllers[index]; - final isLiked = _liked[quip.id] ?? false; - final likeCount = _likeCounts[quip.id] ?? quip.likeCount ?? 0; return VisibilityDetector( key: ValueKey('quip-${quip.id}'), onVisibilityChanged: (info) => @@ -552,13 +605,16 @@ class _QuipsFeedScreenState extends ConsumerState quip: quip, controller: controller, isActive: index == _currentIndex, - isLiked: isLiked, - likeCount: likeCount, + reactions: _reactionCounts[quip.id] ?? quip.reactions, + myReactions: _myReactions[quip.id] ?? quip.myReactions, + commentCount: quip.commentCount, isUserPaused: _isUserPaused, - onLike: () => _toggleLike(quip), + onReact: (emoji) => _toggleReaction(quip, emoji), + onOpenReactionPicker: () => _openReactionPicker(quip), onComment: () => _openComments(quip), onShare: () => _shareQuip(quip), onTogglePause: _toggleUserPause, + onNotInterested: () => _handleNotInterested(quip), ), ); }, diff --git a/sojorn_app/lib/services/api_service.dart b/sojorn_app/lib/services/api_service.dart index 58a96dd..38bb22e 100644 --- a/sojorn_app/lib/services/api_service.dart +++ b/sojorn_app/lib/services/api_service.dart @@ -1089,6 +1089,10 @@ class ApiService { ); } + Future hidePost(String postId) async { + await _callGoApi('/posts/$postId/hide', method: 'POST'); + } + Future appreciatePost(String postId) async { await _callGoApi( '/posts/$postId/like', diff --git a/sojorn_app/lib/widgets/reactions/reaction_picker.dart b/sojorn_app/lib/widgets/reactions/reaction_picker.dart index ce202c2..ffd5482 100644 --- a/sojorn_app/lib/widgets/reactions/reaction_picker.dart +++ b/sojorn_app/lib/widgets/reactions/reaction_picker.dart @@ -1,15 +1,15 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'dart:convert'; +import '../../providers/reactions_provider.dart'; import '../../theme/app_theme.dart'; import '../../theme/tokens.dart'; -class ReactionPicker extends StatefulWidget { +class ReactionPicker extends ConsumerStatefulWidget { final Function(String) onReactionSelected; final VoidCallback? onClosed; final List? reactions; @@ -26,136 +26,47 @@ class ReactionPicker extends StatefulWidget { }); @override - State createState() => _ReactionPickerState(); + ConsumerState createState() => _ReactionPickerState(); } -class _ReactionPickerState extends State with SingleTickerProviderStateMixin { - late TabController _tabController; +class _ReactionPickerState extends ConsumerState + with SingleTickerProviderStateMixin { + TabController? _tabController; int _currentTabIndex = 0; final TextEditingController _searchController = TextEditingController(); bool _isSearching = false; List _filteredReactions = []; - - // Dynamic reaction sets - Map> _reactionSets = {}; - Map _folderCredits = {}; - List _tabOrder = []; - bool _isLoading = true; @override void initState() { super.initState(); _searchController.addListener(_onSearchChanged); - _loadReactionSets(); } - - - Future _loadReactionSets() async { - try { - final reactionSets = >{ - 'emoji': [ - '❤️', '👍', '😂', '😮', '😢', '😡', - '🎉', '🔥', '👏', '🙏', '💯', '🤔', - '😍', '🤣', '😊', '👌', '🙌', '💪', - '🎯', '⭐', '✨', '🌟', '💫', '☀️', - ], - }; - - final folderCredits = {}; - 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); - } - } - - // Sort reactions within each set by file name - for (final key in reactionSets.keys) { - if (key != 'emoji') { - reactionSets[key]!.sort((a, b) => a.split('/').last.compareTo(b.split('/').last)); - } - } - - if (mounted) { - setState(() { - _reactionSets = reactionSets; - _folderCredits = folderCredits; - _tabOrder = tabOrder; - _isLoading = false; - - _tabController = TabController(length: _tabOrder.length, vsync: this); - _tabController.addListener(() { - if (mounted) { - setState(() { - _currentTabIndex = _tabController.index; - _clearSearch(); - }); - } - }); - }); - } - } catch (e) { - // Fallback - if (mounted) { - setState(() { - _reactionSets = { - 'emoji': ['❤️', '👍', '😂', '😮', '😢', '😡'] - }; - _tabOrder = ['emoji']; - _isLoading = false; - _tabController = TabController(length: 1, vsync: this); - }); - } - } - } - - @override void dispose() { - _tabController.dispose(); + _tabController?.dispose(); _searchController.dispose(); super.dispose(); } + void _ensureTabController(ReactionPackage package) { + final neededLength = package.tabOrder.length; + if (_tabController != null && _tabController!.length == neededLength) { + return; + } + _tabController?.dispose(); + _tabController = TabController(length: neededLength, vsync: this); + _tabController!.addListener(() { + if (mounted) { + setState(() { + _currentTabIndex = _tabController!.index; + _clearSearch(); + }); + } + }); + } + void _clearSearch() { _searchController.clear(); setState(() { @@ -180,47 +91,61 @@ class _ReactionPickerState extends State with SingleTickerProvid } List _filterReactions(String query) { - final reactions = _currentReactions; + final reactions = _filterCurrentTab(); 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(); return fileName.contains(query); } - // For emoji, search by description (you could add a mapping) return reaction.toLowerCase().contains(query); }).toList(); } - List get _currentReactions { - if (_tabOrder.isEmpty || _currentTabIndex >= _tabOrder.length) { - return []; - } - final currentTab = _tabOrder[_currentTabIndex]; - return _reactionSets[currentTab] ?? []; + List _filterCurrentTab() { + final package = ref.read(reactionPackageProvider).value; + if (package == null) return []; + final tabOrder = package.tabOrder; + if (_currentTabIndex >= tabOrder.length) return []; + return package.reactionSets[tabOrder[_currentTabIndex]] ?? []; } @override Widget build(BuildContext context) { - if (_isLoading) { - return Dialog( - backgroundColor: SojornColors.transparent, - child: Container( - width: 400, - height: 300, - decoration: BoxDecoration( - color: AppTheme.cardSurface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.1)), - ), - child: const Center( - child: CircularProgressIndicator(), - ), + 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( + backgroundColor: SojornColors.transparent, + child: Container( + width: 400, + height: 300, + decoration: BoxDecoration( + color: AppTheme.cardSurface, + borderRadius: BorderRadius.circular(16), + border: + Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.1)), ), - ); - } - - final reactions = widget.reactions ?? (_isSearching ? _filteredReactions : _currentReactions); + child: const Center(child: CircularProgressIndicator()), + ), + ); + } + + Widget _buildPicker(ReactionPackage package) { + final tabOrder = package.tabOrder; + final reactionSets = package.reactionSets; + final reactionCounts = widget.reactionCounts ?? {}; final myReactions = widget.myReactions ?? {}; @@ -247,84 +172,78 @@ class _ReactionPickerState extends State with SingleTickerProvid mainAxisSize: MainAxisSize.min, children: [ // Header with search - Column( + Row( children: [ - Row( - children: [ - Text( - _isSearching ? 'Search Reactions' : 'Add Reaction', - style: GoogleFonts.inter( - color: AppTheme.navyBlue, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - const Spacer(), - IconButton( - onPressed: () { - Navigator.of(context).pop(); - widget.onClosed?.call(); - }, - icon: Icon( - Icons.close, - color: AppTheme.textSecondary, - size: 20, - ), - ), - ], - ), - const SizedBox(height: 12), - - // Search bar - Container( - decoration: BoxDecoration( - color: AppTheme.navyBlue.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: AppTheme.navyBlue.withValues(alpha: 0.1), - ), + Text( + _isSearching ? 'Search Reactions' : 'Add Reaction', + style: GoogleFonts.inter( + color: AppTheme.navyBlue, + fontSize: 16, + fontWeight: FontWeight.w600, ), - child: TextField( - controller: _searchController, - style: GoogleFonts.inter( - color: AppTheme.navyBlue, - fontSize: 14, - ), - decoration: InputDecoration( - hintText: 'Search reactions...', - hintStyle: GoogleFonts.inter( - color: AppTheme.textSecondary, - fontSize: 14, - ), - prefixIcon: Icon( - Icons.search, - color: AppTheme.textSecondary, - size: 20, - ), - suffixIcon: _searchController.text.isNotEmpty - ? IconButton( - icon: Icon( - Icons.clear, - color: AppTheme.textSecondary, - size: 18, - ), - onPressed: () { - _searchController.clear(); - }, - ) - : null, - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - ), + ), + const Spacer(), + IconButton( + onPressed: () { + Navigator.of(context).pop(); + widget.onClosed?.call(); + }, + icon: Icon( + Icons.close, + color: AppTheme.textSecondary, + size: 20, ), ), ], ), + const SizedBox(height: 12), + + // Search bar + Container( + decoration: BoxDecoration( + color: AppTheme.navyBlue.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppTheme.navyBlue.withValues(alpha: 0.1), + ), + ), + child: TextField( + controller: _searchController, + style: GoogleFonts.inter( + color: AppTheme.navyBlue, + fontSize: 14, + ), + decoration: InputDecoration( + hintText: 'Search reactions...', + hintStyle: GoogleFonts.inter( + color: AppTheme.textSecondary, + fontSize: 14, + ), + prefixIcon: Icon( + Icons.search, + color: AppTheme.textSecondary, + size: 20, + ), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: Icon( + Icons.clear, + color: AppTheme.textSecondary, + size: 18, + ), + onPressed: () => _searchController.clear(), + ) + : null, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + ), + ), const SizedBox(height: 16), - + // Tabs Container( height: 40, @@ -333,7 +252,7 @@ class _ReactionPickerState extends State with SingleTickerProvid borderRadius: BorderRadius.circular(12), ), child: TabBar( - controller: _tabController, + controller: _tabController!, onTap: (index) { setState(() { _currentTabIndex = index; @@ -353,16 +272,14 @@ class _ReactionPickerState extends State with SingleTickerProvid fontWeight: FontWeight.w600, ), indicatorSize: TabBarIndicatorSize.tab, - tabs: _tabOrder.map((tabName) { - return Tab( - text: tabName.toUpperCase(), - ); - }).toList(), + tabs: tabOrder + .map((name) => Tab(text: name.toUpperCase())) + .toList(), ), ), const SizedBox(height: 16), - - // Search results info + + // No results message if (_isSearching && _filteredReactions.isEmpty) Container( padding: const EdgeInsets.all(12), @@ -379,28 +296,31 @@ class _ReactionPickerState extends State with SingleTickerProvid textAlign: TextAlign.center, ), ), - + // Reaction grid SizedBox( - height: 420, // Increased height to show more rows at once + height: 420, child: TabBarView( - controller: _tabController, - children: _tabOrder.map((tabName) { - final reactions = _reactionSets[tabName] ?? []; + controller: _tabController!, + children: tabOrder.map((tabName) { + final tabReactions = reactionSets[tabName] ?? []; final isEmoji = tabName == 'emoji'; - final credit = _folderCredits[tabName]; - + final credit = package.folderCredits[tabName]; + return Column( children: [ - // Reaction grid 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) Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -415,7 +335,6 @@ class _ReactionPickerState extends State with SingleTickerProvid ), ), const SizedBox(height: 4), - // Parse and display credit markdown _buildCreditDisplay(credit), ], ), @@ -437,18 +356,32 @@ class _ReactionPickerState extends State with SingleTickerProvid data: credit, selectable: true, onTapLink: (text, href, title) { - if (href != null) { - launchUrl(Uri.parse(href)); - } + if (href != null) launchUrl(Uri.parse(href)); }, styleSheet: MarkdownStyleSheet( p: GoogleFonts.inter(fontSize: 10, color: AppTheme.textPrimary), - h1: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.bold, color: AppTheme.textPrimary), - h2: GoogleFonts.inter(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), + h1: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary), + h2: GoogleFonts.inter( + 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), ), ); } @@ -473,26 +406,26 @@ class _ReactionPickerState extends State with SingleTickerProvid final reaction = reactions[index]; final count = reactionCounts[reaction] ?? 0; final isSelected = myReactions.contains(reaction); - + return Material( color: SojornColors.transparent, child: InkWell( onTap: () { Navigator.of(context).pop(); - final result = reaction.startsWith('assets/') - ? 'asset:$reaction' - : reaction; + // CDN URLs and emoji are passed as-is; local assets get 'asset:' prefix + final result = + reaction.startsWith('assets/') ? 'asset:$reaction' : reaction; widget.onReactionSelected(result); }, borderRadius: BorderRadius.circular(12), child: Container( decoration: BoxDecoration( - color: isSelected + color: isSelected ? AppTheme.brightNavy.withValues(alpha: 0.2) : AppTheme.navyBlue.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(12), border: Border.all( - color: isSelected + color: isSelected ? AppTheme.brightNavy : AppTheme.navyBlue.withValues(alpha: 0.1), width: isSelected ? 2 : 1, @@ -510,7 +443,8 @@ class _ReactionPickerState extends State with SingleTickerProvid right: 2, bottom: 2, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + padding: const EdgeInsets.symmetric( + horizontal: 4, vertical: 1), decoration: BoxDecoration( color: AppTheme.brightNavy, borderRadius: BorderRadius.circular(8), @@ -535,13 +469,27 @@ class _ReactionPickerState extends State with SingleTickerProvid } Widget _buildEmojiReaction(String emoji) { - return Text( - emoji, - style: const TextStyle(fontSize: 24), - ); + return Text(emoji, style: const TextStyle(fontSize: 24)); } 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:') ? reaction.replaceFirst('asset:', '') : reaction; @@ -560,27 +508,23 @@ class _ReactionPickerState extends State with SingleTickerProvid color: AppTheme.textSecondary, ), ), - errorBuilder: (context, error, stackTrace) { - return Icon( - Icons.image_not_supported, - size: 24, - color: AppTheme.textSecondary, - ); - }, + errorBuilder: (context, error, stackTrace) => Icon( + Icons.image_not_supported, + size: 24, + color: AppTheme.textSecondary, + ), ); } - + return Image.asset( imagePath, width: 32, height: 32, - errorBuilder: (context, error, stackTrace) { - return Icon( - Icons.image_not_supported, - size: 24, - color: AppTheme.textSecondary, - ); - }, + errorBuilder: (context, error, stackTrace) => Icon( + Icons.image_not_supported, + size: 24, + color: AppTheme.textSecondary, + ), ); } } diff --git a/sojorn_app/lib/widgets/reactions/reactions_display.dart b/sojorn_app/lib/widgets/reactions/reactions_display.dart index 8a5a88f..d306a75 100644 --- a/sojorn_app/lib/widgets/reactions/reactions_display.dart +++ b/sojorn_app/lib/widgets/reactions/reactions_display.dart @@ -1,3 +1,4 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -245,17 +246,27 @@ class _ReactionIcon extends StatelessWidget { @override 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:')) { final assetPath = reactionId.startsWith('asset:') ? reactionId.replaceFirst('asset:', '') : reactionId; if (assetPath.endsWith('.svg')) { - return SvgPicture.asset( - assetPath, - width: size, - height: size, - ); + return SvgPicture.asset(assetPath, width: size, height: size); } return Image.asset( assetPath, @@ -264,9 +275,8 @@ class _ReactionIcon extends StatelessWidget { fit: BoxFit.contain, ); } - return Text( - reactionId, - style: TextStyle(fontSize: size), - ); + + // Emoji + return Text(reactionId, style: TextStyle(fontSize: size)); } } diff --git a/sojorn_app/lib/widgets/traditional_quips_sheet.dart b/sojorn_app/lib/widgets/traditional_quips_sheet.dart index 42cd386..ece8f74 100644 --- a/sojorn_app/lib/widgets/traditional_quips_sheet.dart +++ b/sojorn_app/lib/widgets/traditional_quips_sheet.dart @@ -26,12 +26,16 @@ class TraditionalQuipsSheet extends ConsumerStatefulWidget { final String postId; final int initialQuipCount; 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({ super.key, required this.postId, this.initialQuipCount = 0, this.onQuipPosted, + this.showNavActions = true, }); @override @@ -300,8 +304,8 @@ class _TraditionalQuipsSheetState extends ConsumerState { borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), border: Border( bottom: BorderSide( - color: AppTheme.egyptianBlue.withValues(alpha: 0.1), - width: 1 + color: AppTheme.egyptianBlue.withValues(alpha: 0.1), + width: 1, ), ), ), @@ -321,44 +325,7 @@ class _TraditionalQuipsSheetState extends ConsumerState { padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), child: Row( children: [ - 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 ...[ + if (_isSelectionMode) ...[ IconButton( onPressed: () => setState(() { _isSelectionMode = false; @@ -380,7 +347,64 @@ class _TraditionalQuipsSheetState extends ConsumerState { icon: const Icon(Icons.delete_outline, color: SojornColors.destructive), 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), + ), + ], ], ), ), diff --git a/sojorn_app/lib/widgets/video_comments_sheet.dart b/sojorn_app/lib/widgets/video_comments_sheet.dart index 742b384..50f1174 100644 --- a/sojorn_app/lib/widgets/video_comments_sheet.dart +++ b/sojorn_app/lib/widgets/video_comments_sheet.dart @@ -12,12 +12,15 @@ class VideoCommentsSheet extends StatefulWidget { final String postId; final int initialCommentCount; final VoidCallback? onCommentPosted; + /// Set to false for Quips feed (hides Home/Chat/Search nav icons in header) + final bool showNavActions; const VideoCommentsSheet({ super.key, required this.postId, this.initialCommentCount = 0, this.onCommentPosted, + this.showNavActions = true, }); @override @@ -31,6 +34,7 @@ class _VideoCommentsSheetState extends State { postId: widget.postId, initialQuipCount: widget.initialCommentCount, onQuipPosted: widget.onCommentPosted, + showNavActions: widget.showNavActions, ); } }