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

feat: main notifications screen

Changed files
+1351 -18
doc
lib
test
+2 -2
doc/roadmap.txt
··· 23 23 - [x] NotificationsNotifier: 24 24 - AsyncNotifier with pagination support 25 25 - refresh() and loadMore() methods 26 - - [ ] NotificationsScreen: 26 + - [x] NotificationsScreen: 27 27 - list display with pull-to-refresh 28 28 - infinite scroll with cursor-based pagination 29 29 - tap navigation to thread/profile 30 - - [ ] Widgets: 30 + - [x] Widgets: 31 31 - NotificationListItem (actor, type icon, content preview, timestamp) 32 32 - NotificationTypeIcon (like, repost, follow, mention, reply, quote) 33 33
+153 -4
lib/src/features/notifications/presentation/notifications_screen.dart
··· 1 + import 'package:flutter/cupertino.dart'; 1 2 import 'package:flutter/material.dart'; 3 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 + import 'package:lazurite/src/core/animations/animation_utils.dart'; 5 + import 'package:lazurite/src/core/widgets/error_view.dart'; 6 + import 'package:lazurite/src/core/widgets/loading_view.dart'; 7 + import 'package:lazurite/src/core/widgets/pull_to_refresh_wrapper.dart'; 8 + import 'package:lazurite/src/features/auth/application/auth_providers.dart'; 9 + import 'package:lazurite/src/features/auth/domain/auth_state.dart'; 10 + import 'package:lazurite/src/features/notifications/application/notifications_notifier.dart'; 2 11 3 - /// Notifications screen placeholder for the notifications tab. 4 - class NotificationsScreen extends StatelessWidget { 12 + import 'widgets/notification_list_item.dart'; 13 + 14 + /// Notifications screen displaying the user's notifications. 15 + /// 16 + /// Features: 17 + /// - Pull-to-refresh to fetch new notifications 18 + /// - Infinite scroll with cursor-based pagination 19 + /// - Loading, error, and empty states 20 + /// - Tap navigation to thread or profile 21 + class NotificationsScreen extends ConsumerStatefulWidget { 5 22 const NotificationsScreen({super.key}); 6 23 7 24 @override 25 + ConsumerState<NotificationsScreen> createState() => _NotificationsScreenState(); 26 + } 27 + 28 + class _NotificationsScreenState extends ConsumerState<NotificationsScreen> { 29 + final ScrollController _scrollController = ScrollController(); 30 + bool _hasTriggeredInitialLoad = false; 31 + 32 + @override 33 + void initState() { 34 + super.initState(); 35 + _scrollController.addListener(_onScroll); 36 + } 37 + 38 + @override 39 + void dispose() { 40 + _scrollController.dispose(); 41 + super.dispose(); 42 + } 43 + 44 + void _onScroll() { 45 + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { 46 + ref.read(notificationsProvider.notifier).loadMore(); 47 + } 48 + } 49 + 50 + @override 8 51 Widget build(BuildContext context) { 52 + final authState = ref.watch(authProvider); 53 + final isAuthenticated = authState is AuthStateAuthenticated; 54 + 55 + if (!isAuthenticated) { 56 + return _buildUnauthenticatedState(context); 57 + } 58 + 59 + final notificationsAsync = ref.watch(notificationsProvider); 60 + 61 + if (!_hasTriggeredInitialLoad) { 62 + _hasTriggeredInitialLoad = true; 63 + Future.microtask(() { 64 + if (mounted) { 65 + ref.read(notificationsProvider.notifier).refresh(); 66 + } 67 + }); 68 + } 69 + 70 + return Scaffold( 71 + body: AnimatedContentSwitcher( 72 + child: notificationsAsync.when( 73 + data: (notifications) { 74 + if (notifications.isEmpty) { 75 + return _buildEmptyState(context); 76 + } 77 + 78 + return PullToRefreshWrapper( 79 + key: const ValueKey('notifications_list'), 80 + onRefresh: () async { 81 + await ref.read(notificationsProvider.notifier).refresh(); 82 + }, 83 + child: CustomScrollView( 84 + controller: _scrollController, 85 + physics: const AlwaysScrollableScrollPhysics(), 86 + slivers: [ 87 + SliverAppBar( 88 + title: const Text('Notifications'), 89 + floating: true, 90 + snap: true, 91 + actions: [ 92 + IconButton( 93 + icon: const Icon(Icons.done_all), 94 + tooltip: 'Mark all as read', 95 + onPressed: () { 96 + ref.read(notificationsProvider.notifier).markAllAsRead(); 97 + }, 98 + ), 99 + ], 100 + ), 101 + SliverList( 102 + delegate: SliverChildBuilderDelegate((context, index) { 103 + return AnimatedItem( 104 + index: index, 105 + child: NotificationListItem(notification: notifications[index]), 106 + ); 107 + }, childCount: notifications.length), 108 + ), 109 + const SliverPadding(padding: EdgeInsets.only(bottom: 80)), 110 + ], 111 + ), 112 + ); 113 + }, 114 + loading: () => const LoadingView(key: ValueKey('loading')), 115 + error: (err, stack) => ErrorView( 116 + key: const ValueKey('error'), 117 + title: 'Failed to load notifications', 118 + message: err.toString(), 119 + onRetry: () => ref.read(notificationsProvider.notifier).refresh(), 120 + ), 121 + ), 122 + ), 123 + ); 124 + } 125 + 126 + Widget _buildUnauthenticatedState(BuildContext context) { 9 127 final theme = Theme.of(context); 10 128 11 129 return Scaffold( ··· 14 132 child: Column( 15 133 mainAxisAlignment: MainAxisAlignment.center, 16 134 children: [ 17 - Icon(Icons.notifications_outlined, size: 64, color: theme.colorScheme.primary), 135 + Icon(CupertinoIcons.bell, size: 64, color: theme.colorScheme.primary), 18 136 const SizedBox(height: 16), 19 137 Text('Notifications', style: theme.textTheme.headlineSmall), 20 138 const SizedBox(height: 8), 21 139 Text( 22 - 'Your notifications will appear here', 140 + 'Sign in to see your notifications', 23 141 style: theme.textTheme.bodyMedium?.copyWith( 24 142 color: theme.colorScheme.onSurface.withAlpha(153), 25 143 ), 144 + ), 145 + ], 146 + ), 147 + ), 148 + ); 149 + } 150 + 151 + Widget _buildEmptyState(BuildContext context) { 152 + final theme = Theme.of(context); 153 + 154 + return Scaffold( 155 + appBar: AppBar(title: const Text('Notifications')), 156 + body: Center( 157 + child: Column( 158 + mainAxisAlignment: MainAxisAlignment.center, 159 + children: [ 160 + Icon(CupertinoIcons.bell, size: 64, color: theme.colorScheme.onSurface.withAlpha(102)), 161 + const SizedBox(height: 16), 162 + Text( 163 + 'No notifications yet', 164 + style: theme.textTheme.titleMedium?.copyWith( 165 + color: theme.colorScheme.onSurface.withAlpha(153), 166 + ), 167 + ), 168 + const SizedBox(height: 8), 169 + Text( 170 + 'When someone interacts with you, it will show up here', 171 + style: theme.textTheme.bodyMedium?.copyWith( 172 + color: theme.colorScheme.onSurface.withAlpha(102), 173 + ), 174 + textAlign: TextAlign.center, 26 175 ), 27 176 ], 28 177 ),
+122
lib/src/features/notifications/presentation/widgets/notification_list_item.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:go_router/go_router.dart'; 3 + 4 + import '../../../../core/utils/date_formatter.dart'; 5 + import '../../../../core/widgets/avatar.dart'; 6 + import '../../domain/notification.dart'; 7 + import '../../domain/notification_type.dart'; 8 + import 'notification_type_icon.dart'; 9 + 10 + /// A list item widget for displaying a single notification. 11 + /// 12 + /// Displays the actor's avatar, display name, notification type, timestamp, 13 + /// and provides tap navigation to the related content. 14 + class NotificationListItem extends StatelessWidget { 15 + const NotificationListItem({required this.notification, this.onTap, super.key}); 16 + 17 + /// The notification to display. 18 + final AppNotification notification; 19 + 20 + /// Optional tap callback. If not provided, navigates to thread or profile. 21 + final VoidCallback? onTap; 22 + 23 + @override 24 + Widget build(BuildContext context) { 25 + final theme = Theme.of(context); 26 + final colorScheme = theme.colorScheme; 27 + 28 + final backgroundColor = notification.isRead ? null : colorScheme.surfaceContainerHighest; 29 + 30 + return Card( 31 + clipBehavior: Clip.antiAlias, 32 + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), 33 + elevation: 1, 34 + color: backgroundColor, 35 + child: InkWell( 36 + onTap: onTap ?? () => _handleTap(context), 37 + child: Padding( 38 + padding: const EdgeInsets.all(12), 39 + child: Row( 40 + crossAxisAlignment: CrossAxisAlignment.start, 41 + children: [ 42 + GestureDetector( 43 + onTap: () => _navigateToProfile(context), 44 + child: Avatar(imageUrl: notification.actor.avatar, radius: 20), 45 + ), 46 + const SizedBox(width: 12), 47 + Expanded( 48 + child: Column( 49 + crossAxisAlignment: CrossAxisAlignment.start, 50 + children: [ 51 + Row( 52 + children: [ 53 + Flexible( 54 + child: Text( 55 + notification.actor.displayName ?? notification.actor.handle, 56 + style: theme.textTheme.bodyMedium?.copyWith( 57 + fontWeight: FontWeight.bold, 58 + ), 59 + overflow: TextOverflow.ellipsis, 60 + ), 61 + ), 62 + const SizedBox(width: 4), 63 + Flexible( 64 + child: Text( 65 + '@${notification.actor.handle}', 66 + style: theme.textTheme.bodySmall?.copyWith( 67 + color: colorScheme.onSurfaceVariant, 68 + ), 69 + overflow: TextOverflow.ellipsis, 70 + ), 71 + ), 72 + ], 73 + ), 74 + const SizedBox(height: 4), 75 + Row( 76 + children: [ 77 + NotificationTypeIcon(type: notification.type, size: 16), 78 + const SizedBox(width: 6), 79 + Expanded( 80 + child: Text( 81 + notification.type.displayText, 82 + style: theme.textTheme.bodyMedium?.copyWith( 83 + color: colorScheme.onSurfaceVariant, 84 + ), 85 + ), 86 + ), 87 + ], 88 + ), 89 + ], 90 + ), 91 + ), 92 + const SizedBox(width: 8), 93 + Text( 94 + DateFormatter.formatRelative(notification.indexedAt), 95 + style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 96 + ), 97 + ], 98 + ), 99 + ), 100 + ), 101 + ); 102 + } 103 + 104 + void _handleTap(BuildContext context) { 105 + if (notification.type == NotificationType.follow) { 106 + _navigateToProfile(context); 107 + return; 108 + } 109 + 110 + if (notification.reasonSubjectUri != null) { 111 + final encodedUri = Uri.encodeComponent(notification.reasonSubjectUri!); 112 + GoRouter.of(context).push('/home/t/$encodedUri'); 113 + } else { 114 + _navigateToProfile(context); 115 + } 116 + } 117 + 118 + void _navigateToProfile(BuildContext context) { 119 + final encodedDid = Uri.encodeComponent(notification.actor.did); 120 + GoRouter.of(context).push('/home/u/$encodedDid'); 121 + } 122 + }
+44
lib/src/features/notifications/presentation/widgets/notification_type_icon.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + import '../../domain/notification_type.dart'; 4 + 5 + /// Widget that displays an icon for a notification type. 6 + /// 7 + /// Maps each [NotificationType] to a specific Material icon with 8 + /// an appropriate color to help users quickly identify notification types. 9 + class NotificationTypeIcon extends StatelessWidget { 10 + const NotificationTypeIcon({required this.type, this.size = 20, super.key}); 11 + 12 + /// The notification type to display an icon for. 13 + final NotificationType type; 14 + 15 + /// The size of the icon. 16 + final double size; 17 + 18 + @override 19 + Widget build(BuildContext context) { 20 + final (icon, color) = _getIconAndColor(context); 21 + return Icon(icon, size: size, color: color); 22 + } 23 + 24 + (IconData, Color) _getIconAndColor(BuildContext context) { 25 + final colorScheme = Theme.of(context).colorScheme; 26 + 27 + switch (type) { 28 + case NotificationType.like: 29 + return (Icons.favorite, Colors.pink); 30 + case NotificationType.repost: 31 + return (Icons.repeat, Colors.green); 32 + case NotificationType.follow: 33 + return (Icons.person_add, colorScheme.primary); 34 + case NotificationType.mention: 35 + return (Icons.alternate_email, Colors.purple); 36 + case NotificationType.reply: 37 + return (Icons.reply, colorScheme.onSurfaceVariant); 38 + case NotificationType.quote: 39 + return (Icons.format_quote, Colors.teal); 40 + case NotificationType.starterpackJoined: 41 + return (Icons.group_add, Colors.amber); 42 + } 43 + } 44 + }
+1 -1
pubspec.lock
··· 1438 1438 source: hosted 1439 1439 version: "4.10.11" 1440 1440 webview_flutter_platform_interface: 1441 - dependency: transitive 1441 + dependency: "direct dev" 1442 1442 description: 1443 1443 name: webview_flutter_platform_interface 1444 1444 sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
+1
pubspec.yaml
··· 90 90 fake_async: ^1.3.3 91 91 image_picker_platform_interface: ^2.9.0 92 92 plugin_platform_interface: ^2.1.0 93 + webview_flutter_platform_interface: ^2.10.0 93 94 94 95 flutter: 95 96 uses-material-design: true
+13 -1
test/src/app/router_test.dart
··· 21 21 import 'package:lazurite/src/features/feeds/application/feed_content_providers.dart'; 22 22 import 'package:lazurite/src/features/feeds/application/feed_providers.dart'; 23 23 import 'package:lazurite/src/features/feeds/application/feed_sync_controller.dart'; 24 + import 'package:lazurite/src/features/notifications/application/notifications_providers.dart'; 24 25 import 'package:lazurite/src/features/profile/application/profile_providers.dart'; 25 26 import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart'; 26 27 import 'package:lazurite/src/features/search/application/search_providers.dart'; ··· 38 39 late MockProfileRepository mockProfileRepository; 39 40 late MockAppDatabase mockDatabase; 40 41 late MockFeedContentRepository mockFeedContentRepository; 42 + late MockNotificationsRepository mockNotificationsRepository; 41 43 late Session testSession; 42 44 43 45 setUp(() { ··· 46 48 mockProfileRepository = MockProfileRepository(); 47 49 mockDatabase = MockAppDatabase(); 48 50 mockFeedContentRepository = MockFeedContentRepository(); 51 + mockNotificationsRepository = MockNotificationsRepository(); 52 + when( 53 + () => mockNotificationsRepository.watchNotifications(), 54 + ).thenAnswer((_) => Stream.value([])); 55 + when(() => mockNotificationsRepository.getCursor()).thenAnswer((_) async => null); 56 + when( 57 + () => mockNotificationsRepository.fetchNotifications(cursor: any(named: 'cursor')), 58 + ).thenAnswer((_) async {}); 59 + when(() => mockNotificationsRepository.fetchNotifications()).thenAnswer((_) async {}); 49 60 testSession = Session( 50 61 did: 'did:web:test', 51 62 handle: 'handle', ··· 100 111 activeFeedProvider.overrideWith(() => MockActiveFeed()), 101 112 draftsProvider.overrideWith((ref) => Stream.value([])), 102 113 lazurite_anim.animationControllerProvider.overrideWith(MockAnimationController.new), 114 + notificationsRepositoryProvider.overrideWithValue(mockNotificationsRepository), 103 115 ]; 104 116 } 105 117 ··· 189 201 190 202 await tester.tap(find.text('Notifications')); 191 203 await tester.pumpAndSettle(); 192 - expect(find.text('Your notifications will appear here'), findsOneWidget); 204 + expect(find.text('No notifications yet'), findsOneWidget); 193 205 194 206 await tester.tap(find.text('Home')); 195 207 await tester.pumpAndSettle();
+226
test/src/features/auth/presentation/oauth_webview_screen_test.dart
··· 1 + import 'package:flutter/material.dart'; 1 2 import 'package:flutter_test/flutter_test.dart'; 2 3 import 'package:lazurite/src/features/auth/presentation/oauth_webview_screen.dart'; 4 + import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; 3 5 4 6 void main() { 5 7 group('OAuthWebViewScreen', () { 6 8 const testAuthorizeUrl = 'https://bsky.social/oauth/authorize?request_uri=test'; 7 9 const testCallbackPrefix = 'http://127.0.0.1:8080/callback'; 8 10 11 + setUpAll(() { 12 + WebViewPlatform.instance = _FakeWebViewPlatform(); 13 + }); 14 + 9 15 test('constructs with required parameters', () { 10 16 const screen = OAuthWebViewScreen( 11 17 authorizeUrl: testAuthorizeUrl, ··· 15 21 expect(screen.authorizeUrl, testAuthorizeUrl); 16 22 expect(screen.callbackUrlPrefix, testCallbackPrefix); 17 23 }); 24 + 25 + testWidgets('renders scaffold with AppBar', (tester) async { 26 + await tester.pumpWidget( 27 + const MaterialApp( 28 + home: OAuthWebViewScreen( 29 + authorizeUrl: testAuthorizeUrl, 30 + callbackUrlPrefix: testCallbackPrefix, 31 + ), 32 + ), 33 + ); 34 + 35 + expect(find.byType(Scaffold), findsOneWidget); 36 + expect(find.byType(AppBar), findsOneWidget); 37 + }); 38 + 39 + testWidgets('AppBar has Sign In title', (tester) async { 40 + await tester.pumpWidget( 41 + const MaterialApp( 42 + home: OAuthWebViewScreen( 43 + authorizeUrl: testAuthorizeUrl, 44 + callbackUrlPrefix: testCallbackPrefix, 45 + ), 46 + ), 47 + ); 48 + 49 + expect(find.text('Sign In'), findsOneWidget); 50 + }); 51 + 52 + testWidgets('AppBar has close button', (tester) async { 53 + await tester.pumpWidget( 54 + const MaterialApp( 55 + home: OAuthWebViewScreen( 56 + authorizeUrl: testAuthorizeUrl, 57 + callbackUrlPrefix: testCallbackPrefix, 58 + ), 59 + ), 60 + ); 61 + 62 + expect(find.byIcon(Icons.close), findsOneWidget); 63 + }); 64 + 65 + testWidgets('shows loading indicator initially', (tester) async { 66 + await tester.pumpWidget( 67 + const MaterialApp( 68 + home: OAuthWebViewScreen( 69 + authorizeUrl: testAuthorizeUrl, 70 + callbackUrlPrefix: testCallbackPrefix, 71 + ), 72 + ), 73 + ); 74 + 75 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 76 + }); 77 + 78 + testWidgets('close button is tappable', (tester) async { 79 + await tester.pumpWidget( 80 + const MaterialApp( 81 + home: OAuthWebViewScreen( 82 + authorizeUrl: testAuthorizeUrl, 83 + callbackUrlPrefix: testCallbackPrefix, 84 + ), 85 + ), 86 + ); 87 + 88 + final closeButton = find.ancestor( 89 + of: find.byIcon(Icons.close), 90 + matching: find.byType(IconButton), 91 + ); 92 + expect(closeButton, findsOneWidget); 93 + }); 94 + 95 + testWidgets('body uses Stack layout for overlay', (tester) async { 96 + await tester.pumpWidget( 97 + const MaterialApp( 98 + home: OAuthWebViewScreen( 99 + authorizeUrl: testAuthorizeUrl, 100 + callbackUrlPrefix: testCallbackPrefix, 101 + ), 102 + ), 103 + ); 104 + 105 + final scaffoldFinder = find.byType(Scaffold); 106 + final stackFinder = find.descendant(of: scaffoldFinder, matching: find.byType(Stack)); 107 + expect(stackFinder, findsAtLeastNWidgets(1)); 108 + }); 109 + 110 + testWidgets('creates state correctly', (tester) async { 111 + await tester.pumpWidget( 112 + const MaterialApp( 113 + home: OAuthWebViewScreen( 114 + authorizeUrl: testAuthorizeUrl, 115 + callbackUrlPrefix: testCallbackPrefix, 116 + ), 117 + ), 118 + ); 119 + 120 + final statefulWidget = tester.element(find.byType(OAuthWebViewScreen)); 121 + expect(statefulWidget, isNotNull); 122 + }); 123 + 124 + testWidgets('preserves authorizeUrl and callbackUrlPrefix', (tester) async { 125 + const customAuthorizeUrl = 'https://example.com/authorize'; 126 + const customCallbackPrefix = 'http://localhost:3000/callback'; 127 + 128 + await tester.pumpWidget( 129 + const MaterialApp( 130 + home: OAuthWebViewScreen( 131 + authorizeUrl: customAuthorizeUrl, 132 + callbackUrlPrefix: customCallbackPrefix, 133 + ), 134 + ), 135 + ); 136 + 137 + final widget = tester.widget<OAuthWebViewScreen>(find.byType(OAuthWebViewScreen)); 138 + expect(widget.authorizeUrl, customAuthorizeUrl); 139 + expect(widget.callbackUrlPrefix, customCallbackPrefix); 140 + }); 141 + 142 + testWidgets('close button has close icon', (tester) async { 143 + await tester.pumpWidget( 144 + const MaterialApp( 145 + home: OAuthWebViewScreen( 146 + authorizeUrl: testAuthorizeUrl, 147 + callbackUrlPrefix: testCallbackPrefix, 148 + ), 149 + ), 150 + ); 151 + 152 + final closeButton = find.ancestor( 153 + of: find.byIcon(Icons.close), 154 + matching: find.byType(IconButton), 155 + ); 156 + expect(closeButton, findsOneWidget); 157 + }); 158 + 159 + testWidgets('loading indicator is centered', (tester) async { 160 + await tester.pumpWidget( 161 + const MaterialApp( 162 + home: OAuthWebViewScreen( 163 + authorizeUrl: testAuthorizeUrl, 164 + callbackUrlPrefix: testCallbackPrefix, 165 + ), 166 + ), 167 + ); 168 + 169 + final centerFinder = find.ancestor( 170 + of: find.byType(CircularProgressIndicator), 171 + matching: find.byType(Center), 172 + ); 173 + expect(centerFinder, findsOneWidget); 174 + }); 18 175 }); 19 176 } 177 + 178 + /// Fake platform implementation to allow WebViewWidget to render in tests. 179 + class _FakeWebViewPlatform extends WebViewPlatform { 180 + @override 181 + PlatformWebViewController createPlatformWebViewController( 182 + PlatformWebViewControllerCreationParams params, 183 + ) { 184 + return _FakePlatformWebViewController(params); 185 + } 186 + 187 + @override 188 + PlatformWebViewWidget createPlatformWebViewWidget(PlatformWebViewWidgetCreationParams params) { 189 + return _FakePlatformWebViewWidget(params); 190 + } 191 + 192 + @override 193 + PlatformNavigationDelegate createPlatformNavigationDelegate( 194 + PlatformNavigationDelegateCreationParams params, 195 + ) { 196 + return _FakePlatformNavigationDelegate(params); 197 + } 198 + } 199 + 200 + class _FakePlatformWebViewController extends PlatformWebViewController { 201 + _FakePlatformWebViewController(super.params) : super.implementation(); 202 + 203 + @override 204 + Future<void> setJavaScriptMode(JavaScriptMode javaScriptMode) async {} 205 + 206 + @override 207 + Future<void> setPlatformNavigationDelegate(PlatformNavigationDelegate handler) async {} 208 + 209 + @override 210 + Future<void> loadRequest(LoadRequestParams params) async {} 211 + } 212 + 213 + class _FakePlatformWebViewWidget extends PlatformWebViewWidget { 214 + _FakePlatformWebViewWidget(super.params) : super.implementation(); 215 + 216 + @override 217 + Widget build(BuildContext context) { 218 + return const SizedBox.expand(key: Key('fake_webview')); 219 + } 220 + } 221 + 222 + class _FakePlatformNavigationDelegate extends PlatformNavigationDelegate { 223 + _FakePlatformNavigationDelegate(super.params) : super.implementation(); 224 + 225 + @override 226 + Future<void> setOnPageStarted(PageEventCallback onPageStarted) async {} 227 + 228 + @override 229 + Future<void> setOnPageFinished(PageEventCallback onPageFinished) async {} 230 + 231 + @override 232 + Future<void> setOnWebResourceError(WebResourceErrorCallback onWebResourceError) async {} 233 + 234 + @override 235 + Future<void> setOnNavigationRequest(NavigationRequestCallback onNavigationRequest) async {} 236 + 237 + @override 238 + Future<void> setOnProgress(ProgressCallback onProgress) async {} 239 + 240 + @override 241 + Future<void> setOnUrlChange(UrlChangeCallback onUrlChange) async {} 242 + 243 + @override 244 + Future<void> setOnHttpAuthRequest(HttpAuthRequestCallback onHttpAuthRequest) async {} 245 + }
+186 -10
test/src/features/notifications/notifications_screen_test.dart
··· 1 1 import 'package:flutter/material.dart'; 2 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 3 import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/src/core/auth/session_model.dart'; 5 + import 'package:lazurite/src/core/utils/logger_provider.dart'; 6 + import 'package:lazurite/src/features/auth/application/auth_providers.dart'; 7 + import 'package:lazurite/src/features/auth/domain/auth_state.dart'; 8 + import 'package:lazurite/src/features/notifications/application/notifications_providers.dart'; 9 + import 'package:lazurite/src/features/notifications/domain/notification.dart'; 10 + import 'package:lazurite/src/features/notifications/domain/notification_type.dart'; 3 11 import 'package:lazurite/src/features/notifications/presentation/notifications_screen.dart'; 12 + import 'package:lazurite/src/infrastructure/db/app_database.dart'; 13 + import 'package:mocktail/mocktail.dart'; 14 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 15 5 - import '../../../helpers/pump_app.dart'; 16 + import '../../../helpers/mocks.dart'; 6 17 7 18 void main() { 19 + late MockNotificationsRepository mockRepository; 20 + late MockLogger mockLogger; 21 + 22 + Widget createSubject({ 23 + List<AppNotification> notifications = const [], 24 + bool authenticated = true, 25 + bool throwError = false, 26 + }) { 27 + final overrides = <Override>[ 28 + notificationsRepositoryProvider.overrideWithValue(mockRepository), 29 + loggerProvider('NotificationsNotifier').overrideWithValue(mockLogger), 30 + authProvider.overrideWith(() => _FakeAuthNotifier(authenticated: authenticated)), 31 + ]; 32 + 33 + if (throwError) { 34 + when( 35 + () => mockRepository.watchNotifications(), 36 + ).thenAnswer((_) => Stream.error(Exception('Network error'))); 37 + } else { 38 + when( 39 + () => mockRepository.watchNotifications(), 40 + ).thenAnswer((_) => Stream.value(notifications)); 41 + } 42 + 43 + return ProviderScope( 44 + overrides: overrides, 45 + child: const MaterialApp(home: NotificationsScreen()), 46 + ); 47 + } 48 + 49 + setUp(() { 50 + mockRepository = MockNotificationsRepository(); 51 + mockLogger = MockLogger(); 52 + 53 + when(() => mockLogger.debug(any(), any())).thenReturn(null); 54 + when(() => mockLogger.info(any(), any())).thenReturn(null); 55 + when(() => mockLogger.error(any(), any(), any())).thenReturn(null); 56 + 57 + when(() => mockRepository.watchNotifications()).thenAnswer((_) => Stream.value([])); 58 + when( 59 + () => mockRepository.fetchNotifications( 60 + cursor: any(named: 'cursor'), 61 + limit: any(named: 'limit'), 62 + ), 63 + ).thenAnswer((_) async {}); 64 + when(() => mockRepository.fetchNotifications()).thenAnswer((_) async {}); 65 + when(() => mockRepository.getCursor()).thenAnswer((_) async => null); 66 + when(() => mockRepository.markAllAsRead()).thenAnswer((_) async {}); 67 + }); 68 + 8 69 group('NotificationsScreen', () { 9 - testWidgets('renders app bar with title', (tester) async { 10 - await tester.pumpApp(const NotificationsScreen()); 70 + testWidgets('shows empty state when no notifications', (tester) async { 71 + await tester.pumpWidget(createSubject()); 72 + await tester.pump(); 73 + await tester.pump(const Duration(milliseconds: 100)); 74 + await tester.pump(const Duration(milliseconds: 100)); 11 75 12 - expect(find.text('Notifications'), findsWidgets); 76 + expect(find.text('No notifications yet'), findsOneWidget); 77 + expect(find.text('When someone interacts with you, it will show up here'), findsOneWidget); 13 78 }); 14 79 15 - testWidgets('renders notifications icon', (tester) async { 16 - await tester.pumpApp(const NotificationsScreen()); 80 + testWidgets('shows notification list when notifications exist', (tester) async { 81 + final notification = AppNotification( 82 + uri: 'at://did:plc:user/app.bsky.notification/1', 83 + actor: _createProfile('did:plc:actor1', 'alice.bsky'), 84 + type: NotificationType.like, 85 + indexedAt: DateTime.now(), 86 + isRead: false, 87 + ); 17 88 18 - expect(find.byIcon(Icons.notifications_outlined), findsOneWidget); 89 + await tester.pumpWidget(createSubject(notifications: [notification])); 90 + await tester.pump(); 91 + await tester.pump(const Duration(milliseconds: 100)); 92 + await tester.pump(const Duration(milliseconds: 100)); 93 + 94 + expect(find.text('alice.bsky'), findsWidgets); 95 + expect(find.text('liked your post'), findsOneWidget); 19 96 }); 20 97 21 - testWidgets('renders notifications placeholder text', (tester) async { 22 - await tester.pumpApp(const NotificationsScreen()); 98 + testWidgets('shows sign in message when not authenticated', (tester) async { 99 + await tester.pumpWidget(createSubject(authenticated: false)); 100 + await tester.pump(); 23 101 24 - expect(find.text('Your notifications will appear here'), findsOneWidget); 102 + expect(find.text('Sign in to see your notifications'), findsOneWidget); 103 + }); 104 + 105 + testWidgets('displays app bar with title', (tester) async { 106 + await tester.pumpWidget(createSubject()); 107 + await tester.pump(); 108 + await tester.pump(const Duration(milliseconds: 100)); 109 + await tester.pump(const Duration(milliseconds: 100)); 110 + 111 + expect(find.text('Notifications'), findsOneWidget); 112 + }); 113 + 114 + testWidgets('calls refresh on initial load', (tester) async { 115 + await tester.pumpWidget(createSubject()); 116 + await tester.pump(); 117 + await tester.pump(const Duration(milliseconds: 100)); 118 + 119 + verify(() => mockRepository.fetchNotifications()).called(1); 120 + }); 121 + 122 + testWidgets('mark all as read button is visible when authenticated', (tester) async { 123 + final notification = AppNotification( 124 + uri: 'at://did:plc:user/app.bsky.notification/1', 125 + actor: _createProfile('did:plc:actor1', 'alice.bsky'), 126 + type: NotificationType.like, 127 + indexedAt: DateTime.now(), 128 + isRead: false, 129 + ); 130 + 131 + await tester.pumpWidget(createSubject(notifications: [notification])); 132 + await tester.pump(); 133 + await tester.pump(const Duration(milliseconds: 100)); 134 + await tester.pump(const Duration(milliseconds: 100)); 135 + 136 + expect(find.byIcon(Icons.done_all), findsOneWidget); 137 + }); 138 + 139 + testWidgets('mark all as read calls repository', (tester) async { 140 + final notification = AppNotification( 141 + uri: 'at://did:plc:user/app.bsky.notification/1', 142 + actor: _createProfile('did:plc:actor1', 'alice.bsky'), 143 + type: NotificationType.like, 144 + indexedAt: DateTime.now(), 145 + isRead: false, 146 + ); 147 + 148 + await tester.pumpWidget(createSubject(notifications: [notification])); 149 + await tester.pump(); 150 + await tester.pump(const Duration(milliseconds: 100)); 151 + await tester.pump(const Duration(milliseconds: 100)); 152 + 153 + await tester.tap(find.byIcon(Icons.done_all)); 154 + await tester.pump(); 155 + 156 + verify(() => mockRepository.markAllAsRead()).called(1); 25 157 }); 26 158 }); 27 159 } 160 + 161 + class _FakeAuthNotifier extends AuthNotifier { 162 + _FakeAuthNotifier({required this.authenticated}); 163 + 164 + final bool authenticated; 165 + 166 + @override 167 + AuthState build() { 168 + if (authenticated) { 169 + return AuthState.authenticated( 170 + Session( 171 + did: 'did:plc:test', 172 + scope: 'test', 173 + handle: 'test.bsky.social', 174 + accessJwt: 'access', 175 + refreshJwt: 'refresh', 176 + pdsUrl: 'https://bsky.social', 177 + dpopKey: {'kty': 'OKP'}, 178 + expiresAt: DateTime.now().add(const Duration(minutes: 30)), 179 + ), 180 + ); 181 + } 182 + 183 + return const AuthState.unauthenticated(); 184 + } 185 + } 186 + 187 + Profile _createProfile(String did, String handle) { 188 + return Profile( 189 + did: did, 190 + handle: handle, 191 + displayName: null, 192 + description: null, 193 + avatar: null, 194 + banner: null, 195 + indexedAt: null, 196 + pronouns: null, 197 + website: null, 198 + createdAt: null, 199 + verificationStatus: null, 200 + labels: null, 201 + pinnedPostUri: null, 202 + ); 203 + }
+138
test/src/features/notifications/presentation/widgets/notification_list_item_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/src/features/notifications/domain/notification.dart'; 4 + import 'package:lazurite/src/features/notifications/domain/notification_type.dart'; 5 + import 'package:lazurite/src/features/notifications/presentation/widgets/notification_list_item.dart'; 6 + import 'package:lazurite/src/infrastructure/db/app_database.dart'; 7 + 8 + import '../../../../../helpers/pump_app.dart'; 9 + 10 + void main() { 11 + group('NotificationListItem', () { 12 + late AppNotification notification; 13 + late Profile actor; 14 + 15 + setUp(() { 16 + actor = const Profile( 17 + did: 'did:plc:actor1', 18 + handle: 'alice.bsky.social', 19 + displayName: 'Alice', 20 + description: null, 21 + avatar: 'https://example.com/avatar.jpg', 22 + banner: null, 23 + indexedAt: null, 24 + pronouns: null, 25 + website: null, 26 + createdAt: null, 27 + verificationStatus: null, 28 + labels: null, 29 + pinnedPostUri: null, 30 + ); 31 + 32 + notification = AppNotification( 33 + uri: 'at://did:plc:user/app.bsky.notification/1', 34 + actor: actor, 35 + type: NotificationType.like, 36 + reasonSubjectUri: 'at://did:plc:user/app.bsky.feed.post/1', 37 + indexedAt: DateTime.now().subtract(const Duration(hours: 2)), 38 + isRead: false, 39 + ); 40 + }); 41 + 42 + testWidgets('displays actor display name', (tester) async { 43 + await tester.pumpApp(NotificationListItem(notification: notification)); 44 + 45 + expect(find.text('Alice'), findsOneWidget); 46 + }); 47 + 48 + testWidgets('displays actor handle', (tester) async { 49 + await tester.pumpApp(NotificationListItem(notification: notification)); 50 + 51 + expect(find.text('@alice.bsky.social'), findsOneWidget); 52 + }); 53 + 54 + testWidgets('displays notification type text', (tester) async { 55 + await tester.pumpApp(NotificationListItem(notification: notification)); 56 + 57 + expect(find.text('liked your post'), findsOneWidget); 58 + }); 59 + 60 + testWidgets('displays notification type icon', (tester) async { 61 + await tester.pumpApp(NotificationListItem(notification: notification)); 62 + 63 + expect(find.byIcon(Icons.favorite), findsOneWidget); 64 + }); 65 + 66 + testWidgets('displays relative timestamp', (tester) async { 67 + await tester.pumpApp(NotificationListItem(notification: notification)); 68 + 69 + expect(find.text('2h'), findsOneWidget); 70 + }); 71 + 72 + testWidgets('unread notification has highlighted background', (tester) async { 73 + await tester.pumpApp(NotificationListItem(notification: notification)); 74 + 75 + final card = tester.widget<Card>(find.byType(Card)); 76 + expect(card.color, isNotNull); 77 + }); 78 + 79 + testWidgets('read notification has no special background', (tester) async { 80 + final readNotification = notification.copyWith(isRead: true); 81 + await tester.pumpApp(NotificationListItem(notification: readNotification)); 82 + 83 + final card = tester.widget<Card>(find.byType(Card)); 84 + expect(card.color, isNull); 85 + }); 86 + 87 + testWidgets('calls onTap when tapped', (tester) async { 88 + var tapped = false; 89 + await tester.pumpApp( 90 + NotificationListItem(notification: notification, onTap: () => tapped = true), 91 + ); 92 + 93 + await tester.tap(find.byType(InkWell)); 94 + expect(tapped, isTrue); 95 + }); 96 + 97 + testWidgets('displays handle when displayName is null', (tester) async { 98 + const noDisplayNameActor = Profile( 99 + did: 'did:plc:actor2', 100 + handle: 'bob.bsky.social', 101 + displayName: null, 102 + description: null, 103 + avatar: null, 104 + banner: null, 105 + indexedAt: null, 106 + pronouns: null, 107 + website: null, 108 + createdAt: null, 109 + verificationStatus: null, 110 + labels: null, 111 + pinnedPostUri: null, 112 + ); 113 + final noDisplayNameNotification = notification.copyWith(actor: noDisplayNameActor); 114 + await tester.pumpApp(NotificationListItem(notification: noDisplayNameNotification)); 115 + 116 + expect(find.text('bob.bsky.social'), findsOneWidget); 117 + }); 118 + 119 + testWidgets('shows follow type icon and text for follow notifications', (tester) async { 120 + final followNotification = notification.copyWith( 121 + type: NotificationType.follow, 122 + reasonSubjectUri: null, 123 + ); 124 + await tester.pumpApp(NotificationListItem(notification: followNotification)); 125 + 126 + expect(find.byIcon(Icons.person_add), findsOneWidget); 127 + expect(find.text('followed you'), findsOneWidget); 128 + }); 129 + 130 + testWidgets('shows reply type icon and text for reply notifications', (tester) async { 131 + final replyNotification = notification.copyWith(type: NotificationType.reply); 132 + await tester.pumpApp(NotificationListItem(notification: replyNotification)); 133 + 134 + expect(find.byIcon(Icons.reply), findsOneWidget); 135 + expect(find.text('replied to your post'), findsOneWidget); 136 + }); 137 + }); 138 + }
+66
test/src/features/notifications/presentation/widgets/notification_type_icon_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/src/features/notifications/domain/notification_type.dart'; 4 + import 'package:lazurite/src/features/notifications/presentation/widgets/notification_type_icon.dart'; 5 + 6 + import '../../../../../helpers/pump_app.dart'; 7 + 8 + void main() { 9 + group('NotificationTypeIcon', () { 10 + testWidgets('displays heart icon for like', (tester) async { 11 + await tester.pumpApp(const NotificationTypeIcon(type: NotificationType.like)); 12 + 13 + expect(find.byIcon(Icons.favorite), findsOneWidget); 14 + }); 15 + 16 + testWidgets('displays repeat icon for repost', (tester) async { 17 + await tester.pumpApp(const NotificationTypeIcon(type: NotificationType.repost)); 18 + 19 + expect(find.byIcon(Icons.repeat), findsOneWidget); 20 + }); 21 + 22 + testWidgets('displays person_add icon for follow', (tester) async { 23 + await tester.pumpApp(const NotificationTypeIcon(type: NotificationType.follow)); 24 + 25 + expect(find.byIcon(Icons.person_add), findsOneWidget); 26 + }); 27 + 28 + testWidgets('displays alternate_email icon for mention', (tester) async { 29 + await tester.pumpApp(const NotificationTypeIcon(type: NotificationType.mention)); 30 + 31 + expect(find.byIcon(Icons.alternate_email), findsOneWidget); 32 + }); 33 + 34 + testWidgets('displays reply icon for reply', (tester) async { 35 + await tester.pumpApp(const NotificationTypeIcon(type: NotificationType.reply)); 36 + 37 + expect(find.byIcon(Icons.reply), findsOneWidget); 38 + }); 39 + 40 + testWidgets('displays format_quote icon for quote', (tester) async { 41 + await tester.pumpApp(const NotificationTypeIcon(type: NotificationType.quote)); 42 + 43 + expect(find.byIcon(Icons.format_quote), findsOneWidget); 44 + }); 45 + 46 + testWidgets('displays group_add icon for starterpackJoined', (tester) async { 47 + await tester.pumpApp(const NotificationTypeIcon(type: NotificationType.starterpackJoined)); 48 + 49 + expect(find.byIcon(Icons.group_add), findsOneWidget); 50 + }); 51 + 52 + testWidgets('respects custom size', (tester) async { 53 + await tester.pumpApp(const NotificationTypeIcon(type: NotificationType.like, size: 32)); 54 + 55 + final icon = tester.widget<Icon>(find.byIcon(Icons.favorite)); 56 + expect(icon.size, 32); 57 + }); 58 + 59 + testWidgets('uses default size of 20', (tester) async { 60 + await tester.pumpApp(const NotificationTypeIcon(type: NotificationType.like)); 61 + 62 + final icon = tester.widget<Icon>(find.byIcon(Icons.favorite)); 63 + expect(icon.size, 20); 64 + }); 65 + }); 66 + }
+399
test/src/features/settings/presentation/widgets/color_role_picker_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/src/features/settings/presentation/widgets/color_role_picker.dart'; 4 + 5 + void main() { 6 + group('ColorRolePicker', () { 7 + const defaultColor = Color(0xFF0085FF); 8 + const overrideColor = Color(0xFFFF7EB6); 9 + 10 + Widget buildTestWidget({ 11 + required String label, 12 + required String description, 13 + required Color? currentColor, 14 + required Color defaultColor, 15 + required ValueChanged<Color?> onColorChanged, 16 + }) { 17 + return MaterialApp( 18 + home: Scaffold( 19 + body: ColorRolePicker( 20 + label: label, 21 + description: description, 22 + currentColor: currentColor, 23 + defaultColor: defaultColor, 24 + onColorChanged: onColorChanged, 25 + ), 26 + ), 27 + ); 28 + } 29 + 30 + testWidgets('displays label and description', (tester) async { 31 + await tester.pumpWidget( 32 + buildTestWidget( 33 + label: 'Primary', 34 + description: 'Main accent color', 35 + currentColor: null, 36 + defaultColor: defaultColor, 37 + onColorChanged: (_) {}, 38 + ), 39 + ); 40 + 41 + expect(find.text('Primary'), findsOneWidget); 42 + expect(find.text('Main accent color'), findsOneWidget); 43 + }); 44 + 45 + testWidgets('displays default color when currentColor is null', (tester) async { 46 + await tester.pumpWidget( 47 + buildTestWidget( 48 + label: 'Primary', 49 + description: 'Main accent color', 50 + currentColor: null, 51 + defaultColor: defaultColor, 52 + onColorChanged: (_) {}, 53 + ), 54 + ); 55 + 56 + final container = tester.widget<Container>( 57 + find.descendant(of: find.byType(GestureDetector), matching: find.byType(Container)).first, 58 + ); 59 + 60 + expect(container.decoration, isA<BoxDecoration>()); 61 + final decoration = container.decoration as BoxDecoration; 62 + expect(decoration.color, defaultColor); 63 + }); 64 + 65 + testWidgets('displays override color when currentColor is set', (tester) async { 66 + await tester.pumpWidget( 67 + buildTestWidget( 68 + label: 'Primary', 69 + description: 'Main accent color', 70 + currentColor: overrideColor, 71 + defaultColor: defaultColor, 72 + onColorChanged: (_) {}, 73 + ), 74 + ); 75 + 76 + final container = tester.widget<Container>( 77 + find.descendant(of: find.byType(GestureDetector), matching: find.byType(Container)).first, 78 + ); 79 + 80 + expect(container.decoration, isA<BoxDecoration>()); 81 + final decoration = container.decoration as BoxDecoration; 82 + expect(decoration.color, overrideColor); 83 + }); 84 + 85 + testWidgets('shows edit icon when color has override', (tester) async { 86 + await tester.pumpWidget( 87 + buildTestWidget( 88 + label: 'Primary', 89 + description: 'Main accent color', 90 + currentColor: overrideColor, 91 + defaultColor: defaultColor, 92 + onColorChanged: (_) {}, 93 + ), 94 + ); 95 + 96 + expect(find.byIcon(Icons.edit), findsOneWidget); 97 + }); 98 + 99 + testWidgets('does not show edit icon when using default', (tester) async { 100 + await tester.pumpWidget( 101 + buildTestWidget( 102 + label: 'Primary', 103 + description: 'Main accent color', 104 + currentColor: null, 105 + defaultColor: defaultColor, 106 + onColorChanged: (_) {}, 107 + ), 108 + ); 109 + 110 + expect(find.byIcon(Icons.edit), findsNothing); 111 + }); 112 + 113 + testWidgets('shows restore button when color has override', (tester) async { 114 + await tester.pumpWidget( 115 + buildTestWidget( 116 + label: 'Primary', 117 + description: 'Main accent color', 118 + currentColor: overrideColor, 119 + defaultColor: defaultColor, 120 + onColorChanged: (_) {}, 121 + ), 122 + ); 123 + 124 + expect(find.byIcon(Icons.restore), findsOneWidget); 125 + }); 126 + 127 + testWidgets('does not show restore button when using default', (tester) async { 128 + await tester.pumpWidget( 129 + buildTestWidget( 130 + label: 'Primary', 131 + description: 'Main accent color', 132 + currentColor: null, 133 + defaultColor: defaultColor, 134 + onColorChanged: (_) {}, 135 + ), 136 + ); 137 + 138 + expect(find.byIcon(Icons.restore), findsNothing); 139 + }); 140 + 141 + testWidgets('tapping restore button calls onColorChanged with null', (tester) async { 142 + Color? changedColor = overrideColor; 143 + await tester.pumpWidget( 144 + buildTestWidget( 145 + label: 'Primary', 146 + description: 'Main accent color', 147 + currentColor: overrideColor, 148 + defaultColor: defaultColor, 149 + onColorChanged: (color) => changedColor = color, 150 + ), 151 + ); 152 + 153 + await tester.tap(find.byIcon(Icons.restore)); 154 + await tester.pump(); 155 + 156 + expect(changedColor, isNull); 157 + }); 158 + 159 + testWidgets('tapping color box opens color picker dialog', (tester) async { 160 + await tester.pumpWidget( 161 + buildTestWidget( 162 + label: 'Primary', 163 + description: 'Main accent color', 164 + currentColor: null, 165 + defaultColor: defaultColor, 166 + onColorChanged: (_) {}, 167 + ), 168 + ); 169 + 170 + await tester.tap(find.byType(GestureDetector).first); 171 + await tester.pumpAndSettle(); 172 + 173 + expect(find.text('Select Color'), findsOneWidget); 174 + }); 175 + 176 + testWidgets('tapping ListTile opens color picker dialog', (tester) async { 177 + await tester.pumpWidget( 178 + buildTestWidget( 179 + label: 'Primary', 180 + description: 'Main accent color', 181 + currentColor: null, 182 + defaultColor: defaultColor, 183 + onColorChanged: (_) {}, 184 + ), 185 + ); 186 + 187 + await tester.tap(find.byType(ListTile)); 188 + await tester.pumpAndSettle(); 189 + 190 + expect(find.text('Select Color'), findsOneWidget); 191 + }); 192 + 193 + testWidgets('color picker dialog shows preset colors', (tester) async { 194 + await tester.pumpWidget( 195 + buildTestWidget( 196 + label: 'Primary', 197 + description: 'Main accent color', 198 + currentColor: null, 199 + defaultColor: defaultColor, 200 + onColorChanged: (_) {}, 201 + ), 202 + ); 203 + 204 + await tester.tap(find.byType(ListTile)); 205 + await tester.pumpAndSettle(); 206 + 207 + expect(find.byType(Wrap), findsOneWidget); 208 + }); 209 + 210 + testWidgets('color picker dialog shows hex input', (tester) async { 211 + await tester.pumpWidget( 212 + buildTestWidget( 213 + label: 'Primary', 214 + description: 'Main accent color', 215 + currentColor: null, 216 + defaultColor: defaultColor, 217 + onColorChanged: (_) {}, 218 + ), 219 + ); 220 + 221 + await tester.tap(find.byType(ListTile)); 222 + await tester.pumpAndSettle(); 223 + 224 + expect(find.widgetWithText(TextField, '0085FF'), findsOneWidget); 225 + }); 226 + 227 + testWidgets('dialog cancel button closes without callback', (tester) async { 228 + bool callbackCalled = false; 229 + await tester.pumpWidget( 230 + buildTestWidget( 231 + label: 'Primary', 232 + description: 'Main accent color', 233 + currentColor: null, 234 + defaultColor: defaultColor, 235 + onColorChanged: (_) => callbackCalled = true, 236 + ), 237 + ); 238 + 239 + await tester.tap(find.byType(ListTile)); 240 + await tester.pumpAndSettle(); 241 + 242 + await tester.tap(find.text('Cancel')); 243 + await tester.pumpAndSettle(); 244 + 245 + expect(find.text('Select Color'), findsNothing); 246 + expect(callbackCalled, isFalse); 247 + }); 248 + 249 + testWidgets('dialog select button calls onColorChanged with selected color', (tester) async { 250 + Color? selectedColor; 251 + await tester.pumpWidget( 252 + buildTestWidget( 253 + label: 'Primary', 254 + description: 'Main accent color', 255 + currentColor: null, 256 + defaultColor: defaultColor, 257 + onColorChanged: (color) => selectedColor = color, 258 + ), 259 + ); 260 + 261 + await tester.tap(find.byType(ListTile)); 262 + await tester.pumpAndSettle(); 263 + 264 + await tester.tap(find.text('Select')); 265 + await tester.pumpAndSettle(); 266 + 267 + expect(find.text('Select Color'), findsNothing); 268 + expect(selectedColor, isNotNull); 269 + }); 270 + 271 + testWidgets('typing hex code updates selected color', (tester) async { 272 + Color? selectedColor; 273 + await tester.pumpWidget( 274 + buildTestWidget( 275 + label: 'Primary', 276 + description: 'Main accent color', 277 + currentColor: null, 278 + defaultColor: defaultColor, 279 + onColorChanged: (color) => selectedColor = color, 280 + ), 281 + ); 282 + 283 + await tester.tap(find.byType(ListTile)); 284 + await tester.pumpAndSettle(); 285 + 286 + final textField = find.byType(TextField); 287 + await tester.enterText(textField, 'FF0000'); 288 + await tester.pump(); 289 + 290 + await tester.tap(find.text('Select')); 291 + await tester.pumpAndSettle(); 292 + 293 + expect(selectedColor, const Color(0xFFFF0000)); 294 + }); 295 + 296 + testWidgets('tapping preset color updates selection', (tester) async { 297 + Color? selectedColor; 298 + await tester.pumpWidget( 299 + buildTestWidget( 300 + label: 'Primary', 301 + description: 'Main accent color', 302 + currentColor: null, 303 + defaultColor: defaultColor, 304 + onColorChanged: (color) => selectedColor = color, 305 + ), 306 + ); 307 + 308 + await tester.tap(find.byType(ListTile)); 309 + await tester.pumpAndSettle(); 310 + 311 + final presetContainers = find.descendant( 312 + of: find.byType(Wrap), 313 + matching: find.byType(GestureDetector), 314 + ); 315 + await tester.tap(presetContainers.at(1)); 316 + await tester.pump(); 317 + 318 + await tester.tap(find.text('Select')); 319 + await tester.pumpAndSettle(); 320 + 321 + expect(selectedColor, isNotNull); 322 + }); 323 + 324 + testWidgets('displayColor getter returns currentColor when set', (tester) async { 325 + final widget = ColorRolePicker( 326 + label: 'Primary', 327 + description: 'Test', 328 + currentColor: overrideColor, 329 + defaultColor: defaultColor, 330 + onColorChanged: (_) {}, 331 + ); 332 + 333 + expect(widget.displayColor, overrideColor); 334 + }); 335 + 336 + testWidgets('displayColor getter returns defaultColor when currentColor is null', ( 337 + tester, 338 + ) async { 339 + final widget = ColorRolePicker( 340 + label: 'Primary', 341 + description: 'Test', 342 + currentColor: null, 343 + defaultColor: defaultColor, 344 + onColorChanged: (_) {}, 345 + ); 346 + 347 + expect(widget.displayColor, defaultColor); 348 + }); 349 + 350 + testWidgets('hasOverride getter returns true when currentColor is set', (tester) async { 351 + final widget = ColorRolePicker( 352 + label: 'Primary', 353 + description: 'Test', 354 + currentColor: overrideColor, 355 + defaultColor: defaultColor, 356 + onColorChanged: (_) {}, 357 + ); 358 + 359 + expect(widget.hasOverride, isTrue); 360 + }); 361 + 362 + testWidgets('hasOverride getter returns false when currentColor is null', (tester) async { 363 + final widget = ColorRolePicker( 364 + label: 'Primary', 365 + description: 'Test', 366 + currentColor: null, 367 + defaultColor: defaultColor, 368 + onColorChanged: (_) {}, 369 + ); 370 + 371 + expect(widget.hasOverride, isFalse); 372 + }); 373 + 374 + testWidgets('hex input handles # prefix', (tester) async { 375 + Color? selectedColor; 376 + await tester.pumpWidget( 377 + buildTestWidget( 378 + label: 'Primary', 379 + description: 'Main accent color', 380 + currentColor: null, 381 + defaultColor: defaultColor, 382 + onColorChanged: (color) => selectedColor = color, 383 + ), 384 + ); 385 + 386 + await tester.tap(find.byType(ListTile)); 387 + await tester.pumpAndSettle(); 388 + 389 + final textField = find.byType(TextField); 390 + await tester.enterText(textField, '#00FF00'); 391 + await tester.pump(); 392 + 393 + await tester.tap(find.text('Select')); 394 + await tester.pumpAndSettle(); 395 + 396 + expect(selectedColor, const Color(0xFF00FF00)); 397 + }); 398 + }); 399 + }