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: exhaustive notification handling with reason utilities and deep linking

+404 -148
+21 -1
lib/features/notifications/data/notification_repository.dart
··· 123 123 if (senderDid != null && senderDid.trim().isNotEmpty && notification.author.did != senderDid) { 124 124 continue; 125 125 } 126 - if (reason != null && reason.trim().isNotEmpty && _reasonName(notification) != reason) { 126 + if (reason != null && reason.trim().isNotEmpty && !_reasonMatches(notification, reason)) { 127 127 continue; 128 128 } 129 129 return notification; ··· 203 203 return unknown; 204 204 } 205 205 return 'unknown'; 206 + } 207 + 208 + bool _reasonMatches(Notification notification, String reason) { 209 + final normalizedPayloadReason = reason.trim(); 210 + if (normalizedPayloadReason.isEmpty) { 211 + return true; 212 + } 213 + 214 + final notificationReason = _reasonName(notification); 215 + if (notificationReason == normalizedPayloadReason) { 216 + return true; 217 + } 218 + 219 + final familyReason = switch (notificationReason) { 220 + 'like-via-repost' => 'like', 221 + 'repost-via-repost' => 'repost', 222 + _ => notificationReason, 223 + }; 224 + 225 + return familyReason == normalizedPayloadReason; 206 226 } 207 227 } 208 228
+4 -69
lib/features/notifications/domain/notification_local_mappers.dart
··· 3 3 import 'package:bluesky/app_bsky_notification_listnotifications.dart'; 4 4 import 'package:crypto/crypto.dart'; 5 5 import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; 6 + import 'package:lazurite/features/notifications/domain/notification_reason_utils.dart'; 6 7 7 8 class NotificationPayloadCodec { 8 9 static String encode(NotificationDeepLink deepLink) { ··· 43 44 return null; 44 45 } 45 46 46 - final deepLink = _deepLinkForNotification(notification); 47 + final deepLink = NotificationReasonUtils.deepLinkForNotification(notification); 47 48 if (deepLink == null) { 48 49 return null; 49 50 } ··· 51 52 return LocalNotificationRequest( 52 53 notificationId: _stableNotificationId(notification.uri.toString()), 53 54 title: _titleForNotification(notification), 54 - body: _bodyForReason(notification.reason), 55 - reasonFamily: _reasonFamilyForReason(notification.reason), 55 + body: NotificationReasonUtils.localNotificationBodyForReason(notification.reason), 56 + reasonFamily: NotificationReasonUtils.reasonFamilyForReason(notification.reason), 56 57 deepLink: deepLink, 57 58 ); 58 59 } 59 60 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 61 static String _titleForNotification(Notification notification) { 107 62 final displayName = notification.author.displayName?.trim(); 108 63 if (displayName != null && displayName.isNotEmpty) { ··· 110 65 } 111 66 final handle = notification.author.handle.trim(); 112 67 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 68 } 134 69 135 70 static int _stableNotificationId(String value) {
+176
lib/features/notifications/domain/notification_reason_utils.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 3 + import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; 4 + 5 + abstract final class NotificationReasonUtils { 6 + static bool isProfileNavigationReason(bsky.NotificationReason reason) { 7 + if (!reason.isKnownValue) { 8 + return false; 9 + } 10 + 11 + switch (reason.knownValue) { 12 + case bsky.KnownNotificationReason.follow: 13 + case bsky.KnownNotificationReason.verified: 14 + case bsky.KnownNotificationReason.unverified: 15 + case bsky.KnownNotificationReason.contactMatch: 16 + return true; 17 + default: 18 + return false; 19 + } 20 + } 21 + 22 + static bool isEngagementReason(bsky.NotificationReason reason) { 23 + if (!reason.isKnownValue) { 24 + return false; 25 + } 26 + 27 + switch (reason.knownValue) { 28 + case bsky.KnownNotificationReason.like: 29 + case bsky.KnownNotificationReason.repost: 30 + case bsky.KnownNotificationReason.likeViaRepost: 31 + case bsky.KnownNotificationReason.repostViaRepost: 32 + return true; 33 + default: 34 + return false; 35 + } 36 + } 37 + 38 + static String summaryTextForReason(bsky.NotificationReason reason) { 39 + if (!reason.isKnownValue) { 40 + return 'interacted with you'; 41 + } 42 + 43 + switch (reason.knownValue) { 44 + case bsky.KnownNotificationReason.like: 45 + return 'liked your post'; 46 + case bsky.KnownNotificationReason.repost: 47 + return 'reposted your post'; 48 + case bsky.KnownNotificationReason.likeViaRepost: 49 + return 'liked your repost'; 50 + case bsky.KnownNotificationReason.repostViaRepost: 51 + return 'reposted your repost'; 52 + case bsky.KnownNotificationReason.follow: 53 + return 'followed you'; 54 + case bsky.KnownNotificationReason.mention: 55 + return 'mentioned you'; 56 + case bsky.KnownNotificationReason.reply: 57 + return 'replied to your post'; 58 + case bsky.KnownNotificationReason.quote: 59 + return 'quoted your post'; 60 + case bsky.KnownNotificationReason.starterpackJoined: 61 + return 'joined via your starter pack'; 62 + case bsky.KnownNotificationReason.verified: 63 + return 'verified your account'; 64 + case bsky.KnownNotificationReason.unverified: 65 + return 'removed your verification'; 66 + case bsky.KnownNotificationReason.subscribedPost: 67 + return 'posted a new update'; 68 + case bsky.KnownNotificationReason.contactMatch: 69 + return 'joined from your contacts'; 70 + default: 71 + return 'interacted with you'; 72 + } 73 + } 74 + 75 + static String localNotificationBodyForReason(bsky.NotificationReason reason) { 76 + final summary = summaryTextForReason(reason); 77 + return summary == 'interacted with you' ? 'sent a notification' : summary; 78 + } 79 + 80 + static NotificationReasonFamily reasonFamilyForReason(bsky.NotificationReason reason) { 81 + final known = reason.knownValue; 82 + if (known == null) { 83 + return NotificationReasonFamily.misc; 84 + } 85 + 86 + switch (known) { 87 + case bsky.KnownNotificationReason.mention: 88 + return NotificationReasonFamily.mentions; 89 + case bsky.KnownNotificationReason.reply: 90 + case bsky.KnownNotificationReason.quote: 91 + case bsky.KnownNotificationReason.subscribedPost: 92 + return NotificationReasonFamily.replies; 93 + case bsky.KnownNotificationReason.follow: 94 + case bsky.KnownNotificationReason.contactMatch: 95 + case bsky.KnownNotificationReason.starterpackJoined: 96 + return NotificationReasonFamily.follows; 97 + case bsky.KnownNotificationReason.like: 98 + case bsky.KnownNotificationReason.repost: 99 + case bsky.KnownNotificationReason.likeViaRepost: 100 + case bsky.KnownNotificationReason.repostViaRepost: 101 + return NotificationReasonFamily.likes; 102 + case bsky.KnownNotificationReason.verified: 103 + case bsky.KnownNotificationReason.unverified: 104 + return NotificationReasonFamily.misc; 105 + } 106 + } 107 + 108 + static NotificationDeepLink? deepLinkForNotification(bsky.Notification notification) { 109 + if (notification.reason.knownValue == bsky.KnownNotificationReason.starterpackJoined) { 110 + final starterPackUri = notification.reasonSubject ?? _extractSubjectUri(notification.record); 111 + if (starterPackUri != null) { 112 + return NotificationDeepLink( 113 + route: '/starter-pack?uri=${Uri.encodeQueryComponent(starterPackUri.toString())}', 114 + navigationMode: NotificationTapNavigationMode.push, 115 + ); 116 + } 117 + // Fallback to actor profile if the payload is missing starter pack context. 118 + } 119 + 120 + if (isProfileNavigationReason(notification.reason)) { 121 + final actor = notification.author.did.trim(); 122 + if (actor.isEmpty) { 123 + return null; 124 + } 125 + return NotificationDeepLink( 126 + route: '/profile/${Uri.encodeComponent(actor)}', 127 + navigationMode: NotificationTapNavigationMode.go, 128 + ); 129 + } 130 + 131 + final targetUri = deepLinkTargetUri(notification); 132 + return NotificationDeepLink( 133 + route: '/post?uri=${Uri.encodeQueryComponent(targetUri.toString())}', 134 + navigationMode: NotificationTapNavigationMode.push, 135 + ); 136 + } 137 + 138 + static AtUri deepLinkTargetUri(bsky.Notification notification) { 139 + if (!isEngagementReason(notification.reason)) { 140 + return notification.uri; 141 + } 142 + 143 + final reasonSubject = notification.reasonSubject; 144 + if (_isPostUri(reasonSubject)) { 145 + return reasonSubject!; 146 + } 147 + 148 + final recordSubject = _extractSubjectUri(notification.record); 149 + if (_isPostUri(recordSubject)) { 150 + return recordSubject!; 151 + } 152 + 153 + return reasonSubject ?? recordSubject ?? notification.uri; 154 + } 155 + 156 + static bool _isPostUri(AtUri? uri) => uri?.collection.toString() == 'app.bsky.feed.post'; 157 + 158 + static AtUri? _extractSubjectUri(Map<String, dynamic> record) { 159 + final rawSubject = record['subject']; 160 + final uriValue = switch (rawSubject) { 161 + final Map<String, dynamic> subjectMap => subjectMap['uri'], 162 + final String value => value, 163 + _ => null, 164 + }; 165 + 166 + if (uriValue is! String || uriValue.trim().isEmpty) { 167 + return null; 168 + } 169 + 170 + try { 171 + return AtUri.parse(uriValue.trim()); 172 + } catch (_) { 173 + return null; 174 + } 175 + } 176 + }
+8 -39
lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart
··· 2 2 import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 3 3 import 'package:bluesky/moderation.dart' as bsky_moderation; 4 4 import 'package:flutter/material.dart' hide Notification; 5 + import 'package:go_router/go_router.dart'; 5 6 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 6 7 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 7 - import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 8 + import 'package:lazurite/features/notifications/domain/notification_deep_link_navigator.dart'; 9 + import 'package:lazurite/features/notifications/domain/notification_reason_utils.dart'; 8 10 import 'package:lazurite/shared/presentation/helpers/notification_icon_mapper.dart'; 9 11 import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 10 12 import 'package:lazurite/shared/utils/format_utils.dart'; ··· 184 186 } 185 187 186 188 String _getReasonText(bsky.Notification notification) { 187 - final reason = notification.reason; 188 - 189 - if (reason.isKnownValue) { 190 - switch (reason.knownValue) { 191 - case bsky.KnownNotificationReason.like: 192 - return 'liked your post'; 193 - case bsky.KnownNotificationReason.repost: 194 - return 'reposted your post'; 195 - case bsky.KnownNotificationReason.follow: 196 - return 'followed you'; 197 - case bsky.KnownNotificationReason.mention: 198 - return 'mentioned you'; 199 - case bsky.KnownNotificationReason.reply: 200 - return 'replied to your post'; 201 - case bsky.KnownNotificationReason.quote: 202 - return 'quoted your post'; 203 - default: 204 - return 'interacted with you'; 205 - } 206 - } 207 - 208 - return 'interacted with you'; 189 + return NotificationReasonUtils.summaryTextForReason(notification.reason); 209 190 } 210 191 211 192 bool _shouldShowPreview(bsky.Notification notification) { 212 - final reason = notification.reason; 213 - if (reason.isKnownValue) { 214 - return reason.knownValue != bsky.KnownNotificationReason.follow; 215 - } 216 - return false; 193 + return !NotificationReasonUtils.isProfileNavigationReason(notification.reason); 217 194 } 218 195 219 196 Widget _buildPreview(BuildContext context, ThemeData theme, bsky.Notification notification) { ··· 249 226 250 227 void _onTap(BuildContext context) { 251 228 final notification = group.latest; 252 - final reason = notification.reason; 253 - 254 - if (reason.isKnownValue && reason.knownValue == bsky.KnownNotificationReason.follow) { 255 - navigateToProfile(context, notification.author.did); 229 + final deepLink = NotificationReasonUtils.deepLinkForNotification(notification); 230 + if (deepLink == null) { 256 231 return; 257 232 } 258 - 259 - final isLikeOrRepost = 260 - reason.isKnownValue && 261 - (reason.knownValue == bsky.KnownNotificationReason.like || 262 - reason.knownValue == bsky.KnownNotificationReason.repost); 263 - final uri = isLikeOrRepost ? (notification.reasonSubject ?? notification.uri) : notification.uri; 264 - navigateToPost(context, uri.toString()); 233 + NotificationDeepLinkNavigator.navigate(GoRouter.of(context), deepLink); 265 234 } 266 235 }
+8 -39
lib/features/notifications/presentation/widgets/notification_list_item.dart
··· 5 5 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 6 6 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 7 7 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 8 - import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 8 + import 'package:lazurite/features/notifications/domain/notification_deep_link_navigator.dart'; 9 + import 'package:lazurite/features/notifications/domain/notification_reason_utils.dart'; 9 10 import 'package:lazurite/shared/presentation/helpers/notification_icon_mapper.dart'; 10 11 import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 11 12 import 'package:lazurite/shared/utils/format_utils.dart'; ··· 119 120 } 120 121 121 122 String _getReasonText() { 122 - final reason = notification.reason; 123 - 124 - if (reason.isKnownValue) { 125 - switch (reason.knownValue) { 126 - case bsky.KnownNotificationReason.like: 127 - return 'liked your post'; 128 - case bsky.KnownNotificationReason.repost: 129 - return 'reposted your post'; 130 - case bsky.KnownNotificationReason.follow: 131 - return 'followed you'; 132 - case bsky.KnownNotificationReason.mention: 133 - return 'mentioned you'; 134 - case bsky.KnownNotificationReason.reply: 135 - return 'replied to your post'; 136 - case bsky.KnownNotificationReason.quote: 137 - return 'quoted your post'; 138 - default: 139 - return 'interacted with you'; 140 - } 141 - } 142 - 143 - return 'interacted with you'; 123 + return NotificationReasonUtils.summaryTextForReason(notification.reason); 144 124 } 145 125 146 126 Widget _buildTime(ThemeData theme) { ··· 151 131 } 152 132 153 133 bool get _shouldShowPreview { 154 - final reason = notification.reason; 155 - if (reason.isKnownValue) { 156 - return reason.knownValue != bsky.KnownNotificationReason.follow; 157 - } 158 - return false; 134 + return !NotificationReasonUtils.isProfileNavigationReason(notification.reason); 159 135 } 160 136 161 137 Widget _buildPreview(BuildContext context, ThemeData theme) { ··· 191 167 } 192 168 193 169 void _onTap(BuildContext context) { 194 - final reason = notification.reason; 195 - 196 - if (reason.isKnownValue && reason.knownValue == bsky.KnownNotificationReason.follow) { 197 - navigateToProfile(context, notification.author.did); 198 - } else { 199 - final isLikeOrRepost = 200 - reason.isKnownValue && 201 - (reason.knownValue == bsky.KnownNotificationReason.like || 202 - reason.knownValue == bsky.KnownNotificationReason.repost); 203 - final uri = isLikeOrRepost ? (notification.reasonSubject ?? notification.uri) : notification.uri; 204 - context.push('/post?uri=${Uri.encodeComponent(uri.toString())}'); 170 + final deepLink = NotificationReasonUtils.deepLinkForNotification(notification); 171 + if (deepLink == null) { 172 + return; 205 173 } 174 + NotificationDeepLinkNavigator.navigate(GoRouter.of(context), deepLink); 206 175 } 207 176 }
+22
lib/shared/presentation/helpers/notification_icon_mapper.dart
··· 21 21 22 22 switch (reason.knownValue) { 23 23 case bsky.KnownNotificationReason.like: 24 + case bsky.KnownNotificationReason.likeViaRepost: 24 25 return NotificationIconStyle( 25 26 backgroundColor: colorScheme.error.withValues(alpha: 0.1), 26 27 iconColor: colorScheme.error, 27 28 icon: Icons.favorite, 28 29 ); 29 30 case bsky.KnownNotificationReason.repost: 31 + case bsky.KnownNotificationReason.repostViaRepost: 30 32 return NotificationIconStyle( 31 33 backgroundColor: Colors.green.withValues(alpha: 0.1), 32 34 iconColor: Colors.green, 33 35 icon: Icons.repeat, 34 36 ); 35 37 case bsky.KnownNotificationReason.follow: 38 + case bsky.KnownNotificationReason.contactMatch: 36 39 return NotificationIconStyle( 37 40 backgroundColor: colorScheme.primary.withValues(alpha: 0.1), 38 41 iconColor: colorScheme.primary, 39 42 icon: Icons.person_add, 40 43 ); 44 + case bsky.KnownNotificationReason.starterpackJoined: 45 + return NotificationIconStyle( 46 + backgroundColor: colorScheme.primary.withValues(alpha: 0.1), 47 + iconColor: colorScheme.primary, 48 + icon: Icons.group_add, 49 + ); 41 50 case bsky.KnownNotificationReason.reply: 51 + case bsky.KnownNotificationReason.subscribedPost: 42 52 return NotificationIconStyle( 43 53 backgroundColor: colorScheme.secondary.withValues(alpha: 0.1), 44 54 iconColor: colorScheme.secondary, ··· 55 65 backgroundColor: Colors.purple.withValues(alpha: 0.1), 56 66 iconColor: Colors.purple, 57 67 icon: Icons.format_quote, 68 + ); 69 + case bsky.KnownNotificationReason.verified: 70 + return NotificationIconStyle( 71 + backgroundColor: colorScheme.primary.withValues(alpha: 0.1), 72 + iconColor: colorScheme.primary, 73 + icon: Icons.verified, 74 + ); 75 + case bsky.KnownNotificationReason.unverified: 76 + return NotificationIconStyle( 77 + backgroundColor: colorScheme.error.withValues(alpha: 0.1), 78 + iconColor: colorScheme.error, 79 + icon: Icons.gpp_bad, 58 80 ); 59 81 default: 60 82 return NotificationIconStyle(
+44
test/features/notifications/domain/notification_local_mappers_test.dart
··· 46 46 expect(request.deepLink.navigationMode, NotificationTapNavigationMode.push); 47 47 expect(request.deepLink.route, '/post?uri=${Uri.encodeQueryComponent(reasonSubject.toString())}'); 48 48 }); 49 + 50 + test('maps like-via-repost notifications to post route using reasonSubject', () { 51 + final reasonSubject = AtUri.parse('at://did:plc:target/app.bsky.feed.post/reposted-post'); 52 + final notification = bsky.Notification( 53 + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.like/def'), 54 + cid: 'cid-123', 55 + author: const ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), 56 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.likeViaRepost), 57 + reasonSubject: reasonSubject, 58 + record: const {}, 59 + isRead: false, 60 + indexedAt: DateTime.utc(2026, 5, 1, 12), 61 + ); 62 + 63 + final request = NotificationLocalMapper.requestFromNotification(notification); 64 + 65 + expect(request, isNotNull); 66 + expect(request!.reasonFamily, NotificationReasonFamily.likes); 67 + expect(request.body, 'liked your repost'); 68 + expect(request.deepLink.navigationMode, NotificationTapNavigationMode.push); 69 + expect(request.deepLink.route, '/post?uri=${Uri.encodeQueryComponent(reasonSubject.toString())}'); 70 + }); 71 + 72 + test('maps starterpack-joined notifications to starter pack detail route', () { 73 + final starterPackUri = AtUri.parse('at://did:plc:author/app.bsky.graph.starterpack/sp1'); 74 + final notification = bsky.Notification( 75 + uri: AtUri.parse('at://did:plc:author/app.bsky.graph.starterpackjoin/abc'), 76 + cid: 'cid-123', 77 + author: const ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), 78 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.starterpackJoined), 79 + reasonSubject: starterPackUri, 80 + record: const {}, 81 + isRead: false, 82 + indexedAt: DateTime.utc(2026, 5, 1, 12), 83 + ); 84 + 85 + final request = NotificationLocalMapper.requestFromNotification(notification); 86 + 87 + expect(request, isNotNull); 88 + expect(request!.reasonFamily, NotificationReasonFamily.follows); 89 + expect(request.body, 'joined via your starter pack'); 90 + expect(request.deepLink.navigationMode, NotificationTapNavigationMode.push); 91 + expect(request.deepLink.route, '/starter-pack?uri=${Uri.encodeQueryComponent(starterPackUri.toString())}'); 92 + }); 49 93 }); 50 94 51 95 group('NotificationPayloadCodec', () {
+30
test/features/notifications/domain/notification_reason_utils_test.dart
··· 1 + import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/features/notifications/domain/notification_reason_utils.dart'; 4 + 5 + void main() { 6 + group('NotificationReasonUtils', () { 7 + test('provides explicit summary copy for all known notification reasons', () { 8 + final expectedTextByReason = <bsky.KnownNotificationReason, String>{ 9 + bsky.KnownNotificationReason.like: 'liked your post', 10 + bsky.KnownNotificationReason.repost: 'reposted your post', 11 + bsky.KnownNotificationReason.follow: 'followed you', 12 + bsky.KnownNotificationReason.mention: 'mentioned you', 13 + bsky.KnownNotificationReason.reply: 'replied to your post', 14 + bsky.KnownNotificationReason.quote: 'quoted your post', 15 + bsky.KnownNotificationReason.starterpackJoined: 'joined via your starter pack', 16 + bsky.KnownNotificationReason.verified: 'verified your account', 17 + bsky.KnownNotificationReason.unverified: 'removed your verification', 18 + bsky.KnownNotificationReason.likeViaRepost: 'liked your repost', 19 + bsky.KnownNotificationReason.repostViaRepost: 'reposted your repost', 20 + bsky.KnownNotificationReason.subscribedPost: 'posted a new update', 21 + bsky.KnownNotificationReason.contactMatch: 'joined from your contacts', 22 + }; 23 + 24 + for (final entry in expectedTextByReason.entries) { 25 + final text = NotificationReasonUtils.summaryTextForReason(bsky.NotificationReason.knownValue(data: entry.key)); 26 + expect(text, entry.value, reason: 'Unexpected summary for ${entry.key.value}'); 27 + } 28 + }); 29 + }); 30 + }
+71
test/features/notifications/presentation/widgets/notification_list_item_test.dart
··· 123 123 expect(Uri.decodeComponent(Uri.parse(pushedRoute!).queryParameters['uri']!), postUri.toString()); 124 124 }); 125 125 126 + testWidgets('like-via-repost notification uses reasonSubject to navigate to post', (tester) async { 127 + final postUri = AtUri.parse('at://did:plc:owner/app.bsky.feed.post/post789'); 128 + final notification = _makeNotification( 129 + reason: bsky.KnownNotificationReason.likeViaRepost, 130 + uri: AtUri.parse('at://did:plc:liker/app.bsky.feed.like/like-via-repost'), 131 + reasonSubject: postUri, 132 + ); 133 + String? pushedRoute; 134 + 135 + final router = GoRouter( 136 + routes: [ 137 + GoRoute( 138 + path: '/', 139 + builder: (context, state) => Scaffold(body: NotificationListItem(notification: notification)), 140 + ), 141 + GoRoute( 142 + path: '/post', 143 + builder: (context, state) { 144 + pushedRoute = state.uri.toString(); 145 + return const Scaffold(body: Text('post thread')); 146 + }, 147 + ), 148 + ], 149 + ); 150 + 151 + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 152 + await tester.pumpAndSettle(); 153 + 154 + await tester.tap(find.byType(NotificationListItem)); 155 + await tester.pumpAndSettle(); 156 + 157 + expect(pushedRoute, isNotNull); 158 + expect(Uri.parse(pushedRoute!).path, '/post'); 159 + expect(Uri.decodeComponent(Uri.parse(pushedRoute!).queryParameters['uri']!), postUri.toString()); 160 + }); 161 + 162 + testWidgets('starterpack-joined notification navigates to starter pack detail route', (tester) async { 163 + final starterPackUri = AtUri.parse('at://did:plc:author/app.bsky.graph.starterpack/sp1'); 164 + final notification = _makeNotification( 165 + reason: bsky.KnownNotificationReason.starterpackJoined, 166 + reasonSubject: starterPackUri, 167 + ); 168 + String? pushedRoute; 169 + 170 + final router = GoRouter( 171 + routes: [ 172 + GoRoute( 173 + path: '/', 174 + builder: (context, state) => Scaffold(body: NotificationListItem(notification: notification)), 175 + ), 176 + GoRoute( 177 + path: '/starter-pack', 178 + builder: (context, state) { 179 + pushedRoute = state.uri.toString(); 180 + return const Scaffold(body: Text('starter pack detail')); 181 + }, 182 + ), 183 + ], 184 + ); 185 + 186 + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 187 + await tester.pumpAndSettle(); 188 + 189 + await tester.tap(find.byType(NotificationListItem)); 190 + await tester.pumpAndSettle(); 191 + 192 + expect(pushedRoute, isNotNull); 193 + expect(Uri.parse(pushedRoute!).path, '/starter-pack'); 194 + expect(Uri.decodeComponent(Uri.parse(pushedRoute!).queryParameters['uri']!), starterPackUri.toString()); 195 + }); 196 + 126 197 testWidgets('like notification falls back to uri when reasonSubject is null', (tester) async { 127 198 final likeUri = AtUri.parse('at://did:plc:liker/app.bsky.feed.like/fallback'); 128 199 final notification = _makeNotification(
+20
test/shared/presentation/helpers/notification_icon_mapper_test.dart
··· 27 27 expect(style.iconColor, colorScheme.primary); 28 28 }); 29 29 30 + test('maps like-via-repost reason to favorite icon and error color', () { 31 + final style = NotificationIconMapper.map( 32 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.likeViaRepost), 33 + colorScheme: colorScheme, 34 + ); 35 + 36 + expect(style.icon, Icons.favorite); 37 + expect(style.iconColor, colorScheme.error); 38 + }); 39 + 40 + test('maps verified reason to verified icon and primary color', () { 41 + final style = NotificationIconMapper.map( 42 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.verified), 43 + colorScheme: colorScheme, 44 + ); 45 + 46 + expect(style.icon, Icons.verified); 47 + expect(style.iconColor, colorScheme.primary); 48 + }); 49 + 30 50 test('maps quote reason to quote icon and purple color', () { 31 51 final style = NotificationIconMapper.map( 32 52 reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.quote),