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: quote & notif post navigation

+585 -13
+3 -3
docs/BUGS.md
··· 8 8 - [x] [1. Post Thread Screen](#1-post-thread-screen) 9 9 - [x] [2. Post Tap Navigation](#2-post-tap-navigation) 10 10 - [x] [3. Avatar Tap Navigation](#3-avatar-tap-navigation) 11 - - [ ] [4. Quoted Post Tap Navigation](#4-quoted-post-tap-navigation) 12 - - [ ] [5. Notification Tap Navigation](#5-notification-tap-navigation) 13 - - [ ] [6. Viewer State on Own Posts](#6-viewer-state-on-own-posts) 11 + - [x] [4. Quoted Post Tap Navigation](#4-quoted-post-tap-navigation) 12 + - [x] [5. Notification Tap Navigation](#5-notification-tap-navigation) 13 + - [x] [6. Viewer State on Own Posts](#6-viewer-state-on-own-posts) 14 14 - [ ] [7. Saved Posts Screen — Render Actual Posts](#7-saved-posts-screen--render-actual-posts) 15 15 - [ ] [8. Saved Posts — Accessible from Profile](#8-saved-posts--accessible-from-profile) 16 16 - [ ] [9. Saved Posts — Long Press for Local, Tap for Menu](#9-saved-posts--long-press-for-local-tap-for-menu)
+43
lib/features/feed/cubit/post_action_cache.dart
··· 1 + import 'package:lazurite/features/feed/cubit/post_action_cubit.dart'; 2 + 3 + /// In-memory cache that preserves optimistic like/repost state across 4 + /// [PostActionCubit] recreations (e.g. after scroll recycling). 5 + /// 6 + /// Keyed by post URI. Transient fields (loading, error) are never cached. 7 + class PostActionCache { 8 + final Map<String, CachedPostAction> _cache = {}; 9 + 10 + CachedPostAction? read(String postUri) => _cache[postUri]; 11 + 12 + /// Persists the settled state of [state] into the cache. 13 + /// No-ops while either loading flag is set. 14 + void write(PostActionState state) { 15 + if (state.isLoadingLike || state.isLoadingRepost) return; 16 + _cache[state.postUri] = CachedPostAction( 17 + isLiked: state.isLiked, 18 + isReposted: state.isReposted, 19 + likeCount: state.likeCount, 20 + repostCount: state.repostCount, 21 + likeUri: state.likeUri, 22 + repostUri: state.repostUri, 23 + ); 24 + } 25 + } 26 + 27 + class CachedPostAction { 28 + const CachedPostAction({ 29 + required this.isLiked, 30 + required this.isReposted, 31 + required this.likeCount, 32 + required this.repostCount, 33 + this.likeUri, 34 + this.repostUri, 35 + }); 36 + 37 + final bool isLiked; 38 + final bool isReposted; 39 + final int likeCount; 40 + final int repostCount; 41 + final String? likeUri; 42 + final String? repostUri; 43 + }
+45 -1
lib/features/feed/cubit/post_action_cubit.dart
··· 2 2 import 'package:equatable/equatable.dart'; 3 3 import 'package:flutter_bloc/flutter_bloc.dart'; 4 4 import 'package:lazurite/core/logging/app_logger.dart'; 5 + import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 5 6 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 6 7 7 8 class PostActionState extends Equatable { ··· 80 81 int repostCount = 0, 81 82 String? likeUri, 82 83 String? repostUri, 84 + PostActionCache? cache, 83 85 }) : _postActionRepository = postActionRepository, 84 86 _postCid = postCid, 87 + _cache = cache, 85 88 super( 86 - PostActionState( 89 + _buildInitialState( 87 90 postUri: postUri, 88 91 isLiked: isLiked, 89 92 isReposted: isReposted, ··· 91 94 repostCount: repostCount, 92 95 likeUri: likeUri, 93 96 repostUri: repostUri, 97 + cache: cache, 94 98 ), 95 99 ); 96 100 97 101 final PostActionRepository _postActionRepository; 98 102 final String _postCid; 103 + final PostActionCache? _cache; 104 + 105 + static PostActionState _buildInitialState({ 106 + required String postUri, 107 + required bool isLiked, 108 + required bool isReposted, 109 + required int likeCount, 110 + required int repostCount, 111 + String? likeUri, 112 + String? repostUri, 113 + PostActionCache? cache, 114 + }) { 115 + final cached = cache?.read(postUri); 116 + if (cached != null) { 117 + return PostActionState( 118 + postUri: postUri, 119 + isLiked: cached.isLiked, 120 + isReposted: cached.isReposted, 121 + likeCount: cached.likeCount, 122 + repostCount: cached.repostCount, 123 + likeUri: cached.likeUri, 124 + repostUri: cached.repostUri, 125 + ); 126 + } 127 + return PostActionState( 128 + postUri: postUri, 129 + isLiked: isLiked, 130 + isReposted: isReposted, 131 + likeCount: likeCount, 132 + repostCount: repostCount, 133 + likeUri: likeUri, 134 + repostUri: repostUri, 135 + ); 136 + } 137 + 138 + void _persistToCache() => _cache?.write(state); 99 139 100 140 Future<void> toggleLike() async { 101 141 if (state.isLoadingLike) return; ··· 137 177 error: 'Failed to ${wasLiked ? 'unlike' : 'like'} post', 138 178 ), 139 179 ); 180 + } finally { 181 + _persistToCache(); 140 182 } 141 183 } 142 184 ··· 180 222 error: 'Failed to ${wasReposted ? 'unrepost' : 'repost'} post', 181 223 ), 182 224 ); 225 + } finally { 226 + _persistToCache(); 183 227 } 184 228 } 185 229
+2 -5
lib/features/feed/presentation/widgets/post_card.dart
··· 51 51 ), 52 52 ), 53 53 ), 54 - Padding( 55 - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), 56 - child: actionBar ?? _buildActions(context), 57 - ), 54 + Padding(padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: actionBar ?? _buildActions(context)), 58 55 ], 59 56 ), 60 57 ); ··· 357 354 onTap: () { 358 355 final router = GoRouter.maybeOf(context); 359 356 if (router != null) { 360 - router.push('/profile/view?actor=${Uri.encodeQueryComponent(quoted.author.did)}'); 357 + router.push('/post?uri=${Uri.encodeComponent(quoted.uri.toString())}'); 361 358 } 362 359 }, 363 360 borderRadius: BorderRadius.circular(16),
+4 -2
lib/features/feed/presentation/widgets/post_card_with_actions.dart
··· 7 7 import 'package:flutter_bloc/flutter_bloc.dart'; 8 8 import 'package:go_router/go_router.dart'; 9 9 import 'package:lazurite/core/logging/app_logger.dart'; 10 + import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 10 11 import 'package:lazurite/features/feed/cubit/post_action_cubit.dart'; 11 12 import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 12 13 import 'package:lazurite/features/feed/data/post_action_repository.dart'; ··· 28 29 final viewer = post.viewer; 29 30 30 31 return BlocProvider( 31 - create: (_) => PostActionCubit( 32 - postActionRepository: context.read<PostActionRepository>(), 32 + create: (ctx) => PostActionCubit( 33 + postActionRepository: ctx.read<PostActionRepository>(), 33 34 postUri: post.uri.toString(), 34 35 postCid: post.cid, 35 36 isLiked: viewer?.like != null, ··· 38 39 repostCount: post.repostCount ?? 0, 39 40 likeUri: viewer?.like?.toString(), 40 41 repostUri: viewer?.repost?.toString(), 42 + cache: ctx.read<PostActionCache>(), 41 43 ), 42 44 child: _PostCardWithActionsContent(feedViewPost: feedViewPost, accountDid: accountDid), 43 45 );
+5 -1
lib/features/notifications/presentation/widgets/notification_list_item.dart
··· 259 259 if (reason.isKnownValue && reason.knownValue == bsky.KnownNotificationReason.follow) { 260 260 context.push('/profile/view?actor=${notification.author.did}'); 261 261 } else { 262 - final uri = notification.uri; 262 + final isLikeOrRepost = 263 + reason.isKnownValue && 264 + (reason.knownValue == bsky.KnownNotificationReason.like || 265 + reason.knownValue == bsky.KnownNotificationReason.repost); 266 + final uri = isLikeOrRepost ? (notification.reasonSubject ?? notification.uri) : notification.uri; 263 267 context.push('/post?uri=${Uri.encodeComponent(uri.toString())}'); 264 268 } 265 269 }
+2
lib/main.dart
··· 21 21 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 22 22 import 'package:lazurite/features/search/data/search_repository.dart'; 23 23 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 24 + import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 24 25 import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 25 26 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 26 27 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; ··· 152 153 RepositoryProvider.value(value: feedRepository), 153 154 RepositoryProvider.value(value: searchRepository), 154 155 RepositoryProvider.value(value: postActionRepository), 156 + RepositoryProvider(create: (_) => PostActionCache()), 155 157 RepositoryProvider.value(value: profileActionRepository), 156 158 RepositoryProvider.value(value: bluesky), 157 159 RepositoryProvider.value(value: widget.database),
+83
test/features/feed/cubit/post_action_cache_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 3 + import 'package:lazurite/features/feed/cubit/post_action_cubit.dart'; 4 + 5 + void main() { 6 + const postUri = 'at://did:plc:test/app.bsky.feed.post/abc'; 7 + const likeUri = 'at://did:plc:test/app.bsky.feed.like/like1'; 8 + const repostUri = 'at://did:plc:test/app.bsky.feed.repost/rp1'; 9 + 10 + group('PostActionCache', () { 11 + test('read returns null when nothing cached', () { 12 + final cache = PostActionCache(); 13 + expect(cache.read(postUri), isNull); 14 + }); 15 + 16 + test('write then read returns cached values', () { 17 + final cache = PostActionCache(); 18 + const state = PostActionState( 19 + postUri: postUri, 20 + isLiked: true, 21 + isReposted: false, 22 + likeCount: 3, 23 + repostCount: 1, 24 + likeUri: likeUri, 25 + repostUri: null, 26 + ); 27 + 28 + cache.write(state); 29 + final cached = cache.read(postUri); 30 + 31 + expect(cached, isNotNull); 32 + expect(cached!.isLiked, isTrue); 33 + expect(cached.isReposted, isFalse); 34 + expect(cached.likeCount, 3); 35 + expect(cached.repostCount, 1); 36 + expect(cached.likeUri, likeUri); 37 + expect(cached.repostUri, isNull); 38 + }); 39 + 40 + test('write ignores states with isLoadingLike true', () { 41 + final cache = PostActionCache(); 42 + const state = PostActionState(postUri: postUri, isLiked: true, likeCount: 5, isLoadingLike: true); 43 + 44 + cache.write(state); 45 + 46 + expect(cache.read(postUri), isNull); 47 + }); 48 + 49 + test('write ignores states with isLoadingRepost true', () { 50 + final cache = PostActionCache(); 51 + const state = PostActionState(postUri: postUri, isReposted: true, repostCount: 2, isLoadingRepost: true); 52 + 53 + cache.write(state); 54 + 55 + expect(cache.read(postUri), isNull); 56 + }); 57 + 58 + test('write overwrites existing entry', () { 59 + final cache = PostActionCache(); 60 + 61 + cache.write(const PostActionState(postUri: postUri, isLiked: false, likeCount: 0)); 62 + cache.write(const PostActionState(postUri: postUri, isLiked: true, likeCount: 1, likeUri: likeUri)); 63 + 64 + final cached = cache.read(postUri); 65 + expect(cached!.isLiked, isTrue); 66 + expect(cached.likeCount, 1); 67 + expect(cached.likeUri, likeUri); 68 + }); 69 + 70 + test('caches multiple posts independently', () { 71 + const otherUri = 'at://did:plc:test/app.bsky.feed.post/other'; 72 + final cache = PostActionCache(); 73 + 74 + cache.write(const PostActionState(postUri: postUri, isLiked: true, likeCount: 5, likeUri: likeUri)); 75 + cache.write(const PostActionState(postUri: otherUri, isReposted: true, repostCount: 2, repostUri: repostUri)); 76 + 77 + expect(cache.read(postUri)!.isLiked, isTrue); 78 + expect(cache.read(postUri)!.isReposted, isFalse); 79 + expect(cache.read(otherUri)!.isReposted, isTrue); 80 + expect(cache.read(otherUri)!.isLiked, isFalse); 81 + }); 82 + }); 83 + }
+145
test/features/feed/cubit/post_action_cubit_test.dart
··· 1 1 import 'package:atproto_core/atproto_core.dart'; 2 2 import 'package:bloc_test/bloc_test.dart'; 3 3 import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 4 5 import 'package:lazurite/features/feed/cubit/post_action_cubit.dart'; 5 6 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 6 7 import 'package:mocktail/mocktail.dart'; ··· 264 265 act: (cubit) => cubit.deletePost(), 265 266 expect: () => [isA<PostActionState>().having((s) => s.error, 'error', 'Failed to delete post')], 266 267 ); 268 + }); 269 + }); 270 + 271 + group('PostActionCubit cache integration', () { 272 + test('seeds initial state from cache when entry exists', () { 273 + final cache = PostActionCache(); 274 + cache.write( 275 + const PostActionState( 276 + postUri: testPostUri, 277 + isLiked: true, 278 + isReposted: true, 279 + likeCount: 42, 280 + repostCount: 7, 281 + likeUri: testLikeUri, 282 + repostUri: testRepostUri, 283 + ), 284 + ); 285 + 286 + final cubit = PostActionCubit( 287 + postActionRepository: mockRepository, 288 + postUri: testPostUri, 289 + postCid: testPostCid, 290 + // API says unliked with 0 counts — cache should win 291 + isLiked: false, 292 + likeCount: 0, 293 + cache: cache, 294 + ); 295 + 296 + expect(cubit.state.isLiked, isTrue); 297 + expect(cubit.state.likeCount, 42); 298 + expect(cubit.state.isReposted, isTrue); 299 + expect(cubit.state.repostCount, 7); 300 + expect(cubit.state.likeUri, testLikeUri); 301 + expect(cubit.state.repostUri, testRepostUri); 302 + }); 303 + 304 + test('uses API values when no cache entry exists', () { 305 + final cache = PostActionCache(); 306 + 307 + final cubit = PostActionCubit( 308 + postActionRepository: mockRepository, 309 + postUri: testPostUri, 310 + postCid: testPostCid, 311 + isLiked: true, 312 + likeCount: 5, 313 + likeUri: testLikeUri, 314 + cache: cache, 315 + ); 316 + 317 + expect(cubit.state.isLiked, isTrue); 318 + expect(cubit.state.likeCount, 5); 319 + expect(cubit.state.likeUri, testLikeUri); 320 + }); 321 + 322 + blocTest<PostActionCubit, PostActionState>( 323 + 'writes to cache after successful like', 324 + build: () { 325 + when( 326 + () => mockRepository.likePost( 327 + uri: any(named: 'uri'), 328 + cid: any(named: 'cid'), 329 + ), 330 + ).thenAnswer((_) async => testLikeUri); 331 + return PostActionCubit( 332 + postActionRepository: mockRepository, 333 + postUri: testPostUri, 334 + postCid: testPostCid, 335 + likeCount: 2, 336 + cache: PostActionCache(), 337 + ); 338 + }, 339 + act: (cubit) async { 340 + await cubit.toggleLike(); 341 + }, 342 + verify: (cubit) { 343 + // After settling, cache should reflect liked state. 344 + // We verify by re-seeding a new cubit from the same cache. 345 + // The cubit exposes its cache indirectly — just check final state. 346 + expect(cubit.state.isLiked, isTrue); 347 + expect(cubit.state.likeUri, testLikeUri); 348 + expect(cubit.state.isLoadingLike, isFalse); 349 + }, 350 + ); 351 + 352 + blocTest<PostActionCubit, PostActionState>( 353 + 'writes rolled-back state to cache after like failure', 354 + build: () { 355 + when( 356 + () => mockRepository.likePost( 357 + uri: any(named: 'uri'), 358 + cid: any(named: 'cid'), 359 + ), 360 + ).thenThrow(Exception('network')); 361 + return PostActionCubit( 362 + postActionRepository: mockRepository, 363 + postUri: testPostUri, 364 + postCid: testPostCid, 365 + likeCount: 2, 366 + cache: PostActionCache(), 367 + ); 368 + }, 369 + act: (cubit) async { 370 + await cubit.toggleLike(); 371 + }, 372 + verify: (cubit) { 373 + expect(cubit.state.isLiked, isFalse); 374 + expect(cubit.state.likeCount, 2); 375 + expect(cubit.state.isLoadingLike, isFalse); 376 + }, 377 + ); 378 + 379 + test('new cubit seeded from cache reflects prior like action', () async { 380 + final cache = PostActionCache(); 381 + 382 + when( 383 + () => mockRepository.likePost( 384 + uri: any(named: 'uri'), 385 + cid: any(named: 'cid'), 386 + ), 387 + ).thenAnswer((_) async => testLikeUri); 388 + 389 + final cubit1 = PostActionCubit( 390 + postActionRepository: mockRepository, 391 + postUri: testPostUri, 392 + postCid: testPostCid, 393 + likeCount: 3, 394 + cache: cache, 395 + ); 396 + await cubit1.toggleLike(); 397 + await cubit1.close(); 398 + 399 + // Simulate widget recycling: new cubit with stale API data (isLiked=false). 400 + final cubit2 = PostActionCubit( 401 + postActionRepository: mockRepository, 402 + postUri: testPostUri, 403 + postCid: testPostCid, 404 + isLiked: false, 405 + likeCount: 3, // stale API count 406 + cache: cache, 407 + ); 408 + 409 + expect(cubit2.state.isLiked, isTrue); 410 + expect(cubit2.state.likeCount, 4); 411 + expect(cubit2.state.likeUri, testLikeUri); 267 412 }); 268 413 }); 269 414
+59 -1
test/features/feed/presentation/post_card_test.dart
··· 3 3 import 'package:atproto_core/atproto_core.dart'; 4 4 import 'package:bluesky/app_bsky_actor_defs.dart'; 5 5 import 'package:bluesky/app_bsky_embed_external.dart'; 6 + import 'package:bluesky/app_bsky_embed_record.dart'; 6 7 import 'package:bluesky/app_bsky_feed_defs.dart'; 7 8 import 'package:bluesky/app_bsky_feed_post.dart'; 8 9 import 'package:bluesky/app_bsky_richtext_facet.dart'; ··· 27 28 void main() { 28 29 Widget buildSubject(FeedViewPost post, {VoidCallback? onTap}) { 29 30 return MaterialApp( 30 - home: Scaffold(body: PostCard(feedViewPost: post, onTap: onTap)), 31 + home: Scaffold( 32 + body: PostCard(feedViewPost: post, onTap: onTap), 33 + ), 31 34 ); 32 35 } 33 36 ··· 111 114 // Should not throw when tapping without a callback. 112 115 await tester.tap(find.text('test.bsky.social', findRichText: true).first); 113 116 await tester.pump(); 117 + }); 118 + 119 + testWidgets('tapping quoted post navigates to /post with quoted uri', (tester) async { 120 + final quotedUri = AtUri.parse('at://did:plc:quoted/app.bsky.feed.post/quoted123'); 121 + final record = FeedPostRecord(text: 'Main post', createdAt: DateTime.utc(2026, 3, 16)); 122 + final quotedRecord = FeedPostRecord(text: 'Quoted text', createdAt: DateTime.utc(2026, 3, 15)); 123 + final post = FeedViewPost( 124 + post: PostView( 125 + uri: const AtUri('at://did:plc:test/app.bsky.feed.post/xyz'), 126 + cid: 'cid-xyz', 127 + author: const ProfileViewBasic(did: 'did:plc:test', handle: 'test.bsky.social'), 128 + record: record.toJson(), 129 + indexedAt: DateTime.utc(2026, 3, 16), 130 + embed: UPostViewEmbed.embedRecordView( 131 + data: EmbedRecordView( 132 + record: UEmbedRecordViewRecord.embedRecordViewRecord( 133 + data: EmbedRecordViewRecord( 134 + uri: quotedUri, 135 + cid: 'cid-quoted', 136 + author: const ProfileViewBasic(did: 'did:plc:quoted', handle: 'quoted.bsky.social'), 137 + value: quotedRecord.toJson(), 138 + indexedAt: DateTime.utc(2026, 3, 15), 139 + ), 140 + ), 141 + ), 142 + ), 143 + ), 144 + ); 145 + 146 + String? pushedRoute; 147 + final router = GoRouter( 148 + routes: [ 149 + GoRoute( 150 + path: '/', 151 + builder: (context, state) => Scaffold(body: PostCard(feedViewPost: post)), 152 + ), 153 + GoRoute( 154 + path: '/post', 155 + builder: (context, state) { 156 + pushedRoute = state.uri.toString(); 157 + return const Scaffold(body: Text('post thread')); 158 + }, 159 + ), 160 + ], 161 + ); 162 + 163 + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 164 + await tester.pumpAndSettle(); 165 + 166 + await tester.tap(find.text('Quoted text', findRichText: true)); 167 + await tester.pumpAndSettle(); 168 + 169 + expect(pushedRoute, isNotNull); 170 + expect(Uri.parse(pushedRoute!).path, '/post'); 171 + expect(Uri.decodeComponent(Uri.parse(pushedRoute!).queryParameters['uri']!), quotedUri.toString()); 114 172 }); 115 173 116 174 testWidgets('tapping avatar navigates to author profile', (tester) async {
+194
test/features/notifications/presentation/widgets/notification_list_item_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/material.dart'; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:go_router/go_router.dart'; 7 + import 'package:lazurite/features/notifications/presentation/widgets/notification_list_item.dart'; 8 + 9 + bsky.Notification _makeNotification({required bsky.KnownNotificationReason reason, AtUri? uri, AtUri? reasonSubject}) { 10 + return bsky.Notification( 11 + uri: uri ?? AtUri.parse('at://did:plc:liker/app.bsky.feed.like/abc'), 12 + cid: 'cid-abc', 13 + author: const ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), 14 + reason: bsky.NotificationReason.knownValue(data: reason), 15 + reasonSubject: reasonSubject, 16 + record: const {}, 17 + isRead: true, 18 + indexedAt: DateTime.utc(2026, 3, 17), 19 + ); 20 + } 21 + 22 + void main() { 23 + group('NotificationListItem tap navigation', () { 24 + testWidgets('follow notification navigates to author profile', (tester) async { 25 + final notification = _makeNotification(reason: bsky.KnownNotificationReason.follow); 26 + String? pushedRoute; 27 + 28 + final router = GoRouter( 29 + routes: [ 30 + GoRoute( 31 + path: '/', 32 + builder: (context, state) => Scaffold(body: NotificationListItem(notification: notification)), 33 + ), 34 + GoRoute( 35 + path: '/profile/view', 36 + builder: (context, state) { 37 + pushedRoute = state.uri.toString(); 38 + return const Scaffold(body: Text('profile')); 39 + }, 40 + ), 41 + ], 42 + ); 43 + 44 + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 45 + await tester.pumpAndSettle(); 46 + 47 + await tester.tap(find.byType(NotificationListItem)); 48 + await tester.pumpAndSettle(); 49 + 50 + expect(pushedRoute, isNotNull); 51 + expect(Uri.parse(pushedRoute!).path, '/profile/view'); 52 + }); 53 + 54 + testWidgets('like notification uses reasonSubject to navigate to post', (tester) async { 55 + final postUri = AtUri.parse('at://did:plc:owner/app.bsky.feed.post/post123'); 56 + final notification = _makeNotification( 57 + reason: bsky.KnownNotificationReason.like, 58 + uri: AtUri.parse('at://did:plc:liker/app.bsky.feed.like/abc'), 59 + reasonSubject: postUri, 60 + ); 61 + String? pushedRoute; 62 + 63 + final router = GoRouter( 64 + routes: [ 65 + GoRoute( 66 + path: '/', 67 + builder: (context, state) => Scaffold(body: NotificationListItem(notification: notification)), 68 + ), 69 + GoRoute( 70 + path: '/post', 71 + builder: (context, state) { 72 + pushedRoute = state.uri.toString(); 73 + return const Scaffold(body: Text('post thread')); 74 + }, 75 + ), 76 + ], 77 + ); 78 + 79 + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 80 + await tester.pumpAndSettle(); 81 + 82 + await tester.tap(find.byType(NotificationListItem)); 83 + await tester.pumpAndSettle(); 84 + 85 + expect(pushedRoute, isNotNull); 86 + expect(Uri.parse(pushedRoute!).path, '/post'); 87 + expect(Uri.decodeComponent(Uri.parse(pushedRoute!).queryParameters['uri']!), postUri.toString()); 88 + }); 89 + 90 + testWidgets('repost notification uses reasonSubject to navigate to post', (tester) async { 91 + final postUri = AtUri.parse('at://did:plc:owner/app.bsky.feed.post/post456'); 92 + final notification = _makeNotification( 93 + reason: bsky.KnownNotificationReason.repost, 94 + uri: AtUri.parse('at://did:plc:reposter/app.bsky.feed.repost/repost1'), 95 + reasonSubject: postUri, 96 + ); 97 + String? pushedRoute; 98 + 99 + final router = GoRouter( 100 + routes: [ 101 + GoRoute( 102 + path: '/', 103 + builder: (context, state) => Scaffold(body: NotificationListItem(notification: notification)), 104 + ), 105 + GoRoute( 106 + path: '/post', 107 + builder: (context, state) { 108 + pushedRoute = state.uri.toString(); 109 + return const Scaffold(body: Text('post thread')); 110 + }, 111 + ), 112 + ], 113 + ); 114 + 115 + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 116 + await tester.pumpAndSettle(); 117 + 118 + await tester.tap(find.byType(NotificationListItem)); 119 + await tester.pumpAndSettle(); 120 + 121 + expect(pushedRoute, isNotNull); 122 + expect(Uri.parse(pushedRoute!).path, '/post'); 123 + expect(Uri.decodeComponent(Uri.parse(pushedRoute!).queryParameters['uri']!), postUri.toString()); 124 + }); 125 + 126 + testWidgets('like notification falls back to uri when reasonSubject is null', (tester) async { 127 + final likeUri = AtUri.parse('at://did:plc:liker/app.bsky.feed.like/fallback'); 128 + final notification = _makeNotification( 129 + reason: bsky.KnownNotificationReason.like, 130 + uri: likeUri, 131 + reasonSubject: null, 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']!), likeUri.toString()); 160 + }); 161 + 162 + testWidgets('reply notification navigates to post using notification uri', (tester) async { 163 + final replyUri = AtUri.parse('at://did:plc:replier/app.bsky.feed.post/reply1'); 164 + final notification = _makeNotification(reason: bsky.KnownNotificationReason.reply, uri: replyUri); 165 + String? pushedRoute; 166 + 167 + final router = GoRouter( 168 + routes: [ 169 + GoRoute( 170 + path: '/', 171 + builder: (context, state) => Scaffold(body: NotificationListItem(notification: notification)), 172 + ), 173 + GoRoute( 174 + path: '/post', 175 + builder: (context, state) { 176 + pushedRoute = state.uri.toString(); 177 + return const Scaffold(body: Text('post thread')); 178 + }, 179 + ), 180 + ], 181 + ); 182 + 183 + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 184 + await tester.pumpAndSettle(); 185 + 186 + await tester.tap(find.byType(NotificationListItem)); 187 + await tester.pumpAndSettle(); 188 + 189 + expect(pushedRoute, isNotNull); 190 + expect(Uri.parse(pushedRoute!).path, '/post'); 191 + expect(Uri.decodeComponent(Uri.parse(pushedRoute!).queryParameters['uri']!), replyUri.toString()); 192 + }); 193 + }); 194 + }