diff --git a/docs/specs/ui-refactor.md b/docs/specs/ui-refactor.md index b79bcdd..2af44cd 100644 --- a/docs/specs/ui-refactor.md +++ b/docs/specs/ui-refactor.md @@ -196,6 +196,80 @@ Store `ui_density` (`compact` | `standard` | `relaxed`) and `feed_architecture` (`grid` | `linear`) in the Drift `settings` table. Expose via `SettingsCubit` alongside existing theme preferences. +## Post Thread — Collapsible Threaded Replies + +Current: `PostThreadScreen` renders the parent chain with vertical connectors, +then a flat list of direct replies. No nesting, no collapse controls. + +Target: recursive threaded reply tree with indented threadlines and +tap-to-collapse interaction. + +### Reply Tree Rendering + +Replace the flat reply list with a recursive widget tree that mirrors the +`ThreadViewPost.replies` structure from the Bluesky API. Each reply that itself +has replies renders its children indented one level deeper. + +- Use a recursive `ThreadReplyNode` widget that takes a `ThreadViewPost` and a + `depth` parameter +- Each node renders its post via `PostCardWithActions`, followed by its children + at `depth + 1` + +### Threadlines & Indentation + +- Each nesting level draws a vertical threadline on its left edge — a `2px`-wide + line in `outlineVariant`, offset `37px` from the current indent origin + (matching the existing parent-chain connector) +- Indentation per level: `24px` left padding, applied cumulatively +- **Color-coded threadlines**: cycle through a palette of 6 muted colors per + depth level to help users visually track nesting (colors derived from the + theme's `outline` / `outlineVariant` / `primary` tones) +- Cap visual indentation at **depth 6**. Beyond that, show a + "Continue this thread →" link that navigates to a new `PostThreadScreen` + rooted at that reply + +### Collapse / Expand Interaction + +Two interaction methods (both always active): + +| Method | Detail | +| --------------------- | ---------------------------------------------------------------------- | +| **Tap threadline** | Tap the vertical threadline to collapse/expand the subtree beneath it | +| **Long-press comment**| Long-press the post body as a secondary affordance | + +Threadline tap target: `24dp` wide (centered on the `2px` line) for comfortable +touch targets, with a subtle highlight on press. + +### Collapsed State + +When a subtree is collapsed: + +- The parent comment's header row (avatar, handle, timestamp) remains visible +- Body text and children are hidden +- A collapsed indicator appears below the header: `"N replies hidden"` in + `labelSmall`, `onSurfaceVariant`, uppercase, `letterSpacing: 0.1em` +- Smooth `AnimatedCrossFade` or `AnimatedSize` transition (duration `200ms`) + +### Auto-Collapse (Optional Setting) + +Add a `thread_auto_collapse_depth` setting (`int`, default `null` = disabled). +When set to a value (e.g., `3`), replies deeper than that level are +auto-collapsed on initial load. The user can still expand them manually. + +- Persist in the Drift `settings` table alongside existing layout settings +- Never auto-collapse replies by the thread's original poster (OP) +- Expose in the Layout Settings screen as a stepper/dropdown below the existing + density and architecture options + +### State Management + +- Collapse state is local to the `PostThreadScreen` — a `Set` of + collapsed post URIs held in the screen's `State` +- No cubit needed; collapse is ephemeral UI state, not persisted across + navigations +- When navigating into a "Continue this thread" link, the new screen manages its + own collapse state independently + ## Shared Geometry Tokens All `0px` border-radius throughout (square corners). Ensure no Flutter widgets diff --git a/docs/tasks/ui-refactor.md b/docs/tasks/ui-refactor.md index 9e0dad2..134f789 100644 --- a/docs/tasks/ui-refactor.md +++ b/docs/tasks/ui-refactor.md @@ -53,3 +53,19 @@ - [ ] Settings screen entry point (new section or drawer link) - [ ] Persist selections to Drift on change - [ ] Tests for settings screen interactions and persistence round-trip + +## M6 — Collapsible Threaded Replies + +- [ ] Recursive `ThreadReplyNode` widget that renders nested replies from `ThreadViewPost.replies` +- [ ] Indentation with cumulative `24px` left padding per depth level +- [ ] Color-coded vertical threadlines (cycle palette of 6 muted theme-derived colors) +- [ ] Tap-threadline-to-collapse interaction with `24dp` touch target +- [ ] Long-press-to-collapse as secondary affordance +- [ ] Collapsed state: header visible, body/children hidden, "N replies hidden" indicator +- [ ] `AnimatedSize` / `AnimatedCrossFade` collapse transition (`200ms`) +- [ ] Depth cap at 6 with "Continue this thread →" navigation link +- [ ] Local collapse state via `Set` of post URIs in screen `State` +- [ ] `thread_auto_collapse_depth` setting in Drift + Drift migration +- [ ] Expose auto-collapse depth in Layout Settings screen +- [ ] Never auto-collapse OP replies +- [ ] Tests for thread tree rendering, collapse/expand, depth cap, and auto-collapse behavior diff --git a/lib/features/feed/presentation/widgets/grid_post_card.dart b/lib/features/feed/presentation/widgets/grid_post_card.dart index d439752..e98bdda 100644 --- a/lib/features/feed/presentation/widgets/grid_post_card.dart +++ b/lib/features/feed/presentation/widgets/grid_post_card.dart @@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart'; import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_embed_view.dart'; +import 'package:lazurite/features/feed/presentation/widgets/post_text_styles.dart'; const _greyscale = ColorFilter.matrix([ 0.2126, @@ -55,9 +56,10 @@ class GridPostCard extends StatelessWidget { final primaryImageUrl = _extractPrimaryImageUrl(post.embed); final bodyText = record?.text ?? ''; final colorScheme = Theme.of(context).colorScheme; + final isCompactGrid = MediaQuery.of(context).size.width >= 600; final contentEmbed = primaryImageUrl == null && post.embed != null - ? PostEmbedView(feedViewPost: feedViewPost, embed: post.embed!) + ? PostEmbedView(feedViewPost: feedViewPost, embed: post.embed!, compact: isCompactGrid) : null; final resolvedFooter = footer ?? PostCardFooter(timestamp: formatPostTime(record?.createdAt ?? post.indexedAt)); @@ -98,20 +100,25 @@ class GridPostCard extends StatelessWidget { FacetText( text: bodyText, facets: record?.facets, - style: Theme.of(context).textTheme.titleMedium?.copyWith(letterSpacing: -0.5), + style: feedPostBodyTextStyle(context), maxLines: 6, overflow: TextOverflow.ellipsis, ) + else if (!isCompactGrid) + FacetText(text: bodyText, facets: record?.facets, style: feedPostBodyTextStyle(context)) else FacetText( text: bodyText, facets: record?.facets, - style: Theme.of(context).textTheme.bodySmall, + style: feedPostBodyTextStyle(context, compact: true), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], - if (contentEmbed != null) ...[const SizedBox(height: 8), _buildEmbedPreview(contentEmbed)], + if (contentEmbed != null) ...[ + const SizedBox(height: 8), + _buildEmbedPreview(contentEmbed, compact: isCompactGrid), + ], ], ), ), @@ -175,7 +182,11 @@ class GridPostCard extends StatelessWidget { ); } - Widget _buildEmbedPreview(Widget contentEmbed) { + Widget _buildEmbedPreview(Widget contentEmbed, {required bool compact}) { + if (!compact) { + return contentEmbed; + } + return SizedBox( height: _gridEmbedPreviewMaxHeight, child: ClipRect( diff --git a/lib/features/feed/presentation/widgets/post_card.dart b/lib/features/feed/presentation/widgets/post_card.dart index b894ec3..e3aad8f 100644 --- a/lib/features/feed/presentation/widgets/post_card.dart +++ b/lib/features/feed/presentation/widgets/post_card.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_embed_view.dart'; +import 'package:lazurite/features/feed/presentation/widgets/post_text_styles.dart'; class PostCard extends StatelessWidget { const PostCard({super.key, required this.feedViewPost, this.actionBar, this.onTap}); @@ -49,13 +50,7 @@ class PostCard extends StatelessWidget { if (record?.reply != null) ...[const SizedBox(height: 8), _buildReplyLabel(context)], if (record != null && record.text.isNotEmpty) ...[ const SizedBox(height: 12), - FacetText( - text: record.text, - facets: record.facets, - style: Theme.of(context).textTheme.bodySmall, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), + FacetText(text: record.text, facets: record.facets, style: feedPostBodyTextStyle(context)), ], if (post.embed != null) ...[ const SizedBox(height: 12), diff --git a/lib/features/feed/presentation/widgets/post_embed_view.dart b/lib/features/feed/presentation/widgets/post_embed_view.dart index f6d1004..0647d80 100644 --- a/lib/features/feed/presentation/widgets/post_embed_view.dart +++ b/lib/features/feed/presentation/widgets/post_embed_view.dart @@ -11,6 +11,7 @@ import 'package:lazurite/features/feed/presentation/media/image_viewer_route_arg import 'package:lazurite/features/feed/presentation/media/media_actions.dart'; import 'package:lazurite/features/feed/presentation/media/video_player_route_args.dart'; import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; +import 'package:lazurite/features/feed/presentation/widgets/post_text_styles.dart'; import 'package:url_launcher/url_launcher.dart'; /// Renders the appropriate embed widget for a post embed. @@ -18,10 +19,11 @@ import 'package:url_launcher/url_launcher.dart'; /// Handles images, external links, videos, quoted records, and record-with-media. /// Used by both [PostCard] and [GridPostCard]. class PostEmbedView extends StatelessWidget { - const PostEmbedView({super.key, required this.feedViewPost, required this.embed}); + const PostEmbedView({super.key, required this.feedViewPost, required this.embed, this.compact = false}); final FeedViewPost feedViewPost; final UPostViewEmbed embed; + final bool compact; @override Widget build(BuildContext context) { @@ -114,17 +116,25 @@ class PostEmbedView extends StatelessWidget { } Widget _buildExternalEmbed(BuildContext context, EmbedExternalViewExternal external) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return InkWell( onTap: () => _launchExternal(Uri.parse(external.uri)), child: Container( - decoration: BoxDecoration(border: Border.all(color: Theme.of(context).dividerColor)), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + border: Border.all(color: colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(12), + color: colorScheme.surfaceContainerLow, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (external.thumb != null) Image.network( external.thumb!, - height: 160, + height: compact ? 140 : 180, width: double.infinity, fit: BoxFit.cover, errorBuilder: (_, _, _) => const SizedBox(height: 0), @@ -136,23 +146,24 @@ class PostEmbedView extends StatelessWidget { children: [ Text( external.title, - style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + ), ), if (external.description.isNotEmpty) ...[ const SizedBox(height: 4), Text( external.description, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium, + maxLines: compact ? 3 : null, + overflow: compact ? TextOverflow.ellipsis : TextOverflow.visible, + style: theme.textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant, height: 1.4), ), ], const SizedBox(height: 8), Text( Uri.parse(external.uri).host, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), ), ], ), @@ -210,6 +221,8 @@ class PostEmbedView extends StatelessWidget { Widget _buildQuotedRecord(BuildContext context, EmbedRecordView recordView) { final record = recordView.record; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; if (record.isEmbedRecordViewRecord) { final quoted = record.embedRecordViewRecord!; @@ -217,8 +230,13 @@ class PostEmbedView extends StatelessWidget { final nestedEmbed = _buildQuotedEmbeds(context, quoted.embeds); return Container( - decoration: BoxDecoration(border: Border.all(color: Theme.of(context).dividerColor)), + decoration: BoxDecoration( + border: Border.all(color: colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(12), + color: colorScheme.surfaceContainerLow, + ), child: InkWell( + borderRadius: BorderRadius.circular(12), onTap: () { GoRouter.maybeOf(context)?.push('/post?uri=${Uri.encodeComponent(quoted.uri.toString())}'); }, @@ -246,7 +264,10 @@ class PostEmbedView extends StatelessWidget { '${quoted.author.displayName ?? quoted.author.handle} @${quoted.author.handle}', maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), ), ), ], @@ -256,9 +277,9 @@ class PostEmbedView extends StatelessWidget { FacetText( text: quotedRecord.text, facets: quotedRecord.facets, - style: Theme.of(context).textTheme.bodyMedium, - maxLines: 6, - overflow: TextOverflow.ellipsis, + style: feedPostBodyTextStyle(context, compact: compact, nested: true), + maxLines: compact ? 6 : null, + overflow: compact ? TextOverflow.ellipsis : TextOverflow.visible, ), ], if (nestedEmbed != null) ...[const SizedBox(height: 8), nestedEmbed], @@ -286,7 +307,10 @@ class PostEmbedView extends StatelessWidget { return Container( width: double.infinity, padding: const EdgeInsets.all(12), - color: Theme.of(context).colorScheme.surfaceContainerHighest, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + ), child: Text( label, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), diff --git a/lib/features/feed/presentation/widgets/post_text_styles.dart b/lib/features/feed/presentation/widgets/post_text_styles.dart new file mode 100644 index 0000000..b86692a --- /dev/null +++ b/lib/features/feed/presentation/widgets/post_text_styles.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +TextStyle? feedPostBodyTextStyle(BuildContext context, {bool compact = false, bool nested = false}) { + final theme = Theme.of(context); + final color = theme.colorScheme.onSurface; + + if (compact) { + return theme.textTheme.bodySmall?.copyWith(color: color, height: 1.45); + } + + final baseStyle = nested ? theme.textTheme.titleSmall : theme.textTheme.titleMedium; + return baseStyle?.copyWith(color: color, height: nested ? 1.5 : 1.55, letterSpacing: nested ? 0 : -0.35); +} diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index 31a50a1..c04cb19 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -100,6 +100,11 @@ class _ProfileScreenState extends State with SingleTickerProvider FeedFilter get _currentFilter => _tabs[_tabController.index].filter; + String _appBarTitle(ProfileViewDetailed? profile) { + final authState = context.read().state; + return profile?.displayName ?? profile?.handle ?? widget.actor ?? authState.tokens?.handle ?? 'Profile'; + } + Future _refresh() async { context.read().add(const ProfileRefreshRequested()); context.read().add(const FeedRefreshRequested()); @@ -124,7 +129,7 @@ class _ProfileScreenState extends State with SingleTickerProvider floating: true, pinned: true, snap: true, - title: innerBoxIsScrolled ? Text(profile?.displayName ?? profile?.handle ?? 'Profile') : null, + title: Text(_appBarTitle(profile)), leading: widget.showBackButton ? IconButton( icon: const Icon(Icons.arrow_back), @@ -514,16 +519,15 @@ class _ProfileScreenState extends State with SingleTickerProvider buildWhen: (prev, curr) => prev.feedArchitecture != curr.feedArchitecture, builder: (context, settingsState) { if (settingsState.feedArchitecture == FeedArchitecture.grid) { - return _buildGridFeed(context, feedState, profile); + return _buildGridFeed(context, feedState); } return _buildLinearFeed(context, feedState); }, ); } - Widget _buildGridFeed(BuildContext context, FeedState feedState, ProfileViewDetailed? profile) { + Widget _buildGridFeed(BuildContext context, FeedState feedState) { final accountDid = _resolvedActor ?? ''; - final infoCardCount = profile == null ? 0 : 1; return RefreshIndicator( onRefresh: _refresh, @@ -539,23 +543,9 @@ class _ProfileScreenState extends State with SingleTickerProvider child: ListView.builder( key: const ValueKey('profile_grid_feed'), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - itemCount: infoCardCount + feedState.posts.length + (feedState.isLoadingMore ? 1 : 0), + itemCount: feedState.posts.length + (feedState.isLoadingMore ? 1 : 0), itemBuilder: (context, index) { - if (profile != null && index == 0) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 720), - child: _ProfileInfoCard(profile: profile), - ), - ), - ); - } - - final postIndex = index - infoCardCount; - - if (postIndex >= feedState.posts.length) { + if (index >= feedState.posts.length) { return const Padding( padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()), @@ -563,13 +553,13 @@ class _ProfileScreenState extends State with SingleTickerProvider } return Padding( - padding: EdgeInsets.only(bottom: postIndex == feedState.posts.length - 1 ? 0 : 16), + padding: EdgeInsets.only(bottom: index == feedState.posts.length - 1 ? 0 : 16), child: Center( child: ConstrainedBox( - key: ValueKey('profile_large_card_$postIndex'), + key: ValueKey('profile_large_card_$index'), constraints: const BoxConstraints(maxWidth: 720), child: PostCardWithActions( - feedViewPost: feedState.posts[postIndex], + feedViewPost: feedState.posts[index], accountDid: accountDid, variant: PostCardVariant.grid, ), @@ -642,55 +632,6 @@ class _ProfileScreenState extends State with SingleTickerProvider } } -class _ProfileInfoCard extends StatelessWidget { - const _ProfileInfoCard({required this.profile}); - - final ProfileViewDetailed profile; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Container( - key: const ValueKey('profile_info_card'), - color: colorScheme.surfaceContainerHigh, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _formatCount(profile.postsCount ?? 0), - style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), - ), - Text('POSTS', style: TextStyle(fontSize: 11, letterSpacing: 1.1, color: colorScheme.onSurfaceVariant)), - const SizedBox(height: 12), - Text( - _formatCount(profile.followersCount ?? 0), - style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), - ), - Text('FOLLOWERS', style: TextStyle(fontSize: 11, letterSpacing: 1.1, color: colorScheme.onSurfaceVariant)), - if (profile.description?.isNotEmpty ?? false) ...[ - const SizedBox(height: 12), - Text( - profile.description!, - style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ); - } - - String _formatCount(int count) { - if (count >= 1000000) return '${(count / 1000000).toStringAsFixed(1)}M'; - if (count >= 1000) return '${(count / 1000).toStringAsFixed(1)}K'; - return '$count'; - } -} - /// Sticky tab bar delegate with backdrop blur background and uppercase styled labels. class _SliverTabBarDelegate extends SliverPersistentHeaderDelegate { _SliverTabBarDelegate(this.tabBar); diff --git a/test/features/feed/presentation/grid_post_card_test.dart b/test/features/feed/presentation/grid_post_card_test.dart index abc3a5e..76e0297 100644 --- a/test/features/feed/presentation/grid_post_card_test.dart +++ b/test/features/feed/presentation/grid_post_card_test.dart @@ -7,6 +7,7 @@ import 'package:bluesky/app_bsky_feed_post.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/features/feed/presentation/widgets/grid_post_card.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; @@ -24,11 +25,16 @@ FeedViewPost _makePost({String text = 'Hello', UPostViewEmbed? embed}) { ); } -Widget _buildSubject(FeedViewPost post, {VoidCallback? onTap}) { +Widget _buildSubject(FeedViewPost post, {VoidCallback? onTap, Size size = const Size(390, 844)}) { + final theme = AppTheme.getTheme(AppThemePalette.oxocarbon, AppThemeVariant.dark); return MaterialApp( - home: Scaffold( - body: SingleChildScrollView( - child: GridPostCard(feedViewPost: post, onTap: onTap), + theme: theme, + home: MediaQuery( + data: MediaQueryData(size: size), + child: Scaffold( + body: SingleChildScrollView( + child: GridPostCard(feedViewPost: post, onTap: onTap), + ), ), ), ); @@ -126,9 +132,57 @@ void main() { await tester.pumpWidget(_buildSubject(post)); expect(find.text('Example Article'), findsOneWidget); + expect(find.byWidgetPredicate((widget) => widget is SizedBox && widget.height == 240), findsNothing); + }); + + testWidgets('keeps capped embed previews on wider compact grid layouts', (tester) async { + final post = _makePost( + text: 'Read this', + embed: const UPostViewEmbed.embedExternalView( + data: EmbedExternalView( + external: EmbedExternalViewExternal( + uri: 'https://example.com/article', + title: 'Example Article', + description: 'A useful external card', + ), + ), + ), + ); + + await tester.pumpWidget(_buildSubject(post, size: const Size(900, 1200))); + expect(find.byWidgetPredicate((widget) => widget is SizedBox && widget.height == 240), findsOneWidget); }); + testWidgets('uses themed serif styling for embed-bearing posts on phone widths', (tester) async { + final post = _makePost( + text: 'Serif body copy with an external preview', + embed: const UPostViewEmbed.embedExternalView( + data: EmbedExternalView( + external: EmbedExternalViewExternal( + uri: 'https://example.com/article', + title: 'Example Article', + description: 'A useful external card', + ), + ), + ), + ); + + await tester.pumpWidget(_buildSubject(post)); + + final richText = tester.widget( + find.byWidgetPredicate( + (widget) => widget is RichText && widget.text.toPlainText() == 'Serif body copy with an external preview', + ), + ); + final style = (richText.text as TextSpan).style; + final theme = AppTheme.getTheme(AppThemePalette.oxocarbon, AppThemeVariant.dark); + + expect(style?.fontFamily, theme.textTheme.titleMedium?.fontFamily); + expect(style?.color, theme.colorScheme.onSurface); + expect(richText.maxLines, isNull); + }); + testWidgets('uses square container for avatar — no CircleAvatar', (tester) async { final post = _makePost(); await tester.pumpWidget(_buildSubject(post)); diff --git a/test/features/feed/presentation/post_card_test.dart b/test/features/feed/presentation/post_card_test.dart index 71408b8..549abff 100644 --- a/test/features/feed/presentation/post_card_test.dart +++ b/test/features/feed/presentation/post_card_test.dart @@ -10,6 +10,7 @@ import 'package:bluesky/app_bsky_richtext_facet.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; @@ -28,7 +29,9 @@ FeedViewPost _makePost({String text = 'Hello'}) { void main() { Widget buildSubject(FeedViewPost post, {VoidCallback? onTap}) { + final theme = AppTheme.getTheme(AppThemePalette.oxocarbon, AppThemeVariant.dark); return MaterialApp( + theme: theme, home: Scaffold( body: PostCard(feedViewPost: post, onTap: onTap), ), @@ -98,6 +101,22 @@ void main() { expect(find.text('example.com'), findsOneWidget); }); + testWidgets('uses themed serif styling for post body text', (tester) async { + final post = _makePost(text: 'Styled body copy'); + + await tester.pumpWidget(buildSubject(post)); + + final richText = tester.widget( + find.byWidgetPredicate((widget) => widget is RichText && widget.text.toPlainText() == 'Styled body copy'), + ); + final style = (richText.text as TextSpan).style; + final theme = AppTheme.getTheme(AppThemePalette.oxocarbon, AppThemeVariant.dark); + + expect(style?.fontFamily, theme.textTheme.titleMedium?.fontFamily); + expect(style?.color, theme.colorScheme.onSurface); + expect(richText.maxLines, isNull); + }); + testWidgets('calls onTap when content area is tapped', (tester) async { var tapped = false; final post = _makePost(); @@ -185,6 +204,50 @@ void main() { expect(Uri.decodeComponent(Uri.parse(pushedRoute!).queryParameters['uri']!), quotedUri.toString()); }); + testWidgets('renders quoted post text with serif styling and without truncation', (tester) async { + final quotedRecord = FeedPostRecord( + text: 'Quoted text that should fully expand inside the embed card', + createdAt: DateTime.utc(2026, 3, 15), + ); + final post = FeedViewPost( + post: PostView( + uri: const AtUri('at://did:plc:test/app.bsky.feed.post/xyz'), + cid: 'cid-xyz', + author: const ProfileViewBasic(did: 'did:plc:test', handle: 'test.bsky.social'), + record: FeedPostRecord(text: 'Main post', createdAt: DateTime.utc(2026, 3, 16)).toJson(), + indexedAt: DateTime.utc(2026, 3, 16), + embed: UPostViewEmbed.embedRecordView( + data: EmbedRecordView( + record: UEmbedRecordViewRecord.embedRecordViewRecord( + data: EmbedRecordViewRecord( + uri: AtUri.parse('at://did:plc:quoted/app.bsky.feed.post/quoted123'), + cid: 'cid-quoted', + author: const ProfileViewBasic(did: 'did:plc:quoted', handle: 'quoted.bsky.social'), + value: quotedRecord.toJson(), + indexedAt: DateTime.utc(2026, 3, 15), + ), + ), + ), + ), + ), + ); + + await tester.pumpWidget(buildSubject(post)); + + final richText = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is RichText && + widget.text.toPlainText() == 'Quoted text that should fully expand inside the embed card', + ), + ); + final style = (richText.text as TextSpan).style; + final theme = AppTheme.getTheme(AppThemePalette.oxocarbon, AppThemeVariant.dark); + + expect(style?.fontFamily, theme.textTheme.titleSmall?.fontFamily); + expect(richText.maxLines, isNull); + }); + testWidgets('tapping avatar navigates to author profile', (tester) async { final post = _makePost(); String? pushedRoute; diff --git a/test/features/profile/presentation/profile_screen_test.dart b/test/features/profile/presentation/profile_screen_test.dart index f8195f6..12ef16b 100644 --- a/test/features/profile/presentation/profile_screen_test.dart +++ b/test/features/profile/presentation/profile_screen_test.dart @@ -149,6 +149,13 @@ void main() { expect(find.text('Joined March 2024'), findsOneWidget); }); + testWidgets('app bar always shows the profile display name', (tester) async { + useLargeScreen(tester); + await tester.pumpWidget(buildSubject()); + + expect(find.text('River Tam'), findsOneWidget); + }); + testWidgets('shows Saved Posts button on own profile', (tester) async { useLargeScreen(tester); await tester.pumpWidget(buildSubject()); @@ -278,7 +285,7 @@ void main() { await tester.pumpWidget(buildSubject()); expect(find.text('RIVER TAM'), findsOneWidget); - expect(find.text('River Tam'), findsNothing); + expect(find.text('River Tam'), findsOneWidget); }); testWidgets('handle is shown with @ prefix', (tester) async { @@ -300,6 +307,13 @@ void main() { expect(find.byKey(const ValueKey('profile_stats_row')), findsOneWidget); }); + testWidgets('does not render the profile info card in the feed', (tester) async { + useLargeScreen(tester); + await tester.pumpWidget(buildSubject()); + + expect(find.byKey(const ValueKey('profile_info_card')), findsNothing); + }); + testWidgets('stat values are shown as formatted counts', (tester) async { useLargeScreen(tester); await tester.pumpWidget(buildSubject()); @@ -395,7 +409,7 @@ void main() { ); } - testWidgets('grid mode shows centered large grid cards with the metadata info card', (tester) async { + testWidgets('grid mode shows centered large grid cards without the metadata info card', (tester) async { final cubit = MockSettingsCubit(); when(() => cubit.state).thenReturn(settingsStateWith(FeedArchitecture.grid)); whenListen(cubit, const Stream.empty(), initialState: settingsStateWith(FeedArchitecture.grid)); @@ -404,7 +418,7 @@ void main() { await tester.pump(); expect(find.byKey(const ValueKey('profile_grid_feed')), findsOneWidget); - expect(find.byKey(const ValueKey('profile_info_card')), findsOneWidget); + expect(find.byKey(const ValueKey('profile_info_card')), findsNothing); expect(find.byKey(const ValueKey('profile_large_card_0')), findsOneWidget); expect(find.byKey(const ValueKey('profile_large_card_1')), findsOneWidget); expect(find.byKey(const ValueKey('profile_large_card_2')), findsOneWidget); @@ -423,9 +437,7 @@ void main() { expect(find.byKey(const ValueKey('profile_large_card_0')), findsNothing); }); - testWidgets('switching from grid to linear removes the large grid feed and metadata card without re-fetch', ( - tester, - ) async { + testWidgets('switching from grid to linear removes the large grid feed without re-fetch', (tester) async { final cubit = MockSettingsCubit(); final streamCtrl = StreamController.broadcast(); @@ -436,7 +448,7 @@ void main() { await tester.pump(); expect(find.byKey(const ValueKey('profile_grid_feed')), findsOneWidget); - expect(find.byKey(const ValueKey('profile_info_card')), findsOneWidget); + expect(find.byKey(const ValueKey('profile_info_card')), findsNothing); when(() => cubit.state).thenReturn(settingsStateWith(FeedArchitecture.linear)); streamCtrl.add(settingsStateWith(FeedArchitecture.linear));