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: add custom AppBar component for consistent screen headers

+456 -168
+13 -13
docs/tasks/ui-refactor.md
··· 2 2 3 3 ## M0 — Foundation & Layout Settings Persistence 4 4 5 - - [ ] Add `ui_density` and `feed_architecture` keys to Drift `settings` table 6 - - [ ] Drift migration for new settings keys 7 - - [ ] Extend `SettingsCubit` / `SettingsState` with density and feed architecture fields 8 - - [ ] `UiDensity` enum (`compact`, `standard`, `relaxed`) with padding scale factors 9 - - [ ] `FeedArchitecture` enum (`grid`, `linear`) 10 - - [ ] Theme extension or `InheritedWidget` that provides density-scaled spacing values 5 + - [x] Add `ui_density` and `feed_architecture` keys to Drift `settings` table 6 + - [x] Drift migration for new settings keys 7 + - [x] Extend `SettingsCubit` / `SettingsState` with density and feed architecture fields 8 + - [x] `UiDensity` enum (`compact`, `standard`, `relaxed`) with padding scale factors 9 + - [x] `FeedArchitecture` enum (`grid`, `linear`) 10 + - [x] Theme extension or `InheritedWidget` that provides density-scaled spacing values 11 11 12 12 ## M1 — Navigation Chrome 13 13 14 - - [ ] Custom top app bar widget replacing stock `AppBar` — hamburger, section label, avatar 15 - - [ ] Home-screen variant with inline feed switcher tabs 16 - - [ ] Navigation drawer with Messages and Settings entries 17 - - [ ] Refactor `AppShell` bottom nav: 6 tabs → 4 (Home, Search, Alerts, Profile) 18 - - [ ] Bottom nav styling: `h-80`, semi-transparent blur background, labels, filled active icon 19 - - [ ] Route updates — Messages and Settings accessible via drawer instead of bottom tabs 20 - - [ ] Tests for navigation (drawer opens, tabs switch, routes resolve) 14 + - [x] Custom top app bar widget replacing stock `AppBar` — hamburger, section label, avatar 15 + - [x] Home-screen variant with inline feed switcher tabs 16 + - [x] Navigation drawer with Messages and Settings entries 17 + - [x] Refactor `AppShell` bottom nav: 6 tabs → 4 (Home, Search, Alerts, Profile) 18 + - [x] Bottom nav styling: `h-80`, semi-transparent blur background, labels, filled active icon 19 + - [x] Route updates — Messages and Settings accessible via drawer instead of bottom tabs 20 + - [x] Tests for navigation (drawer opens, tabs switch, routes resolve) 21 21 22 22 ## M2 — Post Card Variants 23 23
+12 -12
ios/Podfile.lock
··· 99 99 :path: ".symlinks/plugins/workmanager/ios" 100 100 101 101 SPEC CHECKSUMS: 102 - connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd 102 + connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d 103 103 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 104 - gal: baecd024ebfd13c441269ca7404792a7152fde89 105 - image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 106 - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 107 - path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 108 - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d 109 - share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a 104 + gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5 105 + image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b 106 + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 107 + path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba 108 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 109 + share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f 110 110 sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921 111 - sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab 112 - url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b 113 - video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a 114 - wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 115 - workmanager: 01be2de7f184bd15de93a1812936a2b7f42ef07e 111 + sqlite3_flutter_libs: f9114e4bbe1f2e03dd543373c53d23245982ca13 112 + url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa 113 + video_player_avfoundation: 7993f492ae0bd77edaea24d9dc051d8bb2cd7c86 114 + wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e 115 + workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6 116 116 117 117 PODFILE CHECKSUM: eacb6976ee55d09dd7976888367ed4eee5f3a1bd 118 118
+34 -44
lib/core/router/app_router.dart
··· 46 46 final GlobalKey<NavigatorState> _searchNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'search'); 47 47 final GlobalKey<NavigatorState> _notificationsNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'notifications'); 48 48 final GlobalKey<NavigatorState> _profileNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'profile'); 49 - final GlobalKey<NavigatorState> _messagesNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'messages'); 50 - final GlobalKey<NavigatorState> _settingsNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'settings'); 51 49 52 50 GoRouter get router => GoRouter( 53 51 navigatorKey: _rootNavigatorKey, ··· 123 121 path: '/saved', 124 122 builder: (context, state) => SavedPostsScreen(accountDid: context.read<String>()), 125 123 ), 124 + GoRoute( 125 + path: '/messages', 126 + parentNavigatorKey: _rootNavigatorKey, 127 + builder: (context, state) => BlocProvider( 128 + create: (_) => ConvoListBloc(convoRepository: context.read<ConvoRepository>()), 129 + child: const ConvoListScreen(), 130 + ), 131 + routes: [ 132 + GoRoute( 133 + path: ':id', 134 + builder: (context, state) { 135 + final convoId = state.pathParameters['id']!; 136 + final args = state.extra as MessageThreadRouteArgs?; 137 + return BlocProvider( 138 + create: (_) => MessageBloc( 139 + convoRepository: context.read<ConvoRepository>(), 140 + currentUserDid: context.read<String>(), 141 + ), 142 + child: MessageThreadScreen(convoId: convoId, title: args?.title ?? 'Conversation'), 143 + ); 144 + }, 145 + ), 146 + ], 147 + ), 148 + GoRoute( 149 + path: '/settings', 150 + parentNavigatorKey: _rootNavigatorKey, 151 + builder: (context, state) => const SettingsScreen(), 152 + routes: [ 153 + GoRoute(path: 'about', builder: (context, state) => const AboutScreen()), 154 + GoRoute(path: 'logs', builder: (context, state) => const LogsScreen()), 155 + GoRoute(path: 'devtools', builder: (context, state) => const DevToolsScreen()), 156 + ], 157 + ), 126 158 StatefulShellRoute.indexedStack( 127 159 builder: (context, state, navigationShell) { 128 160 if (!context.read<AuthBloc>().state.isAuthenticated) { ··· 191 223 builder: (context, state) => 192 224 ProfileScreen(actor: state.uri.queryParameters['actor'], showBackButton: true), 193 225 ), 194 - ], 195 - ), 196 - ], 197 - ), 198 - StatefulShellBranch( 199 - navigatorKey: _messagesNavigatorKey, 200 - routes: [ 201 - GoRoute( 202 - path: '/messages', 203 - builder: (context, state) => BlocProvider( 204 - create: (_) => ConvoListBloc(convoRepository: context.read<ConvoRepository>()), 205 - child: const ConvoListScreen(), 206 - ), 207 - routes: [ 208 - GoRoute( 209 - path: ':id', 210 - builder: (context, state) { 211 - final convoId = state.pathParameters['id']!; 212 - final args = state.extra as MessageThreadRouteArgs?; 213 - return BlocProvider( 214 - create: (_) => MessageBloc( 215 - convoRepository: context.read<ConvoRepository>(), 216 - currentUserDid: context.read<String>(), 217 - ), 218 - child: MessageThreadScreen(convoId: convoId, title: args?.title ?? 'Conversation'), 219 - ); 220 - }, 221 - ), 222 - ], 223 - ), 224 - ], 225 - ), 226 - StatefulShellBranch( 227 - navigatorKey: _settingsNavigatorKey, 228 - routes: [ 229 - GoRoute( 230 - path: '/settings', 231 - builder: (context, state) => const SettingsScreen(), 232 - routes: [ 233 - GoRoute(path: 'about', builder: (context, state) => const AboutScreen()), 234 - GoRoute(path: 'logs', builder: (context, state) => const LogsScreen()), 235 - GoRoute(path: 'devtools', builder: (context, state) => const DevToolsScreen()), 236 226 ], 237 227 ), 238 228 ],
+37 -29
lib/core/router/app_shell.dart
··· 48 48 49 49 @override 50 50 Widget build(BuildContext context) { 51 + final theme = Theme.of(context); 51 52 return AppShellScope( 52 53 openMenu: _openMenu, 53 54 child: Scaffold( 54 55 key: _scaffoldKey, 55 56 drawer: _AppMenu(navigationShell: widget.navigationShell, rootContext: context), 56 57 body: widget.navigationShell, 57 - bottomNavigationBar: NavigationBar( 58 - height: 50, 59 - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, 60 - selectedIndex: widget.navigationShell.currentIndex, 61 - onDestinationSelected: (index) { 62 - widget.navigationShell.goBranch(index, initialLocation: index == widget.navigationShell.currentIndex); 63 - }, 64 - indicatorShape: RoundedSuperellipseBorder(borderRadius: BorderRadius.circular(10)), 65 - labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, 66 - destinations: _destinations, 58 + bottomNavigationBar: Container( 59 + decoration: BoxDecoration( 60 + color: theme.colorScheme.surface.withValues(alpha: 0.92), 61 + border: Border(top: BorderSide(color: theme.colorScheme.outlineVariant)), 62 + ), 63 + child: NavigationBar( 64 + height: 80, 65 + backgroundColor: Colors.transparent, 66 + surfaceTintColor: Colors.transparent, 67 + indicatorColor: Colors.transparent, 68 + selectedIndex: widget.navigationShell.currentIndex, 69 + onDestinationSelected: (index) { 70 + widget.navigationShell.goBranch(index, initialLocation: index == widget.navigationShell.currentIndex); 71 + }, 72 + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, 73 + destinations: _destinations, 74 + ), 67 75 ), 68 76 ), 69 77 ); 70 78 } 71 79 72 80 List<Widget> get _destinations => [ 73 - const NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: 'Home'), 74 - const NavigationDestination(icon: Icon(Icons.search_outlined), selectedIcon: Icon(Icons.search), label: 'Search'), 75 - const NavigationDestination( 76 - icon: _NotificationDestinationIcon(selected: false), 77 - selectedIcon: _NotificationDestinationIcon(selected: true), 78 - label: 'Notifications', 81 + NavigationDestination( 82 + icon: const Icon(Icons.home_outlined), 83 + selectedIcon: Transform.scale(scale: 1.15, child: const Icon(Icons.home)), 84 + label: 'HOME', 79 85 ), 80 - const NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: 'Profile'), 81 - const NavigationDestination( 82 - icon: Icon(Icons.chat_bubble_outline), 83 - selectedIcon: Icon(Icons.chat_bubble), 84 - label: 'Messages', 86 + NavigationDestination( 87 + icon: const Icon(Icons.search_outlined), 88 + selectedIcon: Transform.scale(scale: 1.15, child: const Icon(Icons.search)), 89 + label: 'SEARCH', 85 90 ), 86 - const NavigationDestination( 87 - icon: Icon(Icons.settings_outlined), 88 - selectedIcon: Icon(Icons.settings), 89 - label: 'Settings', 91 + NavigationDestination( 92 + icon: const _NotificationDestinationIcon(selected: false), 93 + selectedIcon: Transform.scale(scale: 1.15, child: const _NotificationDestinationIcon(selected: true)), 94 + label: 'ALERTS', 95 + ), 96 + NavigationDestination( 97 + icon: const Icon(Icons.person_outline), 98 + selectedIcon: Transform.scale(scale: 1.15, child: const Icon(Icons.person)), 99 + label: 'PROFILE', 90 100 ), 91 101 ]; 92 102 } ··· 229 239 icon: Icons.chat_bubble_outline, 230 240 selectedIcon: Icons.chat_bubble, 231 241 label: 'Messages', 232 - isSelected: navigationShell.currentIndex == 4, 233 - onTap: () => _selectBranch(context, 4), 242 + onTap: () => _pushRoute(context, '/messages'), 234 243 ), 235 244 _MenuTile( 236 245 icon: Icons.person_outline, ··· 250 259 icon: Icons.settings_outlined, 251 260 selectedIcon: Icons.settings, 252 261 label: 'Settings', 253 - isSelected: navigationShell.currentIndex == 5, 254 - onTap: () => _selectBranch(context, 5), 262 + onTap: () => _pushRoute(context, '/settings'), 255 263 ), 256 264 const Divider(height: 24), 257 265 _MenuTile(
+82
lib/core/widgets/lazurite_app_bar.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:lazurite/core/router/app_shell.dart'; 4 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 5 + 6 + /// Custom top app bar for the Lazurite shell screens. 7 + /// 8 + /// Shows a hamburger button on the left, a section label (uppercase, 9 + /// letterSpacing 3, labelSmall, onSurfaceVariant), and a square user 10 + /// avatar thumbnail on the right. 11 + /// 12 + /// Pass [bottom] to add an additional row below the toolbar (e.g., for the 13 + /// home-screen feed-switcher tabs). 14 + class LazuriteAppBar extends StatelessWidget implements PreferredSizeWidget { 15 + const LazuriteAppBar({super.key, required this.sectionLabel, this.bottom, this.actions}); 16 + 17 + final String sectionLabel; 18 + final PreferredSizeWidget? bottom; 19 + final List<Widget>? actions; 20 + 21 + static const double _toolbarHeight = 64; 22 + 23 + @override 24 + Size get preferredSize => Size.fromHeight(_toolbarHeight + (bottom?.preferredSize.height ?? 0)); 25 + 26 + @override 27 + Widget build(BuildContext context) { 28 + final theme = Theme.of(context); 29 + return AppBar( 30 + toolbarHeight: _toolbarHeight, 31 + backgroundColor: theme.colorScheme.surfaceContainerLowest.withValues(alpha: 0.92), 32 + surfaceTintColor: Colors.transparent, 33 + elevation: 0, 34 + scrolledUnderElevation: 0, 35 + leading: const AppShellMenuButton(), 36 + title: Text( 37 + sectionLabel.toUpperCase(), 38 + style: theme.textTheme.labelSmall?.copyWith(letterSpacing: 3, color: theme.colorScheme.onSurfaceVariant), 39 + ), 40 + centerTitle: false, 41 + titleSpacing: 0, 42 + actions: [...?actions, const _AppBarAvatar(), const SizedBox(width: 8)], 43 + bottom: bottom, 44 + shape: Border(bottom: BorderSide(color: theme.colorScheme.outlineVariant)), 45 + ); 46 + } 47 + } 48 + 49 + class _AppBarAvatar extends StatelessWidget { 50 + const _AppBarAvatar(); 51 + 52 + @override 53 + Widget build(BuildContext context) { 54 + final theme = Theme.of(context); 55 + AuthState? authState; 56 + try { 57 + authState = context.watch<AuthBloc>().state; 58 + } catch (_) { 59 + // AuthBloc not provided — show default avatar 60 + } 61 + final tokens = authState?.tokens; 62 + final initials = _initialsFor(tokens?.displayName ?? tokens?.handle ?? 'L'); 63 + 64 + return Container( 65 + width: 32, 66 + height: 32, 67 + decoration: BoxDecoration( 68 + color: theme.colorScheme.surfaceContainerHigh, 69 + border: Border.all(color: theme.colorScheme.outlineVariant), 70 + ), 71 + child: Center( 72 + child: Text(initials, style: theme.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600)), 73 + ), 74 + ); 75 + } 76 + 77 + String _initialsFor(String value) { 78 + final parts = value.trim().split(RegExp(r'\s+')).where((p) => p.isNotEmpty).take(2).toList(); 79 + if (parts.isEmpty) return 'L'; 80 + return parts.map((p) => p[0].toUpperCase()).join(); 81 + } 82 + }
+53 -50
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 + import 'package:lazurite/core/widgets/lazurite_app_bar.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'; ··· 43 43 44 44 if (prefsState.status == FeedPreferencesStatus.error) { 45 45 return Scaffold( 46 - appBar: AppBar(leading: const AppShellMenuButton(), title: _title), 46 + appBar: const LazuriteAppBar(sectionLabel: 'Home'), 47 47 body: Center( 48 48 child: Column( 49 49 mainAxisAlignment: MainAxisAlignment.center, ··· 66 66 67 67 if (pinnedFeeds.isEmpty) { 68 68 return Scaffold( 69 - appBar: AppBar(leading: const AppShellMenuButton(), title: _title), 69 + appBar: const LazuriteAppBar(sectionLabel: 'Home'), 70 70 body: Center( 71 71 child: Padding( 72 72 padding: const EdgeInsets.all(24), ··· 93 93 _syncSelectedFeed(pinnedFeeds, currentTabIndex); 94 94 95 95 return Scaffold( 96 - appBar: AppBar( 97 - leading: const AppShellMenuButton(), 98 - title: _title, 96 + appBar: LazuriteAppBar( 97 + sectionLabel: 'Home', 99 98 actions: [IconButton(icon: const Icon(Icons.rss_feed), onPressed: () => context.push('/feeds'))], 99 + bottom: _FeedTabBar( 100 + feeds: pinnedFeeds, 101 + prefsState: prefsState, 102 + currentTabIndex: currentTabIndex, 103 + onTabTapped: (index) { 104 + _pageController.animateToPage( 105 + index, 106 + duration: const Duration(milliseconds: 300), 107 + curve: Curves.easeInOut, 108 + ); 109 + setState(() => _selectedFeedId = pinnedFeeds[index].id); 110 + }, 111 + ), 100 112 ), 101 - body: Column( 102 - children: [ 103 - _buildTabBar(context, pinnedFeeds, prefsState, currentTabIndex), 104 - Expanded( 105 - child: PageView.builder( 106 - controller: _pageController, 107 - onPageChanged: (index) => setState(() => _selectedFeedId = pinnedFeeds[index].id), 108 - itemCount: pinnedFeeds.length, 109 - itemBuilder: (context, index) => 110 - _FeedListView(feed: pinnedFeeds[index], key: ValueKey(pinnedFeeds[index].id)), 111 - ), 112 - ), 113 - ], 113 + body: PageView.builder( 114 + controller: _pageController, 115 + onPageChanged: (index) => setState(() => _selectedFeedId = pinnedFeeds[index].id), 116 + itemCount: pinnedFeeds.length, 117 + itemBuilder: (context, index) => 118 + _FeedListView(feed: pinnedFeeds[index], key: ValueKey(pinnedFeeds[index].id)), 114 119 ), 115 120 floatingActionButton: FloatingActionButton( 116 121 onPressed: () => context.push('/compose'), ··· 150 155 final index = feeds.indexWhere((feed) => feed.id == _selectedFeedId); 151 156 return index >= 0 ? index : 0; 152 157 } 158 + } 159 + 160 + class _FeedTabBar extends StatelessWidget implements PreferredSizeWidget { 161 + const _FeedTabBar({ 162 + required this.feeds, 163 + required this.prefsState, 164 + required this.currentTabIndex, 165 + required this.onTabTapped, 166 + }); 153 167 154 - Widget get _title => Text('Home', style: Theme.of(context).textTheme.titleLarge); 168 + final List<SavedFeed> feeds; 169 + final FeedPreferencesState prefsState; 170 + final int currentTabIndex; 171 + final ValueChanged<int> onTabTapped; 155 172 156 - Widget _buildTabBar( 157 - BuildContext context, 158 - List<SavedFeed> feeds, 159 - FeedPreferencesState prefsState, 160 - int currentTabIndex, 161 - ) { 162 - return Container( 163 - decoration: BoxDecoration( 164 - border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), 165 - ), 173 + @override 174 + Size get preferredSize => const Size.fromHeight(44); 175 + 176 + @override 177 + Widget build(BuildContext context) { 178 + final theme = Theme.of(context); 179 + return SizedBox( 180 + height: preferredSize.height, 166 181 child: SingleChildScrollView( 167 182 scrollDirection: Axis.horizontal, 168 183 child: Row( ··· 170 185 final index = entry.key; 171 186 final feed = entry.value; 172 187 final isSelected = currentTabIndex == index; 173 - 174 188 return GestureDetector( 175 - onTap: () { 176 - _pageController.animateToPage( 177 - index, 178 - duration: const Duration(milliseconds: 300), 179 - curve: Curves.easeInOut, 180 - ); 181 - setState(() => _selectedFeedId = feed.id); 182 - }, 189 + onTap: () => onTabTapped(index), 183 190 child: Container( 184 - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), 191 + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), 185 192 decoration: BoxDecoration( 186 193 border: Border( 187 - bottom: BorderSide( 188 - color: isSelected ? Theme.of(context).colorScheme.primary : Colors.transparent, 189 - width: 2, 190 - ), 194 + bottom: BorderSide(color: isSelected ? theme.colorScheme.primary : Colors.transparent, width: 2), 191 195 ), 192 196 ), 193 197 child: Text( 194 - prefsState.displayNameFor(feed), 195 - style: Theme.of(context).textTheme.bodyLarge?.copyWith( 196 - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, 197 - color: isSelected 198 - ? Theme.of(context).colorScheme.onSurface 199 - : Theme.of(context).colorScheme.onSurfaceVariant, 198 + prefsState.displayNameFor(feed).toUpperCase(), 199 + style: theme.textTheme.labelSmall?.copyWith( 200 + letterSpacing: 1.0, 201 + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, 202 + color: isSelected ? theme.colorScheme.onSurface : theme.colorScheme.onSurfaceVariant, 200 203 ), 201 204 ), 202 205 ),
+3 -6
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 + import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; 5 5 import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; 6 6 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 7 7 import 'package:lazurite/features/notifications/presentation/widgets/notification_list_item.dart'; ··· 49 49 context.read<UnreadCountCubit>().refresh(); 50 50 } 51 51 52 - Widget get _title => Text('Notifications', style: Theme.of(context).textTheme.titleMedium); 53 - 54 52 @override 55 53 Widget build(BuildContext context) { 56 54 return Scaffold( 57 - appBar: AppBar( 58 - leading: const AppShellMenuButton(), 59 - title: _title, 55 + appBar: LazuriteAppBar( 56 + sectionLabel: 'Alerts', 60 57 actions: [TextButton(onPressed: _markAllRead, child: const Text('Mark All Read'))], 61 58 ), 62 59 body: BlocBuilder<NotificationBloc, NotificationState>(
+20 -12
pubspec.lock
··· 181 181 dependency: "direct main" 182 182 description: 183 183 name: characters 184 - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b 184 + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 185 185 url: "https://pub.dev" 186 186 source: hosted 187 - version: "1.4.1" 187 + version: "1.4.0" 188 188 charcode: 189 189 dependency: transitive 190 190 description: ··· 688 688 url: "https://pub.dev" 689 689 source: hosted 690 690 version: "1.0.5" 691 + js: 692 + dependency: transitive 693 + description: 694 + name: js 695 + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" 696 + url: "https://pub.dev" 697 + source: hosted 698 + version: "0.7.2" 691 699 json_annotation: 692 700 dependency: "direct main" 693 701 description: ··· 756 764 dependency: transitive 757 765 description: 758 766 name: matcher 759 - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 767 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 760 768 url: "https://pub.dev" 761 769 source: hosted 762 - version: "0.12.19" 770 + version: "0.12.17" 763 771 material_color_utilities: 764 772 dependency: transitive 765 773 description: 766 774 name: material_color_utilities 767 - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" 775 + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec 768 776 url: "https://pub.dev" 769 777 source: hosted 770 - version: "0.13.0" 778 + version: "0.11.1" 771 779 meta: 772 780 dependency: transitive 773 781 description: ··· 1209 1217 dependency: transitive 1210 1218 description: 1211 1219 name: test 1212 - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" 1220 + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" 1213 1221 url: "https://pub.dev" 1214 1222 source: hosted 1215 - version: "1.30.0" 1223 + version: "1.26.3" 1216 1224 test_api: 1217 1225 dependency: transitive 1218 1226 description: 1219 1227 name: test_api 1220 - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" 1228 + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 1221 1229 url: "https://pub.dev" 1222 1230 source: hosted 1223 - version: "0.7.10" 1231 + version: "0.7.7" 1224 1232 test_core: 1225 1233 dependency: transitive 1226 1234 description: 1227 1235 name: test_core 1228 - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" 1236 + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" 1229 1237 url: "https://pub.dev" 1230 1238 source: hosted 1231 - version: "0.6.16" 1239 + version: "0.6.12" 1232 1240 typed_data: 1233 1241 dependency: transitive 1234 1242 description:
+77 -1
test/core/router/app_router_test.dart
··· 170 170 expect(find.text('APPEARANCE'), findsOneWidget); 171 171 }); 172 172 173 - testWidgets('redirects to login after logout without crashing on the settings branch', (tester) async { 173 + testWidgets('bottom navigation bar shows 4 tabs with uppercase labels', (tester) async { 174 + await tester.binding.setSurfaceSize(const Size(430, 932)); 175 + addTearDown(() => tester.binding.setSurfaceSize(null)); 176 + 177 + await tester.pumpWidget(buildSubject()); 178 + await tester.pumpAndSettle(); 179 + 180 + final navBar = tester.widget<NavigationBar>(find.byType(NavigationBar)); 181 + expect(navBar.destinations.length, 4); 182 + 183 + // Labels appear in the nav bar (HOME also appears in the AppBar section label) 184 + final destinations = navBar.destinations.cast<NavigationDestination>(); 185 + expect(destinations.map((d) => d.label), containsAll(['HOME', 'SEARCH', 'ALERTS', 'PROFILE'])); 186 + 187 + // Messages and Settings are no longer in the bottom nav 188 + expect(destinations.any((d) => d.label == 'MESSAGES'), isFalse); 189 + expect(destinations.any((d) => d.label == 'SETTINGS'), isFalse); 190 + }); 191 + 192 + testWidgets('bottom navigation bar height is 80', (tester) async { 193 + await tester.binding.setSurfaceSize(const Size(430, 932)); 194 + addTearDown(() => tester.binding.setSurfaceSize(null)); 195 + 196 + await tester.pumpWidget(buildSubject()); 197 + await tester.pumpAndSettle(); 198 + 199 + final navBar = tester.widget<NavigationBar>(find.byType(NavigationBar)); 200 + expect(navBar.height, 80); 201 + }); 202 + 203 + testWidgets('drawer contains Messages and Settings entries', (tester) async { 204 + await tester.binding.setSurfaceSize(const Size(430, 932)); 205 + addTearDown(() => tester.binding.setSurfaceSize(null)); 206 + 207 + await tester.pumpWidget(buildSubject()); 208 + await tester.pumpAndSettle(); 209 + 210 + await tester.tap(find.byTooltip('Open menu')); 211 + await tester.pumpAndSettle(); 212 + 213 + expect(find.text('Messages'), findsOneWidget); 214 + expect(find.text('Settings'), findsOneWidget); 215 + }); 216 + 217 + testWidgets('tapping bottom nav tabs switches active branch', (tester) async { 218 + await tester.binding.setSurfaceSize(const Size(430, 932)); 219 + addTearDown(() => tester.binding.setSurfaceSize(null)); 220 + 221 + await tester.pumpWidget(buildSubject()); 222 + await tester.pumpAndSettle(); 223 + 224 + // Start on home branch (index 0) 225 + final navBar = tester.widget<NavigationBar>(find.byType(NavigationBar)); 226 + expect(navBar.selectedIndex, 0); 227 + 228 + // Tap PROFILE tab (index 3) 229 + await tester.tap(find.text('PROFILE')); 230 + await tester.pumpAndSettle(); 231 + 232 + final navBarAfter = tester.widget<NavigationBar>(find.byType(NavigationBar)); 233 + expect(navBarAfter.selectedIndex, 3); 234 + expect(find.text('River Tam'), findsOneWidget); 235 + }); 236 + 237 + testWidgets('LazuriteAppBar shows section label and hamburger on home screen', (tester) async { 238 + await tester.binding.setSurfaceSize(const Size(430, 932)); 239 + addTearDown(() => tester.binding.setSurfaceSize(null)); 240 + 241 + await tester.pumpWidget(buildSubject()); 242 + await tester.pumpAndSettle(); 243 + 244 + // Home screen uses LazuriteAppBar with sectionLabel 'Home' — 'HOME' appears at least once 245 + expect(find.text('HOME'), findsAtLeastNWidgets(1)); 246 + expect(find.byTooltip('Open menu'), findsOneWidget); 247 + }); 248 + 249 + testWidgets('redirects to login after logout without crashing on the settings route', (tester) async { 174 250 await tester.binding.setSurfaceSize(const Size(430, 932)); 175 251 addTearDown(() => tester.binding.setSurfaceSize(null)); 176 252
+125
test/core/widgets/lazurite_app_bar_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/core/router/app_shell.dart'; 6 + import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; 7 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 8 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 9 + import 'package:mocktail/mocktail.dart'; 10 + 11 + class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 12 + 13 + void main() { 14 + late MockAuthBloc authBloc; 15 + 16 + const tokens = AuthTokens( 17 + accessToken: 'access', 18 + refreshToken: 'refresh', 19 + did: 'did:plc:test', 20 + handle: 'test.bsky.social', 21 + displayName: 'River Tam', 22 + ); 23 + 24 + setUp(() { 25 + authBloc = MockAuthBloc(); 26 + when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); 27 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.authenticated(tokens)); 28 + }); 29 + 30 + Widget buildSubject({required String sectionLabel, PreferredSizeWidget? bottom, List<Widget>? actions}) { 31 + return BlocProvider<AuthBloc>.value( 32 + value: authBloc, 33 + child: MaterialApp( 34 + home: Scaffold( 35 + appBar: LazuriteAppBar(sectionLabel: sectionLabel, bottom: bottom, actions: actions), 36 + body: const SizedBox.shrink(), 37 + ), 38 + ), 39 + ); 40 + } 41 + 42 + testWidgets('renders section label in uppercase', (tester) async { 43 + await tester.pumpWidget(buildSubject(sectionLabel: 'Home')); 44 + await tester.pumpAndSettle(); 45 + 46 + expect(find.text('HOME'), findsOneWidget); 47 + }); 48 + 49 + testWidgets('renders hamburger menu button', (tester) async { 50 + await tester.pumpWidget(buildSubject(sectionLabel: 'Search')); 51 + await tester.pumpAndSettle(); 52 + 53 + expect(find.byType(AppShellMenuButton), findsOneWidget); 54 + }); 55 + 56 + testWidgets('renders user initials in avatar from displayName', (tester) async { 57 + await tester.pumpWidget(buildSubject(sectionLabel: 'Home')); 58 + await tester.pumpAndSettle(); 59 + 60 + // 'River Tam' → initials 'RT' 61 + expect(find.text('RT'), findsOneWidget); 62 + }); 63 + 64 + testWidgets('renders initials from handle when displayName is absent', (tester) async { 65 + authBloc = MockAuthBloc(); 66 + const noDisplayName = AuthTokens( 67 + accessToken: 'access', 68 + refreshToken: 'refresh', 69 + did: 'did:plc:test', 70 + handle: 'alice.bsky.social', 71 + displayName: null, 72 + ); 73 + when(() => authBloc.state).thenReturn(const AuthState.authenticated(noDisplayName)); 74 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.authenticated(noDisplayName)); 75 + 76 + await tester.pumpWidget(buildSubject(sectionLabel: 'Home')); 77 + await tester.pumpAndSettle(); 78 + 79 + // handle 'alice.bsky.social' → first part 'alice.bsky.social' → 'A' 80 + expect(find.text('A'), findsOneWidget); 81 + }); 82 + 83 + testWidgets('renders additional actions alongside avatar', (tester) async { 84 + await tester.pumpWidget( 85 + buildSubject( 86 + sectionLabel: 'Alerts', 87 + actions: [TextButton(onPressed: () {}, child: const Text('Mark All Read'))], 88 + ), 89 + ); 90 + await tester.pumpAndSettle(); 91 + 92 + expect(find.text('Mark All Read'), findsOneWidget); 93 + expect(find.text('ALERTS'), findsOneWidget); 94 + }); 95 + 96 + testWidgets('preferred size height is 64 without bottom widget', (tester) async { 97 + const bar = LazuriteAppBar(sectionLabel: 'Home'); 98 + expect(bar.preferredSize.height, 64); 99 + }); 100 + 101 + testWidgets('preferred size height includes bottom widget height', (tester) async { 102 + const bottom = PreferredSize(preferredSize: Size.fromHeight(44), child: SizedBox(height: 44)); 103 + const bar = LazuriteAppBar(sectionLabel: 'Home', bottom: bottom); 104 + expect(bar.preferredSize.height, 108); 105 + }); 106 + 107 + testWidgets('renders bottom widget when provided', (tester) async { 108 + const bottom = PreferredSize(preferredSize: Size.fromHeight(44), child: Text('bottom-content')); 109 + await tester.pumpWidget(buildSubject(sectionLabel: 'Home', bottom: bottom)); 110 + await tester.pumpAndSettle(); 111 + 112 + expect(find.text('bottom-content'), findsOneWidget); 113 + }); 114 + 115 + testWidgets('unauthenticated state shows default L initial', (tester) async { 116 + authBloc = MockAuthBloc(); 117 + when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); 118 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.unauthenticated()); 119 + 120 + await tester.pumpWidget(buildSubject(sectionLabel: 'Home')); 121 + await tester.pumpAndSettle(); 122 + 123 + expect(find.text('L'), findsOneWidget); 124 + }); 125 + }
-1
test/features/notifications/presentation/notifications_screen_test.dart
··· 71 71 await tester.pumpWidget(buildSubject()); 72 72 await tester.pumpAndSettle(); 73 73 74 - expect(find.text('Notifications'), findsOneWidget); 75 74 expect(find.byType(ListView), findsOneWidget); 76 75 }); 77 76