+2
-2
doc/roadmap.txt
+2
-2
doc/roadmap.txt
···
32
32
- NotificationTypeIcon (like, repost, follow, mention, reply, quote)
33
33
34
34
Phase 2: Unread Count and Mark as Seen
35
-
- [ ] UnreadCountNotifier:
35
+
- [x] UnreadCountNotifier:
36
36
- watchUnreadCount stream
37
37
- refresh from getUnreadCount API
38
-
- [ ] UnreadBadge widget:
38
+
- [x] UnreadBadge widget:
39
39
- display count on tab icon
40
40
- hide when zero, show "99+" for large counts
41
41
- [ ] Mark as seen:
+42
-28
lib/src/core/widgets/tab_scaffold.dart
+42
-28
lib/src/core/widgets/tab_scaffold.dart
···
5
5
import 'package:lazurite/src/features/auth/application/auth_providers.dart';
6
6
import 'package:lazurite/src/features/auth/domain/auth_state.dart';
7
7
import 'package:lazurite/src/features/composer/presentation/widgets/global_compose_fab.dart';
8
+
import 'package:lazurite/src/features/notifications/application/unread_count_notifier.dart';
9
+
import 'package:lazurite/src/features/notifications/presentation/widgets/unread_badge.dart';
8
10
9
11
/// Bottom navigation scaffold that wraps the tab content.
10
12
///
···
29
31
Widget build(BuildContext context) {
30
32
final authState = ref.watch(authProvider);
31
33
final isAuthenticated = authState is AuthStateAuthenticated;
34
+
final unreadCountAsync = ref.watch(unreadCountProvider);
32
35
33
36
_ensureHomeBranchWhenUnauthenticated(isAuthenticated);
37
+
38
+
final unreadCount = unreadCountAsync.when(
39
+
data: (count) => count,
40
+
loading: () => 0,
41
+
error: (_, _) => 0,
42
+
);
34
43
35
44
return Scaffold(
36
45
body: widget.navigationShell,
···
48
57
widget.navigationShell.goBranch(0, initialLocation: true);
49
58
}
50
59
},
51
-
destinations: isAuthenticated ? _authenticatedDestinations : _unauthenticatedDestinations,
60
+
destinations: isAuthenticated
61
+
? _buildAuthenticatedDestinations(unreadCount)
62
+
: _unauthenticatedDestinations,
52
63
),
53
64
floatingActionButton: _shouldShowFab(context, isAuthenticated)
54
65
? const GlobalComposeFab()
···
94
105
}
95
106
}
96
107
97
-
const _authenticatedDestinations = [
98
-
NavigationDestination(
99
-
icon: Icon(Icons.home_outlined),
100
-
selectedIcon: Icon(Icons.home),
101
-
label: 'Home',
102
-
),
103
-
NavigationDestination(
104
-
icon: Icon(Icons.search_outlined),
105
-
selectedIcon: Icon(Icons.search),
106
-
label: 'Search',
107
-
),
108
-
NavigationDestination(
109
-
icon: Icon(Icons.notifications_outlined),
110
-
selectedIcon: Icon(Icons.notifications),
111
-
label: 'Notifications',
112
-
),
113
-
NavigationDestination(
114
-
icon: Icon(Icons.mail_outlined),
115
-
selectedIcon: Icon(Icons.mail),
116
-
label: 'Messages',
117
-
),
118
-
NavigationDestination(
119
-
icon: Icon(Icons.person_outlined),
120
-
selectedIcon: Icon(Icons.person),
121
-
label: 'Profile',
122
-
),
123
-
];
108
+
/// Builds authenticated navigation destinations with unread count badge.
109
+
List<NavigationDestination> _buildAuthenticatedDestinations(int unreadCount) {
110
+
return [
111
+
const NavigationDestination(
112
+
icon: Icon(Icons.home_outlined),
113
+
selectedIcon: Icon(Icons.home),
114
+
label: 'Home',
115
+
),
116
+
const NavigationDestination(
117
+
icon: Icon(Icons.search_outlined),
118
+
selectedIcon: Icon(Icons.search),
119
+
label: 'Search',
120
+
),
121
+
NavigationDestination(
122
+
icon: UnreadBadge(count: unreadCount, child: const Icon(Icons.notifications_outlined)),
123
+
selectedIcon: UnreadBadge(count: unreadCount, child: const Icon(Icons.notifications)),
124
+
label: 'Notifications',
125
+
),
126
+
const NavigationDestination(
127
+
icon: Icon(Icons.mail_outlined),
128
+
selectedIcon: Icon(Icons.mail),
129
+
label: 'Messages',
130
+
),
131
+
const NavigationDestination(
132
+
icon: Icon(Icons.person_outlined),
133
+
selectedIcon: Icon(Icons.person),
134
+
label: 'Profile',
135
+
),
136
+
];
137
+
}
124
138
125
139
const _unauthenticatedDestinations = [
126
140
NavigationDestination(
+32
lib/src/features/notifications/application/unread_count_notifier.dart
+32
lib/src/features/notifications/application/unread_count_notifier.dart
···
1
+
import 'package:riverpod_annotation/riverpod_annotation.dart';
2
+
3
+
import '../../../core/utils/logger.dart';
4
+
import '../../../core/utils/logger_provider.dart';
5
+
import '../../../features/auth/application/auth_providers.dart';
6
+
import '../../../features/auth/domain/auth_state.dart';
7
+
import 'notifications_providers.dart';
8
+
9
+
part 'unread_count_notifier.g.dart';
10
+
11
+
/// Notifier for managing unread notification count.
12
+
///
13
+
/// Watches the unread count stream from the database and provides
14
+
/// reactive updates when notifications are marked as read.
15
+
@riverpod
16
+
class UnreadCountNotifier extends _$UnreadCountNotifier {
17
+
Logger get _logger => ref.read(loggerProvider('UnreadCountNotifier'));
18
+
19
+
bool get _isAuthenticated => ref.read(authProvider) is AuthStateAuthenticated;
20
+
21
+
@override
22
+
Stream<int> build() {
23
+
if (!_isAuthenticated) {
24
+
_logger.debug('Not authenticated, returning 0 unread count', {});
25
+
return Stream.value(0);
26
+
}
27
+
28
+
final repository = ref.watch(notificationsRepositoryProvider);
29
+
_logger.debug('Building unread count stream', {});
30
+
return repository.watchUnreadCount();
31
+
}
32
+
}
+70
lib/src/features/notifications/application/unread_count_notifier.g.dart
+70
lib/src/features/notifications/application/unread_count_notifier.g.dart
···
1
+
// GENERATED CODE - DO NOT MODIFY BY HAND
2
+
3
+
part of 'unread_count_notifier.dart';
4
+
5
+
// **************************************************************************
6
+
// RiverpodGenerator
7
+
// **************************************************************************
8
+
9
+
// GENERATED CODE - DO NOT MODIFY BY HAND
10
+
// ignore_for_file: type=lint, type=warning
11
+
/// Notifier for managing unread notification count.
12
+
///
13
+
/// Watches the unread count stream from the database and provides
14
+
/// reactive updates when notifications are marked as read.
15
+
16
+
@ProviderFor(UnreadCountNotifier)
17
+
final unreadCountProvider = UnreadCountNotifierProvider._();
18
+
19
+
/// Notifier for managing unread notification count.
20
+
///
21
+
/// Watches the unread count stream from the database and provides
22
+
/// reactive updates when notifications are marked as read.
23
+
final class UnreadCountNotifierProvider extends $StreamNotifierProvider<UnreadCountNotifier, int> {
24
+
/// Notifier for managing unread notification count.
25
+
///
26
+
/// Watches the unread count stream from the database and provides
27
+
/// reactive updates when notifications are marked as read.
28
+
UnreadCountNotifierProvider._()
29
+
: super(
30
+
from: null,
31
+
argument: null,
32
+
retry: null,
33
+
name: r'unreadCountProvider',
34
+
isAutoDispose: true,
35
+
dependencies: null,
36
+
$allTransitiveDependencies: null,
37
+
);
38
+
39
+
@override
40
+
String debugGetCreateSourceHash() => _$unreadCountNotifierHash();
41
+
42
+
@$internal
43
+
@override
44
+
UnreadCountNotifier create() => UnreadCountNotifier();
45
+
}
46
+
47
+
String _$unreadCountNotifierHash() => r'd92a51e1927057acc715ca32c4371e16e56b8707';
48
+
49
+
/// Notifier for managing unread notification count.
50
+
///
51
+
/// Watches the unread count stream from the database and provides
52
+
/// reactive updates when notifications are marked as read.
53
+
54
+
abstract class _$UnreadCountNotifier extends $StreamNotifier<int> {
55
+
Stream<int> build();
56
+
@$mustCallSuper
57
+
@override
58
+
void runBuild() {
59
+
final ref = this.ref as $Ref<AsyncValue<int>, int>;
60
+
final element =
61
+
ref.element
62
+
as $ClassProviderElement<
63
+
AnyNotifier<AsyncValue<int>, int>,
64
+
AsyncValue<int>,
65
+
Object?,
66
+
Object?
67
+
>;
68
+
element.handleCreate(ref, build);
69
+
}
70
+
}
+7
lib/src/features/notifications/infrastructure/notifications_repository.dart
+7
lib/src/features/notifications/infrastructure/notifications_repository.dart
···
163
163
return _dao.markAllAsRead();
164
164
}
165
165
166
+
/// Returns a stream of the unread notification count.
167
+
///
168
+
/// Emits updates whenever notifications are inserted, updated, or deleted.
169
+
Stream<int> watchUnreadCount() {
170
+
return _dao.watchUnreadCount();
171
+
}
172
+
166
173
/// Encodes labels array to JSON string.
167
174
String? _encodeLabels(dynamic labels) {
168
175
if (labels == null) return null;
+43
lib/src/features/notifications/presentation/widgets/unread_badge.dart
+43
lib/src/features/notifications/presentation/widgets/unread_badge.dart
···
1
+
import 'package:flutter/material.dart';
2
+
3
+
/// Badge widget for displaying unread notification count.
4
+
///
5
+
/// Displays a numeric badge on the notifications tab icon. The badge
6
+
/// automatically hides when count is zero and formats large counts as "99+".
7
+
///
8
+
/// Features:
9
+
/// - Shows count for values > 0
10
+
/// - Formats counts > 99 as "99+"
11
+
/// - Hides badge when count is 0
12
+
/// - Semantic label for screen readers
13
+
/// - Adapts to Material Design theme
14
+
class UnreadBadge extends StatelessWidget {
15
+
const UnreadBadge({required this.count, required this.child, super.key});
16
+
17
+
/// The unread notification count to display.
18
+
///
19
+
/// When 0, the badge is hidden. When > 99, displays as "99+".
20
+
final int count;
21
+
22
+
/// The child widget to display the badge on (typically an Icon).
23
+
final Widget child;
24
+
25
+
/// Maximum count to display before showing "99+".
26
+
static const int _maxDisplayCount = 99;
27
+
28
+
@override
29
+
Widget build(BuildContext context) {
30
+
final isVisible = count > 0;
31
+
final displayText = count > _maxDisplayCount ? '99+' : '$count';
32
+
33
+
return Badge(
34
+
label: Text(displayText),
35
+
isLabelVisible: isVisible,
36
+
child: Semantics(
37
+
label: isVisible ? '$count unread notifications' : null,
38
+
excludeSemantics: !isVisible,
39
+
child: child,
40
+
),
41
+
);
42
+
}
43
+
}
+9
lib/src/infrastructure/db/daos/notifications_dao.dart
+9
lib/src/infrastructure/db/daos/notifications_dao.dart
···
80
80
const NotificationsCompanion(isRead: Value(true)),
81
81
);
82
82
}
83
+
84
+
/// Gets a stream of the unread notification count.
85
+
///
86
+
/// Emits updates whenever notifications are inserted, updated, or deleted.
87
+
Stream<int> watchUnreadCount() {
88
+
final query = selectOnly(notifications)..addColumns([notifications.uri.count()]);
89
+
query.where(notifications.isRead.equals(false));
90
+
return query.map((row) => row.read(notifications.uri.count()) ?? 0).watchSingle();
91
+
}
83
92
}
84
93
85
94
/// Represents a notification with its actor profile.
+253
-3
test/src/core/widgets/tab_scaffold_test.dart
+253
-3
test/src/core/widgets/tab_scaffold_test.dart
···
1
+
import 'dart:async';
2
+
1
3
import 'package:flutter/material.dart';
2
4
import 'package:flutter_test/flutter_test.dart';
3
5
import 'package:go_router/go_router.dart';
···
6
8
import 'package:lazurite/src/features/auth/application/auth_providers.dart';
7
9
import 'package:lazurite/src/features/auth/domain/auth_state.dart';
8
10
import 'package:lazurite/src/features/composer/presentation/widgets/global_compose_fab.dart';
11
+
import 'package:lazurite/src/features/notifications/application/unread_count_notifier.dart';
12
+
import 'package:lazurite/src/features/notifications/presentation/widgets/unread_badge.dart';
9
13
10
14
import '../../../helpers/pump_app.dart';
11
15
···
93
97
}
94
98
}
95
99
100
+
class _TestUnreadCountNotifier extends UnreadCountNotifier {
101
+
_TestUnreadCountNotifier(this._stream);
102
+
103
+
final Stream<int> _stream;
104
+
105
+
@override
106
+
Stream<int> build() => _stream;
107
+
}
108
+
96
109
void main() {
97
110
group('TabScaffold - Authenticated', () {
98
111
testWidgets('renders NavigationBar with 5 destinations when authenticated', (tester) async {
···
103
116
authProvider.overrideWith(
104
117
() => _TestAuthNotifier(AuthState.authenticated(_testSession())),
105
118
),
119
+
unreadCountProvider.overrideWith(() => _TestUnreadCountNotifier(Stream.value(0))),
106
120
],
107
121
);
108
122
expect(find.byType(NavigationBar), findsOneWidget);
···
117
131
authProvider.overrideWith(
118
132
() => _TestAuthNotifier(AuthState.authenticated(_testSession())),
119
133
),
134
+
unreadCountProvider.overrideWith(() => _TestUnreadCountNotifier(Stream.value(0))),
120
135
],
121
136
);
122
137
expect(find.text('Home Content'), findsOneWidget);
···
136
151
authProvider.overrideWith(
137
152
() => _TestAuthNotifier(AuthState.authenticated(_testSession())),
138
153
),
154
+
unreadCountProvider.overrideWith(() => _TestUnreadCountNotifier(Stream.value(0))),
139
155
],
140
156
);
141
157
expect(find.byIcon(Icons.home), findsOneWidget);
···
155
171
156
172
await tester.pumpRouterApp(
157
173
router: router,
158
-
overrides: [authProvider.overrideWith(() => authNotifier)],
174
+
overrides: [
175
+
authProvider.overrideWith(() => authNotifier),
176
+
unreadCountProvider.overrideWith(() => _TestUnreadCountNotifier(Stream.value(0))),
177
+
],
159
178
);
160
179
161
180
expect(find.byType(NavigationDestination), findsNWidgets(2));
···
179
198
180
199
await tester.pumpRouterApp(
181
200
router: router,
182
-
overrides: [authProvider.overrideWith(() => authNotifier)],
201
+
overrides: [
202
+
authProvider.overrideWith(() => authNotifier),
203
+
unreadCountProvider.overrideWith(() => _TestUnreadCountNotifier(Stream.value(0))),
204
+
],
183
205
);
184
206
185
207
expect(find.byType(NavigationDestination), findsNWidgets(5));
···
203
225
204
226
await tester.pumpRouterApp(
205
227
router: router,
206
-
overrides: [authProvider.overrideWith(() => authNotifier)],
228
+
overrides: [
229
+
authProvider.overrideWith(() => authNotifier),
230
+
unreadCountProvider.overrideWith(() => _TestUnreadCountNotifier(Stream.value(0))),
231
+
],
207
232
);
208
233
209
234
await tester.tap(find.text('Messages'));
···
271
296
authProvider.overrideWith(
272
297
() => _TestAuthNotifier(AuthState.authenticated(_testSession())),
273
298
),
299
+
unreadCountProvider.overrideWith(() => _TestUnreadCountNotifier(Stream.value(0))),
274
300
],
275
301
);
276
302
···
285
311
authProvider.overrideWith(
286
312
() => _TestAuthNotifier(AuthState.authenticated(_testSession())),
287
313
),
314
+
unreadCountProvider.overrideWith(() => _TestUnreadCountNotifier(Stream.value(0))),
288
315
],
289
316
);
290
317
···
302
329
authProvider.overrideWith(
303
330
() => _TestAuthNotifier(AuthState.authenticated(_testSession())),
304
331
),
332
+
unreadCountProvider.overrideWith(() => _TestUnreadCountNotifier(Stream.value(0))),
305
333
],
306
334
);
307
335
···
319
347
authProvider.overrideWith(
320
348
() => _TestAuthNotifier(AuthState.authenticated(_testSession())),
321
349
),
350
+
unreadCountProvider.overrideWith(() => _TestUnreadCountNotifier(Stream.value(0))),
322
351
],
323
352
);
324
353
···
372
401
authProvider.overrideWith(
373
402
() => _TestAuthNotifier(AuthState.authenticated(_testSession())),
374
403
),
404
+
unreadCountProvider.overrideWith(() => _TestUnreadCountNotifier(Stream.value(0))),
375
405
],
376
406
);
377
407
···
391
421
authProvider.overrideWith(
392
422
() => _TestAuthNotifier(AuthState.authenticated(_testSession())),
393
423
),
424
+
unreadCountProvider.overrideWith(() => _TestUnreadCountNotifier(Stream.value(0))),
394
425
],
395
426
);
396
427
···
407
438
await tester.tap(find.text('Profile'));
408
439
await tester.pumpAndSettle();
409
440
expect(find.byType(GlobalComposeFab), findsOneWidget);
441
+
});
442
+
});
443
+
444
+
group('TabScaffold - Unread Badge', () {
445
+
testWidgets('uses UnreadBadge widget for notifications tab', (tester) async {
446
+
final router = _createTestRouter();
447
+
final unreadCountController = StreamController<int>();
448
+
449
+
await tester.pumpRouterApp(
450
+
router: router,
451
+
overrides: [
452
+
authProvider.overrideWith(
453
+
() => _TestAuthNotifier(AuthState.authenticated(_testSession())),
454
+
),
455
+
unreadCountProvider.overrideWith(
456
+
() => _TestUnreadCountNotifier(unreadCountController.stream),
457
+
),
458
+
],
459
+
);
460
+
461
+
unreadCountController.add(5);
462
+
await tester.pumpAndSettle();
463
+
464
+
expect(find.byType(UnreadBadge), findsAtLeastNWidgets(1));
465
+
expect(find.text('5'), findsOneWidget);
466
+
467
+
await unreadCountController.close();
468
+
});
469
+
470
+
testWidgets('shows badge with count on notifications tab when unread count > 0', (
471
+
tester,
472
+
) async {
473
+
final router = _createTestRouter();
474
+
final unreadCountController = StreamController<int>();
475
+
476
+
await tester.pumpRouterApp(
477
+
router: router,
478
+
overrides: [
479
+
authProvider.overrideWith(
480
+
() => _TestAuthNotifier(AuthState.authenticated(_testSession())),
481
+
),
482
+
unreadCountProvider.overrideWith(
483
+
() => _TestUnreadCountNotifier(unreadCountController.stream),
484
+
),
485
+
],
486
+
);
487
+
488
+
unreadCountController.add(5);
489
+
await tester.pumpAndSettle();
490
+
491
+
expect(find.byType(Badge), findsAtLeastNWidgets(1));
492
+
expect(find.text('5'), findsOneWidget);
493
+
494
+
await unreadCountController.close();
495
+
});
496
+
497
+
testWidgets('hides badge when unread count is 0', (tester) async {
498
+
final router = _createTestRouter();
499
+
final unreadCountController = StreamController<int>();
500
+
501
+
await tester.pumpRouterApp(
502
+
router: router,
503
+
overrides: [
504
+
authProvider.overrideWith(
505
+
() => _TestAuthNotifier(AuthState.authenticated(_testSession())),
506
+
),
507
+
unreadCountProvider.overrideWith(
508
+
() => _TestUnreadCountNotifier(unreadCountController.stream),
509
+
),
510
+
],
511
+
);
512
+
513
+
unreadCountController.add(0);
514
+
await tester.pumpAndSettle();
515
+
516
+
final badges = tester.widgetList<Badge>(find.byType(Badge));
517
+
for (final badge in badges) {
518
+
expect(badge.isLabelVisible, false);
519
+
}
520
+
521
+
await unreadCountController.close();
522
+
});
523
+
524
+
testWidgets('updates badge count reactively', (tester) async {
525
+
final router = _createTestRouter();
526
+
final unreadCountController = StreamController<int>();
527
+
528
+
await tester.pumpRouterApp(
529
+
router: router,
530
+
overrides: [
531
+
authProvider.overrideWith(
532
+
() => _TestAuthNotifier(AuthState.authenticated(_testSession())),
533
+
),
534
+
unreadCountProvider.overrideWith(
535
+
() => _TestUnreadCountNotifier(unreadCountController.stream),
536
+
),
537
+
],
538
+
);
539
+
540
+
unreadCountController.add(3);
541
+
await tester.pumpAndSettle();
542
+
expect(find.text('3'), findsOneWidget);
543
+
544
+
unreadCountController.add(10);
545
+
await tester.pumpAndSettle();
546
+
expect(find.text('10'), findsOneWidget);
547
+
expect(find.text('3'), findsNothing);
548
+
549
+
unreadCountController.add(0);
550
+
await tester.pumpAndSettle();
551
+
final badges = tester.widgetList<Badge>(find.byType(Badge));
552
+
for (final badge in badges) {
553
+
expect(badge.isLabelVisible, false);
554
+
}
555
+
556
+
await unreadCountController.close();
557
+
});
558
+
559
+
testWidgets('badge appears on both selected and unselected notification icons', (
560
+
tester,
561
+
) async {
562
+
final router = _createTestRouter();
563
+
final unreadCountController = StreamController<int>();
564
+
565
+
await tester.pumpRouterApp(
566
+
router: router,
567
+
overrides: [
568
+
authProvider.overrideWith(
569
+
() => _TestAuthNotifier(AuthState.authenticated(_testSession())),
570
+
),
571
+
unreadCountProvider.overrideWith(
572
+
() => _TestUnreadCountNotifier(unreadCountController.stream),
573
+
),
574
+
],
575
+
);
576
+
577
+
unreadCountController.add(7);
578
+
await tester.pumpAndSettle();
579
+
580
+
await tester.tap(find.text('Notifications'));
581
+
await tester.pumpAndSettle();
582
+
583
+
expect(find.byType(Badge), findsAtLeastNWidgets(1));
584
+
expect(find.text('7'), findsOneWidget);
585
+
586
+
await unreadCountController.close();
587
+
});
588
+
589
+
testWidgets('no badge shown when unauthenticated', (tester) async {
590
+
final router = _createTestRouter();
591
+
final unreadCountController = StreamController<int>();
592
+
593
+
await tester.pumpRouterApp(
594
+
router: router,
595
+
overrides: [
596
+
authProvider.overrideWith(() => _TestAuthNotifier(const AuthState.unauthenticated())),
597
+
unreadCountProvider.overrideWith(
598
+
() => _TestUnreadCountNotifier(unreadCountController.stream),
599
+
),
600
+
],
601
+
);
602
+
603
+
unreadCountController.add(5);
604
+
await tester.pumpAndSettle();
605
+
606
+
expect(find.text('Notifications'), findsNothing);
607
+
expect(find.byType(Badge), findsNothing);
608
+
609
+
await unreadCountController.close();
610
+
});
611
+
612
+
testWidgets('displays "99+" for counts greater than 99', (tester) async {
613
+
final router = _createTestRouter();
614
+
final unreadCountController = StreamController<int>();
615
+
616
+
await tester.pumpRouterApp(
617
+
router: router,
618
+
overrides: [
619
+
authProvider.overrideWith(
620
+
() => _TestAuthNotifier(AuthState.authenticated(_testSession())),
621
+
),
622
+
unreadCountProvider.overrideWith(
623
+
() => _TestUnreadCountNotifier(unreadCountController.stream),
624
+
),
625
+
],
626
+
);
627
+
628
+
unreadCountController.add(150);
629
+
await tester.pumpAndSettle();
630
+
631
+
expect(find.text('99+'), findsOneWidget);
632
+
expect(find.text('150'), findsNothing);
633
+
634
+
await unreadCountController.close();
635
+
});
636
+
637
+
testWidgets('displays exact count for 99 notifications', (tester) async {
638
+
final router = _createTestRouter();
639
+
final unreadCountController = StreamController<int>();
640
+
641
+
await tester.pumpRouterApp(
642
+
router: router,
643
+
overrides: [
644
+
authProvider.overrideWith(
645
+
() => _TestAuthNotifier(AuthState.authenticated(_testSession())),
646
+
),
647
+
unreadCountProvider.overrideWith(
648
+
() => _TestUnreadCountNotifier(unreadCountController.stream),
649
+
),
650
+
],
651
+
);
652
+
653
+
unreadCountController.add(99);
654
+
await tester.pumpAndSettle();
655
+
656
+
expect(find.text('99'), findsOneWidget);
657
+
expect(find.text('99+'), findsNothing);
658
+
659
+
await unreadCountController.close();
410
660
});
411
661
});
412
662
}
+189
test/src/features/notifications/application/unread_count_notifier_test.dart
+189
test/src/features/notifications/application/unread_count_notifier_test.dart
···
1
+
import 'dart:async';
2
+
3
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
4
+
import 'package:flutter_test/flutter_test.dart';
5
+
import 'package:lazurite/src/core/auth/session_model.dart';
6
+
import 'package:lazurite/src/core/utils/logger_provider.dart';
7
+
import 'package:lazurite/src/features/auth/application/auth_providers.dart';
8
+
import 'package:lazurite/src/features/auth/domain/auth_state.dart';
9
+
import 'package:lazurite/src/features/notifications/application/notifications_providers.dart';
10
+
import 'package:lazurite/src/features/notifications/application/unread_count_notifier.dart';
11
+
import 'package:mocktail/mocktail.dart';
12
+
13
+
import '../../../../helpers/mocks.dart';
14
+
15
+
void main() {
16
+
late MockNotificationsRepository mockRepository;
17
+
late MockLogger mockLogger;
18
+
19
+
setUpAll(() {
20
+
registerFallbackValue(const AsyncLoading<int>());
21
+
});
22
+
23
+
ProviderContainer createContainer({bool authenticated = true}) {
24
+
return ProviderContainer(
25
+
overrides: [
26
+
notificationsRepositoryProvider.overrideWithValue(mockRepository),
27
+
loggerProvider('UnreadCountNotifier').overrideWithValue(mockLogger),
28
+
authProvider.overrideWith(() => _FakeAuthNotifier(authenticated: authenticated)),
29
+
],
30
+
);
31
+
}
32
+
33
+
setUp(() {
34
+
mockRepository = MockNotificationsRepository();
35
+
mockLogger = MockLogger();
36
+
37
+
when(() => mockLogger.debug(any(), any())).thenReturn(null);
38
+
when(() => mockLogger.info(any(), any())).thenReturn(null);
39
+
when(() => mockLogger.error(any(), any(), any())).thenReturn(null);
40
+
41
+
when(() => mockRepository.watchUnreadCount()).thenAnswer((_) => Stream.value(0));
42
+
});
43
+
44
+
group('UnreadCountNotifier', () {
45
+
test('build watches unread count stream when authenticated', () async {
46
+
final container = createContainer();
47
+
addTearDown(container.dispose);
48
+
49
+
container.read(unreadCountProvider);
50
+
51
+
await pumpEventQueue();
52
+
53
+
verify(() => mockRepository.watchUnreadCount()).called(1);
54
+
});
55
+
56
+
test('build returns 0 when not authenticated', () async {
57
+
final container = createContainer(authenticated: false);
58
+
addTearDown(container.dispose);
59
+
60
+
int? emittedValue;
61
+
container.listen(
62
+
unreadCountProvider,
63
+
(previous, next) {
64
+
next.whenData((value) => emittedValue = value);
65
+
},
66
+
);
67
+
68
+
await pumpEventQueue();
69
+
70
+
expect(emittedValue, 0);
71
+
verifyNever(() => mockRepository.watchUnreadCount());
72
+
});
73
+
74
+
test('stream emits unread count from repository', () async {
75
+
when(() => mockRepository.watchUnreadCount()).thenAnswer((_) => Stream.value(5));
76
+
77
+
final container = createContainer();
78
+
addTearDown(container.dispose);
79
+
80
+
int? emittedValue;
81
+
container.listen(
82
+
unreadCountProvider,
83
+
(previous, next) {
84
+
next.whenData((value) => emittedValue = value);
85
+
},
86
+
);
87
+
88
+
await pumpEventQueue();
89
+
90
+
expect(emittedValue, 5);
91
+
verify(() => mockRepository.watchUnreadCount()).called(1);
92
+
});
93
+
94
+
test('stream emits multiple updates as count changes', () async {
95
+
final controller = StreamController<int>();
96
+
when(() => mockRepository.watchUnreadCount()).thenAnswer((_) => controller.stream);
97
+
98
+
final container = createContainer();
99
+
addTearDown(() {
100
+
controller.close();
101
+
container.dispose();
102
+
});
103
+
104
+
final emittedValues = <int>[];
105
+
container.listen(
106
+
unreadCountProvider,
107
+
(previous, next) {
108
+
next.whenData((value) => emittedValues.add(value));
109
+
},
110
+
);
111
+
112
+
controller.add(3);
113
+
await pumpEventQueue();
114
+
115
+
controller.add(5);
116
+
await pumpEventQueue();
117
+
118
+
controller.add(0);
119
+
await pumpEventQueue();
120
+
121
+
expect(emittedValues, [3, 5, 0]);
122
+
});
123
+
124
+
test('stream handles zero unread count', () async {
125
+
when(() => mockRepository.watchUnreadCount()).thenAnswer((_) => Stream.value(0));
126
+
127
+
final container = createContainer();
128
+
addTearDown(container.dispose);
129
+
130
+
int? emittedValue;
131
+
container.listen(
132
+
unreadCountProvider,
133
+
(previous, next) {
134
+
next.whenData((value) => emittedValue = value);
135
+
},
136
+
);
137
+
138
+
await pumpEventQueue();
139
+
140
+
expect(emittedValue, 0);
141
+
verify(() => mockRepository.watchUnreadCount()).called(1);
142
+
});
143
+
144
+
test('stream handles large unread counts', () async {
145
+
when(() => mockRepository.watchUnreadCount()).thenAnswer((_) => Stream.value(9999));
146
+
147
+
final container = createContainer();
148
+
addTearDown(container.dispose);
149
+
150
+
int? emittedValue;
151
+
container.listen(
152
+
unreadCountProvider,
153
+
(previous, next) {
154
+
next.whenData((value) => emittedValue = value);
155
+
},
156
+
);
157
+
158
+
await pumpEventQueue();
159
+
160
+
expect(emittedValue, 9999);
161
+
});
162
+
});
163
+
}
164
+
165
+
class _FakeAuthNotifier extends AuthNotifier {
166
+
_FakeAuthNotifier({required this.authenticated});
167
+
168
+
final bool authenticated;
169
+
170
+
@override
171
+
AuthState build() {
172
+
if (authenticated) {
173
+
return AuthState.authenticated(
174
+
Session(
175
+
did: 'did:plc:test',
176
+
scope: 'test',
177
+
handle: 'test.bsky.social',
178
+
accessJwt: 'access',
179
+
refreshJwt: 'refresh',
180
+
pdsUrl: 'https://bsky.social',
181
+
dpopKey: {'kty': 'OKP'},
182
+
expiresAt: DateTime.now().add(const Duration(minutes: 30)),
183
+
),
184
+
);
185
+
}
186
+
187
+
return const AuthState.unauthenticated();
188
+
}
189
+
}
+226
test/src/features/notifications/presentation/widgets/unread_badge_test.dart
+226
test/src/features/notifications/presentation/widgets/unread_badge_test.dart
···
1
+
import 'package:flutter/material.dart';
2
+
import 'package:flutter_test/flutter_test.dart';
3
+
import 'package:lazurite/src/features/notifications/presentation/widgets/unread_badge.dart';
4
+
5
+
void main() {
6
+
group('UnreadBadge', () {
7
+
testWidgets('displays badge with count when count > 0', (tester) async {
8
+
await tester.pumpWidget(
9
+
const MaterialApp(
10
+
home: Scaffold(body: UnreadBadge(count: 5, child: Icon(Icons.notifications))),
11
+
),
12
+
);
13
+
14
+
expect(find.byType(Badge), findsOneWidget);
15
+
expect(find.text('5'), findsOneWidget);
16
+
17
+
final badge = tester.widget<Badge>(find.byType(Badge));
18
+
expect(badge.isLabelVisible, isTrue);
19
+
});
20
+
21
+
testWidgets('hides badge when count is 0', (tester) async {
22
+
await tester.pumpWidget(
23
+
const MaterialApp(
24
+
home: Scaffold(body: UnreadBadge(count: 0, child: Icon(Icons.notifications))),
25
+
),
26
+
);
27
+
28
+
expect(find.byType(Badge), findsOneWidget);
29
+
final badge = tester.widget<Badge>(find.byType(Badge));
30
+
expect(badge.isLabelVisible, isFalse);
31
+
});
32
+
33
+
testWidgets('displays "99+" for counts greater than 99', (tester) async {
34
+
await tester.pumpWidget(
35
+
const MaterialApp(
36
+
home: Scaffold(body: UnreadBadge(count: 150, child: Icon(Icons.notifications))),
37
+
),
38
+
);
39
+
40
+
expect(find.text('99+'), findsOneWidget);
41
+
expect(find.text('150'), findsNothing);
42
+
43
+
final badge = tester.widget<Badge>(find.byType(Badge));
44
+
expect(badge.isLabelVisible, isTrue);
45
+
});
46
+
47
+
testWidgets('displays exact count for count = 99', (tester) async {
48
+
await tester.pumpWidget(
49
+
const MaterialApp(
50
+
home: Scaffold(body: UnreadBadge(count: 99, child: Icon(Icons.notifications))),
51
+
),
52
+
);
53
+
54
+
expect(find.text('99'), findsOneWidget);
55
+
expect(find.text('99+'), findsNothing);
56
+
});
57
+
58
+
testWidgets('displays "99+" for count = 100', (tester) async {
59
+
await tester.pumpWidget(
60
+
const MaterialApp(
61
+
home: Scaffold(body: UnreadBadge(count: 100, child: Icon(Icons.notifications))),
62
+
),
63
+
);
64
+
65
+
expect(find.text('99+'), findsOneWidget);
66
+
expect(find.text('100'), findsNothing);
67
+
});
68
+
69
+
testWidgets('displays single digit counts correctly', (tester) async {
70
+
await tester.pumpWidget(
71
+
const MaterialApp(
72
+
home: Scaffold(body: UnreadBadge(count: 1, child: Icon(Icons.notifications))),
73
+
),
74
+
);
75
+
76
+
expect(find.text('1'), findsOneWidget);
77
+
});
78
+
79
+
testWidgets('displays double digit counts correctly', (tester) async {
80
+
await tester.pumpWidget(
81
+
const MaterialApp(
82
+
home: Scaffold(body: UnreadBadge(count: 42, child: Icon(Icons.notifications))),
83
+
),
84
+
);
85
+
86
+
expect(find.text('42'), findsOneWidget);
87
+
});
88
+
89
+
testWidgets('renders child widget correctly', (tester) async {
90
+
const testIcon = Icon(Icons.notifications_outlined, key: Key('test-icon'));
91
+
92
+
await tester.pumpWidget(
93
+
const MaterialApp(
94
+
home: Scaffold(body: UnreadBadge(count: 3, child: testIcon)),
95
+
),
96
+
);
97
+
98
+
expect(find.byKey(const Key('test-icon')), findsOneWidget);
99
+
expect(find.byIcon(Icons.notifications_outlined), findsOneWidget);
100
+
});
101
+
102
+
testWidgets('badge updates when count changes from 0 to positive', (tester) async {
103
+
await tester.pumpWidget(
104
+
const MaterialApp(
105
+
home: Scaffold(body: UnreadBadge(count: 0, child: Icon(Icons.notifications))),
106
+
),
107
+
);
108
+
109
+
Badge badge = tester.widget<Badge>(find.byType(Badge));
110
+
expect(badge.isLabelVisible, isFalse);
111
+
112
+
await tester.pumpWidget(
113
+
const MaterialApp(
114
+
home: Scaffold(body: UnreadBadge(count: 5, child: Icon(Icons.notifications))),
115
+
),
116
+
);
117
+
118
+
badge = tester.widget<Badge>(find.byType(Badge));
119
+
expect(badge.isLabelVisible, isTrue);
120
+
expect(find.text('5'), findsOneWidget);
121
+
});
122
+
123
+
testWidgets('badge updates when count changes from positive to 0', (tester) async {
124
+
await tester.pumpWidget(
125
+
const MaterialApp(
126
+
home: Scaffold(body: UnreadBadge(count: 10, child: Icon(Icons.notifications))),
127
+
),
128
+
);
129
+
130
+
Badge badge = tester.widget<Badge>(find.byType(Badge));
131
+
expect(badge.isLabelVisible, isTrue);
132
+
expect(find.text('10'), findsOneWidget);
133
+
134
+
await tester.pumpWidget(
135
+
const MaterialApp(
136
+
home: Scaffold(body: UnreadBadge(count: 0, child: Icon(Icons.notifications))),
137
+
),
138
+
);
139
+
140
+
badge = tester.widget<Badge>(find.byType(Badge));
141
+
expect(badge.isLabelVisible, isFalse);
142
+
});
143
+
144
+
testWidgets('badge updates when count changes from below 99 to above 99', (tester) async {
145
+
await tester.pumpWidget(
146
+
const MaterialApp(
147
+
home: Scaffold(body: UnreadBadge(count: 50, child: Icon(Icons.notifications))),
148
+
),
149
+
);
150
+
151
+
expect(find.text('50'), findsOneWidget);
152
+
153
+
await tester.pumpWidget(
154
+
const MaterialApp(
155
+
home: Scaffold(body: UnreadBadge(count: 150, child: Icon(Icons.notifications))),
156
+
),
157
+
);
158
+
159
+
expect(find.text('99+'), findsOneWidget);
160
+
expect(find.text('50'), findsNothing);
161
+
});
162
+
163
+
group('Semantics', () {
164
+
testWidgets('provides semantic label when count > 0', (tester) async {
165
+
await tester.pumpWidget(
166
+
const MaterialApp(
167
+
home: Scaffold(body: UnreadBadge(count: 7, child: Icon(Icons.notifications))),
168
+
),
169
+
);
170
+
171
+
expect(
172
+
tester.getSemantics(find.byType(Icon)),
173
+
matchesSemantics(label: '7 unread notifications'),
174
+
);
175
+
});
176
+
177
+
testWidgets('no semantic label when count is 0', (tester) async {
178
+
await tester.pumpWidget(
179
+
const MaterialApp(
180
+
home: Scaffold(body: UnreadBadge(count: 0, child: Icon(Icons.notifications))),
181
+
),
182
+
);
183
+
184
+
final semantics = tester.getSemantics(find.byType(Icon));
185
+
expect(semantics.label, anyOf(isNull, isEmpty));
186
+
});
187
+
188
+
testWidgets('semantic label uses actual count for large numbers', (tester) async {
189
+
await tester.pumpWidget(
190
+
const MaterialApp(
191
+
home: Scaffold(body: UnreadBadge(count: 250, child: Icon(Icons.notifications))),
192
+
),
193
+
);
194
+
195
+
expect(
196
+
tester.getSemantics(find.byType(Icon)),
197
+
matchesSemantics(label: '250 unread notifications'),
198
+
);
199
+
});
200
+
});
201
+
202
+
group('Edge Cases', () {
203
+
testWidgets('handles negative count gracefully', (tester) async {
204
+
await tester.pumpWidget(
205
+
const MaterialApp(
206
+
home: Scaffold(body: UnreadBadge(count: -5, child: Icon(Icons.notifications))),
207
+
),
208
+
);
209
+
210
+
final badge = tester.widget<Badge>(find.byType(Badge));
211
+
expect(badge.isLabelVisible, isFalse);
212
+
});
213
+
214
+
testWidgets('handles very large counts', (tester) async {
215
+
await tester.pumpWidget(
216
+
const MaterialApp(
217
+
home: Scaffold(body: UnreadBadge(count: 999999, child: Icon(Icons.notifications))),
218
+
),
219
+
);
220
+
221
+
expect(find.text('99+'), findsOneWidget);
222
+
expect(find.text('999999'), findsNothing);
223
+
});
224
+
});
225
+
});
226
+
}