diff --git a/pubspec.lock b/pubspec.lock --- a/pubspec.lock +++ b/pubspec.lock @@ -542,6 +542,38 @@ url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" + url: "https://pub.dev" + source: hosted + version: "19.5.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" + url: "https://pub.dev" + source: hosted + version: "9.1.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" + url: "https://pub.dev" + source: hosted + version: "1.0.3" flutter_native_splash: dependency: "direct dev" description: @@ -1477,6 +1509,14 @@ url: "https://pub.dev" source: hosted version: "0.12.1" + timezone: + dependency: transitive + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,7 @@ flutter_animate: ^4.5.2 cached_network_image: ^3.4.1 flutter_cache_manager: ^3.4.1 + flutter_local_notifications: ^19.4.2 dev_dependencies: flutter_test: diff --git a/lib/main.dart b/lib/main.dart --- a/lib/main.dart +++ b/lib/main.dart @@ -40,6 +40,11 @@ import 'package:lazurite/features/messages/data/convo_repository.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; import 'package:lazurite/features/notifications/data/notification_repository.dart'; +import 'package:lazurite/features/notifications/data/flutter_local_notification_adapter.dart'; +import 'package:lazurite/features/notifications/domain/local_notification_adapter.dart'; +import 'package:lazurite/features/notifications/domain/notification_deep_link_navigator.dart'; +import 'package:lazurite/features/notifications/domain/notification_domain_service.dart'; +import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; import 'package:lazurite/features/profile/data/profile_action_repository.dart'; import 'package:lazurite/features/profile/data/profile_repository.dart'; @@ -96,6 +101,7 @@ final accountSwitcherCubit = AccountSwitcherCubit(database: database, authRepository: authRepository); await accountSwitcherCubit.loadAccounts(); + final localNotificationAdapter = FlutterLocalNotificationAdapter(); log.i('AppLogger: App started'); @@ -109,6 +115,7 @@ settingsCubit, connectivityCubit, accountSwitcherCubit, + localNotificationAdapter, ), ); } @@ -124,6 +131,7 @@ required this.settingsCubit, required this.connectivityCubit, required this.accountSwitcherCubit, + required this.localNotificationAdapter, }); final AuthBloc authBloc; @@ -134,6 +142,7 @@ final SettingsCubit settingsCubit; final ConnectivityCubit connectivityCubit; final AccountSwitcherCubit accountSwitcherCubit; + final LocalNotificationAdapter localNotificationAdapter; /// factory constructor with positional params static LazuriteApp from( @@ -145,6 +154,7 @@ SettingsCubit settingsCubit, ConnectivityCubit connectivityCubit, AccountSwitcherCubit accountSwitcherCubit, + LocalNotificationAdapter localNotificationAdapter, ) => LazuriteApp( authBloc: authBloc, database: database, @@ -154,6 +164,7 @@ settingsCubit: settingsCubit, connectivityCubit: connectivityCubit, accountSwitcherCubit: accountSwitcherCubit, + localNotificationAdapter: localNotificationAdapter, ); @override @@ -178,6 +189,11 @@ _routerSessionKey = _sessionKeyFor(widget.authBloc.state); _observedAppViewProvider = widget.settingsCubit.state.appViewProvider; _router = _createRouter(); + unawaited( + widget.localNotificationAdapter.initialize(onTap: _handleNotificationDeepLink).then((_) { + return widget.localNotificationAdapter.requestPermissions(); + }), + ); _authSubscription = widget.authBloc.stream.map(_sessionKeyFor).distinct().listen(_handleSessionKeyChanged); _simulateOfflineSubscription = widget.settingsCubit.stream .map((state) => state.simulateOffline) @@ -264,6 +280,18 @@ }); unawaited(widget.settingsCubit.refreshAppViewHealth()); } + } + + void _handleNotificationDeepLink(NotificationDeepLink deepLink) { + if (!mounted) { + return; + } + NotificationDeepLinkNavigator.navigate(_router, deepLink); + } + + bool _isAlertsRouteActive() { + final path = _router.routerDelegate.currentConfiguration.uri.path; + return path.startsWith('/alerts'); } Bluesky? _createBluesky(AuthState state) => state.isAuthenticated ? createBlueskyClient(state.tokens) : null; @@ -416,6 +444,15 @@ bluesky: bluesky, moderationService: context.read(), appViewProviderResolver: () => context.read().state.appViewProvider, + ), + ), + RepositoryProvider( + create: (context) => NotificationDomainService( + notificationRepository: context.read(), + database: widget.database, + accountDid: accountDid, + localNotificationAdapter: widget.localNotificationAdapter, + shouldSuppressLocalNotifications: _isAlertsRouteActive, ), ), RepositoryProvider( diff --git a/docs/tasks/notification.md b/docs/tasks/notification.md --- a/docs/tasks/notification.md +++ b/docs/tasks/notification.md @@ -5,18 +5,18 @@ ## M1 - Foundation Hardening (Polling Baseline) -- [ ] Introduce `NotificationDomainService` orchestration layer -- [ ] Add Drift `notification_deliveries` table with migration -- [ ] Route existing polling paths through orchestration layer -- [ ] Add unit tests for dedupe and state persistence +- [x] Introduce `NotificationDomainService` orchestration layer +- [x] Add Drift `notification_deliveries` table with migration +- [x] Route existing polling paths through orchestration layer +- [x] Add unit tests for dedupe and state persistence ## M2 - Local Notifications from Reconcile -- [ ] Add local notification adapter abstraction -- [ ] Android channels by reason family -- [ ] iOS category + payload deep-link mapping -- [ ] Show local notifications for newly discovered unseen items -- [ ] Add widget/integration tests for tap -> route behavior +- [x] Add local notification adapter abstraction +- [x] Android channels by reason family +- [x] iOS category + payload deep-link mapping +- [x] Show local notifications for newly discovered unseen items +- [x] Add widget/integration tests for tap -> route behavior ## M3 - Push Registration Lifecycle diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -19,6 +19,7 @@ Drafts, SavedPosts, LabelerCache, + NotificationDeliveries, LikedPosts, ], ) @@ -28,12 +29,16 @@ static const activeAccountDidSettingKey = 'active_account_did'; @override - int get schemaVersion => 20; + int get schemaVersion => 21; @override MigrationStrategy get migration => MigrationStrategy( onCreate: (migrator) async { await migrator.createAll(); + await customStatement( + 'CREATE INDEX IF NOT EXISTS idx_notification_deliveries_notification_uri ' + 'ON notification_deliveries(notification_uri)', + ); await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('typeahead_provider', 'bluesky')"); await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('appview_provider', 'bluesky')"); await customStatement( @@ -142,6 +147,13 @@ if (from < 20) { await migrator.createTable(cachedFeedPosts); await migrator.createTable(cachedThreadRoots); + } + if (from < 21) { + await migrator.createTable(notificationDeliveries); + await customStatement( + 'CREATE INDEX IF NOT EXISTS idx_notification_deliveries_notification_uri ' + 'ON notification_deliveries(notification_uri)', + ); } }, ); @@ -578,4 +590,54 @@ Future deleteAllLikedPosts(String accountDid) => (delete(likedPosts)..where((l) => l.accountDid.equals(accountDid))).go(); + + Future recordNotificationDelivery({ + required String accountDid, + required String notificationUri, + String? notificationCid, + required String reason, + required DateTime indexedAt, + required String source, + DateTime? deliveredAt, + }) async { + final existing = await getNotificationDelivery(accountDid, notificationUri); + if (existing == null) { + await into(notificationDeliveries).insert( + NotificationDeliveriesCompanion.insert( + accountDid: accountDid, + notificationUri: notificationUri, + notificationCid: Value(notificationCid), + reason: reason, + indexedAt: indexedAt, + source: source, + deliveredAt: Value(deliveredAt ?? DateTime.now()), + ), + ); + return true; + } + + await (update( + notificationDeliveries, + )..where((entry) => entry.accountDid.equals(accountDid) & entry.notificationUri.equals(notificationUri))).write( + NotificationDeliveriesCompanion( + notificationCid: notificationCid != null ? Value(notificationCid) : const Value.absent(), + reason: Value(reason), + indexedAt: Value(indexedAt), + source: Value(source), + ), + ); + + return false; + } + + Future getNotificationDelivery(String accountDid, String notificationUri) { + return (select(notificationDeliveries) + ..where((entry) => entry.accountDid.equals(accountDid) & entry.notificationUri.equals(notificationUri))) + .getSingleOrNull(); + } + + Future countNotificationDeliveries(String accountDid) async { + final rows = await (select(notificationDeliveries)..where((entry) => entry.accountDid.equals(accountDid))).get(); + return rows.length; + } } diff --git a/lib/core/database/app_database.g.dart b/lib/core/database/app_database.g.dart --- a/lib/core/database/app_database.g.dart +++ b/lib/core/database/app_database.g.dart @@ -5012,6 +5012,632 @@ } } +class $NotificationDeliveriesTable extends NotificationDeliveries + with TableInfo<$NotificationDeliveriesTable, NotificationDeliveryEntry> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $NotificationDeliveriesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _accountDidMeta = const VerificationMeta( + 'accountDid', + ); + @override + late final GeneratedColumn accountDid = GeneratedColumn( + 'account_did', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _notificationUriMeta = const VerificationMeta( + 'notificationUri', + ); + @override + late final GeneratedColumn notificationUri = GeneratedColumn( + 'notification_uri', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _notificationCidMeta = const VerificationMeta( + 'notificationCid', + ); + @override + late final GeneratedColumn notificationCid = GeneratedColumn( + 'notification_cid', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _reasonMeta = const VerificationMeta('reason'); + @override + late final GeneratedColumn reason = GeneratedColumn( + 'reason', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _indexedAtMeta = const VerificationMeta( + 'indexedAt', + ); + @override + late final GeneratedColumn indexedAt = GeneratedColumn( + 'indexed_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _sourceMeta = const VerificationMeta('source'); + @override + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _deliveredAtMeta = const VerificationMeta( + 'deliveredAt', + ); + @override + late final GeneratedColumn deliveredAt = GeneratedColumn( + 'delivered_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + static const VerificationMeta _openedAtMeta = const VerificationMeta( + 'openedAt', + ); + @override + late final GeneratedColumn openedAt = GeneratedColumn( + 'opened_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _dismissedAtMeta = const VerificationMeta( + 'dismissedAt', + ); + @override + late final GeneratedColumn dismissedAt = GeneratedColumn( + 'dismissed_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + accountDid, + notificationUri, + notificationCid, + reason, + indexedAt, + source, + deliveredAt, + openedAt, + dismissedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'notification_deliveries'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('account_did')) { + context.handle( + _accountDidMeta, + accountDid.isAcceptableOrUnknown(data['account_did']!, _accountDidMeta), + ); + } else if (isInserting) { + context.missing(_accountDidMeta); + } + if (data.containsKey('notification_uri')) { + context.handle( + _notificationUriMeta, + notificationUri.isAcceptableOrUnknown( + data['notification_uri']!, + _notificationUriMeta, + ), + ); + } else if (isInserting) { + context.missing(_notificationUriMeta); + } + if (data.containsKey('notification_cid')) { + context.handle( + _notificationCidMeta, + notificationCid.isAcceptableOrUnknown( + data['notification_cid']!, + _notificationCidMeta, + ), + ); + } + if (data.containsKey('reason')) { + context.handle( + _reasonMeta, + reason.isAcceptableOrUnknown(data['reason']!, _reasonMeta), + ); + } else if (isInserting) { + context.missing(_reasonMeta); + } + if (data.containsKey('indexed_at')) { + context.handle( + _indexedAtMeta, + indexedAt.isAcceptableOrUnknown(data['indexed_at']!, _indexedAtMeta), + ); + } else if (isInserting) { + context.missing(_indexedAtMeta); + } + if (data.containsKey('source')) { + context.handle( + _sourceMeta, + source.isAcceptableOrUnknown(data['source']!, _sourceMeta), + ); + } else if (isInserting) { + context.missing(_sourceMeta); + } + if (data.containsKey('delivered_at')) { + context.handle( + _deliveredAtMeta, + deliveredAt.isAcceptableOrUnknown( + data['delivered_at']!, + _deliveredAtMeta, + ), + ); + } + if (data.containsKey('opened_at')) { + context.handle( + _openedAtMeta, + openedAt.isAcceptableOrUnknown(data['opened_at']!, _openedAtMeta), + ); + } + if (data.containsKey('dismissed_at')) { + context.handle( + _dismissedAtMeta, + dismissedAt.isAcceptableOrUnknown( + data['dismissed_at']!, + _dismissedAtMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + NotificationDeliveryEntry map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return NotificationDeliveryEntry( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + accountDid: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}account_did'], + )!, + notificationUri: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}notification_uri'], + )!, + notificationCid: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}notification_cid'], + ), + reason: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}reason'], + )!, + indexedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}indexed_at'], + )!, + source: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source'], + )!, + deliveredAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}delivered_at'], + )!, + openedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}opened_at'], + ), + dismissedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}dismissed_at'], + ), + ); + } + + @override + $NotificationDeliveriesTable createAlias(String alias) { + return $NotificationDeliveriesTable(attachedDatabase, alias); + } +} + +class NotificationDeliveryEntry extends DataClass + implements Insertable { + final int id; + final String accountDid; + final String notificationUri; + final String? notificationCid; + final String reason; + final DateTime indexedAt; + final String source; + final DateTime deliveredAt; + final DateTime? openedAt; + final DateTime? dismissedAt; + const NotificationDeliveryEntry({ + required this.id, + required this.accountDid, + required this.notificationUri, + this.notificationCid, + required this.reason, + required this.indexedAt, + required this.source, + required this.deliveredAt, + this.openedAt, + this.dismissedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['account_did'] = Variable(accountDid); + map['notification_uri'] = Variable(notificationUri); + if (!nullToAbsent || notificationCid != null) { + map['notification_cid'] = Variable(notificationCid); + } + map['reason'] = Variable(reason); + map['indexed_at'] = Variable(indexedAt); + map['source'] = Variable(source); + map['delivered_at'] = Variable(deliveredAt); + if (!nullToAbsent || openedAt != null) { + map['opened_at'] = Variable(openedAt); + } + if (!nullToAbsent || dismissedAt != null) { + map['dismissed_at'] = Variable(dismissedAt); + } + return map; + } + + NotificationDeliveriesCompanion toCompanion(bool nullToAbsent) { + return NotificationDeliveriesCompanion( + id: Value(id), + accountDid: Value(accountDid), + notificationUri: Value(notificationUri), + notificationCid: notificationCid == null && nullToAbsent + ? const Value.absent() + : Value(notificationCid), + reason: Value(reason), + indexedAt: Value(indexedAt), + source: Value(source), + deliveredAt: Value(deliveredAt), + openedAt: openedAt == null && nullToAbsent + ? const Value.absent() + : Value(openedAt), + dismissedAt: dismissedAt == null && nullToAbsent + ? const Value.absent() + : Value(dismissedAt), + ); + } + + factory NotificationDeliveryEntry.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return NotificationDeliveryEntry( + id: serializer.fromJson(json['id']), + accountDid: serializer.fromJson(json['accountDid']), + notificationUri: serializer.fromJson(json['notificationUri']), + notificationCid: serializer.fromJson(json['notificationCid']), + reason: serializer.fromJson(json['reason']), + indexedAt: serializer.fromJson(json['indexedAt']), + source: serializer.fromJson(json['source']), + deliveredAt: serializer.fromJson(json['deliveredAt']), + openedAt: serializer.fromJson(json['openedAt']), + dismissedAt: serializer.fromJson(json['dismissedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'accountDid': serializer.toJson(accountDid), + 'notificationUri': serializer.toJson(notificationUri), + 'notificationCid': serializer.toJson(notificationCid), + 'reason': serializer.toJson(reason), + 'indexedAt': serializer.toJson(indexedAt), + 'source': serializer.toJson(source), + 'deliveredAt': serializer.toJson(deliveredAt), + 'openedAt': serializer.toJson(openedAt), + 'dismissedAt': serializer.toJson(dismissedAt), + }; + } + + NotificationDeliveryEntry copyWith({ + int? id, + String? accountDid, + String? notificationUri, + Value notificationCid = const Value.absent(), + String? reason, + DateTime? indexedAt, + String? source, + DateTime? deliveredAt, + Value openedAt = const Value.absent(), + Value dismissedAt = const Value.absent(), + }) => NotificationDeliveryEntry( + id: id ?? this.id, + accountDid: accountDid ?? this.accountDid, + notificationUri: notificationUri ?? this.notificationUri, + notificationCid: notificationCid.present + ? notificationCid.value + : this.notificationCid, + reason: reason ?? this.reason, + indexedAt: indexedAt ?? this.indexedAt, + source: source ?? this.source, + deliveredAt: deliveredAt ?? this.deliveredAt, + openedAt: openedAt.present ? openedAt.value : this.openedAt, + dismissedAt: dismissedAt.present ? dismissedAt.value : this.dismissedAt, + ); + NotificationDeliveryEntry copyWithCompanion( + NotificationDeliveriesCompanion data, + ) { + return NotificationDeliveryEntry( + id: data.id.present ? data.id.value : this.id, + accountDid: data.accountDid.present + ? data.accountDid.value + : this.accountDid, + notificationUri: data.notificationUri.present + ? data.notificationUri.value + : this.notificationUri, + notificationCid: data.notificationCid.present + ? data.notificationCid.value + : this.notificationCid, + reason: data.reason.present ? data.reason.value : this.reason, + indexedAt: data.indexedAt.present ? data.indexedAt.value : this.indexedAt, + source: data.source.present ? data.source.value : this.source, + deliveredAt: data.deliveredAt.present + ? data.deliveredAt.value + : this.deliveredAt, + openedAt: data.openedAt.present ? data.openedAt.value : this.openedAt, + dismissedAt: data.dismissedAt.present + ? data.dismissedAt.value + : this.dismissedAt, + ); + } + + @override + String toString() { + return (StringBuffer('NotificationDeliveryEntry(') + ..write('id: $id, ') + ..write('accountDid: $accountDid, ') + ..write('notificationUri: $notificationUri, ') + ..write('notificationCid: $notificationCid, ') + ..write('reason: $reason, ') + ..write('indexedAt: $indexedAt, ') + ..write('source: $source, ') + ..write('deliveredAt: $deliveredAt, ') + ..write('openedAt: $openedAt, ') + ..write('dismissedAt: $dismissedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + accountDid, + notificationUri, + notificationCid, + reason, + indexedAt, + source, + deliveredAt, + openedAt, + dismissedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is NotificationDeliveryEntry && + other.id == this.id && + other.accountDid == this.accountDid && + other.notificationUri == this.notificationUri && + other.notificationCid == this.notificationCid && + other.reason == this.reason && + other.indexedAt == this.indexedAt && + other.source == this.source && + other.deliveredAt == this.deliveredAt && + other.openedAt == this.openedAt && + other.dismissedAt == this.dismissedAt); +} + +class NotificationDeliveriesCompanion + extends UpdateCompanion { + final Value id; + final Value accountDid; + final Value notificationUri; + final Value notificationCid; + final Value reason; + final Value indexedAt; + final Value source; + final Value deliveredAt; + final Value openedAt; + final Value dismissedAt; + const NotificationDeliveriesCompanion({ + this.id = const Value.absent(), + this.accountDid = const Value.absent(), + this.notificationUri = const Value.absent(), + this.notificationCid = const Value.absent(), + this.reason = const Value.absent(), + this.indexedAt = const Value.absent(), + this.source = const Value.absent(), + this.deliveredAt = const Value.absent(), + this.openedAt = const Value.absent(), + this.dismissedAt = const Value.absent(), + }); + NotificationDeliveriesCompanion.insert({ + this.id = const Value.absent(), + required String accountDid, + required String notificationUri, + this.notificationCid = const Value.absent(), + required String reason, + required DateTime indexedAt, + required String source, + this.deliveredAt = const Value.absent(), + this.openedAt = const Value.absent(), + this.dismissedAt = const Value.absent(), + }) : accountDid = Value(accountDid), + notificationUri = Value(notificationUri), + reason = Value(reason), + indexedAt = Value(indexedAt), + source = Value(source); + static Insertable custom({ + Expression? id, + Expression? accountDid, + Expression? notificationUri, + Expression? notificationCid, + Expression? reason, + Expression? indexedAt, + Expression? source, + Expression? deliveredAt, + Expression? openedAt, + Expression? dismissedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (accountDid != null) 'account_did': accountDid, + if (notificationUri != null) 'notification_uri': notificationUri, + if (notificationCid != null) 'notification_cid': notificationCid, + if (reason != null) 'reason': reason, + if (indexedAt != null) 'indexed_at': indexedAt, + if (source != null) 'source': source, + if (deliveredAt != null) 'delivered_at': deliveredAt, + if (openedAt != null) 'opened_at': openedAt, + if (dismissedAt != null) 'dismissed_at': dismissedAt, + }); + } + + NotificationDeliveriesCompanion copyWith({ + Value? id, + Value? accountDid, + Value? notificationUri, + Value? notificationCid, + Value? reason, + Value? indexedAt, + Value? source, + Value? deliveredAt, + Value? openedAt, + Value? dismissedAt, + }) { + return NotificationDeliveriesCompanion( + id: id ?? this.id, + accountDid: accountDid ?? this.accountDid, + notificationUri: notificationUri ?? this.notificationUri, + notificationCid: notificationCid ?? this.notificationCid, + reason: reason ?? this.reason, + indexedAt: indexedAt ?? this.indexedAt, + source: source ?? this.source, + deliveredAt: deliveredAt ?? this.deliveredAt, + openedAt: openedAt ?? this.openedAt, + dismissedAt: dismissedAt ?? this.dismissedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (accountDid.present) { + map['account_did'] = Variable(accountDid.value); + } + if (notificationUri.present) { + map['notification_uri'] = Variable(notificationUri.value); + } + if (notificationCid.present) { + map['notification_cid'] = Variable(notificationCid.value); + } + if (reason.present) { + map['reason'] = Variable(reason.value); + } + if (indexedAt.present) { + map['indexed_at'] = Variable(indexedAt.value); + } + if (source.present) { + map['source'] = Variable(source.value); + } + if (deliveredAt.present) { + map['delivered_at'] = Variable(deliveredAt.value); + } + if (openedAt.present) { + map['opened_at'] = Variable(openedAt.value); + } + if (dismissedAt.present) { + map['dismissed_at'] = Variable(dismissedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('NotificationDeliveriesCompanion(') + ..write('id: $id, ') + ..write('accountDid: $accountDid, ') + ..write('notificationUri: $notificationUri, ') + ..write('notificationCid: $notificationCid, ') + ..write('reason: $reason, ') + ..write('indexedAt: $indexedAt, ') + ..write('source: $source, ') + ..write('deliveredAt: $deliveredAt, ') + ..write('openedAt: $openedAt, ') + ..write('dismissedAt: $dismissedAt') + ..write(')')) + .toString(); + } +} + class $LikedPostsTable extends LikedPosts with TableInfo<$LikedPostsTable, LikedPostEntry> { @override @@ -5384,6 +6010,8 @@ late final $DraftsTable drafts = $DraftsTable(this); late final $SavedPostsTable savedPosts = $SavedPostsTable(this); late final $LabelerCacheTable labelerCache = $LabelerCacheTable(this); + late final $NotificationDeliveriesTable notificationDeliveries = + $NotificationDeliveriesTable(this); late final $LikedPostsTable likedPosts = $LikedPostsTable(this); @override Iterable> get allTables => @@ -5402,6 +6030,7 @@ drafts, savedPosts, labelerCache, + notificationDeliveries, likedPosts, ]; } @@ -8096,6 +8725,324 @@ LabelerCacheEntry, PrefetchHooks Function() >; +typedef $$NotificationDeliveriesTableCreateCompanionBuilder = + NotificationDeliveriesCompanion Function({ + Value id, + required String accountDid, + required String notificationUri, + Value notificationCid, + required String reason, + required DateTime indexedAt, + required String source, + Value deliveredAt, + Value openedAt, + Value dismissedAt, + }); +typedef $$NotificationDeliveriesTableUpdateCompanionBuilder = + NotificationDeliveriesCompanion Function({ + Value id, + Value accountDid, + Value notificationUri, + Value notificationCid, + Value reason, + Value indexedAt, + Value source, + Value deliveredAt, + Value openedAt, + Value dismissedAt, + }); + +class $$NotificationDeliveriesTableFilterComposer + extends Composer<_$AppDatabase, $NotificationDeliveriesTable> { + $$NotificationDeliveriesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get accountDid => $composableBuilder( + column: $table.accountDid, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get notificationUri => $composableBuilder( + column: $table.notificationUri, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get notificationCid => $composableBuilder( + column: $table.notificationCid, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get reason => $composableBuilder( + column: $table.reason, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get indexedAt => $composableBuilder( + column: $table.indexedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get source => $composableBuilder( + column: $table.source, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get deliveredAt => $composableBuilder( + column: $table.deliveredAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get openedAt => $composableBuilder( + column: $table.openedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get dismissedAt => $composableBuilder( + column: $table.dismissedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$NotificationDeliveriesTableOrderingComposer + extends Composer<_$AppDatabase, $NotificationDeliveriesTable> { + $$NotificationDeliveriesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get accountDid => $composableBuilder( + column: $table.accountDid, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get notificationUri => $composableBuilder( + column: $table.notificationUri, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get notificationCid => $composableBuilder( + column: $table.notificationCid, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get reason => $composableBuilder( + column: $table.reason, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get indexedAt => $composableBuilder( + column: $table.indexedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get source => $composableBuilder( + column: $table.source, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get deliveredAt => $composableBuilder( + column: $table.deliveredAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get openedAt => $composableBuilder( + column: $table.openedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get dismissedAt => $composableBuilder( + column: $table.dismissedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$NotificationDeliveriesTableAnnotationComposer + extends Composer<_$AppDatabase, $NotificationDeliveriesTable> { + $$NotificationDeliveriesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get accountDid => $composableBuilder( + column: $table.accountDid, + builder: (column) => column, + ); + + GeneratedColumn get notificationUri => $composableBuilder( + column: $table.notificationUri, + builder: (column) => column, + ); + + GeneratedColumn get notificationCid => $composableBuilder( + column: $table.notificationCid, + builder: (column) => column, + ); + + GeneratedColumn get reason => + $composableBuilder(column: $table.reason, builder: (column) => column); + + GeneratedColumn get indexedAt => + $composableBuilder(column: $table.indexedAt, builder: (column) => column); + + GeneratedColumn get source => + $composableBuilder(column: $table.source, builder: (column) => column); + + GeneratedColumn get deliveredAt => $composableBuilder( + column: $table.deliveredAt, + builder: (column) => column, + ); + + GeneratedColumn get openedAt => + $composableBuilder(column: $table.openedAt, builder: (column) => column); + + GeneratedColumn get dismissedAt => $composableBuilder( + column: $table.dismissedAt, + builder: (column) => column, + ); +} + +class $$NotificationDeliveriesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $NotificationDeliveriesTable, + NotificationDeliveryEntry, + $$NotificationDeliveriesTableFilterComposer, + $$NotificationDeliveriesTableOrderingComposer, + $$NotificationDeliveriesTableAnnotationComposer, + $$NotificationDeliveriesTableCreateCompanionBuilder, + $$NotificationDeliveriesTableUpdateCompanionBuilder, + ( + NotificationDeliveryEntry, + BaseReferences< + _$AppDatabase, + $NotificationDeliveriesTable, + NotificationDeliveryEntry + >, + ), + NotificationDeliveryEntry, + PrefetchHooks Function() + > { + $$NotificationDeliveriesTableTableManager( + _$AppDatabase db, + $NotificationDeliveriesTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$NotificationDeliveriesTableFilterComposer( + $db: db, + $table: table, + ), + createOrderingComposer: () => + $$NotificationDeliveriesTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$NotificationDeliveriesTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value accountDid = const Value.absent(), + Value notificationUri = const Value.absent(), + Value notificationCid = const Value.absent(), + Value reason = const Value.absent(), + Value indexedAt = const Value.absent(), + Value source = const Value.absent(), + Value deliveredAt = const Value.absent(), + Value openedAt = const Value.absent(), + Value dismissedAt = const Value.absent(), + }) => NotificationDeliveriesCompanion( + id: id, + accountDid: accountDid, + notificationUri: notificationUri, + notificationCid: notificationCid, + reason: reason, + indexedAt: indexedAt, + source: source, + deliveredAt: deliveredAt, + openedAt: openedAt, + dismissedAt: dismissedAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String accountDid, + required String notificationUri, + Value notificationCid = const Value.absent(), + required String reason, + required DateTime indexedAt, + required String source, + Value deliveredAt = const Value.absent(), + Value openedAt = const Value.absent(), + Value dismissedAt = const Value.absent(), + }) => NotificationDeliveriesCompanion.insert( + id: id, + accountDid: accountDid, + notificationUri: notificationUri, + notificationCid: notificationCid, + reason: reason, + indexedAt: indexedAt, + source: source, + deliveredAt: deliveredAt, + openedAt: openedAt, + dismissedAt: dismissedAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$NotificationDeliveriesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $NotificationDeliveriesTable, + NotificationDeliveryEntry, + $$NotificationDeliveriesTableFilterComposer, + $$NotificationDeliveriesTableOrderingComposer, + $$NotificationDeliveriesTableAnnotationComposer, + $$NotificationDeliveriesTableCreateCompanionBuilder, + $$NotificationDeliveriesTableUpdateCompanionBuilder, + ( + NotificationDeliveryEntry, + BaseReferences< + _$AppDatabase, + $NotificationDeliveriesTable, + NotificationDeliveryEntry + >, + ), + NotificationDeliveryEntry, + PrefetchHooks Function() + >; typedef $$LikedPostsTableCreateCompanionBuilder = LikedPostsCompanion Function({ Value id, @@ -8320,6 +9267,11 @@ $$SavedPostsTableTableManager(_db, _db.savedPosts); $$LabelerCacheTableTableManager get labelerCache => $$LabelerCacheTableTableManager(_db, _db.labelerCache); + $$NotificationDeliveriesTableTableManager get notificationDeliveries => + $$NotificationDeliveriesTableTableManager( + _db, + _db.notificationDeliveries, + ); $$LikedPostsTableTableManager get likedPosts => $$LikedPostsTableTableManager(_db, _db.likedPosts); } diff --git a/lib/core/database/tables.dart b/lib/core/database/tables.dart --- a/lib/core/database/tables.dart +++ b/lib/core/database/tables.dart @@ -151,6 +151,23 @@ Set get primaryKey => {labelerDid}; } +@DataClassName('NotificationDeliveryEntry') +class NotificationDeliveries extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get accountDid => text()(); + TextColumn get notificationUri => text()(); + TextColumn get notificationCid => text().nullable()(); + TextColumn get reason => text()(); + DateTimeColumn get indexedAt => dateTime()(); + TextColumn get source => text()(); + DateTimeColumn get deliveredAt => dateTime().withDefault(currentDateAndTime)(); + DateTimeColumn get openedAt => dateTime().nullable()(); + DateTimeColumn get dismissedAt => dateTime().nullable()(); + + @override + List get customConstraints => ['UNIQUE (account_did, notification_uri)']; +} + @DataClassName('LikedPostEntry') class LikedPosts extends Table { IntColumn get id => integer().autoIncrement()(); diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -44,6 +44,7 @@ import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; import 'package:lazurite/features/notifications/data/notification_repository.dart'; +import 'package:lazurite/features/notifications/domain/notification_domain_service.dart'; import 'package:lazurite/features/profile/cubit/follow_audit_cubit.dart'; import 'package:lazurite/features/profile/cubit/profile_context_cubit.dart'; import 'package:lazurite/features/profile/data/follow_audit_repository.dart'; @@ -329,7 +330,10 @@ providers: [ if (existingUnreadCubit == null) BlocProvider( - create: (_) => UnreadCountCubit(notificationRepository: context.read()), + create: (_) => UnreadCountCubit( + notificationDomainService: _readNotificationDomainService(context), + notificationRepository: context.read(), + ), ), ], child: AppShell(navigationShell: navigationShell, branchNavigatorKeys: _branchNavigatorKeys), @@ -524,9 +528,20 @@ } return BlocProvider( - create: (_) => NotificationBloc(notificationRepository: context.read()), + create: (_) => NotificationBloc( + notificationDomainService: _readNotificationDomainService(context), + notificationRepository: context.read(), + ), child: child, ); + } + + NotificationDomainService? _readNotificationDomainService(BuildContext context) { + try { + return context.read(); + } catch (_) { + return null; + } } } diff --git a/test/core/database/app_database_test.dart b/test/core/database/app_database_test.dart --- a/test/core/database/app_database_test.dart +++ b/test/core/database/app_database_test.dart @@ -264,6 +264,42 @@ }); }); + group('Notification delivery operations', () { + test('should update existing delivery metadata on duplicate insert', () async { + final firstInsert = await database.recordNotificationDelivery( + accountDid: 'did:plc:test', + notificationUri: 'at://did:plc:test/app.bsky.feed.post/1', + notificationCid: 'cid-1', + reason: 'like', + indexedAt: DateTime.utc(2026, 5, 1, 9, 0), + source: 'poll', + ); + + final secondInsert = await database.recordNotificationDelivery( + accountDid: 'did:plc:test', + notificationUri: 'at://did:plc:test/app.bsky.feed.post/1', + notificationCid: 'cid-2', + reason: 'repost', + indexedAt: DateTime.utc(2026, 5, 1, 10, 0), + source: 'push', + ); + + final delivery = await database.getNotificationDelivery( + 'did:plc:test', + 'at://did:plc:test/app.bsky.feed.post/1', + ); + + expect(firstInsert, isTrue); + expect(secondInsert, isFalse); + expect(await database.countNotificationDeliveries('did:plc:test'), 1); + expect(delivery, isNotNull); + expect(delivery!.notificationCid, 'cid-2'); + expect(delivery.reason, 'repost'); + expect(delivery.indexedAt.toUtc(), DateTime.utc(2026, 5, 1, 10, 0)); + expect(delivery.source, 'push'); + }); + }); + group('Settings operations', () { test('should seed default typeahead provider on database creation', () async { final value = await database.getSetting('typeahead_provider'); diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + diff --git a/lib/features/alerts/presentation/alerts_screen.dart b/lib/features/alerts/presentation/alerts_screen.dart --- a/lib/features/alerts/presentation/alerts_screen.dart +++ b/lib/features/alerts/presentation/alerts_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:bluesky/chat_bsky_convo_defs.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; @@ -22,8 +23,19 @@ class _AlertsScreenState extends State { @override + void initState() { + super.initState(); + final convoBloc = context.read(); + if (convoBloc.state.status == ConvoListStatus.initial) { + convoBloc.add(const ConvosRequested()); + } + } + + @override Widget build(BuildContext context) { final currentTab = widget.initialTab; + final notificationsUnread = context.select((cubit) => cubit.state.count); + final messagesUnread = context.select((bloc) => _primaryMessagesUnreadCount(bloc.state)); return AppScreenEntrance( child: Scaffold( @@ -34,12 +46,28 @@ : null, bottom: PreferredSize( preferredSize: const Size.fromHeight(48), - child: _AlertsTabs(currentTab: currentTab), + child: _AlertsTabs( + currentTab: currentTab, + notificationsUnreadCount: notificationsUnread, + messagesUnreadCount: messagesUnread, + ), ), ), body: KeyedSubtree(key: ValueKey(currentTab), child: _buildTab(currentTab)), ), ); + } + + int _primaryMessagesUnreadCount(ConvoListState state) { + var unread = 0; + for (final convo in state.convos) { + final status = convo.status; + final isRequest = status != null && status.isKnownValue && status.knownValue == KnownConvoViewStatus.request; + if (!isRequest) { + unread += convo.unreadCount; + } + } + return unread; } Widget _buildTab(AlertsTab tab) { @@ -60,9 +88,15 @@ } class _AlertsTabs extends StatelessWidget { - const _AlertsTabs({required this.currentTab}); + const _AlertsTabs({ + required this.currentTab, + required this.notificationsUnreadCount, + required this.messagesUnreadCount, + }); final AlertsTab currentTab; + final int notificationsUnreadCount; + final int messagesUnreadCount; @override Widget build(BuildContext context) { @@ -72,8 +106,18 @@ ), child: Row( children: [ - _AlertsTabButton(tab: AlertsTab.notifications, label: 'Notifications', currentTab: currentTab), - _AlertsTabButton(tab: AlertsTab.messages, label: 'Messages', currentTab: currentTab), + _AlertsTabButton( + tab: AlertsTab.notifications, + label: 'Notifications', + currentTab: currentTab, + unreadCount: notificationsUnreadCount, + ), + _AlertsTabButton( + tab: AlertsTab.messages, + label: 'Messages', + currentTab: currentTab, + unreadCount: messagesUnreadCount, + ), _AlertsTabButton(tab: AlertsTab.requests, label: 'Requests', currentTab: currentTab), ], ), @@ -82,11 +126,12 @@ } class _AlertsTabButton extends StatelessWidget { - const _AlertsTabButton({required this.tab, required this.label, required this.currentTab}); + const _AlertsTabButton({required this.tab, required this.label, required this.currentTab, this.unreadCount = 0}); final AlertsTab tab; final String label; final AlertsTab currentTab; + final int unreadCount; @override Widget build(BuildContext context) { @@ -107,7 +152,30 @@ bottom: BorderSide(color: isSelected ? theme.colorScheme.primary : Colors.transparent, width: 2), ), ), - child: Text(label, textAlign: TextAlign.center, style: textStyle), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, textAlign: TextAlign.center, style: textStyle), + if (unreadCount > 0) ...[ + const SizedBox(width: 6), + Container( + key: ValueKey('alerts-tab-unread-${tab.name}'), + constraints: const BoxConstraints(minWidth: 18), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration(color: theme.colorScheme.primary, borderRadius: BorderRadius.circular(12)), + child: Text( + unreadCount > 99 ? '99+' : unreadCount.toString(), + textAlign: TextAlign.center, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onPrimary, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ], + ), ), ), ); diff --git a/lib/features/notifications/bloc/notification_bloc.dart b/lib/features/notifications/bloc/notification_bloc.dart --- a/lib/features/notifications/bloc/notification_bloc.dart +++ b/lib/features/notifications/bloc/notification_bloc.dart @@ -3,13 +3,21 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/features/notifications/data/notification_repository.dart'; +import 'package:lazurite/features/notifications/domain/notification_domain_service.dart'; part 'notification_event.dart'; part 'notification_state.dart'; class NotificationBloc extends Bloc { - NotificationBloc({required NotificationRepository notificationRepository}) - : _notificationRepository = notificationRepository, + NotificationBloc({ + NotificationDomainService? notificationDomainService, + NotificationRepository? notificationRepository, + }) : _notificationDomainService = + notificationDomainService ?? + NotificationDomainService( + notificationRepository: notificationRepository ?? + (throw ArgumentError('Either notificationDomainService or notificationRepository is required')), + ), super(const NotificationState.initial()) { on(_onNotificationsRequested); on(_onNotificationsRefreshed); @@ -17,13 +25,13 @@ on(_onNotificationsMarkedRead); } - final NotificationRepository _notificationRepository; + final NotificationDomainService _notificationDomainService; Future _onNotificationsRequested(NotificationsRequested event, Emitter emit) async { emit(const NotificationState.loading()); try { - final result = await _notificationRepository.listNotifications(limit: event.limit); + final result = await _notificationDomainService.listNotifications(limit: event.limit); emit( NotificationState.loaded( @@ -45,7 +53,7 @@ emit(state.copyWith(isRefreshing: true)); try { - final result = await _notificationRepository.listNotifications(limit: 50); + final result = await _notificationDomainService.listNotifications(limit: 50); emit( state.copyWith( @@ -68,7 +76,7 @@ emit(state.copyWith(isLoadingMore: true)); try { - final result = await _notificationRepository.listNotifications(cursor: state.cursor, limit: event.limit); + final result = await _notificationDomainService.listNotifications(cursor: state.cursor, limit: event.limit); emit( state.copyWith( @@ -85,7 +93,14 @@ Future _onNotificationsMarkedRead(NotificationsMarkedRead event, Emitter emit) async { try { - await _notificationRepository.updateSeen(); + await _notificationDomainService.markSeen(); + if (state.status == NotificationStatus.loaded && state.notifications.isNotEmpty) { + emit( + state.copyWith( + notifications: state.notifications.map((notification) => notification.copyWith(isRead: true)).toList(), + ), + ); + } } catch (_) { log.w('Failed to mark notifications as read/seen'); } diff --git a/lib/features/notifications/cubit/unread_count_cubit.dart b/lib/features/notifications/cubit/unread_count_cubit.dart --- a/lib/features/notifications/cubit/unread_count_cubit.dart +++ b/lib/features/notifications/cubit/unread_count_cubit.dart @@ -3,16 +3,24 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lazurite/features/notifications/data/notification_repository.dart'; +import 'package:lazurite/features/notifications/domain/notification_domain_service.dart'; import 'package:lazurite/core/logging/app_logger.dart'; class UnreadCountCubit extends Cubit { - UnreadCountCubit({required NotificationRepository notificationRepository}) - : _notificationRepository = notificationRepository, + UnreadCountCubit({ + NotificationDomainService? notificationDomainService, + NotificationRepository? notificationRepository, + }) : _notificationDomainService = + notificationDomainService ?? + NotificationDomainService( + notificationRepository: notificationRepository ?? + (throw ArgumentError('Either notificationDomainService or notificationRepository is required')), + ), super(const UnreadCountState(0)) { _startPolling(); } - final NotificationRepository _notificationRepository; + final NotificationDomainService _notificationDomainService; Timer? _pollingTimer; static const _pollingInterval = Duration(seconds: 30); @@ -24,7 +32,7 @@ Future _pollUnreadCount() async { try { - final count = await _notificationRepository.getUnreadCount(); + final count = await _notificationDomainService.getUnreadCount(); emit(UnreadCountState(count)); } catch (_) { log.w('Failed to poll unread count'); diff --git a/lib/features/notifications/data/flutter_local_notification_adapter.dart b/lib/features/notifications/data/flutter_local_notification_adapter.dart new file mode 100644 --- /dev/null +++ b/lib/features/notifications/data/flutter_local_notification_adapter.dart @@ -0,0 +1,101 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:lazurite/features/notifications/domain/local_notification_adapter.dart'; +import 'package:lazurite/features/notifications/domain/notification_local_mappers.dart'; +import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; + +class FlutterLocalNotificationAdapter implements LocalNotificationAdapter { + FlutterLocalNotificationAdapter({FlutterLocalNotificationsPlugin? plugin}) + : _plugin = plugin ?? FlutterLocalNotificationsPlugin(); + + final FlutterLocalNotificationsPlugin _plugin; + var _initialized = false; + + @override + Future initialize({required NotificationTapCallback onTap}) async { + if (_initialized) { + return; + } + + const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); + final categories = NotificationReasonFamily.values + .map((family) => DarwinNotificationCategory(family.iosCategoryIdentifier)) + .toList(growable: false); + final darwinSettings = DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + notificationCategories: categories, + ); + + await _plugin.initialize( + InitializationSettings(android: androidSettings, iOS: darwinSettings), + onDidReceiveNotificationResponse: (response) { + final deepLink = NotificationPayloadCodec.decode(response.payload); + if (deepLink != null) { + onTap(deepLink); + } + }, + ); + + await _createAndroidChannels(); + + final launchDetails = await _plugin.getNotificationAppLaunchDetails(); + if (launchDetails?.didNotificationLaunchApp ?? false) { + final deepLink = NotificationPayloadCodec.decode(launchDetails?.notificationResponse?.payload); + if (deepLink != null) { + onTap(deepLink); + } + } + + _initialized = true; + } + + @override + Future requestPermissions() async { + final android = _plugin.resolvePlatformSpecificImplementation(); + await android?.requestNotificationsPermission(); + + final ios = _plugin.resolvePlatformSpecificImplementation(); + await ios?.requestPermissions(alert: true, badge: true, sound: true); + } + + @override + Future show(LocalNotificationRequest request) async { + final details = NotificationDetails( + android: AndroidNotificationDetails( + request.reasonFamily.androidChannelId, + request.reasonFamily.androidChannelName, + channelDescription: '${request.reasonFamily.androidChannelName} notifications', + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + ), + iOS: DarwinNotificationDetails(categoryIdentifier: request.reasonFamily.iosCategoryIdentifier), + ); + + await _plugin.show( + request.notificationId, + request.title, + request.body, + details, + payload: NotificationPayloadCodec.encode(request.deepLink), + ); + } + + Future _createAndroidChannels() async { + final android = _plugin.resolvePlatformSpecificImplementation(); + if (android == null) { + return; + } + + for (final family in NotificationReasonFamily.values) { + await android.createNotificationChannel( + AndroidNotificationChannel( + family.androidChannelId, + family.androidChannelName, + description: '${family.androidChannelName} notifications', + importance: Importance.defaultImportance, + ), + ); + } + } +} diff --git a/lib/features/notifications/domain/local_notification_adapter.dart b/lib/features/notifications/domain/local_notification_adapter.dart new file mode 100644 --- /dev/null +++ b/lib/features/notifications/domain/local_notification_adapter.dart @@ -0,0 +1,11 @@ +import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; + +typedef NotificationTapCallback = void Function(NotificationDeepLink deepLink); + +abstract class LocalNotificationAdapter { + Future initialize({required NotificationTapCallback onTap}); + + Future requestPermissions(); + + Future show(LocalNotificationRequest request); +} diff --git a/lib/features/notifications/domain/notification_deep_link_navigator.dart b/lib/features/notifications/domain/notification_deep_link_navigator.dart new file mode 100644 --- /dev/null +++ b/lib/features/notifications/domain/notification_deep_link_navigator.dart @@ -0,0 +1,15 @@ +import 'package:go_router/go_router.dart'; +import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; + +class NotificationDeepLinkNavigator { + static void navigate(GoRouter router, NotificationDeepLink deepLink) { + switch (deepLink.navigationMode) { + case NotificationTapNavigationMode.go: + router.go(deepLink.route); + break; + case NotificationTapNavigationMode.push: + router.push(deepLink.route); + break; + } + } +} diff --git a/lib/features/notifications/domain/notification_domain_service.dart b/lib/features/notifications/domain/notification_domain_service.dart new file mode 100644 --- /dev/null +++ b/lib/features/notifications/domain/notification_domain_service.dart @@ -0,0 +1,110 @@ +import 'package:bluesky/app_bsky_notification_listnotifications.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/features/notifications/data/notification_repository.dart'; +import 'package:lazurite/features/notifications/domain/local_notification_adapter.dart'; +import 'package:lazurite/features/notifications/domain/notification_local_mappers.dart'; + +/// Orchestrates notification polling flows and delivery-state persistence. +class NotificationDomainService { + NotificationDomainService({ + required NotificationRepository notificationRepository, + AppDatabase? database, + String? accountDid, + LocalNotificationAdapter? localNotificationAdapter, + bool Function()? shouldSuppressLocalNotifications, + }) : _notificationRepository = notificationRepository, + _database = database, + _accountDid = accountDid, + _localNotificationAdapter = localNotificationAdapter, + _shouldSuppressLocalNotifications = shouldSuppressLocalNotifications { + if ((database == null) != (accountDid == null)) { + throw ArgumentError('database and accountDid must both be provided together, or both omitted'); + } + } + + final NotificationRepository _notificationRepository; + final AppDatabase? _database; + final String? _accountDid; + final LocalNotificationAdapter? _localNotificationAdapter; + final bool Function()? _shouldSuppressLocalNotifications; + + Future listNotifications({ + String? cursor, + int limit = 50, + NotificationDeliverySource source = NotificationDeliverySource.poll, + }) async { + final result = await _notificationRepository.listNotifications(cursor: cursor, limit: limit); + await persistNotificationDeliveries( + result.notifications, + source: source, + onNewDelivery: (notification) async { + if (notification.isRead) { + return; + } + + final request = NotificationLocalMapper.requestFromNotification(notification); + if (request == null) { + return; + } + + if (_shouldSuppressLocalNotifications?.call() ?? false) { + return; + } + + await _localNotificationAdapter?.show(request); + }, + ); + return result; + } + + Future getUnreadCount() => _notificationRepository.getUnreadCount(); + + Future markSeen() => _notificationRepository.updateSeen(); + + Future persistNotificationDeliveries( + Iterable notifications, { + NotificationDeliverySource source = NotificationDeliverySource.poll, + Future Function(Notification notification)? onNewDelivery, + }) async { + final database = _database; + final accountDid = _accountDid; + if (database == null || accountDid == null) { + return 0; + } + + var insertedCount = 0; + for (final notification in notifications) { + final didInsert = await database.recordNotificationDelivery( + accountDid: accountDid, + notificationUri: notification.uri.toString(), + notificationCid: notification.cid, + reason: _reasonName(notification.reason), + indexedAt: notification.indexedAt, + source: source.value, + ); + if (didInsert) { + insertedCount += 1; + await onNewDelivery?.call(notification); + } + } + + return insertedCount; + } + + String _reasonName(NotificationReason reason) { + final knownReason = reason.knownValue; + if (knownReason != null) { + return knownReason.name; + } + return 'unknown'; + } +} + +enum NotificationDeliverySource { + poll('poll'), + push('push'); + + const NotificationDeliverySource(this.value); + + final String value; +} diff --git a/lib/features/notifications/domain/notification_local_mappers.dart b/lib/features/notifications/domain/notification_local_mappers.dart new file mode 100644 --- /dev/null +++ b/lib/features/notifications/domain/notification_local_mappers.dart @@ -0,0 +1,187 @@ +import 'dart:convert'; + +import 'package:bluesky/app_bsky_notification_listnotifications.dart'; +import 'package:crypto/crypto.dart'; +import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; + +class NotificationPayloadCodec { + static String encode(NotificationDeepLink deepLink) { + return jsonEncode({'route': deepLink.route, 'mode': deepLink.navigationMode.name}); + } + + static NotificationDeepLink? decode(String? payload) { + if (payload == null || payload.trim().isEmpty) { + return null; + } + + try { + final decoded = jsonDecode(payload); + if (decoded is! Map) { + return null; + } + + final route = decoded['route']; + if (route is! String || !route.startsWith('/')) { + return null; + } + + final mode = decoded['mode']; + final navigationMode = mode == NotificationTapNavigationMode.go.name + ? NotificationTapNavigationMode.go + : NotificationTapNavigationMode.push; + + return NotificationDeepLink(route: route, navigationMode: navigationMode); + } catch (_) { + return null; + } + } +} + +class NotificationLocalMapper { + static LocalNotificationRequest? requestFromNotification(Notification notification) { + if (notification.isRead) { + return null; + } + + final deepLink = _deepLinkForNotification(notification); + if (deepLink == null) { + return null; + } + + return LocalNotificationRequest( + notificationId: _stableNotificationId(notification.uri.toString()), + title: _titleForNotification(notification), + body: _bodyForReason(notification.reason), + reasonFamily: _reasonFamilyForReason(notification.reason), + deepLink: deepLink, + ); + } + + static NotificationReasonFamily _reasonFamilyForReason(NotificationReason reason) { + final known = reason.knownValue; + if (known == null) { + return NotificationReasonFamily.misc; + } + + switch (known) { + case KnownNotificationReason.mention: + return NotificationReasonFamily.mentions; + case KnownNotificationReason.reply: + case KnownNotificationReason.quote: + return NotificationReasonFamily.replies; + case KnownNotificationReason.follow: + return NotificationReasonFamily.follows; + case KnownNotificationReason.like: + case KnownNotificationReason.repost: + return NotificationReasonFamily.likes; + default: + return NotificationReasonFamily.misc; + } + } + + static NotificationDeepLink? _deepLinkForNotification(Notification notification) { + final knownReason = notification.reason.knownValue; + + if (knownReason == KnownNotificationReason.follow) { + final actor = notification.author.did.trim(); + if (actor.isEmpty) { + return null; + } + return NotificationDeepLink( + route: '/profile/${Uri.encodeComponent(actor)}', + navigationMode: NotificationTapNavigationMode.go, + ); + } + + final useReasonSubject = + knownReason == KnownNotificationReason.like || knownReason == KnownNotificationReason.repost; + final targetUri = (useReasonSubject ? notification.reasonSubject : null) ?? notification.uri; + + return NotificationDeepLink( + route: '/post?uri=${Uri.encodeQueryComponent(targetUri.toString())}', + navigationMode: NotificationTapNavigationMode.push, + ); + } + + static String _titleForNotification(Notification notification) { + final displayName = notification.author.displayName?.trim(); + if (displayName != null && displayName.isNotEmpty) { + return displayName; + } + final handle = notification.author.handle.trim(); + return handle.isEmpty ? 'New notification' : handle; + } + + static String _bodyForReason(NotificationReason reason) { + final known = reason.knownValue; + switch (known) { + case KnownNotificationReason.like: + return 'liked your post'; + case KnownNotificationReason.repost: + return 'reposted your post'; + case KnownNotificationReason.reply: + return 'replied to your post'; + case KnownNotificationReason.follow: + return 'followed you'; + case KnownNotificationReason.mention: + return 'mentioned you'; + case KnownNotificationReason.quote: + return 'quoted your post'; + default: + return 'sent a notification'; + } + } + + static int _stableNotificationId(String value) { + final digest = sha1.convert(utf8.encode(value)).bytes; + final id = (digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]; + return id & 0x7fffffff; + } +} + +extension NotificationReasonFamilyChannels on NotificationReasonFamily { + String get androidChannelId { + switch (this) { + case NotificationReasonFamily.mentions: + return 'mentions'; + case NotificationReasonFamily.replies: + return 'replies'; + case NotificationReasonFamily.follows: + return 'follows'; + case NotificationReasonFamily.likes: + return 'likes'; + case NotificationReasonFamily.misc: + return 'misc'; + } + } + + String get androidChannelName { + switch (this) { + case NotificationReasonFamily.mentions: + return 'Mentions'; + case NotificationReasonFamily.replies: + return 'Replies'; + case NotificationReasonFamily.follows: + return 'Follows'; + case NotificationReasonFamily.likes: + return 'Likes'; + case NotificationReasonFamily.misc: + return 'Other'; + } + } + + String get iosCategoryIdentifier { + switch (this) { + case NotificationReasonFamily.mentions: + return 'mentions'; + case NotificationReasonFamily.replies: + return 'replies'; + case NotificationReasonFamily.follows: + return 'follows'; + case NotificationReasonFamily.likes: + return 'likes'; + case NotificationReasonFamily.misc: + return 'misc'; + } + } +} diff --git a/lib/features/notifications/domain/notification_local_models.dart b/lib/features/notifications/domain/notification_local_models.dart new file mode 100644 --- /dev/null +++ b/lib/features/notifications/domain/notification_local_models.dart @@ -0,0 +1,26 @@ +enum NotificationTapNavigationMode { go, push } + +class NotificationDeepLink { + const NotificationDeepLink({required this.route, required this.navigationMode}); + + final String route; + final NotificationTapNavigationMode navigationMode; +} + +enum NotificationReasonFamily { mentions, replies, follows, likes, misc } + +class LocalNotificationRequest { + const LocalNotificationRequest({ + required this.notificationId, + required this.title, + required this.body, + required this.reasonFamily, + required this.deepLink, + }); + + final int notificationId; + final String title; + final String body; + final NotificationReasonFamily reasonFamily; + final NotificationDeepLink deepLink; +} diff --git a/test/features/alerts/presentation/alerts_screen_test.dart b/test/features/alerts/presentation/alerts_screen_test.dart --- a/test/features/alerts/presentation/alerts_screen_test.dart +++ b/test/features/alerts/presentation/alerts_screen_test.dart @@ -185,6 +185,16 @@ expect(find.text('Mark All Read'), findsOneWidget); }); + testWidgets('shows unread badges for notifications and messages tabs', (tester) async { + await tester.pumpWidget(buildSubject('/alerts')); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('alerts-tab-unread-notifications')), findsOneWidget); + expect(find.byKey(const ValueKey('alerts-tab-unread-messages')), findsOneWidget); + expect(find.text('1'), findsOneWidget); + expect(find.text('2'), findsOneWidget); + }); + testWidgets('opens messages tab from deep link', (tester) async { await tester.pumpWidget(buildSubject('/alerts/messages')); await tester.pumpAndSettle(); diff --git a/test/features/notifications/bloc/notification_bloc_test.dart b/test/features/notifications/bloc/notification_bloc_test.dart --- a/test/features/notifications/bloc/notification_bloc_test.dart +++ b/test/features/notifications/bloc/notification_bloc_test.dart @@ -192,6 +192,24 @@ ); blocTest( + 'marks loaded notifications as read after NotificationsMarkedRead succeeds', + build: () => NotificationBloc(notificationRepository: mockNotificationRepository), + seed: () => NotificationState.loaded(notifications: [sampleNotification], cursor: null, hasMore: false), + setUp: () { + when(() => mockNotificationRepository.updateSeen()).thenAnswer((_) async {}); + }, + act: (bloc) => bloc.add(const NotificationsMarkedRead()), + expect: () => [ + predicate( + (state) => + state.status == NotificationStatus.loaded && + state.notifications.length == 1 && + state.notifications.first.isRead, + ), + ], + ); + + blocTest( 'handles updateSeen failure silently', build: () => NotificationBloc(notificationRepository: mockNotificationRepository), setUp: () { diff --git a/test/features/notifications/domain/notification_deep_link_navigator_test.dart b/test/features/notifications/domain/notification_deep_link_navigator_test.dart new file mode 100644 --- /dev/null +++ b/test/features/notifications/domain/notification_deep_link_navigator_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/features/notifications/domain/notification_deep_link_navigator.dart'; +import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; + +void main() { + testWidgets('go navigation opens profile route from notification deep link', (tester) async { + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const Scaffold(body: Text('home')), + ), + GoRoute( + path: '/profile/:actor', + builder: (_, state) => Scaffold(body: Text('profile:${state.pathParameters['actor']}')), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + NotificationDeepLinkNavigator.navigate( + router, + const NotificationDeepLink(route: '/profile/did%3Aplc%3Aalice', navigationMode: NotificationTapNavigationMode.go), + ); + await tester.pumpAndSettle(); + + expect(find.text('profile:did:plc:alice'), findsOneWidget); + }); + + testWidgets('push navigation opens post route from notification deep link', (tester) async { + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const Scaffold(body: Text('home')), + ), + GoRoute( + path: '/post', + builder: (_, state) => Scaffold(body: Text('post:${state.uri.queryParameters['uri']}')), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + NotificationDeepLinkNavigator.navigate( + router, + const NotificationDeepLink( + route: '/post?uri=at%3A%2F%2Fdid%3Aplc%3Atest%2Fapp.bsky.feed.post%2F1', + navigationMode: NotificationTapNavigationMode.push, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('post:at://did:plc:test/app.bsky.feed.post/1'), findsOneWidget); + }); +} diff --git a/test/features/notifications/domain/notification_domain_service_test.dart b/test/features/notifications/domain/notification_domain_service_test.dart new file mode 100644 --- /dev/null +++ b/test/features/notifications/domain/notification_domain_service_test.dart @@ -0,0 +1,209 @@ +import 'package:atproto_core/atproto_core.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/features/notifications/data/notification_repository.dart'; +import 'package:lazurite/features/notifications/domain/notification_domain_service.dart'; +import 'package:lazurite/features/notifications/domain/local_notification_adapter.dart'; +import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockNotificationRepository extends Mock implements NotificationRepository {} + +class MockLocalNotificationAdapter extends Mock implements LocalNotificationAdapter {} + +class FakeLocalNotificationRequest extends Fake implements LocalNotificationRequest {} + +void main() { + late MockNotificationRepository repository; + late MockLocalNotificationAdapter localNotificationAdapter; + + setUp(() { + repository = MockNotificationRepository(); + localNotificationAdapter = MockLocalNotificationAdapter(); + when(() => localNotificationAdapter.show(any())).thenAnswer((_) async {}); + }); + + setUpAll(() { + registerFallbackValue(FakeLocalNotificationRequest()); + }); + + group('NotificationDomainService', () { + final sampleNotifications = [ + bsky.Notification( + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/abc'), + cid: 'cid-123', + author: const ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.like), + record: const {}, + isRead: false, + indexedAt: DateTime.utc(2026, 4, 29, 12), + ), + bsky.Notification( + uri: AtUri.parse('at://did:plc:author2/app.bsky.feed.post/def'), + cid: 'cid-456', + author: const ProfileView(did: 'did:plc:author2', handle: 'author2.bsky.social'), + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.follow), + record: const {}, + isRead: true, + indexedAt: DateTime.utc(2026, 4, 29, 13), + ), + ]; + + test('persists deliveries and dedupes by accountDid + notificationUri', () async { + final database = AppDatabase(executor: NativeDatabase.memory()); + addTearDown(database.close); + + when( + () => repository.listNotifications( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => NotificationListResult(notifications: sampleNotifications, cursor: null)); + + final service = NotificationDomainService( + notificationRepository: repository, + database: database, + accountDid: 'did:plc:test', + ); + + await service.listNotifications(); + await service.listNotifications(); + + expect(await database.countNotificationDeliveries('did:plc:test'), 2); + + final first = await database.getNotificationDelivery( + 'did:plc:test', + 'at://did:plc:author/app.bsky.feed.post/abc', + ); + expect(first, isNotNull); + expect(first!.source, 'poll'); + expect(first.reason, 'like'); + }); + + test('updates reason/indexedAt/source on duplicate observation while preserving dedupe', () async { + final database = AppDatabase(executor: NativeDatabase.memory()); + addTearDown(database.close); + + final originalNotification = sampleNotifications.first; + final updatedNotification = bsky.Notification( + uri: originalNotification.uri, + cid: originalNotification.cid, + author: originalNotification.author, + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.repost), + record: originalNotification.record, + isRead: originalNotification.isRead, + indexedAt: DateTime.utc(2026, 5, 2, 10, 0), + ); + + when( + () => repository.listNotifications( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => NotificationListResult(notifications: [originalNotification], cursor: null)); + + final service = NotificationDomainService( + notificationRepository: repository, + database: database, + accountDid: 'did:plc:test', + ); + + await service.listNotifications(); + + when( + () => repository.listNotifications( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => NotificationListResult(notifications: [updatedNotification], cursor: null)); + + await service.listNotifications(); + + final delivery = await database.getNotificationDelivery('did:plc:test', originalNotification.uri.toString()); + + expect(await database.countNotificationDeliveries('did:plc:test'), 1); + expect(delivery, isNotNull); + expect(delivery!.reason, 'repost'); + expect(delivery.source, 'poll'); + expect(delivery.indexedAt.toUtc(), DateTime.utc(2026, 5, 2, 10, 0)); + }); + + test('shows local notifications for newly inserted unseen items only once', () async { + final database = AppDatabase(executor: NativeDatabase.memory()); + addTearDown(database.close); + final unseenNotification = sampleNotifications.first; + + when( + () => repository.listNotifications( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => NotificationListResult(notifications: [unseenNotification], cursor: null)); + + final service = NotificationDomainService( + notificationRepository: repository, + database: database, + accountDid: 'did:plc:test', + localNotificationAdapter: localNotificationAdapter, + ); + + await service.listNotifications(); + await service.listNotifications(); + + verify(() => localNotificationAdapter.show(any())).called(1); + }); + + test('suppresses local notification display while alerts route is active', () async { + final database = AppDatabase(executor: NativeDatabase.memory()); + addTearDown(database.close); + final unseenNotification = sampleNotifications.first; + + when( + () => repository.listNotifications( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => NotificationListResult(notifications: [unseenNotification], cursor: null)); + + final service = NotificationDomainService( + notificationRepository: repository, + database: database, + accountDid: 'did:plc:test', + localNotificationAdapter: localNotificationAdapter, + shouldSuppressLocalNotifications: () => true, + ); + + await service.listNotifications(); + + verifyNever(() => localNotificationAdapter.show(any())); + expect(await database.countNotificationDeliveries('did:plc:test'), 1); + }); + + test('listNotifications still works without persistence dependencies', () async { + when( + () => repository.listNotifications( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => NotificationListResult(notifications: sampleNotifications, cursor: 'next')); + + final service = NotificationDomainService(notificationRepository: repository); + final result = await service.listNotifications(); + + expect(result.notifications.length, 2); + expect(result.cursor, 'next'); + }); + + test('markSeen delegates to repository', () async { + when(() => repository.updateSeen()).thenAnswer((_) async {}); + final service = NotificationDomainService(notificationRepository: repository); + + await service.markSeen(); + + verify(() => repository.updateSeen()).called(1); + }); + }); +} diff --git a/test/features/notifications/domain/notification_local_mappers_test.dart b/test/features/notifications/domain/notification_local_mappers_test.dart new file mode 100644 --- /dev/null +++ b/test/features/notifications/domain/notification_local_mappers_test.dart @@ -0,0 +1,72 @@ +import 'package:atproto_core/atproto_core.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/notifications/domain/notification_local_mappers.dart'; +import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; + +void main() { + group('NotificationLocalMapper', () { + test('maps follow notifications to profile route with go navigation', () { + final notification = bsky.Notification( + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/abc'), + cid: 'cid-123', + author: const ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.follow), + record: const {}, + isRead: false, + indexedAt: DateTime.utc(2026, 5, 1, 12), + ); + + final request = NotificationLocalMapper.requestFromNotification(notification); + + expect(request, isNotNull); + expect(request!.reasonFamily, NotificationReasonFamily.follows); + expect(request.deepLink.navigationMode, NotificationTapNavigationMode.go); + expect(request.deepLink.route, '/profile/${Uri.encodeComponent('did:plc:author')}'); + }); + + test('maps like notifications to post route using reasonSubject', () { + final reasonSubject = AtUri.parse('at://did:plc:target/app.bsky.feed.post/xyz'); + final notification = bsky.Notification( + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.like/abc'), + cid: 'cid-123', + author: const ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.like), + reasonSubject: reasonSubject, + record: const {}, + isRead: false, + indexedAt: DateTime.utc(2026, 5, 1, 12), + ); + + final request = NotificationLocalMapper.requestFromNotification(notification); + + expect(request, isNotNull); + expect(request!.reasonFamily, NotificationReasonFamily.likes); + expect(request.deepLink.navigationMode, NotificationTapNavigationMode.push); + expect(request.deepLink.route, '/post?uri=${Uri.encodeQueryComponent(reasonSubject.toString())}'); + }); + }); + + group('NotificationPayloadCodec', () { + test('encodes and decodes deep links', () { + const deepLink = NotificationDeepLink( + route: '/post?uri=at%3A%2F%2Fabc', + navigationMode: NotificationTapNavigationMode.push, + ); + + final payload = NotificationPayloadCodec.encode(deepLink); + final decoded = NotificationPayloadCodec.decode(payload); + + expect(decoded, isNotNull); + expect(decoded!.route, deepLink.route); + expect(decoded.navigationMode, NotificationTapNavigationMode.push); + }); + + test('returns null for invalid payload', () { + expect(NotificationPayloadCodec.decode('not-json'), isNull); + expect(NotificationPayloadCodec.decode('{"mode":"go"}'), isNull); + expect(NotificationPayloadCodec.decode(''), isNull); + }); + }); +} diff --git a/lib/features/notifications/presentation/widgets/notifications_pane.dart b/lib/features/notifications/presentation/widgets/notifications_pane.dart --- a/lib/features/notifications/presentation/widgets/notifications_pane.dart +++ b/lib/features/notifications/presentation/widgets/notifications_pane.dart @@ -1,4 +1,6 @@ import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; @@ -23,6 +25,9 @@ class _NotificationsPaneState extends State { final ScrollController _scrollController = ScrollController(); final Set _seenNotificationKeys = {}; + Timer? _pollTimer; + + static const _pollInterval = Duration(seconds: 30); @override void initState() { @@ -33,6 +38,7 @@ context.read().add(const NotificationsMarkedRead()); context.read().refresh(); } + _pollTimer = Timer.periodic(_pollInterval, (_) => _pollForUpdates()); } @override @@ -40,6 +46,7 @@ _scrollController ..removeListener(_onScroll) ..dispose(); + _pollTimer?.cancel(); super.dispose(); } @@ -53,6 +60,15 @@ context.read().add(const NotificationsRefreshed()); context.read().add(const NotificationsMarkedRead()); await context.read().refresh(); + } + + void _pollForUpdates() { + if (!mounted) { + return; + } + context.read().add(const NotificationsRefreshed()); + context.read().add(const NotificationsMarkedRead()); + context.read().refresh(); } @override