diff --git a/lib/features/feed/presentation/post_thread_screen.dart b/lib/features/feed/presentation/post_thread_screen.dart index 881b15b..cfc401d 100644 --- a/lib/features/feed/presentation/post_thread_screen.dart +++ b/lib/features/feed/presentation/post_thread_screen.dart @@ -2,13 +2,13 @@ import 'dart:async'; import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; import 'package:bluesky_poptart/app/bsky/feed/post.dart'; -import 'package:poptart_bluesky_moderation/poptart_bluesky_moderation.dart' as bsky_moderation; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/core/router/app_route_paths.dart'; import 'package:lazurite/core/theme/feed_layout.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; @@ -34,12 +34,14 @@ import 'package:lazurite/features/moderation/presentation/widgets/moderated_avat import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; import 'package:lazurite/features/profile/data/profile_action_repository.dart'; import 'package:lazurite/features/profile/presentation/widgets/report_dialog.dart'; +import 'package:lazurite/features/public/presentation/public_route_state.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart'; import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; -import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; +import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; +import 'package:poptart_bluesky_moderation/poptart_bluesky_moderation.dart' as bsky_moderation; class PostThreadScreen extends StatelessWidget { const PostThreadScreen({super.key, required this.postUri, this.publicProviderKey}); @@ -218,7 +220,10 @@ class _PostThreadContentState extends State<_PostThreadContent> { _syncInitialCollapsedUris(state.thread!); }, child: Scaffold( - appBar: AppBar(title: const Text('Thread')), + appBar: AppBar( + leading: _ThreadBackButton(publicProviderKey: widget.publicProviderKey), + title: const Text('Thread'), + ), body: BlocBuilder( builder: (context, state) { return switch (state.status) { @@ -345,6 +350,34 @@ class _PostThreadContentState extends State<_PostThreadContent> { } } +class _ThreadBackButton extends StatelessWidget { + const _ThreadBackButton({required this.publicProviderKey}); + + final String? publicProviderKey; + + @override + Widget build(BuildContext context) => BackButton(onPressed: () => unawaited(_navigateBack(context))); + + Future _navigateBack(BuildContext context) async { + final router = GoRouter.maybeOf(context); + if (router != null && router.canPop()) { + router.pop(); + return; + } + + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(); + return; + } + + final fallbackLocation = publicProviderKey == null + ? AppRoutePath.home.path + : PublicRouteState(providerKey: publicProviderKey!, contentTab: PublicContentTab.discover).location; + router?.go(fallbackLocation); + } +} + class ThreadReplyNode extends StatelessWidget { const ThreadReplyNode({ super.key, diff --git a/lib/features/feed/presentation/widgets/public_post_card.dart b/lib/features/feed/presentation/widgets/public_post_card.dart index 6be4b2c..fb92cff 100644 --- a/lib/features/feed/presentation/widgets/public_post_card.dart +++ b/lib/features/feed/presentation/widgets/public_post_card.dart @@ -32,7 +32,7 @@ class PublicPostCard extends StatelessWidget { final footer = PublicPostCardFooter(feedViewPost: feedViewPost, providerKey: providerKey); Future onTap() async { - context.go('/post?uri=${Uri.encodeQueryComponent(feedViewPost.post.uri.toString())}&provider=$providerKey'); + await context.push('/post?uri=${Uri.encodeQueryComponent(feedViewPost.post.uri.toString())}&provider=$providerKey'); } final card = switch (resolvedVariant) { diff --git a/lib/features/public/presentation/public_navigation.dart b/lib/features/public/presentation/public_navigation.dart index edf3a71..5cac167 100644 --- a/lib/features/public/presentation/public_navigation.dart +++ b/lib/features/public/presentation/public_navigation.dart @@ -29,7 +29,7 @@ void navigateToPublicProfile(BuildContext context, String actor, PublicProviderC context.go(uri.toString()); } -void navigateToPublicPost(BuildContext context, String postUri, PublicProviderContext providerContext) { +Future navigateToPublicPost(BuildContext context, String postUri, PublicProviderContext providerContext) { final uri = Uri(path: '/post', queryParameters: {'uri': postUri, 'provider': providerContext.providerKey}); - context.go(uri.toString()); + return context.push(uri.toString()); } diff --git a/test/features/feed/presentation/post_thread_screen_test.dart b/test/features/feed/presentation/post_thread_screen_test.dart index 30050d9..fe6591b 100644 --- a/test/features/feed/presentation/post_thread_screen_test.dart +++ b/test/features/feed/presentation/post_thread_screen_test.dart @@ -188,9 +188,40 @@ void main() { expect(find.text('Root post', findRichText: true), findsOneWidget); expect(find.text('Child reply', findRichText: true), findsOneWidget); expect(find.byKey(const ValueKey('public_post_card_footer')), findsNWidgets(2)); + expect(find.byType(BackButton), findsOneWidget); expect(find.byIcon(Icons.bookmark_outline), findsNothing); }); + testWidgets('renders authenticated thread with an explicit back button', (tester) async { + final root = _makeThread(did: 'did:plc:root', handle: 'root.bsky.social', rkey: 'root', text: 'Root post'); + when(() => mockPostThreadRepository.getPostThread(root.post.uri.toString())).thenAnswer((_) async => root); + + await tester.pumpWidget( + MaterialApp( + home: MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: 'did:plc:current'), + RepositoryProvider.value(value: mockPostThreadRepository), + RepositoryProvider.value(value: mockPostActionRepository), + RepositoryProvider(create: (_) => PostActionCache()), + ], + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: mockSettingsCubit), + BlocProvider.value(value: mockSavedPostsCubit), + BlocProvider.value(value: mockConnectivityCubit), + ], + child: PostThreadScreen(postUri: root.post.uri.toString()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Root post', findRichText: true), findsOneWidget); + expect(find.byType(BackButton), findsOneWidget); + }); + testWidgets('renders nested threaded replies recursively', (tester) async { final grandchild = _makeThread( did: 'did:plc:grandchild', diff --git a/test/features/feed/presentation/public_post_card_test.dart b/test/features/feed/presentation/public_post_card_test.dart index efb6d29..0296b44 100644 --- a/test/features/feed/presentation/public_post_card_test.dart +++ b/test/features/feed/presentation/public_post_card_test.dart @@ -71,7 +71,7 @@ void main() { router.dispose(); }); - testWidgets('post taps replace public route instead of stacking', (tester) async { + testWidgets('post taps stack public route so back can return to previous location', (tester) async { final visitedPostUris = []; final router = GoRouter( routes: [ @@ -103,21 +103,18 @@ void main() { expect(find.text('post'), findsOneWidget); expect(visitedPostUris.last.path, '/post'); expect(visitedPostUris.last.queryParameters['provider'], 'blacksky'); - expect(router.canPop(), isFalse); + expect(router.canPop(), isTrue); router.dispose(); }); } -Widget _buildSubject(FeedViewPost post, {required PostCardVariant variant}) { - final theme = AppTheme.getTheme(AppThemePalette.oxocarbon, AppThemeVariant.dark); - return MaterialApp( - theme: theme, - home: Scaffold( - body: PublicPostCard(feedViewPost: post, providerKey: 'blacksky', variant: variant), - ), - ); -} +Widget _buildSubject(FeedViewPost post, {required PostCardVariant variant}) => MaterialApp( + theme: AppTheme.getTheme(AppThemePalette.oxocarbon, AppThemeVariant.dark), + home: Scaffold( + body: PublicPostCard(feedViewPost: post, providerKey: 'blacksky', variant: variant), + ), +); FeedViewPost _makePost() { final record = FeedPostRecord(text: 'Public post', createdAt: DateTime.utc(2026, 3, 16)); diff --git a/test/features/public/presentation/public_navigation_test.dart b/test/features/public/presentation/public_navigation_test.dart index f4091db..44981e6 100644 --- a/test/features/public/presentation/public_navigation_test.dart +++ b/test/features/public/presentation/public_navigation_test.dart @@ -47,12 +47,14 @@ void main() { router.go('/'); await tester.pumpAndSettle(); - navigateToPublicPost(buttonContext, 'at://did:plc:alice/app.bsky.feed.post/abc', context); + final postNavigation = navigateToPublicPost(buttonContext, 'at://did:plc:alice/app.bsky.feed.post/abc', context); await tester.pumpAndSettle(); expect(find.textContaining('provider=blacksky'), findsOneWidget); expect(find.textContaining('/post?'), findsOneWidget); - expect(router.canPop(), isFalse); + expect(router.canPop(), isTrue); + router.pop(); + await postNavigation; router.go('/'); await tester.pumpAndSettle(); navigateToPublicProfile(buttonContext, 'alice.bsky.social', context); @@ -62,13 +64,11 @@ void main() { }); } -GeneratorView _feed() { - return GeneratorView( - uri: atcore.AtUri.parse('at://did:plc:feed/app.bsky.feed.generator/news'), - cid: 'cid', - did: 'did:web:feeds.example', - creator: const ProfileView(did: 'did:plc:feed', handle: 'feeds.example'), - displayName: 'News', - indexedAt: DateTime.utc(2026, 5, 18), - ); -} +GeneratorView _feed() => GeneratorView( + uri: atcore.AtUri.parse('at://did:plc:feed/app.bsky.feed.generator/news'), + cid: 'cid', + did: 'did:web:feeds.example', + creator: const ProfileView(did: 'did:plc:feed', handle: 'feeds.example'), + displayName: 'News', + indexedAt: DateTime.utc(2026, 5, 18), +);