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

Configure Feed

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

feat: responsive grid and linear layouts for the home feed

* open drawer from any context

+1094 -242
+5
docs/TODO.md
··· 31 31 It would be cool to display this and allow exporting the feed or a link to it. 32 32 - In dev tools, show Firehose, Jetstream, and [spacedust](https://spacedust.microcosm.blue/#GET/subscribe) as tabs. 33 33 34 + --- 35 + 36 + - Markdown support (toggleable) 37 + - Collapsible threads 38 + 34 39 ## Privacy Policy 35 40 36 41 - Should mention that Lazurite is an AppView that doesn't store any user data.
+8 -6
docs/specs/ui-refactor.md
··· 96 96 Current: `NestedScrollView` with collapsible header, `CircleAvatar`, 97 97 `TabBar` (Posts / Replies / Media). 98 98 99 - Refactor to an asymmetric "bento" layout: 99 + Refactor to a larger-card profile layout: 100 100 101 101 ### Header 102 102 ··· 122 122 123 123 ### Content Area 124 124 125 - Profile posts use a `12-column` asymmetric bento grid: 125 + Profile posts use a simple large-card stack when the feed architecture is set 126 + to "Grid Matrix": 126 127 127 - - Pinned post spans `8 columns` (featured, with full image embed) 128 - - Metadata / info card spans `4 columns` (`surfaceContainerHigh` background) 129 - - Remaining posts in `6+6` two-column pairs 128 + - Keep the metadata/info card as a standalone block above the posts 129 + - Use the grid post card variant 130 + - Render one card per row 131 + - Center cards with a comfortable max width so they stay visually larger than 132 + the linear feed on wide screens 130 133 131 - The bento grid applies when the feed architecture is set to "Grid Matrix". 132 134 When set to "Linear Flow", profile posts render as a standard vertical list 133 135 using the linear post card. 134 136
+11 -11
docs/tasks/ui-refactor.md
··· 30 30 31 31 ## M3 — Home Feed Grid Layout 32 32 33 - - [ ] `HomeFeedScreen` reads `feed_architecture` from `SettingsCubit` 34 - - [ ] Grid mode: responsive `SliverGrid` with breakpoint-based column count 35 - - [ ] Linear mode: existing `ListView` of linear post cards (no change) 36 - - [ ] Feed architecture toggle triggers rebuild without re-fetch 37 - - [ ] Tests for grid/linear switching and column count at breakpoints 33 + - [x] `HomeFeedScreen` reads `feed_architecture` from `SettingsCubit` 34 + - [x] Grid mode: responsive `SliverGrid` with breakpoint-based column count 35 + - [x] Linear mode: existing `ListView` of linear post cards (with more space around cards) 36 + - [x] Feed architecture toggle triggers rebuild without re-fetch 37 + - [x] Tests for grid/linear switching and column count at breakpoints 38 38 39 39 ## M4 — Profile Screen Refactor 40 40 41 - - [ ] Profile header: square avatar, cover image (grayscale, opacity), stats row with border 42 - - [ ] Display name uppercase + tight tracking, handle below 43 - - [ ] Sticky tab bar with backdrop blur and uppercase labels 44 - - [ ] Bento grid layout for profile posts (8+4 featured row, 6+6 pairs) in grid mode 45 - - [ ] Linear fallback for profile posts when feed architecture is "linear" 46 - - [ ] Tests for profile header rendering and layout mode switching 41 + - [x] Profile header: square avatar, cover image (grayscale, opacity), stats row with border 42 + - [x] Display name uppercase + tight tracking, handle below 43 + - [x] Sticky tab bar with backdrop blur and uppercase labels 44 + - [x] Large-card grid layout for profile posts in grid mode, with the metadata info card retained above the feed 45 + - [x] Linear fallback for profile posts when feed architecture is "linear" 46 + - [x] Tests for profile header rendering and layout mode switching 47 47 48 48 ## M5 — Layout Settings Screen 49 49
+11 -8
lib/core/router/app_shell.dart
··· 25 25 @override 26 26 Widget build(BuildContext context) { 27 27 final shellScope = AppShellScope.maybeOf(context); 28 - 29 - return IconButton(tooltip: 'Open menu', onPressed: shellScope?.openMenu, icon: const Icon(Icons.menu)); 28 + final onPressed = shellScope?.openMenu ?? AppShell.openDrawer; 29 + return IconButton(tooltip: 'Open menu', onPressed: onPressed, icon: const Icon(Icons.menu)); 30 30 } 31 31 } 32 32 ··· 35 35 36 36 final StatefulNavigationShell navigationShell; 37 37 38 + /// Global key for the shell [Scaffold]. Accessible from anywhere — even 39 + /// screens pushed onto the root navigator that are outside [AppShellScope]. 40 + static final scaffoldKey = GlobalKey<ScaffoldState>(); 41 + 42 + /// Opens the navigation drawer from any context. 43 + static void openDrawer() => AppShell.scaffoldKey.currentState?.openDrawer(); 44 + 38 45 @override 39 46 State<AppShell> createState() => _AppShellState(); 40 47 } 41 48 42 49 class _AppShellState extends State<AppShell> { 43 - final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>(); 44 - 45 - void _openMenu() { 46 - _scaffoldKey.currentState?.openDrawer(); 47 - } 50 + void _openMenu() => AppShell.openDrawer(); 48 51 49 52 @override 50 53 Widget build(BuildContext context) { ··· 52 55 return AppShellScope( 53 56 openMenu: _openMenu, 54 57 child: Scaffold( 55 - key: _scaffoldKey, 58 + key: AppShell.scaffoldKey, 56 59 drawer: _AppMenu(navigationShell: widget.navigationShell, rootContext: context), 57 60 body: widget.navigationShell, 58 61 bottomNavigationBar: Container(
+2 -2
lib/core/theme/typography.dart
··· 65 65 titleSmall: lora(fontSize: 14, fontWeight: FontWeight.w500, color: bodyColor, letterSpacing: 0.1), 66 66 bodyLarge: dmSans(fontSize: 16, fontWeight: FontWeight.w400, color: bodyColor, letterSpacing: 0.5), 67 67 bodyMedium: dmSans(fontSize: 14, fontWeight: FontWeight.w400, color: bodyColor, letterSpacing: 0.25), 68 - bodySmall: jetBrainsMono(fontSize: 12, fontWeight: FontWeight.w400, color: captionColor, letterSpacing: 0.4), 68 + bodySmall: dmSans(fontSize: 12, fontWeight: FontWeight.w400, color: captionColor, letterSpacing: 0.4), 69 69 labelLarge: dmSans(fontSize: 14, fontWeight: FontWeight.w500, color: bodyColor, letterSpacing: 0.1), 70 70 labelMedium: dmSans(fontSize: 12, fontWeight: FontWeight.w500, color: bodyColor, letterSpacing: 0.5), 71 - labelSmall: jetBrainsMono(fontSize: 11, fontWeight: FontWeight.w500, color: captionColor, letterSpacing: 0.5), 71 + labelSmall: dmSans(fontSize: 12, fontWeight: FontWeight.w500, color: captionColor, letterSpacing: 0.5), 72 72 ); 73 73 } 74 74 }
+2 -1
lib/core/widgets/lazurite_app_bar.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:lazurite/core/logging/app_logger.dart'; 3 4 import 'package:lazurite/core/router/app_shell.dart'; 4 5 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 5 6 ··· 56 57 try { 57 58 authState = context.watch<AuthBloc>().state; 58 59 } catch (_) { 59 - // AuthBloc not provided — show default avatar 60 + log.d('showing default avatar'); 60 61 } 61 62 final tokens = authState?.tokens; 62 63 final initials = _initialsFor(tokens?.displayName ?? tokens?.handle ?? 'L');
+30 -22
lib/features/feed/presentation/home_feed_screen.dart
··· 8 8 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 9 9 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 10 10 import 'package:lazurite/features/feed/data/feed_repository.dart'; 11 + import 'package:lazurite/features/feed/presentation/widgets/feed_layout_view.dart'; 11 12 import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 13 + 14 + /// Returns the number of grid columns for [width] per the responsive 15 + /// breakpoints defined in the UI spec. 16 + int feedColumnCount(double width) { 17 + if (width >= 1200) return 4; 18 + if (width >= 840) return 3; 19 + if (width >= 600) return 2; 20 + return 1; 21 + } 12 22 13 23 class HomeFeedScreen extends StatefulWidget { 14 24 const HomeFeedScreen({super.key}); ··· 343 353 return Center(child: Text('No posts yet', style: Theme.of(context).textTheme.bodyLarge)); 344 354 } 345 355 346 - return RefreshIndicator( 347 - onRefresh: _loadFeed, 348 - child: ListView.builder( 349 - controller: _scrollController, 350 - itemCount: _posts.length + (_isLoadingMore ? 1 : 0), 351 - itemBuilder: (context, index) { 352 - if (index == _posts.length) { 353 - return const Center( 354 - child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), 355 - ); 356 - } 356 + final accountDid = context.read<AuthBloc>().state.tokens?.did ?? ''; 357 357 358 - final accountDid = context.read<AuthBloc>().state.tokens?.did ?? ''; 359 - final post = _posts[index]; 360 - return PostCardWithActions( 361 - feedViewPost: post, 362 - accountDid: accountDid, 363 - onDeleted: () { 364 - final uri = post.post.uri.toString(); 365 - setState(() => _posts.removeWhere((p) => p.post.uri.toString() == uri)); 366 - }, 367 - ); 358 + PostCardWithActions buildCard(int index, PostCardVariant variant) { 359 + final post = _posts[index]; 360 + return PostCardWithActions( 361 + feedViewPost: post, 362 + accountDid: accountDid, 363 + variant: variant, 364 + onDeleted: () { 365 + final uri = post.post.uri.toString(); 366 + setState(() => _posts.removeWhere((p) => p.post.uri.toString() == uri)); 368 367 }, 369 - ), 368 + ); 369 + } 370 + 371 + return FeedLayoutView( 372 + itemCount: _posts.length, 373 + scrollController: _scrollController, 374 + isLoadingMore: _isLoadingMore, 375 + onRefresh: _loadFeed, 376 + gridItemBuilder: (context, index) => buildCard(index, PostCardVariant.grid), 377 + linearItemBuilder: (context, index) => buildCard(index, PostCardVariant.linear), 370 378 ); 371 379 } 372 380 }
+98
lib/features/feed/presentation/widgets/feed_layout_view.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:lazurite/core/theme/feed_architecture.dart'; 4 + import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; 5 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 6 + import 'package:lazurite/features/settings/bloc/settings_state.dart'; 7 + 8 + const double _gridSpacing = 1; 9 + const double _gridCardChromeHeight = 160; 10 + 11 + /// Renders a scrollable list of items in either a responsive [SliverGrid] 12 + /// (grid architecture) or a padded [ListView] (linear architecture), driven 13 + /// by [SettingsCubit.feedArchitecture]. 14 + /// 15 + /// [gridItemBuilder] is used when the grid architecture is active. 16 + /// [linearItemBuilder] is used when the linear architecture is active. 17 + /// This allows the caller to render the appropriate card variant for each mode. 18 + class FeedLayoutView extends StatelessWidget { 19 + const FeedLayoutView({ 20 + super.key, 21 + required this.itemCount, 22 + required this.gridItemBuilder, 23 + required this.linearItemBuilder, 24 + required this.scrollController, 25 + required this.isLoadingMore, 26 + required this.onRefresh, 27 + }); 28 + 29 + final int itemCount; 30 + final IndexedWidgetBuilder gridItemBuilder; 31 + final IndexedWidgetBuilder linearItemBuilder; 32 + final ScrollController scrollController; 33 + final bool isLoadingMore; 34 + final RefreshCallback onRefresh; 35 + 36 + @override 37 + Widget build(BuildContext context) { 38 + return BlocBuilder<SettingsCubit, SettingsState>( 39 + buildWhen: (prev, curr) => prev.feedArchitecture != curr.feedArchitecture, 40 + builder: (context, settingsState) { 41 + if (settingsState.feedArchitecture == FeedArchitecture.grid) { 42 + return _buildGrid(context); 43 + } 44 + return _buildLinear(context); 45 + }, 46 + ); 47 + } 48 + 49 + Widget _buildGrid(BuildContext context) { 50 + final width = MediaQuery.of(context).size.width; 51 + final columns = feedColumnCount(width); 52 + final tileWidth = (width - ((columns - 1) * _gridSpacing)) / columns; 53 + 54 + return RefreshIndicator( 55 + onRefresh: onRefresh, 56 + child: CustomScrollView( 57 + controller: scrollController, 58 + slivers: [ 59 + SliverGrid( 60 + delegate: SliverChildBuilderDelegate(gridItemBuilder, childCount: itemCount), 61 + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( 62 + crossAxisCount: columns, 63 + crossAxisSpacing: _gridSpacing, 64 + mainAxisSpacing: _gridSpacing, 65 + // Grid cards have a square media region plus fixed author/body/footer chrome. 66 + mainAxisExtent: tileWidth + _gridCardChromeHeight, 67 + ), 68 + ), 69 + if (isLoadingMore) 70 + const SliverToBoxAdapter( 71 + child: Center( 72 + child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), 73 + ), 74 + ), 75 + ], 76 + ), 77 + ); 78 + } 79 + 80 + Widget _buildLinear(BuildContext context) { 81 + return RefreshIndicator( 82 + onRefresh: onRefresh, 83 + child: ListView.builder( 84 + controller: scrollController, 85 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), 86 + itemCount: itemCount + (isLoadingMore ? 1 : 0), 87 + itemBuilder: (context, index) { 88 + if (index == itemCount) { 89 + return const Center( 90 + child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), 91 + ); 92 + } 93 + return Padding(padding: const EdgeInsets.only(bottom: 8), child: linearItemBuilder(context, index)); 94 + }, 95 + ), 96 + ); 97 + } 98 + }
+11 -3
lib/features/feed/presentation/widgets/grid_post_card.dart
··· 30 30 1, 31 31 0, 32 32 ]); 33 + const double _gridEmbedPreviewMaxHeight = 240; 33 34 34 35 /// Grid layout post card. 35 36 /// ··· 55 56 final bodyText = record?.text ?? ''; 56 57 final colorScheme = Theme.of(context).colorScheme; 57 58 58 - // Non-image embeds rendered in the content area 59 59 final contentEmbed = primaryImageUrl == null && post.embed != null 60 60 ? PostEmbedView(feedViewPost: feedViewPost, embed: post.embed!) 61 61 : null; ··· 95 95 if (bodyText.isNotEmpty) ...[ 96 96 const SizedBox(height: 8), 97 97 if (primaryImageUrl == null && contentEmbed == null) 98 - // Text-only: larger, tighter body text 99 98 FacetText( 100 99 text: bodyText, 101 100 facets: record?.facets, ··· 112 111 overflow: TextOverflow.ellipsis, 113 112 ), 114 113 ], 115 - if (contentEmbed != null) ...[const SizedBox(height: 8), contentEmbed], 114 + if (contentEmbed != null) ...[const SizedBox(height: 8), _buildEmbedPreview(contentEmbed)], 116 115 ], 117 116 ), 118 117 ), ··· 173 172 ), 174 173 ), 175 174 ], 175 + ); 176 + } 177 + 178 + Widget _buildEmbedPreview(Widget contentEmbed) { 179 + return SizedBox( 180 + height: _gridEmbedPreviewMaxHeight, 181 + child: ClipRect( 182 + child: SingleChildScrollView(physics: const NeverScrollableScrollPhysics(), child: contentEmbed), 183 + ), 176 184 ); 177 185 } 178 186
+41 -13
lib/features/feed/presentation/widgets/post_card_footer.dart
··· 62 62 Widget build(BuildContext context) { 63 63 final colorScheme = Theme.of(context).colorScheme; 64 64 final saveActiveColor = (saveType == 'cloud' || saveType == 'both') ? colorScheme.primary : Colors.amber; 65 + const horizontalPadding = 12.0; 66 + const actionSpacing = 8.0; 67 + const iconSize = 18.0; 68 + const actionPadding = 4.0; 65 69 66 70 return Container( 67 71 decoration: BoxDecoration( 68 72 border: Border(top: BorderSide(color: colorScheme.outlineVariant)), 69 73 ), 70 - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 74 + padding: const EdgeInsets.symmetric(horizontal: horizontalPadding, vertical: 8), 71 75 child: Row( 72 76 children: [ 73 77 _FooterAction( ··· 77 81 isLoading: false, 78 82 onTap: onReply, 79 83 color: colorScheme.onSurfaceVariant, 84 + iconSize: iconSize, 85 + padding: actionPadding, 80 86 ), 81 - const SizedBox(width: 16), 87 + const SizedBox(width: actionSpacing), 82 88 _FooterAction( 83 89 icon: Icons.repeat, 84 90 activeIcon: Icons.repeat, ··· 87 93 onTap: onRepost, 88 94 color: colorScheme.onSurfaceVariant, 89 95 activeColor: Colors.green, 96 + iconSize: iconSize, 97 + padding: actionPadding, 90 98 ), 91 - const SizedBox(width: 16), 99 + const SizedBox(width: actionSpacing), 92 100 _FooterAction( 93 101 icon: Icons.favorite_outline, 94 102 activeIcon: Icons.favorite, ··· 97 105 onTap: onLike, 98 106 color: colorScheme.onSurfaceVariant, 99 107 activeColor: Colors.pink, 108 + iconSize: iconSize, 109 + padding: actionPadding, 100 110 ), 101 - const SizedBox(width: 16), 111 + const SizedBox(width: actionSpacing), 102 112 _FooterAction( 103 113 icon: isSaved ? Icons.bookmark : Icons.bookmark_outline, 104 114 activeIcon: Icons.bookmark, ··· 108 118 onLongPress: onLongPressSave, 109 119 color: colorScheme.onSurfaceVariant, 110 120 activeColor: saveActiveColor, 121 + iconSize: iconSize, 122 + padding: actionPadding, 111 123 ), 112 - const Spacer(), 113 - Text( 114 - timestamp, 115 - style: Theme.of( 116 - context, 117 - ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 10, letterSpacing: 1.0), 124 + const SizedBox(width: actionSpacing), 125 + Expanded( 126 + child: Align( 127 + alignment: Alignment.centerRight, 128 + child: Text( 129 + timestamp, 130 + maxLines: 1, 131 + overflow: TextOverflow.ellipsis, 132 + softWrap: false, 133 + style: Theme.of( 134 + context, 135 + ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 10, letterSpacing: 1.0), 136 + ), 137 + ), 118 138 ), 119 139 ], 120 140 ), ··· 171 191 required this.activeIcon, 172 192 required this.isActive, 173 193 required this.isLoading, 194 + required this.iconSize, 195 + required this.padding, 174 196 this.onTap, 175 197 this.onLongPress, 176 198 this.color, ··· 181 203 final IconData activeIcon; 182 204 final bool isActive; 183 205 final bool isLoading; 206 + final double iconSize; 207 + final double padding; 184 208 final VoidCallback? onTap; 185 209 final VoidCallback? onLongPress; 186 210 final Color? color; ··· 196 220 onLongPress: onLongPress, 197 221 borderRadius: BorderRadius.zero, 198 222 child: Padding( 199 - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), 223 + padding: EdgeInsets.symmetric(horizontal: padding, vertical: padding), 200 224 child: isLoading 201 - ? SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: iconColor)) 202 - : Icon(isActive ? activeIcon : icon, size: 18, color: iconColor), 225 + ? SizedBox( 226 + width: iconSize, 227 + height: iconSize, 228 + child: CircularProgressIndicator(strokeWidth: 2, color: iconColor), 229 + ) 230 + : Icon(isActive ? activeIcon : icon, size: iconSize, color: iconColor), 203 231 ), 204 232 ); 205 233 }
+316 -136
lib/features/profile/presentation/profile_screen.dart
··· 1 + import 'dart:ui'; 2 + 1 3 import 'package:bluesky/app_bsky_actor_defs.dart'; 2 4 import 'package:flutter/material.dart'; 3 5 import 'package:flutter/services.dart'; ··· 5 7 import 'package:go_router/go_router.dart'; 6 8 import 'package:intl/intl.dart'; 7 9 import 'package:lazurite/core/router/app_shell.dart'; 10 + import 'package:lazurite/core/theme/feed_architecture.dart'; 8 11 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 12 + import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 9 13 import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 10 14 import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 11 15 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 12 16 import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; 13 17 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 14 - import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 15 18 import 'package:lazurite/features/profile/presentation/widgets/profile_action_buttons.dart'; 19 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 20 + import 'package:lazurite/features/settings/bloc/settings_state.dart'; 16 21 import 'package:share_plus/share_plus.dart'; 17 22 import 'package:url_launcher/url_launcher.dart'; 18 23 24 + const _greyscale = ColorFilter.matrix(<double>[ 25 + 0.2126, 26 + 0.7152, 27 + 0.0722, 28 + 0, 29 + 0, 30 + 0.2126, 31 + 0.7152, 32 + 0.0722, 33 + 0, 34 + 0, 35 + 0.2126, 36 + 0.7152, 37 + 0.0722, 38 + 0, 39 + 0, 40 + 0, 41 + 0, 42 + 0, 43 + 1, 44 + 0, 45 + ]); 46 + 19 47 class ProfileScreen extends StatefulWidget { 20 48 const ProfileScreen({super.key, this.actor, this.showBackButton = false}); 21 49 ··· 27 55 } 28 56 29 57 class _ProfileScreenState extends State<ProfileScreen> with SingleTickerProviderStateMixin { 30 - static const double _headerExpandedHeight = 120; 31 58 static const _tabs = [ 32 59 (label: 'Posts', filter: FeedFilter.postsNoReplies), 33 60 (label: 'Replies', filter: FeedFilter.postsAndAuthorThreads), ··· 60 87 61 88 void _loadProfileAndFeed({FeedFilter? filter}) { 62 89 final actor = _resolvedActor; 63 - if (actor == null) { 64 - return; 65 - } 66 - 90 + if (actor == null) return; 67 91 context.read<ProfileBloc>().add(ProfileLoadRequested(actor: actor)); 68 92 context.read<FeedBloc>().add(FeedLoadRequested(actor: actor, filter: filter ?? _currentFilter)); 69 93 } 70 94 71 95 String? get _resolvedActor { 72 96 final authState = context.read<AuthBloc>().state; 73 - if (!authState.isAuthenticated) { 74 - return null; 75 - } 76 - 97 + if (!authState.isAuthenticated) return null; 77 98 return widget.actor ?? authState.tokens?.did; 78 99 } 79 100 ··· 100 121 headerSliverBuilder: (context, innerBoxIsScrolled) { 101 122 return [ 102 123 SliverAppBar( 103 - expandedHeight: _headerExpandedHeight, 104 124 floating: true, 105 125 pinned: true, 106 126 snap: true, 107 - stretch: true, 108 127 title: innerBoxIsScrolled ? Text(profile?.displayName ?? profile?.handle ?? 'Profile') : null, 109 - flexibleSpace: FlexibleSpaceBar(background: _buildBanner(context, profile)), 110 128 leading: widget.showBackButton 111 129 ? IconButton( 112 130 icon: const Icon(Icons.arrow_back), ··· 117 135 IconButton(icon: const Icon(Icons.settings_outlined), onPressed: () => context.go('/settings')), 118 136 ], 119 137 ), 138 + SliverToBoxAdapter(child: _buildCoverSection(context, profile)), 120 139 SliverToBoxAdapter( 121 140 child: switch (profileState.status) { 122 141 ProfileStatus.loading => const Padding( ··· 132 151 delegate: _SliverTabBarDelegate( 133 152 TabBar( 134 153 controller: _tabController, 135 - tabs: [for (final tab in _tabs) Tab(text: tab.label)], 154 + tabs: [for (final tab in _tabs) Tab(text: tab.label.toUpperCase())], 136 155 onTap: (index) => _loadProfileAndFeed(filter: _tabs[index].filter), 156 + labelStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 2.2), 157 + unselectedLabelStyle: const TextStyle( 158 + fontSize: 11, 159 + fontWeight: FontWeight.w700, 160 + letterSpacing: 2.2, 161 + ), 162 + indicatorWeight: 2, 137 163 ), 138 164 ), 139 165 ), ··· 141 167 }, 142 168 body: TabBarView( 143 169 controller: _tabController, 144 - children: [for (var i = 0; i < _tabs.length; i++) _buildFeedList(feedState, _tabs[i].filter)], 170 + children: [ 171 + for (var i = 0; i < _tabs.length; i++) _buildFeedList(feedState, _tabs[i].filter, profile), 172 + ], 145 173 ), 146 174 ); 147 175 }, ··· 152 180 ); 153 181 } 154 182 155 - Widget? _buildComposeFab(BuildContext context) { 156 - return BlocBuilder<ProfileBloc, ProfileState>( 157 - builder: (context, state) { 158 - final profile = state.profile; 159 - if (profile == null) { 160 - return const SizedBox.shrink(); 161 - } 183 + Widget _buildCoverSection(BuildContext context, ProfileViewDetailed? profile) { 184 + final width = MediaQuery.of(context).size.width; 185 + final coverHeight = width >= 600 ? 256.0 : 192.0; 186 + final avatarSize = width >= 600 ? 128.0 : 96.0; 187 + final colorScheme = Theme.of(context).colorScheme; 162 188 163 - final currentUserDid = context.read<AuthBloc>().state.tokens?.did; 164 - final isOwnProfile = profile.did == currentUserDid; 165 - final initialText = isOwnProfile ? null : '@${profile.handle} '; 189 + Widget coverContent; 190 + if (profile?.banner != null) { 191 + coverContent = ColorFiltered( 192 + colorFilter: _greyscale, 193 + child: Image.network( 194 + profile!.banner!, 195 + fit: BoxFit.cover, 196 + width: double.infinity, 197 + height: coverHeight, 198 + errorBuilder: (_, _, _) => 199 + ColoredBox(color: colorScheme.surfaceContainerHigh, child: const SizedBox.expand()), 200 + ), 201 + ); 202 + } else { 203 + coverContent = ColoredBox(color: colorScheme.surfaceContainerHigh, child: const SizedBox.expand()); 204 + } 166 205 167 - return FloatingActionButton( 168 - onPressed: () => context.push('/compose', extra: ComposeRouteArgs(initialText: initialText)), 169 - child: const Icon(Icons.add), 170 - ); 171 - }, 206 + return SizedBox( 207 + height: coverHeight + avatarSize / 2, 208 + child: Stack( 209 + clipBehavior: Clip.none, 210 + children: [ 211 + Positioned( 212 + top: 0, 213 + left: 0, 214 + right: 0, 215 + height: coverHeight, 216 + child: Container( 217 + decoration: BoxDecoration( 218 + border: Border(bottom: BorderSide(color: colorScheme.outlineVariant)), 219 + ), 220 + child: Opacity(opacity: 0.5, child: coverContent), 221 + ), 222 + ), 223 + Positioned( 224 + top: coverHeight - avatarSize / 2, 225 + left: 16, 226 + child: _buildSquareAvatar(context, profile, avatarSize), 227 + ), 228 + ], 229 + ), 172 230 ); 173 231 } 174 232 175 - Widget _buildBanner(BuildContext context, ProfileViewDetailed? profile) { 176 - final fallback = DecoratedBox( 233 + Widget _buildSquareAvatar(BuildContext context, ProfileViewDetailed? profile, double size) { 234 + final colorScheme = Theme.of(context).colorScheme; 235 + final avatarUrl = profile?.avatar; 236 + 237 + return Container( 238 + key: const ValueKey('profile_square_avatar'), 239 + width: size, 240 + height: size, 177 241 decoration: BoxDecoration( 178 - gradient: LinearGradient( 179 - colors: [ 180 - Theme.of(context).colorScheme.surfaceContainerHighest, 181 - Theme.of(context).colorScheme.surfaceContainer, 182 - ], 183 - begin: Alignment.topLeft, 184 - end: Alignment.bottomRight, 242 + color: colorScheme.surfaceContainerHighest, 243 + border: Border.all(color: colorScheme.surfaceContainerLowest, width: 4), 244 + ), 245 + child: avatarUrl != null 246 + ? Image.network( 247 + avatarUrl, 248 + fit: BoxFit.cover, 249 + errorBuilder: (_, _, _) => _buildAvatarInitials(context, profile), 250 + ) 251 + : _buildAvatarInitials(context, profile), 252 + ); 253 + } 254 + 255 + Widget _buildAvatarInitials(BuildContext context, ProfileViewDetailed? profile) { 256 + return ColoredBox( 257 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 258 + child: Center( 259 + child: Text( 260 + _initials(profile?.displayName ?? profile?.handle ?? '?'), 261 + style: Theme.of(context).textTheme.headlineSmall, 185 262 ), 186 263 ), 187 264 ); 188 - 189 - if (profile?.banner == null) { 190 - return fallback; 191 - } 192 - 193 - return Image.network(profile!.banner!, fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback); 194 265 } 195 266 196 267 Widget _buildProfileError(BuildContext context, String? errorMessage) { ··· 210 281 } 211 282 212 283 Widget _buildProfileSummary(BuildContext context, ProfileViewDetailed? profile, bool isOwnProfile) { 213 - if (profile == null) { 214 - return const SizedBox.shrink(); 215 - } 284 + if (profile == null) return const SizedBox.shrink(); 285 + 286 + final colorScheme = Theme.of(context).colorScheme; 287 + final textTheme = Theme.of(context).textTheme; 216 288 217 289 final metaChildren = <Widget>[ 218 290 if (profile.pronouns?.isNotEmpty ?? false) ··· 228 300 ]; 229 301 230 302 return Padding( 231 - padding: const EdgeInsets.fromLTRB(16, 16, 16, 20), 303 + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), 232 304 child: Column( 233 305 crossAxisAlignment: CrossAxisAlignment.start, 234 306 children: [ 235 - _buildAvatar(profile), 236 - const SizedBox(height: 16), 237 307 Text( 238 - profile.displayName ?? profile.handle, 239 - style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700), 308 + (profile.displayName ?? profile.handle).toUpperCase(), 309 + style: textTheme.headlineLarge?.copyWith(fontWeight: FontWeight.w600, letterSpacing: -0.5), 240 310 ), 241 311 const SizedBox(height: 4), 242 - Text( 243 - '@${profile.handle}', 244 - style: Theme.of( 245 - context, 246 - ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 247 - ), 312 + 313 + Text('@${profile.handle}', style: textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)), 248 314 if (profile.description?.isNotEmpty ?? false) ...[ 249 315 const SizedBox(height: 12), 250 - Text(profile.description!, style: Theme.of(context).textTheme.bodyLarge), 316 + 317 + ConstrainedBox( 318 + constraints: const BoxConstraints(maxWidth: 500), 319 + child: Text(profile.description!, style: textTheme.bodyMedium), 320 + ), 251 321 ], 252 322 if (metaChildren.isNotEmpty) ...[ 253 323 const SizedBox(height: 16), 254 324 Wrap(spacing: 8, runSpacing: 8, children: metaChildren), 255 325 ], 256 326 const SizedBox(height: 16), 257 - Wrap( 258 - spacing: 16, 259 - runSpacing: 8, 260 - children: [ 261 - _buildStat(context, profile.followsCount ?? 0, 'Following'), 262 - _buildStat(context, profile.followersCount ?? 0, 'Followers'), 263 - _buildStat(context, profile.postsCount ?? 0, 'Posts'), 264 - ], 327 + Container( 328 + key: const ValueKey('profile_stats_row'), 329 + decoration: BoxDecoration( 330 + border: Border.symmetric(horizontal: BorderSide(color: colorScheme.outlineVariant)), 331 + ), 332 + padding: const EdgeInsets.symmetric(vertical: 12), 333 + child: Row( 334 + children: [ 335 + _buildStat(context, profile.followsCount ?? 0, 'Following'), 336 + const SizedBox(width: 24), 337 + _buildStat(context, profile.followersCount ?? 0, 'Followers'), 338 + const SizedBox(width: 24), 339 + _buildStat(context, profile.postsCount ?? 0, 'Posts'), 340 + ], 341 + ), 265 342 ), 266 - if (isOwnProfile) ...[ 267 - const SizedBox(height: 16), 343 + const SizedBox(height: 16), 344 + if (isOwnProfile) 268 345 OutlinedButton.icon( 269 346 onPressed: () => context.push('/saved'), 270 347 icon: const Icon(Icons.bookmark_outline), 271 348 label: const Text('Saved Posts'), 272 349 ), 273 - ], 274 - if (!isOwnProfile) ...[const SizedBox(height: 16), _buildProfileActions(context, profile)], 350 + if (!isOwnProfile) _buildProfileActions(context, profile), 351 + const SizedBox(height: 16), 275 352 ], 276 353 ), 277 354 ); 278 355 } 279 356 280 - Widget _buildAvatar(ProfileViewDetailed profile) { 281 - final avatarUrl = profile.avatar; 282 - 283 - return Container( 284 - width: 96, 285 - height: 96, 286 - decoration: BoxDecoration( 287 - shape: BoxShape.circle, 288 - border: Border.all(color: Theme.of(context).scaffoldBackgroundColor, width: 4), 289 - ), 290 - child: CircleAvatar( 291 - radius: 44, 292 - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, 293 - backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, 294 - child: avatarUrl == null 295 - ? Text(_initials(profile.displayName ?? profile.handle), style: Theme.of(context).textTheme.headlineSmall) 296 - : null, 297 - ), 298 - ); 299 - } 300 - 301 357 Widget _buildMetaChip(BuildContext context, IconData icon, String label, {VoidCallback? onTap}) { 302 358 final chip = Container( 303 359 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), ··· 322 378 ), 323 379 ); 324 380 325 - if (onTap == null) { 326 - return chip; 327 - } 328 - 381 + if (onTap == null) return chip; 329 382 return InkWell(onTap: onTap, borderRadius: BorderRadius.circular(999), child: chip); 330 383 } 331 384 332 385 Widget _buildStat(BuildContext context, int count, String label) { 333 - return RichText( 334 - text: TextSpan( 335 - style: Theme.of(context).textTheme.bodyMedium, 336 - children: [ 337 - TextSpan( 338 - text: _formatCount(count), 339 - style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), 340 - ), 341 - TextSpan( 342 - text: ' $label', 343 - style: Theme.of( 344 - context, 345 - ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 346 - ), 347 - ], 348 - ), 386 + final colorScheme = Theme.of(context).colorScheme; 387 + return Column( 388 + crossAxisAlignment: CrossAxisAlignment.start, 389 + children: [ 390 + Text( 391 + _formatCount(count), 392 + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), 393 + ), 394 + Text( 395 + label.toUpperCase(), 396 + style: TextStyle(fontSize: 11, letterSpacing: 1.1, color: colorScheme.onSurfaceVariant), 397 + ), 398 + ], 349 399 ); 350 400 } 351 401 ··· 425 475 ); 426 476 } 427 477 428 - Widget _buildFeedList(FeedState feedState, FeedFilter tabFilter) { 478 + Widget? _buildComposeFab(BuildContext context) { 479 + return BlocBuilder<ProfileBloc, ProfileState>( 480 + builder: (context, state) { 481 + final profile = state.profile; 482 + if (profile == null) return const SizedBox.shrink(); 483 + 484 + final currentUserDid = context.read<AuthBloc>().state.tokens?.did; 485 + final isOwnProfile = profile.did == currentUserDid; 486 + final initialText = isOwnProfile ? null : '@${profile.handle} '; 487 + 488 + return FloatingActionButton( 489 + onPressed: () => context.push('/compose', extra: ComposeRouteArgs(initialText: initialText)), 490 + child: const Icon(Icons.add), 491 + ); 492 + }, 493 + ); 494 + } 495 + 496 + Widget _buildFeedList(FeedState feedState, FeedFilter tabFilter, ProfileViewDetailed? profile) { 429 497 if (feedState.isLoading && feedState.filter == tabFilter) { 430 498 return const Center(child: CircularProgressIndicator()); 431 499 } ··· 441 509 if (!feedState.hasPosts) { 442 510 return Center(child: Text(_emptyLabel(tabFilter))); 443 511 } 512 + 513 + return BlocBuilder<SettingsCubit, SettingsState>( 514 + buildWhen: (prev, curr) => prev.feedArchitecture != curr.feedArchitecture, 515 + builder: (context, settingsState) { 516 + if (settingsState.feedArchitecture == FeedArchitecture.grid) { 517 + return _buildGridFeed(context, feedState, profile); 518 + } 519 + return _buildLinearFeed(context, feedState); 520 + }, 521 + ); 522 + } 523 + 524 + Widget _buildGridFeed(BuildContext context, FeedState feedState, ProfileViewDetailed? profile) { 525 + final accountDid = _resolvedActor ?? ''; 526 + final infoCardCount = profile == null ? 0 : 1; 444 527 445 528 return RefreshIndicator( 446 529 onRefresh: _refresh, ··· 451 534 !feedState.isLoadingMore) { 452 535 context.read<FeedBloc>().add(const FeedLoadMoreRequested()); 453 536 } 537 + return false; 538 + }, 539 + child: ListView.builder( 540 + key: const ValueKey('profile_grid_feed'), 541 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 542 + itemCount: infoCardCount + feedState.posts.length + (feedState.isLoadingMore ? 1 : 0), 543 + itemBuilder: (context, index) { 544 + if (profile != null && index == 0) { 545 + return Padding( 546 + padding: const EdgeInsets.only(bottom: 16), 547 + child: Center( 548 + child: ConstrainedBox( 549 + constraints: const BoxConstraints(maxWidth: 720), 550 + child: _ProfileInfoCard(profile: profile), 551 + ), 552 + ), 553 + ); 554 + } 454 555 556 + final postIndex = index - infoCardCount; 557 + 558 + if (postIndex >= feedState.posts.length) { 559 + return const Padding( 560 + padding: EdgeInsets.all(16), 561 + child: Center(child: CircularProgressIndicator()), 562 + ); 563 + } 564 + 565 + return Padding( 566 + padding: EdgeInsets.only(bottom: postIndex == feedState.posts.length - 1 ? 0 : 16), 567 + child: Center( 568 + child: ConstrainedBox( 569 + key: ValueKey('profile_large_card_$postIndex'), 570 + constraints: const BoxConstraints(maxWidth: 720), 571 + child: PostCardWithActions( 572 + feedViewPost: feedState.posts[postIndex], 573 + accountDid: accountDid, 574 + variant: PostCardVariant.grid, 575 + ), 576 + ), 577 + ), 578 + ); 579 + }, 580 + ), 581 + ), 582 + ); 583 + } 584 + 585 + Widget _buildLinearFeed(BuildContext context, FeedState feedState) { 586 + return RefreshIndicator( 587 + onRefresh: _refresh, 588 + child: NotificationListener<ScrollNotification>( 589 + onNotification: (notification) { 590 + if (notification.metrics.pixels > notification.metrics.maxScrollExtent - 300 && 591 + feedState.hasMore && 592 + !feedState.isLoadingMore) { 593 + context.read<FeedBloc>().add(const FeedLoadMoreRequested()); 594 + } 455 595 return false; 456 596 }, 457 597 child: ListView.builder( ··· 464 604 child: Center(child: CircularProgressIndicator()), 465 605 ); 466 606 } 467 - 468 607 return PostCardWithActions(feedViewPost: feedState.posts[index], accountDid: _resolvedActor ?? ''); 469 608 }, 470 609 ), ··· 484 623 } 485 624 486 625 String _formatCount(int count) { 487 - if (count >= 1000000) { 488 - return '${(count / 1000000).toStringAsFixed(1)}M'; 489 - } 490 - 491 - if (count >= 1000) { 492 - return '${(count / 1000).toStringAsFixed(1)}K'; 493 - } 494 - 626 + if (count >= 1000000) return '${(count / 1000000).toStringAsFixed(1)}M'; 627 + if (count >= 1000) return '${(count / 1000).toStringAsFixed(1)}K'; 495 628 return '$count'; 496 629 } 497 630 498 631 String _initials(String value) { 499 632 final parts = value.trim().split(RegExp(r'\s+')); 500 - if (parts.isEmpty || parts.first.isEmpty) { 501 - return '?'; 502 - } 503 - 504 - if (parts.length == 1) { 505 - return parts.first.substring(0, 1).toUpperCase(); 506 - } 507 - 633 + if (parts.isEmpty || parts.first.isEmpty) return '?'; 634 + if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase(); 508 635 return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 509 636 } 510 637 511 638 Future<void> _launchWebsite(String website) async { 512 639 final uri = Uri.tryParse(website.startsWith('http') ? website : 'https://$website'); 513 - if (uri == null) { 514 - return; 515 - } 640 + if (uri == null) return; 641 + await launchUrl(uri, mode: LaunchMode.externalApplication); 642 + } 643 + } 644 + 645 + class _ProfileInfoCard extends StatelessWidget { 646 + const _ProfileInfoCard({required this.profile}); 647 + 648 + final ProfileViewDetailed profile; 516 649 517 - await launchUrl(uri, mode: LaunchMode.externalApplication); 650 + @override 651 + Widget build(BuildContext context) { 652 + final colorScheme = Theme.of(context).colorScheme; 653 + final textTheme = Theme.of(context).textTheme; 654 + 655 + return Container( 656 + key: const ValueKey('profile_info_card'), 657 + color: colorScheme.surfaceContainerHigh, 658 + padding: const EdgeInsets.all(16), 659 + child: Column( 660 + crossAxisAlignment: CrossAxisAlignment.start, 661 + children: [ 662 + Text( 663 + _formatCount(profile.postsCount ?? 0), 664 + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), 665 + ), 666 + Text('POSTS', style: TextStyle(fontSize: 11, letterSpacing: 1.1, color: colorScheme.onSurfaceVariant)), 667 + const SizedBox(height: 12), 668 + Text( 669 + _formatCount(profile.followersCount ?? 0), 670 + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), 671 + ), 672 + Text('FOLLOWERS', style: TextStyle(fontSize: 11, letterSpacing: 1.1, color: colorScheme.onSurfaceVariant)), 673 + if (profile.description?.isNotEmpty ?? false) ...[ 674 + const SizedBox(height: 12), 675 + Text( 676 + profile.description!, 677 + style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 678 + maxLines: 3, 679 + overflow: TextOverflow.ellipsis, 680 + ), 681 + ], 682 + ], 683 + ), 684 + ); 685 + } 686 + 687 + String _formatCount(int count) { 688 + if (count >= 1000000) return '${(count / 1000000).toStringAsFixed(1)}M'; 689 + if (count >= 1000) return '${(count / 1000).toStringAsFixed(1)}K'; 690 + return '$count'; 518 691 } 519 692 } 520 693 694 + /// Sticky tab bar delegate with backdrop blur background and uppercase styled labels. 521 695 class _SliverTabBarDelegate extends SliverPersistentHeaderDelegate { 522 696 _SliverTabBarDelegate(this.tabBar); 523 697 ··· 531 705 532 706 @override 533 707 Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { 534 - return ColoredBox(color: Theme.of(context).scaffoldBackgroundColor, child: tabBar); 708 + final colorScheme = Theme.of(context).colorScheme; 709 + return ClipRect( 710 + child: BackdropFilter( 711 + filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), 712 + child: ColoredBox(color: colorScheme.surface.withValues(alpha: 0.85), child: tabBar), 713 + ), 714 + ); 535 715 } 536 716 537 717 @override
+16 -24
test/core/router/app_router_test.dart
··· 121 121 await authController.close(); 122 122 }); 123 123 124 - Widget buildSubject() { 125 - return MultiBlocProvider( 126 - providers: [ 127 - BlocProvider<AuthBloc>.value(value: authBloc), 128 - BlocProvider<FeedPreferencesCubit>.value(value: feedPreferencesCubit), 129 - BlocProvider<ProfileBloc>.value(value: profileBloc), 130 - BlocProvider<FeedBloc>.value(value: feedBloc), 131 - BlocProvider<SettingsCubit>.value(value: settingsCubit), 132 - BlocProvider<UnreadCountCubit>.value(value: unreadCountCubit), 133 - ], 134 - child: RepositoryProvider<NotificationRepository>( 135 - create: (_) => notificationRepository, 136 - child: MaterialApp.router(routerConfig: AppRouter(authBloc: authBloc).router), 137 - ), 138 - ); 139 - } 124 + Widget buildSubject() => MultiBlocProvider( 125 + providers: [ 126 + BlocProvider<AuthBloc>.value(value: authBloc), 127 + BlocProvider<FeedPreferencesCubit>.value(value: feedPreferencesCubit), 128 + BlocProvider<ProfileBloc>.value(value: profileBloc), 129 + BlocProvider<FeedBloc>.value(value: feedBloc), 130 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 131 + BlocProvider<UnreadCountCubit>.value(value: unreadCountCubit), 132 + ], 133 + child: RepositoryProvider<NotificationRepository>( 134 + create: (_) => notificationRepository, 135 + child: MaterialApp.router(routerConfig: AppRouter(authBloc: authBloc).router), 136 + ), 137 + ); 140 138 141 139 testWidgets('opens the side menu and switches authenticated branches', (tester) async { 142 140 await tester.binding.setSurfaceSize(const Size(430, 932)); ··· 159 157 await tester.tap(find.text('Profile').last); 160 158 await tester.pumpAndSettle(); 161 159 162 - expect(find.text('River Tam'), findsOneWidget); 160 + expect(find.text('RIVER TAM'), findsOneWidget); 163 161 164 162 await tester.tap(find.byTooltip('Open menu')); 165 163 await tester.pumpAndSettle(); ··· 180 178 final navBar = tester.widget<NavigationBar>(find.byType(NavigationBar)); 181 179 expect(navBar.destinations.length, 4); 182 180 183 - // Labels appear in the nav bar (HOME also appears in the AppBar section label) 184 181 final destinations = navBar.destinations.cast<NavigationDestination>(); 185 182 expect(destinations.map((d) => d.label), containsAll(['HOME', 'SEARCH', 'ALERTS', 'PROFILE'])); 186 - 187 - // Messages and Settings are no longer in the bottom nav 188 183 expect(destinations.any((d) => d.label == 'MESSAGES'), isFalse); 189 184 expect(destinations.any((d) => d.label == 'SETTINGS'), isFalse); 190 185 }); ··· 221 216 await tester.pumpWidget(buildSubject()); 222 217 await tester.pumpAndSettle(); 223 218 224 - // Start on home branch (index 0) 225 219 final navBar = tester.widget<NavigationBar>(find.byType(NavigationBar)); 226 220 expect(navBar.selectedIndex, 0); 227 221 228 - // Tap PROFILE tab (index 3) 229 222 await tester.tap(find.text('PROFILE')); 230 223 await tester.pumpAndSettle(); 231 224 232 225 final navBarAfter = tester.widget<NavigationBar>(find.byType(NavigationBar)); 233 226 expect(navBarAfter.selectedIndex, 3); 234 - expect(find.text('River Tam'), findsOneWidget); 227 + expect(find.text('RIVER TAM'), findsOneWidget); 235 228 }); 236 229 237 230 testWidgets('LazuriteAppBar shows section label and hamburger on home screen', (tester) async { ··· 241 234 await tester.pumpWidget(buildSubject()); 242 235 await tester.pumpAndSettle(); 243 236 244 - // Home screen uses LazuriteAppBar with sectionLabel 'Home' — 'HOME' appears at least once 245 237 expect(find.text('HOME'), findsAtLeastNWidgets(1)); 246 238 expect(find.byTooltip('Open menu'), findsOneWidget); 247 239 });
-6
test/core/widgets/lazurite_app_bar_test.dart
··· 42 42 testWidgets('renders section label in uppercase', (tester) async { 43 43 await tester.pumpWidget(buildSubject(sectionLabel: 'Home')); 44 44 await tester.pumpAndSettle(); 45 - 46 45 expect(find.text('HOME'), findsOneWidget); 47 46 }); 48 47 49 48 testWidgets('renders hamburger menu button', (tester) async { 50 49 await tester.pumpWidget(buildSubject(sectionLabel: 'Search')); 51 50 await tester.pumpAndSettle(); 52 - 53 51 expect(find.byType(AppShellMenuButton), findsOneWidget); 54 52 }); 55 53 56 54 testWidgets('renders user initials in avatar from displayName', (tester) async { 57 55 await tester.pumpWidget(buildSubject(sectionLabel: 'Home')); 58 56 await tester.pumpAndSettle(); 59 - 60 - // 'River Tam' → initials 'RT' 61 57 expect(find.text('RT'), findsOneWidget); 62 58 }); 63 59 ··· 75 71 76 72 await tester.pumpWidget(buildSubject(sectionLabel: 'Home')); 77 73 await tester.pumpAndSettle(); 78 - 79 - // handle 'alice.bsky.social' → first part 'alice.bsky.social' → 'A' 80 74 expect(find.text('A'), findsOneWidget); 81 75 }); 82 76
+21
test/features/feed/presentation/grid_post_card_test.dart
··· 1 1 import 'package:atproto_core/atproto_core.dart'; 2 2 import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_embed_external.dart'; 3 4 import 'package:bluesky/app_bsky_embed_images.dart'; 4 5 import 'package:bluesky/app_bsky_feed_defs.dart'; 5 6 import 'package:bluesky/app_bsky_feed_post.dart'; ··· 106 107 107 108 expect(find.byType(AspectRatio), findsOneWidget); 108 109 expect(find.byType(ColorFiltered), findsOneWidget); 110 + }); 111 + 112 + testWidgets('caps non-image embed previews inside grid cards', (tester) async { 113 + final post = _makePost( 114 + text: 'Read this', 115 + embed: const UPostViewEmbed.embedExternalView( 116 + data: EmbedExternalView( 117 + external: EmbedExternalViewExternal( 118 + uri: 'https://example.com/article', 119 + title: 'Example Article', 120 + description: 'A useful external card', 121 + ), 122 + ), 123 + ), 124 + ); 125 + 126 + await tester.pumpWidget(_buildSubject(post)); 127 + 128 + expect(find.text('Example Article'), findsOneWidget); 129 + expect(find.byWidgetPredicate((widget) => widget is SizedBox && widget.height == 240), findsOneWidget); 109 130 }); 110 131 111 132 testWidgets('uses square container for avatar — no CircleAvatar', (tester) async {
+252
test/features/feed/presentation/home_feed_screen_test.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:bloc_test/bloc_test.dart'; 4 + import 'package:flutter/material.dart'; 5 + import 'package:flutter_bloc/flutter_bloc.dart'; 6 + import 'package:flutter_test/flutter_test.dart'; 7 + import 'package:lazurite/core/theme/app_theme.dart'; 8 + import 'package:lazurite/core/theme/feed_architecture.dart'; 9 + import 'package:lazurite/core/theme/ui_density.dart'; 10 + import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; 11 + import 'package:lazurite/features/feed/presentation/widgets/feed_layout_view.dart'; 12 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 13 + import 'package:lazurite/features/settings/bloc/settings_state.dart'; 14 + import 'package:mocktail/mocktail.dart'; 15 + 16 + class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 17 + 18 + SettingsState _settingsState(FeedArchitecture architecture) => SettingsState( 19 + themePalette: AppThemePalette.oxocarbon, 20 + themeVariant: AppThemeVariant.dark, 21 + useSystemTheme: false, 22 + uiDensity: UiDensity.standard, 23 + feedArchitecture: architecture, 24 + ); 25 + 26 + Widget _buildSubject({required FeedArchitecture architecture, double screenWidth = 400, int itemCount = 3}) { 27 + final cubit = MockSettingsCubit(); 28 + when(() => cubit.state).thenReturn(_settingsState(architecture)); 29 + 30 + return MediaQuery( 31 + data: MediaQueryData(size: Size(screenWidth, 800)), 32 + child: MaterialApp( 33 + home: Scaffold( 34 + body: BlocProvider<SettingsCubit>.value( 35 + value: cubit, 36 + child: FeedLayoutView( 37 + itemCount: itemCount, 38 + scrollController: ScrollController(), 39 + isLoadingMore: false, 40 + onRefresh: () async {}, 41 + gridItemBuilder: (_, i) => SizedBox(key: ValueKey('grid-$i'), child: Text('grid $i')), 42 + linearItemBuilder: (_, i) => SizedBox(key: ValueKey('linear-$i'), child: Text('linear $i')), 43 + ), 44 + ), 45 + ), 46 + ), 47 + ); 48 + } 49 + 50 + void main() { 51 + group('feedColumnCount', () { 52 + test('returns 1 column for width < 600', () { 53 + expect(feedColumnCount(599), 1); 54 + expect(feedColumnCount(400), 1); 55 + expect(feedColumnCount(0), 1); 56 + }); 57 + 58 + test('returns 2 columns for width 600–839', () { 59 + expect(feedColumnCount(600), 2); 60 + expect(feedColumnCount(720), 2); 61 + expect(feedColumnCount(839), 2); 62 + }); 63 + 64 + test('returns 3 columns for width 840–1199', () { 65 + expect(feedColumnCount(840), 3); 66 + expect(feedColumnCount(1000), 3); 67 + expect(feedColumnCount(1199), 3); 68 + }); 69 + 70 + test('returns 4 columns for width >= 1200', () { 71 + expect(feedColumnCount(1200), 4); 72 + expect(feedColumnCount(1600), 4); 73 + }); 74 + }); 75 + 76 + group('FeedLayoutView — grid architecture', () { 77 + testWidgets('shows SliverGrid when architecture is grid', (tester) async { 78 + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid)); 79 + expect(find.byType(SliverGrid), findsOneWidget); 80 + expect(find.byType(CustomScrollView), findsOneWidget); 81 + }); 82 + 83 + testWidgets('uses gridItemBuilder in grid mode', (tester) async { 84 + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid)); 85 + expect(find.text('grid 0'), findsOneWidget); 86 + expect(find.text('linear 0'), findsNothing); 87 + }); 88 + 89 + testWidgets('uses 1 column at width < 600', (tester) async { 90 + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 400)); 91 + 92 + final grid = tester.widget<SliverGrid>(find.byType(SliverGrid)); 93 + final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; 94 + expect(delegate.crossAxisCount, 1); 95 + }); 96 + 97 + testWidgets('uses 2 columns at width 600–839', (tester) async { 98 + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 720)); 99 + 100 + final grid = tester.widget<SliverGrid>(find.byType(SliverGrid)); 101 + final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; 102 + expect(delegate.crossAxisCount, 2); 103 + }); 104 + 105 + testWidgets('uses 3 columns at width 840–1199', (tester) async { 106 + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 1000)); 107 + 108 + final grid = tester.widget<SliverGrid>(find.byType(SliverGrid)); 109 + final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; 110 + expect(delegate.crossAxisCount, 3); 111 + }); 112 + 113 + testWidgets('uses 4 columns at width >= 1200', (tester) async { 114 + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: 1400)); 115 + 116 + final grid = tester.widget<SliverGrid>(find.byType(SliverGrid)); 117 + final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; 118 + expect(delegate.crossAxisCount, 4); 119 + }); 120 + 121 + testWidgets('allocates extra height beyond the square media region', (tester) async { 122 + const screenWidth = 400.0; 123 + 124 + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.grid, screenWidth: screenWidth)); 125 + 126 + final grid = tester.widget<SliverGrid>(find.byType(SliverGrid)); 127 + final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; 128 + final tileWidth = screenWidth; 129 + 130 + expect(delegate.mainAxisExtent, isNotNull); 131 + expect(delegate.mainAxisExtent!, greaterThan(tileWidth + 100)); 132 + }); 133 + }); 134 + 135 + group('FeedLayoutView — linear architecture', () { 136 + testWidgets('shows ListView when architecture is linear', (tester) async { 137 + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.linear)); 138 + 139 + expect(find.byType(ListView), findsOneWidget); 140 + expect(find.byType(SliverGrid), findsNothing); 141 + }); 142 + 143 + testWidgets('uses linearItemBuilder in linear mode', (tester) async { 144 + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.linear)); 145 + 146 + expect(find.text('linear 0'), findsOneWidget); 147 + expect(find.text('linear 1'), findsOneWidget); 148 + expect(find.text('linear 2'), findsOneWidget); 149 + }); 150 + }); 151 + 152 + group('FeedLayoutView — architecture switching', () { 153 + testWidgets('switches from grid to linear without re-fetch', (tester) async { 154 + final cubit = MockSettingsCubit(); 155 + final streamController = StreamController<SettingsState>.broadcast(); 156 + 157 + when(() => cubit.state).thenReturn(_settingsState(FeedArchitecture.grid)); 158 + when(() => cubit.stream).thenAnswer((_) => streamController.stream); 159 + 160 + var buildCount = 0; 161 + 162 + await tester.pumpWidget( 163 + MediaQuery( 164 + data: const MediaQueryData(size: Size(400, 800)), 165 + child: MaterialApp( 166 + home: Scaffold( 167 + body: BlocProvider<SettingsCubit>.value( 168 + value: cubit, 169 + child: FeedLayoutView( 170 + itemCount: 1, 171 + scrollController: ScrollController(), 172 + isLoadingMore: false, 173 + onRefresh: () async => buildCount++, 174 + gridItemBuilder: (_, i) => const Text('grid'), 175 + linearItemBuilder: (_, i) => const Text('linear'), 176 + ), 177 + ), 178 + ), 179 + ), 180 + ), 181 + ); 182 + 183 + expect(find.byType(SliverGrid), findsOneWidget); 184 + 185 + when(() => cubit.state).thenReturn(_settingsState(FeedArchitecture.linear)); 186 + streamController.add(_settingsState(FeedArchitecture.linear)); 187 + await tester.pump(); 188 + 189 + expect(find.byType(SliverGrid), findsNothing); 190 + expect(find.byType(ListView), findsOneWidget); 191 + expect(buildCount, 0); 192 + 193 + await streamController.close(); 194 + }); 195 + 196 + testWidgets('loading indicator appears when isLoadingMore is true in grid mode', (tester) async { 197 + final cubit = MockSettingsCubit(); 198 + when(() => cubit.state).thenReturn(_settingsState(FeedArchitecture.grid)); 199 + 200 + await tester.pumpWidget( 201 + MediaQuery( 202 + data: const MediaQueryData(size: Size(400, 800)), 203 + child: MaterialApp( 204 + home: Scaffold( 205 + body: BlocProvider<SettingsCubit>.value( 206 + value: cubit, 207 + child: FeedLayoutView( 208 + itemCount: 0, 209 + scrollController: ScrollController(), 210 + isLoadingMore: true, 211 + onRefresh: () async {}, 212 + gridItemBuilder: (_, i) => const Text('item'), 213 + linearItemBuilder: (_, i) => const Text('item'), 214 + ), 215 + ), 216 + ), 217 + ), 218 + ), 219 + ); 220 + 221 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 222 + }); 223 + 224 + testWidgets('loading indicator appears when isLoadingMore is true in linear mode', (tester) async { 225 + final cubit = MockSettingsCubit(); 226 + when(() => cubit.state).thenReturn(_settingsState(FeedArchitecture.linear)); 227 + 228 + await tester.pumpWidget( 229 + MediaQuery( 230 + data: const MediaQueryData(size: Size(400, 800)), 231 + child: MaterialApp( 232 + home: Scaffold( 233 + body: BlocProvider<SettingsCubit>.value( 234 + value: cubit, 235 + child: FeedLayoutView( 236 + itemCount: 1, 237 + scrollController: ScrollController(), 238 + isLoadingMore: true, 239 + onRefresh: () async {}, 240 + gridItemBuilder: (_, i) => const Text('item'), 241 + linearItemBuilder: (_, i) => const Text('item'), 242 + ), 243 + ), 244 + ), 245 + ), 246 + ), 247 + ); 248 + 249 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 250 + }); 251 + }); 252 + }
+250 -6
test/features/profile/presentation/profile_screen_test.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:atproto_core/atproto_core.dart'; 1 4 import 'package:bloc_test/bloc_test.dart'; 2 5 import 'package:bluesky/app_bsky_actor_defs.dart'; 6 + import 'package:bluesky/app_bsky_feed_defs.dart'; 7 + import 'package:bluesky/app_bsky_feed_post.dart'; 3 8 import 'package:flutter/material.dart'; 4 9 import 'package:flutter_bloc/flutter_bloc.dart'; 5 10 import 'package:flutter_test/flutter_test.dart'; 6 11 import 'package:go_router/go_router.dart'; 12 + import 'package:lazurite/core/theme/app_theme.dart'; 13 + import 'package:lazurite/core/theme/feed_architecture.dart'; 14 + import 'package:lazurite/core/theme/ui_density.dart'; 7 15 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 8 - import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 9 16 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 17 + import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 10 18 import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 19 + import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 20 + import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 21 + import 'package:lazurite/features/feed/data/post_action_repository.dart'; 11 22 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 12 23 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 13 24 import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 25 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 26 + import 'package:lazurite/features/settings/bloc/settings_state.dart'; 14 27 import 'package:mocktail/mocktail.dart'; 15 28 16 29 class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} ··· 21 34 22 35 class MockProfileActionRepository extends Mock implements ProfileActionRepository {} 23 36 37 + class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 38 + 39 + class MockPostActionRepository extends Mock implements PostActionRepository {} 40 + 41 + class MockSavedPostsCubit extends MockCubit<SavedPostsState> implements SavedPostsCubit {} 42 + 43 + class MockPostActionCache extends Mock implements PostActionCache {} 44 + 24 45 void main() { 25 46 late MockAuthBloc authBloc; 26 47 late MockProfileBloc profileBloc; 27 48 late MockFeedBloc feedBloc; 49 + late MockSettingsCubit settingsCubit; 28 50 29 51 const tokens = AuthTokens( 30 52 accessToken: 'access', ··· 46 68 createdAt: DateTime.utc(2024, 3, 1), 47 69 ); 48 70 71 + SettingsState defaultSettingsState() => const SettingsState( 72 + themePalette: AppThemePalette.oxocarbon, 73 + themeVariant: AppThemeVariant.dark, 74 + useSystemTheme: false, 75 + uiDensity: UiDensity.standard, 76 + feedArchitecture: FeedArchitecture.grid, 77 + ); 78 + 79 + SettingsState settingsStateWith(FeedArchitecture architecture) => SettingsState( 80 + themePalette: AppThemePalette.oxocarbon, 81 + themeVariant: AppThemeVariant.dark, 82 + useSystemTheme: false, 83 + uiDensity: UiDensity.standard, 84 + feedArchitecture: architecture, 85 + ); 86 + 49 87 setUp(() { 50 88 authBloc = MockAuthBloc(); 51 89 profileBloc = MockProfileBloc(); 52 90 feedBloc = MockFeedBloc(); 91 + settingsCubit = MockSettingsCubit(); 53 92 54 93 when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); 55 94 when(() => profileBloc.state).thenReturn(ProfileState.loaded(profile: profile)); 56 95 when(() => feedBloc.state).thenReturn( 57 96 const FeedState.loaded(actor: 'did:plc:me', posts: [], filter: FeedFilter.postsNoReplies, hasMore: false), 58 97 ); 98 + when(() => settingsCubit.state).thenReturn(defaultSettingsState()); 59 99 60 100 whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.authenticated(tokens)); 61 101 whenListen(profileBloc, const Stream<ProfileState>.empty(), initialState: ProfileState.loaded(profile: profile)); ··· 69 109 hasMore: false, 70 110 ), 71 111 ); 112 + whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: defaultSettingsState()); 72 113 }); 73 114 74 115 Widget buildSubject() { ··· 77 118 BlocProvider<AuthBloc>.value(value: authBloc), 78 119 BlocProvider<ProfileBloc>.value(value: profileBloc), 79 120 BlocProvider<FeedBloc>.value(value: feedBloc), 121 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 80 122 ], 81 123 child: const MaterialApp(home: ProfileScreen()), 82 124 ); 83 125 } 84 126 127 + /// Sets the test viewport to a tall size so that the full profile header 128 + /// (cover + summary + tab bar) fits within the viewport. 129 + void useLargeScreen(WidgetTester tester) { 130 + tester.view.physicalSize = const Size(800, 2400); 131 + tester.view.devicePixelRatio = 1.0; 132 + addTearDown(tester.view.reset); 133 + } 134 + 85 135 testWidgets('loads posts filter by default and renders the required profile fields', (tester) async { 136 + useLargeScreen(tester); 86 137 await tester.pumpWidget(buildSubject()); 87 138 88 139 verify(() => profileBloc.add(const ProfileLoadRequested(actor: 'did:plc:me'))).called(1); ··· 90 141 () => feedBloc.add(const FeedLoadRequested(actor: 'did:plc:me', filter: FeedFilter.postsNoReplies)), 91 142 ).called(1); 92 143 93 - expect(find.text('River Tam'), findsOneWidget); 144 + expect(find.text('RIVER TAM'), findsOneWidget); 94 145 expect(find.text('@me.bsky.social'), findsOneWidget); 95 146 expect(find.text('Signal and signal boost.'), findsOneWidget); 96 147 expect(find.text('she/her'), findsOneWidget); ··· 99 150 }); 100 151 101 152 testWidgets('shows Saved Posts button on own profile', (tester) async { 153 + useLargeScreen(tester); 102 154 await tester.pumpWidget(buildSubject()); 103 155 104 156 expect(find.text('Saved Posts'), findsOneWidget); 105 157 }); 106 158 107 159 testWidgets('does not show Saved Posts button on other profiles', (tester) async { 160 + useLargeScreen(tester); 108 161 const otherProfile = ProfileViewDetailed( 109 162 did: 'did:plc:other', 110 163 handle: 'other.bsky.social', ··· 126 179 BlocProvider<AuthBloc>.value(value: authBloc), 127 180 BlocProvider<ProfileBloc>.value(value: profileBloc), 128 181 BlocProvider<FeedBloc>.value(value: feedBloc), 182 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 129 183 ], 130 184 child: const MaterialApp(home: ProfileScreen(actor: 'did:plc:other', showBackButton: true)), 131 185 ), ··· 137 191 }); 138 192 139 193 testWidgets('maps tabs to the expected server filters', (tester) async { 194 + useLargeScreen(tester); 140 195 await tester.pumpWidget(buildSubject()); 141 196 142 - await tester.tap(find.text('Replies')); 197 + await tester.tap(find.text('REPLIES')); 143 198 await tester.pump(); 144 199 145 200 verify( 146 201 () => feedBloc.add(const FeedLoadRequested(actor: 'did:plc:me', filter: FeedFilter.postsAndAuthorThreads)), 147 202 ).called(1); 148 203 149 - await tester.tap(find.text('Media')); 204 + await tester.tap(find.text('MEDIA')); 150 205 await tester.pump(); 151 206 152 207 verify( ··· 155 210 }); 156 211 157 212 testWidgets('compose FAB on other profiles prefills the mentioned handle', (tester) async { 213 + useLargeScreen(tester); 158 214 const otherProfile = ProfileViewDetailed( 159 215 did: 'did:plc:other', 160 216 handle: 'other.bsky.social', ··· 172 228 routes: [ 173 229 GoRoute( 174 230 path: '/', 175 - builder: (context, state) => RepositoryProvider<ProfileActionRepository>.value( 176 - value: mockProfileActionRepository, 231 + builder: (context, state) => MultiRepositoryProvider( 232 + providers: [RepositoryProvider<ProfileActionRepository>.value(value: mockProfileActionRepository)], 177 233 child: MultiBlocProvider( 178 234 providers: [ 179 235 BlocProvider<AuthBloc>.value(value: authBloc), 180 236 BlocProvider<ProfileBloc>.value(value: profileBloc), 181 237 BlocProvider<FeedBloc>.value(value: feedBloc), 238 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 182 239 ], 183 240 child: const ProfileScreen(actor: 'did:plc:other', showBackButton: true), 184 241 ), ··· 205 262 expect(find.text('@other.bsky.social '), findsOneWidget); 206 263 207 264 router.dispose(); 265 + }); 266 + 267 + group('Profile header', () { 268 + testWidgets('avatar is square (no CircleAvatar)', (tester) async { 269 + useLargeScreen(tester); 270 + await tester.pumpWidget(buildSubject()); 271 + 272 + expect(find.byType(CircleAvatar), findsNothing); 273 + expect(find.byKey(const ValueKey('profile_square_avatar')), findsOneWidget); 274 + }); 275 + 276 + testWidgets('display name is rendered uppercase', (tester) async { 277 + useLargeScreen(tester); 278 + await tester.pumpWidget(buildSubject()); 279 + 280 + expect(find.text('RIVER TAM'), findsOneWidget); 281 + expect(find.text('River Tam'), findsNothing); 282 + }); 283 + 284 + testWidgets('handle is shown with @ prefix', (tester) async { 285 + useLargeScreen(tester); 286 + await tester.pumpWidget(buildSubject()); 287 + 288 + expect(find.text('@me.bsky.social'), findsOneWidget); 289 + }); 290 + 291 + testWidgets('bio is shown', (tester) async { 292 + useLargeScreen(tester); 293 + await tester.pumpWidget(buildSubject()); 294 + expect(find.text('Signal and signal boost.'), findsOneWidget); 295 + }); 296 + 297 + testWidgets('stats row is rendered in a bordered container', (tester) async { 298 + useLargeScreen(tester); 299 + await tester.pumpWidget(buildSubject()); 300 + expect(find.byKey(const ValueKey('profile_stats_row')), findsOneWidget); 301 + }); 302 + 303 + testWidgets('stat values are shown as formatted counts', (tester) async { 304 + useLargeScreen(tester); 305 + await tester.pumpWidget(buildSubject()); 306 + 307 + expect(find.text('1.2K'), findsWidgets); 308 + expect(find.text('64'), findsOneWidget); 309 + }); 310 + 311 + testWidgets('stat labels are uppercase', (tester) async { 312 + useLargeScreen(tester); 313 + await tester.pumpWidget(buildSubject()); 314 + 315 + expect(find.text('FOLLOWING'), findsOneWidget); 316 + expect(find.text('FOLLOWERS'), findsOneWidget); 317 + expect(find.text('POSTS'), findsAtLeastNWidgets(1)); 318 + }); 319 + 320 + testWidgets('cover section is present', (tester) async { 321 + useLargeScreen(tester); 322 + await tester.pumpWidget(buildSubject()); 323 + 324 + expect(find.byKey(const ValueKey('profile_square_avatar')), findsOneWidget); 325 + }); 326 + }); 327 + 328 + group('Tab bar', () { 329 + testWidgets('tab labels are uppercase', (tester) async { 330 + useLargeScreen(tester); 331 + await tester.pumpWidget(buildSubject()); 332 + 333 + expect(find.text('REPLIES'), findsOneWidget); 334 + expect(find.text('MEDIA'), findsOneWidget); 335 + }); 336 + 337 + testWidgets('original-case tab labels are not shown', (tester) async { 338 + useLargeScreen(tester); 339 + await tester.pumpWidget(buildSubject()); 340 + 341 + expect(find.text('Replies'), findsNothing); 342 + expect(find.text('Media'), findsNothing); 343 + }); 344 + }); 345 + 346 + group('Feed layout switching', () { 347 + FeedViewPost makePost(String id) { 348 + final record = FeedPostRecord(text: 'Post $id', createdAt: DateTime.utc(2026, 3, 1)); 349 + return FeedViewPost( 350 + post: PostView( 351 + uri: AtUri('at://did:plc:me/app.bsky.feed.post/$id'), 352 + cid: 'cid-$id', 353 + author: const ProfileViewBasic(did: 'did:plc:me', handle: 'me.bsky.social', displayName: 'River Tam'), 354 + record: record.toJson(), 355 + indexedAt: DateTime.utc(2026, 3, 1), 356 + ), 357 + ); 358 + } 359 + 360 + final posts = List.generate(3, (i) => makePost('$i')); 361 + 362 + FeedState feedStateWith(List<FeedViewPost> p) => 363 + FeedState.loaded(actor: 'did:plc:me', posts: p, filter: FeedFilter.postsNoReplies, hasMore: false); 364 + 365 + /// Builds the profile screen with [posts] in the feed and the given 366 + /// [settCubit] controlling layout mode. 367 + Widget buildWithPosts(WidgetTester tester, MockSettingsCubit settCubit) { 368 + useLargeScreen(tester); 369 + 370 + final mockPostActionRepo = MockPostActionRepository(); 371 + final mockSavedPostsCubit = MockSavedPostsCubit(); 372 + final mockPostActionCache = MockPostActionCache(); 373 + 374 + when(() => mockSavedPostsCubit.state).thenReturn(const SavedPostsState()); 375 + whenListen(mockSavedPostsCubit, const Stream<SavedPostsState>.empty()); 376 + 377 + when(() => feedBloc.state).thenReturn(feedStateWith(posts)); 378 + whenListen(feedBloc, const Stream<FeedState>.empty(), initialState: feedStateWith(posts)); 379 + 380 + return MultiRepositoryProvider( 381 + providers: [ 382 + RepositoryProvider<PostActionRepository>.value(value: mockPostActionRepo), 383 + RepositoryProvider<PostActionCache>.value(value: mockPostActionCache), 384 + ], 385 + child: MultiBlocProvider( 386 + providers: [ 387 + BlocProvider<AuthBloc>.value(value: authBloc), 388 + BlocProvider<ProfileBloc>.value(value: profileBloc), 389 + BlocProvider<FeedBloc>.value(value: feedBloc), 390 + BlocProvider<SettingsCubit>.value(value: settCubit), 391 + BlocProvider<SavedPostsCubit>.value(value: mockSavedPostsCubit), 392 + ], 393 + child: const MaterialApp(home: ProfileScreen()), 394 + ), 395 + ); 396 + } 397 + 398 + testWidgets('grid mode shows centered large grid cards with the metadata info card', (tester) async { 399 + final cubit = MockSettingsCubit(); 400 + when(() => cubit.state).thenReturn(settingsStateWith(FeedArchitecture.grid)); 401 + whenListen(cubit, const Stream<SettingsState>.empty(), initialState: settingsStateWith(FeedArchitecture.grid)); 402 + 403 + await tester.pumpWidget(buildWithPosts(tester, cubit)); 404 + await tester.pump(); 405 + 406 + expect(find.byKey(const ValueKey('profile_grid_feed')), findsOneWidget); 407 + expect(find.byKey(const ValueKey('profile_info_card')), findsOneWidget); 408 + expect(find.byKey(const ValueKey('profile_large_card_0')), findsOneWidget); 409 + expect(find.byKey(const ValueKey('profile_large_card_1')), findsOneWidget); 410 + expect(find.byKey(const ValueKey('profile_large_card_2')), findsOneWidget); 411 + }); 412 + 413 + testWidgets('linear mode does not show the large grid card feed or metadata info card', (tester) async { 414 + final cubit = MockSettingsCubit(); 415 + when(() => cubit.state).thenReturn(settingsStateWith(FeedArchitecture.linear)); 416 + whenListen(cubit, const Stream<SettingsState>.empty(), initialState: settingsStateWith(FeedArchitecture.linear)); 417 + 418 + await tester.pumpWidget(buildWithPosts(tester, cubit)); 419 + await tester.pump(); 420 + 421 + expect(find.byKey(const ValueKey('profile_grid_feed')), findsNothing); 422 + expect(find.byKey(const ValueKey('profile_info_card')), findsNothing); 423 + expect(find.byKey(const ValueKey('profile_large_card_0')), findsNothing); 424 + }); 425 + 426 + testWidgets('switching from grid to linear removes the large grid feed and metadata card without re-fetch', ( 427 + tester, 428 + ) async { 429 + final cubit = MockSettingsCubit(); 430 + final streamCtrl = StreamController<SettingsState>.broadcast(); 431 + 432 + when(() => cubit.state).thenReturn(settingsStateWith(FeedArchitecture.grid)); 433 + when(() => cubit.stream).thenAnswer((_) => streamCtrl.stream); 434 + 435 + await tester.pumpWidget(buildWithPosts(tester, cubit)); 436 + await tester.pump(); 437 + 438 + expect(find.byKey(const ValueKey('profile_grid_feed')), findsOneWidget); 439 + expect(find.byKey(const ValueKey('profile_info_card')), findsOneWidget); 440 + 441 + when(() => cubit.state).thenReturn(settingsStateWith(FeedArchitecture.linear)); 442 + streamCtrl.add(settingsStateWith(FeedArchitecture.linear)); 443 + await tester.pumpAndSettle(); 444 + 445 + expect(find.byKey(const ValueKey('profile_grid_feed')), findsNothing); 446 + expect(find.byKey(const ValueKey('profile_info_card')), findsNothing); 447 + 448 + verifyNever(() => feedBloc.add(const FeedRefreshRequested())); 449 + 450 + await streamCtrl.close(); 451 + }); 208 452 }); 209 453 }