diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 375c5a2..3bf25fc 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -22,6 +22,8 @@ import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; import 'package:lazurite/features/compose/presentation/compose_screen.dart'; import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; import 'package:lazurite/features/devtools/presentation/dev_tools_screen.dart'; +import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; +import 'package:lazurite/features/feed/data/feed_repository.dart'; import 'package:lazurite/features/feed/presentation/feed_detail_screen.dart'; import 'package:lazurite/features/feed/presentation/feed_management_screen.dart'; import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; @@ -48,6 +50,7 @@ import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; import 'package:lazurite/features/notifications/data/notification_repository.dart'; import 'package:lazurite/features/notifications/domain/notification_domain_service.dart'; +import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; import 'package:lazurite/features/profile/cubit/follow_audit_cubit.dart'; import 'package:lazurite/features/profile/cubit/profile_connections_cubit.dart'; import 'package:lazurite/features/profile/cubit/profile_context_cubit.dart'; @@ -403,6 +406,58 @@ class AppRouter { ); }, ), + GoRoute( + path: r'/profile/:actor(m|[^m][^/]*|m[^e][^/]*|me[^/]+)', + parentNavigatorKey: _rootNavigatorKey, + redirect: (_, state) { + final actor = (state.pathParameters['actor'] ?? '').trim().toLowerCase(); + if (actor == 'me') { + return '/profile/me'; + } + return null; + }, + pageBuilder: (context, state) => _page( + context, + state, + _buildContextualProfileRoute(context, Uri.decodeComponent(state.pathParameters['actor'] ?? '')), + ), + routes: [ + GoRoute( + path: 'connections', + pageBuilder: (context, state) => _page( + context, + state, + _buildProfileConnectionsRoute(context, state, Uri.decodeComponent(state.pathParameters['actor'] ?? '')), + ), + ), + GoRoute( + path: 'search-posts', + pageBuilder: (context, state) { + final actor = Uri.decodeComponent(state.pathParameters['actor'] ?? ''); + return _page( + context, + state, + BlocProvider( + create: (_) => SearchBloc( + searchRepository: context.read(), + typeaheadRepository: context.read(), + database: context.read(), + accountDid: context.read(), + config: SearchBlocConfig.profileScoped(fixedPostAuthor: actor), + ), + child: SearchScreen( + postsOnlyMode: true, + fixedPostAuthor: actor, + showBackButton: true, + title: 'Search @${actor.startsWith('did:') ? actor : actor}', + showJumpToProfileAction: false, + ), + ), + ); + }, + ), + ], + ), StatefulShellRoute.indexedStack( builder: (context, state, navigationShell) { if (!context.read().state.isAuthenticated) { @@ -541,39 +596,10 @@ class AppRouter { path: 'edit', pageBuilder: (context, state) => _page(context, state, const ProfileEditScreen()), ), - ], - ), - GoRoute( - path: '/profile/:actor', - redirect: (_, state) { - final actor = (state.pathParameters['actor'] ?? '').trim().toLowerCase(); - if (actor == 'me') { - return '/profile/me'; - } - return null; - }, - pageBuilder: (context, state) => _page( - context, - state, - ProfileScreen(actor: Uri.decodeComponent(state.pathParameters['actor'] ?? ''), showBackButton: true), - ), - routes: [ - GoRoute( - path: 'connections', - pageBuilder: (context, state) => _page( - context, - state, - _buildProfileConnectionsRoute( - context, - state, - Uri.decodeComponent(state.pathParameters['actor'] ?? ''), - ), - ), - ), GoRoute( path: 'search-posts', pageBuilder: (context, state) { - final actor = Uri.decodeComponent(state.pathParameters['actor'] ?? ''); + final actor = context.read(); return _page( context, state, @@ -626,6 +652,16 @@ class AppRouter { ); } + Widget _buildContextualProfileRoute(BuildContext context, String actor) { + return MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => ProfileBloc(profileRepository: context.read())), + BlocProvider(create: (_) => FeedBloc(feedRepository: context.read())), + ], + child: ProfileScreen(actor: actor, showBackButton: true), + ); + } + Widget _buildProfileConnectionsRoute(BuildContext context, GoRouterState state, String actor) { final normalizedActor = actor.trim(); final initialTab = ProfileConnectionsTabX.fromRouteValue(state.uri.queryParameters['tab']); diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index 74a1c7b..c2ec9f9 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -593,7 +593,7 @@ class _ProfileScreenState extends State with TickerProviderStateM ), IconButton( icon: const Icon(Icons.settings_outlined), - onPressed: () => context.go('/settings'), + onPressed: () => navigateToSettings(context), ), ], ), diff --git a/lib/shared/presentation/helpers/navigation_helpers.dart b/lib/shared/presentation/helpers/navigation_helpers.dart index ccd9cab..d0f40e0 100644 --- a/lib/shared/presentation/helpers/navigation_helpers.dart +++ b/lib/shared/presentation/helpers/navigation_helpers.dart @@ -1,11 +1,12 @@ import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; /// Profile navigation helper /// -/// Profile routes live inside the stateful app shell. Using imperative `push` -/// for shell destinations can stack a second shell instance and collide -/// navigator keys. Always use declarative `go` for profile navigation. +/// The current user's profile lives inside the stateful app shell as the +/// Profile tab root. Other profiles are contextual detail routes on the root +/// navigator, so pushing them preserves the caller's back stack. Future? navigateToProfile(BuildContext context, String actorDid) { final router = GoRouter.maybeOf(context); if (router == null) { @@ -18,9 +19,20 @@ Future? navigateToProfile(BuildContext context, String actorDid) { } final normalizedActor = actor.startsWith('@') ? actor.substring(1) : actor; + String? currentUserDid; + try { + currentUserDid = context.read(); + } catch (_) { + currentUserDid = null; + } + + if (currentUserDid != null && normalizedActor == currentUserDid) { + router.go('/profile/me'); + return null; + } + final location = '/profile/${Uri.encodeComponent(normalizedActor)}'; - router.go(location); - return null; + return router.push(location); } Future? navigateToPost(BuildContext context, String postUri) { @@ -31,3 +43,12 @@ Future? navigateToPost(BuildContext context, String postUri) { return router.push('/post?uri=${Uri.encodeQueryComponent(postUri)}'); } + +Future? navigateToSettings(BuildContext context) { + final router = GoRouter.maybeOf(context); + if (router == null) { + return null; + } + + return router.push('/settings'); +} diff --git a/test/core/router/app_router_test.dart b/test/core/router/app_router_test.dart index 7431e48..e83fae0 100644 --- a/test/core/router/app_router_test.dart +++ b/test/core/router/app_router_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:bloc_test/bloc_test.dart'; import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -16,10 +17,12 @@ import 'package:lazurite/features/auth/presentation/oauth_callback_screen.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; +import 'package:lazurite/features/feed/data/feed_repository.dart'; import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; import 'package:lazurite/features/notifications/data/notification_repository.dart'; import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; +import 'package:lazurite/features/profile/data/profile_action_repository.dart'; import 'package:lazurite/features/profile/data/profile_repository.dart'; import 'package:lazurite/features/search/data/search_repository.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; @@ -35,6 +38,8 @@ class MockProfileBloc extends MockBloc implements Pr class MockFeedBloc extends MockBloc implements FeedBloc {} +class MockFeedRepository extends Mock implements FeedRepository {} + class MockSettingsCubit extends MockCubit implements SettingsCubit {} class MockConnectivityCubit extends MockCubit implements ConnectivityCubit {} @@ -49,6 +54,8 @@ class MockNotificationRepository extends Mock implements NotificationRepository class MockProfileRepository extends Mock implements ProfileRepository {} +class MockProfileActionRepository extends Mock implements ProfileActionRepository {} + class MockSearchRepository extends Mock implements SearchRepository {} class MockTypeaheadRepository extends Mock implements TypeaheadRepository {} @@ -60,6 +67,7 @@ void main() { late MockFeedPreferencesCubit feedPreferencesCubit; late MockProfileBloc profileBloc; late MockFeedBloc feedBloc; + late MockFeedRepository feedRepository; late MockSettingsCubit settingsCubit; late MockConnectivityCubit connectivityCubit; late MockAccountSwitcherCubit accountSwitcherCubit; @@ -67,6 +75,7 @@ void main() { late MockConvoListBloc convoListBloc; late MockNotificationRepository notificationRepository; late MockProfileRepository profileRepository; + late MockProfileActionRepository profileActionRepository; late MockSearchRepository searchRepository; late MockTypeaheadRepository typeaheadRepository; late MockAppDatabase database; @@ -93,6 +102,7 @@ void main() { setUpAll(() { registerFallbackValue(Uri.parse('https://example.com/oauth/callback')); + registerFallbackValue(FeedFilter.postsNoReplies); }); setUp(() { @@ -100,6 +110,7 @@ void main() { feedPreferencesCubit = MockFeedPreferencesCubit(); profileBloc = MockProfileBloc(); feedBloc = MockFeedBloc(); + feedRepository = MockFeedRepository(); settingsCubit = MockSettingsCubit(); connectivityCubit = MockConnectivityCubit(); accountSwitcherCubit = MockAccountSwitcherCubit(); @@ -107,6 +118,7 @@ void main() { convoListBloc = MockConvoListBloc(); notificationRepository = MockNotificationRepository(); profileRepository = MockProfileRepository(); + profileActionRepository = MockProfileActionRepository(); searchRepository = MockSearchRepository(); typeaheadRepository = MockTypeaheadRepository(); database = MockAppDatabase(); @@ -134,6 +146,29 @@ void main() { when(() => unreadCountCubit.state).thenReturn(const UnreadCountState(0)); when(() => convoListBloc.state).thenReturn(const ConvoListState.loaded(convos: [], cursor: null, hasMore: false)); when(() => notificationRepository.getUnreadCount()).thenAnswer((_) async => 0); + when(() => profileRepository.getProfile(any())).thenAnswer((invocation) async { + final actor = invocation.positionalArguments.first as String; + if (actor == tokens.did || actor == tokens.handle || actor == 'me') { + return profile; + } + + return ProfileViewDetailed( + did: actor.startsWith('did:') ? actor : 'did:plc:$actor', + handle: actor.startsWith('did:') ? 'alice.bsky.social' : actor, + displayName: 'Alice', + followersCount: 2, + followsCount: 3, + postsCount: 5, + ); + }); + when( + () => feedRepository.getAuthorFeed( + actor: any(named: 'actor'), + filter: any(named: 'filter'), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => FeedResult(posts: [])); whenListen(authBloc, authController.stream, initialState: currentAuthState); whenListen( @@ -183,7 +218,7 @@ void main() { await authController.close(); }); - Widget buildSubjectWithRouter(GoRouter router) => MultiBlocProvider( + Widget buildSubjectWithRouter(GoRouter router, {ThemeData? theme}) => MultiBlocProvider( providers: [ BlocProvider.value(value: authBloc), BlocProvider.value(value: feedPreferencesCubit), @@ -203,9 +238,11 @@ void main() { RepositoryProvider.value(value: typeaheadRepository), RepositoryProvider.value(value: database), RepositoryProvider.value(value: profileRepository), + RepositoryProvider.value(value: profileActionRepository), + RepositoryProvider.value(value: feedRepository), RepositoryProvider.value(value: tokens.did), ], - child: MaterialApp.router(routerConfig: router), + child: MaterialApp.router(theme: theme, routerConfig: router), ), ), ); @@ -603,6 +640,76 @@ void main() { router.dispose(); }); + testWidgets('authenticated settings back button returns to profile when opened from profile', (tester) async { + currentAuthState = const AuthState.authenticated(tokens); + when(() => authBloc.state).thenReturn(currentAuthState); + whenListen(authBloc, Stream.value(currentAuthState), initialState: currentAuthState); + + final router = AppRouter(authBloc: authBloc).router; + + await tester.pumpWidget(buildSubjectWithRouter(router)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('PROFILE')); + await tester.pumpAndSettle(); + expect(find.text('RIVER TAM'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.settings_outlined)); + await tester.pumpAndSettle(); + expect(find.text('APPEARANCE'), findsOneWidget); + + await tester.tap(find.byTooltip('Back')); + await tester.pumpAndSettle(); + + expect(find.text('RIVER TAM'), findsOneWidget); + expect(find.text('APPEARANCE'), findsNothing); + + router.dispose(); + }); + + testWidgets('contextual profile route pops back to the originating shell route', (tester) async { + final router = AppRouter(authBloc: authBloc).router; + + await tester.pumpWidget(buildSubjectWithRouter(router)); + await tester.pumpAndSettle(); + + expect(find.text('No feeds pinned'), findsOneWidget); + clearInteractions(profileBloc); + clearInteractions(feedBloc); + + unawaited(router.push('/profile/${Uri.encodeComponent('did:plc:alice')}')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(router.canPop(), isTrue); + verifyNever(() => profileBloc.add(const ProfileLoadRequested(actor: 'did:plc:alice'))); + verifyNever(() => feedBloc.add(const FeedLoadRequested(actor: 'did:plc:alice', filter: FeedFilter.postsNoReplies))); + + router.pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('No feeds pinned'), findsOneWidget); + + router.dispose(); + }); + + testWidgets('contextual profile route uses Cupertino pages on iOS for edge-swipe back', (tester) async { + final router = AppRouter(authBloc: authBloc).router; + + await tester.pumpWidget(buildSubjectWithRouter(router, theme: ThemeData(platform: TargetPlatform.iOS))); + await tester.pumpAndSettle(); + + unawaited(router.push('/profile/${Uri.encodeComponent('did:plc:alice')}')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.byType(CupertinoPageTransition), findsWidgets); + expect(router.canPop(), isTrue); + + router.dispose(); + }); + testWidgets('keeps account-scoped settings routes auth-gated when unauthenticated', (tester) async { currentAuthState = const AuthState.unauthenticated(); when(() => authBloc.state).thenReturn(currentAuthState); diff --git a/test/shared/presentation/helpers/navigation_helpers_test.dart b/test/shared/presentation/helpers/navigation_helpers_test.dart index 250c247..895780d 100644 --- a/test/shared/presentation/helpers/navigation_helpers_test.dart +++ b/test/shared/presentation/helpers/navigation_helpers_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; @@ -37,9 +38,10 @@ void main() { expect(pushedRoute, isNotNull); expect(Uri.parse(pushedRoute!).path, '/profile/${Uri.encodeComponent(actorDid)}'); + expect(router.canPop(), isTrue); }); - testWidgets('navigateToProfile uses go from non-shell routes like /post', (tester) async { + testWidgets('navigateToProfile pushes from non-shell routes like /post', (tester) async { const actorDid = 'did:plc:alice.test'; String? activePath; @@ -71,6 +73,47 @@ void main() { await tester.pumpAndSettle(); expect(activePath, '/profile/${Uri.encodeComponent(actorDid)}'); + expect(router.canPop(), isTrue); + expect(tester.takeException(), isNull); + }); + + testWidgets('navigateToProfile routes the current user to the profile tab root', (tester) async { + const actorDid = 'did:plc:me.test'; + String? activePath; + + final router = GoRouter( + initialLocation: '/post', + routes: [ + GoRoute( + path: '/post', + builder: (context, state) => Scaffold( + body: Center( + child: FilledButton(onPressed: () => navigateToProfile(context, actorDid), child: const Text('go')), + ), + ), + ), + GoRoute( + path: '/profile/me', + builder: (context, state) { + activePath = state.uri.path; + return const Scaffold(body: Text('me')); + }, + ), + ], + ); + + await tester.pumpWidget( + RepositoryProvider.value( + value: actorDid, + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('go')); + await tester.pumpAndSettle(); + + expect(activePath, '/profile/me'); expect(router.canPop(), isFalse); expect(tester.takeException(), isNull); }); @@ -129,5 +172,38 @@ void main() { expect(result, isNull); expect(tester.takeException(), isNull); }); + + testWidgets('navigateToSettings pushes settings when a router is available', (tester) async { + String? activePath; + + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold( + body: Center( + child: FilledButton(onPressed: () => navigateToSettings(context), child: const Text('settings')), + ), + ), + ), + GoRoute( + path: '/settings', + builder: (context, state) { + activePath = state.uri.path; + return const Scaffold(body: Text('settings screen')); + }, + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('settings')); + await tester.pumpAndSettle(); + + expect(activePath, '/settings'); + expect(router.canPop(), isTrue); + }); }); }