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

feat: add unread notification count widget & notifier

Changed files
+873 -33
doc
lib
src
core
features
notifications
infrastructure
test
src
core
features
notifications
application
presentation
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 + }