+2
-2
doc/roadmap.txt
+2
-2
doc/roadmap.txt
···
23
- [x] NotificationsNotifier:
24
- AsyncNotifier with pagination support
25
- refresh() and loadMore() methods
26
-
- [ ] NotificationsScreen:
27
- list display with pull-to-refresh
28
- infinite scroll with cursor-based pagination
29
- tap navigation to thread/profile
30
-
- [ ] Widgets:
31
- NotificationListItem (actor, type icon, content preview, timestamp)
32
- NotificationTypeIcon (like, repost, follow, mention, reply, quote)
33
···
23
- [x] NotificationsNotifier:
24
- AsyncNotifier with pagination support
25
- refresh() and loadMore() methods
26
+
- [x] NotificationsScreen:
27
- list display with pull-to-refresh
28
- infinite scroll with cursor-based pagination
29
- tap navigation to thread/profile
30
+
- [x] Widgets:
31
- NotificationListItem (actor, type icon, content preview, timestamp)
32
- NotificationTypeIcon (like, repost, follow, mention, reply, quote)
33
+153
-4
lib/src/features/notifications/presentation/notifications_screen.dart
+153
-4
lib/src/features/notifications/presentation/notifications_screen.dart
···
1
import 'package:flutter/material.dart';
2
3
-
/// Notifications screen placeholder for the notifications tab.
4
-
class NotificationsScreen extends StatelessWidget {
5
const NotificationsScreen({super.key});
6
7
@override
8
Widget build(BuildContext context) {
9
final theme = Theme.of(context);
10
11
return Scaffold(
···
14
child: Column(
15
mainAxisAlignment: MainAxisAlignment.center,
16
children: [
17
-
Icon(Icons.notifications_outlined, size: 64, color: theme.colorScheme.primary),
18
const SizedBox(height: 16),
19
Text('Notifications', style: theme.textTheme.headlineSmall),
20
const SizedBox(height: 8),
21
Text(
22
-
'Your notifications will appear here',
23
style: theme.textTheme.bodyMedium?.copyWith(
24
color: theme.colorScheme.onSurface.withAlpha(153),
25
),
26
),
27
],
28
),
···
1
+
import 'package:flutter/cupertino.dart';
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';
11
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 {
22
const NotificationsScreen({super.key});
23
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
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) {
127
final theme = Theme.of(context);
128
129
return Scaffold(
···
132
child: Column(
133
mainAxisAlignment: MainAxisAlignment.center,
134
children: [
135
+
Icon(CupertinoIcons.bell, size: 64, color: theme.colorScheme.primary),
136
const SizedBox(height: 16),
137
Text('Notifications', style: theme.textTheme.headlineSmall),
138
const SizedBox(height: 8),
139
Text(
140
+
'Sign in to see your notifications',
141
style: theme.textTheme.bodyMedium?.copyWith(
142
color: theme.colorScheme.onSurface.withAlpha(153),
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,
175
),
176
],
177
),
+122
lib/src/features/notifications/presentation/widgets/notification_list_item.dart
+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
+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
+1
-1
pubspec.lock
+1
pubspec.yaml
+1
pubspec.yaml
+13
-1
test/src/app/router_test.dart
+13
-1
test/src/app/router_test.dart
···
21
import 'package:lazurite/src/features/feeds/application/feed_content_providers.dart';
22
import 'package:lazurite/src/features/feeds/application/feed_providers.dart';
23
import 'package:lazurite/src/features/feeds/application/feed_sync_controller.dart';
24
import 'package:lazurite/src/features/profile/application/profile_providers.dart';
25
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
26
import 'package:lazurite/src/features/search/application/search_providers.dart';
···
38
late MockProfileRepository mockProfileRepository;
39
late MockAppDatabase mockDatabase;
40
late MockFeedContentRepository mockFeedContentRepository;
41
late Session testSession;
42
43
setUp(() {
···
46
mockProfileRepository = MockProfileRepository();
47
mockDatabase = MockAppDatabase();
48
mockFeedContentRepository = MockFeedContentRepository();
49
testSession = Session(
50
did: 'did:web:test',
51
handle: 'handle',
···
100
activeFeedProvider.overrideWith(() => MockActiveFeed()),
101
draftsProvider.overrideWith((ref) => Stream.value([])),
102
lazurite_anim.animationControllerProvider.overrideWith(MockAnimationController.new),
103
];
104
}
105
···
189
190
await tester.tap(find.text('Notifications'));
191
await tester.pumpAndSettle();
192
-
expect(find.text('Your notifications will appear here'), findsOneWidget);
193
194
await tester.tap(find.text('Home'));
195
await tester.pumpAndSettle();
···
21
import 'package:lazurite/src/features/feeds/application/feed_content_providers.dart';
22
import 'package:lazurite/src/features/feeds/application/feed_providers.dart';
23
import 'package:lazurite/src/features/feeds/application/feed_sync_controller.dart';
24
+
import 'package:lazurite/src/features/notifications/application/notifications_providers.dart';
25
import 'package:lazurite/src/features/profile/application/profile_providers.dart';
26
import 'package:lazurite/src/features/profile/infrastructure/profile_repository.dart';
27
import 'package:lazurite/src/features/search/application/search_providers.dart';
···
39
late MockProfileRepository mockProfileRepository;
40
late MockAppDatabase mockDatabase;
41
late MockFeedContentRepository mockFeedContentRepository;
42
+
late MockNotificationsRepository mockNotificationsRepository;
43
late Session testSession;
44
45
setUp(() {
···
48
mockProfileRepository = MockProfileRepository();
49
mockDatabase = MockAppDatabase();
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 {});
60
testSession = Session(
61
did: 'did:web:test',
62
handle: 'handle',
···
111
activeFeedProvider.overrideWith(() => MockActiveFeed()),
112
draftsProvider.overrideWith((ref) => Stream.value([])),
113
lazurite_anim.animationControllerProvider.overrideWith(MockAnimationController.new),
114
+
notificationsRepositoryProvider.overrideWithValue(mockNotificationsRepository),
115
];
116
}
117
···
201
202
await tester.tap(find.text('Notifications'));
203
await tester.pumpAndSettle();
204
+
expect(find.text('No notifications yet'), findsOneWidget);
205
206
await tester.tap(find.text('Home'));
207
await tester.pumpAndSettle();
+226
test/src/features/auth/presentation/oauth_webview_screen_test.dart
+226
test/src/features/auth/presentation/oauth_webview_screen_test.dart
···
1
import 'package:flutter_test/flutter_test.dart';
2
import 'package:lazurite/src/features/auth/presentation/oauth_webview_screen.dart';
3
4
void main() {
5
group('OAuthWebViewScreen', () {
6
const testAuthorizeUrl = 'https://bsky.social/oauth/authorize?request_uri=test';
7
const testCallbackPrefix = 'http://127.0.0.1:8080/callback';
8
9
test('constructs with required parameters', () {
10
const screen = OAuthWebViewScreen(
11
authorizeUrl: testAuthorizeUrl,
···
15
expect(screen.authorizeUrl, testAuthorizeUrl);
16
expect(screen.callbackUrlPrefix, testCallbackPrefix);
17
});
18
});
19
}
···
1
+
import 'package:flutter/material.dart';
2
import 'package:flutter_test/flutter_test.dart';
3
import 'package:lazurite/src/features/auth/presentation/oauth_webview_screen.dart';
4
+
import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';
5
6
void main() {
7
group('OAuthWebViewScreen', () {
8
const testAuthorizeUrl = 'https://bsky.social/oauth/authorize?request_uri=test';
9
const testCallbackPrefix = 'http://127.0.0.1:8080/callback';
10
11
+
setUpAll(() {
12
+
WebViewPlatform.instance = _FakeWebViewPlatform();
13
+
});
14
+
15
test('constructs with required parameters', () {
16
const screen = OAuthWebViewScreen(
17
authorizeUrl: testAuthorizeUrl,
···
21
expect(screen.authorizeUrl, testAuthorizeUrl);
22
expect(screen.callbackUrlPrefix, testCallbackPrefix);
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
+
});
175
});
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
+186
-10
test/src/features/notifications/notifications_screen_test.dart
···
1
import 'package:flutter/material.dart';
2
import 'package:flutter_test/flutter_test.dart';
3
import 'package:lazurite/src/features/notifications/presentation/notifications_screen.dart';
4
5
-
import '../../../helpers/pump_app.dart';
6
7
void main() {
8
group('NotificationsScreen', () {
9
-
testWidgets('renders app bar with title', (tester) async {
10
-
await tester.pumpApp(const NotificationsScreen());
11
12
-
expect(find.text('Notifications'), findsWidgets);
13
});
14
15
-
testWidgets('renders notifications icon', (tester) async {
16
-
await tester.pumpApp(const NotificationsScreen());
17
18
-
expect(find.byIcon(Icons.notifications_outlined), findsOneWidget);
19
});
20
21
-
testWidgets('renders notifications placeholder text', (tester) async {
22
-
await tester.pumpApp(const NotificationsScreen());
23
24
-
expect(find.text('Your notifications will appear here'), findsOneWidget);
25
});
26
});
27
}
···
1
import 'package:flutter/material.dart';
2
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
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';
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';
15
16
+
import '../../../helpers/mocks.dart';
17
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
+
69
group('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));
75
76
+
expect(find.text('No notifications yet'), findsOneWidget);
77
+
expect(find.text('When someone interacts with you, it will show up here'), findsOneWidget);
78
});
79
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
+
);
88
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);
96
});
97
98
+
testWidgets('shows sign in message when not authenticated', (tester) async {
99
+
await tester.pumpWidget(createSubject(authenticated: false));
100
+
await tester.pump();
101
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);
157
});
158
});
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
+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
+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
+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
+
}