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

Configure Feed

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

refactor: platform specific back behavior

+451 -228
+2 -2
docs/dev/smoke-test.md
··· 387 387 388 388 ### Navigation 389 389 390 - - [ ] Bottom nav switches between Home, Search, Alerts, Profile 391 - - [ ] Back button / swipe-back navigates correctly through stack 390 + - [x] Bottom nav switches between Home, Search, Alerts, Profile 391 + - [x] Back button / swipe-back navigates correctly through stack 392 392 - [ ] Deep link to a post URI opens thread view 393 393 394 394 ### Performance
+14
lib/core/router/app_route_page.dart
··· 1 + import 'package:flutter/cupertino.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:go_router/go_router.dart'; 4 + import 'package:lazurite/core/router/fade_through_page.dart'; 5 + 6 + bool useCupertinoRoutePage(TargetPlatform platform) => platform == TargetPlatform.iOS; 7 + 8 + Page<T> buildAppRoutePage<T>({required BuildContext context, required GoRouterState state, required Widget child}) { 9 + if (useCupertinoRoutePage(Theme.of(context).platform)) { 10 + return CupertinoPage<T>(key: state.pageKey, child: child); 11 + } 12 + 13 + return buildFadeThroughPage<T>(context: context, state: state, child: child); 14 + }
+11 -5
lib/core/router/app_router.dart
··· 10 10 import 'package:lazurite/core/network/app_view_provider.dart'; 11 11 import 'package:lazurite/core/network/constellation_client.dart'; 12 12 import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; 13 + import 'package:lazurite/core/router/app_route_page.dart'; 13 14 import 'package:lazurite/core/router/app_shell.dart'; 14 - import 'package:lazurite/core/router/fade_through_page.dart'; 15 15 import 'package:lazurite/features/alerts/presentation/alerts_screen.dart'; 16 16 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 17 17 import 'package:lazurite/features/auth/presentation/login_screen.dart'; ··· 81 81 final GlobalKey<NavigatorState> _searchNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'search'); 82 82 final GlobalKey<NavigatorState> _notificationsNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'notifications'); 83 83 final GlobalKey<NavigatorState> _profileNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'profile'); 84 + List<GlobalKey<NavigatorState>> get _branchNavigatorKeys => [ 85 + _homeNavigatorKey, 86 + _searchNavigatorKey, 87 + _notificationsNavigatorKey, 88 + _profileNavigatorKey, 89 + ]; 84 90 85 91 Page<dynamic> _page(BuildContext context, GoRouterState state, Widget child) => 86 - buildFadeThroughPage(context: context, state: state, child: child); 92 + buildAppRoutePage(context: context, state: state, child: child); 87 93 88 94 GoRouter get router => GoRouter( 89 95 navigatorKey: _rootNavigatorKey, ··· 292 298 StatefulShellRoute.indexedStack( 293 299 builder: (context, state, navigationShell) { 294 300 if (!context.read<AuthBloc>().state.isAuthenticated) { 295 - return AppShell(navigationShell: navigationShell); 301 + return AppShell(navigationShell: navigationShell, branchNavigatorKeys: _branchNavigatorKeys); 296 302 } 297 303 298 304 UnreadCountCubit? existingUnreadCubit; ··· 303 309 } 304 310 305 311 if (existingUnreadCubit != null) { 306 - return AppShell(navigationShell: navigationShell); 312 + return AppShell(navigationShell: navigationShell, branchNavigatorKeys: _branchNavigatorKeys); 307 313 } 308 314 309 315 return MultiBlocProvider( ··· 313 319 create: (_) => UnreadCountCubit(notificationRepository: context.read<NotificationRepository>()), 314 320 ), 315 321 ], 316 - child: AppShell(navigationShell: navigationShell), 322 + child: AppShell(navigationShell: navigationShell, branchNavigatorKeys: _branchNavigatorKeys), 317 323 ); 318 324 }, 319 325 branches: [
+63 -21
lib/core/router/app_shell.dart
··· 1 + import 'dart:async'; 2 + 1 3 import 'package:flutter/material.dart'; 4 + import 'package:flutter/services.dart'; 2 5 import 'package:flutter_animate/flutter_animate.dart'; 3 6 import 'package:flutter_bloc/flutter_bloc.dart'; 4 7 import 'package:go_router/go_router.dart'; ··· 38 41 } 39 42 40 43 class AppShell extends StatefulWidget { 41 - const AppShell({super.key, required this.navigationShell}); 44 + const AppShell({super.key, required this.navigationShell, required this.branchNavigatorKeys}); 42 45 43 46 final StatefulNavigationShell navigationShell; 47 + final List<GlobalKey<NavigatorState>> branchNavigatorKeys; 44 48 45 49 /// Global key for the shell [Scaffold]. Accessible from any screen, even 46 50 /// screens pushed onto the root navigator that are outside [AppShellScope]. ··· 56 60 class _AppShellState extends State<AppShell> { 57 61 void _openMenu() => AppShell.openDrawer(); 58 62 63 + bool get _isAndroid => Theme.of(context).platform == TargetPlatform.android; 64 + 65 + NavigatorState? get _activeBranchNavigator { 66 + final index = widget.navigationShell.currentIndex; 67 + if (index < 0 || index >= widget.branchNavigatorKeys.length) { 68 + return null; 69 + } 70 + return widget.branchNavigatorKeys[index].currentState; 71 + } 72 + 73 + bool _activeBranchCanPop() => _activeBranchNavigator?.canPop() ?? false; 74 + 75 + bool _isAndroidHomeRoot() => _isAndroid && widget.navigationShell.currentIndex == 0 && !_activeBranchCanPop(); 76 + 77 + Future<void> _handleAndroidBack() async { 78 + final activeNavigator = _activeBranchNavigator; 79 + if (activeNavigator != null && activeNavigator.canPop()) { 80 + await activeNavigator.maybePop(); 81 + return; 82 + } 83 + 84 + if (widget.navigationShell.currentIndex != 0) { 85 + widget.navigationShell.goBranch(0); 86 + return; 87 + } 88 + 89 + await SystemNavigator.pop(); 90 + } 91 + 59 92 @override 60 93 Widget build(BuildContext context) { 61 94 final theme = Theme.of(context); 62 95 return AppShellScope( 63 96 openMenu: _openMenu, 64 - child: Scaffold( 65 - key: AppShell.scaffoldKey, 66 - drawer: _AppMenu(navigationShell: widget.navigationShell, rootContext: context), 67 - body: widget.navigationShell, 68 - bottomNavigationBar: Container( 69 - decoration: BoxDecoration( 70 - color: theme.colorScheme.surface.withValues(alpha: 0.92), 71 - border: Border(top: BorderSide(color: theme.colorScheme.outlineVariant)), 72 - ), 73 - child: NavigationBar( 74 - height: 80, 75 - backgroundColor: Colors.transparent, 76 - surfaceTintColor: Colors.transparent, 77 - indicatorColor: Colors.transparent, 78 - selectedIndex: widget.navigationShell.currentIndex, 79 - onDestinationSelected: (index) { 80 - widget.navigationShell.goBranch(index, initialLocation: index == widget.navigationShell.currentIndex); 81 - }, 82 - labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, 83 - destinations: _destinations, 97 + child: PopScope( 98 + canPop: !_isAndroid || _isAndroidHomeRoot(), 99 + onPopInvokedWithResult: (didPop, _) { 100 + if (didPop || !_isAndroid) { 101 + return; 102 + } 103 + unawaited(_handleAndroidBack()); 104 + }, 105 + child: Scaffold( 106 + key: AppShell.scaffoldKey, 107 + drawer: _AppMenu(navigationShell: widget.navigationShell, rootContext: context), 108 + body: widget.navigationShell, 109 + bottomNavigationBar: Container( 110 + decoration: BoxDecoration( 111 + color: theme.colorScheme.surface.withValues(alpha: 0.92), 112 + border: Border(top: BorderSide(color: theme.colorScheme.outlineVariant)), 113 + ), 114 + child: NavigationBar( 115 + height: 80, 116 + backgroundColor: Colors.transparent, 117 + surfaceTintColor: Colors.transparent, 118 + indicatorColor: Colors.transparent, 119 + selectedIndex: widget.navigationShell.currentIndex, 120 + onDestinationSelected: (index) { 121 + widget.navigationShell.goBranch(index, initialLocation: index == widget.navigationShell.currentIndex); 122 + }, 123 + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, 124 + destinations: _destinations, 125 + ), 84 126 ), 85 127 ), 86 128 ),
+13 -10
lib/features/alerts/presentation/alerts_screen.dart
··· 7 7 import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; 8 8 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 9 9 import 'package:lazurite/features/notifications/presentation/widgets/notifications_pane.dart'; 10 + import 'package:lazurite/shared/presentation/widgets/app_screen_entrance.dart'; 10 11 11 12 enum AlertsTab { notifications, messages, requests } 12 13 ··· 24 25 Widget build(BuildContext context) { 25 26 final currentTab = widget.initialTab; 26 27 27 - return Scaffold( 28 - appBar: LazuriteAppBar( 29 - sectionLabel: 'Alerts', 30 - actions: currentTab == AlertsTab.notifications 31 - ? [TextButton(onPressed: () => _markAllRead(context), child: const Text('Mark All Read'))] 32 - : null, 33 - bottom: PreferredSize( 34 - preferredSize: const Size.fromHeight(48), 35 - child: _AlertsTabs(currentTab: currentTab), 28 + return AppScreenEntrance( 29 + child: Scaffold( 30 + appBar: LazuriteAppBar( 31 + sectionLabel: 'Alerts', 32 + actions: currentTab == AlertsTab.notifications 33 + ? [TextButton(onPressed: () => _markAllRead(context), child: const Text('Mark All Read'))] 34 + : null, 35 + bottom: PreferredSize( 36 + preferredSize: const Size.fromHeight(48), 37 + child: _AlertsTabs(currentTab: currentTab), 38 + ), 36 39 ), 40 + body: KeyedSubtree(key: ValueKey(currentTab), child: _buildTab(currentTab)), 37 41 ), 38 - body: KeyedSubtree(key: ValueKey(currentTab), child: _buildTab(currentTab)), 39 42 ); 40 43 } 41 44
+70 -61
lib/features/feed/presentation/home_feed_screen.dart
··· 17 17 import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 18 18 import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 19 19 import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 20 + import 'package:lazurite/shared/presentation/widgets/app_screen_entrance.dart'; 20 21 import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 21 22 import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 22 23 ··· 54 55 55 56 @override 56 57 Widget build(BuildContext context) { 58 + Widget withEntrance(Widget child) => AppScreenEntrance(child: child); 59 + 57 60 return BlocBuilder<FeedPreferencesCubit, FeedPreferencesState>( 58 61 builder: (context, prefsState) { 59 62 if (prefsState.status == FeedPreferencesStatus.initial || prefsState.status == FeedPreferencesStatus.loading) { 60 - return const Scaffold(body: LoadingState()); 63 + return withEntrance(const Scaffold(body: LoadingState())); 61 64 } 62 65 63 66 if (prefsState.status == FeedPreferencesStatus.error) { 64 - return Scaffold( 65 - appBar: const LazuriteAppBar(sectionLabel: 'Home'), 66 - body: ErrorState( 67 - title: 'Failed to load feeds', 68 - message: prefsState.message ?? 'Unknown error', 69 - onRetry: () => context.read<FeedPreferencesCubit>().loadPreferences(), 67 + return withEntrance( 68 + Scaffold( 69 + appBar: const LazuriteAppBar(sectionLabel: 'Home'), 70 + body: ErrorState( 71 + title: 'Failed to load feeds', 72 + message: prefsState.message ?? 'Unknown error', 73 + onRetry: () => context.read<FeedPreferencesCubit>().loadPreferences(), 74 + ), 70 75 ), 71 76 ); 72 77 } ··· 75 80 final isOffline = context.select<ConnectivityCubit, bool>((cubit) => cubit.state.isOffline); 76 81 77 82 if (pinnedFeeds.isEmpty) { 78 - return Scaffold( 79 - appBar: const LazuriteAppBar(sectionLabel: 'Home'), 80 - body: EmptyState( 81 - message: 'No feeds pinned', 82 - icon: Icons.rss_feed_outlined, 83 - subtitle: 'Pin a timeline or custom feed to build your home tabs.', 84 - action: FilledButton(onPressed: () => context.push('/feeds'), child: const Text('Manage Feeds')), 83 + return withEntrance( 84 + Scaffold( 85 + appBar: const LazuriteAppBar(sectionLabel: 'Home'), 86 + body: EmptyState( 87 + message: 'No feeds pinned', 88 + icon: Icons.rss_feed_outlined, 89 + subtitle: 'Pin a timeline or custom feed to build your home tabs.', 90 + action: FilledButton(onPressed: () => context.push('/feeds'), child: const Text('Manage Feeds')), 91 + ), 85 92 ), 86 93 ); 87 94 } ··· 89 96 final currentTabIndex = _selectedIndexFor(pinnedFeeds); 90 97 _syncSelectedFeed(pinnedFeeds, currentTabIndex); 91 98 92 - return Scaffold( 93 - appBar: LazuriteAppBar( 94 - sectionLabel: 'Home', 95 - actions: [ 96 - IconButton( 97 - icon: const Icon(Icons.trending_up_outlined), 98 - tooltip: 'Trending', 99 - onPressed: () => context.push('/trending'), 99 + return withEntrance( 100 + Scaffold( 101 + appBar: LazuriteAppBar( 102 + sectionLabel: 'Home', 103 + actions: [ 104 + IconButton( 105 + icon: const Icon(Icons.trending_up_outlined), 106 + tooltip: 'Trending', 107 + onPressed: () => context.push('/trending'), 108 + ), 109 + IconButton( 110 + icon: const Icon(Icons.rss_feed), 111 + tooltip: 'Manage Feeds', 112 + onPressed: () => context.push('/feeds'), 113 + ), 114 + ], 115 + bottom: _FeedTabBar( 116 + feeds: pinnedFeeds, 117 + prefsState: prefsState, 118 + currentTabIndex: currentTabIndex, 119 + onTabTapped: (index) { 120 + _pageController.animateToPage( 121 + index, 122 + duration: const Duration(milliseconds: 300), 123 + curve: Curves.easeInOut, 124 + ); 125 + setState(() => _selectedFeedId = pinnedFeeds[index].id); 126 + }, 100 127 ), 101 - IconButton( 102 - icon: const Icon(Icons.rss_feed), 103 - tooltip: 'Manage Feeds', 104 - onPressed: () => context.push('/feeds'), 105 - ), 106 - ], 107 - bottom: _FeedTabBar( 108 - feeds: pinnedFeeds, 109 - prefsState: prefsState, 110 - currentTabIndex: currentTabIndex, 111 - onTabTapped: (index) { 112 - _pageController.animateToPage( 113 - index, 114 - duration: const Duration(milliseconds: 300), 115 - curve: Curves.easeInOut, 116 - ); 117 - setState(() => _selectedFeedId = pinnedFeeds[index].id); 118 - }, 128 + ), 129 + body: PageView.builder( 130 + controller: _pageController, 131 + onPageChanged: (index) => setState(() => _selectedFeedId = pinnedFeeds[index].id), 132 + itemCount: pinnedFeeds.length, 133 + itemBuilder: (context, index) => 134 + _FeedListView(feed: pinnedFeeds[index], key: ValueKey(pinnedFeeds[index].id)), 119 135 ), 120 - ), 121 - body: PageView.builder( 122 - controller: _pageController, 123 - onPageChanged: (index) => setState(() => _selectedFeedId = pinnedFeeds[index].id), 124 - itemCount: pinnedFeeds.length, 125 - itemBuilder: (context, index) => 126 - _FeedListView(feed: pinnedFeeds[index], key: ValueKey(pinnedFeeds[index].id)), 136 + floatingActionButton: 137 + FloatingActionButton( 138 + heroTag: 'home-compose-fab', 139 + tooltip: isOffline ? offlineActionMessage('compose a post') : 'Compose', 140 + onPressed: isOffline ? null : () => context.push('/compose'), 141 + shape: const CircleBorder(), 142 + child: const Icon(Icons.add), 143 + ).animateIfAllowed( 144 + context, 145 + effects: const [ 146 + FadeEffect(duration: Anim.feedItem, curve: Anim.enter), 147 + ScaleEffect(begin: Offset(0, 0), end: Offset(1, 1), duration: Anim.feedItem, curve: Anim.emphasis), 148 + ], 149 + ), 127 150 ), 128 - floatingActionButton: 129 - FloatingActionButton( 130 - heroTag: 'home-compose-fab', 131 - tooltip: isOffline ? offlineActionMessage('compose a post') : 'Compose', 132 - onPressed: isOffline ? null : () => context.push('/compose'), 133 - shape: const CircleBorder(), 134 - child: const Icon(Icons.add), 135 - ).animateIfAllowed( 136 - context, 137 - effects: const [ 138 - FadeEffect(duration: Anim.feedItem, curve: Anim.enter), 139 - ScaleEffect(begin: Offset(0, 0), end: Offset(1, 1), duration: Anim.feedItem, curve: Anim.emphasis), 140 - ], 141 - ), 142 151 ); 143 152 }, 144 153 );
+108 -105
lib/features/profile/presentation/profile_screen.dart
··· 46 46 import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 47 47 import 'package:lazurite/shared/presentation/helpers/share_helper.dart'; 48 48 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 49 + import 'package:lazurite/shared/presentation/widgets/app_screen_entrance.dart'; 49 50 import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; 50 51 import 'package:lazurite/shared/utils/format_utils.dart'; 51 52 import 'package:url_launcher/url_launcher.dart'; ··· 153 154 154 155 @override 155 156 Widget build(BuildContext context) { 156 - return BlocListener<ProfileBloc, ProfileState>( 157 - listenWhen: (previous, current) { 158 - return _shouldShowSuggestedTab(previous.profile) != _shouldShowSuggestedTab(current.profile); 159 - }, 160 - listener: (context, state) => _setSuggestedTabVisibility(_shouldShowSuggestedTab(state.profile)), 161 - child: Scaffold( 162 - body: BlocBuilder<ProfileBloc, ProfileState>( 163 - builder: (context, profileState) { 164 - return BlocBuilder<FeedBloc, FeedState>( 165 - builder: (context, feedState) { 166 - final profile = profileState.profile; 167 - final currentUserDid = context.read<AuthBloc>().state.tokens?.did; 168 - final isOwnProfile = profile?.did == currentUserDid; 169 - final tabChildren = <Widget>[ 170 - ..._feedTabs.map((t) => _buildFeedList(feedState, t.filter, profile)), 171 - _buildListsTab(context, profile), 172 - _buildStarterPacksTab(context, profile), 173 - if (_showSuggestedTab) _buildSuggestedFollowsTab(profile), 174 - ]; 157 + return AppScreenEntrance( 158 + child: BlocListener<ProfileBloc, ProfileState>( 159 + listenWhen: (previous, current) { 160 + return _shouldShowSuggestedTab(previous.profile) != _shouldShowSuggestedTab(current.profile); 161 + }, 162 + listener: (context, state) => _setSuggestedTabVisibility(_shouldShowSuggestedTab(state.profile)), 163 + child: Scaffold( 164 + body: BlocBuilder<ProfileBloc, ProfileState>( 165 + builder: (context, profileState) { 166 + return BlocBuilder<FeedBloc, FeedState>( 167 + builder: (context, feedState) { 168 + final profile = profileState.profile; 169 + final currentUserDid = context.read<AuthBloc>().state.tokens?.did; 170 + final isOwnProfile = profile?.did == currentUserDid; 171 + final tabChildren = <Widget>[ 172 + ..._feedTabs.map((t) => _buildFeedList(feedState, t.filter, profile)), 173 + _buildListsTab(context, profile), 174 + _buildStarterPacksTab(context, profile), 175 + if (_showSuggestedTab) _buildSuggestedFollowsTab(profile), 176 + ]; 175 177 176 - return NotificationListener<ScrollUpdateNotification>( 177 - onNotification: (notification) { 178 - if (notification.metrics.axis != Axis.vertical) { 178 + return NotificationListener<ScrollUpdateNotification>( 179 + onNotification: (notification) { 180 + if (notification.metrics.axis != Axis.vertical) { 181 + return false; 182 + } 183 + final offset = notification.metrics.pixels; 184 + if ((offset - _coverScrollOffset).abs() >= 1) { 185 + setState(() => _coverScrollOffset = offset); 186 + } 179 187 return false; 180 - } 181 - final offset = notification.metrics.pixels; 182 - if ((offset - _coverScrollOffset).abs() >= 1) { 183 - setState(() => _coverScrollOffset = offset); 184 - } 185 - return false; 186 - }, 187 - child: NestedScrollView( 188 - headerSliverBuilder: (context, innerBoxIsScrolled) { 189 - return [ 190 - SliverAppBar( 191 - floating: true, 192 - pinned: true, 193 - snap: true, 194 - title: Text(_appBarTitle(profile)), 195 - leading: widget.showBackButton 196 - ? IconButton( 197 - icon: const Icon(Icons.arrow_back), 198 - onPressed: () => context.canPop() ? context.pop() : context.go('/profile'), 199 - ) 200 - : const AppShellMenuButton(), 201 - actions: [ 202 - if (profile != null && isOwnProfile) 188 + }, 189 + child: NestedScrollView( 190 + headerSliverBuilder: (context, innerBoxIsScrolled) { 191 + return [ 192 + SliverAppBar( 193 + floating: true, 194 + pinned: true, 195 + snap: true, 196 + title: Text(_appBarTitle(profile)), 197 + leading: widget.showBackButton 198 + ? IconButton( 199 + icon: const Icon(Icons.arrow_back), 200 + onPressed: () => context.canPop() ? context.pop() : context.go('/profile'), 201 + ) 202 + : const AppShellMenuButton(), 203 + actions: [ 204 + if (profile != null && isOwnProfile) 205 + IconButton( 206 + key: const Key('profile_more_button'), 207 + icon: const Icon(Icons.more_vert), 208 + onPressed: () => _showOwnProfileMoreOptions(context, profile), 209 + ), 203 210 IconButton( 204 - key: const Key('profile_more_button'), 205 - icon: const Icon(Icons.more_vert), 206 - onPressed: () => _showOwnProfileMoreOptions(context, profile), 211 + icon: const Icon(Icons.settings_outlined), 212 + onPressed: () => context.go('/settings'), 207 213 ), 208 - IconButton( 209 - icon: const Icon(Icons.settings_outlined), 210 - onPressed: () => context.go('/settings'), 211 - ), 212 - ], 213 - ), 214 - SliverToBoxAdapter(child: _buildCoverSection(context, profile)), 215 - SliverToBoxAdapter( 216 - child: switch (profileState.status) { 217 - ProfileStatus.loading => const Padding( 218 - padding: AppInsets.allLg, 219 - child: Center(child: CircularProgressIndicator()), 220 - ), 221 - ProfileStatus.error => _buildProfileError(context, profileState.errorMessage), 222 - _ => _buildProfileSummary(context, profile, isOwnProfile), 223 - }, 224 - ), 225 - SliverPersistentHeader( 226 - pinned: true, 227 - delegate: SliverTabBarDelegate( 228 - TabBar( 229 - controller: _tabController, 230 - tabs: [for (final label in _tabLabels) Tab(text: label)], 231 - onTap: (index) { 232 - if (index < _feedTabs.length) { 233 - _loadProfileAndFeed(filter: _feedTabs[index].filter); 234 - } 235 - }, 236 - isScrollable: true, 237 - tabAlignment: TabAlignment.start, 238 - labelStyle: const TextStyle( 239 - fontSize: 11, 240 - fontWeight: FontWeight.w700, 241 - letterSpacing: 2.2, 214 + ], 215 + ), 216 + SliverToBoxAdapter(child: _buildCoverSection(context, profile)), 217 + SliverToBoxAdapter( 218 + child: switch (profileState.status) { 219 + ProfileStatus.loading => const Padding( 220 + padding: AppInsets.allLg, 221 + child: Center(child: CircularProgressIndicator()), 242 222 ), 243 - unselectedLabelStyle: const TextStyle( 244 - fontSize: 11, 245 - fontWeight: FontWeight.w700, 246 - letterSpacing: 2.2, 223 + ProfileStatus.error => _buildProfileError(context, profileState.errorMessage), 224 + _ => _buildProfileSummary(context, profile, isOwnProfile), 225 + }, 226 + ), 227 + SliverPersistentHeader( 228 + pinned: true, 229 + delegate: SliverTabBarDelegate( 230 + TabBar( 231 + controller: _tabController, 232 + tabs: [for (final label in _tabLabels) Tab(text: label)], 233 + onTap: (index) { 234 + if (index < _feedTabs.length) { 235 + _loadProfileAndFeed(filter: _feedTabs[index].filter); 236 + } 237 + }, 238 + isScrollable: true, 239 + tabAlignment: TabAlignment.start, 240 + labelStyle: const TextStyle( 241 + fontSize: 11, 242 + fontWeight: FontWeight.w700, 243 + letterSpacing: 2.2, 244 + ), 245 + unselectedLabelStyle: const TextStyle( 246 + fontSize: 11, 247 + fontWeight: FontWeight.w700, 248 + letterSpacing: 2.2, 249 + ), 250 + indicatorWeight: 2, 247 251 ), 248 - indicatorWeight: 2, 249 252 ), 250 253 ), 251 - ), 252 - ]; 253 - }, 254 - body: TabBarView(controller: _tabController, children: tabChildren), 255 - ), 256 - ); 257 - }, 258 - ); 259 - }, 260 - ), 261 - floatingActionButton: AnimatedSwitcher( 262 - duration: Anim.feedItem, 263 - switchInCurve: Anim.enter, 264 - switchOutCurve: Anim.exit, 265 - transitionBuilder: (child, animation) => FadeTransition( 266 - opacity: animation, 267 - child: ScaleTransition(scale: animation, child: child), 254 + ]; 255 + }, 256 + body: TabBarView(controller: _tabController, children: tabChildren), 257 + ), 258 + ); 259 + }, 260 + ); 261 + }, 262 + ), 263 + floatingActionButton: AnimatedSwitcher( 264 + duration: Anim.feedItem, 265 + switchInCurve: Anim.enter, 266 + switchOutCurve: Anim.exit, 267 + transitionBuilder: (child, animation) => FadeTransition( 268 + opacity: animation, 269 + child: ScaleTransition(scale: animation, child: child), 270 + ), 271 + child: _buildComposeFab(context), 268 272 ), 269 - child: _buildComposeFab(context), 270 273 ), 271 274 ), 272 275 );
+27 -24
lib/features/search/presentation/search_screen.dart
··· 23 23 import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 24 24 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 25 25 import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 26 + import 'package:lazurite/shared/presentation/widgets/app_screen_entrance.dart'; 26 27 import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 27 28 import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 28 29 import 'package:lazurite/shared/utils/format_utils.dart'; ··· 199 200 200 201 @override 201 202 Widget build(BuildContext context) { 202 - return Scaffold( 203 - floatingActionButton: 204 - FloatingActionButton.extended( 205 - onPressed: _openJumpToProfileDialog, 206 - icon: const Icon(Icons.person_search), 207 - label: const Text('Jump to profile'), 208 - ).animateIfAllowed( 209 - context, 210 - effects: const [ 211 - FadeEffect(duration: Anim.feedItem, curve: Anim.enter), 212 - ScaleEffect(begin: Offset(0, 0), end: Offset(1, 1), duration: Anim.feedItem, curve: Anim.emphasis), 213 - ], 214 - ), 215 - body: SafeArea( 216 - child: BlocBuilder<SearchBloc, SearchState>( 217 - builder: (context, state) { 218 - return Column( 219 - children: [ 220 - _buildSearchBar(context, state), 221 - _buildTabs(context, state), 222 - if (state.currentTab == SearchTab.posts && state.hasResults) _buildSortToggle(context, state), 223 - Expanded(child: _buildBody(context, state)), 203 + return AppScreenEntrance( 204 + child: Scaffold( 205 + floatingActionButton: 206 + FloatingActionButton.extended( 207 + onPressed: _openJumpToProfileDialog, 208 + icon: const Icon(Icons.person_search), 209 + label: const Text('Jump to profile'), 210 + ).animateIfAllowed( 211 + context, 212 + effects: const [ 213 + FadeEffect(duration: Anim.feedItem, curve: Anim.enter), 214 + ScaleEffect(begin: Offset(0, 0), end: Offset(1, 1), duration: Anim.feedItem, curve: Anim.emphasis), 224 215 ], 225 - ); 226 - }, 216 + ), 217 + body: SafeArea( 218 + child: BlocBuilder<SearchBloc, SearchState>( 219 + builder: (context, state) { 220 + return Column( 221 + children: [ 222 + _buildSearchBar(context, state), 223 + _buildTabs(context, state), 224 + if (state.currentTab == SearchTab.posts && state.hasResults) _buildSortToggle(context, state), 225 + Expanded(child: _buildBody(context, state)), 226 + ], 227 + ); 228 + }, 229 + ), 227 230 ), 228 231 ), 229 232 );
+19
lib/shared/presentation/widgets/app_screen_entrance.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_animate/flutter_animate.dart'; 3 + import 'package:lazurite/core/theme/animation_tokens.dart'; 4 + import 'package:lazurite/core/theme/animation_utils.dart'; 5 + 6 + class AppScreenEntrance extends StatelessWidget { 7 + const AppScreenEntrance({super.key, required this.child}); 8 + 9 + final Widget child; 10 + 11 + @override 12 + Widget build(BuildContext context) => child.animateIfAllowed( 13 + context, 14 + effects: const [ 15 + FadeEffect(duration: Anim.normal, curve: Anim.enter), 16 + SlideEffect(begin: Offset(0, 0.02), end: Offset.zero, duration: Anim.normal, curve: Anim.enter), 17 + ], 18 + ); 19 + }
+60
test/core/router/app_route_page_test.dart
··· 1 + import 'package:flutter/cupertino.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:go_router/go_router.dart'; 5 + import 'package:lazurite/core/router/app_route_page.dart'; 6 + 7 + class _FakeGoRouterState extends Fake implements GoRouterState { 8 + @override 9 + ValueKey<String> get pageKey => const ValueKey<String>('page-key'); 10 + } 11 + 12 + void main() { 13 + late GoRouterState state; 14 + 15 + setUp(() { 16 + state = _FakeGoRouterState(); 17 + }); 18 + 19 + test('useCupertinoRoutePage returns true only for iOS', () { 20 + expect(useCupertinoRoutePage(TargetPlatform.iOS), isTrue); 21 + expect(useCupertinoRoutePage(TargetPlatform.android), isFalse); 22 + expect(useCupertinoRoutePage(TargetPlatform.macOS), isFalse); 23 + }); 24 + 25 + testWidgets('buildAppRoutePage uses CupertinoPage on iOS', (tester) async { 26 + late Page<void> page; 27 + 28 + await tester.pumpWidget( 29 + MaterialApp( 30 + theme: ThemeData(platform: TargetPlatform.iOS), 31 + home: Builder( 32 + builder: (context) { 33 + page = buildAppRoutePage<void>(context: context, state: state, child: const SizedBox()); 34 + return const SizedBox(); 35 + }, 36 + ), 37 + ), 38 + ); 39 + 40 + expect(page, isA<CupertinoPage<void>>()); 41 + }); 42 + 43 + testWidgets('buildAppRoutePage uses CustomTransitionPage on Android', (tester) async { 44 + late Page<void> page; 45 + 46 + await tester.pumpWidget( 47 + MaterialApp( 48 + theme: ThemeData(platform: TargetPlatform.android), 49 + home: Builder( 50 + builder: (context) { 51 + page = buildAppRoutePage<void>(context: context, state: state, child: const SizedBox()); 52 + return const SizedBox(); 53 + }, 54 + ), 55 + ), 56 + ); 57 + 58 + expect(page, isA<CustomTransitionPage<void>>()); 59 + }); 60 + }
+56
test/core/router/app_router_test.dart
··· 231 231 expect(navBar.height, 80); 232 232 }); 233 233 234 + testWidgets('Android back pops nested route before tab-root policy', (tester) async { 235 + await tester.binding.setSurfaceSize(const Size(430, 932)); 236 + addTearDown(() => tester.binding.setSurfaceSize(null)); 237 + 238 + await tester.pumpWidget(buildSubject()); 239 + await tester.pumpAndSettle(); 240 + 241 + await tester.tap(find.byTooltip('Open menu')); 242 + await tester.pumpAndSettle(); 243 + 244 + await tester.tap(find.text('SETTINGS').last); 245 + await tester.pumpAndSettle(); 246 + 247 + expect(find.text('APPEARANCE'), findsOneWidget); 248 + 249 + await tester.binding.handlePopRoute(); 250 + await tester.pumpAndSettle(); 251 + 252 + expect(find.text('No feeds pinned'), findsOneWidget); 253 + final navBar = tester.widget<NavigationBar>(find.byType(NavigationBar)); 254 + expect(navBar.selectedIndex, 0); 255 + }); 256 + 257 + testWidgets('Android back at non-Home tab root switches to Home tab', (tester) async { 258 + await tester.binding.setSurfaceSize(const Size(430, 932)); 259 + addTearDown(() => tester.binding.setSurfaceSize(null)); 260 + 261 + await tester.pumpWidget(buildSubject()); 262 + await tester.pumpAndSettle(); 263 + 264 + await tester.tap(find.text('PROFILE')); 265 + await tester.pumpAndSettle(); 266 + 267 + expect(find.text('RIVER TAM'), findsOneWidget); 268 + 269 + await tester.binding.handlePopRoute(); 270 + await tester.pumpAndSettle(); 271 + 272 + final navBar = tester.widget<NavigationBar>(find.byType(NavigationBar)); 273 + expect(navBar.selectedIndex, 0); 274 + expect(find.text('No feeds pinned'), findsOneWidget); 275 + }); 276 + 277 + testWidgets('Android back at Home root follows system-exit path', (tester) async { 278 + await tester.binding.setSurfaceSize(const Size(430, 932)); 279 + addTearDown(() => tester.binding.setSurfaceSize(null)); 280 + 281 + await tester.pumpWidget(buildSubject()); 282 + await tester.pumpAndSettle(); 283 + 284 + final handled = await tester.binding.handlePopRoute(); 285 + await tester.pumpAndSettle(); 286 + 287 + expect(handled, isFalse); 288 + }); 289 + 234 290 testWidgets('drawer contains Messages and Settings entries', (tester) async { 235 291 await tester.binding.setSurfaceSize(const Size(430, 932)); 236 292 addTearDown(() => tester.binding.setSurfaceSize(null));
+2
test/features/alerts/presentation/alerts_screen_test.dart
··· 15 15 import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; 16 16 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 17 17 import 'package:lazurite/features/notifications/data/notification_repository.dart'; 18 + import 'package:lazurite/shared/presentation/widgets/app_screen_entrance.dart'; 18 19 import 'package:mocktail/mocktail.dart'; 19 20 20 21 class MockNotificationRepository extends Mock implements NotificationRepository {} ··· 177 178 await tester.pumpWidget(buildSubject('/alerts')); 178 179 await tester.pumpAndSettle(); 179 180 181 + expect(find.byType(AppScreenEntrance), findsOneWidget); 180 182 expect(find.text('Notifications'), findsOneWidget); 181 183 expect(find.text('Messages'), findsOneWidget); 182 184 expect(find.text('Requests'), findsOneWidget);
+2
test/features/feed/presentation/home_feed_screen_test.dart
··· 16 16 import 'package:lazurite/features/feed/presentation/widgets/feed_layout_view.dart'; 17 17 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 18 18 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 19 + import 'package:lazurite/shared/presentation/widgets/app_screen_entrance.dart'; 19 20 import 'package:mocktail/mocktail.dart'; 20 21 21 22 class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} ··· 340 341 ); 341 342 await tester.pump(); 342 343 344 + expect(find.byType(AppScreenEntrance), findsOneWidget); 343 345 expect(find.byIcon(Icons.trending_up_outlined), findsOneWidget); 344 346 expect(find.byIcon(Icons.rss_feed), findsOneWidget); 345 347 expect(find.byIcon(Icons.chat_bubble_outline), findsNothing);
+2
test/features/profile/presentation/profile_screen_test.dart
··· 26 26 import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 27 27 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 28 28 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 29 + import 'package:lazurite/shared/presentation/widgets/app_screen_entrance.dart'; 29 30 import 'package:mocktail/mocktail.dart'; 30 31 31 32 class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} ··· 153 154 useLargeScreen(tester); 154 155 await tester.pumpWidget(buildSubject()); 155 156 157 + expect(find.byType(AppScreenEntrance), findsOneWidget); 156 158 verify(() => profileBloc.add(const ProfileLoadRequested(actor: 'did:plc:me'))).called(1); 157 159 verify( 158 160 () => feedBloc.add(const FeedLoadRequested(actor: 'did:plc:me', filter: FeedFilter.postsNoReplies)),
+2
test/features/search/presentation/search_screen_test.dart
··· 14 14 import 'package:lazurite/features/search/presentation/search_screen.dart'; 15 15 import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 16 16 import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; 17 + import 'package:lazurite/shared/presentation/widgets/app_screen_entrance.dart'; 17 18 import 'package:mocktail/mocktail.dart'; 18 19 19 20 class MockSearchRepository extends Mock implements SearchRepository {} ··· 164 165 await tester.pumpWidget(buildSubject()); 165 166 await tester.pumpAndSettle(); 166 167 168 + expect(find.byType(AppScreenEntrance), findsOneWidget); 167 169 final searchField = tester.widget<TextField>(find.byType(TextField).first); 168 170 expect(searchField.decoration?.hintText, 'Search posts'); 169 171 expect(find.text('Posts'), findsOneWidget);