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

Configure Feed

Select the types of activity you want to include in your feed.

feat: local notifications & count UI (in tabs)

+2118 -29
+1
android/app/src/main/AndroidManifest.xml
··· 1 1 <manifest xmlns:android="http://schemas.android.com/apk/res/android"> 2 + <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> 2 3 <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> 3 4 <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> 4 5 <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
+9 -9
docs/tasks/notification.md
··· 5 5 6 6 ## M1 - Foundation Hardening (Polling Baseline) 7 7 8 - - [ ] Introduce `NotificationDomainService` orchestration layer 9 - - [ ] Add Drift `notification_deliveries` table with migration 10 - - [ ] Route existing polling paths through orchestration layer 11 - - [ ] Add unit tests for dedupe and state persistence 8 + - [x] Introduce `NotificationDomainService` orchestration layer 9 + - [x] Add Drift `notification_deliveries` table with migration 10 + - [x] Route existing polling paths through orchestration layer 11 + - [x] Add unit tests for dedupe and state persistence 12 12 13 13 ## M2 - Local Notifications from Reconcile 14 14 15 - - [ ] Add local notification adapter abstraction 16 - - [ ] Android channels by reason family 17 - - [ ] iOS category + payload deep-link mapping 18 - - [ ] Show local notifications for newly discovered unseen items 19 - - [ ] Add widget/integration tests for tap -> route behavior 15 + - [x] Add local notification adapter abstraction 16 + - [x] Android channels by reason family 17 + - [x] iOS category + payload deep-link mapping 18 + - [x] Show local notifications for newly discovered unseen items 19 + - [x] Add widget/integration tests for tap -> route behavior 20 20 21 21 ## M3 - Push Registration Lifecycle 22 22
+63 -1
lib/core/database/app_database.dart
··· 19 19 Drafts, 20 20 SavedPosts, 21 21 LabelerCache, 22 + NotificationDeliveries, 22 23 LikedPosts, 23 24 ], 24 25 ) ··· 28 29 static const activeAccountDidSettingKey = 'active_account_did'; 29 30 30 31 @override 31 - int get schemaVersion => 20; 32 + int get schemaVersion => 21; 32 33 33 34 @override 34 35 MigrationStrategy get migration => MigrationStrategy( 35 36 onCreate: (migrator) async { 36 37 await migrator.createAll(); 38 + await customStatement( 39 + 'CREATE INDEX IF NOT EXISTS idx_notification_deliveries_notification_uri ' 40 + 'ON notification_deliveries(notification_uri)', 41 + ); 37 42 await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('typeahead_provider', 'bluesky')"); 38 43 await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('appview_provider', 'bluesky')"); 39 44 await customStatement( ··· 142 147 if (from < 20) { 143 148 await migrator.createTable(cachedFeedPosts); 144 149 await migrator.createTable(cachedThreadRoots); 150 + } 151 + if (from < 21) { 152 + await migrator.createTable(notificationDeliveries); 153 + await customStatement( 154 + 'CREATE INDEX IF NOT EXISTS idx_notification_deliveries_notification_uri ' 155 + 'ON notification_deliveries(notification_uri)', 156 + ); 145 157 } 146 158 }, 147 159 ); ··· 578 590 579 591 Future<int> deleteAllLikedPosts(String accountDid) => 580 592 (delete(likedPosts)..where((l) => l.accountDid.equals(accountDid))).go(); 593 + 594 + Future<bool> recordNotificationDelivery({ 595 + required String accountDid, 596 + required String notificationUri, 597 + String? notificationCid, 598 + required String reason, 599 + required DateTime indexedAt, 600 + required String source, 601 + DateTime? deliveredAt, 602 + }) async { 603 + final existing = await getNotificationDelivery(accountDid, notificationUri); 604 + if (existing == null) { 605 + await into(notificationDeliveries).insert( 606 + NotificationDeliveriesCompanion.insert( 607 + accountDid: accountDid, 608 + notificationUri: notificationUri, 609 + notificationCid: Value(notificationCid), 610 + reason: reason, 611 + indexedAt: indexedAt, 612 + source: source, 613 + deliveredAt: Value(deliveredAt ?? DateTime.now()), 614 + ), 615 + ); 616 + return true; 617 + } 618 + 619 + await (update( 620 + notificationDeliveries, 621 + )..where((entry) => entry.accountDid.equals(accountDid) & entry.notificationUri.equals(notificationUri))).write( 622 + NotificationDeliveriesCompanion( 623 + notificationCid: notificationCid != null ? Value(notificationCid) : const Value.absent(), 624 + reason: Value(reason), 625 + indexedAt: Value(indexedAt), 626 + source: Value(source), 627 + ), 628 + ); 629 + 630 + return false; 631 + } 632 + 633 + Future<NotificationDeliveryEntry?> getNotificationDelivery(String accountDid, String notificationUri) { 634 + return (select(notificationDeliveries) 635 + ..where((entry) => entry.accountDid.equals(accountDid) & entry.notificationUri.equals(notificationUri))) 636 + .getSingleOrNull(); 637 + } 638 + 639 + Future<int> countNotificationDeliveries(String accountDid) async { 640 + final rows = await (select(notificationDeliveries)..where((entry) => entry.accountDid.equals(accountDid))).get(); 641 + return rows.length; 642 + } 581 643 }
+952
lib/core/database/app_database.g.dart
··· 5012 5012 } 5013 5013 } 5014 5014 5015 + class $NotificationDeliveriesTable extends NotificationDeliveries 5016 + with TableInfo<$NotificationDeliveriesTable, NotificationDeliveryEntry> { 5017 + @override 5018 + final GeneratedDatabase attachedDatabase; 5019 + final String? _alias; 5020 + $NotificationDeliveriesTable(this.attachedDatabase, [this._alias]); 5021 + static const VerificationMeta _idMeta = const VerificationMeta('id'); 5022 + @override 5023 + late final GeneratedColumn<int> id = GeneratedColumn<int>( 5024 + 'id', 5025 + aliasedName, 5026 + false, 5027 + hasAutoIncrement: true, 5028 + type: DriftSqlType.int, 5029 + requiredDuringInsert: false, 5030 + defaultConstraints: GeneratedColumn.constraintIsAlways( 5031 + 'PRIMARY KEY AUTOINCREMENT', 5032 + ), 5033 + ); 5034 + static const VerificationMeta _accountDidMeta = const VerificationMeta( 5035 + 'accountDid', 5036 + ); 5037 + @override 5038 + late final GeneratedColumn<String> accountDid = GeneratedColumn<String>( 5039 + 'account_did', 5040 + aliasedName, 5041 + false, 5042 + type: DriftSqlType.string, 5043 + requiredDuringInsert: true, 5044 + ); 5045 + static const VerificationMeta _notificationUriMeta = const VerificationMeta( 5046 + 'notificationUri', 5047 + ); 5048 + @override 5049 + late final GeneratedColumn<String> notificationUri = GeneratedColumn<String>( 5050 + 'notification_uri', 5051 + aliasedName, 5052 + false, 5053 + type: DriftSqlType.string, 5054 + requiredDuringInsert: true, 5055 + ); 5056 + static const VerificationMeta _notificationCidMeta = const VerificationMeta( 5057 + 'notificationCid', 5058 + ); 5059 + @override 5060 + late final GeneratedColumn<String> notificationCid = GeneratedColumn<String>( 5061 + 'notification_cid', 5062 + aliasedName, 5063 + true, 5064 + type: DriftSqlType.string, 5065 + requiredDuringInsert: false, 5066 + ); 5067 + static const VerificationMeta _reasonMeta = const VerificationMeta('reason'); 5068 + @override 5069 + late final GeneratedColumn<String> reason = GeneratedColumn<String>( 5070 + 'reason', 5071 + aliasedName, 5072 + false, 5073 + type: DriftSqlType.string, 5074 + requiredDuringInsert: true, 5075 + ); 5076 + static const VerificationMeta _indexedAtMeta = const VerificationMeta( 5077 + 'indexedAt', 5078 + ); 5079 + @override 5080 + late final GeneratedColumn<DateTime> indexedAt = GeneratedColumn<DateTime>( 5081 + 'indexed_at', 5082 + aliasedName, 5083 + false, 5084 + type: DriftSqlType.dateTime, 5085 + requiredDuringInsert: true, 5086 + ); 5087 + static const VerificationMeta _sourceMeta = const VerificationMeta('source'); 5088 + @override 5089 + late final GeneratedColumn<String> source = GeneratedColumn<String>( 5090 + 'source', 5091 + aliasedName, 5092 + false, 5093 + type: DriftSqlType.string, 5094 + requiredDuringInsert: true, 5095 + ); 5096 + static const VerificationMeta _deliveredAtMeta = const VerificationMeta( 5097 + 'deliveredAt', 5098 + ); 5099 + @override 5100 + late final GeneratedColumn<DateTime> deliveredAt = GeneratedColumn<DateTime>( 5101 + 'delivered_at', 5102 + aliasedName, 5103 + false, 5104 + type: DriftSqlType.dateTime, 5105 + requiredDuringInsert: false, 5106 + defaultValue: currentDateAndTime, 5107 + ); 5108 + static const VerificationMeta _openedAtMeta = const VerificationMeta( 5109 + 'openedAt', 5110 + ); 5111 + @override 5112 + late final GeneratedColumn<DateTime> openedAt = GeneratedColumn<DateTime>( 5113 + 'opened_at', 5114 + aliasedName, 5115 + true, 5116 + type: DriftSqlType.dateTime, 5117 + requiredDuringInsert: false, 5118 + ); 5119 + static const VerificationMeta _dismissedAtMeta = const VerificationMeta( 5120 + 'dismissedAt', 5121 + ); 5122 + @override 5123 + late final GeneratedColumn<DateTime> dismissedAt = GeneratedColumn<DateTime>( 5124 + 'dismissed_at', 5125 + aliasedName, 5126 + true, 5127 + type: DriftSqlType.dateTime, 5128 + requiredDuringInsert: false, 5129 + ); 5130 + @override 5131 + List<GeneratedColumn> get $columns => [ 5132 + id, 5133 + accountDid, 5134 + notificationUri, 5135 + notificationCid, 5136 + reason, 5137 + indexedAt, 5138 + source, 5139 + deliveredAt, 5140 + openedAt, 5141 + dismissedAt, 5142 + ]; 5143 + @override 5144 + String get aliasedName => _alias ?? actualTableName; 5145 + @override 5146 + String get actualTableName => $name; 5147 + static const String $name = 'notification_deliveries'; 5148 + @override 5149 + VerificationContext validateIntegrity( 5150 + Insertable<NotificationDeliveryEntry> instance, { 5151 + bool isInserting = false, 5152 + }) { 5153 + final context = VerificationContext(); 5154 + final data = instance.toColumns(true); 5155 + if (data.containsKey('id')) { 5156 + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); 5157 + } 5158 + if (data.containsKey('account_did')) { 5159 + context.handle( 5160 + _accountDidMeta, 5161 + accountDid.isAcceptableOrUnknown(data['account_did']!, _accountDidMeta), 5162 + ); 5163 + } else if (isInserting) { 5164 + context.missing(_accountDidMeta); 5165 + } 5166 + if (data.containsKey('notification_uri')) { 5167 + context.handle( 5168 + _notificationUriMeta, 5169 + notificationUri.isAcceptableOrUnknown( 5170 + data['notification_uri']!, 5171 + _notificationUriMeta, 5172 + ), 5173 + ); 5174 + } else if (isInserting) { 5175 + context.missing(_notificationUriMeta); 5176 + } 5177 + if (data.containsKey('notification_cid')) { 5178 + context.handle( 5179 + _notificationCidMeta, 5180 + notificationCid.isAcceptableOrUnknown( 5181 + data['notification_cid']!, 5182 + _notificationCidMeta, 5183 + ), 5184 + ); 5185 + } 5186 + if (data.containsKey('reason')) { 5187 + context.handle( 5188 + _reasonMeta, 5189 + reason.isAcceptableOrUnknown(data['reason']!, _reasonMeta), 5190 + ); 5191 + } else if (isInserting) { 5192 + context.missing(_reasonMeta); 5193 + } 5194 + if (data.containsKey('indexed_at')) { 5195 + context.handle( 5196 + _indexedAtMeta, 5197 + indexedAt.isAcceptableOrUnknown(data['indexed_at']!, _indexedAtMeta), 5198 + ); 5199 + } else if (isInserting) { 5200 + context.missing(_indexedAtMeta); 5201 + } 5202 + if (data.containsKey('source')) { 5203 + context.handle( 5204 + _sourceMeta, 5205 + source.isAcceptableOrUnknown(data['source']!, _sourceMeta), 5206 + ); 5207 + } else if (isInserting) { 5208 + context.missing(_sourceMeta); 5209 + } 5210 + if (data.containsKey('delivered_at')) { 5211 + context.handle( 5212 + _deliveredAtMeta, 5213 + deliveredAt.isAcceptableOrUnknown( 5214 + data['delivered_at']!, 5215 + _deliveredAtMeta, 5216 + ), 5217 + ); 5218 + } 5219 + if (data.containsKey('opened_at')) { 5220 + context.handle( 5221 + _openedAtMeta, 5222 + openedAt.isAcceptableOrUnknown(data['opened_at']!, _openedAtMeta), 5223 + ); 5224 + } 5225 + if (data.containsKey('dismissed_at')) { 5226 + context.handle( 5227 + _dismissedAtMeta, 5228 + dismissedAt.isAcceptableOrUnknown( 5229 + data['dismissed_at']!, 5230 + _dismissedAtMeta, 5231 + ), 5232 + ); 5233 + } 5234 + return context; 5235 + } 5236 + 5237 + @override 5238 + Set<GeneratedColumn> get $primaryKey => {id}; 5239 + @override 5240 + NotificationDeliveryEntry map( 5241 + Map<String, dynamic> data, { 5242 + String? tablePrefix, 5243 + }) { 5244 + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; 5245 + return NotificationDeliveryEntry( 5246 + id: attachedDatabase.typeMapping.read( 5247 + DriftSqlType.int, 5248 + data['${effectivePrefix}id'], 5249 + )!, 5250 + accountDid: attachedDatabase.typeMapping.read( 5251 + DriftSqlType.string, 5252 + data['${effectivePrefix}account_did'], 5253 + )!, 5254 + notificationUri: attachedDatabase.typeMapping.read( 5255 + DriftSqlType.string, 5256 + data['${effectivePrefix}notification_uri'], 5257 + )!, 5258 + notificationCid: attachedDatabase.typeMapping.read( 5259 + DriftSqlType.string, 5260 + data['${effectivePrefix}notification_cid'], 5261 + ), 5262 + reason: attachedDatabase.typeMapping.read( 5263 + DriftSqlType.string, 5264 + data['${effectivePrefix}reason'], 5265 + )!, 5266 + indexedAt: attachedDatabase.typeMapping.read( 5267 + DriftSqlType.dateTime, 5268 + data['${effectivePrefix}indexed_at'], 5269 + )!, 5270 + source: attachedDatabase.typeMapping.read( 5271 + DriftSqlType.string, 5272 + data['${effectivePrefix}source'], 5273 + )!, 5274 + deliveredAt: attachedDatabase.typeMapping.read( 5275 + DriftSqlType.dateTime, 5276 + data['${effectivePrefix}delivered_at'], 5277 + )!, 5278 + openedAt: attachedDatabase.typeMapping.read( 5279 + DriftSqlType.dateTime, 5280 + data['${effectivePrefix}opened_at'], 5281 + ), 5282 + dismissedAt: attachedDatabase.typeMapping.read( 5283 + DriftSqlType.dateTime, 5284 + data['${effectivePrefix}dismissed_at'], 5285 + ), 5286 + ); 5287 + } 5288 + 5289 + @override 5290 + $NotificationDeliveriesTable createAlias(String alias) { 5291 + return $NotificationDeliveriesTable(attachedDatabase, alias); 5292 + } 5293 + } 5294 + 5295 + class NotificationDeliveryEntry extends DataClass 5296 + implements Insertable<NotificationDeliveryEntry> { 5297 + final int id; 5298 + final String accountDid; 5299 + final String notificationUri; 5300 + final String? notificationCid; 5301 + final String reason; 5302 + final DateTime indexedAt; 5303 + final String source; 5304 + final DateTime deliveredAt; 5305 + final DateTime? openedAt; 5306 + final DateTime? dismissedAt; 5307 + const NotificationDeliveryEntry({ 5308 + required this.id, 5309 + required this.accountDid, 5310 + required this.notificationUri, 5311 + this.notificationCid, 5312 + required this.reason, 5313 + required this.indexedAt, 5314 + required this.source, 5315 + required this.deliveredAt, 5316 + this.openedAt, 5317 + this.dismissedAt, 5318 + }); 5319 + @override 5320 + Map<String, Expression> toColumns(bool nullToAbsent) { 5321 + final map = <String, Expression>{}; 5322 + map['id'] = Variable<int>(id); 5323 + map['account_did'] = Variable<String>(accountDid); 5324 + map['notification_uri'] = Variable<String>(notificationUri); 5325 + if (!nullToAbsent || notificationCid != null) { 5326 + map['notification_cid'] = Variable<String>(notificationCid); 5327 + } 5328 + map['reason'] = Variable<String>(reason); 5329 + map['indexed_at'] = Variable<DateTime>(indexedAt); 5330 + map['source'] = Variable<String>(source); 5331 + map['delivered_at'] = Variable<DateTime>(deliveredAt); 5332 + if (!nullToAbsent || openedAt != null) { 5333 + map['opened_at'] = Variable<DateTime>(openedAt); 5334 + } 5335 + if (!nullToAbsent || dismissedAt != null) { 5336 + map['dismissed_at'] = Variable<DateTime>(dismissedAt); 5337 + } 5338 + return map; 5339 + } 5340 + 5341 + NotificationDeliveriesCompanion toCompanion(bool nullToAbsent) { 5342 + return NotificationDeliveriesCompanion( 5343 + id: Value(id), 5344 + accountDid: Value(accountDid), 5345 + notificationUri: Value(notificationUri), 5346 + notificationCid: notificationCid == null && nullToAbsent 5347 + ? const Value.absent() 5348 + : Value(notificationCid), 5349 + reason: Value(reason), 5350 + indexedAt: Value(indexedAt), 5351 + source: Value(source), 5352 + deliveredAt: Value(deliveredAt), 5353 + openedAt: openedAt == null && nullToAbsent 5354 + ? const Value.absent() 5355 + : Value(openedAt), 5356 + dismissedAt: dismissedAt == null && nullToAbsent 5357 + ? const Value.absent() 5358 + : Value(dismissedAt), 5359 + ); 5360 + } 5361 + 5362 + factory NotificationDeliveryEntry.fromJson( 5363 + Map<String, dynamic> json, { 5364 + ValueSerializer? serializer, 5365 + }) { 5366 + serializer ??= driftRuntimeOptions.defaultSerializer; 5367 + return NotificationDeliveryEntry( 5368 + id: serializer.fromJson<int>(json['id']), 5369 + accountDid: serializer.fromJson<String>(json['accountDid']), 5370 + notificationUri: serializer.fromJson<String>(json['notificationUri']), 5371 + notificationCid: serializer.fromJson<String?>(json['notificationCid']), 5372 + reason: serializer.fromJson<String>(json['reason']), 5373 + indexedAt: serializer.fromJson<DateTime>(json['indexedAt']), 5374 + source: serializer.fromJson<String>(json['source']), 5375 + deliveredAt: serializer.fromJson<DateTime>(json['deliveredAt']), 5376 + openedAt: serializer.fromJson<DateTime?>(json['openedAt']), 5377 + dismissedAt: serializer.fromJson<DateTime?>(json['dismissedAt']), 5378 + ); 5379 + } 5380 + @override 5381 + Map<String, dynamic> toJson({ValueSerializer? serializer}) { 5382 + serializer ??= driftRuntimeOptions.defaultSerializer; 5383 + return <String, dynamic>{ 5384 + 'id': serializer.toJson<int>(id), 5385 + 'accountDid': serializer.toJson<String>(accountDid), 5386 + 'notificationUri': serializer.toJson<String>(notificationUri), 5387 + 'notificationCid': serializer.toJson<String?>(notificationCid), 5388 + 'reason': serializer.toJson<String>(reason), 5389 + 'indexedAt': serializer.toJson<DateTime>(indexedAt), 5390 + 'source': serializer.toJson<String>(source), 5391 + 'deliveredAt': serializer.toJson<DateTime>(deliveredAt), 5392 + 'openedAt': serializer.toJson<DateTime?>(openedAt), 5393 + 'dismissedAt': serializer.toJson<DateTime?>(dismissedAt), 5394 + }; 5395 + } 5396 + 5397 + NotificationDeliveryEntry copyWith({ 5398 + int? id, 5399 + String? accountDid, 5400 + String? notificationUri, 5401 + Value<String?> notificationCid = const Value.absent(), 5402 + String? reason, 5403 + DateTime? indexedAt, 5404 + String? source, 5405 + DateTime? deliveredAt, 5406 + Value<DateTime?> openedAt = const Value.absent(), 5407 + Value<DateTime?> dismissedAt = const Value.absent(), 5408 + }) => NotificationDeliveryEntry( 5409 + id: id ?? this.id, 5410 + accountDid: accountDid ?? this.accountDid, 5411 + notificationUri: notificationUri ?? this.notificationUri, 5412 + notificationCid: notificationCid.present 5413 + ? notificationCid.value 5414 + : this.notificationCid, 5415 + reason: reason ?? this.reason, 5416 + indexedAt: indexedAt ?? this.indexedAt, 5417 + source: source ?? this.source, 5418 + deliveredAt: deliveredAt ?? this.deliveredAt, 5419 + openedAt: openedAt.present ? openedAt.value : this.openedAt, 5420 + dismissedAt: dismissedAt.present ? dismissedAt.value : this.dismissedAt, 5421 + ); 5422 + NotificationDeliveryEntry copyWithCompanion( 5423 + NotificationDeliveriesCompanion data, 5424 + ) { 5425 + return NotificationDeliveryEntry( 5426 + id: data.id.present ? data.id.value : this.id, 5427 + accountDid: data.accountDid.present 5428 + ? data.accountDid.value 5429 + : this.accountDid, 5430 + notificationUri: data.notificationUri.present 5431 + ? data.notificationUri.value 5432 + : this.notificationUri, 5433 + notificationCid: data.notificationCid.present 5434 + ? data.notificationCid.value 5435 + : this.notificationCid, 5436 + reason: data.reason.present ? data.reason.value : this.reason, 5437 + indexedAt: data.indexedAt.present ? data.indexedAt.value : this.indexedAt, 5438 + source: data.source.present ? data.source.value : this.source, 5439 + deliveredAt: data.deliveredAt.present 5440 + ? data.deliveredAt.value 5441 + : this.deliveredAt, 5442 + openedAt: data.openedAt.present ? data.openedAt.value : this.openedAt, 5443 + dismissedAt: data.dismissedAt.present 5444 + ? data.dismissedAt.value 5445 + : this.dismissedAt, 5446 + ); 5447 + } 5448 + 5449 + @override 5450 + String toString() { 5451 + return (StringBuffer('NotificationDeliveryEntry(') 5452 + ..write('id: $id, ') 5453 + ..write('accountDid: $accountDid, ') 5454 + ..write('notificationUri: $notificationUri, ') 5455 + ..write('notificationCid: $notificationCid, ') 5456 + ..write('reason: $reason, ') 5457 + ..write('indexedAt: $indexedAt, ') 5458 + ..write('source: $source, ') 5459 + ..write('deliveredAt: $deliveredAt, ') 5460 + ..write('openedAt: $openedAt, ') 5461 + ..write('dismissedAt: $dismissedAt') 5462 + ..write(')')) 5463 + .toString(); 5464 + } 5465 + 5466 + @override 5467 + int get hashCode => Object.hash( 5468 + id, 5469 + accountDid, 5470 + notificationUri, 5471 + notificationCid, 5472 + reason, 5473 + indexedAt, 5474 + source, 5475 + deliveredAt, 5476 + openedAt, 5477 + dismissedAt, 5478 + ); 5479 + @override 5480 + bool operator ==(Object other) => 5481 + identical(this, other) || 5482 + (other is NotificationDeliveryEntry && 5483 + other.id == this.id && 5484 + other.accountDid == this.accountDid && 5485 + other.notificationUri == this.notificationUri && 5486 + other.notificationCid == this.notificationCid && 5487 + other.reason == this.reason && 5488 + other.indexedAt == this.indexedAt && 5489 + other.source == this.source && 5490 + other.deliveredAt == this.deliveredAt && 5491 + other.openedAt == this.openedAt && 5492 + other.dismissedAt == this.dismissedAt); 5493 + } 5494 + 5495 + class NotificationDeliveriesCompanion 5496 + extends UpdateCompanion<NotificationDeliveryEntry> { 5497 + final Value<int> id; 5498 + final Value<String> accountDid; 5499 + final Value<String> notificationUri; 5500 + final Value<String?> notificationCid; 5501 + final Value<String> reason; 5502 + final Value<DateTime> indexedAt; 5503 + final Value<String> source; 5504 + final Value<DateTime> deliveredAt; 5505 + final Value<DateTime?> openedAt; 5506 + final Value<DateTime?> dismissedAt; 5507 + const NotificationDeliveriesCompanion({ 5508 + this.id = const Value.absent(), 5509 + this.accountDid = const Value.absent(), 5510 + this.notificationUri = const Value.absent(), 5511 + this.notificationCid = const Value.absent(), 5512 + this.reason = const Value.absent(), 5513 + this.indexedAt = const Value.absent(), 5514 + this.source = const Value.absent(), 5515 + this.deliveredAt = const Value.absent(), 5516 + this.openedAt = const Value.absent(), 5517 + this.dismissedAt = const Value.absent(), 5518 + }); 5519 + NotificationDeliveriesCompanion.insert({ 5520 + this.id = const Value.absent(), 5521 + required String accountDid, 5522 + required String notificationUri, 5523 + this.notificationCid = const Value.absent(), 5524 + required String reason, 5525 + required DateTime indexedAt, 5526 + required String source, 5527 + this.deliveredAt = const Value.absent(), 5528 + this.openedAt = const Value.absent(), 5529 + this.dismissedAt = const Value.absent(), 5530 + }) : accountDid = Value(accountDid), 5531 + notificationUri = Value(notificationUri), 5532 + reason = Value(reason), 5533 + indexedAt = Value(indexedAt), 5534 + source = Value(source); 5535 + static Insertable<NotificationDeliveryEntry> custom({ 5536 + Expression<int>? id, 5537 + Expression<String>? accountDid, 5538 + Expression<String>? notificationUri, 5539 + Expression<String>? notificationCid, 5540 + Expression<String>? reason, 5541 + Expression<DateTime>? indexedAt, 5542 + Expression<String>? source, 5543 + Expression<DateTime>? deliveredAt, 5544 + Expression<DateTime>? openedAt, 5545 + Expression<DateTime>? dismissedAt, 5546 + }) { 5547 + return RawValuesInsertable({ 5548 + if (id != null) 'id': id, 5549 + if (accountDid != null) 'account_did': accountDid, 5550 + if (notificationUri != null) 'notification_uri': notificationUri, 5551 + if (notificationCid != null) 'notification_cid': notificationCid, 5552 + if (reason != null) 'reason': reason, 5553 + if (indexedAt != null) 'indexed_at': indexedAt, 5554 + if (source != null) 'source': source, 5555 + if (deliveredAt != null) 'delivered_at': deliveredAt, 5556 + if (openedAt != null) 'opened_at': openedAt, 5557 + if (dismissedAt != null) 'dismissed_at': dismissedAt, 5558 + }); 5559 + } 5560 + 5561 + NotificationDeliveriesCompanion copyWith({ 5562 + Value<int>? id, 5563 + Value<String>? accountDid, 5564 + Value<String>? notificationUri, 5565 + Value<String?>? notificationCid, 5566 + Value<String>? reason, 5567 + Value<DateTime>? indexedAt, 5568 + Value<String>? source, 5569 + Value<DateTime>? deliveredAt, 5570 + Value<DateTime?>? openedAt, 5571 + Value<DateTime?>? dismissedAt, 5572 + }) { 5573 + return NotificationDeliveriesCompanion( 5574 + id: id ?? this.id, 5575 + accountDid: accountDid ?? this.accountDid, 5576 + notificationUri: notificationUri ?? this.notificationUri, 5577 + notificationCid: notificationCid ?? this.notificationCid, 5578 + reason: reason ?? this.reason, 5579 + indexedAt: indexedAt ?? this.indexedAt, 5580 + source: source ?? this.source, 5581 + deliveredAt: deliveredAt ?? this.deliveredAt, 5582 + openedAt: openedAt ?? this.openedAt, 5583 + dismissedAt: dismissedAt ?? this.dismissedAt, 5584 + ); 5585 + } 5586 + 5587 + @override 5588 + Map<String, Expression> toColumns(bool nullToAbsent) { 5589 + final map = <String, Expression>{}; 5590 + if (id.present) { 5591 + map['id'] = Variable<int>(id.value); 5592 + } 5593 + if (accountDid.present) { 5594 + map['account_did'] = Variable<String>(accountDid.value); 5595 + } 5596 + if (notificationUri.present) { 5597 + map['notification_uri'] = Variable<String>(notificationUri.value); 5598 + } 5599 + if (notificationCid.present) { 5600 + map['notification_cid'] = Variable<String>(notificationCid.value); 5601 + } 5602 + if (reason.present) { 5603 + map['reason'] = Variable<String>(reason.value); 5604 + } 5605 + if (indexedAt.present) { 5606 + map['indexed_at'] = Variable<DateTime>(indexedAt.value); 5607 + } 5608 + if (source.present) { 5609 + map['source'] = Variable<String>(source.value); 5610 + } 5611 + if (deliveredAt.present) { 5612 + map['delivered_at'] = Variable<DateTime>(deliveredAt.value); 5613 + } 5614 + if (openedAt.present) { 5615 + map['opened_at'] = Variable<DateTime>(openedAt.value); 5616 + } 5617 + if (dismissedAt.present) { 5618 + map['dismissed_at'] = Variable<DateTime>(dismissedAt.value); 5619 + } 5620 + return map; 5621 + } 5622 + 5623 + @override 5624 + String toString() { 5625 + return (StringBuffer('NotificationDeliveriesCompanion(') 5626 + ..write('id: $id, ') 5627 + ..write('accountDid: $accountDid, ') 5628 + ..write('notificationUri: $notificationUri, ') 5629 + ..write('notificationCid: $notificationCid, ') 5630 + ..write('reason: $reason, ') 5631 + ..write('indexedAt: $indexedAt, ') 5632 + ..write('source: $source, ') 5633 + ..write('deliveredAt: $deliveredAt, ') 5634 + ..write('openedAt: $openedAt, ') 5635 + ..write('dismissedAt: $dismissedAt') 5636 + ..write(')')) 5637 + .toString(); 5638 + } 5639 + } 5640 + 5015 5641 class $LikedPostsTable extends LikedPosts 5016 5642 with TableInfo<$LikedPostsTable, LikedPostEntry> { 5017 5643 @override ··· 5384 6010 late final $DraftsTable drafts = $DraftsTable(this); 5385 6011 late final $SavedPostsTable savedPosts = $SavedPostsTable(this); 5386 6012 late final $LabelerCacheTable labelerCache = $LabelerCacheTable(this); 6013 + late final $NotificationDeliveriesTable notificationDeliveries = 6014 + $NotificationDeliveriesTable(this); 5387 6015 late final $LikedPostsTable likedPosts = $LikedPostsTable(this); 5388 6016 @override 5389 6017 Iterable<TableInfo<Table, Object?>> get allTables => ··· 5402 6030 drafts, 5403 6031 savedPosts, 5404 6032 labelerCache, 6033 + notificationDeliveries, 5405 6034 likedPosts, 5406 6035 ]; 5407 6036 } ··· 8096 8725 LabelerCacheEntry, 8097 8726 PrefetchHooks Function() 8098 8727 >; 8728 + typedef $$NotificationDeliveriesTableCreateCompanionBuilder = 8729 + NotificationDeliveriesCompanion Function({ 8730 + Value<int> id, 8731 + required String accountDid, 8732 + required String notificationUri, 8733 + Value<String?> notificationCid, 8734 + required String reason, 8735 + required DateTime indexedAt, 8736 + required String source, 8737 + Value<DateTime> deliveredAt, 8738 + Value<DateTime?> openedAt, 8739 + Value<DateTime?> dismissedAt, 8740 + }); 8741 + typedef $$NotificationDeliveriesTableUpdateCompanionBuilder = 8742 + NotificationDeliveriesCompanion Function({ 8743 + Value<int> id, 8744 + Value<String> accountDid, 8745 + Value<String> notificationUri, 8746 + Value<String?> notificationCid, 8747 + Value<String> reason, 8748 + Value<DateTime> indexedAt, 8749 + Value<String> source, 8750 + Value<DateTime> deliveredAt, 8751 + Value<DateTime?> openedAt, 8752 + Value<DateTime?> dismissedAt, 8753 + }); 8754 + 8755 + class $$NotificationDeliveriesTableFilterComposer 8756 + extends Composer<_$AppDatabase, $NotificationDeliveriesTable> { 8757 + $$NotificationDeliveriesTableFilterComposer({ 8758 + required super.$db, 8759 + required super.$table, 8760 + super.joinBuilder, 8761 + super.$addJoinBuilderToRootComposer, 8762 + super.$removeJoinBuilderFromRootComposer, 8763 + }); 8764 + ColumnFilters<int> get id => $composableBuilder( 8765 + column: $table.id, 8766 + builder: (column) => ColumnFilters(column), 8767 + ); 8768 + 8769 + ColumnFilters<String> get accountDid => $composableBuilder( 8770 + column: $table.accountDid, 8771 + builder: (column) => ColumnFilters(column), 8772 + ); 8773 + 8774 + ColumnFilters<String> get notificationUri => $composableBuilder( 8775 + column: $table.notificationUri, 8776 + builder: (column) => ColumnFilters(column), 8777 + ); 8778 + 8779 + ColumnFilters<String> get notificationCid => $composableBuilder( 8780 + column: $table.notificationCid, 8781 + builder: (column) => ColumnFilters(column), 8782 + ); 8783 + 8784 + ColumnFilters<String> get reason => $composableBuilder( 8785 + column: $table.reason, 8786 + builder: (column) => ColumnFilters(column), 8787 + ); 8788 + 8789 + ColumnFilters<DateTime> get indexedAt => $composableBuilder( 8790 + column: $table.indexedAt, 8791 + builder: (column) => ColumnFilters(column), 8792 + ); 8793 + 8794 + ColumnFilters<String> get source => $composableBuilder( 8795 + column: $table.source, 8796 + builder: (column) => ColumnFilters(column), 8797 + ); 8798 + 8799 + ColumnFilters<DateTime> get deliveredAt => $composableBuilder( 8800 + column: $table.deliveredAt, 8801 + builder: (column) => ColumnFilters(column), 8802 + ); 8803 + 8804 + ColumnFilters<DateTime> get openedAt => $composableBuilder( 8805 + column: $table.openedAt, 8806 + builder: (column) => ColumnFilters(column), 8807 + ); 8808 + 8809 + ColumnFilters<DateTime> get dismissedAt => $composableBuilder( 8810 + column: $table.dismissedAt, 8811 + builder: (column) => ColumnFilters(column), 8812 + ); 8813 + } 8814 + 8815 + class $$NotificationDeliveriesTableOrderingComposer 8816 + extends Composer<_$AppDatabase, $NotificationDeliveriesTable> { 8817 + $$NotificationDeliveriesTableOrderingComposer({ 8818 + required super.$db, 8819 + required super.$table, 8820 + super.joinBuilder, 8821 + super.$addJoinBuilderToRootComposer, 8822 + super.$removeJoinBuilderFromRootComposer, 8823 + }); 8824 + ColumnOrderings<int> get id => $composableBuilder( 8825 + column: $table.id, 8826 + builder: (column) => ColumnOrderings(column), 8827 + ); 8828 + 8829 + ColumnOrderings<String> get accountDid => $composableBuilder( 8830 + column: $table.accountDid, 8831 + builder: (column) => ColumnOrderings(column), 8832 + ); 8833 + 8834 + ColumnOrderings<String> get notificationUri => $composableBuilder( 8835 + column: $table.notificationUri, 8836 + builder: (column) => ColumnOrderings(column), 8837 + ); 8838 + 8839 + ColumnOrderings<String> get notificationCid => $composableBuilder( 8840 + column: $table.notificationCid, 8841 + builder: (column) => ColumnOrderings(column), 8842 + ); 8843 + 8844 + ColumnOrderings<String> get reason => $composableBuilder( 8845 + column: $table.reason, 8846 + builder: (column) => ColumnOrderings(column), 8847 + ); 8848 + 8849 + ColumnOrderings<DateTime> get indexedAt => $composableBuilder( 8850 + column: $table.indexedAt, 8851 + builder: (column) => ColumnOrderings(column), 8852 + ); 8853 + 8854 + ColumnOrderings<String> get source => $composableBuilder( 8855 + column: $table.source, 8856 + builder: (column) => ColumnOrderings(column), 8857 + ); 8858 + 8859 + ColumnOrderings<DateTime> get deliveredAt => $composableBuilder( 8860 + column: $table.deliveredAt, 8861 + builder: (column) => ColumnOrderings(column), 8862 + ); 8863 + 8864 + ColumnOrderings<DateTime> get openedAt => $composableBuilder( 8865 + column: $table.openedAt, 8866 + builder: (column) => ColumnOrderings(column), 8867 + ); 8868 + 8869 + ColumnOrderings<DateTime> get dismissedAt => $composableBuilder( 8870 + column: $table.dismissedAt, 8871 + builder: (column) => ColumnOrderings(column), 8872 + ); 8873 + } 8874 + 8875 + class $$NotificationDeliveriesTableAnnotationComposer 8876 + extends Composer<_$AppDatabase, $NotificationDeliveriesTable> { 8877 + $$NotificationDeliveriesTableAnnotationComposer({ 8878 + required super.$db, 8879 + required super.$table, 8880 + super.joinBuilder, 8881 + super.$addJoinBuilderToRootComposer, 8882 + super.$removeJoinBuilderFromRootComposer, 8883 + }); 8884 + GeneratedColumn<int> get id => 8885 + $composableBuilder(column: $table.id, builder: (column) => column); 8886 + 8887 + GeneratedColumn<String> get accountDid => $composableBuilder( 8888 + column: $table.accountDid, 8889 + builder: (column) => column, 8890 + ); 8891 + 8892 + GeneratedColumn<String> get notificationUri => $composableBuilder( 8893 + column: $table.notificationUri, 8894 + builder: (column) => column, 8895 + ); 8896 + 8897 + GeneratedColumn<String> get notificationCid => $composableBuilder( 8898 + column: $table.notificationCid, 8899 + builder: (column) => column, 8900 + ); 8901 + 8902 + GeneratedColumn<String> get reason => 8903 + $composableBuilder(column: $table.reason, builder: (column) => column); 8904 + 8905 + GeneratedColumn<DateTime> get indexedAt => 8906 + $composableBuilder(column: $table.indexedAt, builder: (column) => column); 8907 + 8908 + GeneratedColumn<String> get source => 8909 + $composableBuilder(column: $table.source, builder: (column) => column); 8910 + 8911 + GeneratedColumn<DateTime> get deliveredAt => $composableBuilder( 8912 + column: $table.deliveredAt, 8913 + builder: (column) => column, 8914 + ); 8915 + 8916 + GeneratedColumn<DateTime> get openedAt => 8917 + $composableBuilder(column: $table.openedAt, builder: (column) => column); 8918 + 8919 + GeneratedColumn<DateTime> get dismissedAt => $composableBuilder( 8920 + column: $table.dismissedAt, 8921 + builder: (column) => column, 8922 + ); 8923 + } 8924 + 8925 + class $$NotificationDeliveriesTableTableManager 8926 + extends 8927 + RootTableManager< 8928 + _$AppDatabase, 8929 + $NotificationDeliveriesTable, 8930 + NotificationDeliveryEntry, 8931 + $$NotificationDeliveriesTableFilterComposer, 8932 + $$NotificationDeliveriesTableOrderingComposer, 8933 + $$NotificationDeliveriesTableAnnotationComposer, 8934 + $$NotificationDeliveriesTableCreateCompanionBuilder, 8935 + $$NotificationDeliveriesTableUpdateCompanionBuilder, 8936 + ( 8937 + NotificationDeliveryEntry, 8938 + BaseReferences< 8939 + _$AppDatabase, 8940 + $NotificationDeliveriesTable, 8941 + NotificationDeliveryEntry 8942 + >, 8943 + ), 8944 + NotificationDeliveryEntry, 8945 + PrefetchHooks Function() 8946 + > { 8947 + $$NotificationDeliveriesTableTableManager( 8948 + _$AppDatabase db, 8949 + $NotificationDeliveriesTable table, 8950 + ) : super( 8951 + TableManagerState( 8952 + db: db, 8953 + table: table, 8954 + createFilteringComposer: () => 8955 + $$NotificationDeliveriesTableFilterComposer( 8956 + $db: db, 8957 + $table: table, 8958 + ), 8959 + createOrderingComposer: () => 8960 + $$NotificationDeliveriesTableOrderingComposer( 8961 + $db: db, 8962 + $table: table, 8963 + ), 8964 + createComputedFieldComposer: () => 8965 + $$NotificationDeliveriesTableAnnotationComposer( 8966 + $db: db, 8967 + $table: table, 8968 + ), 8969 + updateCompanionCallback: 8970 + ({ 8971 + Value<int> id = const Value.absent(), 8972 + Value<String> accountDid = const Value.absent(), 8973 + Value<String> notificationUri = const Value.absent(), 8974 + Value<String?> notificationCid = const Value.absent(), 8975 + Value<String> reason = const Value.absent(), 8976 + Value<DateTime> indexedAt = const Value.absent(), 8977 + Value<String> source = const Value.absent(), 8978 + Value<DateTime> deliveredAt = const Value.absent(), 8979 + Value<DateTime?> openedAt = const Value.absent(), 8980 + Value<DateTime?> dismissedAt = const Value.absent(), 8981 + }) => NotificationDeliveriesCompanion( 8982 + id: id, 8983 + accountDid: accountDid, 8984 + notificationUri: notificationUri, 8985 + notificationCid: notificationCid, 8986 + reason: reason, 8987 + indexedAt: indexedAt, 8988 + source: source, 8989 + deliveredAt: deliveredAt, 8990 + openedAt: openedAt, 8991 + dismissedAt: dismissedAt, 8992 + ), 8993 + createCompanionCallback: 8994 + ({ 8995 + Value<int> id = const Value.absent(), 8996 + required String accountDid, 8997 + required String notificationUri, 8998 + Value<String?> notificationCid = const Value.absent(), 8999 + required String reason, 9000 + required DateTime indexedAt, 9001 + required String source, 9002 + Value<DateTime> deliveredAt = const Value.absent(), 9003 + Value<DateTime?> openedAt = const Value.absent(), 9004 + Value<DateTime?> dismissedAt = const Value.absent(), 9005 + }) => NotificationDeliveriesCompanion.insert( 9006 + id: id, 9007 + accountDid: accountDid, 9008 + notificationUri: notificationUri, 9009 + notificationCid: notificationCid, 9010 + reason: reason, 9011 + indexedAt: indexedAt, 9012 + source: source, 9013 + deliveredAt: deliveredAt, 9014 + openedAt: openedAt, 9015 + dismissedAt: dismissedAt, 9016 + ), 9017 + withReferenceMapper: (p0) => p0 9018 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) 9019 + .toList(), 9020 + prefetchHooksCallback: null, 9021 + ), 9022 + ); 9023 + } 9024 + 9025 + typedef $$NotificationDeliveriesTableProcessedTableManager = 9026 + ProcessedTableManager< 9027 + _$AppDatabase, 9028 + $NotificationDeliveriesTable, 9029 + NotificationDeliveryEntry, 9030 + $$NotificationDeliveriesTableFilterComposer, 9031 + $$NotificationDeliveriesTableOrderingComposer, 9032 + $$NotificationDeliveriesTableAnnotationComposer, 9033 + $$NotificationDeliveriesTableCreateCompanionBuilder, 9034 + $$NotificationDeliveriesTableUpdateCompanionBuilder, 9035 + ( 9036 + NotificationDeliveryEntry, 9037 + BaseReferences< 9038 + _$AppDatabase, 9039 + $NotificationDeliveriesTable, 9040 + NotificationDeliveryEntry 9041 + >, 9042 + ), 9043 + NotificationDeliveryEntry, 9044 + PrefetchHooks Function() 9045 + >; 8099 9046 typedef $$LikedPostsTableCreateCompanionBuilder = 8100 9047 LikedPostsCompanion Function({ 8101 9048 Value<int> id, ··· 8320 9267 $$SavedPostsTableTableManager(_db, _db.savedPosts); 8321 9268 $$LabelerCacheTableTableManager get labelerCache => 8322 9269 $$LabelerCacheTableTableManager(_db, _db.labelerCache); 9270 + $$NotificationDeliveriesTableTableManager get notificationDeliveries => 9271 + $$NotificationDeliveriesTableTableManager( 9272 + _db, 9273 + _db.notificationDeliveries, 9274 + ); 8323 9275 $$LikedPostsTableTableManager get likedPosts => 8324 9276 $$LikedPostsTableTableManager(_db, _db.likedPosts); 8325 9277 }
+17
lib/core/database/tables.dart
··· 151 151 Set<Column> get primaryKey => {labelerDid}; 152 152 } 153 153 154 + @DataClassName('NotificationDeliveryEntry') 155 + class NotificationDeliveries extends Table { 156 + IntColumn get id => integer().autoIncrement()(); 157 + TextColumn get accountDid => text()(); 158 + TextColumn get notificationUri => text()(); 159 + TextColumn get notificationCid => text().nullable()(); 160 + TextColumn get reason => text()(); 161 + DateTimeColumn get indexedAt => dateTime()(); 162 + TextColumn get source => text()(); 163 + DateTimeColumn get deliveredAt => dateTime().withDefault(currentDateAndTime)(); 164 + DateTimeColumn get openedAt => dateTime().nullable()(); 165 + DateTimeColumn get dismissedAt => dateTime().nullable()(); 166 + 167 + @override 168 + List<String> get customConstraints => ['UNIQUE (account_did, notification_uri)']; 169 + } 170 + 154 171 @DataClassName('LikedPostEntry') 155 172 class LikedPosts extends Table { 156 173 IntColumn get id => integer().autoIncrement()();
+17 -2
lib/core/router/app_router.dart
··· 44 44 import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; 45 45 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 46 46 import 'package:lazurite/features/notifications/data/notification_repository.dart'; 47 + import 'package:lazurite/features/notifications/domain/notification_domain_service.dart'; 47 48 import 'package:lazurite/features/profile/cubit/follow_audit_cubit.dart'; 48 49 import 'package:lazurite/features/profile/cubit/profile_context_cubit.dart'; 49 50 import 'package:lazurite/features/profile/data/follow_audit_repository.dart'; ··· 329 330 providers: [ 330 331 if (existingUnreadCubit == null) 331 332 BlocProvider( 332 - create: (_) => UnreadCountCubit(notificationRepository: context.read<NotificationRepository>()), 333 + create: (_) => UnreadCountCubit( 334 + notificationDomainService: _readNotificationDomainService(context), 335 + notificationRepository: context.read<NotificationRepository>(), 336 + ), 333 337 ), 334 338 ], 335 339 child: AppShell(navigationShell: navigationShell, branchNavigatorKeys: _branchNavigatorKeys), ··· 524 528 } 525 529 526 530 return BlocProvider( 527 - create: (_) => NotificationBloc(notificationRepository: context.read<NotificationRepository>()), 531 + create: (_) => NotificationBloc( 532 + notificationDomainService: _readNotificationDomainService(context), 533 + notificationRepository: context.read<NotificationRepository>(), 534 + ), 528 535 child: child, 529 536 ); 537 + } 538 + 539 + NotificationDomainService? _readNotificationDomainService(BuildContext context) { 540 + try { 541 + return context.read<NotificationDomainService>(); 542 + } catch (_) { 543 + return null; 544 + } 530 545 } 531 546 } 532 547
+74 -6
lib/features/alerts/presentation/alerts_screen.dart
··· 1 1 import 'package:flutter/material.dart'; 2 + import 'package:bluesky/chat_bsky_convo_defs.dart'; 2 3 import 'package:flutter_bloc/flutter_bloc.dart'; 3 4 import 'package:go_router/go_router.dart'; 4 5 import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; ··· 21 22 } 22 23 23 24 class _AlertsScreenState extends State<AlertsScreen> { 25 + @override 26 + void initState() { 27 + super.initState(); 28 + final convoBloc = context.read<ConvoListBloc>(); 29 + if (convoBloc.state.status == ConvoListStatus.initial) { 30 + convoBloc.add(const ConvosRequested()); 31 + } 32 + } 33 + 24 34 @override 25 35 Widget build(BuildContext context) { 26 36 final currentTab = widget.initialTab; 37 + final notificationsUnread = context.select<UnreadCountCubit, int>((cubit) => cubit.state.count); 38 + final messagesUnread = context.select<ConvoListBloc, int>((bloc) => _primaryMessagesUnreadCount(bloc.state)); 27 39 28 40 return AppScreenEntrance( 29 41 child: Scaffold( ··· 34 46 : null, 35 47 bottom: PreferredSize( 36 48 preferredSize: const Size.fromHeight(48), 37 - child: _AlertsTabs(currentTab: currentTab), 49 + child: _AlertsTabs( 50 + currentTab: currentTab, 51 + notificationsUnreadCount: notificationsUnread, 52 + messagesUnreadCount: messagesUnread, 53 + ), 38 54 ), 39 55 ), 40 56 body: KeyedSubtree(key: ValueKey(currentTab), child: _buildTab(currentTab)), ··· 42 58 ); 43 59 } 44 60 61 + int _primaryMessagesUnreadCount(ConvoListState state) { 62 + var unread = 0; 63 + for (final convo in state.convos) { 64 + final status = convo.status; 65 + final isRequest = status != null && status.isKnownValue && status.knownValue == KnownConvoViewStatus.request; 66 + if (!isRequest) { 67 + unread += convo.unreadCount; 68 + } 69 + } 70 + return unread; 71 + } 72 + 45 73 Widget _buildTab(AlertsTab tab) { 46 74 switch (tab) { 47 75 case AlertsTab.notifications: ··· 60 88 } 61 89 62 90 class _AlertsTabs extends StatelessWidget { 63 - const _AlertsTabs({required this.currentTab}); 91 + const _AlertsTabs({ 92 + required this.currentTab, 93 + required this.notificationsUnreadCount, 94 + required this.messagesUnreadCount, 95 + }); 64 96 65 97 final AlertsTab currentTab; 98 + final int notificationsUnreadCount; 99 + final int messagesUnreadCount; 66 100 67 101 @override 68 102 Widget build(BuildContext context) { ··· 72 106 ), 73 107 child: Row( 74 108 children: [ 75 - _AlertsTabButton(tab: AlertsTab.notifications, label: 'Notifications', currentTab: currentTab), 76 - _AlertsTabButton(tab: AlertsTab.messages, label: 'Messages', currentTab: currentTab), 109 + _AlertsTabButton( 110 + tab: AlertsTab.notifications, 111 + label: 'Notifications', 112 + currentTab: currentTab, 113 + unreadCount: notificationsUnreadCount, 114 + ), 115 + _AlertsTabButton( 116 + tab: AlertsTab.messages, 117 + label: 'Messages', 118 + currentTab: currentTab, 119 + unreadCount: messagesUnreadCount, 120 + ), 77 121 _AlertsTabButton(tab: AlertsTab.requests, label: 'Requests', currentTab: currentTab), 78 122 ], 79 123 ), ··· 82 126 } 83 127 84 128 class _AlertsTabButton extends StatelessWidget { 85 - const _AlertsTabButton({required this.tab, required this.label, required this.currentTab}); 129 + const _AlertsTabButton({required this.tab, required this.label, required this.currentTab, this.unreadCount = 0}); 86 130 87 131 final AlertsTab tab; 88 132 final String label; 89 133 final AlertsTab currentTab; 134 + final int unreadCount; 90 135 91 136 @override 92 137 Widget build(BuildContext context) { ··· 107 152 bottom: BorderSide(color: isSelected ? theme.colorScheme.primary : Colors.transparent, width: 2), 108 153 ), 109 154 ), 110 - child: Text(label, textAlign: TextAlign.center, style: textStyle), 155 + child: Row( 156 + mainAxisAlignment: MainAxisAlignment.center, 157 + mainAxisSize: MainAxisSize.min, 158 + children: [ 159 + Text(label, textAlign: TextAlign.center, style: textStyle), 160 + if (unreadCount > 0) ...[ 161 + const SizedBox(width: 6), 162 + Container( 163 + key: ValueKey('alerts-tab-unread-${tab.name}'), 164 + constraints: const BoxConstraints(minWidth: 18), 165 + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), 166 + decoration: BoxDecoration(color: theme.colorScheme.primary, borderRadius: BorderRadius.circular(12)), 167 + child: Text( 168 + unreadCount > 99 ? '99+' : unreadCount.toString(), 169 + textAlign: TextAlign.center, 170 + style: theme.textTheme.labelSmall?.copyWith( 171 + color: theme.colorScheme.onPrimary, 172 + fontWeight: FontWeight.w700, 173 + ), 174 + ), 175 + ), 176 + ], 177 + ], 178 + ), 111 179 ), 112 180 ), 113 181 );
+22 -7
lib/features/notifications/bloc/notification_bloc.dart
··· 3 3 import 'package:flutter_bloc/flutter_bloc.dart'; 4 4 import 'package:lazurite/core/logging/app_logger.dart'; 5 5 import 'package:lazurite/features/notifications/data/notification_repository.dart'; 6 + import 'package:lazurite/features/notifications/domain/notification_domain_service.dart'; 6 7 7 8 part 'notification_event.dart'; 8 9 part 'notification_state.dart'; 9 10 10 11 class NotificationBloc extends Bloc<NotificationEvent, NotificationState> { 11 - NotificationBloc({required NotificationRepository notificationRepository}) 12 - : _notificationRepository = notificationRepository, 12 + NotificationBloc({ 13 + NotificationDomainService? notificationDomainService, 14 + NotificationRepository? notificationRepository, 15 + }) : _notificationDomainService = 16 + notificationDomainService ?? 17 + NotificationDomainService( 18 + notificationRepository: notificationRepository ?? 19 + (throw ArgumentError('Either notificationDomainService or notificationRepository is required')), 20 + ), 13 21 super(const NotificationState.initial()) { 14 22 on<NotificationsRequested>(_onNotificationsRequested); 15 23 on<NotificationsRefreshed>(_onNotificationsRefreshed); ··· 17 25 on<NotificationsMarkedRead>(_onNotificationsMarkedRead); 18 26 } 19 27 20 - final NotificationRepository _notificationRepository; 28 + final NotificationDomainService _notificationDomainService; 21 29 22 30 Future<void> _onNotificationsRequested(NotificationsRequested event, Emitter<NotificationState> emit) async { 23 31 emit(const NotificationState.loading()); 24 32 25 33 try { 26 - final result = await _notificationRepository.listNotifications(limit: event.limit); 34 + final result = await _notificationDomainService.listNotifications(limit: event.limit); 27 35 28 36 emit( 29 37 NotificationState.loaded( ··· 45 53 emit(state.copyWith(isRefreshing: true)); 46 54 47 55 try { 48 - final result = await _notificationRepository.listNotifications(limit: 50); 56 + final result = await _notificationDomainService.listNotifications(limit: 50); 49 57 50 58 emit( 51 59 state.copyWith( ··· 68 76 emit(state.copyWith(isLoadingMore: true)); 69 77 70 78 try { 71 - final result = await _notificationRepository.listNotifications(cursor: state.cursor, limit: event.limit); 79 + final result = await _notificationDomainService.listNotifications(cursor: state.cursor, limit: event.limit); 72 80 73 81 emit( 74 82 state.copyWith( ··· 85 93 86 94 Future<void> _onNotificationsMarkedRead(NotificationsMarkedRead event, Emitter<NotificationState> emit) async { 87 95 try { 88 - await _notificationRepository.updateSeen(); 96 + await _notificationDomainService.markSeen(); 97 + if (state.status == NotificationStatus.loaded && state.notifications.isNotEmpty) { 98 + emit( 99 + state.copyWith( 100 + notifications: state.notifications.map((notification) => notification.copyWith(isRead: true)).toList(), 101 + ), 102 + ); 103 + } 89 104 } catch (_) { 90 105 log.w('Failed to mark notifications as read/seen'); 91 106 }
+12 -4
lib/features/notifications/cubit/unread_count_cubit.dart
··· 3 3 import 'package:equatable/equatable.dart'; 4 4 import 'package:flutter_bloc/flutter_bloc.dart'; 5 5 import 'package:lazurite/features/notifications/data/notification_repository.dart'; 6 + import 'package:lazurite/features/notifications/domain/notification_domain_service.dart'; 6 7 import 'package:lazurite/core/logging/app_logger.dart'; 7 8 8 9 class UnreadCountCubit extends Cubit<UnreadCountState> { 9 - UnreadCountCubit({required NotificationRepository notificationRepository}) 10 - : _notificationRepository = notificationRepository, 10 + UnreadCountCubit({ 11 + NotificationDomainService? notificationDomainService, 12 + NotificationRepository? notificationRepository, 13 + }) : _notificationDomainService = 14 + notificationDomainService ?? 15 + NotificationDomainService( 16 + notificationRepository: notificationRepository ?? 17 + (throw ArgumentError('Either notificationDomainService or notificationRepository is required')), 18 + ), 11 19 super(const UnreadCountState(0)) { 12 20 _startPolling(); 13 21 } 14 22 15 - final NotificationRepository _notificationRepository; 23 + final NotificationDomainService _notificationDomainService; 16 24 Timer? _pollingTimer; 17 25 18 26 static const _pollingInterval = Duration(seconds: 30); ··· 24 32 25 33 Future<void> _pollUnreadCount() async { 26 34 try { 27 - final count = await _notificationRepository.getUnreadCount(); 35 + final count = await _notificationDomainService.getUnreadCount(); 28 36 emit(UnreadCountState(count)); 29 37 } catch (_) { 30 38 log.w('Failed to poll unread count');
+101
lib/features/notifications/data/flutter_local_notification_adapter.dart
··· 1 + import 'package:flutter_local_notifications/flutter_local_notifications.dart'; 2 + import 'package:lazurite/features/notifications/domain/local_notification_adapter.dart'; 3 + import 'package:lazurite/features/notifications/domain/notification_local_mappers.dart'; 4 + import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; 5 + 6 + class FlutterLocalNotificationAdapter implements LocalNotificationAdapter { 7 + FlutterLocalNotificationAdapter({FlutterLocalNotificationsPlugin? plugin}) 8 + : _plugin = plugin ?? FlutterLocalNotificationsPlugin(); 9 + 10 + final FlutterLocalNotificationsPlugin _plugin; 11 + var _initialized = false; 12 + 13 + @override 14 + Future<void> initialize({required NotificationTapCallback onTap}) async { 15 + if (_initialized) { 16 + return; 17 + } 18 + 19 + const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); 20 + final categories = NotificationReasonFamily.values 21 + .map((family) => DarwinNotificationCategory(family.iosCategoryIdentifier)) 22 + .toList(growable: false); 23 + final darwinSettings = DarwinInitializationSettings( 24 + requestAlertPermission: false, 25 + requestBadgePermission: false, 26 + requestSoundPermission: false, 27 + notificationCategories: categories, 28 + ); 29 + 30 + await _plugin.initialize( 31 + InitializationSettings(android: androidSettings, iOS: darwinSettings), 32 + onDidReceiveNotificationResponse: (response) { 33 + final deepLink = NotificationPayloadCodec.decode(response.payload); 34 + if (deepLink != null) { 35 + onTap(deepLink); 36 + } 37 + }, 38 + ); 39 + 40 + await _createAndroidChannels(); 41 + 42 + final launchDetails = await _plugin.getNotificationAppLaunchDetails(); 43 + if (launchDetails?.didNotificationLaunchApp ?? false) { 44 + final deepLink = NotificationPayloadCodec.decode(launchDetails?.notificationResponse?.payload); 45 + if (deepLink != null) { 46 + onTap(deepLink); 47 + } 48 + } 49 + 50 + _initialized = true; 51 + } 52 + 53 + @override 54 + Future<void> requestPermissions() async { 55 + final android = _plugin.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>(); 56 + await android?.requestNotificationsPermission(); 57 + 58 + final ios = _plugin.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>(); 59 + await ios?.requestPermissions(alert: true, badge: true, sound: true); 60 + } 61 + 62 + @override 63 + Future<void> show(LocalNotificationRequest request) async { 64 + final details = NotificationDetails( 65 + android: AndroidNotificationDetails( 66 + request.reasonFamily.androidChannelId, 67 + request.reasonFamily.androidChannelName, 68 + channelDescription: '${request.reasonFamily.androidChannelName} notifications', 69 + importance: Importance.defaultImportance, 70 + priority: Priority.defaultPriority, 71 + ), 72 + iOS: DarwinNotificationDetails(categoryIdentifier: request.reasonFamily.iosCategoryIdentifier), 73 + ); 74 + 75 + await _plugin.show( 76 + request.notificationId, 77 + request.title, 78 + request.body, 79 + details, 80 + payload: NotificationPayloadCodec.encode(request.deepLink), 81 + ); 82 + } 83 + 84 + Future<void> _createAndroidChannels() async { 85 + final android = _plugin.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>(); 86 + if (android == null) { 87 + return; 88 + } 89 + 90 + for (final family in NotificationReasonFamily.values) { 91 + await android.createNotificationChannel( 92 + AndroidNotificationChannel( 93 + family.androidChannelId, 94 + family.androidChannelName, 95 + description: '${family.androidChannelName} notifications', 96 + importance: Importance.defaultImportance, 97 + ), 98 + ); 99 + } 100 + } 101 + }
+11
lib/features/notifications/domain/local_notification_adapter.dart
··· 1 + import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; 2 + 3 + typedef NotificationTapCallback = void Function(NotificationDeepLink deepLink); 4 + 5 + abstract class LocalNotificationAdapter { 6 + Future<void> initialize({required NotificationTapCallback onTap}); 7 + 8 + Future<void> requestPermissions(); 9 + 10 + Future<void> show(LocalNotificationRequest request); 11 + }
+110
lib/features/notifications/domain/notification_domain_service.dart
··· 1 + import 'package:bluesky/app_bsky_notification_listnotifications.dart'; 2 + import 'package:lazurite/core/database/app_database.dart'; 3 + import 'package:lazurite/features/notifications/data/notification_repository.dart'; 4 + import 'package:lazurite/features/notifications/domain/local_notification_adapter.dart'; 5 + import 'package:lazurite/features/notifications/domain/notification_local_mappers.dart'; 6 + 7 + /// Orchestrates notification polling flows and delivery-state persistence. 8 + class NotificationDomainService { 9 + NotificationDomainService({ 10 + required NotificationRepository notificationRepository, 11 + AppDatabase? database, 12 + String? accountDid, 13 + LocalNotificationAdapter? localNotificationAdapter, 14 + bool Function()? shouldSuppressLocalNotifications, 15 + }) : _notificationRepository = notificationRepository, 16 + _database = database, 17 + _accountDid = accountDid, 18 + _localNotificationAdapter = localNotificationAdapter, 19 + _shouldSuppressLocalNotifications = shouldSuppressLocalNotifications { 20 + if ((database == null) != (accountDid == null)) { 21 + throw ArgumentError('database and accountDid must both be provided together, or both omitted'); 22 + } 23 + } 24 + 25 + final NotificationRepository _notificationRepository; 26 + final AppDatabase? _database; 27 + final String? _accountDid; 28 + final LocalNotificationAdapter? _localNotificationAdapter; 29 + final bool Function()? _shouldSuppressLocalNotifications; 30 + 31 + Future<NotificationListResult> listNotifications({ 32 + String? cursor, 33 + int limit = 50, 34 + NotificationDeliverySource source = NotificationDeliverySource.poll, 35 + }) async { 36 + final result = await _notificationRepository.listNotifications(cursor: cursor, limit: limit); 37 + await persistNotificationDeliveries( 38 + result.notifications, 39 + source: source, 40 + onNewDelivery: (notification) async { 41 + if (notification.isRead) { 42 + return; 43 + } 44 + 45 + final request = NotificationLocalMapper.requestFromNotification(notification); 46 + if (request == null) { 47 + return; 48 + } 49 + 50 + if (_shouldSuppressLocalNotifications?.call() ?? false) { 51 + return; 52 + } 53 + 54 + await _localNotificationAdapter?.show(request); 55 + }, 56 + ); 57 + return result; 58 + } 59 + 60 + Future<int> getUnreadCount() => _notificationRepository.getUnreadCount(); 61 + 62 + Future<void> markSeen() => _notificationRepository.updateSeen(); 63 + 64 + Future<int> persistNotificationDeliveries( 65 + Iterable<Notification> notifications, { 66 + NotificationDeliverySource source = NotificationDeliverySource.poll, 67 + Future<void> Function(Notification notification)? onNewDelivery, 68 + }) async { 69 + final database = _database; 70 + final accountDid = _accountDid; 71 + if (database == null || accountDid == null) { 72 + return 0; 73 + } 74 + 75 + var insertedCount = 0; 76 + for (final notification in notifications) { 77 + final didInsert = await database.recordNotificationDelivery( 78 + accountDid: accountDid, 79 + notificationUri: notification.uri.toString(), 80 + notificationCid: notification.cid, 81 + reason: _reasonName(notification.reason), 82 + indexedAt: notification.indexedAt, 83 + source: source.value, 84 + ); 85 + if (didInsert) { 86 + insertedCount += 1; 87 + await onNewDelivery?.call(notification); 88 + } 89 + } 90 + 91 + return insertedCount; 92 + } 93 + 94 + String _reasonName(NotificationReason reason) { 95 + final knownReason = reason.knownValue; 96 + if (knownReason != null) { 97 + return knownReason.name; 98 + } 99 + return 'unknown'; 100 + } 101 + } 102 + 103 + enum NotificationDeliverySource { 104 + poll('poll'), 105 + push('push'); 106 + 107 + const NotificationDeliverySource(this.value); 108 + 109 + final String value; 110 + }
+187
lib/features/notifications/domain/notification_local_mappers.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:bluesky/app_bsky_notification_listnotifications.dart'; 4 + import 'package:crypto/crypto.dart'; 5 + import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; 6 + 7 + class NotificationPayloadCodec { 8 + static String encode(NotificationDeepLink deepLink) { 9 + return jsonEncode(<String, String>{'route': deepLink.route, 'mode': deepLink.navigationMode.name}); 10 + } 11 + 12 + static NotificationDeepLink? decode(String? payload) { 13 + if (payload == null || payload.trim().isEmpty) { 14 + return null; 15 + } 16 + 17 + try { 18 + final decoded = jsonDecode(payload); 19 + if (decoded is! Map<String, dynamic>) { 20 + return null; 21 + } 22 + 23 + final route = decoded['route']; 24 + if (route is! String || !route.startsWith('/')) { 25 + return null; 26 + } 27 + 28 + final mode = decoded['mode']; 29 + final navigationMode = mode == NotificationTapNavigationMode.go.name 30 + ? NotificationTapNavigationMode.go 31 + : NotificationTapNavigationMode.push; 32 + 33 + return NotificationDeepLink(route: route, navigationMode: navigationMode); 34 + } catch (_) { 35 + return null; 36 + } 37 + } 38 + } 39 + 40 + class NotificationLocalMapper { 41 + static LocalNotificationRequest? requestFromNotification(Notification notification) { 42 + if (notification.isRead) { 43 + return null; 44 + } 45 + 46 + final deepLink = _deepLinkForNotification(notification); 47 + if (deepLink == null) { 48 + return null; 49 + } 50 + 51 + return LocalNotificationRequest( 52 + notificationId: _stableNotificationId(notification.uri.toString()), 53 + title: _titleForNotification(notification), 54 + body: _bodyForReason(notification.reason), 55 + reasonFamily: _reasonFamilyForReason(notification.reason), 56 + deepLink: deepLink, 57 + ); 58 + } 59 + 60 + static NotificationReasonFamily _reasonFamilyForReason(NotificationReason reason) { 61 + final known = reason.knownValue; 62 + if (known == null) { 63 + return NotificationReasonFamily.misc; 64 + } 65 + 66 + switch (known) { 67 + case KnownNotificationReason.mention: 68 + return NotificationReasonFamily.mentions; 69 + case KnownNotificationReason.reply: 70 + case KnownNotificationReason.quote: 71 + return NotificationReasonFamily.replies; 72 + case KnownNotificationReason.follow: 73 + return NotificationReasonFamily.follows; 74 + case KnownNotificationReason.like: 75 + case KnownNotificationReason.repost: 76 + return NotificationReasonFamily.likes; 77 + default: 78 + return NotificationReasonFamily.misc; 79 + } 80 + } 81 + 82 + static NotificationDeepLink? _deepLinkForNotification(Notification notification) { 83 + final knownReason = notification.reason.knownValue; 84 + 85 + if (knownReason == KnownNotificationReason.follow) { 86 + final actor = notification.author.did.trim(); 87 + if (actor.isEmpty) { 88 + return null; 89 + } 90 + return NotificationDeepLink( 91 + route: '/profile/${Uri.encodeComponent(actor)}', 92 + navigationMode: NotificationTapNavigationMode.go, 93 + ); 94 + } 95 + 96 + final useReasonSubject = 97 + knownReason == KnownNotificationReason.like || knownReason == KnownNotificationReason.repost; 98 + final targetUri = (useReasonSubject ? notification.reasonSubject : null) ?? notification.uri; 99 + 100 + return NotificationDeepLink( 101 + route: '/post?uri=${Uri.encodeQueryComponent(targetUri.toString())}', 102 + navigationMode: NotificationTapNavigationMode.push, 103 + ); 104 + } 105 + 106 + static String _titleForNotification(Notification notification) { 107 + final displayName = notification.author.displayName?.trim(); 108 + if (displayName != null && displayName.isNotEmpty) { 109 + return displayName; 110 + } 111 + final handle = notification.author.handle.trim(); 112 + return handle.isEmpty ? 'New notification' : handle; 113 + } 114 + 115 + static String _bodyForReason(NotificationReason reason) { 116 + final known = reason.knownValue; 117 + switch (known) { 118 + case KnownNotificationReason.like: 119 + return 'liked your post'; 120 + case KnownNotificationReason.repost: 121 + return 'reposted your post'; 122 + case KnownNotificationReason.reply: 123 + return 'replied to your post'; 124 + case KnownNotificationReason.follow: 125 + return 'followed you'; 126 + case KnownNotificationReason.mention: 127 + return 'mentioned you'; 128 + case KnownNotificationReason.quote: 129 + return 'quoted your post'; 130 + default: 131 + return 'sent a notification'; 132 + } 133 + } 134 + 135 + static int _stableNotificationId(String value) { 136 + final digest = sha1.convert(utf8.encode(value)).bytes; 137 + final id = (digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]; 138 + return id & 0x7fffffff; 139 + } 140 + } 141 + 142 + extension NotificationReasonFamilyChannels on NotificationReasonFamily { 143 + String get androidChannelId { 144 + switch (this) { 145 + case NotificationReasonFamily.mentions: 146 + return 'mentions'; 147 + case NotificationReasonFamily.replies: 148 + return 'replies'; 149 + case NotificationReasonFamily.follows: 150 + return 'follows'; 151 + case NotificationReasonFamily.likes: 152 + return 'likes'; 153 + case NotificationReasonFamily.misc: 154 + return 'misc'; 155 + } 156 + } 157 + 158 + String get androidChannelName { 159 + switch (this) { 160 + case NotificationReasonFamily.mentions: 161 + return 'Mentions'; 162 + case NotificationReasonFamily.replies: 163 + return 'Replies'; 164 + case NotificationReasonFamily.follows: 165 + return 'Follows'; 166 + case NotificationReasonFamily.likes: 167 + return 'Likes'; 168 + case NotificationReasonFamily.misc: 169 + return 'Other'; 170 + } 171 + } 172 + 173 + String get iosCategoryIdentifier { 174 + switch (this) { 175 + case NotificationReasonFamily.mentions: 176 + return 'mentions'; 177 + case NotificationReasonFamily.replies: 178 + return 'replies'; 179 + case NotificationReasonFamily.follows: 180 + return 'follows'; 181 + case NotificationReasonFamily.likes: 182 + return 'likes'; 183 + case NotificationReasonFamily.misc: 184 + return 'misc'; 185 + } 186 + } 187 + }
+26
lib/features/notifications/domain/notification_local_models.dart
··· 1 + enum NotificationTapNavigationMode { go, push } 2 + 3 + class NotificationDeepLink { 4 + const NotificationDeepLink({required this.route, required this.navigationMode}); 5 + 6 + final String route; 7 + final NotificationTapNavigationMode navigationMode; 8 + } 9 + 10 + enum NotificationReasonFamily { mentions, replies, follows, likes, misc } 11 + 12 + class LocalNotificationRequest { 13 + const LocalNotificationRequest({ 14 + required this.notificationId, 15 + required this.title, 16 + required this.body, 17 + required this.reasonFamily, 18 + required this.deepLink, 19 + }); 20 + 21 + final int notificationId; 22 + final String title; 23 + final String body; 24 + final NotificationReasonFamily reasonFamily; 25 + final NotificationDeepLink deepLink; 26 + }
+16
lib/features/notifications/presentation/widgets/notifications_pane.dart
··· 1 1 import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 2 + import 'dart:async'; 3 + 2 4 import 'package:flutter/material.dart'; 3 5 import 'package:flutter_bloc/flutter_bloc.dart'; 4 6 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; ··· 23 25 class _NotificationsPaneState extends State<NotificationsPane> { 24 26 final ScrollController _scrollController = ScrollController(); 25 27 final Set<String> _seenNotificationKeys = <String>{}; 28 + Timer? _pollTimer; 29 + 30 + static const _pollInterval = Duration(seconds: 30); 26 31 27 32 @override 28 33 void initState() { ··· 33 38 context.read<NotificationBloc>().add(const NotificationsMarkedRead()); 34 39 context.read<UnreadCountCubit>().refresh(); 35 40 } 41 + _pollTimer = Timer.periodic(_pollInterval, (_) => _pollForUpdates()); 36 42 } 37 43 38 44 @override ··· 40 46 _scrollController 41 47 ..removeListener(_onScroll) 42 48 ..dispose(); 49 + _pollTimer?.cancel(); 43 50 super.dispose(); 44 51 } 45 52 ··· 53 60 context.read<NotificationBloc>().add(const NotificationsRefreshed()); 54 61 context.read<NotificationBloc>().add(const NotificationsMarkedRead()); 55 62 await context.read<UnreadCountCubit>().refresh(); 63 + } 64 + 65 + void _pollForUpdates() { 66 + if (!mounted) { 67 + return; 68 + } 69 + context.read<NotificationBloc>().add(const NotificationsRefreshed()); 70 + context.read<NotificationBloc>().add(const NotificationsMarkedRead()); 71 + context.read<UnreadCountCubit>().refresh(); 56 72 } 57 73 58 74 @override
+37
lib/main.dart
··· 40 40 import 'package:lazurite/features/messages/data/convo_repository.dart'; 41 41 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 42 42 import 'package:lazurite/features/notifications/data/notification_repository.dart'; 43 + import 'package:lazurite/features/notifications/data/flutter_local_notification_adapter.dart'; 44 + import 'package:lazurite/features/notifications/domain/local_notification_adapter.dart'; 45 + import 'package:lazurite/features/notifications/domain/notification_deep_link_navigator.dart'; 46 + import 'package:lazurite/features/notifications/domain/notification_domain_service.dart'; 47 + import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; 43 48 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 44 49 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 45 50 import 'package:lazurite/features/profile/data/profile_repository.dart'; ··· 96 101 97 102 final accountSwitcherCubit = AccountSwitcherCubit(database: database, authRepository: authRepository); 98 103 await accountSwitcherCubit.loadAccounts(); 104 + final localNotificationAdapter = FlutterLocalNotificationAdapter(); 99 105 100 106 log.i('AppLogger: App started'); 101 107 ··· 109 115 settingsCubit, 110 116 connectivityCubit, 111 117 accountSwitcherCubit, 118 + localNotificationAdapter, 112 119 ), 113 120 ); 114 121 } ··· 124 131 required this.settingsCubit, 125 132 required this.connectivityCubit, 126 133 required this.accountSwitcherCubit, 134 + required this.localNotificationAdapter, 127 135 }); 128 136 129 137 final AuthBloc authBloc; ··· 134 142 final SettingsCubit settingsCubit; 135 143 final ConnectivityCubit connectivityCubit; 136 144 final AccountSwitcherCubit accountSwitcherCubit; 145 + final LocalNotificationAdapter localNotificationAdapter; 137 146 138 147 /// factory constructor with positional params 139 148 static LazuriteApp from( ··· 145 154 SettingsCubit settingsCubit, 146 155 ConnectivityCubit connectivityCubit, 147 156 AccountSwitcherCubit accountSwitcherCubit, 157 + LocalNotificationAdapter localNotificationAdapter, 148 158 ) => LazuriteApp( 149 159 authBloc: authBloc, 150 160 database: database, ··· 154 164 settingsCubit: settingsCubit, 155 165 connectivityCubit: connectivityCubit, 156 166 accountSwitcherCubit: accountSwitcherCubit, 167 + localNotificationAdapter: localNotificationAdapter, 157 168 ); 158 169 159 170 @override ··· 178 189 _routerSessionKey = _sessionKeyFor(widget.authBloc.state); 179 190 _observedAppViewProvider = widget.settingsCubit.state.appViewProvider; 180 191 _router = _createRouter(); 192 + unawaited( 193 + widget.localNotificationAdapter.initialize(onTap: _handleNotificationDeepLink).then((_) { 194 + return widget.localNotificationAdapter.requestPermissions(); 195 + }), 196 + ); 181 197 _authSubscription = widget.authBloc.stream.map(_sessionKeyFor).distinct().listen(_handleSessionKeyChanged); 182 198 _simulateOfflineSubscription = widget.settingsCubit.stream 183 199 .map((state) => state.simulateOffline) ··· 266 282 } 267 283 } 268 284 285 + void _handleNotificationDeepLink(NotificationDeepLink deepLink) { 286 + if (!mounted) { 287 + return; 288 + } 289 + NotificationDeepLinkNavigator.navigate(_router, deepLink); 290 + } 291 + 292 + bool _isAlertsRouteActive() { 293 + final path = _router.routerDelegate.currentConfiguration.uri.path; 294 + return path.startsWith('/alerts'); 295 + } 296 + 269 297 Bluesky? _createBluesky(AuthState state) => state.isAuthenticated ? createBlueskyClient(state.tokens) : null; 270 298 271 299 BlueskyChat? _createBlueskyChat(AuthState state) => ··· 416 444 bluesky: bluesky, 417 445 moderationService: context.read<ModerationService>(), 418 446 appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 447 + ), 448 + ), 449 + RepositoryProvider( 450 + create: (context) => NotificationDomainService( 451 + notificationRepository: context.read<NotificationRepository>(), 452 + database: widget.database, 453 + accountDid: accountDid, 454 + localNotificationAdapter: widget.localNotificationAdapter, 455 + shouldSuppressLocalNotifications: _isAlertsRouteActive, 419 456 ), 420 457 ), 421 458 RepositoryProvider(
+40
pubspec.lock
··· 542 542 url: "https://pub.dev" 543 543 source: hosted 544 544 version: "6.0.0" 545 + flutter_local_notifications: 546 + dependency: "direct main" 547 + description: 548 + name: flutter_local_notifications 549 + sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" 550 + url: "https://pub.dev" 551 + source: hosted 552 + version: "19.5.0" 553 + flutter_local_notifications_linux: 554 + dependency: transitive 555 + description: 556 + name: flutter_local_notifications_linux 557 + sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 558 + url: "https://pub.dev" 559 + source: hosted 560 + version: "6.0.0" 561 + flutter_local_notifications_platform_interface: 562 + dependency: transitive 563 + description: 564 + name: flutter_local_notifications_platform_interface 565 + sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" 566 + url: "https://pub.dev" 567 + source: hosted 568 + version: "9.1.0" 569 + flutter_local_notifications_windows: 570 + dependency: transitive 571 + description: 572 + name: flutter_local_notifications_windows 573 + sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" 574 + url: "https://pub.dev" 575 + source: hosted 576 + version: "1.0.3" 545 577 flutter_native_splash: 546 578 dependency: "direct dev" 547 579 description: ··· 1477 1509 url: "https://pub.dev" 1478 1510 source: hosted 1479 1511 version: "0.12.1" 1512 + timezone: 1513 + dependency: transitive 1514 + description: 1515 + name: timezone 1516 + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 1517 + url: "https://pub.dev" 1518 + source: hosted 1519 + version: "0.10.1" 1480 1520 typed_data: 1481 1521 dependency: transitive 1482 1522 description:
+1
pubspec.yaml
··· 53 53 flutter_animate: ^4.5.2 54 54 cached_network_image: ^3.4.1 55 55 flutter_cache_manager: ^3.4.1 56 + flutter_local_notifications: ^19.4.2 56 57 57 58 dev_dependencies: 58 59 flutter_test:
+36
test/core/database/app_database_test.dart
··· 264 264 }); 265 265 }); 266 266 267 + group('Notification delivery operations', () { 268 + test('should update existing delivery metadata on duplicate insert', () async { 269 + final firstInsert = await database.recordNotificationDelivery( 270 + accountDid: 'did:plc:test', 271 + notificationUri: 'at://did:plc:test/app.bsky.feed.post/1', 272 + notificationCid: 'cid-1', 273 + reason: 'like', 274 + indexedAt: DateTime.utc(2026, 5, 1, 9, 0), 275 + source: 'poll', 276 + ); 277 + 278 + final secondInsert = await database.recordNotificationDelivery( 279 + accountDid: 'did:plc:test', 280 + notificationUri: 'at://did:plc:test/app.bsky.feed.post/1', 281 + notificationCid: 'cid-2', 282 + reason: 'repost', 283 + indexedAt: DateTime.utc(2026, 5, 1, 10, 0), 284 + source: 'push', 285 + ); 286 + 287 + final delivery = await database.getNotificationDelivery( 288 + 'did:plc:test', 289 + 'at://did:plc:test/app.bsky.feed.post/1', 290 + ); 291 + 292 + expect(firstInsert, isTrue); 293 + expect(secondInsert, isFalse); 294 + expect(await database.countNotificationDeliveries('did:plc:test'), 1); 295 + expect(delivery, isNotNull); 296 + expect(delivery!.notificationCid, 'cid-2'); 297 + expect(delivery.reason, 'repost'); 298 + expect(delivery.indexedAt.toUtc(), DateTime.utc(2026, 5, 1, 10, 0)); 299 + expect(delivery.source, 'push'); 300 + }); 301 + }); 302 + 267 303 group('Settings operations', () { 268 304 test('should seed default typeahead provider on database creation', () async { 269 305 final value = await database.getSetting('typeahead_provider');
+10
test/features/alerts/presentation/alerts_screen_test.dart
··· 185 185 expect(find.text('Mark All Read'), findsOneWidget); 186 186 }); 187 187 188 + testWidgets('shows unread badges for notifications and messages tabs', (tester) async { 189 + await tester.pumpWidget(buildSubject('/alerts')); 190 + await tester.pumpAndSettle(); 191 + 192 + expect(find.byKey(const ValueKey('alerts-tab-unread-notifications')), findsOneWidget); 193 + expect(find.byKey(const ValueKey('alerts-tab-unread-messages')), findsOneWidget); 194 + expect(find.text('1'), findsOneWidget); 195 + expect(find.text('2'), findsOneWidget); 196 + }); 197 + 188 198 testWidgets('opens messages tab from deep link', (tester) async { 189 199 await tester.pumpWidget(buildSubject('/alerts/messages')); 190 200 await tester.pumpAndSettle();
+18
test/features/notifications/bloc/notification_bloc_test.dart
··· 192 192 ); 193 193 194 194 blocTest<NotificationBloc, NotificationState>( 195 + 'marks loaded notifications as read after NotificationsMarkedRead succeeds', 196 + build: () => NotificationBloc(notificationRepository: mockNotificationRepository), 197 + seed: () => NotificationState.loaded(notifications: [sampleNotification], cursor: null, hasMore: false), 198 + setUp: () { 199 + when(() => mockNotificationRepository.updateSeen()).thenAnswer((_) async {}); 200 + }, 201 + act: (bloc) => bloc.add(const NotificationsMarkedRead()), 202 + expect: () => [ 203 + predicate<NotificationState>( 204 + (state) => 205 + state.status == NotificationStatus.loaded && 206 + state.notifications.length == 1 && 207 + state.notifications.first.isRead, 208 + ), 209 + ], 210 + ); 211 + 212 + blocTest<NotificationBloc, NotificationState>( 195 213 'handles updateSeen failure silently', 196 214 build: () => NotificationBloc(notificationRepository: mockNotificationRepository), 197 215 setUp: () {
+209
test/features/notifications/domain/notification_domain_service_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 4 + import 'package:drift/native.dart'; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:lazurite/core/database/app_database.dart'; 7 + import 'package:lazurite/features/notifications/data/notification_repository.dart'; 8 + import 'package:lazurite/features/notifications/domain/notification_domain_service.dart'; 9 + import 'package:lazurite/features/notifications/domain/local_notification_adapter.dart'; 10 + import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; 11 + import 'package:mocktail/mocktail.dart'; 12 + 13 + class MockNotificationRepository extends Mock implements NotificationRepository {} 14 + 15 + class MockLocalNotificationAdapter extends Mock implements LocalNotificationAdapter {} 16 + 17 + class FakeLocalNotificationRequest extends Fake implements LocalNotificationRequest {} 18 + 19 + void main() { 20 + late MockNotificationRepository repository; 21 + late MockLocalNotificationAdapter localNotificationAdapter; 22 + 23 + setUp(() { 24 + repository = MockNotificationRepository(); 25 + localNotificationAdapter = MockLocalNotificationAdapter(); 26 + when(() => localNotificationAdapter.show(any())).thenAnswer((_) async {}); 27 + }); 28 + 29 + setUpAll(() { 30 + registerFallbackValue(FakeLocalNotificationRequest()); 31 + }); 32 + 33 + group('NotificationDomainService', () { 34 + final sampleNotifications = [ 35 + bsky.Notification( 36 + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/abc'), 37 + cid: 'cid-123', 38 + author: const ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), 39 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.like), 40 + record: const {}, 41 + isRead: false, 42 + indexedAt: DateTime.utc(2026, 4, 29, 12), 43 + ), 44 + bsky.Notification( 45 + uri: AtUri.parse('at://did:plc:author2/app.bsky.feed.post/def'), 46 + cid: 'cid-456', 47 + author: const ProfileView(did: 'did:plc:author2', handle: 'author2.bsky.social'), 48 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.follow), 49 + record: const {}, 50 + isRead: true, 51 + indexedAt: DateTime.utc(2026, 4, 29, 13), 52 + ), 53 + ]; 54 + 55 + test('persists deliveries and dedupes by accountDid + notificationUri', () async { 56 + final database = AppDatabase(executor: NativeDatabase.memory()); 57 + addTearDown(database.close); 58 + 59 + when( 60 + () => repository.listNotifications( 61 + cursor: any(named: 'cursor'), 62 + limit: any(named: 'limit'), 63 + ), 64 + ).thenAnswer((_) async => NotificationListResult(notifications: sampleNotifications, cursor: null)); 65 + 66 + final service = NotificationDomainService( 67 + notificationRepository: repository, 68 + database: database, 69 + accountDid: 'did:plc:test', 70 + ); 71 + 72 + await service.listNotifications(); 73 + await service.listNotifications(); 74 + 75 + expect(await database.countNotificationDeliveries('did:plc:test'), 2); 76 + 77 + final first = await database.getNotificationDelivery( 78 + 'did:plc:test', 79 + 'at://did:plc:author/app.bsky.feed.post/abc', 80 + ); 81 + expect(first, isNotNull); 82 + expect(first!.source, 'poll'); 83 + expect(first.reason, 'like'); 84 + }); 85 + 86 + test('updates reason/indexedAt/source on duplicate observation while preserving dedupe', () async { 87 + final database = AppDatabase(executor: NativeDatabase.memory()); 88 + addTearDown(database.close); 89 + 90 + final originalNotification = sampleNotifications.first; 91 + final updatedNotification = bsky.Notification( 92 + uri: originalNotification.uri, 93 + cid: originalNotification.cid, 94 + author: originalNotification.author, 95 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.repost), 96 + record: originalNotification.record, 97 + isRead: originalNotification.isRead, 98 + indexedAt: DateTime.utc(2026, 5, 2, 10, 0), 99 + ); 100 + 101 + when( 102 + () => repository.listNotifications( 103 + cursor: any(named: 'cursor'), 104 + limit: any(named: 'limit'), 105 + ), 106 + ).thenAnswer((_) async => NotificationListResult(notifications: [originalNotification], cursor: null)); 107 + 108 + final service = NotificationDomainService( 109 + notificationRepository: repository, 110 + database: database, 111 + accountDid: 'did:plc:test', 112 + ); 113 + 114 + await service.listNotifications(); 115 + 116 + when( 117 + () => repository.listNotifications( 118 + cursor: any(named: 'cursor'), 119 + limit: any(named: 'limit'), 120 + ), 121 + ).thenAnswer((_) async => NotificationListResult(notifications: [updatedNotification], cursor: null)); 122 + 123 + await service.listNotifications(); 124 + 125 + final delivery = await database.getNotificationDelivery('did:plc:test', originalNotification.uri.toString()); 126 + 127 + expect(await database.countNotificationDeliveries('did:plc:test'), 1); 128 + expect(delivery, isNotNull); 129 + expect(delivery!.reason, 'repost'); 130 + expect(delivery.source, 'poll'); 131 + expect(delivery.indexedAt.toUtc(), DateTime.utc(2026, 5, 2, 10, 0)); 132 + }); 133 + 134 + test('shows local notifications for newly inserted unseen items only once', () async { 135 + final database = AppDatabase(executor: NativeDatabase.memory()); 136 + addTearDown(database.close); 137 + final unseenNotification = sampleNotifications.first; 138 + 139 + when( 140 + () => repository.listNotifications( 141 + cursor: any(named: 'cursor'), 142 + limit: any(named: 'limit'), 143 + ), 144 + ).thenAnswer((_) async => NotificationListResult(notifications: [unseenNotification], cursor: null)); 145 + 146 + final service = NotificationDomainService( 147 + notificationRepository: repository, 148 + database: database, 149 + accountDid: 'did:plc:test', 150 + localNotificationAdapter: localNotificationAdapter, 151 + ); 152 + 153 + await service.listNotifications(); 154 + await service.listNotifications(); 155 + 156 + verify(() => localNotificationAdapter.show(any<LocalNotificationRequest>())).called(1); 157 + }); 158 + 159 + test('suppresses local notification display while alerts route is active', () async { 160 + final database = AppDatabase(executor: NativeDatabase.memory()); 161 + addTearDown(database.close); 162 + final unseenNotification = sampleNotifications.first; 163 + 164 + when( 165 + () => repository.listNotifications( 166 + cursor: any(named: 'cursor'), 167 + limit: any(named: 'limit'), 168 + ), 169 + ).thenAnswer((_) async => NotificationListResult(notifications: [unseenNotification], cursor: null)); 170 + 171 + final service = NotificationDomainService( 172 + notificationRepository: repository, 173 + database: database, 174 + accountDid: 'did:plc:test', 175 + localNotificationAdapter: localNotificationAdapter, 176 + shouldSuppressLocalNotifications: () => true, 177 + ); 178 + 179 + await service.listNotifications(); 180 + 181 + verifyNever(() => localNotificationAdapter.show(any<LocalNotificationRequest>())); 182 + expect(await database.countNotificationDeliveries('did:plc:test'), 1); 183 + }); 184 + 185 + test('listNotifications still works without persistence dependencies', () async { 186 + when( 187 + () => repository.listNotifications( 188 + cursor: any(named: 'cursor'), 189 + limit: any(named: 'limit'), 190 + ), 191 + ).thenAnswer((_) async => NotificationListResult(notifications: sampleNotifications, cursor: 'next')); 192 + 193 + final service = NotificationDomainService(notificationRepository: repository); 194 + final result = await service.listNotifications(); 195 + 196 + expect(result.notifications.length, 2); 197 + expect(result.cursor, 'next'); 198 + }); 199 + 200 + test('markSeen delegates to repository', () async { 201 + when(() => repository.updateSeen()).thenAnswer((_) async {}); 202 + final service = NotificationDomainService(notificationRepository: repository); 203 + 204 + await service.markSeen(); 205 + 206 + verify(() => repository.updateSeen()).called(1); 207 + }); 208 + }); 209 + }
+72
test/features/notifications/domain/notification_local_mappers_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/features/notifications/domain/notification_local_mappers.dart'; 6 + import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; 7 + 8 + void main() { 9 + group('NotificationLocalMapper', () { 10 + test('maps follow notifications to profile route with go navigation', () { 11 + final notification = bsky.Notification( 12 + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/abc'), 13 + cid: 'cid-123', 14 + author: const ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), 15 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.follow), 16 + record: const {}, 17 + isRead: false, 18 + indexedAt: DateTime.utc(2026, 5, 1, 12), 19 + ); 20 + 21 + final request = NotificationLocalMapper.requestFromNotification(notification); 22 + 23 + expect(request, isNotNull); 24 + expect(request!.reasonFamily, NotificationReasonFamily.follows); 25 + expect(request.deepLink.navigationMode, NotificationTapNavigationMode.go); 26 + expect(request.deepLink.route, '/profile/${Uri.encodeComponent('did:plc:author')}'); 27 + }); 28 + 29 + test('maps like notifications to post route using reasonSubject', () { 30 + final reasonSubject = AtUri.parse('at://did:plc:target/app.bsky.feed.post/xyz'); 31 + final notification = bsky.Notification( 32 + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.like/abc'), 33 + cid: 'cid-123', 34 + author: const ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), 35 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.like), 36 + reasonSubject: reasonSubject, 37 + record: const {}, 38 + isRead: false, 39 + indexedAt: DateTime.utc(2026, 5, 1, 12), 40 + ); 41 + 42 + final request = NotificationLocalMapper.requestFromNotification(notification); 43 + 44 + expect(request, isNotNull); 45 + expect(request!.reasonFamily, NotificationReasonFamily.likes); 46 + expect(request.deepLink.navigationMode, NotificationTapNavigationMode.push); 47 + expect(request.deepLink.route, '/post?uri=${Uri.encodeQueryComponent(reasonSubject.toString())}'); 48 + }); 49 + }); 50 + 51 + group('NotificationPayloadCodec', () { 52 + test('encodes and decodes deep links', () { 53 + const deepLink = NotificationDeepLink( 54 + route: '/post?uri=at%3A%2F%2Fabc', 55 + navigationMode: NotificationTapNavigationMode.push, 56 + ); 57 + 58 + final payload = NotificationPayloadCodec.encode(deepLink); 59 + final decoded = NotificationPayloadCodec.decode(payload); 60 + 61 + expect(decoded, isNotNull); 62 + expect(decoded!.route, deepLink.route); 63 + expect(decoded.navigationMode, NotificationTapNavigationMode.push); 64 + }); 65 + 66 + test('returns null for invalid payload', () { 67 + expect(NotificationPayloadCodec.decode('not-json'), isNull); 68 + expect(NotificationPayloadCodec.decode('{"mode":"go"}'), isNull); 69 + expect(NotificationPayloadCodec.decode(''), isNull); 70 + }); 71 + }); 72 + }