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.

fix: embed layout

+314 -111
+74
docs/specs/ui-refactor.md
··· 196 196 `feed_architecture` (`grid` | `linear`) in the Drift `settings` table. Expose 197 197 via `SettingsCubit` alongside existing theme preferences. 198 198 199 + ## Post Thread — Collapsible Threaded Replies 200 + 201 + Current: `PostThreadScreen` renders the parent chain with vertical connectors, 202 + then a flat list of direct replies. No nesting, no collapse controls. 203 + 204 + Target: recursive threaded reply tree with indented threadlines and 205 + tap-to-collapse interaction. 206 + 207 + ### Reply Tree Rendering 208 + 209 + Replace the flat reply list with a recursive widget tree that mirrors the 210 + `ThreadViewPost.replies` structure from the Bluesky API. Each reply that itself 211 + has replies renders its children indented one level deeper. 212 + 213 + - Use a recursive `ThreadReplyNode` widget that takes a `ThreadViewPost` and a 214 + `depth` parameter 215 + - Each node renders its post via `PostCardWithActions`, followed by its children 216 + at `depth + 1` 217 + 218 + ### Threadlines & Indentation 219 + 220 + - Each nesting level draws a vertical threadline on its left edge — a `2px`-wide 221 + line in `outlineVariant`, offset `37px` from the current indent origin 222 + (matching the existing parent-chain connector) 223 + - Indentation per level: `24px` left padding, applied cumulatively 224 + - **Color-coded threadlines**: cycle through a palette of 6 muted colors per 225 + depth level to help users visually track nesting (colors derived from the 226 + theme's `outline` / `outlineVariant` / `primary` tones) 227 + - Cap visual indentation at **depth 6**. Beyond that, show a 228 + "Continue this thread →" link that navigates to a new `PostThreadScreen` 229 + rooted at that reply 230 + 231 + ### Collapse / Expand Interaction 232 + 233 + Two interaction methods (both always active): 234 + 235 + | Method | Detail | 236 + | --------------------- | ---------------------------------------------------------------------- | 237 + | **Tap threadline** | Tap the vertical threadline to collapse/expand the subtree beneath it | 238 + | **Long-press comment**| Long-press the post body as a secondary affordance | 239 + 240 + Threadline tap target: `24dp` wide (centered on the `2px` line) for comfortable 241 + touch targets, with a subtle highlight on press. 242 + 243 + ### Collapsed State 244 + 245 + When a subtree is collapsed: 246 + 247 + - The parent comment's header row (avatar, handle, timestamp) remains visible 248 + - Body text and children are hidden 249 + - A collapsed indicator appears below the header: `"N replies hidden"` in 250 + `labelSmall`, `onSurfaceVariant`, uppercase, `letterSpacing: 0.1em` 251 + - Smooth `AnimatedCrossFade` or `AnimatedSize` transition (duration `200ms`) 252 + 253 + ### Auto-Collapse (Optional Setting) 254 + 255 + Add a `thread_auto_collapse_depth` setting (`int`, default `null` = disabled). 256 + When set to a value (e.g., `3`), replies deeper than that level are 257 + auto-collapsed on initial load. The user can still expand them manually. 258 + 259 + - Persist in the Drift `settings` table alongside existing layout settings 260 + - Never auto-collapse replies by the thread's original poster (OP) 261 + - Expose in the Layout Settings screen as a stepper/dropdown below the existing 262 + density and architecture options 263 + 264 + ### State Management 265 + 266 + - Collapse state is local to the `PostThreadScreen` — a `Set<String>` of 267 + collapsed post URIs held in the screen's `State` 268 + - No cubit needed; collapse is ephemeral UI state, not persisted across 269 + navigations 270 + - When navigating into a "Continue this thread" link, the new screen manages its 271 + own collapse state independently 272 + 199 273 ## Shared Geometry Tokens 200 274 201 275 All `0px` border-radius throughout (square corners). Ensure no Flutter widgets
+16
docs/tasks/ui-refactor.md
··· 53 53 - [ ] Settings screen entry point (new section or drawer link) 54 54 - [ ] Persist selections to Drift on change 55 55 - [ ] Tests for settings screen interactions and persistence round-trip 56 + 57 + ## M6 — Collapsible Threaded Replies 58 + 59 + - [ ] Recursive `ThreadReplyNode` widget that renders nested replies from `ThreadViewPost.replies` 60 + - [ ] Indentation with cumulative `24px` left padding per depth level 61 + - [ ] Color-coded vertical threadlines (cycle palette of 6 muted theme-derived colors) 62 + - [ ] Tap-threadline-to-collapse interaction with `24dp` touch target 63 + - [ ] Long-press-to-collapse as secondary affordance 64 + - [ ] Collapsed state: header visible, body/children hidden, "N replies hidden" indicator 65 + - [ ] `AnimatedSize` / `AnimatedCrossFade` collapse transition (`200ms`) 66 + - [ ] Depth cap at 6 with "Continue this thread →" navigation link 67 + - [ ] Local collapse state via `Set<String>` of post URIs in screen `State` 68 + - [ ] `thread_auto_collapse_depth` setting in Drift + Drift migration 69 + - [ ] Expose auto-collapse depth in Layout Settings screen 70 + - [ ] Never auto-collapse OP replies 71 + - [ ] Tests for thread tree rendering, collapse/expand, depth cap, and auto-collapse behavior
+16 -5
lib/features/feed/presentation/widgets/grid_post_card.dart
··· 7 7 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 8 8 import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; 9 9 import 'package:lazurite/features/feed/presentation/widgets/post_embed_view.dart'; 10 + import 'package:lazurite/features/feed/presentation/widgets/post_text_styles.dart'; 10 11 11 12 const _greyscale = ColorFilter.matrix(<double>[ 12 13 0.2126, ··· 55 56 final primaryImageUrl = _extractPrimaryImageUrl(post.embed); 56 57 final bodyText = record?.text ?? ''; 57 58 final colorScheme = Theme.of(context).colorScheme; 59 + final isCompactGrid = MediaQuery.of(context).size.width >= 600; 58 60 59 61 final contentEmbed = primaryImageUrl == null && post.embed != null 60 - ? PostEmbedView(feedViewPost: feedViewPost, embed: post.embed!) 62 + ? PostEmbedView(feedViewPost: feedViewPost, embed: post.embed!, compact: isCompactGrid) 61 63 : null; 62 64 63 65 final resolvedFooter = footer ?? PostCardFooter(timestamp: formatPostTime(record?.createdAt ?? post.indexedAt)); ··· 98 100 FacetText( 99 101 text: bodyText, 100 102 facets: record?.facets, 101 - style: Theme.of(context).textTheme.titleMedium?.copyWith(letterSpacing: -0.5), 103 + style: feedPostBodyTextStyle(context), 102 104 maxLines: 6, 103 105 overflow: TextOverflow.ellipsis, 104 106 ) 107 + else if (!isCompactGrid) 108 + FacetText(text: bodyText, facets: record?.facets, style: feedPostBodyTextStyle(context)) 105 109 else 106 110 FacetText( 107 111 text: bodyText, 108 112 facets: record?.facets, 109 - style: Theme.of(context).textTheme.bodySmall, 113 + style: feedPostBodyTextStyle(context, compact: true), 110 114 maxLines: 2, 111 115 overflow: TextOverflow.ellipsis, 112 116 ), 113 117 ], 114 - if (contentEmbed != null) ...[const SizedBox(height: 8), _buildEmbedPreview(contentEmbed)], 118 + if (contentEmbed != null) ...[ 119 + const SizedBox(height: 8), 120 + _buildEmbedPreview(contentEmbed, compact: isCompactGrid), 121 + ], 115 122 ], 116 123 ), 117 124 ), ··· 175 182 ); 176 183 } 177 184 178 - Widget _buildEmbedPreview(Widget contentEmbed) { 185 + Widget _buildEmbedPreview(Widget contentEmbed, {required bool compact}) { 186 + if (!compact) { 187 + return contentEmbed; 188 + } 189 + 179 190 return SizedBox( 180 191 height: _gridEmbedPreviewMaxHeight, 181 192 child: ClipRect(
+2 -7
lib/features/feed/presentation/widgets/post_card.dart
··· 6 6 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 7 7 import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; 8 8 import 'package:lazurite/features/feed/presentation/widgets/post_embed_view.dart'; 9 + import 'package:lazurite/features/feed/presentation/widgets/post_text_styles.dart'; 9 10 10 11 class PostCard extends StatelessWidget { 11 12 const PostCard({super.key, required this.feedViewPost, this.actionBar, this.onTap}); ··· 49 50 if (record?.reply != null) ...[const SizedBox(height: 8), _buildReplyLabel(context)], 50 51 if (record != null && record.text.isNotEmpty) ...[ 51 52 const SizedBox(height: 12), 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 - ), 53 + FacetText(text: record.text, facets: record.facets, style: feedPostBodyTextStyle(context)), 59 54 ], 60 55 if (post.embed != null) ...[ 61 56 const SizedBox(height: 12),
+40 -16
lib/features/feed/presentation/widgets/post_embed_view.dart
··· 11 11 import 'package:lazurite/features/feed/presentation/media/media_actions.dart'; 12 12 import 'package:lazurite/features/feed/presentation/media/video_player_route_args.dart'; 13 13 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 14 + import 'package:lazurite/features/feed/presentation/widgets/post_text_styles.dart'; 14 15 import 'package:url_launcher/url_launcher.dart'; 15 16 16 17 /// Renders the appropriate embed widget for a post embed. ··· 18 19 /// Handles images, external links, videos, quoted records, and record-with-media. 19 20 /// Used by both [PostCard] and [GridPostCard]. 20 21 class PostEmbedView extends StatelessWidget { 21 - const PostEmbedView({super.key, required this.feedViewPost, required this.embed}); 22 + const PostEmbedView({super.key, required this.feedViewPost, required this.embed, this.compact = false}); 22 23 23 24 final FeedViewPost feedViewPost; 24 25 final UPostViewEmbed embed; 26 + final bool compact; 25 27 26 28 @override 27 29 Widget build(BuildContext context) { ··· 114 116 } 115 117 116 118 Widget _buildExternalEmbed(BuildContext context, EmbedExternalViewExternal external) { 119 + final theme = Theme.of(context); 120 + final colorScheme = theme.colorScheme; 121 + 117 122 return InkWell( 118 123 onTap: () => _launchExternal(Uri.parse(external.uri)), 119 124 child: Container( 120 - decoration: BoxDecoration(border: Border.all(color: Theme.of(context).dividerColor)), 125 + clipBehavior: Clip.antiAlias, 126 + decoration: BoxDecoration( 127 + border: Border.all(color: colorScheme.outlineVariant), 128 + borderRadius: BorderRadius.circular(12), 129 + color: colorScheme.surfaceContainerLow, 130 + ), 121 131 child: Column( 122 132 crossAxisAlignment: CrossAxisAlignment.start, 123 133 children: [ 124 134 if (external.thumb != null) 125 135 Image.network( 126 136 external.thumb!, 127 - height: 160, 137 + height: compact ? 140 : 180, 128 138 width: double.infinity, 129 139 fit: BoxFit.cover, 130 140 errorBuilder: (_, _, _) => const SizedBox(height: 0), ··· 136 146 children: [ 137 147 Text( 138 148 external.title, 139 - style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), 149 + style: theme.textTheme.bodyLarge?.copyWith( 150 + fontWeight: FontWeight.w700, 151 + color: colorScheme.onSurface, 152 + ), 140 153 ), 141 154 if (external.description.isNotEmpty) ...[ 142 155 const SizedBox(height: 4), 143 156 Text( 144 157 external.description, 145 - maxLines: 3, 146 - overflow: TextOverflow.ellipsis, 147 - style: Theme.of(context).textTheme.bodyMedium, 158 + maxLines: compact ? 3 : null, 159 + overflow: compact ? TextOverflow.ellipsis : TextOverflow.visible, 160 + style: theme.textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant, height: 1.4), 148 161 ), 149 162 ], 150 163 const SizedBox(height: 8), 151 164 Text( 152 165 Uri.parse(external.uri).host, 153 - style: Theme.of( 154 - context, 155 - ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 166 + style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 156 167 ), 157 168 ], 158 169 ), ··· 210 221 211 222 Widget _buildQuotedRecord(BuildContext context, EmbedRecordView recordView) { 212 223 final record = recordView.record; 224 + final theme = Theme.of(context); 225 + final colorScheme = theme.colorScheme; 213 226 214 227 if (record.isEmbedRecordViewRecord) { 215 228 final quoted = record.embedRecordViewRecord!; ··· 217 230 final nestedEmbed = _buildQuotedEmbeds(context, quoted.embeds); 218 231 219 232 return Container( 220 - decoration: BoxDecoration(border: Border.all(color: Theme.of(context).dividerColor)), 233 + decoration: BoxDecoration( 234 + border: Border.all(color: colorScheme.outlineVariant), 235 + borderRadius: BorderRadius.circular(12), 236 + color: colorScheme.surfaceContainerLow, 237 + ), 221 238 child: InkWell( 239 + borderRadius: BorderRadius.circular(12), 222 240 onTap: () { 223 241 GoRouter.maybeOf(context)?.push('/post?uri=${Uri.encodeComponent(quoted.uri.toString())}'); 224 242 }, ··· 246 264 '${quoted.author.displayName ?? quoted.author.handle} @${quoted.author.handle}', 247 265 maxLines: 1, 248 266 overflow: TextOverflow.ellipsis, 249 - style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), 267 + style: theme.textTheme.bodyMedium?.copyWith( 268 + fontWeight: FontWeight.w600, 269 + color: colorScheme.onSurface, 270 + ), 250 271 ), 251 272 ), 252 273 ], ··· 256 277 FacetText( 257 278 text: quotedRecord.text, 258 279 facets: quotedRecord.facets, 259 - style: Theme.of(context).textTheme.bodyMedium, 260 - maxLines: 6, 261 - overflow: TextOverflow.ellipsis, 280 + style: feedPostBodyTextStyle(context, compact: compact, nested: true), 281 + maxLines: compact ? 6 : null, 282 + overflow: compact ? TextOverflow.ellipsis : TextOverflow.visible, 262 283 ), 263 284 ], 264 285 if (nestedEmbed != null) ...[const SizedBox(height: 8), nestedEmbed], ··· 286 307 return Container( 287 308 width: double.infinity, 288 309 padding: const EdgeInsets.all(12), 289 - color: Theme.of(context).colorScheme.surfaceContainerHighest, 310 + decoration: BoxDecoration( 311 + color: Theme.of(context).colorScheme.surfaceContainerLow, 312 + borderRadius: BorderRadius.circular(12), 313 + ), 290 314 child: Text( 291 315 label, 292 316 style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
+13
lib/features/feed/presentation/widgets/post_text_styles.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + TextStyle? feedPostBodyTextStyle(BuildContext context, {bool compact = false, bool nested = false}) { 4 + final theme = Theme.of(context); 5 + final color = theme.colorScheme.onSurface; 6 + 7 + if (compact) { 8 + return theme.textTheme.bodySmall?.copyWith(color: color, height: 1.45); 9 + } 10 + 11 + final baseStyle = nested ? theme.textTheme.titleSmall : theme.textTheme.titleMedium; 12 + return baseStyle?.copyWith(color: color, height: nested ? 1.5 : 1.55, letterSpacing: nested ? 0 : -0.35); 13 + }
+13 -72
lib/features/profile/presentation/profile_screen.dart
··· 100 100 101 101 FeedFilter get _currentFilter => _tabs[_tabController.index].filter; 102 102 103 + String _appBarTitle(ProfileViewDetailed? profile) { 104 + final authState = context.read<AuthBloc>().state; 105 + return profile?.displayName ?? profile?.handle ?? widget.actor ?? authState.tokens?.handle ?? 'Profile'; 106 + } 107 + 103 108 Future<void> _refresh() async { 104 109 context.read<ProfileBloc>().add(const ProfileRefreshRequested()); 105 110 context.read<FeedBloc>().add(const FeedRefreshRequested()); ··· 124 129 floating: true, 125 130 pinned: true, 126 131 snap: true, 127 - title: innerBoxIsScrolled ? Text(profile?.displayName ?? profile?.handle ?? 'Profile') : null, 132 + title: Text(_appBarTitle(profile)), 128 133 leading: widget.showBackButton 129 134 ? IconButton( 130 135 icon: const Icon(Icons.arrow_back), ··· 514 519 buildWhen: (prev, curr) => prev.feedArchitecture != curr.feedArchitecture, 515 520 builder: (context, settingsState) { 516 521 if (settingsState.feedArchitecture == FeedArchitecture.grid) { 517 - return _buildGridFeed(context, feedState, profile); 522 + return _buildGridFeed(context, feedState); 518 523 } 519 524 return _buildLinearFeed(context, feedState); 520 525 }, 521 526 ); 522 527 } 523 528 524 - Widget _buildGridFeed(BuildContext context, FeedState feedState, ProfileViewDetailed? profile) { 529 + Widget _buildGridFeed(BuildContext context, FeedState feedState) { 525 530 final accountDid = _resolvedActor ?? ''; 526 - final infoCardCount = profile == null ? 0 : 1; 527 531 528 532 return RefreshIndicator( 529 533 onRefresh: _refresh, ··· 539 543 child: ListView.builder( 540 544 key: const ValueKey('profile_grid_feed'), 541 545 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 542 - itemCount: infoCardCount + feedState.posts.length + (feedState.isLoadingMore ? 1 : 0), 546 + itemCount: feedState.posts.length + (feedState.isLoadingMore ? 1 : 0), 543 547 itemBuilder: (context, index) { 544 - if (profile != null && index == 0) { 545 - return Padding( 546 - padding: const EdgeInsets.only(bottom: 16), 547 - child: Center( 548 - child: ConstrainedBox( 549 - constraints: const BoxConstraints(maxWidth: 720), 550 - child: _ProfileInfoCard(profile: profile), 551 - ), 552 - ), 553 - ); 554 - } 555 - 556 - final postIndex = index - infoCardCount; 557 - 558 - if (postIndex >= feedState.posts.length) { 548 + if (index >= feedState.posts.length) { 559 549 return const Padding( 560 550 padding: EdgeInsets.all(16), 561 551 child: Center(child: CircularProgressIndicator()), ··· 563 553 } 564 554 565 555 return Padding( 566 - padding: EdgeInsets.only(bottom: postIndex == feedState.posts.length - 1 ? 0 : 16), 556 + padding: EdgeInsets.only(bottom: index == feedState.posts.length - 1 ? 0 : 16), 567 557 child: Center( 568 558 child: ConstrainedBox( 569 - key: ValueKey('profile_large_card_$postIndex'), 559 + key: ValueKey('profile_large_card_$index'), 570 560 constraints: const BoxConstraints(maxWidth: 720), 571 561 child: PostCardWithActions( 572 - feedViewPost: feedState.posts[postIndex], 562 + feedViewPost: feedState.posts[index], 573 563 accountDid: accountDid, 574 564 variant: PostCardVariant.grid, 575 565 ), ··· 639 629 final uri = Uri.tryParse(website.startsWith('http') ? website : 'https://$website'); 640 630 if (uri == null) return; 641 631 await launchUrl(uri, mode: LaunchMode.externalApplication); 642 - } 643 - } 644 - 645 - class _ProfileInfoCard extends StatelessWidget { 646 - const _ProfileInfoCard({required this.profile}); 647 - 648 - final ProfileViewDetailed profile; 649 - 650 - @override 651 - Widget build(BuildContext context) { 652 - final colorScheme = Theme.of(context).colorScheme; 653 - final textTheme = Theme.of(context).textTheme; 654 - 655 - return Container( 656 - key: const ValueKey('profile_info_card'), 657 - color: colorScheme.surfaceContainerHigh, 658 - padding: const EdgeInsets.all(16), 659 - child: Column( 660 - crossAxisAlignment: CrossAxisAlignment.start, 661 - children: [ 662 - Text( 663 - _formatCount(profile.postsCount ?? 0), 664 - style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), 665 - ), 666 - Text('POSTS', style: TextStyle(fontSize: 11, letterSpacing: 1.1, color: colorScheme.onSurfaceVariant)), 667 - const SizedBox(height: 12), 668 - Text( 669 - _formatCount(profile.followersCount ?? 0), 670 - style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), 671 - ), 672 - Text('FOLLOWERS', style: TextStyle(fontSize: 11, letterSpacing: 1.1, color: colorScheme.onSurfaceVariant)), 673 - if (profile.description?.isNotEmpty ?? false) ...[ 674 - const SizedBox(height: 12), 675 - Text( 676 - profile.description!, 677 - style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 678 - maxLines: 3, 679 - overflow: TextOverflow.ellipsis, 680 - ), 681 - ], 682 - ], 683 - ), 684 - ); 685 - } 686 - 687 - String _formatCount(int count) { 688 - if (count >= 1000000) return '${(count / 1000000).toStringAsFixed(1)}M'; 689 - if (count >= 1000) return '${(count / 1000).toStringAsFixed(1)}K'; 690 - return '$count'; 691 632 } 692 633 } 693 634
+58 -4
test/features/feed/presentation/grid_post_card_test.dart
··· 7 7 import 'package:flutter/material.dart'; 8 8 import 'package:flutter_test/flutter_test.dart'; 9 9 import 'package:go_router/go_router.dart'; 10 + import 'package:lazurite/core/theme/app_theme.dart'; 10 11 import 'package:lazurite/features/feed/presentation/widgets/grid_post_card.dart'; 11 12 import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; 12 13 ··· 24 25 ); 25 26 } 26 27 27 - Widget _buildSubject(FeedViewPost post, {VoidCallback? onTap}) { 28 + Widget _buildSubject(FeedViewPost post, {VoidCallback? onTap, Size size = const Size(390, 844)}) { 29 + final theme = AppTheme.getTheme(AppThemePalette.oxocarbon, AppThemeVariant.dark); 28 30 return MaterialApp( 29 - home: Scaffold( 30 - body: SingleChildScrollView( 31 - child: GridPostCard(feedViewPost: post, onTap: onTap), 31 + theme: theme, 32 + home: MediaQuery( 33 + data: MediaQueryData(size: size), 34 + child: Scaffold( 35 + body: SingleChildScrollView( 36 + child: GridPostCard(feedViewPost: post, onTap: onTap), 37 + ), 32 38 ), 33 39 ), 34 40 ); ··· 126 132 await tester.pumpWidget(_buildSubject(post)); 127 133 128 134 expect(find.text('Example Article'), findsOneWidget); 135 + expect(find.byWidgetPredicate((widget) => widget is SizedBox && widget.height == 240), findsNothing); 136 + }); 137 + 138 + testWidgets('keeps capped embed previews on wider compact grid layouts', (tester) async { 139 + final post = _makePost( 140 + text: 'Read this', 141 + embed: const UPostViewEmbed.embedExternalView( 142 + data: EmbedExternalView( 143 + external: EmbedExternalViewExternal( 144 + uri: 'https://example.com/article', 145 + title: 'Example Article', 146 + description: 'A useful external card', 147 + ), 148 + ), 149 + ), 150 + ); 151 + 152 + await tester.pumpWidget(_buildSubject(post, size: const Size(900, 1200))); 153 + 129 154 expect(find.byWidgetPredicate((widget) => widget is SizedBox && widget.height == 240), findsOneWidget); 155 + }); 156 + 157 + testWidgets('uses themed serif styling for embed-bearing posts on phone widths', (tester) async { 158 + final post = _makePost( 159 + text: 'Serif body copy with an external preview', 160 + embed: const UPostViewEmbed.embedExternalView( 161 + data: EmbedExternalView( 162 + external: EmbedExternalViewExternal( 163 + uri: 'https://example.com/article', 164 + title: 'Example Article', 165 + description: 'A useful external card', 166 + ), 167 + ), 168 + ), 169 + ); 170 + 171 + await tester.pumpWidget(_buildSubject(post)); 172 + 173 + final richText = tester.widget<RichText>( 174 + find.byWidgetPredicate( 175 + (widget) => widget is RichText && widget.text.toPlainText() == 'Serif body copy with an external preview', 176 + ), 177 + ); 178 + final style = (richText.text as TextSpan).style; 179 + final theme = AppTheme.getTheme(AppThemePalette.oxocarbon, AppThemeVariant.dark); 180 + 181 + expect(style?.fontFamily, theme.textTheme.titleMedium?.fontFamily); 182 + expect(style?.color, theme.colorScheme.onSurface); 183 + expect(richText.maxLines, isNull); 130 184 }); 131 185 132 186 testWidgets('uses square container for avatar — no CircleAvatar', (tester) async {
+63
test/features/feed/presentation/post_card_test.dart
··· 10 10 import 'package:flutter/material.dart'; 11 11 import 'package:flutter_test/flutter_test.dart'; 12 12 import 'package:go_router/go_router.dart'; 13 + import 'package:lazurite/core/theme/app_theme.dart'; 13 14 import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; 14 15 import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; 15 16 ··· 28 29 29 30 void main() { 30 31 Widget buildSubject(FeedViewPost post, {VoidCallback? onTap}) { 32 + final theme = AppTheme.getTheme(AppThemePalette.oxocarbon, AppThemeVariant.dark); 31 33 return MaterialApp( 34 + theme: theme, 32 35 home: Scaffold( 33 36 body: PostCard(feedViewPost: post, onTap: onTap), 34 37 ), ··· 98 101 expect(find.text('example.com'), findsOneWidget); 99 102 }); 100 103 104 + testWidgets('uses themed serif styling for post body text', (tester) async { 105 + final post = _makePost(text: 'Styled body copy'); 106 + 107 + await tester.pumpWidget(buildSubject(post)); 108 + 109 + final richText = tester.widget<RichText>( 110 + find.byWidgetPredicate((widget) => widget is RichText && widget.text.toPlainText() == 'Styled body copy'), 111 + ); 112 + final style = (richText.text as TextSpan).style; 113 + final theme = AppTheme.getTheme(AppThemePalette.oxocarbon, AppThemeVariant.dark); 114 + 115 + expect(style?.fontFamily, theme.textTheme.titleMedium?.fontFamily); 116 + expect(style?.color, theme.colorScheme.onSurface); 117 + expect(richText.maxLines, isNull); 118 + }); 119 + 101 120 testWidgets('calls onTap when content area is tapped', (tester) async { 102 121 var tapped = false; 103 122 final post = _makePost(); ··· 183 202 expect(pushedRoute, isNotNull); 184 203 expect(Uri.parse(pushedRoute!).path, '/post'); 185 204 expect(Uri.decodeComponent(Uri.parse(pushedRoute!).queryParameters['uri']!), quotedUri.toString()); 205 + }); 206 + 207 + testWidgets('renders quoted post text with serif styling and without truncation', (tester) async { 208 + final quotedRecord = FeedPostRecord( 209 + text: 'Quoted text that should fully expand inside the embed card', 210 + createdAt: DateTime.utc(2026, 3, 15), 211 + ); 212 + final post = FeedViewPost( 213 + post: PostView( 214 + uri: const AtUri('at://did:plc:test/app.bsky.feed.post/xyz'), 215 + cid: 'cid-xyz', 216 + author: const ProfileViewBasic(did: 'did:plc:test', handle: 'test.bsky.social'), 217 + record: FeedPostRecord(text: 'Main post', createdAt: DateTime.utc(2026, 3, 16)).toJson(), 218 + indexedAt: DateTime.utc(2026, 3, 16), 219 + embed: UPostViewEmbed.embedRecordView( 220 + data: EmbedRecordView( 221 + record: UEmbedRecordViewRecord.embedRecordViewRecord( 222 + data: EmbedRecordViewRecord( 223 + uri: AtUri.parse('at://did:plc:quoted/app.bsky.feed.post/quoted123'), 224 + cid: 'cid-quoted', 225 + author: const ProfileViewBasic(did: 'did:plc:quoted', handle: 'quoted.bsky.social'), 226 + value: quotedRecord.toJson(), 227 + indexedAt: DateTime.utc(2026, 3, 15), 228 + ), 229 + ), 230 + ), 231 + ), 232 + ), 233 + ); 234 + 235 + await tester.pumpWidget(buildSubject(post)); 236 + 237 + final richText = tester.widget<RichText>( 238 + find.byWidgetPredicate( 239 + (widget) => 240 + widget is RichText && 241 + widget.text.toPlainText() == 'Quoted text that should fully expand inside the embed card', 242 + ), 243 + ); 244 + final style = (richText.text as TextSpan).style; 245 + final theme = AppTheme.getTheme(AppThemePalette.oxocarbon, AppThemeVariant.dark); 246 + 247 + expect(style?.fontFamily, theme.textTheme.titleSmall?.fontFamily); 248 + expect(richText.maxLines, isNull); 186 249 }); 187 250 188 251 testWidgets('tapping avatar navigates to author profile', (tester) async {
+19 -7
test/features/profile/presentation/profile_screen_test.dart
··· 149 149 expect(find.text('Joined March 2024'), findsOneWidget); 150 150 }); 151 151 152 + testWidgets('app bar always shows the profile display name', (tester) async { 153 + useLargeScreen(tester); 154 + await tester.pumpWidget(buildSubject()); 155 + 156 + expect(find.text('River Tam'), findsOneWidget); 157 + }); 158 + 152 159 testWidgets('shows Saved Posts button on own profile', (tester) async { 153 160 useLargeScreen(tester); 154 161 await tester.pumpWidget(buildSubject()); ··· 278 285 await tester.pumpWidget(buildSubject()); 279 286 280 287 expect(find.text('RIVER TAM'), findsOneWidget); 281 - expect(find.text('River Tam'), findsNothing); 288 + expect(find.text('River Tam'), findsOneWidget); 282 289 }); 283 290 284 291 testWidgets('handle is shown with @ prefix', (tester) async { ··· 300 307 expect(find.byKey(const ValueKey('profile_stats_row')), findsOneWidget); 301 308 }); 302 309 310 + testWidgets('does not render the profile info card in the feed', (tester) async { 311 + useLargeScreen(tester); 312 + await tester.pumpWidget(buildSubject()); 313 + 314 + expect(find.byKey(const ValueKey('profile_info_card')), findsNothing); 315 + }); 316 + 303 317 testWidgets('stat values are shown as formatted counts', (tester) async { 304 318 useLargeScreen(tester); 305 319 await tester.pumpWidget(buildSubject()); ··· 395 409 ); 396 410 } 397 411 398 - testWidgets('grid mode shows centered large grid cards with the metadata info card', (tester) async { 412 + testWidgets('grid mode shows centered large grid cards without the metadata info card', (tester) async { 399 413 final cubit = MockSettingsCubit(); 400 414 when(() => cubit.state).thenReturn(settingsStateWith(FeedArchitecture.grid)); 401 415 whenListen(cubit, const Stream<SettingsState>.empty(), initialState: settingsStateWith(FeedArchitecture.grid)); ··· 404 418 await tester.pump(); 405 419 406 420 expect(find.byKey(const ValueKey('profile_grid_feed')), findsOneWidget); 407 - expect(find.byKey(const ValueKey('profile_info_card')), findsOneWidget); 421 + expect(find.byKey(const ValueKey('profile_info_card')), findsNothing); 408 422 expect(find.byKey(const ValueKey('profile_large_card_0')), findsOneWidget); 409 423 expect(find.byKey(const ValueKey('profile_large_card_1')), findsOneWidget); 410 424 expect(find.byKey(const ValueKey('profile_large_card_2')), findsOneWidget); ··· 423 437 expect(find.byKey(const ValueKey('profile_large_card_0')), findsNothing); 424 438 }); 425 439 426 - testWidgets('switching from grid to linear removes the large grid feed and metadata card without re-fetch', ( 427 - tester, 428 - ) async { 440 + testWidgets('switching from grid to linear removes the large grid feed without re-fetch', (tester) async { 429 441 final cubit = MockSettingsCubit(); 430 442 final streamCtrl = StreamController<SettingsState>.broadcast(); 431 443 ··· 436 448 await tester.pump(); 437 449 438 450 expect(find.byKey(const ValueKey('profile_grid_feed')), findsOneWidget); 439 - expect(find.byKey(const ValueKey('profile_info_card')), findsOneWidget); 451 + expect(find.byKey(const ValueKey('profile_info_card')), findsNothing); 440 452 441 453 when(() => cubit.state).thenReturn(settingsStateWith(FeedArchitecture.linear)); 442 454 streamCtrl.add(settingsStateWith(FeedArchitecture.linear));