diff --git a/docs/TODO.md b/docs/TODO.md index 67fbcb8..e56e985 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -31,6 +31,11 @@ It would be cool to display this and allow exporting the feed or a link to it. - In dev tools, show Firehose, Jetstream, and [spacedust](https://spacedust.microcosm.blue/#GET/subscribe) as tabs. +--- + +- Markdown support (toggleable) +- Collapsible threads + ## Privacy Policy - Should mention that Lazurite is an AppView that doesn't store any user data. diff --git a/docs/specs/ui-refactor.md b/docs/specs/ui-refactor.md index 9e3ff97..b79bcdd 100644 --- a/docs/specs/ui-refactor.md +++ b/docs/specs/ui-refactor.md @@ -96,7 +96,7 @@ Outer container: `surfaceContainerLowest`, `border: outlineVariant`, Current: `NestedScrollView` with collapsible header, `CircleAvatar`, `TabBar` (Posts / Replies / Media). -Refactor to an asymmetric "bento" layout: +Refactor to a larger-card profile layout: ### Header @@ -122,13 +122,15 @@ Refactor to an asymmetric "bento" layout: ### Content Area -Profile posts use a `12-column` asymmetric bento grid: +Profile posts use a simple large-card stack when the feed architecture is set +to "Grid Matrix": -- Pinned post spans `8 columns` (featured, with full image embed) -- Metadata / info card spans `4 columns` (`surfaceContainerHigh` background) -- Remaining posts in `6+6` two-column pairs +- Keep the metadata/info card as a standalone block above the posts +- Use the grid post card variant +- Render one card per row +- Center cards with a comfortable max width so they stay visually larger than + the linear feed on wide screens -The bento grid applies when the feed architecture is set to "Grid Matrix". When set to "Linear Flow", profile posts render as a standard vertical list using the linear post card. diff --git a/docs/tasks/ui-refactor.md b/docs/tasks/ui-refactor.md index 2493c37..9e0dad2 100644 --- a/docs/tasks/ui-refactor.md +++ b/docs/tasks/ui-refactor.md @@ -30,20 +30,20 @@ ## M3 — Home Feed Grid Layout -- [ ] `HomeFeedScreen` reads `feed_architecture` from `SettingsCubit` -- [ ] Grid mode: responsive `SliverGrid` with breakpoint-based column count -- [ ] Linear mode: existing `ListView` of linear post cards (no change) -- [ ] Feed architecture toggle triggers rebuild without re-fetch -- [ ] Tests for grid/linear switching and column count at breakpoints +- [x] `HomeFeedScreen` reads `feed_architecture` from `SettingsCubit` +- [x] Grid mode: responsive `SliverGrid` with breakpoint-based column count +- [x] Linear mode: existing `ListView` of linear post cards (with more space around cards) +- [x] Feed architecture toggle triggers rebuild without re-fetch +- [x] Tests for grid/linear switching and column count at breakpoints ## M4 — Profile Screen Refactor -- [ ] Profile header: square avatar, cover image (grayscale, opacity), stats row with border -- [ ] Display name uppercase + tight tracking, handle below -- [ ] Sticky tab bar with backdrop blur and uppercase labels -- [ ] Bento grid layout for profile posts (8+4 featured row, 6+6 pairs) in grid mode -- [ ] Linear fallback for profile posts when feed architecture is "linear" -- [ ] Tests for profile header rendering and layout mode switching +- [x] Profile header: square avatar, cover image (grayscale, opacity), stats row with border +- [x] Display name uppercase + tight tracking, handle below +- [x] Sticky tab bar with backdrop blur and uppercase labels +- [x] Large-card grid layout for profile posts in grid mode, with the metadata info card retained above the feed +- [x] Linear fallback for profile posts when feed architecture is "linear" +- [x] Tests for profile header rendering and layout mode switching ## M5 — Layout Settings Screen diff --git a/lib/core/router/app_shell.dart b/lib/core/router/app_shell.dart index 2cefd9a..271201e 100644 --- a/lib/core/router/app_shell.dart +++ b/lib/core/router/app_shell.dart @@ -25,8 +25,8 @@ class AppShellMenuButton extends StatelessWidget { @override Widget build(BuildContext context) { final shellScope = AppShellScope.maybeOf(context); - - return IconButton(tooltip: 'Open menu', onPressed: shellScope?.openMenu, icon: const Icon(Icons.menu)); + final onPressed = shellScope?.openMenu ?? AppShell.openDrawer; + return IconButton(tooltip: 'Open menu', onPressed: onPressed, icon: const Icon(Icons.menu)); } } @@ -35,16 +35,19 @@ class AppShell extends StatefulWidget { final StatefulNavigationShell navigationShell; + /// Global key for the shell [Scaffold]. Accessible from anywhere — even + /// screens pushed onto the root navigator that are outside [AppShellScope]. + static final scaffoldKey = GlobalKey(); + + /// Opens the navigation drawer from any context. + static void openDrawer() => AppShell.scaffoldKey.currentState?.openDrawer(); + @override State createState() => _AppShellState(); } class _AppShellState extends State { - final GlobalKey _scaffoldKey = GlobalKey(); - - void _openMenu() { - _scaffoldKey.currentState?.openDrawer(); - } + void _openMenu() => AppShell.openDrawer(); @override Widget build(BuildContext context) { @@ -52,7 +55,7 @@ class _AppShellState extends State { return AppShellScope( openMenu: _openMenu, child: Scaffold( - key: _scaffoldKey, + key: AppShell.scaffoldKey, drawer: _AppMenu(navigationShell: widget.navigationShell, rootContext: context), body: widget.navigationShell, bottomNavigationBar: Container( diff --git a/lib/core/theme/typography.dart b/lib/core/theme/typography.dart index 34342d5..d4c151e 100644 --- a/lib/core/theme/typography.dart +++ b/lib/core/theme/typography.dart @@ -65,10 +65,10 @@ class AppTypography { titleSmall: lora(fontSize: 14, fontWeight: FontWeight.w500, color: bodyColor, letterSpacing: 0.1), bodyLarge: dmSans(fontSize: 16, fontWeight: FontWeight.w400, color: bodyColor, letterSpacing: 0.5), bodyMedium: dmSans(fontSize: 14, fontWeight: FontWeight.w400, color: bodyColor, letterSpacing: 0.25), - bodySmall: jetBrainsMono(fontSize: 12, fontWeight: FontWeight.w400, color: captionColor, letterSpacing: 0.4), + bodySmall: dmSans(fontSize: 12, fontWeight: FontWeight.w400, color: captionColor, letterSpacing: 0.4), labelLarge: dmSans(fontSize: 14, fontWeight: FontWeight.w500, color: bodyColor, letterSpacing: 0.1), labelMedium: dmSans(fontSize: 12, fontWeight: FontWeight.w500, color: bodyColor, letterSpacing: 0.5), - labelSmall: jetBrainsMono(fontSize: 11, fontWeight: FontWeight.w500, color: captionColor, letterSpacing: 0.5), + labelSmall: dmSans(fontSize: 12, fontWeight: FontWeight.w500, color: captionColor, letterSpacing: 0.5), ); } } diff --git a/lib/core/widgets/lazurite_app_bar.dart b/lib/core/widgets/lazurite_app_bar.dart index e056714..bb8b7a0 100644 --- a/lib/core/widgets/lazurite_app_bar.dart +++ b/lib/core/widgets/lazurite_app_bar.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/router/app_shell.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; @@ -56,7 +57,7 @@ class _AppBarAvatar extends StatelessWidget { try { authState = context.watch().state; } catch (_) { - // AuthBloc not provided — show default avatar + log.d('showing default avatar'); } final tokens = authState?.tokens; final initials = _initialsFor(tokens?.displayName ?? tokens?.handle ?? 'L'); diff --git a/lib/features/feed/presentation/home_feed_screen.dart b/lib/features/feed/presentation/home_feed_screen.dart index 856a2be..d28c819 100644 --- a/lib/features/feed/presentation/home_feed_screen.dart +++ b/lib/features/feed/presentation/home_feed_screen.dart @@ -8,8 +8,18 @@ import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; import 'package:lazurite/features/feed/data/feed_repository.dart'; +import 'package:lazurite/features/feed/presentation/widgets/feed_layout_view.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; +/// Returns the number of grid columns for [width] per the responsive +/// breakpoints defined in the UI spec. +int feedColumnCount(double width) { + if (width >= 1200) return 4; + if (width >= 840) return 3; + if (width >= 600) return 2; + return 1; +} + class HomeFeedScreen extends StatefulWidget { const HomeFeedScreen({super.key}); @@ -343,30 +353,28 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli return Center(child: Text('No posts yet', style: Theme.of(context).textTheme.bodyLarge)); } - return RefreshIndicator( - onRefresh: _loadFeed, - child: ListView.builder( - controller: _scrollController, - itemCount: _posts.length + (_isLoadingMore ? 1 : 0), - itemBuilder: (context, index) { - if (index == _posts.length) { - return const Center( - child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), - ); - } - - final accountDid = context.read().state.tokens?.did ?? ''; - final post = _posts[index]; - return PostCardWithActions( - feedViewPost: post, - accountDid: accountDid, - onDeleted: () { - final uri = post.post.uri.toString(); - setState(() => _posts.removeWhere((p) => p.post.uri.toString() == uri)); - }, - ); + final accountDid = context.read().state.tokens?.did ?? ''; + + PostCardWithActions buildCard(int index, PostCardVariant variant) { + final post = _posts[index]; + return PostCardWithActions( + feedViewPost: post, + accountDid: accountDid, + variant: variant, + onDeleted: () { + final uri = post.post.uri.toString(); + setState(() => _posts.removeWhere((p) => p.post.uri.toString() == uri)); }, - ), + ); + } + + return FeedLayoutView( + itemCount: _posts.length, + scrollController: _scrollController, + isLoadingMore: _isLoadingMore, + onRefresh: _loadFeed, + gridItemBuilder: (context, index) => buildCard(index, PostCardVariant.grid), + linearItemBuilder: (context, index) => buildCard(index, PostCardVariant.linear), ); } } diff --git a/lib/features/feed/presentation/widgets/feed_layout_view.dart b/lib/features/feed/presentation/widgets/feed_layout_view.dart new file mode 100644 index 0000000..73afebf --- /dev/null +++ b/lib/features/feed/presentation/widgets/feed_layout_view.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/theme/feed_architecture.dart'; +import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/settings/bloc/settings_state.dart'; + +const double _gridSpacing = 1; +const double _gridCardChromeHeight = 160; + +/// Renders a scrollable list of items in either a responsive [SliverGrid] +/// (grid architecture) or a padded [ListView] (linear architecture), driven +/// by [SettingsCubit.feedArchitecture]. +/// +/// [gridItemBuilder] is used when the grid architecture is active. +/// [linearItemBuilder] is used when the linear architecture is active. +/// This allows the caller to render the appropriate card variant for each mode. +class FeedLayoutView extends StatelessWidget { + const FeedLayoutView({ + super.key, + required this.itemCount, + required this.gridItemBuilder, + required this.linearItemBuilder, + required this.scrollController, + required this.isLoadingMore, + required this.onRefresh, + }); + + final int itemCount; + final IndexedWidgetBuilder gridItemBuilder; + final IndexedWidgetBuilder linearItemBuilder; + final ScrollController scrollController; + final bool isLoadingMore; + final RefreshCallback onRefresh; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (prev, curr) => prev.feedArchitecture != curr.feedArchitecture, + builder: (context, settingsState) { + if (settingsState.feedArchitecture == FeedArchitecture.grid) { + return _buildGrid(context); + } + return _buildLinear(context); + }, + ); + } + + Widget _buildGrid(BuildContext context) { + final width = MediaQuery.of(context).size.width; + final columns = feedColumnCount(width); + final tileWidth = (width - ((columns - 1) * _gridSpacing)) / columns; + + return RefreshIndicator( + onRefresh: onRefresh, + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverGrid( + delegate: SliverChildBuilderDelegate(gridItemBuilder, childCount: itemCount), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + crossAxisSpacing: _gridSpacing, + mainAxisSpacing: _gridSpacing, + // Grid cards have a square media region plus fixed author/body/footer chrome. + mainAxisExtent: tileWidth + _gridCardChromeHeight, + ), + ), + if (isLoadingMore) + const SliverToBoxAdapter( + child: Center( + child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), + ), + ), + ], + ), + ); + } + + Widget _buildLinear(BuildContext context) { + return RefreshIndicator( + onRefresh: onRefresh, + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + itemCount: itemCount + (isLoadingMore ? 1 : 0), + itemBuilder: (context, index) { + if (index == itemCount) { + return const Center( + child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), + ); + } + return Padding(padding: const EdgeInsets.only(bottom: 8), child: linearItemBuilder(context, index)); + }, + ), + ); + } +} diff --git a/lib/features/feed/presentation/widgets/grid_post_card.dart b/lib/features/feed/presentation/widgets/grid_post_card.dart index 10e4628..d439752 100644 --- a/lib/features/feed/presentation/widgets/grid_post_card.dart +++ b/lib/features/feed/presentation/widgets/grid_post_card.dart @@ -30,6 +30,7 @@ const _greyscale = ColorFilter.matrix([ 1, 0, ]); +const double _gridEmbedPreviewMaxHeight = 240; /// Grid layout post card. /// @@ -55,7 +56,6 @@ class GridPostCard extends StatelessWidget { final bodyText = record?.text ?? ''; final colorScheme = Theme.of(context).colorScheme; - // Non-image embeds rendered in the content area final contentEmbed = primaryImageUrl == null && post.embed != null ? PostEmbedView(feedViewPost: feedViewPost, embed: post.embed!) : null; @@ -95,7 +95,6 @@ class GridPostCard extends StatelessWidget { if (bodyText.isNotEmpty) ...[ const SizedBox(height: 8), if (primaryImageUrl == null && contentEmbed == null) - // Text-only: larger, tighter body text FacetText( text: bodyText, facets: record?.facets, @@ -112,7 +111,7 @@ class GridPostCard extends StatelessWidget { overflow: TextOverflow.ellipsis, ), ], - if (contentEmbed != null) ...[const SizedBox(height: 8), contentEmbed], + if (contentEmbed != null) ...[const SizedBox(height: 8), _buildEmbedPreview(contentEmbed)], ], ), ), @@ -176,6 +175,15 @@ class GridPostCard extends StatelessWidget { ); } + Widget _buildEmbedPreview(Widget contentEmbed) { + return SizedBox( + height: _gridEmbedPreviewMaxHeight, + child: ClipRect( + child: SingleChildScrollView(physics: const NeverScrollableScrollPhysics(), child: contentEmbed), + ), + ); + } + /// Returns the URL for the square image region when the post has image/video embeds. /// Returns null for external links and quoted records (rendered via [PostEmbedView]). String? _extractPrimaryImageUrl(UPostViewEmbed? embed) { diff --git a/lib/features/feed/presentation/widgets/post_card_footer.dart b/lib/features/feed/presentation/widgets/post_card_footer.dart index 654a36e..73fa7d8 100644 --- a/lib/features/feed/presentation/widgets/post_card_footer.dart +++ b/lib/features/feed/presentation/widgets/post_card_footer.dart @@ -62,12 +62,16 @@ class PostCardFooter extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final saveActiveColor = (saveType == 'cloud' || saveType == 'both') ? colorScheme.primary : Colors.amber; + const horizontalPadding = 12.0; + const actionSpacing = 8.0; + const iconSize = 18.0; + const actionPadding = 4.0; return Container( decoration: BoxDecoration( border: Border(top: BorderSide(color: colorScheme.outlineVariant)), ), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: horizontalPadding, vertical: 8), child: Row( children: [ _FooterAction( @@ -77,8 +81,10 @@ class PostCardFooter extends StatelessWidget { isLoading: false, onTap: onReply, color: colorScheme.onSurfaceVariant, + iconSize: iconSize, + padding: actionPadding, ), - const SizedBox(width: 16), + const SizedBox(width: actionSpacing), _FooterAction( icon: Icons.repeat, activeIcon: Icons.repeat, @@ -87,8 +93,10 @@ class PostCardFooter extends StatelessWidget { onTap: onRepost, color: colorScheme.onSurfaceVariant, activeColor: Colors.green, + iconSize: iconSize, + padding: actionPadding, ), - const SizedBox(width: 16), + const SizedBox(width: actionSpacing), _FooterAction( icon: Icons.favorite_outline, activeIcon: Icons.favorite, @@ -97,8 +105,10 @@ class PostCardFooter extends StatelessWidget { onTap: onLike, color: colorScheme.onSurfaceVariant, activeColor: Colors.pink, + iconSize: iconSize, + padding: actionPadding, ), - const SizedBox(width: 16), + const SizedBox(width: actionSpacing), _FooterAction( icon: isSaved ? Icons.bookmark : Icons.bookmark_outline, activeIcon: Icons.bookmark, @@ -108,13 +118,23 @@ class PostCardFooter extends StatelessWidget { onLongPress: onLongPressSave, color: colorScheme.onSurfaceVariant, activeColor: saveActiveColor, + iconSize: iconSize, + padding: actionPadding, ), - const Spacer(), - Text( - timestamp, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 10, letterSpacing: 1.0), + const SizedBox(width: actionSpacing), + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: Text( + timestamp, + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 10, letterSpacing: 1.0), + ), + ), ), ], ), @@ -171,6 +191,8 @@ class _FooterAction extends StatelessWidget { required this.activeIcon, required this.isActive, required this.isLoading, + required this.iconSize, + required this.padding, this.onTap, this.onLongPress, this.color, @@ -181,6 +203,8 @@ class _FooterAction extends StatelessWidget { final IconData activeIcon; final bool isActive; final bool isLoading; + final double iconSize; + final double padding; final VoidCallback? onTap; final VoidCallback? onLongPress; final Color? color; @@ -196,10 +220,14 @@ class _FooterAction extends StatelessWidget { onLongPress: onLongPress, borderRadius: BorderRadius.zero, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + padding: EdgeInsets.symmetric(horizontal: padding, vertical: padding), child: isLoading - ? SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: iconColor)) - : Icon(isActive ? activeIcon : icon, size: 18, color: iconColor), + ? SizedBox( + width: iconSize, + height: iconSize, + child: CircularProgressIndicator(strokeWidth: 2, color: iconColor), + ) + : Icon(isActive ? activeIcon : icon, size: iconSize, color: iconColor), ), ); } diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index 8034f9c..31a50a1 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -5,17 +7,43 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:lazurite/core/router/app_shell.dart'; +import 'package:lazurite/core/theme/feed_architecture.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; import 'package:lazurite/features/profile/data/profile_action_repository.dart'; -import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; import 'package:lazurite/features/profile/presentation/widgets/profile_action_buttons.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/settings/bloc/settings_state.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; +const _greyscale = ColorFilter.matrix([ + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0, + 0, + 0, + 1, + 0, +]); + class ProfileScreen extends StatefulWidget { const ProfileScreen({super.key, this.actor, this.showBackButton = false}); @@ -27,7 +55,6 @@ class ProfileScreen extends StatefulWidget { } class _ProfileScreenState extends State with SingleTickerProviderStateMixin { - static const double _headerExpandedHeight = 120; static const _tabs = [ (label: 'Posts', filter: FeedFilter.postsNoReplies), (label: 'Replies', filter: FeedFilter.postsAndAuthorThreads), @@ -60,20 +87,14 @@ class _ProfileScreenState extends State with SingleTickerProvider void _loadProfileAndFeed({FeedFilter? filter}) { final actor = _resolvedActor; - if (actor == null) { - return; - } - + if (actor == null) return; context.read().add(ProfileLoadRequested(actor: actor)); context.read().add(FeedLoadRequested(actor: actor, filter: filter ?? _currentFilter)); } String? get _resolvedActor { final authState = context.read().state; - if (!authState.isAuthenticated) { - return null; - } - + if (!authState.isAuthenticated) return null; return widget.actor ?? authState.tokens?.did; } @@ -100,13 +121,10 @@ class _ProfileScreenState extends State with SingleTickerProvider headerSliverBuilder: (context, innerBoxIsScrolled) { return [ SliverAppBar( - expandedHeight: _headerExpandedHeight, floating: true, pinned: true, snap: true, - stretch: true, title: innerBoxIsScrolled ? Text(profile?.displayName ?? profile?.handle ?? 'Profile') : null, - flexibleSpace: FlexibleSpaceBar(background: _buildBanner(context, profile)), leading: widget.showBackButton ? IconButton( icon: const Icon(Icons.arrow_back), @@ -117,6 +135,7 @@ class _ProfileScreenState extends State with SingleTickerProvider IconButton(icon: const Icon(Icons.settings_outlined), onPressed: () => context.go('/settings')), ], ), + SliverToBoxAdapter(child: _buildCoverSection(context, profile)), SliverToBoxAdapter( child: switch (profileState.status) { ProfileStatus.loading => const Padding( @@ -132,8 +151,15 @@ class _ProfileScreenState extends State with SingleTickerProvider delegate: _SliverTabBarDelegate( TabBar( controller: _tabController, - tabs: [for (final tab in _tabs) Tab(text: tab.label)], + tabs: [for (final tab in _tabs) Tab(text: tab.label.toUpperCase())], onTap: (index) => _loadProfileAndFeed(filter: _tabs[index].filter), + labelStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 2.2), + unselectedLabelStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 2.2, + ), + indicatorWeight: 2, ), ), ), @@ -141,7 +167,9 @@ class _ProfileScreenState extends State with SingleTickerProvider }, body: TabBarView( controller: _tabController, - children: [for (var i = 0; i < _tabs.length; i++) _buildFeedList(feedState, _tabs[i].filter)], + children: [ + for (var i = 0; i < _tabs.length; i++) _buildFeedList(feedState, _tabs[i].filter, profile), + ], ), ); }, @@ -152,45 +180,88 @@ class _ProfileScreenState extends State with SingleTickerProvider ); } - Widget? _buildComposeFab(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final profile = state.profile; - if (profile == null) { - return const SizedBox.shrink(); - } - - final currentUserDid = context.read().state.tokens?.did; - final isOwnProfile = profile.did == currentUserDid; - final initialText = isOwnProfile ? null : '@${profile.handle} '; + Widget _buildCoverSection(BuildContext context, ProfileViewDetailed? profile) { + final width = MediaQuery.of(context).size.width; + final coverHeight = width >= 600 ? 256.0 : 192.0; + final avatarSize = width >= 600 ? 128.0 : 96.0; + final colorScheme = Theme.of(context).colorScheme; + + Widget coverContent; + if (profile?.banner != null) { + coverContent = ColorFiltered( + colorFilter: _greyscale, + child: Image.network( + profile!.banner!, + fit: BoxFit.cover, + width: double.infinity, + height: coverHeight, + errorBuilder: (_, _, _) => + ColoredBox(color: colorScheme.surfaceContainerHigh, child: const SizedBox.expand()), + ), + ); + } else { + coverContent = ColoredBox(color: colorScheme.surfaceContainerHigh, child: const SizedBox.expand()); + } - return FloatingActionButton( - onPressed: () => context.push('/compose', extra: ComposeRouteArgs(initialText: initialText)), - child: const Icon(Icons.add), - ); - }, + return SizedBox( + height: coverHeight + avatarSize / 2, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + top: 0, + left: 0, + right: 0, + height: coverHeight, + child: Container( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: colorScheme.outlineVariant)), + ), + child: Opacity(opacity: 0.5, child: coverContent), + ), + ), + Positioned( + top: coverHeight - avatarSize / 2, + left: 16, + child: _buildSquareAvatar(context, profile, avatarSize), + ), + ], + ), ); } - Widget _buildBanner(BuildContext context, ProfileViewDetailed? profile) { - final fallback = DecoratedBox( + Widget _buildSquareAvatar(BuildContext context, ProfileViewDetailed? profile, double size) { + final colorScheme = Theme.of(context).colorScheme; + final avatarUrl = profile?.avatar; + + return Container( + key: const ValueKey('profile_square_avatar'), + width: size, + height: size, decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Theme.of(context).colorScheme.surfaceContainerHighest, - Theme.of(context).colorScheme.surfaceContainer, - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), + color: colorScheme.surfaceContainerHighest, + border: Border.all(color: colorScheme.surfaceContainerLowest, width: 4), ), + child: avatarUrl != null + ? Image.network( + avatarUrl, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _buildAvatarInitials(context, profile), + ) + : _buildAvatarInitials(context, profile), ); + } - if (profile?.banner == null) { - return fallback; - } - - return Image.network(profile!.banner!, fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback); + Widget _buildAvatarInitials(BuildContext context, ProfileViewDetailed? profile) { + return ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Center( + child: Text( + _initials(profile?.displayName ?? profile?.handle ?? '?'), + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ); } Widget _buildProfileError(BuildContext context, String? errorMessage) { @@ -210,9 +281,10 @@ class _ProfileScreenState extends State with SingleTickerProvider } Widget _buildProfileSummary(BuildContext context, ProfileViewDetailed? profile, bool isOwnProfile) { - if (profile == null) { - return const SizedBox.shrink(); - } + if (profile == null) return const SizedBox.shrink(); + + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; final metaChildren = [ if (profile.pronouns?.isNotEmpty ?? false) @@ -228,76 +300,60 @@ class _ProfileScreenState extends State with SingleTickerProvider ]; return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 20), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildAvatar(profile), - const SizedBox(height: 16), Text( - profile.displayName ?? profile.handle, - style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700), + (profile.displayName ?? profile.handle).toUpperCase(), + style: textTheme.headlineLarge?.copyWith(fontWeight: FontWeight.w600, letterSpacing: -0.5), ), const SizedBox(height: 4), - Text( - '@${profile.handle}', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), + + Text('@${profile.handle}', style: textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)), if (profile.description?.isNotEmpty ?? false) ...[ const SizedBox(height: 12), - Text(profile.description!, style: Theme.of(context).textTheme.bodyLarge), + + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: Text(profile.description!, style: textTheme.bodyMedium), + ), ], if (metaChildren.isNotEmpty) ...[ const SizedBox(height: 16), Wrap(spacing: 8, runSpacing: 8, children: metaChildren), ], const SizedBox(height: 16), - Wrap( - spacing: 16, - runSpacing: 8, - children: [ - _buildStat(context, profile.followsCount ?? 0, 'Following'), - _buildStat(context, profile.followersCount ?? 0, 'Followers'), - _buildStat(context, profile.postsCount ?? 0, 'Posts'), - ], + Container( + key: const ValueKey('profile_stats_row'), + decoration: BoxDecoration( + border: Border.symmetric(horizontal: BorderSide(color: colorScheme.outlineVariant)), + ), + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + _buildStat(context, profile.followsCount ?? 0, 'Following'), + const SizedBox(width: 24), + _buildStat(context, profile.followersCount ?? 0, 'Followers'), + const SizedBox(width: 24), + _buildStat(context, profile.postsCount ?? 0, 'Posts'), + ], + ), ), - if (isOwnProfile) ...[ - const SizedBox(height: 16), + const SizedBox(height: 16), + if (isOwnProfile) OutlinedButton.icon( onPressed: () => context.push('/saved'), icon: const Icon(Icons.bookmark_outline), label: const Text('Saved Posts'), ), - ], - if (!isOwnProfile) ...[const SizedBox(height: 16), _buildProfileActions(context, profile)], + if (!isOwnProfile) _buildProfileActions(context, profile), + const SizedBox(height: 16), ], ), ); } - Widget _buildAvatar(ProfileViewDetailed profile) { - final avatarUrl = profile.avatar; - - return Container( - width: 96, - height: 96, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: Theme.of(context).scaffoldBackgroundColor, width: 4), - ), - child: CircleAvatar( - radius: 44, - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, - backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, - child: avatarUrl == null - ? Text(_initials(profile.displayName ?? profile.handle), style: Theme.of(context).textTheme.headlineSmall) - : null, - ), - ); - } - Widget _buildMetaChip(BuildContext context, IconData icon, String label, {VoidCallback? onTap}) { final chip = Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), @@ -322,30 +378,24 @@ class _ProfileScreenState extends State with SingleTickerProvider ), ); - if (onTap == null) { - return chip; - } - + if (onTap == null) return chip; return InkWell(onTap: onTap, borderRadius: BorderRadius.circular(999), child: chip); } Widget _buildStat(BuildContext context, int count, String label) { - return RichText( - text: TextSpan( - style: Theme.of(context).textTheme.bodyMedium, - children: [ - TextSpan( - text: _formatCount(count), - style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), - ), - TextSpan( - text: ' $label', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ], - ), + final colorScheme = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _formatCount(count), + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + Text( + label.toUpperCase(), + style: TextStyle(fontSize: 11, letterSpacing: 1.1, color: colorScheme.onSurfaceVariant), + ), + ], ); } @@ -425,7 +475,25 @@ class _ProfileScreenState extends State with SingleTickerProvider ); } - Widget _buildFeedList(FeedState feedState, FeedFilter tabFilter) { + Widget? _buildComposeFab(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final profile = state.profile; + if (profile == null) return const SizedBox.shrink(); + + final currentUserDid = context.read().state.tokens?.did; + final isOwnProfile = profile.did == currentUserDid; + final initialText = isOwnProfile ? null : '@${profile.handle} '; + + return FloatingActionButton( + onPressed: () => context.push('/compose', extra: ComposeRouteArgs(initialText: initialText)), + child: const Icon(Icons.add), + ); + }, + ); + } + + Widget _buildFeedList(FeedState feedState, FeedFilter tabFilter, ProfileViewDetailed? profile) { if (feedState.isLoading && feedState.filter == tabFilter) { return const Center(child: CircularProgressIndicator()); } @@ -442,6 +510,21 @@ class _ProfileScreenState extends State with SingleTickerProvider return Center(child: Text(_emptyLabel(tabFilter))); } + return BlocBuilder( + buildWhen: (prev, curr) => prev.feedArchitecture != curr.feedArchitecture, + builder: (context, settingsState) { + if (settingsState.feedArchitecture == FeedArchitecture.grid) { + return _buildGridFeed(context, feedState, profile); + } + return _buildLinearFeed(context, feedState); + }, + ); + } + + Widget _buildGridFeed(BuildContext context, FeedState feedState, ProfileViewDetailed? profile) { + final accountDid = _resolvedActor ?? ''; + final infoCardCount = profile == null ? 0 : 1; + return RefreshIndicator( onRefresh: _refresh, child: NotificationListener( @@ -451,7 +534,64 @@ class _ProfileScreenState extends State with SingleTickerProvider !feedState.isLoadingMore) { context.read().add(const FeedLoadMoreRequested()); } + return false; + }, + 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), + 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) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + + return Padding( + padding: EdgeInsets.only(bottom: postIndex == feedState.posts.length - 1 ? 0 : 16), + child: Center( + child: ConstrainedBox( + key: ValueKey('profile_large_card_$postIndex'), + constraints: const BoxConstraints(maxWidth: 720), + child: PostCardWithActions( + feedViewPost: feedState.posts[postIndex], + accountDid: accountDid, + variant: PostCardVariant.grid, + ), + ), + ), + ); + }, + ), + ), + ); + } + + Widget _buildLinearFeed(BuildContext context, FeedState feedState) { + return RefreshIndicator( + onRefresh: _refresh, + child: NotificationListener( + onNotification: (notification) { + if (notification.metrics.pixels > notification.metrics.maxScrollExtent - 300 && + feedState.hasMore && + !feedState.isLoadingMore) { + context.read().add(const FeedLoadMoreRequested()); + } return false; }, child: ListView.builder( @@ -464,7 +604,6 @@ class _ProfileScreenState extends State with SingleTickerProvider child: Center(child: CircularProgressIndicator()), ); } - return PostCardWithActions(feedViewPost: feedState.posts[index], accountDid: _resolvedActor ?? ''); }, ), @@ -484,40 +623,75 @@ class _ProfileScreenState extends State with SingleTickerProvider } String _formatCount(int count) { - if (count >= 1000000) { - return '${(count / 1000000).toStringAsFixed(1)}M'; - } - - if (count >= 1000) { - return '${(count / 1000).toStringAsFixed(1)}K'; - } - + if (count >= 1000000) return '${(count / 1000000).toStringAsFixed(1)}M'; + if (count >= 1000) return '${(count / 1000).toStringAsFixed(1)}K'; return '$count'; } String _initials(String value) { final parts = value.trim().split(RegExp(r'\s+')); - if (parts.isEmpty || parts.first.isEmpty) { - return '?'; - } - - if (parts.length == 1) { - return parts.first.substring(0, 1).toUpperCase(); - } - + if (parts.isEmpty || parts.first.isEmpty) return '?'; + if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase(); return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); } Future _launchWebsite(String website) async { final uri = Uri.tryParse(website.startsWith('http') ? website : 'https://$website'); - if (uri == null) { - return; - } - + if (uri == null) return; await launchUrl(uri, mode: LaunchMode.externalApplication); } } +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); @@ -531,7 +705,13 @@ class _SliverTabBarDelegate extends SliverPersistentHeaderDelegate { @override Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { - return ColoredBox(color: Theme.of(context).scaffoldBackgroundColor, child: tabBar); + final colorScheme = Theme.of(context).colorScheme; + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), + child: ColoredBox(color: colorScheme.surface.withValues(alpha: 0.85), child: tabBar), + ), + ); } @override diff --git a/test/core/router/app_router_test.dart b/test/core/router/app_router_test.dart index 4030e07..91cf3f2 100644 --- a/test/core/router/app_router_test.dart +++ b/test/core/router/app_router_test.dart @@ -121,22 +121,20 @@ void main() { await authController.close(); }); - Widget buildSubject() { - return MultiBlocProvider( - providers: [ - BlocProvider.value(value: authBloc), - BlocProvider.value(value: feedPreferencesCubit), - BlocProvider.value(value: profileBloc), - BlocProvider.value(value: feedBloc), - BlocProvider.value(value: settingsCubit), - BlocProvider.value(value: unreadCountCubit), - ], - child: RepositoryProvider( - create: (_) => notificationRepository, - child: MaterialApp.router(routerConfig: AppRouter(authBloc: authBloc).router), - ), - ); - } + Widget buildSubject() => MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: feedPreferencesCubit), + BlocProvider.value(value: profileBloc), + BlocProvider.value(value: feedBloc), + BlocProvider.value(value: settingsCubit), + BlocProvider.value(value: unreadCountCubit), + ], + child: RepositoryProvider( + create: (_) => notificationRepository, + child: MaterialApp.router(routerConfig: AppRouter(authBloc: authBloc).router), + ), + ); testWidgets('opens the side menu and switches authenticated branches', (tester) async { await tester.binding.setSurfaceSize(const Size(430, 932)); @@ -159,7 +157,7 @@ void main() { await tester.tap(find.text('Profile').last); await tester.pumpAndSettle(); - expect(find.text('River Tam'), findsOneWidget); + expect(find.text('RIVER TAM'), findsOneWidget); await tester.tap(find.byTooltip('Open menu')); await tester.pumpAndSettle(); @@ -180,11 +178,8 @@ void main() { final navBar = tester.widget(find.byType(NavigationBar)); expect(navBar.destinations.length, 4); - // Labels appear in the nav bar (HOME also appears in the AppBar section label) final destinations = navBar.destinations.cast(); expect(destinations.map((d) => d.label), containsAll(['HOME', 'SEARCH', 'ALERTS', 'PROFILE'])); - - // Messages and Settings are no longer in the bottom nav expect(destinations.any((d) => d.label == 'MESSAGES'), isFalse); expect(destinations.any((d) => d.label == 'SETTINGS'), isFalse); }); @@ -221,17 +216,15 @@ void main() { await tester.pumpWidget(buildSubject()); await tester.pumpAndSettle(); - // Start on home branch (index 0) final navBar = tester.widget(find.byType(NavigationBar)); expect(navBar.selectedIndex, 0); - // Tap PROFILE tab (index 3) await tester.tap(find.text('PROFILE')); await tester.pumpAndSettle(); final navBarAfter = tester.widget(find.byType(NavigationBar)); expect(navBarAfter.selectedIndex, 3); - expect(find.text('River Tam'), findsOneWidget); + expect(find.text('RIVER TAM'), findsOneWidget); }); testWidgets('LazuriteAppBar shows section label and hamburger on home screen', (tester) async { @@ -241,7 +234,6 @@ void main() { await tester.pumpWidget(buildSubject()); await tester.pumpAndSettle(); - // Home screen uses LazuriteAppBar with sectionLabel 'Home' — 'HOME' appears at least once expect(find.text('HOME'), findsAtLeastNWidgets(1)); expect(find.byTooltip('Open menu'), findsOneWidget); }); diff --git a/test/core/widgets/lazurite_app_bar_test.dart b/test/core/widgets/lazurite_app_bar_test.dart index 3a5bd65..bb1005c 100644 --- a/test/core/widgets/lazurite_app_bar_test.dart +++ b/test/core/widgets/lazurite_app_bar_test.dart @@ -42,22 +42,18 @@ void main() { testWidgets('renders section label in uppercase', (tester) async { await tester.pumpWidget(buildSubject(sectionLabel: 'Home')); await tester.pumpAndSettle(); - expect(find.text('HOME'), findsOneWidget); }); testWidgets('renders hamburger menu button', (tester) async { await tester.pumpWidget(buildSubject(sectionLabel: 'Search')); await tester.pumpAndSettle(); - expect(find.byType(AppShellMenuButton), findsOneWidget); }); testWidgets('renders user initials in avatar from displayName', (tester) async { await tester.pumpWidget(buildSubject(sectionLabel: 'Home')); await tester.pumpAndSettle(); - - // 'River Tam' → initials 'RT' expect(find.text('RT'), findsOneWidget); }); @@ -75,8 +71,6 @@ void main() { await tester.pumpWidget(buildSubject(sectionLabel: 'Home')); await tester.pumpAndSettle(); - - // handle 'alice.bsky.social' → first part 'alice.bsky.social' → 'A' expect(find.text('A'), findsOneWidget); }); diff --git a/test/features/feed/presentation/grid_post_card_test.dart b/test/features/feed/presentation/grid_post_card_test.dart index 3021061..abc3a5e 100644 --- a/test/features/feed/presentation/grid_post_card_test.dart +++ b/test/features/feed/presentation/grid_post_card_test.dart @@ -1,5 +1,6 @@ import 'package:atproto_core/atproto_core.dart'; import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_embed_external.dart'; import 'package:bluesky/app_bsky_embed_images.dart'; import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:bluesky/app_bsky_feed_post.dart'; @@ -108,6 +109,26 @@ void main() { expect(find.byType(ColorFiltered), findsOneWidget); }); + testWidgets('caps non-image embed previews inside grid cards', (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)); + + expect(find.text('Example Article'), findsOneWidget); + expect(find.byWidgetPredicate((widget) => widget is SizedBox && widget.height == 240), findsOneWidget); + }); + 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/home_feed_screen_test.dart b/test/features/feed/presentation/home_feed_screen_test.dart new file mode 100644 index 0000000..6bcea04 --- /dev/null +++ b/test/features/feed/presentation/home_feed_screen_test.dart @@ -0,0 +1,252 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/theme/app_theme.dart'; +import 'package:lazurite/core/theme/feed_architecture.dart'; +import 'package:lazurite/core/theme/ui_density.dart'; +import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; +import 'package:lazurite/features/feed/presentation/widgets/feed_layout_view.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/settings/bloc/settings_state.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockSettingsCubit extends MockCubit implements SettingsCubit {} + +SettingsState _settingsState(FeedArchitecture architecture) => SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + uiDensity: UiDensity.standard, + feedArchitecture: architecture, +); + +Widget _buildSubject({required FeedArchitecture architecture, double screenWidth = 400, int itemCount = 3}) { + final cubit = MockSettingsCubit(); + when(() => cubit.state).thenReturn(_settingsState(architecture)); + + return MediaQuery( + data: MediaQueryData(size: Size(screenWidth, 800)), + child: MaterialApp( + home: Scaffold( + body: BlocProvider.value( + value: cubit, + child: FeedLayoutView( + itemCount: itemCount, + scrollController: ScrollController(), + isLoadingMore: false, + onRefresh: () async {}, + gridItemBuilder: (_, i) => SizedBox(key: ValueKey('grid-$i'), child: Text('grid $i')), + linearItemBuilder: (_, i) => SizedBox(key: ValueKey('linear-$i'), child: Text('linear $i')), + ), + ), + ), + ), + ); +} + +void main() { + group('feedColumnCount', () { + test('returns 1 column for width < 600', () { + expect(feedColumnCount(599), 1); + expect(feedColumnCount(400), 1); + expect(feedColumnCount(0), 1); + }); + + test('returns 2 columns for width 600–839', () { + expect(feedColumnCount(600), 2); + expect(feedColumnCount(720), 2); + expect(feedColumnCount(839), 2); + }); + + test('returns 3 columns for width 840–1199', () { + expect(feedColumnCount(840), 3); + expect(feedColumnCount(1000), 3); + expect(feedColumnCount(1199), 3); + }); + + test('returns 4 columns for width >= 1200', () { + expect(feedColumnCount(1200), 4); + expect(feedColumnCount(1600), 4); + }); + }); + + group('FeedLayoutView — grid architecture', () { + testWidgets('shows SliverGrid when architecture is grid', (tester) async { + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid)); + expect(find.byType(SliverGrid), findsOneWidget); + expect(find.byType(CustomScrollView), findsOneWidget); + }); + + testWidgets('uses gridItemBuilder in grid mode', (tester) async { + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid)); + expect(find.text('grid 0'), findsOneWidget); + expect(find.text('linear 0'), findsNothing); + }); + + testWidgets('uses 1 column at width < 600', (tester) async { + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 400)); + + final grid = tester.widget(find.byType(SliverGrid)); + final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisCount, 1); + }); + + testWidgets('uses 2 columns at width 600–839', (tester) async { + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 720)); + + final grid = tester.widget(find.byType(SliverGrid)); + final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisCount, 2); + }); + + testWidgets('uses 3 columns at width 840–1199', (tester) async { + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 1000)); + + final grid = tester.widget(find.byType(SliverGrid)); + final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisCount, 3); + }); + + testWidgets('uses 4 columns at width >= 1200', (tester) async { + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 1400)); + + final grid = tester.widget(find.byType(SliverGrid)); + final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisCount, 4); + }); + + testWidgets('allocates extra height beyond the square media region', (tester) async { + const screenWidth = 400.0; + + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: screenWidth)); + + final grid = tester.widget(find.byType(SliverGrid)); + final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + final tileWidth = screenWidth; + + expect(delegate.mainAxisExtent, isNotNull); + expect(delegate.mainAxisExtent!, greaterThan(tileWidth + 100)); + }); + }); + + group('FeedLayoutView — linear architecture', () { + testWidgets('shows ListView when architecture is linear', (tester) async { + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.linear)); + + expect(find.byType(ListView), findsOneWidget); + expect(find.byType(SliverGrid), findsNothing); + }); + + testWidgets('uses linearItemBuilder in linear mode', (tester) async { + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.linear)); + + expect(find.text('linear 0'), findsOneWidget); + expect(find.text('linear 1'), findsOneWidget); + expect(find.text('linear 2'), findsOneWidget); + }); + }); + + group('FeedLayoutView — architecture switching', () { + testWidgets('switches from grid to linear without re-fetch', (tester) async { + final cubit = MockSettingsCubit(); + final streamController = StreamController.broadcast(); + + when(() => cubit.state).thenReturn(_settingsState(FeedArchitecture.grid)); + when(() => cubit.stream).thenAnswer((_) => streamController.stream); + + var buildCount = 0; + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(size: Size(400, 800)), + child: MaterialApp( + home: Scaffold( + body: BlocProvider.value( + value: cubit, + child: FeedLayoutView( + itemCount: 1, + scrollController: ScrollController(), + isLoadingMore: false, + onRefresh: () async => buildCount++, + gridItemBuilder: (_, i) => const Text('grid'), + linearItemBuilder: (_, i) => const Text('linear'), + ), + ), + ), + ), + ), + ); + + expect(find.byType(SliverGrid), findsOneWidget); + + when(() => cubit.state).thenReturn(_settingsState(FeedArchitecture.linear)); + streamController.add(_settingsState(FeedArchitecture.linear)); + await tester.pump(); + + expect(find.byType(SliverGrid), findsNothing); + expect(find.byType(ListView), findsOneWidget); + expect(buildCount, 0); + + await streamController.close(); + }); + + testWidgets('loading indicator appears when isLoadingMore is true in grid mode', (tester) async { + final cubit = MockSettingsCubit(); + when(() => cubit.state).thenReturn(_settingsState(FeedArchitecture.grid)); + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(size: Size(400, 800)), + child: MaterialApp( + home: Scaffold( + body: BlocProvider.value( + value: cubit, + child: FeedLayoutView( + itemCount: 0, + scrollController: ScrollController(), + isLoadingMore: true, + onRefresh: () async {}, + gridItemBuilder: (_, i) => const Text('item'), + linearItemBuilder: (_, i) => const Text('item'), + ), + ), + ), + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('loading indicator appears when isLoadingMore is true in linear mode', (tester) async { + final cubit = MockSettingsCubit(); + when(() => cubit.state).thenReturn(_settingsState(FeedArchitecture.linear)); + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(size: Size(400, 800)), + child: MaterialApp( + home: Scaffold( + body: BlocProvider.value( + value: cubit, + child: FeedLayoutView( + itemCount: 1, + scrollController: ScrollController(), + isLoadingMore: true, + onRefresh: () async {}, + gridItemBuilder: (_, i) => const Text('item'), + linearItemBuilder: (_, i) => const Text('item'), + ), + ), + ), + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + }); +} diff --git a/test/features/feed/presentation/post_card_footer_test.dart b/test/features/feed/presentation/post_card_footer_test.dart index 6277ba6..76dd71c 100644 --- a/test/features/feed/presentation/post_card_footer_test.dart +++ b/test/features/feed/presentation/post_card_footer_test.dart @@ -39,7 +39,6 @@ void main() { group('PostCardFooter', () { testWidgets('renders timestamp text', (tester) async { await tester.pumpWidget(_buildSubject(const PostCardFooter(timestamp: '2H'))); - expect(find.text('2H'), findsOneWidget); }); @@ -54,15 +53,12 @@ void main() { testWidgets('shows active like icon when isLiked is true', (tester) async { await tester.pumpWidget(_buildSubject(const PostCardFooter(timestamp: '1H', isLiked: true))); - expect(find.byIcon(Icons.favorite), findsOneWidget); expect(find.byIcon(Icons.favorite_outline), findsNothing); }); testWidgets('shows active repost icon when isReposted is true', (tester) async { await tester.pumpWidget(_buildSubject(const PostCardFooter(timestamp: '1H', isReposted: true))); - - // repeat icon is used for both active and inactive expect(find.byIcon(Icons.repeat), findsOneWidget); }); @@ -113,5 +109,25 @@ void main() { final decoration = container.decoration as BoxDecoration?; expect(decoration?.border, isNotNull); }); + + testWidgets('uses compact spacing without overflow at narrow grid widths', (tester) async { + final errors = []; + final previousOnError = FlutterError.onError; + FlutterError.onError = errors.add; + addTearDown(() => FlutterError.onError = previousOnError); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox(width: 185.5, child: const PostCardFooter(timestamp: 'SEP 20')), + ), + ), + ), + ); + + expect(errors.where((error) => error.exceptionAsString().contains('A RenderFlex overflowed')), isEmpty); + expect(find.text('SEP 20'), findsOneWidget); + }); }); } diff --git a/test/features/profile/presentation/profile_screen_test.dart b/test/features/profile/presentation/profile_screen_test.dart index eb9e7a0..f8195f6 100644 --- a/test/features/profile/presentation/profile_screen_test.dart +++ b/test/features/profile/presentation/profile_screen_test.dart @@ -1,16 +1,29 @@ +import 'dart:async'; + +import 'package:atproto_core/atproto_core.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_feed_defs.dart'; +import 'package:bluesky/app_bsky_feed_post.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.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/core/theme/feed_architecture.dart'; +import 'package:lazurite/core/theme/ui_density.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; -import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; +import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; +import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; +import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; +import 'package:lazurite/features/feed/data/post_action_repository.dart'; import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; import 'package:lazurite/features/profile/data/profile_action_repository.dart'; import 'package:lazurite/features/profile/presentation/profile_screen.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/settings/bloc/settings_state.dart'; import 'package:mocktail/mocktail.dart'; class MockAuthBloc extends MockBloc implements AuthBloc {} @@ -21,10 +34,19 @@ class MockFeedBloc extends MockBloc implements FeedBloc {} class MockProfileActionRepository extends Mock implements ProfileActionRepository {} +class MockSettingsCubit extends MockCubit implements SettingsCubit {} + +class MockPostActionRepository extends Mock implements PostActionRepository {} + +class MockSavedPostsCubit extends MockCubit implements SavedPostsCubit {} + +class MockPostActionCache extends Mock implements PostActionCache {} + void main() { late MockAuthBloc authBloc; late MockProfileBloc profileBloc; late MockFeedBloc feedBloc; + late MockSettingsCubit settingsCubit; const tokens = AuthTokens( accessToken: 'access', @@ -46,16 +68,34 @@ void main() { createdAt: DateTime.utc(2024, 3, 1), ); + SettingsState defaultSettingsState() => const SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + uiDensity: UiDensity.standard, + feedArchitecture: FeedArchitecture.grid, + ); + + SettingsState settingsStateWith(FeedArchitecture architecture) => SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + uiDensity: UiDensity.standard, + feedArchitecture: architecture, + ); + setUp(() { authBloc = MockAuthBloc(); profileBloc = MockProfileBloc(); feedBloc = MockFeedBloc(); + settingsCubit = MockSettingsCubit(); when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); when(() => profileBloc.state).thenReturn(ProfileState.loaded(profile: profile)); when(() => feedBloc.state).thenReturn( const FeedState.loaded(actor: 'did:plc:me', posts: [], filter: FeedFilter.postsNoReplies, hasMore: false), ); + when(() => settingsCubit.state).thenReturn(defaultSettingsState()); whenListen(authBloc, const Stream.empty(), initialState: const AuthState.authenticated(tokens)); whenListen(profileBloc, const Stream.empty(), initialState: ProfileState.loaded(profile: profile)); @@ -69,6 +109,7 @@ void main() { hasMore: false, ), ); + whenListen(settingsCubit, const Stream.empty(), initialState: defaultSettingsState()); }); Widget buildSubject() { @@ -77,12 +118,22 @@ void main() { BlocProvider.value(value: authBloc), BlocProvider.value(value: profileBloc), BlocProvider.value(value: feedBloc), + BlocProvider.value(value: settingsCubit), ], child: const MaterialApp(home: ProfileScreen()), ); } + /// Sets the test viewport to a tall size so that the full profile header + /// (cover + summary + tab bar) fits within the viewport. + void useLargeScreen(WidgetTester tester) { + tester.view.physicalSize = const Size(800, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + } + testWidgets('loads posts filter by default and renders the required profile fields', (tester) async { + useLargeScreen(tester); await tester.pumpWidget(buildSubject()); verify(() => profileBloc.add(const ProfileLoadRequested(actor: 'did:plc:me'))).called(1); @@ -90,7 +141,7 @@ void main() { () => feedBloc.add(const FeedLoadRequested(actor: 'did:plc:me', filter: FeedFilter.postsNoReplies)), ).called(1); - expect(find.text('River Tam'), findsOneWidget); + expect(find.text('RIVER TAM'), findsOneWidget); expect(find.text('@me.bsky.social'), findsOneWidget); expect(find.text('Signal and signal boost.'), findsOneWidget); expect(find.text('she/her'), findsOneWidget); @@ -99,12 +150,14 @@ void main() { }); testWidgets('shows Saved Posts button on own profile', (tester) async { + useLargeScreen(tester); await tester.pumpWidget(buildSubject()); expect(find.text('Saved Posts'), findsOneWidget); }); testWidgets('does not show Saved Posts button on other profiles', (tester) async { + useLargeScreen(tester); const otherProfile = ProfileViewDetailed( did: 'did:plc:other', handle: 'other.bsky.social', @@ -126,6 +179,7 @@ void main() { BlocProvider.value(value: authBloc), BlocProvider.value(value: profileBloc), BlocProvider.value(value: feedBloc), + BlocProvider.value(value: settingsCubit), ], child: const MaterialApp(home: ProfileScreen(actor: 'did:plc:other', showBackButton: true)), ), @@ -137,16 +191,17 @@ void main() { }); testWidgets('maps tabs to the expected server filters', (tester) async { + useLargeScreen(tester); await tester.pumpWidget(buildSubject()); - await tester.tap(find.text('Replies')); + await tester.tap(find.text('REPLIES')); await tester.pump(); verify( () => feedBloc.add(const FeedLoadRequested(actor: 'did:plc:me', filter: FeedFilter.postsAndAuthorThreads)), ).called(1); - await tester.tap(find.text('Media')); + await tester.tap(find.text('MEDIA')); await tester.pump(); verify( @@ -155,6 +210,7 @@ void main() { }); testWidgets('compose FAB on other profiles prefills the mentioned handle', (tester) async { + useLargeScreen(tester); const otherProfile = ProfileViewDetailed( did: 'did:plc:other', handle: 'other.bsky.social', @@ -172,13 +228,14 @@ void main() { routes: [ GoRoute( path: '/', - builder: (context, state) => RepositoryProvider.value( - value: mockProfileActionRepository, + builder: (context, state) => MultiRepositoryProvider( + providers: [RepositoryProvider.value(value: mockProfileActionRepository)], child: MultiBlocProvider( providers: [ BlocProvider.value(value: authBloc), BlocProvider.value(value: profileBloc), BlocProvider.value(value: feedBloc), + BlocProvider.value(value: settingsCubit), ], child: const ProfileScreen(actor: 'did:plc:other', showBackButton: true), ), @@ -206,4 +263,191 @@ void main() { router.dispose(); }); + + group('Profile header', () { + testWidgets('avatar is square (no CircleAvatar)', (tester) async { + useLargeScreen(tester); + await tester.pumpWidget(buildSubject()); + + expect(find.byType(CircleAvatar), findsNothing); + expect(find.byKey(const ValueKey('profile_square_avatar')), findsOneWidget); + }); + + testWidgets('display name is rendered uppercase', (tester) async { + useLargeScreen(tester); + await tester.pumpWidget(buildSubject()); + + expect(find.text('RIVER TAM'), findsOneWidget); + expect(find.text('River Tam'), findsNothing); + }); + + testWidgets('handle is shown with @ prefix', (tester) async { + useLargeScreen(tester); + await tester.pumpWidget(buildSubject()); + + expect(find.text('@me.bsky.social'), findsOneWidget); + }); + + testWidgets('bio is shown', (tester) async { + useLargeScreen(tester); + await tester.pumpWidget(buildSubject()); + expect(find.text('Signal and signal boost.'), findsOneWidget); + }); + + testWidgets('stats row is rendered in a bordered container', (tester) async { + useLargeScreen(tester); + await tester.pumpWidget(buildSubject()); + expect(find.byKey(const ValueKey('profile_stats_row')), findsOneWidget); + }); + + testWidgets('stat values are shown as formatted counts', (tester) async { + useLargeScreen(tester); + await tester.pumpWidget(buildSubject()); + + expect(find.text('1.2K'), findsWidgets); + expect(find.text('64'), findsOneWidget); + }); + + testWidgets('stat labels are uppercase', (tester) async { + useLargeScreen(tester); + await tester.pumpWidget(buildSubject()); + + expect(find.text('FOLLOWING'), findsOneWidget); + expect(find.text('FOLLOWERS'), findsOneWidget); + expect(find.text('POSTS'), findsAtLeastNWidgets(1)); + }); + + testWidgets('cover section is present', (tester) async { + useLargeScreen(tester); + await tester.pumpWidget(buildSubject()); + + expect(find.byKey(const ValueKey('profile_square_avatar')), findsOneWidget); + }); + }); + + group('Tab bar', () { + testWidgets('tab labels are uppercase', (tester) async { + useLargeScreen(tester); + await tester.pumpWidget(buildSubject()); + + expect(find.text('REPLIES'), findsOneWidget); + expect(find.text('MEDIA'), findsOneWidget); + }); + + testWidgets('original-case tab labels are not shown', (tester) async { + useLargeScreen(tester); + await tester.pumpWidget(buildSubject()); + + expect(find.text('Replies'), findsNothing); + expect(find.text('Media'), findsNothing); + }); + }); + + group('Feed layout switching', () { + FeedViewPost makePost(String id) { + final record = FeedPostRecord(text: 'Post $id', createdAt: DateTime.utc(2026, 3, 1)); + return FeedViewPost( + post: PostView( + uri: AtUri('at://did:plc:me/app.bsky.feed.post/$id'), + cid: 'cid-$id', + author: const ProfileViewBasic(did: 'did:plc:me', handle: 'me.bsky.social', displayName: 'River Tam'), + record: record.toJson(), + indexedAt: DateTime.utc(2026, 3, 1), + ), + ); + } + + final posts = List.generate(3, (i) => makePost('$i')); + + FeedState feedStateWith(List p) => + FeedState.loaded(actor: 'did:plc:me', posts: p, filter: FeedFilter.postsNoReplies, hasMore: false); + + /// Builds the profile screen with [posts] in the feed and the given + /// [settCubit] controlling layout mode. + Widget buildWithPosts(WidgetTester tester, MockSettingsCubit settCubit) { + useLargeScreen(tester); + + final mockPostActionRepo = MockPostActionRepository(); + final mockSavedPostsCubit = MockSavedPostsCubit(); + final mockPostActionCache = MockPostActionCache(); + + when(() => mockSavedPostsCubit.state).thenReturn(const SavedPostsState()); + whenListen(mockSavedPostsCubit, const Stream.empty()); + + when(() => feedBloc.state).thenReturn(feedStateWith(posts)); + whenListen(feedBloc, const Stream.empty(), initialState: feedStateWith(posts)); + + return MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: mockPostActionRepo), + RepositoryProvider.value(value: mockPostActionCache), + ], + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: profileBloc), + BlocProvider.value(value: feedBloc), + BlocProvider.value(value: settCubit), + BlocProvider.value(value: mockSavedPostsCubit), + ], + child: const MaterialApp(home: ProfileScreen()), + ), + ); + } + + testWidgets('grid mode shows centered large grid cards with 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)); + + await tester.pumpWidget(buildWithPosts(tester, cubit)); + 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_large_card_0')), findsOneWidget); + expect(find.byKey(const ValueKey('profile_large_card_1')), findsOneWidget); + expect(find.byKey(const ValueKey('profile_large_card_2')), findsOneWidget); + }); + + testWidgets('linear mode does not show the large grid card feed or metadata info card', (tester) async { + final cubit = MockSettingsCubit(); + when(() => cubit.state).thenReturn(settingsStateWith(FeedArchitecture.linear)); + whenListen(cubit, const Stream.empty(), initialState: settingsStateWith(FeedArchitecture.linear)); + + await tester.pumpWidget(buildWithPosts(tester, cubit)); + await tester.pump(); + + expect(find.byKey(const ValueKey('profile_grid_feed')), findsNothing); + expect(find.byKey(const ValueKey('profile_info_card')), findsNothing); + 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 { + final cubit = MockSettingsCubit(); + final streamCtrl = StreamController.broadcast(); + + when(() => cubit.state).thenReturn(settingsStateWith(FeedArchitecture.grid)); + when(() => cubit.stream).thenAnswer((_) => streamCtrl.stream); + + await tester.pumpWidget(buildWithPosts(tester, cubit)); + await tester.pump(); + + expect(find.byKey(const ValueKey('profile_grid_feed')), findsOneWidget); + expect(find.byKey(const ValueKey('profile_info_card')), findsOneWidget); + + when(() => cubit.state).thenReturn(settingsStateWith(FeedArchitecture.linear)); + streamCtrl.add(settingsStateWith(FeedArchitecture.linear)); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('profile_grid_feed')), findsNothing); + expect(find.byKey(const ValueKey('profile_info_card')), findsNothing); + + verifyNever(() => feedBloc.add(const FeedRefreshRequested())); + + await streamCtrl.close(); + }); + }); }