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: Implement a new app shell with side menu navigation

+648 -112
-2
docs/BUGS.md
··· 2 2 title: Bugs 3 3 updated: 2026-03-18 4 4 --- 5 - 6 - Logging out crashes the app. It requires a restart to fix.
+1 -1
docs/tasks/phase-4.md
··· 16 16 17 17 ## M13 — Media Playback & Download 18 18 19 - - [ ] Add `photo_view`, `video_player`, `chewie`, `dio`, `gal`, `permission_handler` to `pubspec.yaml` 19 + - [x] Add `photo_view`, `video_player`, `chewie`, `dio`, `gal`, `permission_handler` to `pubspec.yaml` 20 20 - [ ] `ImageViewerScreen` — full-screen `PageView` of `PhotoView` widgets loading `fullsize` URLs with hero animation from thumbnail 21 21 - [ ] Page indicator for multi-image posts; alt text bar at the bottom of each page 22 22 - [ ] Swipe-down-to-dismiss gesture on image viewer
-2
ios/Flutter/AppFrameworkInfo.plist
··· 20 20 <string>????</string> 21 21 <key>CFBundleVersion</key> 22 22 <string>1.0</string> 23 - <key>MinimumOSVersion</key> 24 - <string>13.0</string> 25 23 </dict> 26 24 </plist>
+7 -7
ios/Podfile.lock
··· 72 72 :path: ".symlinks/plugins/workmanager/ios" 73 73 74 74 SPEC CHECKSUMS: 75 - connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d 75 + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd 76 76 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 77 - image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b 78 - path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba 79 - share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f 77 + image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 78 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 79 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a 80 80 sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921 81 - sqlite3_flutter_libs: f9114e4bbe1f2e03dd543373c53d23245982ca13 82 - url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa 83 - workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6 81 + sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab 82 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b 83 + workmanager: 01be2de7f184bd15de93a1812936a2b7f42ef07e 84 84 85 85 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e 86 86
+5 -2
ios/Runner/AppDelegate.swift
··· 2 2 import UIKit 3 3 4 4 @main 5 - @objc class AppDelegate: FlutterAppDelegate { 5 + @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { 6 6 override func application( 7 7 _ application: UIApplication, 8 8 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 9 ) -> Bool { 10 - GeneratedPluginRegistrant.register(with: self) 11 10 return super.application(application, didFinishLaunchingWithOptions: launchOptions) 11 + } 12 + 13 + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { 14 + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) 12 15 } 13 16 }
+34 -14
ios/Runner/Info.plist
··· 2 2 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 3 <plist version="1.0"> 4 4 <dict> 5 + <key>BGTaskSchedulerPermittedIdentifiers</key> 6 + <array> 7 + <string>lazurite.scheduled_post</string> 8 + </array> 9 + <key>CADisableMinimumFrameDurationOnPhone</key> 10 + <true/> 5 11 <key>CFBundleDevelopmentRegion</key> 6 12 <string>$(DEVELOPMENT_LANGUAGE)</string> 7 13 <key>CFBundleDisplayName</key> ··· 24 30 <string>$(FLUTTER_BUILD_NUMBER)</string> 25 31 <key>LSRequiresIPhoneOS</key> 26 32 <true/> 33 + <key>UIApplicationSceneManifest</key> 34 + <dict> 35 + <key>UIApplicationSupportsMultipleScenes</key> 36 + <false/> 37 + <key>UISceneConfigurations</key> 38 + <dict> 39 + <key>UIWindowSceneSessionRoleApplication</key> 40 + <array> 41 + <dict> 42 + <key>UISceneClassName</key> 43 + <string>UIWindowScene</string> 44 + <key>UISceneConfigurationName</key> 45 + <string>flutter</string> 46 + <key>UISceneDelegateClassName</key> 47 + <string>FlutterSceneDelegate</string> 48 + <key>UISceneStoryboardFile</key> 49 + <string>Main</string> 50 + </dict> 51 + </array> 52 + </dict> 53 + </dict> 54 + <key>UIApplicationSupportsIndirectInputEvents</key> 55 + <true/> 56 + <key>UIBackgroundModes</key> 57 + <array> 58 + <string>processing</string> 59 + <string>fetch</string> 60 + </array> 27 61 <key>UILaunchStoryboardName</key> 28 62 <string>LaunchScreen</string> 29 63 <key>UIMainStoryboardFile</key> ··· 40 74 <string>UIInterfaceOrientationPortraitUpsideDown</string> 41 75 <string>UIInterfaceOrientationLandscapeLeft</string> 42 76 <string>UIInterfaceOrientationLandscapeRight</string> 43 - </array> 44 - <key>CADisableMinimumFrameDurationOnPhone</key> 45 - <true/> 46 - <key>UIApplicationSupportsIndirectInputEvents</key> 47 - <true/> 48 - <!-- WorkManager / BGTaskScheduler task identifiers for scheduled posts --> 49 - <key>BGTaskSchedulerPermittedIdentifiers</key> 50 - <array> 51 - <string>lazurite.scheduled_post</string> 52 - </array> 53 - <key>UIBackgroundModes</key> 54 - <array> 55 - <string>processing</string> 56 - <string>fetch</string> 57 77 </array> 58 78 </dict> 59 79 </plist>
+4
lib/core/router/app_router.dart
··· 104 104 ), 105 105 StatefulShellRoute.indexedStack( 106 106 builder: (context, state, navigationShell) { 107 + if (!context.read<AuthBloc>().state.isAuthenticated) { 108 + return AppShell(navigationShell: navigationShell); 109 + } 110 + 107 111 UnreadCountCubit? existingCubit; 108 112 try { 109 113 existingCubit = context.read<UnreadCountCubit>();
+289 -47
lib/core/router/app_shell.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_bloc/flutter_bloc.dart'; 3 3 import 'package:go_router/go_router.dart'; 4 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 4 5 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 5 6 6 - class AppShell extends StatelessWidget { 7 + class AppShellScope extends InheritedWidget { 8 + const AppShellScope({super.key, required super.child, required this.openMenu}); 9 + 10 + final VoidCallback openMenu; 11 + 12 + static AppShellScope? maybeOf(BuildContext context) { 13 + return context.dependOnInheritedWidgetOfExactType<AppShellScope>(); 14 + } 15 + 16 + @override 17 + bool updateShouldNotify(covariant AppShellScope oldWidget) => openMenu != oldWidget.openMenu; 18 + } 19 + 20 + class AppShellMenuButton extends StatelessWidget { 21 + const AppShellMenuButton({super.key}); 22 + 23 + @override 24 + Widget build(BuildContext context) { 25 + final shellScope = AppShellScope.maybeOf(context); 26 + 27 + return IconButton(tooltip: 'Open menu', onPressed: shellScope?.openMenu, icon: const Icon(Icons.menu)); 28 + } 29 + } 30 + 31 + class AppShell extends StatefulWidget { 7 32 const AppShell({super.key, required this.navigationShell}); 8 33 9 34 final StatefulNavigationShell navigationShell; 10 35 11 36 @override 37 + State<AppShell> createState() => _AppShellState(); 38 + } 39 + 40 + class _AppShellState extends State<AppShell> { 41 + final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>(); 42 + 43 + void _openMenu() { 44 + _scaffoldKey.currentState?.openDrawer(); 45 + } 46 + 47 + @override 12 48 Widget build(BuildContext context) { 13 - return Scaffold( 14 - body: navigationShell, 15 - bottomNavigationBar: NavigationBar( 16 - height: 50, 17 - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, 18 - selectedIndex: navigationShell.currentIndex, 19 - onDestinationSelected: (index) { 20 - navigationShell.goBranch(index, initialLocation: index == navigationShell.currentIndex); 21 - }, 22 - indicatorShape: RoundedSuperellipseBorder(borderRadius: BorderRadius.circular(10)), 23 - labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, 24 - destinations: _destinations, 49 + return AppShellScope( 50 + openMenu: _openMenu, 51 + child: Scaffold( 52 + key: _scaffoldKey, 53 + drawer: _AppMenu(navigationShell: widget.navigationShell, rootContext: context), 54 + body: widget.navigationShell, 25 55 ), 26 56 ); 27 57 } 58 + } 28 59 29 - List<Widget> get _destinations => [ 30 - const NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: 'Home'), 31 - const NavigationDestination(icon: Icon(Icons.search_outlined), selectedIcon: Icon(Icons.search), label: 'Search'), 32 - NavigationDestination( 33 - icon: BlocBuilder<UnreadCountCubit, UnreadCountState>( 34 - builder: (context, state) { 35 - return Badge( 36 - isLabelVisible: state.hasUnread, 37 - label: Text(state.count > 99 ? '99+' : state.count.toString(), style: const TextStyle(fontSize: 10)), 38 - child: const Icon(Icons.notifications_outlined), 39 - ); 40 - }, 60 + class _AppMenu extends StatelessWidget { 61 + const _AppMenu({required this.navigationShell, required this.rootContext}); 62 + 63 + final StatefulNavigationShell navigationShell; 64 + final BuildContext rootContext; 65 + 66 + @override 67 + Widget build(BuildContext context) { 68 + final theme = Theme.of(context); 69 + final tokens = rootContext.watch<AuthBloc>().state.tokens; 70 + final displayName = tokens?.displayName ?? tokens?.handle ?? 'Guest'; 71 + final handle = tokens?.handle ?? 'Sign in required'; 72 + final initials = _initialsFor(tokens?.displayName ?? tokens?.handle ?? 'L'); 73 + final drawerWidth = (MediaQuery.sizeOf(context).width * 0.82).clamp(280.0, 320.0).toDouble(); 74 + 75 + return SizedBox( 76 + width: drawerWidth, 77 + child: Drawer( 78 + child: SafeArea( 79 + child: Column( 80 + children: [ 81 + Padding( 82 + padding: const EdgeInsets.fromLTRB(12, 8, 8, 8), 83 + child: Row( 84 + children: [ 85 + Text('Menu', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), 86 + const Spacer(), 87 + IconButton( 88 + tooltip: 'Close menu', 89 + onPressed: () => Navigator.of(context).pop(), 90 + icon: const Icon(Icons.close), 91 + ), 92 + ], 93 + ), 94 + ), 95 + Padding( 96 + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), 97 + child: InkWell( 98 + borderRadius: BorderRadius.circular(20), 99 + onTap: () => _runAfterClose(context, () => navigationShell.goBranch(3, initialLocation: false)), 100 + child: Ink( 101 + padding: const EdgeInsets.all(12), 102 + decoration: BoxDecoration( 103 + color: theme.colorScheme.surfaceContainerHigh, 104 + borderRadius: BorderRadius.circular(20), 105 + ), 106 + child: Row( 107 + children: [ 108 + // TODO: Add user avatar (keep initials as fallback) 109 + CircleAvatar(radius: 24, child: Text(initials)), 110 + const SizedBox(width: 12), 111 + Expanded( 112 + child: Column( 113 + crossAxisAlignment: CrossAxisAlignment.start, 114 + children: [ 115 + Text( 116 + displayName, 117 + maxLines: 1, 118 + overflow: TextOverflow.ellipsis, 119 + style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), 120 + ), 121 + const SizedBox(height: 2), 122 + Text( 123 + '@$handle', 124 + maxLines: 1, 125 + overflow: TextOverflow.ellipsis, 126 + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), 127 + ), 128 + ], 129 + ), 130 + ), 131 + const Icon(Icons.chevron_right), 132 + ], 133 + ), 134 + ), 135 + ), 136 + ), 137 + Expanded( 138 + child: ListView( 139 + padding: const EdgeInsets.symmetric(horizontal: 8), 140 + children: [ 141 + _MenuTile( 142 + icon: Icons.home_outlined, 143 + selectedIcon: Icons.home, 144 + label: 'Home', 145 + isSelected: navigationShell.currentIndex == 0, 146 + onTap: () => _selectBranch(context, 0), 147 + ), 148 + _MenuTile( 149 + icon: Icons.search_outlined, 150 + selectedIcon: Icons.search, 151 + label: 'Search', 152 + isSelected: navigationShell.currentIndex == 1, 153 + onTap: () => _selectBranch(context, 1), 154 + ), 155 + _MenuTile( 156 + icon: Icons.rss_feed_outlined, 157 + selectedIcon: Icons.rss_feed, 158 + label: 'Feeds', 159 + onTap: () => _pushRoute(context, '/feeds'), 160 + ), 161 + _MenuTile( 162 + icon: Icons.notifications_outlined, 163 + selectedIcon: Icons.notifications, 164 + label: 'Notifications', 165 + isSelected: navigationShell.currentIndex == 2, 166 + trailing: _notificationsBadge(), 167 + onTap: () => _selectBranch(context, 2), 168 + ), 169 + _MenuTile( 170 + icon: Icons.chat_bubble_outline, 171 + selectedIcon: Icons.chat_bubble, 172 + label: 'Messages', 173 + isSelected: navigationShell.currentIndex == 4, 174 + onTap: () => _selectBranch(context, 4), 175 + ), 176 + _MenuTile( 177 + icon: Icons.person_outline, 178 + selectedIcon: Icons.person, 179 + label: 'Profile', 180 + isSelected: navigationShell.currentIndex == 3, 181 + onTap: () => _selectBranch(context, 3), 182 + ), 183 + const Divider(height: 24), 184 + _MenuTile( 185 + icon: Icons.add_circle_outline, 186 + selectedIcon: Icons.add_circle, 187 + label: 'New Post', 188 + onTap: () => _pushRoute(context, '/compose'), 189 + ), 190 + _MenuTile( 191 + icon: Icons.settings_outlined, 192 + selectedIcon: Icons.settings, 193 + label: 'Settings', 194 + isSelected: navigationShell.currentIndex == 5, 195 + onTap: () => _selectBranch(context, 5), 196 + ), 197 + const Divider(height: 24), 198 + _MenuTile( 199 + icon: Icons.logout, 200 + selectedIcon: Icons.logout, 201 + label: 'Log Out', 202 + isDestructive: true, 203 + onTap: () => 204 + _runAfterClose(context, () => rootContext.read<AuthBloc>().add(const LogoutRequested())), 205 + ), 206 + ], 207 + ), 208 + ), 209 + ], 210 + ), 211 + ), 41 212 ), 42 - selectedIcon: BlocBuilder<UnreadCountCubit, UnreadCountState>( 43 - builder: (context, state) { 44 - return Badge( 45 - isLabelVisible: state.hasUnread, 46 - label: Text(state.count > 99 ? '99+' : state.count.toString(), style: const TextStyle(fontSize: 10)), 47 - child: const Icon(Icons.notifications), 48 - ); 49 - }, 213 + ); 214 + } 215 + 216 + Widget _notificationsBadge() { 217 + try { 218 + rootContext.read<UnreadCountCubit>(); 219 + } catch (_) { 220 + return const SizedBox.shrink(); 221 + } 222 + 223 + return BlocBuilder<UnreadCountCubit, UnreadCountState>( 224 + builder: (context, state) { 225 + if (!state.hasUnread) { 226 + return const SizedBox.shrink(); 227 + } 228 + 229 + return Badge( 230 + label: Text(state.count > 99 ? '99+' : state.count.toString(), style: const TextStyle(fontSize: 10)), 231 + ); 232 + }, 233 + ); 234 + } 235 + 236 + void _selectBranch(BuildContext context, int index) { 237 + _runAfterClose( 238 + context, 239 + () => navigationShell.goBranch(index, initialLocation: index == navigationShell.currentIndex), 240 + ); 241 + } 242 + 243 + void _pushRoute(BuildContext context, String location) { 244 + _runAfterClose(context, () => GoRouter.of(rootContext).push(location)); 245 + } 246 + 247 + void _runAfterClose(BuildContext context, VoidCallback action) { 248 + Navigator.of(context).pop(); 249 + WidgetsBinding.instance.addPostFrameCallback((_) { 250 + if (rootContext.mounted) { 251 + action(); 252 + } 253 + }); 254 + } 255 + 256 + String _initialsFor(String value) { 257 + final parts = value.trim().split(RegExp(r'\s+')).where((part) => part.isNotEmpty).take(2).toList(); 258 + if (parts.isEmpty) { 259 + return 'L'; 260 + } 261 + 262 + return parts.map((part) => part.characters.first.toUpperCase()).join(); 263 + } 264 + } 265 + 266 + class _MenuTile extends StatelessWidget { 267 + const _MenuTile({ 268 + required this.icon, 269 + required this.selectedIcon, 270 + required this.label, 271 + required this.onTap, 272 + this.isSelected = false, 273 + this.isDestructive = false, 274 + this.trailing, 275 + }); 276 + 277 + final IconData icon; 278 + final IconData selectedIcon; 279 + final String label; 280 + final VoidCallback onTap; 281 + final bool isSelected; 282 + final bool isDestructive; 283 + final Widget? trailing; 284 + 285 + @override 286 + Widget build(BuildContext context) { 287 + final theme = Theme.of(context); 288 + final color = isDestructive 289 + ? theme.colorScheme.error 290 + : isSelected 291 + ? theme.colorScheme.primary 292 + : theme.colorScheme.onSurface; 293 + 294 + return ListTile( 295 + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), 296 + leading: Icon(isSelected ? selectedIcon : icon, color: color), 297 + title: Text( 298 + label, 299 + style: theme.textTheme.titleMedium?.copyWith(color: color, fontWeight: FontWeight.w600), 50 300 ), 51 - label: 'Notifications', 52 - ), 53 - const NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: 'Profile'), 54 - const NavigationDestination( 55 - icon: Icon(Icons.chat_bubble_outline), 56 - selectedIcon: Icon(Icons.chat_bubble), 57 - label: 'Messages', 58 - ), 59 - const NavigationDestination( 60 - icon: Icon(Icons.settings_outlined), 61 - selectedIcon: Icon(Icons.settings), 62 - label: 'Settings', 63 - ), 64 - ]; 301 + trailing: trailing, 302 + selected: isSelected, 303 + selectedTileColor: theme.colorScheme.primaryContainer.withValues(alpha: 0.45), 304 + onTap: onTap, 305 + ); 306 + } 65 307 }
+1 -2
lib/features/auth/presentation/login_screen.dart
··· 149 149 children: [ 150 150 const Icon(Icons.bug_report_outlined), 151 151 const SizedBox(width: 8), 152 - Text('App Password Login', style: theme.textTheme.titleMedium), 153 - const Spacer(), 152 + Expanded(child: Text('App Password Login', style: theme.textTheme.titleMedium)), 154 153 TextButton( 155 154 onPressed: () { 156 155 setState(() {
+4 -2
lib/features/feed/presentation/home_feed_screen.dart
··· 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:flutter_bloc/flutter_bloc.dart'; 6 6 import 'package:go_router/go_router.dart'; 7 + import 'package:lazurite/core/router/app_shell.dart'; 7 8 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 8 9 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 9 10 import 'package:lazurite/features/feed/data/feed_repository.dart'; ··· 42 43 43 44 if (prefsState.status == FeedPreferencesStatus.error) { 44 45 return Scaffold( 45 - appBar: AppBar(title: _title), 46 + appBar: AppBar(leading: const AppShellMenuButton(), title: _title), 46 47 body: Center( 47 48 child: Column( 48 49 mainAxisAlignment: MainAxisAlignment.center, ··· 65 66 66 67 if (pinnedFeeds.isEmpty) { 67 68 return Scaffold( 68 - appBar: AppBar(title: _title), 69 + appBar: AppBar(leading: const AppShellMenuButton(), title: _title), 69 70 body: Center( 70 71 child: Padding( 71 72 padding: const EdgeInsets.all(24), ··· 93 94 94 95 return Scaffold( 95 96 appBar: AppBar( 97 + leading: const AppShellMenuButton(), 96 98 title: _title, 97 99 actions: [IconButton(icon: const Icon(Icons.rss_feed), onPressed: () => context.push('/feeds'))], 98 100 ),
+2
lib/features/messages/presentation/convo_list_screen.dart
··· 3 3 import 'package:flutter_bloc/flutter_bloc.dart'; 4 4 import 'package:go_router/go_router.dart'; 5 5 import 'package:lazurite/core/logging/app_logger.dart'; 6 + import 'package:lazurite/core/router/app_shell.dart'; 6 7 import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 7 8 import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; 8 9 import 'package:lazurite/features/messages/presentation/widgets/convo_list_item.dart'; ··· 64 65 Widget build(BuildContext context) { 65 66 return Scaffold( 66 67 appBar: AppBar( 68 + leading: const AppShellMenuButton(), 67 69 title: Text('Messages', style: Theme.of(context).textTheme.titleMedium), 68 70 bottom: TabBar( 69 71 controller: _tabController,
+2
lib/features/notifications/presentation/notifications_screen.dart
··· 1 1 import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:lazurite/core/router/app_shell.dart'; 4 5 import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; 5 6 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 6 7 import 'package:lazurite/features/notifications/presentation/widgets/notification_list_item.dart'; ··· 54 55 Widget build(BuildContext context) { 55 56 return Scaffold( 56 57 appBar: AppBar( 58 + leading: const AppShellMenuButton(), 57 59 title: _title, 58 60 actions: [TextButton(onPressed: _markAllRead, child: const Text('Mark All Read'))], 59 61 ),
+2 -1
lib/features/profile/presentation/profile_screen.dart
··· 4 4 import 'package:flutter_bloc/flutter_bloc.dart'; 5 5 import 'package:go_router/go_router.dart'; 6 6 import 'package:intl/intl.dart'; 7 + import 'package:lazurite/core/router/app_shell.dart'; 7 8 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 8 9 import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 9 10 import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; ··· 110 111 icon: const Icon(Icons.arrow_back), 111 112 onPressed: () => context.canPop() ? context.pop() : context.go('/profile'), 112 113 ) 113 - : null, 114 + : const AppShellMenuButton(), 114 115 actions: [ 115 116 IconButton(icon: const Icon(Icons.settings_outlined), onPressed: () => context.go('/settings')), 116 117 ],
+3
lib/features/search/presentation/search_screen.dart
··· 5 5 import 'package:flutter_bloc/flutter_bloc.dart'; 6 6 import 'package:go_router/go_router.dart'; 7 7 import 'package:intl/intl.dart'; 8 + import 'package:lazurite/core/router/app_shell.dart'; 8 9 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 9 10 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 10 11 ··· 129 130 ), 130 131 child: Row( 131 132 children: [ 133 + const AppShellMenuButton(), 134 + const SizedBox(width: 8), 132 135 Expanded( 133 136 child: TextField( 134 137 controller: _searchController,
+28 -24
lib/features/settings/presentation/settings_screen.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_bloc/flutter_bloc.dart'; 3 3 import 'package:go_router/go_router.dart'; 4 + import 'package:lazurite/core/router/app_shell.dart'; 4 5 import 'package:lazurite/core/theme/app_theme.dart'; 5 6 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 6 7 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; ··· 13 14 Widget build(BuildContext context) { 14 15 return Scaffold( 15 16 appBar: AppBar( 17 + leading: const AppShellMenuButton(), 16 18 title: _title(context), 17 19 actions: [ 18 - TextButton( 20 + IconButton( 21 + tooltip: 'Log Out', 19 22 onPressed: () { 20 23 context.read<AuthBloc>().add(const LogoutRequested()); 21 - context.go('/login'); 22 24 }, 23 - child: Text('Log Out', style: TextStyle(color: Theme.of(context).colorScheme.error)), 25 + icon: Icon(Icons.logout, color: Theme.of(context).colorScheme.error), 24 26 ), 25 27 ], 26 28 ), ··· 102 104 isDestructive: true, 103 105 onTap: () { 104 106 context.read<AuthBloc>().add(const LogoutRequested()); 105 - context.go('/login'); 106 107 }, 107 108 ), 108 109 const SizedBox(height: 24), ··· 144 145 padding: const EdgeInsets.all(16), 145 146 child: SizedBox( 146 147 width: double.infinity, 147 - child: SegmentedButton<_AppearanceMode>( 148 - segments: const [ 149 - ButtonSegment(value: _AppearanceMode.system, label: Text('System')), 150 - ButtonSegment(value: _AppearanceMode.light, label: Text('Light')), 151 - ButtonSegment(value: _AppearanceMode.dark, label: Text('Dark')), 152 - ], 153 - selected: {_AppearanceMode.fromState(state)}, 154 - onSelectionChanged: (selected) { 155 - final mode = selected.first; 156 - switch (mode) { 157 - case _AppearanceMode.system: 158 - settingsCubit.setUseSystemTheme(true); 159 - case _AppearanceMode.light: 160 - settingsCubit.setUseSystemTheme(false); 161 - settingsCubit.setThemeVariant(AppThemeVariant.light); 162 - case _AppearanceMode.dark: 163 - settingsCubit.setUseSystemTheme(false); 164 - settingsCubit.setThemeVariant(AppThemeVariant.dark); 165 - } 166 - }, 148 + child: SingleChildScrollView( 149 + scrollDirection: Axis.horizontal, 150 + child: SegmentedButton<_AppearanceMode>( 151 + segments: const [ 152 + ButtonSegment(value: _AppearanceMode.system, label: Text('System')), 153 + ButtonSegment(value: _AppearanceMode.light, label: Text('Light')), 154 + ButtonSegment(value: _AppearanceMode.dark, label: Text('Dark')), 155 + ], 156 + selected: {_AppearanceMode.fromState(state)}, 157 + onSelectionChanged: (selected) { 158 + final mode = selected.first; 159 + switch (mode) { 160 + case _AppearanceMode.system: 161 + settingsCubit.setUseSystemTheme(true); 162 + case _AppearanceMode.light: 163 + settingsCubit.setUseSystemTheme(false); 164 + settingsCubit.setThemeVariant(AppThemeVariant.light); 165 + case _AppearanceMode.dark: 166 + settingsCubit.setUseSystemTheme(false); 167 + settingsCubit.setThemeVariant(AppThemeVariant.dark); 168 + } 169 + }, 170 + ), 167 171 ), 168 172 ), 169 173 ),
+176
pubspec.lock
··· 201 201 url: "https://pub.dev" 202 202 source: hosted 203 203 version: "2.0.4" 204 + chewie: 205 + dependency: "direct main" 206 + description: 207 + name: chewie 208 + sha256: "44bcfc5f0dfd1de290c87c9d86a61308b3282a70b63435d5557cfd60f54a69ca" 209 + url: "https://pub.dev" 210 + source: hosted 211 + version: "1.13.0" 204 212 cli_config: 205 213 dependency: transitive 206 214 description: ··· 289 297 url: "https://pub.dev" 290 298 source: hosted 291 299 version: "3.0.7" 300 + csslib: 301 + dependency: transitive 302 + description: 303 + name: csslib 304 + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" 305 + url: "https://pub.dev" 306 + source: hosted 307 + version: "1.0.2" 292 308 cupertino_icons: 293 309 dependency: "direct main" 294 310 description: ··· 329 345 url: "https://pub.dev" 330 346 source: hosted 331 347 version: "0.4.1" 348 + dio: 349 + dependency: "direct main" 350 + description: 351 + name: dio 352 + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c 353 + url: "https://pub.dev" 354 + source: hosted 355 + version: "5.9.2" 356 + dio_web_adapter: 357 + dependency: transitive 358 + description: 359 + name: dio_web_adapter 360 + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" 361 + url: "https://pub.dev" 362 + source: hosted 363 + version: "2.1.2" 332 364 drift: 333 365 dependency: "direct main" 334 366 description: ··· 496 528 url: "https://pub.dev" 497 529 source: hosted 498 530 version: "4.0.0" 531 + gal: 532 + dependency: "direct main" 533 + description: 534 + name: gal 535 + sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee" 536 + url: "https://pub.dev" 537 + source: hosted 538 + version: "2.3.2" 499 539 glob: 500 540 dependency: transitive 501 541 description: ··· 536 576 url: "https://pub.dev" 537 577 source: hosted 538 578 version: "0.2.0" 579 + html: 580 + dependency: transitive 581 + description: 582 + name: html 583 + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" 584 + url: "https://pub.dev" 585 + source: hosted 586 + version: "0.15.6" 539 587 http: 540 588 dependency: "direct main" 541 589 description: ··· 792 840 url: "https://pub.dev" 793 841 source: hosted 794 842 version: "2.2.0" 843 + package_info_plus: 844 + dependency: transitive 845 + description: 846 + name: package_info_plus 847 + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d 848 + url: "https://pub.dev" 849 + source: hosted 850 + version: "9.0.0" 851 + package_info_plus_platform_interface: 852 + dependency: transitive 853 + description: 854 + name: package_info_plus_platform_interface 855 + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" 856 + url: "https://pub.dev" 857 + source: hosted 858 + version: "3.2.1" 795 859 path: 796 860 dependency: "direct main" 797 861 description: ··· 856 920 url: "https://pub.dev" 857 921 source: hosted 858 922 version: "2.3.0" 923 + permission_handler: 924 + dependency: "direct main" 925 + description: 926 + name: permission_handler 927 + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 928 + url: "https://pub.dev" 929 + source: hosted 930 + version: "12.0.1" 931 + permission_handler_android: 932 + dependency: transitive 933 + description: 934 + name: permission_handler_android 935 + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" 936 + url: "https://pub.dev" 937 + source: hosted 938 + version: "13.0.1" 939 + permission_handler_apple: 940 + dependency: transitive 941 + description: 942 + name: permission_handler_apple 943 + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 944 + url: "https://pub.dev" 945 + source: hosted 946 + version: "9.4.7" 947 + permission_handler_html: 948 + dependency: transitive 949 + description: 950 + name: permission_handler_html 951 + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" 952 + url: "https://pub.dev" 953 + source: hosted 954 + version: "0.1.3+5" 955 + permission_handler_platform_interface: 956 + dependency: transitive 957 + description: 958 + name: permission_handler_platform_interface 959 + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 960 + url: "https://pub.dev" 961 + source: hosted 962 + version: "4.3.0" 963 + permission_handler_windows: 964 + dependency: transitive 965 + description: 966 + name: permission_handler_windows 967 + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" 968 + url: "https://pub.dev" 969 + source: hosted 970 + version: "0.2.1" 859 971 petitparser: 860 972 dependency: transitive 861 973 description: ··· 864 976 url: "https://pub.dev" 865 977 source: hosted 866 978 version: "7.0.2" 979 + photo_view: 980 + dependency: "direct main" 981 + description: 982 + name: photo_view 983 + sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e" 984 + url: "https://pub.dev" 985 + source: hosted 986 + version: "0.15.0" 867 987 platform: 868 988 dependency: transitive 869 989 description: ··· 1221 1341 url: "https://pub.dev" 1222 1342 source: hosted 1223 1343 version: "2.2.0" 1344 + video_player: 1345 + dependency: "direct main" 1346 + description: 1347 + name: video_player 1348 + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" 1349 + url: "https://pub.dev" 1350 + source: hosted 1351 + version: "2.11.1" 1352 + video_player_android: 1353 + dependency: transitive 1354 + description: 1355 + name: video_player_android 1356 + sha256: "9862c67c4661c98f30fe707bc1a4f97d6a0faa76784f485d282668e4651a7ac3" 1357 + url: "https://pub.dev" 1358 + source: hosted 1359 + version: "2.9.4" 1360 + video_player_avfoundation: 1361 + dependency: transitive 1362 + description: 1363 + name: video_player_avfoundation 1364 + sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e 1365 + url: "https://pub.dev" 1366 + source: hosted 1367 + version: "2.9.4" 1368 + video_player_platform_interface: 1369 + dependency: transitive 1370 + description: 1371 + name: video_player_platform_interface 1372 + sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec" 1373 + url: "https://pub.dev" 1374 + source: hosted 1375 + version: "6.6.0" 1376 + video_player_web: 1377 + dependency: transitive 1378 + description: 1379 + name: video_player_web 1380 + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" 1381 + url: "https://pub.dev" 1382 + source: hosted 1383 + version: "2.4.0" 1224 1384 vm_service: 1225 1385 dependency: transitive 1226 1386 description: ··· 1229 1389 url: "https://pub.dev" 1230 1390 source: hosted 1231 1391 version: "15.0.2" 1392 + wakelock_plus: 1393 + dependency: transitive 1394 + description: 1395 + name: wakelock_plus 1396 + sha256: "8b12256f616346910c519a35606fb69b1fe0737c06b6a447c6df43888b097f39" 1397 + url: "https://pub.dev" 1398 + source: hosted 1399 + version: "1.5.1" 1400 + wakelock_plus_platform_interface: 1401 + dependency: transitive 1402 + description: 1403 + name: wakelock_plus_platform_interface 1404 + sha256: "24b84143787220a403491c2e5de0877fbbb87baf3f0b18a2a988973863db4b03" 1405 + url: "https://pub.dev" 1406 + source: hosted 1407 + version: "1.4.0" 1232 1408 watcher: 1233 1409 dependency: transitive 1234 1410 description:
+6
pubspec.yaml
··· 40 40 plugin_platform_interface: ^2.1.8 41 41 url_launcher_platform_interface: ^2.3.2 42 42 connectivity_plus: ^7.0.0 43 + photo_view: ^0.15.0 44 + video_player: ^2.11.1 45 + chewie: ^1.13.0 46 + dio: ^5.9.2 47 + gal: ^2.3.2 48 + permission_handler: ^12.0.1 43 49 44 50 dev_dependencies: 45 51 flutter_test:
+84 -8
test/core/router/app_router_test.dart
··· 1 + import 'dart:async'; 2 + 1 3 import 'package:bloc_test/bloc_test.dart'; 2 4 import 'package:bluesky/app_bsky_actor_defs.dart'; 3 5 import 'package:flutter/material.dart'; ··· 38 40 late MockSettingsCubit settingsCubit; 39 41 late MockUnreadCountCubit unreadCountCubit; 40 42 late MockNotificationRepository notificationRepository; 43 + late StreamController<AuthState> authController; 44 + late AuthState currentAuthState; 41 45 42 46 const tokens = AuthTokens( 43 47 accessToken: 'access', ··· 65 69 settingsCubit = MockSettingsCubit(); 66 70 unreadCountCubit = MockUnreadCountCubit(); 67 71 notificationRepository = MockNotificationRepository(); 72 + authController = StreamController<AuthState>.broadcast(); 73 + currentAuthState = const AuthState.authenticated(tokens); 68 74 69 - when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); 75 + when(() => authBloc.state).thenAnswer((_) => currentAuthState); 70 76 when(() => feedPreferencesCubit.state).thenReturn(const FeedPreferencesState.loaded(feeds: [])); 71 77 when(() => profileBloc.state).thenReturn(ProfileState.loaded(profile: profile)); 72 78 when(() => feedBloc.state).thenReturn( ··· 82 88 when(() => unreadCountCubit.state).thenReturn(const UnreadCountState(0)); 83 89 when(() => notificationRepository.getUnreadCount()).thenAnswer((_) async => 0); 84 90 85 - whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.authenticated(tokens)); 91 + whenListen(authBloc, authController.stream, initialState: currentAuthState); 86 92 whenListen( 87 93 feedPreferencesCubit, 88 94 const Stream<FeedPreferencesState>.empty(), ··· 111 117 whenListen(unreadCountCubit, const Stream<UnreadCountState>.empty(), initialState: const UnreadCountState(0)); 112 118 }); 113 119 120 + tearDown(() async { 121 + await authController.close(); 122 + }); 123 + 114 124 Widget buildSubject() { 115 125 return MultiBlocProvider( 116 126 providers: [ ··· 128 138 ); 129 139 } 130 140 131 - testWidgets('renders bottom navigation and switches authenticated branches', (tester) async { 141 + testWidgets('opens the side menu and switches authenticated branches', (tester) async { 142 + await tester.binding.setSurfaceSize(const Size(430, 932)); 143 + addTearDown(() => tester.binding.setSurfaceSize(null)); 144 + 132 145 await tester.pumpWidget(buildSubject()); 133 146 await tester.pumpAndSettle(); 134 147 135 - expect(find.byIcon(Icons.home), findsOneWidget); 136 - expect(find.byIcon(Icons.person_outline), findsOneWidget); 137 - expect(find.byIcon(Icons.settings_outlined), findsOneWidget); 148 + expect(find.byTooltip('Open menu'), findsOneWidget); 138 149 expect(find.text('No feeds pinned'), findsOneWidget); 139 150 140 - await tester.tap(find.byIcon(Icons.person_outline).first); 151 + await tester.tap(find.byTooltip('Open menu')); 152 + await tester.pumpAndSettle(); 153 + 154 + expect(find.text('Menu'), findsOneWidget); 155 + expect(find.text('New Post'), findsOneWidget); 156 + await tester.scrollUntilVisible(find.text('Log Out'), 200, scrollable: find.byType(Scrollable).last); 157 + expect(find.text('Log Out'), findsOneWidget); 158 + 159 + await tester.tap(find.text('Profile')); 141 160 await tester.pumpAndSettle(); 142 161 143 162 expect(find.text('River Tam'), findsOneWidget); 144 163 145 - await tester.tap(find.byIcon(Icons.settings_outlined).first); 164 + await tester.tap(find.byTooltip('Open menu')); 165 + await tester.pumpAndSettle(); 166 + 167 + await tester.tap(find.text('Settings')); 146 168 await tester.pumpAndSettle(); 147 169 148 170 expect(find.text('APPEARANCE'), findsOneWidget); 171 + }); 172 + 173 + testWidgets('redirects to login after logout without crashing on the settings branch', (tester) async { 174 + await tester.binding.setSurfaceSize(const Size(430, 932)); 175 + addTearDown(() => tester.binding.setSurfaceSize(null)); 176 + 177 + final router = AppRouter(authBloc: authBloc).router; 178 + 179 + final widget = MultiBlocProvider( 180 + providers: [ 181 + BlocProvider<AuthBloc>.value(value: authBloc), 182 + BlocProvider<FeedPreferencesCubit>.value(value: feedPreferencesCubit), 183 + BlocProvider<ProfileBloc>.value(value: profileBloc), 184 + BlocProvider<FeedBloc>.value(value: feedBloc), 185 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 186 + ], 187 + child: BlocBuilder<AuthBloc, AuthState>( 188 + builder: (context, state) { 189 + final app = MaterialApp.router(routerConfig: router); 190 + if (!state.isAuthenticated) { 191 + return app; 192 + } 193 + 194 + return MultiBlocProvider( 195 + providers: [BlocProvider<UnreadCountCubit>.value(value: unreadCountCubit)], 196 + child: RepositoryProvider<NotificationRepository>.value(value: notificationRepository, child: app), 197 + ); 198 + }, 199 + ), 200 + ); 201 + 202 + await tester.pumpWidget(widget); 203 + await tester.pumpAndSettle(); 204 + 205 + router.go('/settings'); 206 + await tester.pumpAndSettle(); 207 + 208 + expect(find.text('APPEARANCE'), findsOneWidget); 209 + 210 + await tester.tap(find.byTooltip('Log Out')); 211 + await tester.pump(); 212 + 213 + verify(() => authBloc.add(const LogoutRequested())).called(1); 214 + 215 + currentAuthState = const AuthState.unauthenticated(); 216 + authController.add(currentAuthState); 217 + 218 + await tester.pump(); 219 + await tester.pumpAndSettle(); 220 + 221 + expect(find.text('Continue with BlueSky OAuth'), findsOneWidget); 222 + expect(tester.takeException(), isNull); 223 + 224 + router.dispose(); 149 225 }); 150 226 }