mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: redesign grid post card with dedicated embed view, and post card footer

+1266 -683
+6 -3
docs/specs/ui-refactor.md
··· 63 63 `onSurfaceVariant` 64 64 - Body text: `bodySmall`, `line-clamp: 2` (via `maxLines: 2, overflow: ellipsis`) 65 65 - Action bar: move inside a top-bordered footer area 66 - (`border-t outlineVariant`). Icons only (chat, repeat, favorite) in a left-aligned 67 - row. Timestamp right-aligned in the same row 66 + (`border-t outlineVariant`). Icons only (chat, repeat, favorite, save) in a 67 + left-aligned row. Timestamp right-aligned in the same row 68 + - Save icon: bookmark icon that opens local/cloud save options on tap (same 69 + behaviour as the previous action bar). Active state: amber for local-only saves, 70 + `primary` for cloud saves 68 71 - Embed images: keep existing grid logic, but use square aspect ratio in grid view 69 72 70 73 ## Post Card — Grid Layout ··· 79 82 2. **Content region** (padding `16`): 80 83 - Author row: `5×5` square avatar + handle (same style as linear) 81 84 - Body text: `bodySmall`, `maxLines: 2`, ellipsis 82 - - Footer: top-bordered, icons left, relative timestamp right 85 + - Footer: top-bordered, icons left (chat, repeat, favorite, save), relative timestamp right 83 86 84 87 Text-only variant (no image): content region expands to fill the card with 85 88 larger body text (`titleMedium`, `tracking: tight`). Secondary text below in
+6 -6
docs/tasks/ui-refactor.md
··· 21 21 22 22 ## M2 — Post Card Variants 23 23 24 - - [ ] Refactor `PostCard` to the linear variant: square avatars, uppercase handle, bordered footer 25 - - [ ] New `GridPostCard` widget — image region, content region, footer 26 - - [ ] Text-only grid card variant (no image — expanded body text) 27 - - [ ] Shared `PostCardFooter` widget (action icons left, timestamp right, top border) 28 - - [ ] Wire both variants to `PostCardWithActions` for action state management 29 - - [ ] Tests for both card variants (golden or widget tests) 24 + - [x] Refactor `PostCard` to the linear variant: square avatars, uppercase handle, bordered footer 25 + - [x] New `GridPostCard` widget — image region, content region, footer 26 + - [x] Text-only grid card variant (no image — expanded body text) 27 + - [x] Shared `PostCardFooter` widget (action icons left, timestamp right, top border) 28 + - [x] Wire both variants to `PostCardWithActions` for action state management 29 + - [x] Tests for both card variants 30 30 31 31 ## M3 — Home Feed Grid Layout 32 32
+19 -16
lib/features/feed/presentation/post_thread_screen.dart
··· 186 186 187 187 return PostCard( 188 188 feedViewPost: FeedViewPost(post: post), 189 - actionBar: Column( 190 - crossAxisAlignment: CrossAxisAlignment.start, 191 - children: [ 192 - const SizedBox(height: 4), 193 - Text( 194 - _formatTimestamp(timestamp), 195 - style: Theme.of( 196 - context, 197 - ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 198 - ), 199 - const Divider(), 200 - _buildStats(context, post), 201 - const Divider(height: 1), 202 - const SizedBox(height: 4), 203 - _buildActionBar(context, post), 204 - ], 189 + actionBar: Padding( 190 + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), 191 + child: Column( 192 + crossAxisAlignment: CrossAxisAlignment.start, 193 + children: [ 194 + const SizedBox(height: 4), 195 + Text( 196 + _formatTimestamp(timestamp), 197 + style: Theme.of( 198 + context, 199 + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 200 + ), 201 + const Divider(), 202 + _buildStats(context, post), 203 + const Divider(height: 1), 204 + const SizedBox(height: 4), 205 + _buildActionBar(context, post), 206 + ], 207 + ), 205 208 ), 206 209 ); 207 210 }
+217
lib/features/feed/presentation/widgets/grid_post_card.dart
··· 1 + import 'package:bluesky/app_bsky_actor_defs.dart'; 2 + import 'package:bluesky/app_bsky_embed_recordwithmedia.dart'; 3 + import 'package:bluesky/app_bsky_feed_defs.dart'; 4 + import 'package:bluesky/app_bsky_feed_post.dart'; 5 + import 'package:flutter/material.dart'; 6 + import 'package:go_router/go_router.dart'; 7 + import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 8 + import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; 9 + import 'package:lazurite/features/feed/presentation/widgets/post_embed_view.dart'; 10 + 11 + const _greyscale = ColorFilter.matrix(<double>[ 12 + 0.2126, 13 + 0.7152, 14 + 0.0722, 15 + 0, 16 + 0, 17 + 0.2126, 18 + 0.7152, 19 + 0.0722, 20 + 0, 21 + 0, 22 + 0.2126, 23 + 0.7152, 24 + 0.0722, 25 + 0, 26 + 0, 27 + 0, 28 + 0, 29 + 0, 30 + 1, 31 + 0, 32 + ]); 33 + 34 + /// Grid layout post card. 35 + /// 36 + /// Image embeds are shown in a square greyscale region at the top. 37 + /// All other embed types (external links, videos, quoted records) are rendered 38 + /// via [PostEmbedView] in the content area below the body text. 39 + /// 40 + /// Text-only posts (no images) use expanded [titleMedium] body text. 41 + class GridPostCard extends StatelessWidget { 42 + const GridPostCard({super.key, required this.feedViewPost, this.footer, this.onTap}); 43 + 44 + final FeedViewPost feedViewPost; 45 + 46 + /// Optional footer widget. Defaults to a read-only [PostCardFooter] when null. 47 + final Widget? footer; 48 + final VoidCallback? onTap; 49 + 50 + @override 51 + Widget build(BuildContext context) { 52 + final post = feedViewPost.post; 53 + final record = _tryParseRecord(post.record); 54 + final primaryImageUrl = _extractPrimaryImageUrl(post.embed); 55 + final bodyText = record?.text ?? ''; 56 + final colorScheme = Theme.of(context).colorScheme; 57 + 58 + // Non-image embeds rendered in the content area 59 + final contentEmbed = primaryImageUrl == null && post.embed != null 60 + ? PostEmbedView(feedViewPost: feedViewPost, embed: post.embed!) 61 + : null; 62 + 63 + final resolvedFooter = footer ?? PostCardFooter(timestamp: formatPostTime(record?.createdAt ?? post.indexedAt)); 64 + 65 + return Container( 66 + decoration: BoxDecoration( 67 + border: Border.all(color: colorScheme.outlineVariant), 68 + color: colorScheme.surfaceContainerLowest, 69 + ), 70 + child: InkWell( 71 + onTap: onTap, 72 + child: Column( 73 + crossAxisAlignment: CrossAxisAlignment.start, 74 + children: [ 75 + if (primaryImageUrl != null) 76 + AspectRatio( 77 + aspectRatio: 1.0, 78 + child: ColorFiltered( 79 + colorFilter: _greyscale, 80 + child: Image.network( 81 + primaryImageUrl, 82 + fit: BoxFit.cover, 83 + width: double.infinity, 84 + errorBuilder: (_, _, _) => 85 + ColoredBox(color: colorScheme.surfaceContainerHigh, child: const SizedBox.expand()), 86 + ), 87 + ), 88 + ), 89 + Padding( 90 + padding: const EdgeInsets.all(16), 91 + child: Column( 92 + crossAxisAlignment: CrossAxisAlignment.start, 93 + children: [ 94 + _buildAuthorRow(context, post.author), 95 + if (bodyText.isNotEmpty) ...[ 96 + const SizedBox(height: 8), 97 + if (primaryImageUrl == null && contentEmbed == null) 98 + // Text-only: larger, tighter body text 99 + FacetText( 100 + text: bodyText, 101 + facets: record?.facets, 102 + style: Theme.of(context).textTheme.titleMedium?.copyWith(letterSpacing: -0.5), 103 + maxLines: 6, 104 + overflow: TextOverflow.ellipsis, 105 + ) 106 + else 107 + FacetText( 108 + text: bodyText, 109 + facets: record?.facets, 110 + style: Theme.of(context).textTheme.bodySmall, 111 + maxLines: 2, 112 + overflow: TextOverflow.ellipsis, 113 + ), 114 + ], 115 + if (contentEmbed != null) ...[const SizedBox(height: 8), contentEmbed], 116 + ], 117 + ), 118 + ), 119 + resolvedFooter, 120 + ], 121 + ), 122 + ), 123 + ); 124 + } 125 + 126 + Widget _buildAuthorRow(BuildContext context, ProfileViewBasic author) { 127 + final colorScheme = Theme.of(context).colorScheme; 128 + return Row( 129 + children: [ 130 + GestureDetector( 131 + key: const ValueKey('grid_post_card_avatar'), 132 + onTap: () => GoRouter.maybeOf(context)?.push('/profile/view?actor=${Uri.encodeQueryComponent(author.did)}'), 133 + child: Container( 134 + width: 40, 135 + height: 40, 136 + decoration: BoxDecoration( 137 + color: colorScheme.surfaceContainerHighest, 138 + border: Border.all(color: colorScheme.outlineVariant), 139 + ), 140 + child: author.avatar != null 141 + ? Image.network(author.avatar!, fit: BoxFit.cover) 142 + : Center( 143 + child: Text( 144 + _initials(author.displayName ?? author.handle), 145 + style: Theme.of(context).textTheme.labelMedium, 146 + ), 147 + ), 148 + ), 149 + ), 150 + const SizedBox(width: 8), 151 + Expanded( 152 + child: Column( 153 + crossAxisAlignment: CrossAxisAlignment.start, 154 + children: [ 155 + if (author.displayName != null && author.displayName!.isNotEmpty) 156 + Text( 157 + author.displayName!, 158 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700), 159 + maxLines: 1, 160 + overflow: TextOverflow.ellipsis, 161 + ), 162 + Text( 163 + '@${author.handle}'.toUpperCase(), 164 + style: Theme.of(context).textTheme.labelSmall?.copyWith( 165 + fontWeight: FontWeight.w700, 166 + letterSpacing: 1.5, 167 + color: colorScheme.onSurfaceVariant, 168 + ), 169 + maxLines: 1, 170 + overflow: TextOverflow.ellipsis, 171 + ), 172 + ], 173 + ), 174 + ), 175 + ], 176 + ); 177 + } 178 + 179 + /// Returns the URL for the square image region when the post has image/video embeds. 180 + /// Returns null for external links and quoted records (rendered via [PostEmbedView]). 181 + String? _extractPrimaryImageUrl(UPostViewEmbed? embed) { 182 + if (embed == null) return null; 183 + if (embed.isEmbedImagesView) { 184 + final images = embed.embedImagesView!.images; 185 + return images.isNotEmpty ? images.first.thumb : null; 186 + } 187 + if (embed.isEmbedVideoView) { 188 + return embed.embedVideoView!.thumbnail; 189 + } 190 + if (embed.isEmbedRecordWithMediaView) { 191 + final media = embed.embedRecordWithMediaView!.media; 192 + if (media.isEmbedImagesView) { 193 + final images = media.embedImagesView!.images; 194 + return images.isNotEmpty ? images.first.thumb : null; 195 + } 196 + if (media.isEmbedVideoView) { 197 + return media.embedVideoView!.thumbnail; 198 + } 199 + } 200 + return null; 201 + } 202 + 203 + FeedPostRecord? _tryParseRecord(Map<String, dynamic> record) { 204 + try { 205 + return FeedPostRecord.fromJson(record); 206 + } catch (_) { 207 + return null; 208 + } 209 + } 210 + 211 + String _initials(String value) { 212 + final parts = value.trim().split(RegExp(r'\s+')); 213 + if (parts.isEmpty || parts.first.isEmpty) return '?'; 214 + if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase(); 215 + return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 216 + } 217 + }
+59 -500
lib/features/feed/presentation/widgets/post_card.dart
··· 1 1 import 'package:bluesky/app_bsky_actor_defs.dart'; 2 - import 'package:bluesky/app_bsky_embed_external.dart'; 3 - import 'package:bluesky/app_bsky_embed_images.dart'; 4 - import 'package:bluesky/app_bsky_embed_record.dart'; 5 - import 'package:bluesky/app_bsky_embed_recordwithmedia.dart'; 6 - import 'package:bluesky/app_bsky_embed_video.dart'; 7 2 import 'package:bluesky/app_bsky_feed_defs.dart'; 8 3 import 'package:bluesky/app_bsky_feed_post.dart'; 9 4 import 'package:flutter/material.dart'; 10 5 import 'package:go_router/go_router.dart'; 11 - import 'package:intl/intl.dart'; 12 - import 'package:lazurite/features/feed/presentation/media/media_actions.dart'; 13 - import 'package:lazurite/features/feed/presentation/media/image_viewer_route_args.dart'; 14 - import 'package:lazurite/features/feed/presentation/media/video_player_route_args.dart'; 15 6 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 16 - import 'package:url_launcher/url_launcher.dart'; 7 + import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; 8 + import 'package:lazurite/features/feed/presentation/widgets/post_embed_view.dart'; 17 9 18 10 class PostCard extends StatelessWidget { 19 11 const PostCard({super.key, required this.feedViewPost, this.actionBar, this.onTap}); 20 12 21 13 final FeedViewPost feedViewPost; 14 + 15 + /// Optional footer/action area. Defaults to a read-only [PostCardFooter] 16 + /// showing the post timestamp when null. 17 + /// 18 + /// The widget is rendered directly (no additional padding is applied by 19 + /// [PostCard]). [PostCardFooter] manages its own padding; custom widgets 20 + /// should do the same. 22 21 final Widget? actionBar; 23 22 final VoidCallback? onTap; 24 23 ··· 26 25 Widget build(BuildContext context) { 27 26 final post = feedViewPost.post; 28 27 final record = _tryParseRecord(post.record); 29 - final embed = _buildEmbed(context, post.embed); 28 + final colorScheme = Theme.of(context).colorScheme; 29 + 30 + final resolvedFooter = actionBar ?? PostCardFooter(timestamp: formatPostTime(record?.createdAt ?? post.indexedAt)); 30 31 31 - return Card( 32 - margin: const EdgeInsets.symmetric(horizontal: 0, vertical: 1), 33 - elevation: 0, 34 - shape: const RoundedRectangleBorder(), 32 + return Container( 33 + margin: const EdgeInsets.symmetric(vertical: 1), 34 + decoration: BoxDecoration( 35 + border: Border.all(color: colorScheme.outlineVariant), 36 + color: colorScheme.surfaceContainerLowest, 37 + ), 35 38 child: Column( 36 39 crossAxisAlignment: CrossAxisAlignment.start, 37 40 children: [ ··· 42 45 child: Column( 43 46 crossAxisAlignment: CrossAxisAlignment.start, 44 47 children: [ 45 - _buildHeader(context, post.author, record?.createdAt ?? post.indexedAt), 48 + _buildHeader(context, post.author), 46 49 if (record?.reply != null) ...[const SizedBox(height: 8), _buildReplyLabel(context)], 47 50 if (record != null && record.text.isNotEmpty) ...[ 48 51 const SizedBox(height: 12), 49 - FacetText(text: record.text, facets: record.facets, style: Theme.of(context).textTheme.bodyLarge), 52 + FacetText( 53 + text: record.text, 54 + facets: record.facets, 55 + style: Theme.of(context).textTheme.bodySmall, 56 + maxLines: 2, 57 + overflow: TextOverflow.ellipsis, 58 + ), 50 59 ], 51 - if (embed != null) ...[const SizedBox(height: 12), embed], 60 + if (post.embed != null) ...[ 61 + const SizedBox(height: 12), 62 + PostEmbedView(feedViewPost: feedViewPost, embed: post.embed!), 63 + ], 52 64 const SizedBox(height: 12), 53 65 ], 54 66 ), 55 67 ), 56 68 ), 57 - Padding(padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: actionBar ?? _buildActions(context)), 69 + resolvedFooter, 58 70 ], 59 71 ), 60 72 ); 61 73 } 62 74 63 - Widget _buildHeader(BuildContext context, ProfileViewBasic author, DateTime createdAt) { 75 + Widget _buildHeader(BuildContext context, ProfileViewBasic author) { 76 + final colorScheme = Theme.of(context).colorScheme; 64 77 return Row( 65 78 crossAxisAlignment: CrossAxisAlignment.start, 66 79 children: [ 67 80 GestureDetector( 81 + key: const ValueKey('post_card_avatar'), 68 82 onTap: () => GoRouter.maybeOf(context)?.push('/profile/view?actor=${Uri.encodeQueryComponent(author.did)}'), 69 - child: CircleAvatar( 70 - radius: 22, 71 - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, 72 - backgroundImage: author.avatar != null ? NetworkImage(author.avatar!) : null, 73 - child: author.avatar == null 74 - ? Text(_initials(author.displayName ?? author.handle), style: Theme.of(context).textTheme.labelLarge) 75 - : null, 83 + child: Container( 84 + width: 40, 85 + height: 40, 86 + decoration: BoxDecoration( 87 + color: colorScheme.surfaceContainerHighest, 88 + border: Border.all(color: colorScheme.outlineVariant), 89 + ), 90 + child: author.avatar != null 91 + ? Image.network(author.avatar!, fit: BoxFit.cover) 92 + : Center( 93 + child: Text( 94 + _initials(author.displayName ?? author.handle), 95 + style: Theme.of(context).textTheme.labelLarge, 96 + ), 97 + ), 76 98 ), 77 99 ), 78 100 const SizedBox(width: 12), ··· 88 110 ), 89 111 const SizedBox(height: 2), 90 112 Text( 91 - '@${author.handle} · ${_formatTime(createdAt)}', 92 - style: Theme.of( 93 - context, 94 - ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 113 + '@${author.handle}'.toUpperCase(), 114 + style: Theme.of(context).textTheme.labelSmall?.copyWith( 115 + color: colorScheme.onSurfaceVariant, 116 + fontWeight: FontWeight.w700, 117 + letterSpacing: 1.5, 118 + ), 119 + maxLines: 1, 120 + overflow: TextOverflow.ellipsis, 95 121 ), 96 122 ], 97 123 ), ··· 113 139 ); 114 140 } 115 141 116 - Widget _buildActions(BuildContext context) { 117 - final post = feedViewPost.post; 118 - 119 - return Row( 120 - mainAxisAlignment: MainAxisAlignment.spaceAround, 121 - children: [ 122 - _buildActionButton(context, Icons.chat_bubble_outline, '${post.replyCount ?? 0}'), 123 - _buildActionButton(context, Icons.repeat, '${post.repostCount ?? 0}'), 124 - _buildActionButton(context, Icons.favorite_border, '${post.likeCount ?? 0}'), 125 - _buildActionButton(context, Icons.share_outlined, ''), 126 - ], 127 - ); 128 - } 129 - 130 - Widget _buildActionButton(BuildContext context, IconData icon, String count) { 131 - final iconColor = Theme.of(context).colorScheme.onSurfaceVariant; 132 - 133 - return InkWell( 134 - onTap: () {}, 135 - borderRadius: BorderRadius.circular(999), 136 - child: Padding( 137 - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 138 - child: Row( 139 - children: [ 140 - Icon(icon, size: 18, color: iconColor), 141 - if (count.isNotEmpty) ...[ 142 - const SizedBox(width: 4), 143 - Text(count, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: iconColor)), 144 - ], 145 - ], 146 - ), 147 - ), 148 - ); 149 - } 150 - 151 - Widget? _buildEmbed(BuildContext context, UPostViewEmbed? embed) { 152 - if (embed == null) { 153 - return null; 154 - } 155 - 156 - if (embed.isEmbedImagesView) { 157 - return _buildImagesEmbed(context, embed.embedImagesView!.images); 158 - } 159 - 160 - if (embed.isEmbedExternalView) { 161 - return _buildExternalEmbed(context, embed.embedExternalView!.external); 162 - } 163 - 164 - if (embed.isEmbedRecordView) { 165 - return _buildQuotedRecord(context, embed.embedRecordView!); 166 - } 167 - 168 - if (embed.isEmbedVideoView) { 169 - return _buildVideoEmbed(context, embed.embedVideoView!); 170 - } 171 - 172 - if (embed.isEmbedRecordWithMediaView) { 173 - final recordWithMedia = embed.embedRecordWithMediaView!; 174 - return Column( 175 - crossAxisAlignment: CrossAxisAlignment.start, 176 - children: [ 177 - _buildRecordWithMediaMedia(context, recordWithMedia.media), 178 - const SizedBox(height: 8), 179 - _buildQuotedRecord(context, recordWithMedia.record), 180 - ], 181 - ); 182 - } 183 - 184 - return null; 185 - } 186 - 187 - Widget _buildRecordWithMediaMedia(BuildContext context, UEmbedRecordWithMediaViewMedia media) { 188 - if (media.isEmbedImagesView) { 189 - return _buildImagesEmbed(context, media.embedImagesView!.images); 190 - } 191 - 192 - if (media.isEmbedExternalView) { 193 - return _buildExternalEmbed(context, media.embedExternalView!.external); 194 - } 195 - 196 - if (media.isEmbedVideoView) { 197 - return _buildVideoEmbed(context, media.embedVideoView!); 198 - } 199 - 200 - return const SizedBox.shrink(); 201 - } 202 - 203 - Widget _buildImagesEmbed(BuildContext context, List<EmbedImagesViewImage> images) { 204 - final crossAxisCount = images.length == 1 ? 1 : 2; 205 - final childAspectRatio = images.length == 1 ? 16 / 9 : 1.0; 206 - final postUri = feedViewPost.post.uri.toString(); 207 - 208 - return ClipRRect( 209 - borderRadius: BorderRadius.circular(16), 210 - child: GridView.builder( 211 - shrinkWrap: true, 212 - physics: const NeverScrollableScrollPhysics(), 213 - itemCount: images.length, 214 - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( 215 - crossAxisCount: crossAxisCount, 216 - crossAxisSpacing: 2, 217 - mainAxisSpacing: 2, 218 - childAspectRatio: childAspectRatio, 219 - ), 220 - itemBuilder: (context, index) { 221 - final image = images[index]; 222 - final heroTag = _imageHeroTag(postUri, index); 223 - 224 - return GestureDetector( 225 - onLongPressStart: (details) => _showImageContextMenu(context, details.globalPosition, image: image), 226 - child: InkWell( 227 - onTap: () => _openImageViewer(context, images, initialIndex: index), 228 - child: Hero( 229 - tag: heroTag, 230 - child: Image.network( 231 - image.thumb, 232 - fit: BoxFit.cover, 233 - errorBuilder: (_, _, _) => ColoredBox( 234 - color: Theme.of(context).colorScheme.surfaceContainerHighest, 235 - child: const Center(child: Icon(Icons.image_not_supported_outlined)), 236 - ), 237 - ), 238 - ), 239 - ), 240 - ); 241 - }, 242 - ), 243 - ); 244 - } 245 - 246 - Widget _buildExternalEmbed(BuildContext context, EmbedExternalViewExternal external) { 247 - return InkWell( 248 - onTap: () => _launchExternal(Uri.parse(external.uri)), 249 - borderRadius: BorderRadius.circular(16), 250 - child: Container( 251 - decoration: BoxDecoration( 252 - border: Border.all(color: Theme.of(context).dividerColor), 253 - borderRadius: BorderRadius.circular(16), 254 - ), 255 - child: Column( 256 - crossAxisAlignment: CrossAxisAlignment.start, 257 - children: [ 258 - if (external.thumb != null) 259 - ClipRRect( 260 - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), 261 - child: Image.network( 262 - external.thumb!, 263 - height: 160, 264 - width: double.infinity, 265 - fit: BoxFit.cover, 266 - errorBuilder: (_, _, _) => const SizedBox(height: 0), 267 - ), 268 - ), 269 - Padding( 270 - padding: const EdgeInsets.all(12), 271 - child: Column( 272 - crossAxisAlignment: CrossAxisAlignment.start, 273 - children: [ 274 - Text( 275 - external.title, 276 - style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), 277 - ), 278 - if (external.description.isNotEmpty) ...[ 279 - const SizedBox(height: 4), 280 - Text( 281 - external.description, 282 - maxLines: 3, 283 - overflow: TextOverflow.ellipsis, 284 - style: Theme.of(context).textTheme.bodyMedium, 285 - ), 286 - ], 287 - const SizedBox(height: 8), 288 - Text( 289 - Uri.parse(external.uri).host, 290 - style: Theme.of( 291 - context, 292 - ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 293 - ), 294 - ], 295 - ), 296 - ), 297 - ], 298 - ), 299 - ), 300 - ); 301 - } 302 - 303 - Widget _buildVideoEmbed(BuildContext context, EmbedVideoView video) { 304 - return InkWell( 305 - onTap: () => _openVideoViewer(context, video), 306 - borderRadius: BorderRadius.circular(16), 307 - child: ClipRRect( 308 - borderRadius: BorderRadius.circular(16), 309 - child: Stack( 310 - alignment: Alignment.center, 311 - children: [ 312 - AspectRatio( 313 - aspectRatio: video.aspectRatio == null ? 16 / 9 : video.aspectRatio!.width / video.aspectRatio!.height, 314 - child: video.thumbnail == null 315 - ? ColoredBox( 316 - color: Theme.of(context).colorScheme.surfaceContainerHighest, 317 - child: const SizedBox.expand(), 318 - ) 319 - : Image.network( 320 - video.thumbnail!, 321 - fit: BoxFit.cover, 322 - errorBuilder: (_, _, _) => ColoredBox( 323 - color: Theme.of(context).colorScheme.surfaceContainerHighest, 324 - child: const SizedBox.expand(), 325 - ), 326 - ), 327 - ), 328 - Container( 329 - width: 56, 330 - height: 56, 331 - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.65), shape: BoxShape.circle), 332 - child: const Icon(Icons.play_arrow, color: Colors.white, size: 28), 333 - ), 334 - if (video.alt?.isNotEmpty ?? false) 335 - Positioned( 336 - left: 12, 337 - right: 12, 338 - bottom: 12, 339 - child: Text( 340 - video.alt!, 341 - maxLines: 2, 342 - overflow: TextOverflow.ellipsis, 343 - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white), 344 - ), 345 - ), 346 - ], 347 - ), 348 - ), 349 - ); 350 - } 351 - 352 - Widget _buildQuotedRecord(BuildContext context, EmbedRecordView recordView) { 353 - final record = recordView.record; 354 - 355 - if (record.isEmbedRecordViewRecord) { 356 - final quoted = record.embedRecordViewRecord!; 357 - final quotedRecord = _tryParseRecord(quoted.value); 358 - final nestedEmbed = _buildQuotedEmbeds(context, quoted.embeds); 359 - 360 - return Container( 361 - decoration: BoxDecoration( 362 - border: Border.all(color: Theme.of(context).dividerColor), 363 - borderRadius: BorderRadius.circular(16), 364 - ), 365 - child: InkWell( 366 - onTap: () { 367 - final router = GoRouter.maybeOf(context); 368 - if (router != null) { 369 - router.push('/post?uri=${Uri.encodeComponent(quoted.uri.toString())}'); 370 - } 371 - }, 372 - borderRadius: BorderRadius.circular(16), 373 - child: Padding( 374 - padding: const EdgeInsets.all(12), 375 - child: Column( 376 - crossAxisAlignment: CrossAxisAlignment.start, 377 - children: [ 378 - Row( 379 - children: [ 380 - CircleAvatar( 381 - radius: 14, 382 - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, 383 - backgroundImage: quoted.author.avatar != null ? NetworkImage(quoted.author.avatar!) : null, 384 - child: quoted.author.avatar == null 385 - ? Text(_initials(quoted.author.displayName ?? quoted.author.handle)) 386 - : null, 387 - ), 388 - const SizedBox(width: 8), 389 - Expanded( 390 - child: Text( 391 - '${quoted.author.displayName ?? quoted.author.handle} @${quoted.author.handle}', 392 - maxLines: 1, 393 - overflow: TextOverflow.ellipsis, 394 - style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), 395 - ), 396 - ), 397 - ], 398 - ), 399 - if (quotedRecord != null && quotedRecord.text.isNotEmpty) ...[ 400 - const SizedBox(height: 8), 401 - FacetText( 402 - text: quotedRecord.text, 403 - facets: quotedRecord.facets, 404 - style: Theme.of(context).textTheme.bodyMedium, 405 - maxLines: 6, 406 - overflow: TextOverflow.ellipsis, 407 - ), 408 - ], 409 - if (nestedEmbed != null) ...[const SizedBox(height: 8), nestedEmbed], 410 - ], 411 - ), 412 - ), 413 - ), 414 - ); 415 - } 416 - 417 - if (record.isEmbedRecordViewNotFound) { 418 - return _buildUnavailableQuote(context, 'Quoted post not found'); 419 - } 420 - 421 - if (record.isEmbedRecordViewBlocked) { 422 - return _buildUnavailableQuote(context, 'Quoted post is blocked'); 423 - } 424 - 425 - if (record.isEmbedRecordViewDetached) { 426 - return _buildUnavailableQuote(context, 'Quoted post is unavailable'); 427 - } 428 - 429 - return const SizedBox.shrink(); 430 - } 431 - 432 - Widget _buildUnavailableQuote(BuildContext context, String label) { 433 - return Container( 434 - width: double.infinity, 435 - padding: const EdgeInsets.all(12), 436 - decoration: BoxDecoration( 437 - color: Theme.of(context).colorScheme.surfaceContainerHighest, 438 - borderRadius: BorderRadius.circular(16), 439 - ), 440 - child: Text( 441 - label, 442 - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 443 - ), 444 - ); 445 - } 446 - 447 - Widget? _buildQuotedEmbeds(BuildContext context, List<UEmbedRecordViewRecordEmbeds>? embeds) { 448 - if (embeds == null || embeds.isEmpty) { 449 - return null; 450 - } 451 - 452 - final embed = embeds.first; 453 - 454 - if (embed.isEmbedImagesView) { 455 - return _buildImagesEmbed(context, embed.embedImagesView!.images); 456 - } 457 - 458 - if (embed.isEmbedExternalView) { 459 - return _buildExternalEmbed(context, embed.embedExternalView!.external); 460 - } 461 - 462 - if (embed.isEmbedVideoView) { 463 - return _buildVideoEmbed(context, embed.embedVideoView!); 464 - } 465 - 466 - if (embed.isEmbedRecordWithMediaView) { 467 - final recordWithMedia = embed.embedRecordWithMediaView!; 468 - return Column( 469 - crossAxisAlignment: CrossAxisAlignment.start, 470 - children: [ 471 - _buildRecordWithMediaMedia(context, recordWithMedia.media), 472 - const SizedBox(height: 8), 473 - _buildQuotedRecord(context, recordWithMedia.record), 474 - ], 475 - ); 476 - } 477 - 478 - return null; 479 - } 480 - 481 142 FeedPostRecord? _tryParseRecord(Map<String, dynamic> record) { 482 143 try { 483 144 return FeedPostRecord.fromJson(record); ··· 486 147 } 487 148 } 488 149 489 - String _formatTime(DateTime time) { 490 - final now = DateTime.now(); 491 - final difference = now.difference(time); 492 - 493 - if (difference.inMinutes < 1) { 494 - return 'now'; 495 - } 496 - 497 - if (difference.inHours < 1) { 498 - return '${difference.inMinutes}m'; 499 - } 500 - 501 - if (difference.inDays < 1) { 502 - return '${difference.inHours}h'; 503 - } 504 - 505 - if (difference.inDays < 7) { 506 - return '${difference.inDays}d'; 507 - } 508 - 509 - return DateFormat('MMM d').format(time); 510 - } 511 - 512 150 String _initials(String value) { 513 151 final parts = value.trim().split(RegExp(r'\s+')); 514 - if (parts.isEmpty || parts.first.isEmpty) { 515 - return '?'; 516 - } 517 - 518 - if (parts.length == 1) { 519 - return parts.first.substring(0, 1).toUpperCase(); 520 - } 521 - 152 + if (parts.isEmpty || parts.first.isEmpty) return '?'; 153 + if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase(); 522 154 return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 523 155 } 524 - 525 - void _openImageViewer(BuildContext context, List<EmbedImagesViewImage> images, {required int initialIndex}) { 526 - GoRouter.maybeOf(context)?.push( 527 - '/images', 528 - extra: ImageViewerRouteArgs( 529 - images: [ 530 - for (var i = 0; i < images.length; i++) 531 - ImageViewerItem( 532 - fullsizeUrl: images[i].fullsize, 533 - thumbnailUrl: images[i].thumb, 534 - altText: images[i].alt, 535 - heroTag: _imageHeroTag(feedViewPost.post.uri.toString(), i), 536 - ), 537 - ], 538 - initialIndex: initialIndex, 539 - ), 540 - ); 541 - } 542 - 543 - Future<void> _showImageContextMenu( 544 - BuildContext context, 545 - Offset globalPosition, { 546 - required EmbedImagesViewImage image, 547 - }) async { 548 - final selected = await showMenu<_ImageThumbnailAction>( 549 - context: context, 550 - position: RelativeRect.fromLTRB(globalPosition.dx, globalPosition.dy, globalPosition.dx, globalPosition.dy), 551 - items: const [ 552 - PopupMenuItem<_ImageThumbnailAction>(value: _ImageThumbnailAction.save, child: Text('Save image')), 553 - PopupMenuItem<_ImageThumbnailAction>(value: _ImageThumbnailAction.share, child: Text('Share')), 554 - ], 555 - ); 556 - 557 - if (!context.mounted || selected == null) { 558 - return; 559 - } 560 - 561 - switch (selected) { 562 - case _ImageThumbnailAction.save: 563 - await MediaActions.downloadImage(context, image.fullsize, suggestedName: _downloadFileName(image.fullsize)); 564 - case _ImageThumbnailAction.share: 565 - await MediaActions.shareImage(context, image.fullsize); 566 - } 567 - } 568 - 569 - String _imageHeroTag(String postUri, int index) => 'post-image-$postUri-$index'; 570 - 571 - String _downloadFileName(String url) { 572 - final uri = Uri.tryParse(url); 573 - final segment = uri?.pathSegments.isNotEmpty == true ? uri!.pathSegments.last : 'image.jpg'; 574 - return segment.isEmpty ? 'image.jpg' : segment; 575 - } 576 - 577 - void _openVideoViewer(BuildContext context, EmbedVideoView video) { 578 - final ratio = video.aspectRatio == null ? null : video.aspectRatio!.width / video.aspectRatio!.height; 579 - final isGif = video.presentation?.knownValue == KnownEmbedVideoViewPresentation.gif; 580 - GoRouter.maybeOf(context)?.push( 581 - '/video', 582 - extra: VideoPlayerRouteArgs( 583 - playlistUrl: video.playlist, 584 - thumbnailUrl: video.thumbnail, 585 - altText: video.alt, 586 - aspectRatio: ratio, 587 - isGif: isGif, 588 - ), 589 - ); 590 - } 591 156 } 592 - 593 - Future<void> _launchExternal(Uri url) async { 594 - await launchUrl(url, mode: LaunchMode.externalApplication); 595 - } 596 - 597 - enum _ImageThumbnailAction { save, share }
+206
lib/features/feed/presentation/widgets/post_card_footer.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter/services.dart'; 3 + import 'package:intl/intl.dart'; 4 + 5 + /// Formats a post timestamp as a short, uppercase string. 6 + String formatPostTime(DateTime time) { 7 + final now = DateTime.now(); 8 + final difference = now.difference(time); 9 + 10 + if (difference.inMinutes < 1) return 'NOW'; 11 + if (difference.inHours < 1) return '${difference.inMinutes}M'; 12 + if (difference.inDays < 1) return '${difference.inHours}H'; 13 + if (difference.inDays < 7) return '${difference.inDays}D'; 14 + return DateFormat('MMM d').format(time).toUpperCase(); 15 + } 16 + 17 + /// Shared footer for post cards. Renders a top-bordered row with 18 + /// action icons (reply, repost, like, save) on the left and a timestamp on the right. 19 + class PostCardFooter extends StatelessWidget { 20 + const PostCardFooter({ 21 + super.key, 22 + required this.timestamp, 23 + this.replyCount = 0, 24 + this.repostCount = 0, 25 + this.likeCount = 0, 26 + this.saveCount = 0, 27 + this.isLiked = false, 28 + this.isReposted = false, 29 + this.isSaved = false, 30 + this.saveType, 31 + this.isLoadingLike = false, 32 + this.isLoadingRepost = false, 33 + this.onReply, 34 + this.onRepost, 35 + this.onLike, 36 + this.onSave, 37 + this.onLongPressSave, 38 + this.onCloudSave, 39 + this.onCloudUnsave, 40 + }); 41 + 42 + final String timestamp; 43 + final int replyCount; 44 + final int repostCount; 45 + final int likeCount; 46 + final int saveCount; 47 + final bool isLiked; 48 + final bool isReposted; 49 + final bool isSaved; 50 + final String? saveType; 51 + final bool isLoadingLike; 52 + final bool isLoadingRepost; 53 + final VoidCallback? onReply; 54 + final VoidCallback? onRepost; 55 + final VoidCallback? onLike; 56 + final VoidCallback? onSave; 57 + final VoidCallback? onLongPressSave; 58 + final VoidCallback? onCloudSave; 59 + final VoidCallback? onCloudUnsave; 60 + 61 + @override 62 + Widget build(BuildContext context) { 63 + final colorScheme = Theme.of(context).colorScheme; 64 + final saveActiveColor = (saveType == 'cloud' || saveType == 'both') ? colorScheme.primary : Colors.amber; 65 + 66 + return Container( 67 + decoration: BoxDecoration( 68 + border: Border(top: BorderSide(color: colorScheme.outlineVariant)), 69 + ), 70 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 71 + child: Row( 72 + children: [ 73 + _FooterAction( 74 + icon: Icons.chat_bubble_outline, 75 + activeIcon: Icons.chat_bubble, 76 + isActive: false, 77 + isLoading: false, 78 + onTap: onReply, 79 + color: colorScheme.onSurfaceVariant, 80 + ), 81 + const SizedBox(width: 16), 82 + _FooterAction( 83 + icon: Icons.repeat, 84 + activeIcon: Icons.repeat, 85 + isActive: isReposted, 86 + isLoading: isLoadingRepost, 87 + onTap: onRepost, 88 + color: colorScheme.onSurfaceVariant, 89 + activeColor: Colors.green, 90 + ), 91 + const SizedBox(width: 16), 92 + _FooterAction( 93 + icon: Icons.favorite_outline, 94 + activeIcon: Icons.favorite, 95 + isActive: isLiked, 96 + isLoading: isLoadingLike, 97 + onTap: onLike, 98 + color: colorScheme.onSurfaceVariant, 99 + activeColor: Colors.pink, 100 + ), 101 + const SizedBox(width: 16), 102 + _FooterAction( 103 + icon: isSaved ? Icons.bookmark : Icons.bookmark_outline, 104 + activeIcon: Icons.bookmark, 105 + isActive: isSaved, 106 + isLoading: false, 107 + onTap: onSave != null ? () => _showSaveOptions(context) : null, 108 + onLongPress: onLongPressSave, 109 + color: colorScheme.onSurfaceVariant, 110 + activeColor: saveActiveColor, 111 + ), 112 + const Spacer(), 113 + Text( 114 + timestamp, 115 + style: Theme.of( 116 + context, 117 + ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 10, letterSpacing: 1.0), 118 + ), 119 + ], 120 + ), 121 + ); 122 + } 123 + 124 + void _showSaveOptions(BuildContext context) { 125 + HapticFeedback.mediumImpact(); 126 + final isLocalSaved = isSaved && (saveType == 'local' || saveType == 'both'); 127 + final isCloudSaved = saveType == 'cloud' || saveType == 'both'; 128 + 129 + showModalBottomSheet<void>( 130 + context: context, 131 + builder: (context) => SafeArea( 132 + child: Column( 133 + mainAxisSize: MainAxisSize.min, 134 + children: [ 135 + ListTile( 136 + leading: Icon( 137 + isLocalSaved ? Icons.bookmark_remove_outlined : Icons.bookmark_add_outlined, 138 + color: Colors.amber, 139 + ), 140 + title: Text(isLocalSaved ? 'Remove local save' : 'Save locally'), 141 + onTap: () { 142 + Navigator.pop(context); 143 + onSave?.call(); 144 + }, 145 + ), 146 + ListTile( 147 + leading: Icon( 148 + isCloudSaved ? Icons.cloud_off_outlined : Icons.cloud_outlined, 149 + color: Theme.of(context).colorScheme.primary, 150 + ), 151 + title: Text(isCloudSaved ? 'Remove from Bluesky' : 'Save to Bluesky'), 152 + onTap: () { 153 + Navigator.pop(context); 154 + if (isCloudSaved) { 155 + onCloudUnsave?.call(); 156 + } else { 157 + onCloudSave?.call(); 158 + } 159 + }, 160 + ), 161 + ], 162 + ), 163 + ), 164 + ); 165 + } 166 + } 167 + 168 + class _FooterAction extends StatelessWidget { 169 + const _FooterAction({ 170 + required this.icon, 171 + required this.activeIcon, 172 + required this.isActive, 173 + required this.isLoading, 174 + this.onTap, 175 + this.onLongPress, 176 + this.color, 177 + this.activeColor, 178 + }); 179 + 180 + final IconData icon; 181 + final IconData activeIcon; 182 + final bool isActive; 183 + final bool isLoading; 184 + final VoidCallback? onTap; 185 + final VoidCallback? onLongPress; 186 + final Color? color; 187 + final Color? activeColor; 188 + 189 + @override 190 + Widget build(BuildContext context) { 191 + final defaultColor = color ?? Theme.of(context).colorScheme.onSurfaceVariant; 192 + final iconColor = isActive ? (activeColor ?? defaultColor) : defaultColor; 193 + 194 + return InkWell( 195 + onTap: isLoading ? null : onTap, 196 + onLongPress: onLongPress, 197 + borderRadius: BorderRadius.zero, 198 + child: Padding( 199 + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), 200 + child: isLoading 201 + ? SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: iconColor)) 202 + : Icon(isActive ? activeIcon : icon, size: 18, color: iconColor), 203 + ), 204 + ); 205 + } 206 + }
+42 -157
lib/features/feed/presentation/widgets/post_card_with_actions.dart
··· 6 6 import 'package:flutter/services.dart'; 7 7 import 'package:flutter_bloc/flutter_bloc.dart'; 8 8 import 'package:go_router/go_router.dart'; 9 - import 'package:lazurite/core/logging/app_logger.dart'; 10 9 import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 11 10 import 'package:lazurite/features/feed/cubit/post_action_cubit.dart'; 12 11 import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 13 12 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 14 - import 'package:lazurite/features/feed/presentation/widgets/post_action_bar.dart'; 13 + import 'package:lazurite/features/feed/presentation/widgets/grid_post_card.dart'; 15 14 import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; 16 - import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; 17 - import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 18 - import 'package:lazurite/features/profile/presentation/widgets/report_dialog.dart'; 15 + import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; 16 + 17 + /// Controls which card layout variant is rendered by [PostCardWithActions]. 18 + enum PostCardVariant { linear, grid } 19 19 20 20 class PostCardWithActions extends StatelessWidget { 21 - const PostCardWithActions({super.key, required this.feedViewPost, required this.accountDid, this.onDeleted}); 21 + const PostCardWithActions({ 22 + super.key, 23 + required this.feedViewPost, 24 + required this.accountDid, 25 + this.variant = PostCardVariant.linear, 26 + this.onDeleted, 27 + }); 22 28 23 29 final FeedViewPost feedViewPost; 24 30 final String accountDid; 31 + final PostCardVariant variant; 25 32 final VoidCallback? onDeleted; 26 33 27 34 @override ··· 42 49 repostUri: viewer?.repost?.toString(), 43 50 cache: ctx.read<PostActionCache>(), 44 51 ), 45 - child: _PostCardWithActionsContent(feedViewPost: feedViewPost, accountDid: accountDid, onDeleted: onDeleted), 52 + child: _PostCardWithActionsContent( 53 + feedViewPost: feedViewPost, 54 + accountDid: accountDid, 55 + variant: variant, 56 + onDeleted: onDeleted, 57 + ), 46 58 ); 47 59 } 48 60 } 49 61 50 62 class _PostCardWithActionsContent extends StatelessWidget { 51 - const _PostCardWithActionsContent({required this.feedViewPost, required this.accountDid, this.onDeleted}); 63 + const _PostCardWithActionsContent({ 64 + required this.feedViewPost, 65 + required this.accountDid, 66 + required this.variant, 67 + this.onDeleted, 68 + }); 52 69 53 70 final FeedViewPost feedViewPost; 54 71 final String accountDid; 72 + final PostCardVariant variant; 55 73 final VoidCallback? onDeleted; 56 74 57 75 @override ··· 91 109 cubit.clearError(); 92 110 } 93 111 }, 94 - child: PostCard( 95 - feedViewPost: feedViewPost, 96 - actionBar: _buildActionBar(context), 97 - onTap: () => context.push('/post?uri=${Uri.encodeQueryComponent(feedViewPost.post.uri.toString())}'), 98 - ), 112 + child: _buildCard(context), 99 113 ); 100 114 } 101 115 102 - Widget _buildActionBar(BuildContext context) { 103 - final post = feedViewPost.post; 116 + Widget _buildCard(BuildContext context) { 117 + Future<Object?> onTap() => context.push('/post?uri=${Uri.encodeQueryComponent(feedViewPost.post.uri.toString())}'); 118 + if (variant == PostCardVariant.grid) { 119 + return GridPostCard(feedViewPost: feedViewPost, footer: _buildFooter(context), onTap: onTap); 120 + } 121 + return PostCard(feedViewPost: feedViewPost, actionBar: _buildFooter(context), onTap: onTap); 122 + } 104 123 124 + Widget _buildFooter(BuildContext context) { 125 + final post = feedViewPost.post; 105 126 return BlocBuilder<PostActionCubit, PostActionState>( 106 127 builder: (context, postActionState) { 107 128 return BlocBuilder<SavedPostsCubit, SavedPostsState>( 108 129 builder: (context, savedState) { 109 - return PostActionBar( 130 + return PostCardFooter( 131 + timestamp: formatPostTime(post.indexedAt), 110 132 replyCount: post.replyCount ?? 0, 111 133 repostCount: postActionState.repostCount, 112 134 likeCount: postActionState.likeCount, ··· 115 137 isReposted: postActionState.isReposted, 116 138 isSaved: savedState.isSaved(post.uri.toString()), 117 139 saveType: savedState.saveTypeForUri(post.uri.toString()), 118 - postUri: post.uri.toString(), 119 - postCid: post.cid, 120 140 isLoadingLike: postActionState.isLoadingLike, 121 141 isLoadingRepost: postActionState.isLoadingRepost, 122 142 onReply: () => _onReply(context), 123 143 onRepost: () => context.read<PostActionCubit>().toggleRepost(), 124 - onQuote: () => _onQuote(context), 125 144 onLike: () => context.read<PostActionCubit>().toggleLike(), 126 - onSave: () { 127 - unawaited(_onToggleSave(context)); 128 - }, 129 - onLongPressSave: () { 130 - unawaited(_onToggleSave(context)); 131 - }, 132 - onCloudSave: () { 133 - unawaited(_onCloudSave(context)); 134 - }, 135 - onCloudUnsave: () { 136 - unawaited(_onCloudUnsave(context)); 137 - }, 138 - onMore: () => _showMoreOptions(context), 145 + onSave: () => unawaited(_onToggleSave(context)), 146 + onLongPressSave: () => unawaited(_onToggleSave(context)), 147 + onCloudSave: () => unawaited(_onCloudSave(context)), 148 + onCloudUnsave: () => unawaited(_onCloudUnsave(context)), 139 149 ); 140 150 }, 141 151 ); ··· 171 181 ); 172 182 } 173 183 174 - void _onQuote(BuildContext context) { 175 - HapticFeedback.selectionClick(); 176 - final post = feedViewPost.post; 177 - 178 - context.push( 179 - '/compose', 180 - extra: {'quoteUri': post.uri.toString(), 'quoteCid': post.cid, 'quoteAuthorHandle': post.author.handle}, 181 - ); 182 - } 183 - 184 184 Future<void> _onToggleSave(BuildContext context) async { 185 185 final cubit = context.read<SavedPostsCubit>(); 186 186 final post = feedViewPost.post; 187 - 188 187 await HapticFeedback.lightImpact(); 189 188 await cubit.toggleSave(postUri: post.uri.toString(), postJson: jsonEncode(post.toJson())); 190 189 } ··· 192 191 Future<void> _onCloudSave(BuildContext context) async { 193 192 final cubit = context.read<SavedPostsCubit>(); 194 193 final post = feedViewPost.post; 195 - 196 194 await HapticFeedback.lightImpact(); 197 195 await cubit.cloudSave(postUri: post.uri.toString(), cid: post.cid, postJson: jsonEncode(post.toJson())); 198 196 } ··· 200 198 Future<void> _onCloudUnsave(BuildContext context) async { 201 199 final cubit = context.read<SavedPostsCubit>(); 202 200 final post = feedViewPost.post; 203 - 204 201 await HapticFeedback.lightImpact(); 205 202 await cubit.cloudUnsave(post.uri.toString()); 206 - } 207 - 208 - void _showMoreOptions(BuildContext context) { 209 - HapticFeedback.mediumImpact(); 210 - final post = feedViewPost.post; 211 - final postUri = post.uri.toString(); 212 - final bskyUrl = _convertAtUriToBskyUrl(postUri); 213 - 214 - showModalBottomSheet<void>( 215 - context: context, 216 - builder: (context) => SafeArea( 217 - child: Column( 218 - mainAxisSize: MainAxisSize.min, 219 - children: [ 220 - ListTile( 221 - leading: const Icon(Icons.copy), 222 - title: const Text('Copy Link'), 223 - onTap: () { 224 - Navigator.pop(context); 225 - _copyToClipboard(context, bskyUrl); 226 - }, 227 - ), 228 - ListTile( 229 - leading: const Icon(Icons.person_outline), 230 - title: Text('View @${post.author.handle}'), 231 - onTap: () { 232 - Navigator.pop(context); 233 - context.push('/profile/view?actor=${Uri.encodeQueryComponent(post.author.did)}'); 234 - }, 235 - ), 236 - ListTile( 237 - leading: const Icon(Icons.report_outlined, color: Colors.orange), 238 - title: const Text('Report Post', style: TextStyle(color: Colors.orange)), 239 - onTap: () { 240 - Navigator.pop(context); 241 - _showReportDialog(context); 242 - }, 243 - ), 244 - if (post.author.did == accountDid) 245 - ListTile( 246 - leading: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), 247 - title: Text('Delete Post', style: TextStyle(color: Theme.of(context).colorScheme.error)), 248 - onTap: () { 249 - Navigator.pop(context); 250 - _confirmDelete(context); 251 - }, 252 - ), 253 - ], 254 - ), 255 - ), 256 - ); 257 - } 258 - 259 - void _showReportDialog(BuildContext context) { 260 - final post = feedViewPost.post; 261 - 262 - showDialog<void>( 263 - context: context, 264 - builder: (dialogContext) => BlocProvider( 265 - create: (_) => ProfileActionCubit( 266 - profileActionRepository: context.read<ProfileActionRepository>(), 267 - actorDid: post.author.did, 268 - ), 269 - child: ReportDialog.post(postUri: post.uri, cid: post.cid, authorHandle: post.author.handle), 270 - ), 271 - ); 272 - } 273 - 274 - void _confirmDelete(BuildContext context) { 275 - showDialog<void>( 276 - context: context, 277 - builder: (context) => AlertDialog( 278 - title: const Text('Delete Post?'), 279 - content: const Text('This action cannot be undone.'), 280 - actions: [ 281 - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), 282 - FilledButton( 283 - onPressed: () { 284 - Navigator.pop(context); 285 - context.read<PostActionCubit>().deletePost(); 286 - }, 287 - style: FilledButton.styleFrom( 288 - backgroundColor: Theme.of(context).colorScheme.error, 289 - foregroundColor: Theme.of(context).colorScheme.onError, 290 - ), 291 - child: const Text('Delete'), 292 - ), 293 - ], 294 - ), 295 - ); 296 - } 297 - 298 - void _copyToClipboard(BuildContext context, String text) { 299 - Clipboard.setData(ClipboardData(text: text)); 300 - ScaffoldMessenger.of( 301 - context, 302 - ).showSnackBar(const SnackBar(content: Text('Link copied to clipboard'), behavior: SnackBarBehavior.floating)); 303 - } 304 - 305 - String _convertAtUriToBskyUrl(String atUri) { 306 - try { 307 - final uri = Uri.parse(atUri); 308 - final parts = uri.pathSegments; 309 - if (parts.length >= 2) { 310 - final did = uri.host; 311 - final rkey = parts.last; 312 - return 'https://bsky.app/profile/$did/post/$rkey'; 313 - } 314 - } catch (_) { 315 - log.d('failed to convert atUri to bskyUrl'); 316 - } 317 - return atUri; 318 203 } 319 204 }
+411
lib/features/feed/presentation/widgets/post_embed_view.dart
··· 1 + import 'package:bluesky/app_bsky_embed_external.dart'; 2 + import 'package:bluesky/app_bsky_embed_images.dart'; 3 + import 'package:bluesky/app_bsky_embed_record.dart'; 4 + import 'package:bluesky/app_bsky_embed_recordwithmedia.dart'; 5 + import 'package:bluesky/app_bsky_embed_video.dart'; 6 + import 'package:bluesky/app_bsky_feed_defs.dart'; 7 + import 'package:bluesky/app_bsky_feed_post.dart'; 8 + import 'package:flutter/material.dart'; 9 + import 'package:go_router/go_router.dart'; 10 + import 'package:lazurite/features/feed/presentation/media/image_viewer_route_args.dart'; 11 + import 'package:lazurite/features/feed/presentation/media/media_actions.dart'; 12 + import 'package:lazurite/features/feed/presentation/media/video_player_route_args.dart'; 13 + import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 14 + import 'package:url_launcher/url_launcher.dart'; 15 + 16 + /// Renders the appropriate embed widget for a post embed. 17 + /// 18 + /// Handles images, external links, videos, quoted records, and record-with-media. 19 + /// Used by both [PostCard] and [GridPostCard]. 20 + class PostEmbedView extends StatelessWidget { 21 + const PostEmbedView({super.key, required this.feedViewPost, required this.embed}); 22 + 23 + final FeedViewPost feedViewPost; 24 + final UPostViewEmbed embed; 25 + 26 + @override 27 + Widget build(BuildContext context) { 28 + return _buildEmbed(context, embed) ?? const SizedBox.shrink(); 29 + } 30 + 31 + Widget? _buildEmbed(BuildContext context, UPostViewEmbed embed) { 32 + if (embed.isEmbedImagesView) { 33 + return _buildImagesEmbed(context, embed.embedImagesView!.images); 34 + } 35 + 36 + if (embed.isEmbedExternalView) { 37 + return _buildExternalEmbed(context, embed.embedExternalView!.external); 38 + } 39 + 40 + if (embed.isEmbedRecordView) { 41 + return _buildQuotedRecord(context, embed.embedRecordView!); 42 + } 43 + 44 + if (embed.isEmbedVideoView) { 45 + return _buildVideoEmbed(context, embed.embedVideoView!); 46 + } 47 + 48 + if (embed.isEmbedRecordWithMediaView) { 49 + final recordWithMedia = embed.embedRecordWithMediaView!; 50 + return Column( 51 + crossAxisAlignment: CrossAxisAlignment.start, 52 + children: [ 53 + _buildRecordWithMediaMedia(context, recordWithMedia.media), 54 + const SizedBox(height: 8), 55 + _buildQuotedRecord(context, recordWithMedia.record), 56 + ], 57 + ); 58 + } 59 + 60 + return null; 61 + } 62 + 63 + Widget _buildRecordWithMediaMedia(BuildContext context, UEmbedRecordWithMediaViewMedia media) { 64 + if (media.isEmbedImagesView) { 65 + return _buildImagesEmbed(context, media.embedImagesView!.images); 66 + } 67 + if (media.isEmbedExternalView) { 68 + return _buildExternalEmbed(context, media.embedExternalView!.external); 69 + } 70 + if (media.isEmbedVideoView) { 71 + return _buildVideoEmbed(context, media.embedVideoView!); 72 + } 73 + return const SizedBox.shrink(); 74 + } 75 + 76 + Widget _buildImagesEmbed(BuildContext context, List<EmbedImagesViewImage> images) { 77 + final crossAxisCount = images.length == 1 ? 1 : 2; 78 + final childAspectRatio = images.length == 1 ? 16 / 9 : 1.0; 79 + final postUri = feedViewPost.post.uri.toString(); 80 + 81 + return GridView.builder( 82 + shrinkWrap: true, 83 + physics: const NeverScrollableScrollPhysics(), 84 + itemCount: images.length, 85 + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( 86 + crossAxisCount: crossAxisCount, 87 + crossAxisSpacing: 2, 88 + mainAxisSpacing: 2, 89 + childAspectRatio: childAspectRatio, 90 + ), 91 + itemBuilder: (context, index) { 92 + final image = images[index]; 93 + final heroTag = _imageHeroTag(postUri, index); 94 + 95 + return GestureDetector( 96 + onLongPressStart: (details) => _showImageContextMenu(context, details.globalPosition, image: image), 97 + child: InkWell( 98 + onTap: () => _openImageViewer(context, images, initialIndex: index), 99 + child: Hero( 100 + tag: heroTag, 101 + child: Image.network( 102 + image.thumb, 103 + fit: BoxFit.cover, 104 + errorBuilder: (_, _, _) => ColoredBox( 105 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 106 + child: const Center(child: Icon(Icons.image_not_supported_outlined)), 107 + ), 108 + ), 109 + ), 110 + ), 111 + ); 112 + }, 113 + ); 114 + } 115 + 116 + Widget _buildExternalEmbed(BuildContext context, EmbedExternalViewExternal external) { 117 + return InkWell( 118 + onTap: () => _launchExternal(Uri.parse(external.uri)), 119 + child: Container( 120 + decoration: BoxDecoration(border: Border.all(color: Theme.of(context).dividerColor)), 121 + child: Column( 122 + crossAxisAlignment: CrossAxisAlignment.start, 123 + children: [ 124 + if (external.thumb != null) 125 + Image.network( 126 + external.thumb!, 127 + height: 160, 128 + width: double.infinity, 129 + fit: BoxFit.cover, 130 + errorBuilder: (_, _, _) => const SizedBox(height: 0), 131 + ), 132 + Padding( 133 + padding: const EdgeInsets.all(12), 134 + child: Column( 135 + crossAxisAlignment: CrossAxisAlignment.start, 136 + children: [ 137 + Text( 138 + external.title, 139 + style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), 140 + ), 141 + if (external.description.isNotEmpty) ...[ 142 + const SizedBox(height: 4), 143 + Text( 144 + external.description, 145 + maxLines: 3, 146 + overflow: TextOverflow.ellipsis, 147 + style: Theme.of(context).textTheme.bodyMedium, 148 + ), 149 + ], 150 + const SizedBox(height: 8), 151 + Text( 152 + Uri.parse(external.uri).host, 153 + style: Theme.of( 154 + context, 155 + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 156 + ), 157 + ], 158 + ), 159 + ), 160 + ], 161 + ), 162 + ), 163 + ); 164 + } 165 + 166 + Widget _buildVideoEmbed(BuildContext context, EmbedVideoView video) { 167 + return InkWell( 168 + onTap: () => _openVideoViewer(context, video), 169 + child: Stack( 170 + alignment: Alignment.center, 171 + children: [ 172 + AspectRatio( 173 + aspectRatio: video.aspectRatio == null ? 16 / 9 : video.aspectRatio!.width / video.aspectRatio!.height, 174 + child: video.thumbnail == null 175 + ? ColoredBox( 176 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 177 + child: const SizedBox.expand(), 178 + ) 179 + : Image.network( 180 + video.thumbnail!, 181 + fit: BoxFit.cover, 182 + errorBuilder: (_, _, _) => ColoredBox( 183 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 184 + child: const SizedBox.expand(), 185 + ), 186 + ), 187 + ), 188 + Container( 189 + width: 56, 190 + height: 56, 191 + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.65), shape: BoxShape.circle), 192 + child: const Icon(Icons.play_arrow, color: Colors.white, size: 28), 193 + ), 194 + if (video.alt?.isNotEmpty ?? false) 195 + Positioned( 196 + left: 12, 197 + right: 12, 198 + bottom: 12, 199 + child: Text( 200 + video.alt!, 201 + maxLines: 2, 202 + overflow: TextOverflow.ellipsis, 203 + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white), 204 + ), 205 + ), 206 + ], 207 + ), 208 + ); 209 + } 210 + 211 + Widget _buildQuotedRecord(BuildContext context, EmbedRecordView recordView) { 212 + final record = recordView.record; 213 + 214 + if (record.isEmbedRecordViewRecord) { 215 + final quoted = record.embedRecordViewRecord!; 216 + final quotedRecord = _tryParseRecord(quoted.value); 217 + final nestedEmbed = _buildQuotedEmbeds(context, quoted.embeds); 218 + 219 + return Container( 220 + decoration: BoxDecoration(border: Border.all(color: Theme.of(context).dividerColor)), 221 + child: InkWell( 222 + onTap: () { 223 + GoRouter.maybeOf(context)?.push('/post?uri=${Uri.encodeComponent(quoted.uri.toString())}'); 224 + }, 225 + child: Padding( 226 + padding: const EdgeInsets.all(12), 227 + child: Column( 228 + crossAxisAlignment: CrossAxisAlignment.start, 229 + children: [ 230 + Row( 231 + children: [ 232 + Container( 233 + width: 28, 234 + height: 28, 235 + decoration: BoxDecoration( 236 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 237 + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), 238 + ), 239 + child: quoted.author.avatar != null 240 + ? Image.network(quoted.author.avatar!, fit: BoxFit.cover) 241 + : Center(child: Text(_initials(quoted.author.displayName ?? quoted.author.handle))), 242 + ), 243 + const SizedBox(width: 8), 244 + Expanded( 245 + child: Text( 246 + '${quoted.author.displayName ?? quoted.author.handle} @${quoted.author.handle}', 247 + maxLines: 1, 248 + overflow: TextOverflow.ellipsis, 249 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), 250 + ), 251 + ), 252 + ], 253 + ), 254 + if (quotedRecord != null && quotedRecord.text.isNotEmpty) ...[ 255 + const SizedBox(height: 8), 256 + FacetText( 257 + text: quotedRecord.text, 258 + facets: quotedRecord.facets, 259 + style: Theme.of(context).textTheme.bodyMedium, 260 + maxLines: 6, 261 + overflow: TextOverflow.ellipsis, 262 + ), 263 + ], 264 + if (nestedEmbed != null) ...[const SizedBox(height: 8), nestedEmbed], 265 + ], 266 + ), 267 + ), 268 + ), 269 + ); 270 + } 271 + 272 + if (record.isEmbedRecordViewNotFound) { 273 + return _buildUnavailableQuote(context, 'Quoted post not found'); 274 + } 275 + if (record.isEmbedRecordViewBlocked) { 276 + return _buildUnavailableQuote(context, 'Quoted post is blocked'); 277 + } 278 + if (record.isEmbedRecordViewDetached) { 279 + return _buildUnavailableQuote(context, 'Quoted post is unavailable'); 280 + } 281 + 282 + return const SizedBox.shrink(); 283 + } 284 + 285 + Widget _buildUnavailableQuote(BuildContext context, String label) { 286 + return Container( 287 + width: double.infinity, 288 + padding: const EdgeInsets.all(12), 289 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 290 + child: Text( 291 + label, 292 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 293 + ), 294 + ); 295 + } 296 + 297 + Widget? _buildQuotedEmbeds(BuildContext context, List<UEmbedRecordViewRecordEmbeds>? embeds) { 298 + if (embeds == null || embeds.isEmpty) return null; 299 + 300 + final embed = embeds.first; 301 + 302 + if (embed.isEmbedImagesView) { 303 + return _buildImagesEmbed(context, embed.embedImagesView!.images); 304 + } 305 + if (embed.isEmbedExternalView) { 306 + return _buildExternalEmbed(context, embed.embedExternalView!.external); 307 + } 308 + if (embed.isEmbedVideoView) { 309 + return _buildVideoEmbed(context, embed.embedVideoView!); 310 + } 311 + if (embed.isEmbedRecordWithMediaView) { 312 + final recordWithMedia = embed.embedRecordWithMediaView!; 313 + return Column( 314 + crossAxisAlignment: CrossAxisAlignment.start, 315 + children: [ 316 + _buildRecordWithMediaMedia(context, recordWithMedia.media), 317 + const SizedBox(height: 8), 318 + _buildQuotedRecord(context, recordWithMedia.record), 319 + ], 320 + ); 321 + } 322 + 323 + return null; 324 + } 325 + 326 + void _openImageViewer(BuildContext context, List<EmbedImagesViewImage> images, {required int initialIndex}) { 327 + GoRouter.maybeOf(context)?.push( 328 + '/images', 329 + extra: ImageViewerRouteArgs( 330 + images: [ 331 + for (var i = 0; i < images.length; i++) 332 + ImageViewerItem( 333 + fullsizeUrl: images[i].fullsize, 334 + thumbnailUrl: images[i].thumb, 335 + altText: images[i].alt, 336 + heroTag: _imageHeroTag(feedViewPost.post.uri.toString(), i), 337 + ), 338 + ], 339 + initialIndex: initialIndex, 340 + ), 341 + ); 342 + } 343 + 344 + Future<void> _showImageContextMenu( 345 + BuildContext context, 346 + Offset globalPosition, { 347 + required EmbedImagesViewImage image, 348 + }) async { 349 + final selected = await showMenu<_ImageThumbnailAction>( 350 + context: context, 351 + position: RelativeRect.fromLTRB(globalPosition.dx, globalPosition.dy, globalPosition.dx, globalPosition.dy), 352 + items: const [ 353 + PopupMenuItem<_ImageThumbnailAction>(value: _ImageThumbnailAction.save, child: Text('Save image')), 354 + PopupMenuItem<_ImageThumbnailAction>(value: _ImageThumbnailAction.share, child: Text('Share')), 355 + ], 356 + ); 357 + 358 + if (!context.mounted || selected == null) return; 359 + 360 + switch (selected) { 361 + case _ImageThumbnailAction.save: 362 + await MediaActions.downloadImage(context, image.fullsize, suggestedName: _downloadFileName(image.fullsize)); 363 + case _ImageThumbnailAction.share: 364 + await MediaActions.shareImage(context, image.fullsize); 365 + } 366 + } 367 + 368 + void _openVideoViewer(BuildContext context, EmbedVideoView video) { 369 + final ratio = video.aspectRatio == null ? null : video.aspectRatio!.width / video.aspectRatio!.height; 370 + final isGif = video.presentation?.knownValue == KnownEmbedVideoViewPresentation.gif; 371 + GoRouter.maybeOf(context)?.push( 372 + '/video', 373 + extra: VideoPlayerRouteArgs( 374 + playlistUrl: video.playlist, 375 + thumbnailUrl: video.thumbnail, 376 + altText: video.alt, 377 + aspectRatio: ratio, 378 + isGif: isGif, 379 + ), 380 + ); 381 + } 382 + 383 + String _imageHeroTag(String postUri, int index) => 'post-image-$postUri-$index'; 384 + 385 + String _downloadFileName(String url) { 386 + final uri = Uri.tryParse(url); 387 + final segment = uri?.pathSegments.isNotEmpty == true ? uri!.pathSegments.last : 'image.jpg'; 388 + return segment.isEmpty ? 'image.jpg' : segment; 389 + } 390 + 391 + FeedPostRecord? _tryParseRecord(Map<String, dynamic> record) { 392 + try { 393 + return FeedPostRecord.fromJson(record); 394 + } catch (_) { 395 + return null; 396 + } 397 + } 398 + 399 + String _initials(String value) { 400 + final parts = value.trim().split(RegExp(r'\s+')); 401 + if (parts.isEmpty || parts.first.isEmpty) return '?'; 402 + if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase(); 403 + return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 404 + } 405 + } 406 + 407 + Future<void> _launchExternal(Uri url) async { 408 + await launchUrl(url, mode: LaunchMode.externalApplication); 409 + } 410 + 411 + enum _ImageThumbnailAction { save, share }
+166
test/features/feed/presentation/grid_post_card_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_embed_images.dart'; 4 + import 'package:bluesky/app_bsky_feed_defs.dart'; 5 + import 'package:bluesky/app_bsky_feed_post.dart'; 6 + import 'package:flutter/material.dart'; 7 + import 'package:flutter_test/flutter_test.dart'; 8 + import 'package:go_router/go_router.dart'; 9 + import 'package:lazurite/features/feed/presentation/widgets/grid_post_card.dart'; 10 + import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; 11 + 12 + FeedViewPost _makePost({String text = 'Hello', UPostViewEmbed? embed}) { 13 + final record = FeedPostRecord(text: text, createdAt: DateTime.utc(2026, 3, 16)); 14 + return FeedViewPost( 15 + post: PostView( 16 + uri: const AtUri('at://did:plc:test/app.bsky.feed.post/xyz'), 17 + cid: 'cid-xyz', 18 + author: const ProfileViewBasic(did: 'did:plc:test', handle: 'test.bsky.social', displayName: 'Test User'), 19 + record: record.toJson(), 20 + indexedAt: DateTime.utc(2026, 3, 16), 21 + embed: embed, 22 + ), 23 + ); 24 + } 25 + 26 + Widget _buildSubject(FeedViewPost post, {VoidCallback? onTap}) { 27 + return MaterialApp( 28 + home: Scaffold( 29 + body: SingleChildScrollView( 30 + child: GridPostCard(feedViewPost: post, onTap: onTap), 31 + ), 32 + ), 33 + ); 34 + } 35 + 36 + void main() { 37 + testWidgets('renders author handle uppercase', (tester) async { 38 + final post = _makePost(); 39 + await tester.pumpWidget(_buildSubject(post)); 40 + 41 + expect(find.text('@TEST.BSKY.SOCIAL'), findsOneWidget); 42 + }); 43 + 44 + testWidgets('renders author display name', (tester) async { 45 + final post = _makePost(); 46 + await tester.pumpWidget(_buildSubject(post)); 47 + 48 + expect(find.text('Test User'), findsOneWidget); 49 + }); 50 + 51 + testWidgets('renders body text', (tester) async { 52 + final post = _makePost(text: 'Short post text'); 53 + await tester.pumpWidget(_buildSubject(post)); 54 + 55 + final richTextFinder = find.byWidgetPredicate( 56 + (widget) => widget is RichText && widget.text.toPlainText().contains('Short post text'), 57 + ); 58 + expect(richTextFinder, findsOneWidget); 59 + }); 60 + 61 + testWidgets('renders PostCardFooter', (tester) async { 62 + final post = _makePost(); 63 + await tester.pumpWidget(_buildSubject(post)); 64 + 65 + expect(find.byType(PostCardFooter), findsOneWidget); 66 + }); 67 + 68 + testWidgets('calls onTap when card tapped', (tester) async { 69 + var tapped = false; 70 + final post = _makePost(); 71 + 72 + await tester.pumpWidget(_buildSubject(post, onTap: () => tapped = true)); 73 + 74 + await tester.tap(find.text('@TEST.BSKY.SOCIAL')); 75 + expect(tapped, isTrue); 76 + }); 77 + 78 + testWidgets('text-only posts have no image AspectRatio', (tester) async { 79 + final post = _makePost(text: 'Text-only post content'); 80 + await tester.pumpWidget(_buildSubject(post)); 81 + 82 + expect(find.byType(AspectRatio), findsNothing); 83 + 84 + final richTextFinder = find.byWidgetPredicate( 85 + (widget) => widget is RichText && widget.text.toPlainText().contains('Text-only post content'), 86 + ); 87 + expect(richTextFinder, findsOneWidget); 88 + }); 89 + 90 + testWidgets('renders image region for posts with images', (tester) async { 91 + final post = _makePost( 92 + embed: const UPostViewEmbed.embedImagesView( 93 + data: EmbedImagesView( 94 + images: [ 95 + EmbedImagesViewImage( 96 + thumb: 'https://example.com/thumb.jpg', 97 + fullsize: 'https://example.com/full.jpg', 98 + alt: '', 99 + ), 100 + ], 101 + ), 102 + ), 103 + ); 104 + 105 + await tester.pumpWidget(_buildSubject(post)); 106 + 107 + expect(find.byType(AspectRatio), findsOneWidget); 108 + expect(find.byType(ColorFiltered), findsOneWidget); 109 + }); 110 + 111 + testWidgets('uses square container for avatar — no CircleAvatar', (tester) async { 112 + final post = _makePost(); 113 + await tester.pumpWidget(_buildSubject(post)); 114 + 115 + expect(find.byType(CircleAvatar), findsNothing); 116 + }); 117 + 118 + testWidgets('tapping avatar navigates to author profile', (tester) async { 119 + final post = _makePost(); 120 + String? pushedRoute; 121 + 122 + final router = GoRouter( 123 + routes: [ 124 + GoRoute( 125 + path: '/', 126 + builder: (context, state) => Scaffold( 127 + body: SingleChildScrollView(child: GridPostCard(feedViewPost: post)), 128 + ), 129 + ), 130 + GoRoute( 131 + path: '/profile/view', 132 + builder: (context, state) { 133 + pushedRoute = state.uri.toString(); 134 + return const Scaffold(body: Text('profile')); 135 + }, 136 + ), 137 + ], 138 + ); 139 + 140 + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 141 + await tester.pumpAndSettle(); 142 + 143 + await tester.tap(find.byKey(const ValueKey('grid_post_card_avatar'))); 144 + await tester.pumpAndSettle(); 145 + 146 + expect(pushedRoute, contains('did%3Aplc%3Atest')); 147 + }); 148 + 149 + testWidgets('accepts custom footer widget', (tester) async { 150 + const customFooter = SizedBox(key: Key('custom_footer'), height: 30); 151 + final post = _makePost(); 152 + 153 + await tester.pumpWidget( 154 + MaterialApp( 155 + home: Scaffold( 156 + body: SingleChildScrollView( 157 + child: GridPostCard(feedViewPost: post, footer: customFooter), 158 + ), 159 + ), 160 + ), 161 + ); 162 + 163 + expect(find.byKey(const Key('custom_footer')), findsOneWidget); 164 + expect(find.byType(PostCardFooter), findsNothing); 165 + }); 166 + }
+17 -1
test/features/feed/presentation/post_card_test.dart
··· 11 11 import 'package:flutter_test/flutter_test.dart'; 12 12 import 'package:go_router/go_router.dart'; 13 13 import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; 14 + import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; 14 15 15 16 FeedViewPost _makePost({String text = 'Hello'}) { 16 17 final record = FeedPostRecord(text: text, createdAt: DateTime.utc(2026, 3, 16)); ··· 114 115 await tester.pump(); 115 116 }); 116 117 118 + testWidgets('renders handle uppercase in header', (tester) async { 119 + final post = _makePost(); 120 + await tester.pumpWidget(buildSubject(post)); 121 + 122 + expect(find.text('@TEST.BSKY.SOCIAL'), findsOneWidget); 123 + }); 124 + 125 + testWidgets('renders PostCardFooter instead of CircleAvatar', (tester) async { 126 + final post = _makePost(); 127 + await tester.pumpWidget(buildSubject(post)); 128 + 129 + expect(find.byType(CircleAvatar), findsNothing); 130 + expect(find.byType(PostCardFooter), findsOneWidget); 131 + }); 132 + 117 133 testWidgets('tapping quoted post navigates to /post with quoted uri', (tester) async { 118 134 final quotedUri = AtUri.parse('at://did:plc:quoted/app.bsky.feed.post/quoted123'); 119 135 final record = FeedPostRecord(text: 'Main post', createdAt: DateTime.utc(2026, 3, 16)); ··· 192 208 await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 193 209 await tester.pumpAndSettle(); 194 210 195 - await tester.tap(find.byType(CircleAvatar)); 211 + await tester.tap(find.byKey(const ValueKey('post_card_avatar'))); 196 212 await tester.pumpAndSettle(); 197 213 198 214 expect(pushedRoute, contains('did%3Aplc%3Atest'));