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.

refactor: profile routing to use actor parameter and normalize actor handling

+261 -44
+16 -2
lib/core/router/app_router.dart
··· 477 477 pageBuilder: (context, state) => _page(context, state, const ProfileScreen()), 478 478 routes: [ 479 479 GoRoute( 480 - path: 'view', 480 + path: ':actor', 481 481 pageBuilder: (context, state) => _page( 482 482 context, 483 483 state, 484 - ProfileScreen(actor: state.uri.queryParameters['actor'], showBackButton: true), 484 + ProfileScreen( 485 + actor: Uri.decodeComponent(state.pathParameters['actor'] ?? ''), 486 + showBackButton: true, 487 + ), 485 488 ), 489 + ), 490 + GoRoute( 491 + path: 'view', 492 + redirect: (_, state) { 493 + final rawActor = state.uri.queryParameters['actor']?.trim() ?? ''; 494 + if (rawActor.isEmpty) { 495 + return '/profile'; 496 + } 497 + final normalizedActor = rawActor.startsWith('@') ? rawActor.substring(1) : rawActor; 498 + return '/profile/${Uri.encodeComponent(normalizedActor)}'; 499 + }, 486 500 ), 487 501 ], 488 502 ),
+1 -1
lib/features/feed/bloc/feed_bloc.dart
··· 35 35 ), 36 36 ); 37 37 } catch (error) { 38 - emit(FeedState.error('Failed to load feed: $error')); 38 + emit(FeedState.error('Failed to load feed: $error', actor: event.actor, filter: event.filter)); 39 39 } 40 40 } 41 41
+2 -1
lib/features/feed/bloc/feed_state.dart
··· 28 28 bool hasMore = true, 29 29 }) : this._(status: FeedStatus.loaded, actor: actor, posts: posts, cursor: cursor, filter: filter, hasMore: hasMore); 30 30 31 - const FeedState.error(String message) : this._(status: FeedStatus.error, errorMessage: message); 31 + const FeedState.error(String message, {String? actor, FeedFilter filter = FeedFilter.postsAndAuthorThreads}) 32 + : this._(status: FeedStatus.error, actor: actor, errorMessage: message, filter: filter, hasMore: false); 32 33 33 34 static const Object _unset = Object(); 34 35
+2 -1
lib/features/feed/presentation/home_feed_screen.dart
··· 250 250 final List<FeedViewPost> _posts = []; 251 251 String? _cursor; 252 252 bool _isLoading = false; 253 - bool _showInitialLoading = false; 253 + bool _showInitialLoading = true; 254 254 bool _isLoadingMore = false; 255 255 bool _hasError = false; 256 256 String? _errorMessage; ··· 294 294 _cursor = cachedResult.cursor; 295 295 _hasError = false; 296 296 _errorMessage = null; 297 + _showInitialLoading = false; 297 298 }); 298 299 } 299 300
+146 -5
lib/features/profile/presentation/profile_screen.dart
··· 80 80 int? _headerTrackingPointer; 81 81 double _headerPullDistance = 0; 82 82 bool _headerRefreshInFlight = false; 83 + String? _lastScheduledProfileActorLoad; 84 + String? _lastScheduledFeedLoadKey; 83 85 84 86 @override 85 87 void initState() { ··· 116 118 String? get _resolvedActor { 117 119 final authState = context.read<AuthBloc>().state; 118 120 if (!authState.isAuthenticated) return null; 119 - return widget.actor ?? authState.tokens?.did; 121 + final rawActor = widget.actor ?? authState.tokens?.did; 122 + if (rawActor == null) { 123 + return null; 124 + } 125 + 126 + final normalizedActor = _normalizeActor(rawActor); 127 + return normalizedActor.isEmpty ? null : normalizedActor; 128 + } 129 + 130 + String _normalizeActor(String actor) { 131 + final trimmed = actor.trim(); 132 + if (trimmed.startsWith('@')) { 133 + return trimmed.substring(1); 134 + } 135 + return trimmed; 136 + } 137 + 138 + String _canonicalActorForCompare(String actor) => _normalizeActor(actor).toLowerCase(); 139 + 140 + bool _profileMatchesExpectedActor(ProfileViewDetailed? profile, String expectedActor) { 141 + if (profile == null) { 142 + return false; 143 + } 144 + 145 + final expected = _canonicalActorForCompare(expectedActor); 146 + return _canonicalActorForCompare(profile.did) == expected || _canonicalActorForCompare(profile.handle) == expected; 147 + } 148 + 149 + bool _feedMatchesExpectedActor(FeedState feedState, String expectedActor, ProfileViewDetailed? profile) { 150 + final stateActor = feedState.actor; 151 + if (stateActor == null) { 152 + return false; 153 + } 154 + 155 + final normalizedStateActor = _canonicalActorForCompare(stateActor); 156 + final normalizedExpectedActor = _canonicalActorForCompare(expectedActor); 157 + if (normalizedStateActor == normalizedExpectedActor) { 158 + return true; 159 + } 160 + 161 + if (profile != null) { 162 + return normalizedStateActor == _canonicalActorForCompare(profile.did) || 163 + normalizedStateActor == _canonicalActorForCompare(profile.handle); 164 + } 165 + 166 + return false; 167 + } 168 + 169 + void _scheduleProfileLoadIfNeeded(String actor, ProfileState profileState) { 170 + if (_profileMatchesExpectedActor(profileState.profile, actor)) { 171 + _lastScheduledProfileActorLoad = null; 172 + return; 173 + } 174 + 175 + if (profileState.status == ProfileStatus.loading) { 176 + return; 177 + } 178 + 179 + if (_lastScheduledProfileActorLoad == actor) { 180 + return; 181 + } 182 + 183 + _lastScheduledProfileActorLoad = actor; 184 + WidgetsBinding.instance.addPostFrameCallback((_) { 185 + if (!mounted) { 186 + return; 187 + } 188 + context.read<ProfileBloc>().add(ProfileLoadRequested(actor: actor)); 189 + }); 190 + } 191 + 192 + void _scheduleFeedLoadIfNeeded(String actor, FeedFilter filter, FeedState feedState, ProfileViewDetailed? profile) { 193 + if (feedState.status == FeedStatus.loading && 194 + feedState.filter == filter && 195 + _feedMatchesExpectedActor(feedState, actor, profile)) { 196 + return; 197 + } 198 + 199 + if (feedState.status == FeedStatus.loaded && 200 + feedState.filter == filter && 201 + _feedMatchesExpectedActor(feedState, actor, profile)) { 202 + _lastScheduledFeedLoadKey = null; 203 + return; 204 + } 205 + 206 + final requestKey = '$actor|${filter.name}'; 207 + if (_lastScheduledFeedLoadKey == requestKey) { 208 + return; 209 + } 210 + 211 + _lastScheduledFeedLoadKey = requestKey; 212 + WidgetsBinding.instance.addPostFrameCallback((_) { 213 + if (!mounted) { 214 + return; 215 + } 216 + context.read<FeedBloc>().add(FeedLoadRequested(actor: actor, filter: filter)); 217 + }); 120 218 } 121 219 122 220 List<String> get _tabLabels => ··· 234 332 return BlocBuilder<FeedBloc, FeedState>( 235 333 builder: (context, feedState) { 236 334 final profile = profileState.profile; 335 + final expectedActor = _resolvedActor; 336 + final profileMatchesExpectedActor = expectedActor == null 337 + ? true 338 + : _profileMatchesExpectedActor(profile, expectedActor); 339 + if (expectedActor != null) { 340 + _scheduleProfileLoadIfNeeded(expectedActor, profileState); 341 + _scheduleFeedLoadIfNeeded(expectedActor, _currentFilter, feedState, profile); 342 + } 343 + 237 344 final currentUserDid = context.read<AuthBloc>().state.tokens?.did; 238 345 final isOwnProfile = profile?.did == currentUserDid; 239 346 final tabChildren = <Widget>[ 240 - ..._feedTabs.map((t) => _buildFeedList(feedState, t.filter, profile)), 347 + ..._feedTabs.map((t) => _buildFeedList(feedState, t.filter, profile, expectedActor)), 241 348 _buildListsTab(context, profile), 242 349 _buildStarterPacksTab(context, profile), 243 350 if (_showSuggestedTab) _buildSuggestedFollowsTab(profile), ··· 298 405 child: Center(child: CircularProgressIndicator()), 299 406 ), 300 407 ProfileStatus.error => _buildProfileError(context, profileState.errorMessage), 408 + _ when !profileMatchesExpectedActor => const Padding( 409 + padding: AppInsets.allLg, 410 + child: Center(child: CircularProgressIndicator()), 411 + ), 301 412 _ => _buildProfileSummary(context, profile, isOwnProfile), 302 413 }, 303 414 ), ··· 815 926 ); 816 927 } 817 928 818 - Widget _buildFeedList(FeedState feedState, FeedFilter tabFilter, ProfileViewDetailed? profile) { 929 + Widget _buildFeedList( 930 + FeedState feedState, 931 + FeedFilter tabFilter, 932 + ProfileViewDetailed? profile, 933 + String? expectedActor, 934 + ) { 935 + final isActiveTab = tabFilter == _currentFilter; 936 + final feedMatchesExpectedActor = expectedActor == null 937 + ? true 938 + : _feedMatchesExpectedActor(feedState, expectedActor, profile); 939 + 940 + if (expectedActor != null && isActiveTab && !feedMatchesExpectedActor) { 941 + return const Center(child: CircularProgressIndicator()); 942 + } 943 + 944 + if (isActiveTab && feedState.status == FeedStatus.initial) { 945 + return const Center(child: CircularProgressIndicator()); 946 + } 947 + 819 948 if (feedState.isLoading && feedState.filter == tabFilter) { 820 949 return const Center(child: CircularProgressIndicator()); 821 950 } 822 951 823 - if (feedState.hasError && feedState.filter == tabFilter) { 824 - return Center(child: Text(feedState.errorMessage ?? 'Failed to load posts')); 952 + if (feedState.hasError && feedState.filter == tabFilter && feedMatchesExpectedActor) { 953 + return Center( 954 + child: Column( 955 + mainAxisSize: MainAxisSize.min, 956 + children: [ 957 + Text(feedState.errorMessage ?? 'Failed to load posts'), 958 + const SizedBox(height: 12), 959 + FilledButton( 960 + onPressed: () => _loadProfileAndFeed(filter: tabFilter), 961 + child: const Text('Retry'), 962 + ), 963 + ], 964 + ), 965 + ); 825 966 } 826 967 827 968 if (feedState.filter != tabFilter) {
+7 -1
lib/shared/presentation/helpers/navigation_helpers.dart
··· 11 11 return null; 12 12 } 13 13 14 - final location = '/profile/view?actor=${Uri.encodeQueryComponent(actorDid)}'; 14 + final actor = actorDid.trim(); 15 + if (actor.isEmpty) { 16 + return null; 17 + } 18 + 19 + final normalizedActor = actor.startsWith('@') ? actor.substring(1) : actor; 20 + final location = '/profile/${Uri.encodeComponent(normalizedActor)}'; 15 21 final currentPath = _currentPath(context); 16 22 17 23 if (!_isStatefulShellPath(currentPath)) {
+1 -1
test/features/feed/presentation/grid_post_card_test.dart
··· 232 232 ), 233 233 ), 234 234 GoRoute( 235 - path: '/profile/view', 235 + path: '/profile/:actor', 236 236 builder: (context, state) { 237 237 pushedRoute = state.uri.toString(); 238 238 return const Scaffold(body: Text('profile'));
+3 -3
test/features/feed/presentation/post_card_test.dart
··· 184 184 builder: (context, state) => Scaffold(body: PostCard(feedViewPost: post)), 185 185 ), 186 186 GoRoute( 187 - path: '/profile/view', 187 + path: '/profile/:actor', 188 188 builder: (context, state) { 189 189 pushedRoute = state.uri.toString(); 190 190 return const Scaffold(body: Text('profile')); ··· 200 200 await tester.pumpAndSettle(); 201 201 202 202 expect(pushedRoute, isNotNull); 203 - expect(Uri.parse(pushedRoute!).path, '/profile/view'); 203 + expect(Uri.parse(pushedRoute!).path, '/profile/alice.bsky.social'); 204 204 expect(fakeUrlLauncher.launchedUrls, isEmpty); 205 205 }); 206 206 ··· 420 420 builder: (context, state) => Scaffold(body: PostCard(feedViewPost: post)), 421 421 ), 422 422 GoRoute( 423 - path: '/profile/view', 423 + path: '/profile/:actor', 424 424 builder: (context, state) { 425 425 pushedRoute = state.uri.toString(); 426 426 return const Scaffold(body: Text('profile'));
+2 -3
test/features/feed/presentation/widgets/facet_text_test.dart
··· 76 76 builder: (context, state) => const Scaffold(body: FacetText(text: text)), 77 77 ), 78 78 GoRoute( 79 - path: '/profile/view', 79 + path: '/profile/:actor', 80 80 builder: (context, state) { 81 81 pushed = state.uri.toString(); 82 82 return const Scaffold(body: Text('profile')); ··· 92 92 await tester.pumpAndSettle(); 93 93 94 94 expect(pushed, isNotNull); 95 - expect(Uri.parse(pushed!).path, '/profile/view'); 96 - expect(Uri.parse(pushed!).queryParameters['actor'], 'alice.bsky.social'); 95 + expect(Uri.parse(pushed!).path, '/profile/alice.bsky.social'); 97 96 }); 98 97 99 98 testWidgets('at:// post URI routes in-app to post thread', (tester) async {
+2 -2
test/features/notifications/presentation/widgets/notification_list_item_test.dart
··· 32 32 builder: (context, state) => Scaffold(body: NotificationListItem(notification: notification)), 33 33 ), 34 34 GoRoute( 35 - path: '/profile/view', 35 + path: '/profile/:actor', 36 36 builder: (context, state) { 37 37 pushedRoute = state.uri.toString(); 38 38 return const Scaffold(body: Text('profile')); ··· 48 48 await tester.pumpAndSettle(); 49 49 50 50 expect(pushedRoute, isNotNull); 51 - expect(Uri.parse(pushedRoute!).path, '/profile/view'); 51 + expect(Uri.parse(pushedRoute!).path, '/profile/${Uri.encodeComponent('did:plc:author')}'); 52 52 }); 53 53 54 54 testWidgets('like notification uses reasonSubject to navigate to post', (tester) async {
+2 -2
test/features/profile/presentation/follow_audit_screen_test.dart
··· 45 45 BlocProvider<FollowAuditCubit>.value(value: cubit, child: const FollowAuditScreen()), 46 46 ), 47 47 GoRoute( 48 - path: '/profile/view', 49 - builder: (context, state) => Scaffold(body: Text('profile:${state.uri.queryParameters['actor'] ?? ''}')), 48 + path: '/profile/:actor', 49 + builder: (context, state) => Scaffold(body: Text('profile:${state.pathParameters['actor'] ?? ''}')), 50 50 ), 51 51 ], 52 52 );
+4 -4
test/features/profile/presentation/profile_context_screen_test.dart
··· 73 73 ), 74 74 routes: [ 75 75 GoRoute( 76 - path: 'profile/view', 77 - builder: (context, state) => Scaffold(body: Text('Profile View ${state.uri.queryParameters['actor']}')), 76 + path: 'profile/:actor', 77 + builder: (context, state) => Scaffold(body: Text('Profile View ${state.pathParameters['actor']}')), 78 78 ), 79 79 GoRoute( 80 80 path: 'list', ··· 148 148 expect(find.text('User did:plc:user2'), findsOneWidget); 149 149 }); 150 150 151 - testWidgets('profile tile navigates to /profile/view on tap', (tester) async { 151 + testWidgets('profile tile navigates to /profile/:actor on tap', (tester) async { 152 152 final profiles = [_profile('did:plc:user1')]; 153 153 final state = initialState().copyWith( 154 154 blockedByStatus: ProfileContextTabStatus.loaded, ··· 352 352 verify(() => cubit.loadBlocking()).called(greaterThanOrEqualTo(1)); 353 353 }); 354 354 355 - testWidgets('profile tile navigates to /profile/view on tap', (tester) async { 355 + testWidgets('profile tile navigates to /profile/:actor on tap', (tester) async { 356 356 final profiles = [_profile('did:plc:blocked1')]; 357 357 final state = const ProfileContextState.initial( 358 358 did: _did,
+52
test/features/profile/presentation/profile_screen_test.dart
··· 254 254 const Stream<ProfileState>.empty(), 255 255 initialState: const ProfileState.loaded(profile: otherProfile), 256 256 ); 257 + when(() => feedBloc.state).thenReturn( 258 + const FeedState.loaded(actor: 'did:plc:other', posts: [], filter: FeedFilter.postsNoReplies, hasMore: false), 259 + ); 260 + whenListen( 261 + feedBloc, 262 + const Stream<FeedState>.empty(), 263 + initialState: const FeedState.loaded( 264 + actor: 'did:plc:other', 265 + posts: [], 266 + filter: FeedFilter.postsNoReplies, 267 + hasMore: false, 268 + ), 269 + ); 257 270 when(() => profileRepository.getSuggestedFollows('did:plc:other')).thenAnswer((_) async => suggestions); 258 271 259 272 await tester.pumpWidget( ··· 297 310 profileBloc, 298 311 const Stream<ProfileState>.empty(), 299 312 initialState: const ProfileState.loaded(profile: otherProfile), 313 + ); 314 + when(() => feedBloc.state).thenReturn( 315 + const FeedState.loaded(actor: 'did:plc:other', posts: [], filter: FeedFilter.postsNoReplies, hasMore: false), 316 + ); 317 + whenListen( 318 + feedBloc, 319 + const Stream<FeedState>.empty(), 320 + initialState: const FeedState.loaded( 321 + actor: 'did:plc:other', 322 + posts: [], 323 + filter: FeedFilter.postsNoReplies, 324 + hasMore: false, 325 + ), 300 326 ); 301 327 final mockProfileActionRepository = MockProfileActionRepository(); 302 328 ··· 683 709 const Stream<ProfileState>.empty(), 684 710 initialState: const ProfileState.loaded(profile: otherProfile), 685 711 ); 712 + when(() => feedBloc.state).thenReturn( 713 + const FeedState.loaded(actor: 'did:plc:other', posts: [], filter: FeedFilter.postsNoReplies, hasMore: false), 714 + ); 715 + whenListen( 716 + feedBloc, 717 + const Stream<FeedState>.empty(), 718 + initialState: const FeedState.loaded( 719 + actor: 'did:plc:other', 720 + posts: [], 721 + filter: FeedFilter.postsNoReplies, 722 + hasMore: false, 723 + ), 724 + ); 686 725 687 726 final mockProfileActionRepository = MockProfileActionRepository(); 688 727 ··· 724 763 profileBloc, 725 764 const Stream<ProfileState>.empty(), 726 765 initialState: const ProfileState.loaded(profile: otherProfile), 766 + ); 767 + when(() => feedBloc.state).thenReturn( 768 + const FeedState.loaded(actor: 'did:plc:other', posts: [], filter: FeedFilter.postsNoReplies, hasMore: false), 769 + ); 770 + whenListen( 771 + feedBloc, 772 + const Stream<FeedState>.empty(), 773 + initialState: const FeedState.loaded( 774 + actor: 'did:plc:other', 775 + posts: [], 776 + filter: FeedFilter.postsNoReplies, 777 + hasMore: false, 778 + ), 727 779 ); 728 780 729 781 final mockProfileActionRepository = MockProfileActionRepository();
+3 -3
test/features/profile/presentation/widgets/suggested_follows_sheet_test.dart
··· 41 41 ), 42 42 routes: [ 43 43 GoRoute( 44 - path: 'profile/view', 45 - builder: (context, state) => Scaffold(body: Text('Profile View ${state.uri.queryParameters['actor']}')), 44 + path: 'profile/:actor', 45 + builder: (context, state) => Scaffold(body: Text('Profile View ${state.pathParameters['actor']}')), 46 46 ), 47 47 ], 48 48 ), ··· 147 147 expect(find.text('Following'), findsOneWidget); 148 148 }); 149 149 150 - testWidgets('tapping a suggestion navigates to /profile/view', (tester) async { 150 + testWidgets('tapping a suggestion navigates to /profile/:actor', (tester) async { 151 151 final profiles = [_profile('did:plc:bob', displayName: 'Bob Builder')]; 152 152 when(() => cubit.state).thenReturn(SuggestedFollowsState.loaded(profiles)); 153 153 whenListen(
+2 -2
test/features/search/presentation/hashtag_screen_test.dart
··· 46 46 }, 47 47 ), 48 48 GoRoute( 49 - path: '/profile/view', 50 - builder: (context, state) => Scaffold(body: Text('profile:${state.uri.queryParameters['actor']}')), 49 + path: '/profile/:actor', 50 + builder: (context, state) => Scaffold(body: Text('profile:${state.pathParameters['actor']}')), 51 51 ), 52 52 GoRoute( 53 53 path: '/post',
+2 -2
test/features/search/presentation/search_screen_test.dart
··· 148 148 ), 149 149 ), 150 150 GoRoute( 151 - path: '/profile/view', 152 - builder: (context, state) => Scaffold(body: Text('profile:${state.uri.queryParameters['actor']}')), 151 + path: '/profile/:actor', 152 + builder: (context, state) => Scaffold(body: Text('profile:${state.pathParameters['actor']}')), 153 153 ), 154 154 GoRoute( 155 155 path: '/feeds',
+6 -2
test/features/settings/presentation/settings_screen_test.dart
··· 112 112 await tester.pumpWidget(buildSubject()); 113 113 await tester.pumpAndSettle(); 114 114 115 - expect(find.text('APPEARANCE'), findsOneWidget); 115 + expect(find.text('APPEARANCE', skipOffstage: false), findsOneWidget); 116 116 expect(find.text('System'), findsOneWidget); 117 - expect(find.text('LAYOUT'), findsOneWidget); 117 + 118 + await tester.scrollUntilVisible(find.text('Feed Layout'), 300); 119 + await tester.pumpAndSettle(); 120 + 121 + expect(find.text('LAYOUT', skipOffstage: false), findsOneWidget); 118 122 expect(find.text('Feed Layout'), findsOneWidget); 119 123 expect(find.text('Thread Auto-Collapse'), findsOneWidget); 120 124 expect(find.text('Animations'), findsOneWidget);
+4 -5
test/shared/presentation/helpers/navigation_helpers_test.dart
··· 20 20 ), 21 21 ), 22 22 GoRoute( 23 - path: '/profile/view', 23 + path: '/profile/:actor', 24 24 builder: (context, state) { 25 25 pushedRoute = state.uri.toString(); 26 26 return const Scaffold(body: Text('profile')); ··· 36 36 await tester.pumpAndSettle(); 37 37 38 38 expect(pushedRoute, isNotNull); 39 - expect(Uri.parse(pushedRoute!).path, '/profile/view'); 40 - expect(Uri.parse(pushedRoute!).queryParameters['actor'], actorDid); 39 + expect(Uri.parse(pushedRoute!).path, '/profile/${Uri.encodeComponent(actorDid)}'); 41 40 }); 42 41 43 42 testWidgets('navigateToProfile uses go from non-shell routes like /post', (tester) async { ··· 56 55 ), 57 56 ), 58 57 GoRoute( 59 - path: '/profile/view', 58 + path: '/profile/:actor', 60 59 builder: (context, state) { 61 60 activePath = state.uri.path; 62 61 return const Scaffold(body: Text('profile')); ··· 71 70 await tester.tap(find.text('go')); 72 71 await tester.pumpAndSettle(); 73 72 74 - expect(activePath, '/profile/view'); 73 + expect(activePath, '/profile/${Uri.encodeComponent(actorDid)}'); 75 74 expect(router.canPop(), isFalse); 76 75 expect(tester.takeException(), isNull); 77 76 });