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: standardize post card layouts/theming

+253 -411
+2 -2
lib/features/feed/presentation/feed_detail_screen.dart
··· 238 238 scrollController: _scrollController, 239 239 isLoadingMore: _isLoadingMore, 240 240 onRefresh: _loadInitial, 241 - gridItemBuilder: (context, index) => buildCard(index, PostCardVariant.grid), 242 - linearItemBuilder: (context, index) => buildCard(index, PostCardVariant.linear), 241 + gridItemBuilder: (context, index) => buildCard(index, PostCardVariant.compact), 242 + linearItemBuilder: (context, index) => buildCard(index, PostCardVariant.card), 243 243 ); 244 244 } 245 245 }
+2 -2
lib/features/feed/presentation/home_feed_screen.dart
··· 446 446 scrollController: _scrollController, 447 447 isLoadingMore: _isLoadingMore, 448 448 onRefresh: _loadFeed, 449 - gridItemBuilder: (context, index) => buildCard(index, PostCardVariant.grid), 450 - linearItemBuilder: (context, index) => buildCard(index, PostCardVariant.linear), 449 + gridItemBuilder: (context, index) => buildCard(index, PostCardVariant.compact), 450 + linearItemBuilder: (context, index) => buildCard(index, PostCardVariant.card), 451 451 ); 452 452 } 453 453 }
+117
lib/features/feed/presentation/widgets/compact_post_card.dart
··· 1 + import 'package:bluesky/app_bsky_actor_defs.dart'; 2 + import 'package:bluesky/app_bsky_feed_defs.dart'; 3 + import 'package:bluesky/moderation.dart' as bsky_moderation; 4 + import 'package:flutter/material.dart'; 5 + import 'package:lazurite/core/theme/theme_extensions.dart'; 6 + import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 7 + import 'package:lazurite/features/feed/presentation/widgets/post_embed_view.dart'; 8 + import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 9 + import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 10 + import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 11 + import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 12 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 13 + import 'package:lazurite/shared/utils/format_utils.dart'; 14 + import 'package:lazurite/shared/utils/parse_utils.dart'; 15 + 16 + /// Compact post card style used by profile-scoped search and compact feed layout. 17 + class CompactPostCard extends StatelessWidget { 18 + const CompactPostCard({ 19 + super.key, 20 + required this.feedViewPost, 21 + this.footer, 22 + this.onTap, 23 + this.moderationContext = bsky_moderation.ModerationBehaviorContext.contentList, 24 + }); 25 + 26 + final FeedViewPost feedViewPost; 27 + final Widget? footer; 28 + final VoidCallback? onTap; 29 + final bsky_moderation.ModerationBehaviorContext moderationContext; 30 + 31 + @override 32 + Widget build(BuildContext context) { 33 + final post = feedViewPost.post; 34 + final record = tryParseRecord(post.record); 35 + final createdAt = record?.createdAt ?? post.indexedAt; 36 + final moderationService = maybeModerationService(context); 37 + final postUi = moderationService?.postUi(post, moderationContext) ?? const bsky_moderation.ModerationUI(); 38 + 39 + return Card( 40 + margin: const EdgeInsets.symmetric(horizontal: 0, vertical: 1), 41 + elevation: 0, 42 + shape: const RoundedRectangleBorder(), 43 + child: ModeratedBlurOverlay( 44 + ui: postUi, 45 + child: Column( 46 + crossAxisAlignment: CrossAxisAlignment.start, 47 + children: [ 48 + InkWell( 49 + onTap: onTap, 50 + child: Padding( 51 + padding: const EdgeInsets.all(16), 52 + child: Column( 53 + crossAxisAlignment: CrossAxisAlignment.start, 54 + children: [ 55 + _buildHeader(context, post.author, createdAt), 56 + if (postUi.alert || postUi.inform) ...[const SizedBox(height: 10), ModerationBadgeRow(ui: postUi)], 57 + if (record != null && record.text.isNotEmpty) ...[ 58 + const SizedBox(height: 12), 59 + FacetText(text: record.text, facets: record.facets, style: context.textTheme.bodyLarge), 60 + ], 61 + if (post.embed != null) ...[ 62 + const SizedBox(height: 12), 63 + PostEmbedView(feedViewPost: feedViewPost, embed: post.embed!, compact: true), 64 + ], 65 + ], 66 + ), 67 + ), 68 + ), 69 + ...[?footer], 70 + ], 71 + ), 72 + ), 73 + ); 74 + } 75 + 76 + Widget _buildHeader(BuildContext context, ProfileViewBasic author, DateTime createdAt) { 77 + final moderationService = maybeModerationService(context); 78 + final avatarUi = 79 + moderationService?.profileBasicUi(author, bsky_moderation.ModerationBehaviorContext.avatar) ?? 80 + const bsky_moderation.ModerationUI(); 81 + 82 + return InkWell( 83 + onTap: () => navigateToProfile(context, author.did), 84 + child: Row( 85 + crossAxisAlignment: CrossAxisAlignment.start, 86 + children: [ 87 + ProfileAvatar( 88 + size: 44, 89 + moderationUi: avatarUi, 90 + imageUrl: author.avatar, 91 + fallbackText: author.displayName ?? author.handle, 92 + shape: BoxShape.circle, 93 + ), 94 + const SizedBox(width: 12), 95 + Expanded( 96 + child: Column( 97 + crossAxisAlignment: CrossAxisAlignment.start, 98 + children: [ 99 + Text( 100 + author.displayName ?? author.handle, 101 + style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), 102 + maxLines: 1, 103 + overflow: TextOverflow.ellipsis, 104 + ), 105 + const SizedBox(height: 2), 106 + Text( 107 + '@${author.handle} · ${formatRelativeTime(createdAt)}', 108 + style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), 109 + ), 110 + ], 111 + ), 112 + ), 113 + ], 114 + ), 115 + ); 116 + } 117 + }
+11 -48
lib/features/feed/presentation/widgets/feed_layout_view.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_bloc/flutter_bloc.dart'; 3 3 import 'package:lazurite/core/theme/feed_layout.dart'; 4 - import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; 5 4 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 6 5 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 7 6 import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; 8 7 9 - const double _gridSpacing = 1; 10 - const double _gridCardChromeHeight = 160; 11 - 12 - /// Renders a scrollable list of items in either a responsive [SliverGrid] 13 - /// (card layout) or a padded [ListView] (compact layout), driven 8 + /// Renders a scrollable list of items in either a padded [ListView] 9 + /// (card layout) or a compact-styled [ListView] (compact layout), driven 14 10 /// by [SettingsCubit.feedLayout]. 15 11 /// 16 - /// [gridItemBuilder] is used when the card layout is active. 17 - /// [linearItemBuilder] is used when the compact layout is active. 12 + /// [linearItemBuilder] is used when the card layout is active. 13 + /// [gridItemBuilder] is used when the compact layout is active. 18 14 /// This allows the caller to render the appropriate card variant for each mode. 19 15 class FeedLayoutView extends StatelessWidget { 20 16 const FeedLayoutView({ ··· 39 35 return BlocBuilder<SettingsCubit, SettingsState>( 40 36 buildWhen: (prev, curr) => prev.feedLayout != curr.feedLayout, 41 37 builder: (context, settingsState) { 42 - if (settingsState.feedLayout == FeedLayout.card) { 43 - return _buildGrid(context); 38 + if (settingsState.feedLayout == FeedLayout.compact) { 39 + return _buildCompact(context); 44 40 } 45 - return _buildLinear(context); 41 + return _buildCard(context); 46 42 }, 47 43 ); 48 44 } 49 45 50 - Widget _buildGrid(BuildContext context) { 51 - final width = MediaQuery.of(context).size.width; 52 - final columns = feedColumnCount(width); 53 - if (columns == 1) { 54 - return _buildSingleColumnGrid(context); 55 - } 56 - final tileWidth = (width - ((columns - 1) * _gridSpacing)) / columns; 57 - 58 - return AnimatedRefreshIndicator( 59 - onRefresh: onRefresh, 60 - child: CustomScrollView( 61 - controller: scrollController, 62 - slivers: [ 63 - SliverGrid( 64 - delegate: SliverChildBuilderDelegate(gridItemBuilder, childCount: itemCount), 65 - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( 66 - crossAxisCount: columns, 67 - crossAxisSpacing: _gridSpacing, 68 - mainAxisSpacing: _gridSpacing, 69 - mainAxisExtent: tileWidth + _gridCardChromeHeight, 70 - ), 71 - ), 72 - if (isLoadingMore) 73 - const SliverToBoxAdapter( 74 - child: Center( 75 - child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), 76 - ), 77 - ), 78 - ], 79 - ), 80 - ); 81 - } 82 - 83 - Widget _buildSingleColumnGrid(BuildContext context) { 46 + Widget _buildCompact(BuildContext context) { 84 47 return AnimatedRefreshIndicator( 85 48 onRefresh: onRefresh, 86 49 child: CustomScrollView( 87 50 controller: scrollController, 88 51 slivers: [ 89 52 SliverPadding( 90 - padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), 53 + padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), 91 54 sliver: SliverList.separated( 92 55 itemCount: itemCount, 93 56 itemBuilder: gridItemBuilder, 94 - separatorBuilder: (_, _) => const SizedBox(height: 12), 57 + separatorBuilder: (_, _) => const SizedBox(height: 2), 95 58 ), 96 59 ), 97 60 if (isLoadingMore) ··· 105 68 ); 106 69 } 107 70 108 - Widget _buildLinear(BuildContext context) { 71 + Widget _buildCard(BuildContext context) { 109 72 return AnimatedRefreshIndicator( 110 73 onRefresh: onRefresh, 111 74 child: ListView.builder(
+6 -4
lib/features/feed/presentation/widgets/post_card_footer.dart
··· 66 66 Widget build(BuildContext context) { 67 67 final colorScheme = context.colorScheme; 68 68 final saveActiveColor = (saveType == 'cloud' || saveType == 'both') ? colorScheme.primary : Colors.amber; 69 - const horizontalPadding = 12.0; 69 + const horizontalPadding = 8.0; 70 + const topPadding = 6.0; 71 + const bottomPadding = 4.0; 70 72 const iconSize = 18.0; 71 73 72 74 return LayoutBuilder( 73 75 builder: (context, constraints) { 74 76 final compactLayout = constraints.maxWidth < 220; 75 77 final actionSpacing = compactLayout ? 4.0 : 8.0; 76 - final actionPadding = compactLayout ? 2.0 : 4.0; 78 + final actionPadding = compactLayout ? 1.5 : 3.0; 77 79 final canShowCounts = showCounts && constraints.maxWidth >= 240; 78 80 final actions = [ 79 81 _FooterAction( ··· 151 153 decoration: BoxDecoration( 152 154 border: Border(top: BorderSide(color: colorScheme.outlineVariant)), 153 155 ), 154 - padding: const EdgeInsets.symmetric(horizontal: horizontalPadding, vertical: 8), 156 + padding: const EdgeInsets.fromLTRB(horizontalPadding, topPadding, horizontalPadding, bottomPadding), 155 157 child: compactLayout 156 158 ? Column( 157 159 crossAxisAlignment: CrossAxisAlignment.start, ··· 162 164 crossAxisAlignment: WrapCrossAlignment.center, 163 165 children: actions, 164 166 ), 165 - const SizedBox(height: 6), 167 + const SizedBox(height: 4), 166 168 Align(alignment: Alignment.centerRight, child: _buildTimestamp(context, colorScheme)), 167 169 ], 168 170 )
+28 -3
lib/features/feed/presentation/widgets/post_card_with_actions.dart
··· 7 7 import 'package:flutter/material.dart'; 8 8 import 'package:flutter_bloc/flutter_bloc.dart'; 9 9 import 'package:go_router/go_router.dart'; 10 + import 'package:lazurite/core/theme/feed_layout.dart'; 10 11 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 11 12 import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 12 13 import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 13 14 import 'package:lazurite/features/feed/cubit/post_action_cubit.dart'; 14 15 import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 15 16 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 17 + import 'package:lazurite/features/feed/presentation/widgets/compact_post_card.dart'; 16 18 import 'package:lazurite/features/feed/presentation/widgets/grid_post_card.dart'; 17 19 import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; 18 20 import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; ··· 20 22 import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; 21 23 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 22 24 import 'package:lazurite/features/profile/presentation/widgets/report_dialog.dart'; 25 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 23 26 import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart'; 24 27 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 25 28 import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 26 29 27 30 /// Controls which card layout variant is rendered by [PostCardWithActions]. 28 - enum PostCardVariant { linear, grid } 31 + enum PostCardVariant { adaptive, card, compact, grid } 29 32 30 33 class PostCardWithActions extends StatefulWidget { 31 34 const PostCardWithActions({ 32 35 super.key, 33 36 required this.feedViewPost, 34 37 required this.accountDid, 35 - this.variant = PostCardVariant.linear, 38 + this.variant = PostCardVariant.adaptive, 36 39 this.onDeleted, 37 40 this.onReplySubmitted, 38 41 this.moderationContext = bsky_moderation.ModerationBehaviorContext.contentList, ··· 172 175 } 173 176 174 177 Widget _buildCard(BuildContext context) { 178 + final resolvedVariant = _resolveVariant(context); 175 179 Future<Object?> onTap() => context.push('/post?uri=${Uri.encodeQueryComponent(feedViewPost.post.uri.toString())}'); 176 - if (variant == PostCardVariant.grid) { 180 + if (resolvedVariant == PostCardVariant.grid) { 177 181 return GridPostCard( 178 182 feedViewPost: feedViewPost, 179 183 footer: _buildFooter(context), ··· 181 185 moderationContext: moderationContext, 182 186 ); 183 187 } 188 + if (resolvedVariant == PostCardVariant.compact) { 189 + return CompactPostCard( 190 + feedViewPost: feedViewPost, 191 + footer: _buildFooter(context), 192 + onTap: onTap, 193 + moderationContext: moderationContext, 194 + ); 195 + } 184 196 return PostCard( 185 197 feedViewPost: feedViewPost, 186 198 actionBar: _buildFooter(context), 187 199 onTap: onTap, 188 200 moderationContext: moderationContext, 189 201 ); 202 + } 203 + 204 + PostCardVariant _resolveVariant(BuildContext context) { 205 + if (variant != PostCardVariant.adaptive) { 206 + return variant; 207 + } 208 + 209 + try { 210 + final layout = context.select<SettingsCubit, FeedLayout>((cubit) => cubit.state.feedLayout); 211 + return layout == FeedLayout.compact ? PostCardVariant.compact : PostCardVariant.card; 212 + } catch (_) { 213 + return PostCardVariant.card; 214 + } 190 215 } 191 216 192 217 Widget _buildFooter(BuildContext context) {
+16 -20
lib/features/profile/presentation/profile_screen.dart
··· 1165 1165 return BlocBuilder<SettingsCubit, SettingsState>( 1166 1166 buildWhen: (prev, curr) => prev.feedLayout != curr.feedLayout, 1167 1167 builder: (context, settingsState) { 1168 - if (settingsState.feedLayout == FeedLayout.card) { 1169 - return _buildGridFeed(context, visibleFeedState, requestFilter: requestFilter, slice: slice); 1168 + if (settingsState.feedLayout == FeedLayout.compact) { 1169 + return _buildCompactFeed(context, visibleFeedState, requestFilter: requestFilter, slice: slice); 1170 1170 } 1171 - return _buildLinearFeed(context, visibleFeedState, requestFilter: requestFilter, slice: slice); 1171 + return _buildCardFeed(context, visibleFeedState, requestFilter: requestFilter, slice: slice); 1172 1172 }, 1173 1173 ); 1174 1174 } ··· 1229 1229 return embed.isEmbedRecordView || embed.isEmbedRecordWithMediaView; 1230 1230 } 1231 1231 1232 - Widget _buildGridFeed( 1232 + Widget _buildCompactFeed( 1233 1233 BuildContext context, 1234 1234 FeedState feedState, { 1235 1235 required FeedFilter requestFilter, ··· 1237 1237 }) { 1238 1238 final accountDid = context.read<AuthBloc>().state.tokens?.did ?? ''; 1239 1239 final scrollKey = slice == _ProfileFeedSlice.posts 1240 - ? const ValueKey('profile_grid_feed') 1241 - : PageStorageKey<String>('profile_grid_feed_${slice.name}'); 1240 + ? const ValueKey('profile_compact_feed') 1241 + : PageStorageKey<String>('profile_compact_feed_${slice.name}'); 1242 1242 1243 1243 return RefreshIndicator( 1244 1244 onRefresh: _refresh, ··· 1254 1254 }, 1255 1255 child: ListView.builder( 1256 1256 key: scrollKey, 1257 - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 1257 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 1258 1258 itemCount: feedState.posts.length + (feedState.isLoadingMore ? 1 : 0), 1259 1259 itemBuilder: (context, index) { 1260 1260 if (index >= feedState.posts.length) { ··· 1266 1266 final post = feedState.posts[index]; 1267 1267 1268 1268 return Padding( 1269 - padding: EdgeInsets.only(bottom: index == feedState.posts.length - 1 ? 0 : 16), 1270 - child: Center( 1271 - child: ConstrainedBox( 1272 - key: ValueKey('profile_large_card_$index'), 1273 - constraints: const BoxConstraints(maxWidth: 720), 1274 - child: PostCardWithActions( 1275 - feedViewPost: post, 1276 - accountDid: accountDid, 1277 - variant: PostCardVariant.grid, 1278 - moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, 1279 - ), 1280 - ), 1269 + key: ValueKey('profile_compact_card_$index'), 1270 + padding: EdgeInsets.only(bottom: index == feedState.posts.length - 1 ? 0 : 2), 1271 + child: PostCardWithActions( 1272 + feedViewPost: post, 1273 + accountDid: accountDid, 1274 + variant: PostCardVariant.compact, 1275 + moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, 1281 1276 ), 1282 1277 ); 1283 1278 }, ··· 1286 1281 ); 1287 1282 } 1288 1283 1289 - Widget _buildLinearFeed( 1284 + Widget _buildCardFeed( 1290 1285 BuildContext context, 1291 1286 FeedState feedState, { 1292 1287 required FeedFilter requestFilter, ··· 1319 1314 return PostCardWithActions( 1320 1315 feedViewPost: feedState.posts[index], 1321 1316 accountDid: accountDid, 1317 + variant: PostCardVariant.card, 1322 1318 moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, 1323 1319 ); 1324 1320 },
+13 -127
lib/features/search/presentation/hashtag_screen.dart
··· 1 - import 'package:bluesky/app_bsky_actor_defs.dart'; 2 1 import 'package:bluesky/app_bsky_feed_defs.dart'; 3 - import 'package:bluesky/moderation.dart' as bsky_moderation; 4 2 import 'package:flutter/material.dart'; 5 3 import 'package:flutter_animate/flutter_animate.dart'; 6 4 import 'package:flutter_bloc/flutter_bloc.dart'; ··· 8 6 import 'package:lazurite/core/theme/animation_tokens.dart'; 9 7 import 'package:lazurite/core/theme/animation_utils.dart'; 10 8 import 'package:lazurite/core/theme/theme_extensions.dart'; 11 - import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 12 - import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 13 - import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 14 - import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 9 + import 'package:lazurite/features/feed/presentation/widgets/compact_post_card.dart'; 10 + import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; 15 11 import 'package:lazurite/features/search/cubit/hashtag_cubit.dart'; 16 12 import 'package:lazurite/features/search/data/hashtag_utils.dart'; 17 - import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 18 13 import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; 19 14 import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; 20 - import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 21 15 import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 22 - import 'package:lazurite/shared/utils/format_utils.dart'; 23 - import 'package:lazurite/shared/utils/parse_utils.dart'; 24 16 25 17 class HashtagScreen extends StatefulWidget { 26 18 const HashtagScreen({super.key, required this.tag}); ··· 269 261 itemKey: post.uri.toString(), 270 262 index: index, 271 263 seenKeys: _seenPostUris, 272 - child: _HashtagPostCard(post: post), 264 + child: CompactPostCard( 265 + feedViewPost: FeedViewPost(post: post), 266 + onTap: () => context.push('/post?uri=${Uri.encodeQueryComponent(post.uri.toString())}'), 267 + footer: PostCardFooter( 268 + timestamp: formatPostTime(post.indexedAt), 269 + replyCount: post.replyCount ?? 0, 270 + repostCount: post.repostCount ?? 0, 271 + likeCount: post.likeCount ?? 0, 272 + showCounts: true, 273 + ), 274 + ), 273 275 ); 274 276 }, 275 277 ), 276 278 ); 277 279 } 278 280 } 279 - 280 - class _HashtagPostCard extends StatelessWidget { 281 - const _HashtagPostCard({required this.post}); 282 - 283 - final PostView post; 284 - 285 - @override 286 - Widget build(BuildContext context) { 287 - final record = tryParseRecord(post.record); 288 - final createdAt = record?.createdAt ?? post.indexedAt; 289 - final moderationService = maybeModerationService(context); 290 - final postUi = 291 - moderationService?.postUi(post, bsky_moderation.ModerationBehaviorContext.contentList) ?? 292 - const bsky_moderation.ModerationUI(); 293 - 294 - return Card( 295 - margin: const EdgeInsets.symmetric(horizontal: 0, vertical: 1), 296 - elevation: 0, 297 - shape: const RoundedRectangleBorder(), 298 - child: ModeratedBlurOverlay( 299 - ui: postUi, 300 - child: Padding( 301 - padding: const EdgeInsets.all(16), 302 - child: Column( 303 - crossAxisAlignment: CrossAxisAlignment.start, 304 - children: [ 305 - _buildHeader(context, post.author, createdAt), 306 - if (postUi.alert || postUi.inform) ...[const SizedBox(height: 10), ModerationBadgeRow(ui: postUi)], 307 - if (record != null && record.text.isNotEmpty) ...[ 308 - const SizedBox(height: 12), 309 - FacetText(text: record.text, facets: record.facets, style: context.textTheme.bodyLarge), 310 - ], 311 - const SizedBox(height: 12), 312 - _buildActions(context), 313 - ], 314 - ), 315 - ), 316 - ), 317 - ); 318 - } 319 - 320 - Widget _buildHeader(BuildContext context, ProfileViewBasic author, DateTime createdAt) { 321 - final moderationService = maybeModerationService(context); 322 - final avatarUi = 323 - moderationService?.profileBasicUi(author, bsky_moderation.ModerationBehaviorContext.avatar) ?? 324 - const bsky_moderation.ModerationUI(); 325 - 326 - return InkWell( 327 - onTap: () => navigateToProfile(context, author.did), 328 - child: Row( 329 - crossAxisAlignment: CrossAxisAlignment.start, 330 - children: [ 331 - ProfileAvatar( 332 - size: 44, 333 - moderationUi: avatarUi, 334 - imageUrl: author.avatar, 335 - fallbackText: author.displayName ?? author.handle, 336 - shape: BoxShape.circle, 337 - ), 338 - const SizedBox(width: 12), 339 - Expanded( 340 - child: Column( 341 - crossAxisAlignment: CrossAxisAlignment.start, 342 - children: [ 343 - Text( 344 - author.displayName ?? author.handle, 345 - style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), 346 - maxLines: 1, 347 - overflow: TextOverflow.ellipsis, 348 - ), 349 - const SizedBox(height: 2), 350 - Text( 351 - '@${author.handle} · ${formatRelativeTime(createdAt)}', 352 - style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), 353 - ), 354 - ], 355 - ), 356 - ), 357 - ], 358 - ), 359 - ); 360 - } 361 - 362 - Widget _buildActions(BuildContext context) { 363 - return Row( 364 - mainAxisAlignment: MainAxisAlignment.spaceAround, 365 - children: [ 366 - _buildActionButton(context, Icons.chat_bubble_outline, '${post.replyCount ?? 0}'), 367 - _buildActionButton(context, Icons.repeat, '${post.repostCount ?? 0}'), 368 - _buildActionButton(context, Icons.favorite_border, '${post.likeCount ?? 0}'), 369 - _buildActionButton(context, Icons.share_outlined, ''), 370 - ], 371 - ); 372 - } 373 - 374 - Widget _buildActionButton(BuildContext context, IconData icon, String count) { 375 - final iconColor = context.colorScheme.onSurfaceVariant; 376 - 377 - return InkWell( 378 - onTap: () {}, 379 - borderRadius: BorderRadius.circular(999), 380 - child: Padding( 381 - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 382 - child: Row( 383 - children: [ 384 - Icon(icon, size: 18, color: iconColor), 385 - if (count.isNotEmpty) ...[ 386 - const SizedBox(width: 4), 387 - Text(count, style: context.textTheme.bodySmall?.copyWith(color: iconColor)), 388 - ], 389 - ], 390 - ), 391 - ), 392 - ); 393 - } 394 - }
+13 -119
lib/features/search/presentation/search_screen.dart
··· 10 10 import 'package:lazurite/core/theme/animation_utils.dart'; 11 11 import 'package:lazurite/core/theme/theme_extensions.dart'; 12 12 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 13 - import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 13 + import 'package:lazurite/features/feed/presentation/widgets/compact_post_card.dart'; 14 + import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; 14 15 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 15 - import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 16 16 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 17 17 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 18 18 import 'package:lazurite/features/search/data/post_search_filters.dart'; ··· 28 28 import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 29 29 import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 30 30 import 'package:lazurite/shared/utils/format_utils.dart'; 31 - import 'package:lazurite/shared/utils/parse_utils.dart'; 32 31 import 'package:url_launcher/url_launcher.dart'; 33 32 34 33 class SearchScreen extends StatefulWidget { ··· 871 870 itemKey: post.uri.toString(), 872 871 index: index, 873 872 seenKeys: _seenResultKeys, 874 - child: _PostViewCard(post: post), 873 + child: CompactPostCard( 874 + feedViewPost: FeedViewPost(post: post), 875 + onTap: () => context.push('/post?uri=${Uri.encodeQueryComponent(post.uri.toString())}'), 876 + footer: PostCardFooter( 877 + timestamp: formatPostTime(post.indexedAt), 878 + replyCount: post.replyCount ?? 0, 879 + repostCount: post.repostCount ?? 0, 880 + likeCount: post.likeCount ?? 0, 881 + showCounts: true, 882 + ), 883 + ), 875 884 ); 876 885 }, 877 886 ); ··· 1010 1019 if (!launched && mounted) { 1011 1020 showAppSnackBar(context, 'Could not open issue link.'); 1012 1021 } 1013 - } 1014 - } 1015 - 1016 - class _PostViewCard extends StatelessWidget { 1017 - const _PostViewCard({required this.post}); 1018 - 1019 - final PostView post; 1020 - 1021 - @override 1022 - Widget build(BuildContext context) { 1023 - final record = tryParseRecord(post.record); 1024 - final createdAt = record?.createdAt ?? post.indexedAt; 1025 - final moderationService = maybeModerationService(context); 1026 - final postUi = 1027 - moderationService?.postUi(post, bsky_moderation.ModerationBehaviorContext.contentList) ?? 1028 - const bsky_moderation.ModerationUI(); 1029 - 1030 - return Card( 1031 - margin: const EdgeInsets.symmetric(horizontal: 0, vertical: 1), 1032 - elevation: 0, 1033 - shape: const RoundedRectangleBorder(), 1034 - child: ModeratedBlurOverlay( 1035 - ui: postUi, 1036 - child: Padding( 1037 - padding: const EdgeInsets.all(16), 1038 - child: Column( 1039 - crossAxisAlignment: CrossAxisAlignment.start, 1040 - children: [ 1041 - _buildHeader(context, post.author, createdAt), 1042 - if (postUi.alert || postUi.inform) ...[const SizedBox(height: 10), ModerationBadgeRow(ui: postUi)], 1043 - if (record != null && record.text.isNotEmpty) ...[ 1044 - const SizedBox(height: 12), 1045 - FacetText(text: record.text, facets: record.facets, style: context.textTheme.bodyLarge), 1046 - ], 1047 - const SizedBox(height: 12), 1048 - _buildActions(context), 1049 - ], 1050 - ), 1051 - ), 1052 - ), 1053 - ); 1054 - } 1055 - 1056 - Widget _buildHeader(BuildContext context, ProfileViewBasic author, DateTime createdAt) { 1057 - final moderationService = maybeModerationService(context); 1058 - final avatarUi = 1059 - moderationService?.profileBasicUi(author, bsky_moderation.ModerationBehaviorContext.avatar) ?? 1060 - const bsky_moderation.ModerationUI(); 1061 - return InkWell( 1062 - onTap: () => navigateToProfile(context, author.did), 1063 - child: Row( 1064 - crossAxisAlignment: CrossAxisAlignment.start, 1065 - children: [ 1066 - ProfileAvatar( 1067 - size: 44, 1068 - moderationUi: avatarUi, 1069 - imageUrl: author.avatar, 1070 - fallbackText: author.displayName ?? author.handle, 1071 - shape: BoxShape.circle, 1072 - ), 1073 - const SizedBox(width: 12), 1074 - Expanded( 1075 - child: Column( 1076 - crossAxisAlignment: CrossAxisAlignment.start, 1077 - children: [ 1078 - Text( 1079 - author.displayName ?? author.handle, 1080 - style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), 1081 - maxLines: 1, 1082 - overflow: TextOverflow.ellipsis, 1083 - ), 1084 - const SizedBox(height: 2), 1085 - Text( 1086 - '@${author.handle} · ${formatRelativeTime(createdAt)}', 1087 - style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), 1088 - ), 1089 - ], 1090 - ), 1091 - ), 1092 - ], 1093 - ), 1094 - ); 1095 - } 1096 - 1097 - Widget _buildActions(BuildContext context) { 1098 - return Row( 1099 - mainAxisAlignment: MainAxisAlignment.spaceAround, 1100 - children: [ 1101 - _buildActionButton(context, Icons.chat_bubble_outline, '${post.replyCount ?? 0}'), 1102 - _buildActionButton(context, Icons.repeat, '${post.repostCount ?? 0}'), 1103 - _buildActionButton(context, Icons.favorite_border, '${post.likeCount ?? 0}'), 1104 - _buildActionButton(context, Icons.share_outlined, ''), 1105 - ], 1106 - ); 1107 - } 1108 - 1109 - Widget _buildActionButton(BuildContext context, IconData icon, String count) { 1110 - final iconColor = context.colorScheme.onSurfaceVariant; 1111 - 1112 - return InkWell( 1113 - onTap: () {}, 1114 - borderRadius: BorderRadius.circular(999), 1115 - child: Padding( 1116 - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 1117 - child: Row( 1118 - children: [ 1119 - Icon(icon, size: 18, color: iconColor), 1120 - if (count.isNotEmpty) ...[ 1121 - const SizedBox(width: 4), 1122 - Text(count, style: context.textTheme.bodySmall?.copyWith(color: iconColor)), 1123 - ], 1124 - ], 1125 - ), 1126 - ), 1127 - ); 1128 1022 } 1129 1023 } 1130 1024
+27 -68
test/features/feed/presentation/home_feed_screen_test.dart
··· 132 132 }); 133 133 }); 134 134 135 - group('FeedLayoutView — grid architecture', () { 136 - testWidgets('shows SliverGrid when architecture is grid', (tester) async { 137 - await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 720)); 138 - expect(find.byType(SliverGrid), findsOneWidget); 135 + group('FeedLayoutView — compact architecture', () { 136 + testWidgets('shows compact sliver list when layout is compact', (tester) async { 137 + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.compact, screenWidth: 720)); 138 + expect(find.byType(SliverGrid), findsNothing); 139 + expect(find.byType(SliverList), findsOneWidget); 139 140 expect(find.byType(CustomScrollView), findsOneWidget); 140 141 }); 141 142 142 - testWidgets('uses gridItemBuilder in grid mode', (tester) async { 143 - await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card)); 143 + testWidgets('uses compact item builder in compact mode', (tester) async { 144 + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.compact)); 144 145 expect(find.text('grid 0'), findsOneWidget); 145 146 expect(find.text('linear 0'), findsNothing); 146 147 }); 147 148 148 - testWidgets('uses 1 column at width < 600', (tester) async { 149 - await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 400)); 150 - 151 - expect(find.byType(SliverGrid), findsNothing); 152 - expect(find.byType(SliverList), findsOneWidget); 153 - }); 154 - 155 - testWidgets('uses tighter single-column padding at phone widths', (tester) async { 156 - await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 400)); 149 + testWidgets('uses compact padding in compact mode', (tester) async { 150 + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.compact, screenWidth: 400)); 157 151 158 152 final padding = tester.widget<SliverPadding>(find.byType(SliverPadding)); 159 - expect(padding.padding, const EdgeInsets.fromLTRB(12, 8, 12, 12)); 160 - }); 161 - 162 - testWidgets('uses 2 columns at width 600–839', (tester) async { 163 - await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 720)); 164 - 165 - final grid = tester.widget<SliverGrid>(find.byType(SliverGrid)); 166 - final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; 167 - expect(delegate.crossAxisCount, 2); 168 - }); 169 - 170 - testWidgets('uses 3 columns at width 840–1199', (tester) async { 171 - await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 1000)); 172 - 173 - final grid = tester.widget<SliverGrid>(find.byType(SliverGrid)); 174 - final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; 175 - expect(delegate.crossAxisCount, 3); 176 - }); 177 - 178 - testWidgets('uses 4 columns at width >= 1200', (tester) async { 179 - await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 1400)); 180 - 181 - final grid = tester.widget<SliverGrid>(find.byType(SliverGrid)); 182 - final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; 183 - expect(delegate.crossAxisCount, 4); 184 - }); 185 - 186 - testWidgets('allocates extra height beyond the square media region', (tester) async { 187 - await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card, screenWidth: 720)); 188 - 189 - final grid = tester.widget<SliverGrid>(find.byType(SliverGrid)); 190 - final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; 191 - const tileWidth = (720.0 - 1.0) / 2; 192 - 193 - expect(delegate.mainAxisExtent, isNotNull); 194 - expect(delegate.mainAxisExtent!, greaterThan(tileWidth + 100)); 153 + expect(padding.padding, const EdgeInsets.fromLTRB(8, 4, 8, 8)); 195 154 }); 196 155 }); 197 156 198 - group('FeedLayoutView — linear architecture', () { 199 - testWidgets('shows ListView when architecture is linear', (tester) async { 200 - await tester.pumpWidget(_buildSubject(architecture: FeedLayout.compact)); 157 + group('FeedLayoutView — card architecture', () { 158 + testWidgets('shows ListView when layout is card', (tester) async { 159 + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card)); 201 160 202 161 expect(find.byType(ListView), findsOneWidget); 203 162 expect(find.byType(SliverGrid), findsNothing); 204 163 }); 205 164 206 - testWidgets('uses linearItemBuilder in linear mode', (tester) async { 207 - await tester.pumpWidget(_buildSubject(architecture: FeedLayout.compact)); 165 + testWidgets('uses card item builder in card mode', (tester) async { 166 + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card)); 208 167 209 168 expect(find.text('linear 0'), findsOneWidget); 210 169 expect(find.text('linear 1'), findsOneWidget); 211 170 expect(find.text('linear 2'), findsOneWidget); 212 171 }); 213 172 214 - testWidgets('uses tighter vertical spacing in linear mode', (tester) async { 215 - await tester.pumpWidget(_buildSubject(architecture: FeedLayout.compact)); 173 + testWidgets('uses tighter vertical spacing in card mode', (tester) async { 174 + await tester.pumpWidget(_buildSubject(architecture: FeedLayout.card)); 216 175 217 176 final listView = tester.widget<ListView>(find.byType(ListView)); 218 177 expect(listView.padding, const EdgeInsets.symmetric(vertical: 4)); ··· 220 179 }); 221 180 222 181 group('FeedLayoutView — architecture switching', () { 223 - testWidgets('switches from grid to linear without re-fetch', (tester) async { 182 + testWidgets('switches from compact to card without re-fetch', (tester) async { 224 183 final cubit = MockSettingsCubit(); 225 184 final streamController = StreamController<SettingsState>.broadcast(); 226 185 227 - when(() => cubit.state).thenReturn(_settingsState(FeedLayout.card)); 186 + when(() => cubit.state).thenReturn(_settingsState(FeedLayout.compact)); 228 187 when(() => cubit.stream).thenAnswer((_) => streamController.stream); 229 188 230 189 var buildCount = 0; ··· 250 209 ), 251 210 ); 252 211 253 - expect(find.byType(SliverGrid), findsOneWidget); 212 + expect(find.byType(SliverList), findsOneWidget); 254 213 255 - when(() => cubit.state).thenReturn(_settingsState(FeedLayout.compact)); 256 - streamController.add(_settingsState(FeedLayout.compact)); 214 + when(() => cubit.state).thenReturn(_settingsState(FeedLayout.card)); 215 + streamController.add(_settingsState(FeedLayout.card)); 257 216 await tester.pump(); 258 217 259 - expect(find.byType(SliverGrid), findsNothing); 218 + expect(find.byType(CustomScrollView), findsNothing); 260 219 expect(find.byType(ListView), findsOneWidget); 261 220 expect(buildCount, 0); 262 221 263 222 await streamController.close(); 264 223 }); 265 224 266 - testWidgets('loading indicator appears when isLoadingMore is true in grid mode', (tester) async { 225 + testWidgets('loading indicator appears when isLoadingMore is true in compact mode', (tester) async { 267 226 final cubit = MockSettingsCubit(); 268 - when(() => cubit.state).thenReturn(_settingsState(FeedLayout.card)); 227 + when(() => cubit.state).thenReturn(_settingsState(FeedLayout.compact)); 269 228 270 229 await tester.pumpWidget( 271 230 MediaQuery( ··· 291 250 expect(find.byType(CircularProgressIndicator), findsOneWidget); 292 251 }); 293 252 294 - testWidgets('loading indicator appears when isLoadingMore is true in linear mode', (tester) async { 253 + testWidgets('loading indicator appears when isLoadingMore is true in card mode', (tester) async { 295 254 final cubit = MockSettingsCubit(); 296 - when(() => cubit.state).thenReturn(_settingsState(FeedLayout.compact)); 255 + when(() => cubit.state).thenReturn(_settingsState(FeedLayout.card)); 297 256 298 257 await tester.pumpWidget( 299 258 MediaQuery(
+18 -18
test/features/profile/presentation/profile_screen_test.dart
··· 657 657 ); 658 658 } 659 659 660 - testWidgets('grid mode shows centered large grid cards without the metadata info card', (tester) async { 660 + testWidgets('compact mode shows compact feed cards without metadata info card', (tester) async { 661 661 final cubit = MockSettingsCubit(); 662 - when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.card)); 663 - whenListen(cubit, const Stream<SettingsState>.empty(), initialState: settingsStateWith(FeedLayout.card)); 662 + when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.compact)); 663 + whenListen(cubit, const Stream<SettingsState>.empty(), initialState: settingsStateWith(FeedLayout.compact)); 664 664 665 665 await tester.pumpWidget(buildWithPosts(tester, cubit)); 666 666 await tester.pump(); 667 667 668 - expect(find.byKey(const ValueKey('profile_grid_feed')), findsOneWidget); 668 + expect(find.byKey(const ValueKey('profile_compact_feed')), findsOneWidget); 669 669 expect(find.byKey(const ValueKey('profile_info_card')), findsNothing); 670 - expect(find.byKey(const ValueKey('profile_large_card_0')), findsOneWidget); 671 - expect(find.byKey(const ValueKey('profile_large_card_1')), findsOneWidget); 672 - expect(find.byKey(const ValueKey('profile_large_card_2')), findsOneWidget); 670 + expect(find.byKey(const ValueKey('profile_compact_card_0')), findsOneWidget); 671 + expect(find.byKey(const ValueKey('profile_compact_card_1')), findsOneWidget); 672 + expect(find.byKey(const ValueKey('profile_compact_card_2')), findsOneWidget); 673 673 }); 674 674 675 - testWidgets('linear mode does not show the large grid card feed or metadata info card', (tester) async { 675 + testWidgets('card mode does not show compact feed keys or metadata info card', (tester) async { 676 676 final cubit = MockSettingsCubit(); 677 - when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.compact)); 678 - whenListen(cubit, const Stream<SettingsState>.empty(), initialState: settingsStateWith(FeedLayout.compact)); 677 + when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.card)); 678 + whenListen(cubit, const Stream<SettingsState>.empty(), initialState: settingsStateWith(FeedLayout.card)); 679 679 680 680 await tester.pumpWidget(buildWithPosts(tester, cubit)); 681 681 await tester.pump(); 682 682 683 - expect(find.byKey(const ValueKey('profile_grid_feed')), findsNothing); 683 + expect(find.byKey(const ValueKey('profile_compact_feed')), findsNothing); 684 684 expect(find.byKey(const ValueKey('profile_info_card')), findsNothing); 685 - expect(find.byKey(const ValueKey('profile_large_card_0')), findsNothing); 685 + expect(find.byKey(const ValueKey('profile_compact_card_0')), findsNothing); 686 686 }); 687 687 688 - testWidgets('switching from grid to linear removes the large grid feed without re-fetch', (tester) async { 688 + testWidgets('switching from compact to card removes compact feed without re-fetch', (tester) async { 689 689 final cubit = MockSettingsCubit(); 690 690 final streamCtrl = StreamController<SettingsState>.broadcast(); 691 691 692 - when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.card)); 692 + when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.compact)); 693 693 when(() => cubit.stream).thenAnswer((_) => streamCtrl.stream); 694 694 695 695 await tester.pumpWidget(buildWithPosts(tester, cubit)); 696 696 await tester.pump(); 697 697 698 - expect(find.byKey(const ValueKey('profile_grid_feed')), findsOneWidget); 698 + expect(find.byKey(const ValueKey('profile_compact_feed')), findsOneWidget); 699 699 expect(find.byKey(const ValueKey('profile_info_card')), findsNothing); 700 700 701 - when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.compact)); 702 - streamCtrl.add(settingsStateWith(FeedLayout.compact)); 701 + when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.card)); 702 + streamCtrl.add(settingsStateWith(FeedLayout.card)); 703 703 await tester.pumpAndSettle(); 704 704 705 - expect(find.byKey(const ValueKey('profile_grid_feed')), findsNothing); 705 + expect(find.byKey(const ValueKey('profile_compact_feed')), findsNothing); 706 706 expect(find.byKey(const ValueKey('profile_info_card')), findsNothing); 707 707 708 708 verifyNever(() => feedBloc.add(const FeedRefreshRequested()));