diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index d0ec432..dfd9f58 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -24,6 +24,7 @@ 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/data/post_thread_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'; @@ -62,6 +63,9 @@ import 'package:lazurite/features/profile/presentation/profile_connections_scree import 'package:lazurite/features/profile/presentation/profile_context_screen.dart'; import 'package:lazurite/features/profile/presentation/profile_edit_screen.dart'; import 'package:lazurite/features/profile/presentation/profile_screen.dart'; +import 'package:lazurite/features/public/data/public_content_repository.dart'; +import 'package:lazurite/features/public/data/public_provider_context.dart'; +import 'package:lazurite/features/public/data/public_repository_factory.dart'; import 'package:lazurite/features/public/presentation/public_home_screen.dart'; import 'package:lazurite/features/public/presentation/public_route_state.dart'; import 'package:lazurite/features/public/presentation/unauthenticated_shell.dart'; @@ -126,13 +130,16 @@ class AppRouter { '/settings/devtools', '/terms', '/privacy', + '/feed', + '/post', OAuthCallbackScreen.routePath, OAuthCallbackScreen.compatibilityRoutePath, }; final isLoggingIn = path == '/login'; final isReauthLogin = state.uri.queryParameters['reauth'] == '1'; final isPublicBrowsingPath = path == '/public' || path.startsWith('/public/'); - final isPublicPath = publicPaths.contains(path) || isPublicBrowsingPath; + final isPublicProfilePath = path.startsWith('/profile/') && path != '/profile/me'; + final isPublicPath = publicPaths.contains(path) || isPublicBrowsingPath || isPublicProfilePath; if (!isAuthenticated && path == '/') { return const PublicRouteState( @@ -192,7 +199,7 @@ class AppRouter { child: _buildUnauthenticatedRouteShell( context, state, - PublicHomeScreen(providerKey: routeState.providerKey, contentTab: routeState.contentTab), + _buildPublicHomeRoute(context, routeState), publicProviderKey: routeState.providerKey, publicHomeLocation: routeState.location, ), @@ -312,7 +319,12 @@ class AppRouter { parentNavigatorKey: _rootNavigatorKey, pageBuilder: (context, state) { final uri = state.uri.queryParameters['uri'] ?? ''; - return _page(context, state, PostThreadScreen(postUri: Uri.decodeComponent(uri))); + final provider = state.uri.queryParameters['provider']; + return _page( + context, + state, + _buildPostThreadRoute(context, postUri: Uri.decodeComponent(uri), provider: provider), + ); }, ), GoRoute( @@ -476,7 +488,11 @@ class AppRouter { pageBuilder: (context, state) => _page( context, state, - _buildContextualProfileRoute(context, Uri.decodeComponent(state.pathParameters['actor'] ?? '')), + _buildContextualProfileRoute( + context, + Uri.decodeComponent(state.pathParameters['actor'] ?? ''), + provider: state.uri.queryParameters['provider'], + ), ), routes: [ GoRoute( @@ -563,6 +579,7 @@ class AppRouter { final encodedUri = state.uri.queryParameters['uri']; final encodedActor = state.uri.queryParameters['actor']; final encodedRkey = state.uri.queryParameters['rkey']; + final encodedProvider = state.uri.queryParameters['provider']; AtUri? feedUri; if (encodedUri != null && encodedUri.trim().isNotEmpty) { @@ -572,7 +589,12 @@ class AppRouter { final actor = encodedActor == null ? null : Uri.decodeComponent(encodedActor); final rkey = encodedRkey == null ? null : Uri.decodeComponent(encodedRkey); - return _page(context, state, FeedDetailScreen(feedUri: feedUri, actor: actor, rkey: rkey)); + final provider = encodedProvider == null ? null : Uri.decodeComponent(encodedProvider); + return _page( + context, + state, + _buildFeedDetailRoute(context, feedUri: feedUri, actor: actor, rkey: rkey, provider: provider), + ); }, ), GoRoute( @@ -716,6 +738,84 @@ class AppRouter { ); } + Widget _buildPublicHomeRoute(BuildContext context, PublicRouteState routeState) { + try { + context.read(); + return PublicHomeScreen(providerKey: routeState.providerKey, contentTab: routeState.contentTab); + } catch (_) { + log.d('PublicContentRepository not found, creating public repository for route'); + } + + AppDatabase database; + try { + database = context.read(); + } catch (error, stackTrace) { + log.d('AppDatabase not found for public route provider setup', error: error, stackTrace: stackTrace); + return RepositoryProvider.value( + value: const EmptyPublicContentRepository(), + child: PublicHomeScreen(providerKey: routeState.providerKey, contentTab: routeState.contentTab), + ); + } + + final factory = PublicRepositoryFactory(database: database); + return RepositoryProvider( + key: ValueKey('public-content-repository-${routeState.providerKey}'), + create: (_) => factory.contentRepository(routeState.providerKey), + child: PublicHomeScreen(providerKey: routeState.providerKey, contentTab: routeState.contentTab), + ); + } + + Widget _buildFeedDetailRoute( + BuildContext context, { + required AtUri? feedUri, + required String? actor, + required String? rkey, + required String? provider, + }) { + if (authBloc.state.isAuthenticated) { + return FeedDetailScreen(feedUri: feedUri, actor: actor, rkey: rkey); + } + + String? fallbackProvider; + try { + fallbackProvider = context.read().state.appViewProvider; + } catch (_) { + log.d('SettingsCubit not found for public feed detail provider fallback'); + } + + final providerContext = PublicProviderContext.fromRoute( + queryProvider: provider, + fallbackProvider: fallbackProvider, + ); + final screen = FeedDetailScreen( + feedUri: feedUri, + actor: actor, + rkey: rkey, + publicProviderKey: providerContext.providerKey, + ); + + try { + final database = context.read(); + final factory = PublicRepositoryFactory(database: database); + return RepositoryProvider( + key: ValueKey('public-feed-repository-${providerContext.providerKey}'), + create: (_) => factory.feedRepository(providerContext.providerKey), + child: screen, + ); + } catch (error, stackTrace) { + log.d('AppDatabase not found for public feed detail provider setup', error: error, stackTrace: stackTrace); + } + + try { + context.read(); + return screen; + } catch (_) { + log.d('FeedRepository not found for public feed detail route'); + } + + return screen; + } + Widget _buildAlertsRoute(BuildContext context, Widget child) { NotificationBloc? existingNotificationBloc; try { @@ -737,7 +837,78 @@ class AppRouter { ); } - Widget _buildContextualProfileRoute(BuildContext context, String actor) { + Widget _buildPostThreadRoute(BuildContext context, {required String postUri, required String? provider}) { + if (authBloc.state.isAuthenticated) { + return PostThreadScreen(postUri: postUri); + } + + final providerContext = PublicProviderContext.fromRoute( + queryProvider: provider, + fallbackProvider: _settingsProviderOrNull(context), + ); + final screen = PostThreadScreen(postUri: postUri, publicProviderKey: providerContext.providerKey); + + try { + final database = context.read(); + final factory = PublicRepositoryFactory(database: database); + return RepositoryProvider( + key: ValueKey('public-post-thread-repository-${providerContext.providerKey}'), + create: (_) => factory.postThreadRepository(providerContext.providerKey), + child: screen, + ); + } catch (error, stackTrace) { + log.d('AppDatabase not found for public post thread provider setup', error: error, stackTrace: stackTrace); + } + + try { + context.read(); + return screen; + } catch (_) { + log.d('PostThreadRepository not found for public post thread route'); + } + + return screen; + } + + Widget _buildContextualProfileRoute(BuildContext context, String actor, {String? provider}) { + if (!authBloc.state.isAuthenticated) { + final providerContext = PublicProviderContext.fromRoute( + queryProvider: provider, + fallbackProvider: _settingsProviderOrNull(context), + ); + final screen = ProfileScreen(actor: actor, showBackButton: true, publicProviderKey: providerContext.providerKey); + + try { + final database = context.read(); + final factory = PublicRepositoryFactory(database: database); + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => ProfileBloc(profileRepository: factory.profileRepository(providerContext.providerKey)), + ), + BlocProvider(create: (_) => FeedBloc(feedRepository: factory.feedRepository(providerContext.providerKey))), + ], + child: screen, + ); + } catch (error, stackTrace) { + log.d('AppDatabase not found for public profile provider setup', error: error, stackTrace: stackTrace); + } + + try { + context.read(); + context.read(); + return MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => ProfileBloc(profileRepository: context.read())), + BlocProvider(create: (_) => FeedBloc(feedRepository: context.read())), + ], + child: screen, + ); + } catch (_) { + log.d('ProfileRepository or FeedRepository not found for public profile route'); + } + } + return MultiBlocProvider( providers: [ BlocProvider(create: (_) => ProfileBloc(profileRepository: context.read())), @@ -747,6 +918,15 @@ class AppRouter { ); } + String? _settingsProviderOrNull(BuildContext context) { + try { + return context.read().state.appViewProvider; + } catch (_) { + log.d('SettingsCubit not found for public provider fallback'); + return null; + } + } + 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/feed/presentation/feed_detail_screen.dart b/lib/features/feed/presentation/feed_detail_screen.dart index e660d05..60a7730 100644 --- a/lib/features/feed/presentation/feed_detail_screen.dart +++ b/lib/features/feed/presentation/feed_detail_screen.dart @@ -4,19 +4,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/feed/data/feed_repository.dart'; +import 'package:lazurite/features/feed/presentation/widgets/feed_detail_header.dart'; import 'package:lazurite/features/feed/presentation/widgets/feed_layout_view.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; +import 'package:lazurite/features/feed/presentation/widgets/public_post_card.dart'; import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; import 'package:lazurite/shared/presentation/widgets/error_state.dart'; import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; class FeedDetailScreen extends StatefulWidget { - const FeedDetailScreen({super.key, this.feedUri, this.actor, this.rkey}); + const FeedDetailScreen({super.key, this.feedUri, this.actor, this.rkey, this.publicProviderKey}); final AtUri? feedUri; final String? actor; final String? rkey; + final String? publicProviderKey; @override State createState() => _FeedDetailScreenState(); @@ -201,20 +204,26 @@ class _FeedDetailScreenState extends State { return const LoadingState(message: 'Loading feed'); } + final header = _generator == null ? null : FeedDetailHeader(generator: _generator!, loadedPostCount: _posts.length); + if (_errorMessage != null && _posts.isEmpty) { - return ErrorState( - title: 'Failed to load feed', - message: _errorMessage!, - onRetry: _loadInitial, - icon: Icons.sync_problem_outlined, + return _withHeader( + header, + ErrorState( + title: 'Failed to load feed', + message: _errorMessage!, + onRetry: _loadInitial, + icon: Icons.sync_problem_outlined, + ), ); } if (_posts.isEmpty) { - return const EmptyState(message: 'No posts yet', icon: Icons.article_outlined); + return _withHeader(header, const EmptyState(message: 'No posts yet', icon: Icons.article_outlined)); } - final accountDid = context.read().state.tokens?.did ?? ''; + final publicProviderKey = widget.publicProviderKey; + final accountDid = publicProviderKey == null ? context.read().state.tokens?.did ?? '' : ''; Widget buildCard(int index, PostCardVariant variant) { final post = _posts[index]; final postUri = post.post.uri.toString(); @@ -222,24 +231,46 @@ class _FeedDetailScreenState extends State { itemKey: postUri, index: index, seenKeys: _seenPostUris, - child: PostCardWithActions( - feedViewPost: post, - accountDid: accountDid, - variant: variant, - onDeleted: () { - _setStateIfMounted(() => _posts.removeWhere((p) => p.post.uri.toString() == postUri)); - }, - ), + child: publicProviderKey == null + ? PostCardWithActions( + feedViewPost: post, + accountDid: accountDid, + variant: variant, + onDeleted: () { + _setStateIfMounted(() => _posts.removeWhere((p) => p.post.uri.toString() == postUri)); + }, + ) + : PublicPostCard(feedViewPost: post, providerKey: publicProviderKey, variant: variant), ); } - return FeedLayoutView( - itemCount: _posts.length, - scrollController: _scrollController, - isLoadingMore: _isLoadingMore, - onRefresh: _loadInitial, - gridItemBuilder: (context, index) => buildCard(index, PostCardVariant.compact), - linearItemBuilder: (context, index) => buildCard(index, PostCardVariant.card), + return Column( + children: [ + ?header, + Expanded( + child: FeedLayoutView( + itemCount: _posts.length, + scrollController: _scrollController, + isLoadingMore: _isLoadingMore, + onRefresh: _loadInitial, + gridItemBuilder: (context, index) => buildCard(index, PostCardVariant.compact), + linearItemBuilder: (context, index) => buildCard(index, PostCardVariant.card), + ), + ), + ], + ); + } + + Widget _withHeader(Widget? header, Widget body) { + if (header == null) { + return body; + } + + return Column( + children: [ + header, + Expanded(child: body), + ], ); } } diff --git a/lib/features/feed/presentation/post_thread_screen.dart b/lib/features/feed/presentation/post_thread_screen.dart index 71fc18d..13d1b85 100644 --- a/lib/features/feed/presentation/post_thread_screen.dart +++ b/lib/features/feed/presentation/post_thread_screen.dart @@ -24,6 +24,7 @@ import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_interactions_sheet.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_menu_actions.dart'; +import 'package:lazurite/features/feed/presentation/widgets/public_post_card.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; @@ -37,15 +38,16 @@ import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator. import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; class PostThreadScreen extends StatelessWidget { - const PostThreadScreen({super.key, required this.postUri}); + const PostThreadScreen({super.key, required this.postUri, this.publicProviderKey}); final String postUri; + final String? publicProviderKey; @override Widget build(BuildContext context) { return BlocProvider( create: (_) => PostThreadCubit(postThreadRepository: context.read())..load(postUri), - child: _PostThreadContent(postUri: postUri), + child: _PostThreadContent(postUri: postUri, publicProviderKey: publicProviderKey), ); } } @@ -79,9 +81,10 @@ Set computeInitialCollapsedThreadUris(ThreadViewPost thread, {required i } class _PostThreadContent extends StatefulWidget { - const _PostThreadContent({required this.postUri}); + const _PostThreadContent({required this.postUri, this.publicProviderKey}); final String postUri; + final String? publicProviderKey; @override State<_PostThreadContent> createState() => _PostThreadContentState(); @@ -172,7 +175,7 @@ class _PostThreadContentState extends State<_PostThreadContent> { @override Widget build(BuildContext context) { - return BlocListener( + final content = BlocListener( listenWhen: (previous, current) { if (current.status != PostThreadStatus.loaded || current.thread == null) { return false; @@ -195,6 +198,11 @@ class _PostThreadContentState extends State<_PostThreadContent> { ), ), ); + final providerKey = widget.publicProviderKey; + if (providerKey == null) { + return content; + } + return _PostThreadPublicProviderScope(providerKey: providerKey, child: content); } Widget _buildError(BuildContext context, String message) { @@ -216,7 +224,8 @@ class _PostThreadContentState extends State<_PostThreadContent> { } Widget _buildThread(BuildContext context, ThreadViewPost thread) { - final accountDid = context.read(); + final publicProviderKey = widget.publicProviderKey; + final accountDid = publicProviderKey == null ? context.read() : ''; final parents = _getParentChain(thread); final replies = _threadRepliesOf(thread); final opDid = (parents.isNotEmpty ? parents.first : thread).post.author.did; @@ -227,15 +236,27 @@ class _PostThreadContentState extends State<_PostThreadContent> { physics: const AlwaysScrollableScrollPhysics(), children: [ for (int i = 0; i < parents.length; i++) ...[ - PostCardWithActions( - feedViewPost: FeedViewPost(post: parents[i].post), - accountDid: accountDid, - onReplySubmitted: _reloadThreadAfterReply, - moderationContext: bsky_moderation.ModerationBehaviorContext.contentView, - ), + if (publicProviderKey == null) + PostCardWithActions( + feedViewPost: FeedViewPost(post: parents[i].post), + accountDid: accountDid, + onReplySubmitted: _reloadThreadAfterReply, + moderationContext: bsky_moderation.ModerationBehaviorContext.contentView, + ) + else + PublicPostCard( + feedViewPost: FeedViewPost(post: parents[i].post), + providerKey: publicProviderKey, + ), _buildThreadConnector(context), ], - _FocusedPostWithActions(thread: thread, accountDid: accountDid, onReplySubmitted: _reloadThreadAfterReply), + if (publicProviderKey == null) + _FocusedPostWithActions(thread: thread, accountDid: accountDid, onReplySubmitted: _reloadThreadAfterReply) + else + PublicPostCard( + feedViewPost: FeedViewPost(post: thread.post), + providerKey: publicProviderKey, + ), if (replies.isNotEmpty) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), @@ -256,6 +277,7 @@ class _PostThreadContentState extends State<_PostThreadContent> { depth: 1, accountDid: accountDid, opDid: opDid, + publicProviderKey: publicProviderKey, collapsedUris: _collapsedUris, onToggleCollapse: _toggleCollapsed, onReplySubmitted: _reloadThreadAfterReply, @@ -299,6 +321,7 @@ class ThreadReplyNode extends StatelessWidget { required this.opDid, required this.collapsedUris, required this.onToggleCollapse, + this.publicProviderKey, this.onReplySubmitted, this.onContinueThread, }); @@ -307,6 +330,7 @@ class ThreadReplyNode extends StatelessWidget { final int depth; final String accountDid; final String opDid; + final String? publicProviderKey; final Set collapsedUris; final ValueChanged onToggleCollapse; final Future Function(String replyParentUri)? onReplySubmitted; @@ -345,6 +369,7 @@ class ThreadReplyNode extends StatelessWidget { depth: depth, accountDid: accountDid, opDid: opDid, + publicProviderKey: publicProviderKey, collapsedUris: collapsedUris, onToggleCollapse: onToggleCollapse, onReplySubmitted: onReplySubmitted, @@ -380,6 +405,7 @@ class _ExpandedThreadReply extends StatelessWidget { required this.opDid, required this.collapsedUris, required this.onToggleCollapse, + this.publicProviderKey, this.onReplySubmitted, this.onContinueThread, }); @@ -388,6 +414,7 @@ class _ExpandedThreadReply extends StatelessWidget { final int depth; final String accountDid; final String opDid; + final String? publicProviderKey; final Set collapsedUris; final ValueChanged onToggleCollapse; final Future Function(String replyParentUri)? onReplySubmitted; @@ -405,12 +432,18 @@ class _ExpandedThreadReply extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 8), - PostCardWithActions( - feedViewPost: FeedViewPost(post: thread.post), - accountDid: accountDid, - onReplySubmitted: onReplySubmitted, - moderationContext: bsky_moderation.ModerationBehaviorContext.contentView, - ), + if (publicProviderKey == null) + PostCardWithActions( + feedViewPost: FeedViewPost(post: thread.post), + accountDid: accountDid, + onReplySubmitted: onReplySubmitted, + moderationContext: bsky_moderation.ModerationBehaviorContext.contentView, + ) + else + PublicPostCard( + feedViewPost: FeedViewPost(post: thread.post), + providerKey: publicProviderKey!, + ), for (final reply in replies) ThreadReplyNode( key: ValueKey('thread-reply-node-${reply.post.uri}'), @@ -418,6 +451,7 @@ class _ExpandedThreadReply extends StatelessWidget { depth: depth + 1, accountDid: accountDid, opDid: opDid, + publicProviderKey: publicProviderKey, collapsedUris: collapsedUris, onToggleCollapse: onToggleCollapse, onReplySubmitted: onReplySubmitted, @@ -559,7 +593,9 @@ class _ThreadOverflowLink extends StatelessWidget { onContinueThread!(thread); return; } - context.push('/post?uri=${Uri.encodeQueryComponent(thread.post.uri.toString())}'); + final providerKey = _publicProviderKeyOrNull(context); + final providerQuery = providerKey == null ? '' : '&provider=$providerKey'; + context.push('/post?uri=${Uri.encodeQueryComponent(thread.post.uri.toString())}$providerQuery'); }, child: const Text('Continue this thread →'), ), @@ -568,6 +604,20 @@ class _ThreadOverflowLink extends StatelessWidget { } } +String? _publicProviderKeyOrNull(BuildContext context) { + final content = context.findAncestorWidgetOfExactType<_PostThreadPublicProviderScope>(); + return content?.providerKey; +} + +class _PostThreadPublicProviderScope extends InheritedWidget { + const _PostThreadPublicProviderScope({required this.providerKey, required super.child}); + + final String providerKey; + + @override + bool updateShouldNotify(covariant _PostThreadPublicProviderScope oldWidget) => oldWidget.providerKey != providerKey; +} + class _ThreadLineButton extends StatelessWidget { const _ThreadLineButton({required this.color, required this.postUri, required this.isCollapsed, required this.onTap}); diff --git a/lib/features/feed/presentation/widgets/feed_detail_header.dart b/lib/features/feed/presentation/widgets/feed_detail_header.dart new file mode 100644 index 0000000..845714f --- /dev/null +++ b/lib/features/feed/presentation/widgets/feed_detail_header.dart @@ -0,0 +1,93 @@ +import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; +import 'package:flutter/material.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; +import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; +import 'package:lazurite/shared/utils/format_utils.dart'; + +class FeedDetailHeader extends StatelessWidget { + const FeedDetailHeader({super.key, required this.generator, required this.loadedPostCount}); + + final GeneratorView generator; + final int loadedPostCount; + + @override + Widget build(BuildContext context) { + final colorScheme = context.colorScheme; + final creator = generator.creator; + final description = generator.description?.trim() ?? ''; + + return Container( + key: const ValueKey('feed_detail_header'), + width: double.infinity, + decoration: BoxDecoration( + color: colorScheme.surface, + border: Border(bottom: BorderSide(color: colorScheme.outlineVariant)), + ), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ProfileAvatar( + size: 64, + imageUrl: generator.avatar, + fallbackText: generator.displayName, + shape: BoxShape.rectangle, + border: Border.all(color: colorScheme.outlineVariant), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + generator.displayName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 4), + Text( + 'by ${creator.displayName?.trim().isNotEmpty == true ? creator.displayName : creator.handle}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + if (description.isNotEmpty) ...[ + const SizedBox(height: 10), + Text(description, maxLines: 4, overflow: TextOverflow.ellipsis, style: context.textTheme.bodyMedium), + ], + const SizedBox(height: 10), + Wrap( + spacing: 12, + runSpacing: 4, + children: [ + _HeaderCount(label: 'likes', count: generator.likeCount ?? 0), + _HeaderCount(label: 'loaded', count: loadedPostCount), + ], + ), + ], + ), + ), + ], + ), + ); + } +} + +class _HeaderCount extends StatelessWidget { + const _HeaderCount({required this.label, required this.count}); + + final String label; + final int count; + + @override + Widget build(BuildContext context) { + return Text( + '${formatCount(count)} $label', + style: context.textTheme.labelMedium?.copyWith( + color: context.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ); + } +} diff --git a/lib/features/feed/presentation/widgets/public_post_card.dart b/lib/features/feed/presentation/widgets/public_post_card.dart new file mode 100644 index 0000000..fe9de70 --- /dev/null +++ b/lib/features/feed/presentation/widgets/public_post_card.dart @@ -0,0 +1,153 @@ +import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; +import 'package:lazurite/core/network/app_view_web_links.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; +import 'package:lazurite/features/feed/presentation/widgets/compact_post_card.dart'; +import 'package:lazurite/features/feed/presentation/widgets/grid_post_card.dart'; +import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; +import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; +import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; +import 'package:lazurite/shared/presentation/helpers/share_helper.dart'; +import 'package:lazurite/shared/utils/format_utils.dart'; +import 'package:lazurite/shared/utils/parse_utils.dart'; + +class PublicPostCard extends StatelessWidget { + const PublicPostCard({ + super.key, + required this.feedViewPost, + required this.providerKey, + this.variant = PostCardVariant.adaptive, + }); + + final FeedViewPost feedViewPost; + final String providerKey; + final PostCardVariant variant; + + @override + Widget build(BuildContext context) { + final resolvedVariant = variant == PostCardVariant.adaptive ? PostCardVariant.card : variant; + final footer = PublicPostCardFooter(feedViewPost: feedViewPost, providerKey: providerKey); + + Future onTap() => + context.push('/post?uri=${Uri.encodeQueryComponent(feedViewPost.post.uri.toString())}&provider=$providerKey'); + + return switch (resolvedVariant) { + PostCardVariant.grid => GridPostCard(feedViewPost: feedViewPost, footer: footer, onTap: onTap), + PostCardVariant.compact => CompactPostCard(feedViewPost: feedViewPost, footer: footer, onTap: onTap), + _ => PostCard(feedViewPost: feedViewPost, actionBar: footer, onTap: onTap), + }; + } +} + +class PublicPostCardFooter extends StatelessWidget { + const PublicPostCardFooter({super.key, required this.feedViewPost, required this.providerKey}); + + final FeedViewPost feedViewPost; + final String providerKey; + + @override + Widget build(BuildContext context) { + final colorScheme = context.colorScheme; + final post = feedViewPost.post; + final record = tryParseRecord(post.record); + final timestamp = formatPostTime(record?.createdAt ?? post.indexedAt, nowLabel: context.l10n.labelNow); + + return Container( + key: const ValueKey('public_post_card_footer'), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: colorScheme.outlineVariant)), + ), + padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 280; + final metrics = Wrap( + spacing: compact ? 4 : 8, + runSpacing: 2, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _PublicMetric(icon: Icons.chat_bubble_outline, count: post.replyCount ?? 0, label: 'Replies'), + _PublicMetric(icon: Icons.repeat, count: post.repostCount ?? 0, label: 'Reposts'), + _PublicMetric(icon: Icons.favorite_outline, count: post.likeCount ?? 0, label: 'Likes'), + ], + ); + final trailing = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + timestamp, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: 10, + letterSpacing: 1, + ), + ), + const SizedBox(width: 4), + IconButton( + key: const ValueKey('public_post_card_share_button'), + tooltip: 'Share post', + icon: const Icon(Icons.ios_share_outlined), + iconSize: 18, + visualDensity: VisualDensity.compact, + onPressed: () => ShareHelper.shareText( + context, + AppViewWebLinks.postFromAtUri(post.uri.toString(), appViewProvider: providerKey), + ), + ), + ], + ); + + if (compact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + metrics, + const SizedBox(height: 2), + Align(alignment: Alignment.centerRight, child: trailing), + ], + ); + } + + return Row( + children: [ + Expanded(child: metrics), + trailing, + ], + ); + }, + ), + ); + } +} + +class _PublicMetric extends StatelessWidget { + const _PublicMetric({required this.icon, required this.count, required this.label}); + + final IconData icon; + final int count; + final String label; + + @override + Widget build(BuildContext context) { + final color = context.colorScheme.onSurfaceVariant; + return Semantics( + label: '$label ${formatCount(count)}', + button: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 20, color: color), + const SizedBox(width: 4), + Text(formatCount(count), style: context.textTheme.bodySmall?.copyWith(color: color)), + ], + ), + ), + ); + } +} diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index ba05b40..6a66934 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -23,6 +23,7 @@ import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; +import 'package:lazurite/features/feed/presentation/widgets/public_post_card.dart'; import 'package:lazurite/features/lists/cubit/add_to_list_cubit.dart'; import 'package:lazurite/features/lists/cubit/my_lists_cubit.dart'; import 'package:lazurite/features/lists/data/list_repository.dart'; @@ -63,10 +64,11 @@ class _ProfileFeedTabConfig { } class ProfileScreen extends StatefulWidget { - const ProfileScreen({super.key, this.actor, this.showBackButton = false}); + const ProfileScreen({super.key, this.actor, this.showBackButton = false, this.publicProviderKey}); final String? actor; final bool showBackButton; + final String? publicProviderKey; @override State createState() => _ProfileScreenState(); @@ -80,6 +82,11 @@ class _ProfileScreenState extends State with TickerProviderStateM _ProfileFeedTabConfig(requestFilter: FeedFilter.postsWithReplies, slice: _ProfileFeedSlice.reposts), _ProfileFeedTabConfig(requestFilter: FeedFilter.postsWithMedia, slice: _ProfileFeedSlice.media), ]; + static const _publicFeedTabs = [ + _ProfileFeedTabConfig(requestFilter: FeedFilter.postsNoReplies, slice: _ProfileFeedSlice.posts), + _ProfileFeedTabConfig(requestFilter: FeedFilter.postsWithReplies, slice: _ProfileFeedSlice.replies), + _ProfileFeedTabConfig(requestFilter: FeedFilter.postsWithMedia, slice: _ProfileFeedSlice.media), + ]; static const _baseTabCountOwn = 7; static const _baseTabCountOther = 8; @@ -100,6 +107,8 @@ class _ProfileScreenState extends State with TickerProviderStateM final Map _cachedFeedStates = {}; bool get _isCurrentRoute => ModalRoute.of(context)?.isCurrent ?? true; + bool get _isPublic => widget.publicProviderKey != null; + List<_ProfileFeedTabConfig> get _visibleFeedTabs => _isPublic ? _publicFeedTabs : _feedTabs; @override void initState() { @@ -151,7 +160,6 @@ class _ProfileScreenState extends State with TickerProviderStateM String? get _resolvedActor { final authState = context.read().state; - if (!authState.isAuthenticated) return null; final authDid = authState.tokens?.did; final rawActor = widget.actor ?? authState.tokens?.did; if (rawActor == null) { @@ -265,10 +273,13 @@ class _ProfileScreenState extends State with TickerProviderStateM int get _baseTabCount => _showSuggestedTab ? _baseTabCountOther : _baseTabCountOwn; - int get _tabCount => _baseTabCount + (_showSuggestedTab ? 1 : 0); + int get _tabCount => _isPublic ? _publicFeedTabs.length : _baseTabCount + (_showSuggestedTab ? 1 : 0); List _localizedTabLabels(BuildContext context) { final l10n = context.l10n; + if (_isPublic) { + return [l10n.labelPosts.toUpperCase(), l10n.labelReplies.toUpperCase(), l10n.labelMedia.toUpperCase()]; + } final labels = [ l10n.labelPosts.toUpperCase(), l10n.labelReplies.toUpperCase(), @@ -295,7 +306,7 @@ class _ProfileScreenState extends State with TickerProviderStateM } _ProfileFeedTabConfig get _currentFeedTab => - _feedTabs[_tabController.index < _feedTabs.length ? _tabController.index : 0]; + _visibleFeedTabs[_tabController.index < _visibleFeedTabs.length ? _tabController.index : 0]; FeedFilter get _currentRequestFilter => _currentFeedTab.requestFilter; @@ -326,6 +337,7 @@ class _ProfileScreenState extends State with TickerProviderStateM } bool _shouldShowSuggestedTab(ProfileViewDetailed? profile) { + if (_isPublic) return false; if (profile == null) return false; return profile.did != context.read().state.tokens?.did; } @@ -491,8 +503,8 @@ class _ProfileScreenState extends State with TickerProviderStateM } final currentUserDid = context.read().state.tokens?.did; - final isOwnProfile = actorScopedProfile?.did == currentUserDid; - final feedTabChildren = _feedTabs.map((tab) { + final isOwnProfile = !_isPublic && actorScopedProfile?.did == currentUserDid; + final feedTabChildren = _visibleFeedTabs.map((tab) { final stateForTab = _cachedStateForFilter( tab.requestFilter, expectedActor: expectedActor, @@ -512,20 +524,22 @@ class _ProfileScreenState extends State with TickerProviderStateM }); final tabChildren = [ ...feedTabChildren, - if (_showSuggestedTab) + if (!_isPublic && _showSuggestedTab) KeyedSubtree( key: const PageStorageKey('profile-liked-tab'), child: _buildLikedPostsTab(context, actorScopedProfile), ), - KeyedSubtree( - key: const PageStorageKey('profile-lists-tab'), - child: _buildListsTab(context, actorScopedProfile), - ), - KeyedSubtree( - key: const PageStorageKey('profile-starter-packs-tab'), - child: _buildStarterPacksTab(context, actorScopedProfile), - ), - if (_showSuggestedTab) + if (!_isPublic) + KeyedSubtree( + key: const PageStorageKey('profile-lists-tab'), + child: _buildListsTab(context, actorScopedProfile), + ), + if (!_isPublic) + KeyedSubtree( + key: const PageStorageKey('profile-starter-packs-tab'), + child: _buildStarterPacksTab(context, actorScopedProfile), + ), + if (!_isPublic && _showSuggestedTab) KeyedSubtree( key: const PageStorageKey('profile-suggested-tab'), child: _buildSuggestedFollowsTab(actorScopedProfile), @@ -566,20 +580,20 @@ class _ProfileScreenState extends State with TickerProviderStateM ) : const AppShellMenuButton(), actions: [ - if (actorScopedProfile != null && isOwnProfile) + if (!_isPublic && actorScopedProfile != null && isOwnProfile) IconButton( key: const Key('profile_edit_header_button'), icon: const Icon(Icons.edit_outlined), tooltip: context.l10n.labelEditProfile, onPressed: () => context.push('/profile/me/edit'), ), - if (actorScopedProfile != null && isOwnProfile) + if (!_isPublic && actorScopedProfile != null && isOwnProfile) IconButton( key: const Key('profile_more_button'), icon: const Icon(Icons.more_vert), onPressed: () => _showOwnProfileMoreOptions(context, actorScopedProfile), ), - if (actorScopedProfile != null) + if (!_isPublic && actorScopedProfile != null) IconButton( key: const Key('profile_search_posts_button'), icon: const Icon(Icons.search), @@ -617,8 +631,8 @@ class _ProfileScreenState extends State with TickerProviderStateM controller: _tabController, tabs: [for (final label in _localizedTabLabels(context)) Tab(text: label)], onTap: (index) { - if (index < _feedTabs.length) { - _loadFeedOnly(filter: _feedTabs[index].requestFilter); + if (index < _visibleFeedTabs.length) { + _loadFeedOnly(filter: _visibleFeedTabs[index].requestFilter); } }, isScrollable: true, @@ -843,7 +857,7 @@ class _ProfileScreenState extends State with TickerProviderStateM const SizedBox(height: 16), Wrap(spacing: 8, runSpacing: 8, children: metaChildren), ], - if (!isOwnProfile && (profile.viewer?.knownFollowers?.count ?? 0) > 0) ...[ + if (!_isPublic && !isOwnProfile && (profile.viewer?.knownFollowers?.count ?? 0) > 0) ...[ const SizedBox(height: 12), TextButton.icon( key: const ValueKey('profile_known_followers_link'), @@ -866,7 +880,7 @@ class _ProfileScreenState extends State with TickerProviderStateM profile.followsCount ?? 0, context.l10n.labelFollowing, key: const ValueKey('profile_following_stat'), - onTap: () => _openConnections(context, profile, ProfileConnectionsTab.following), + onTap: _isPublic ? null : () => _openConnections(context, profile, ProfileConnectionsTab.following), ), const SizedBox(width: 24), _buildStat( @@ -874,7 +888,7 @@ class _ProfileScreenState extends State with TickerProviderStateM profile.followersCount ?? 0, context.l10n.labelFollowers, key: const ValueKey('profile_followers_stat'), - onTap: () => _openConnections(context, profile, ProfileConnectionsTab.followers), + onTap: _isPublic ? null : () => _openConnections(context, profile, ProfileConnectionsTab.followers), ), const SizedBox(width: 24), _buildStat(context, profile.postsCount ?? 0, context.l10n.labelPosts), @@ -882,7 +896,7 @@ class _ProfileScreenState extends State with TickerProviderStateM ), ), const SizedBox(height: 16), - if (isOwnProfile) + if (!_isPublic && isOwnProfile) Wrap( spacing: 8, runSpacing: 8, @@ -899,7 +913,7 @@ class _ProfileScreenState extends State with TickerProviderStateM ), ], ), - if (!isOwnProfile) _buildProfileActions(context, profile), + if (!_isPublic && !isOwnProfile) _buildProfileActions(context, profile), const SizedBox(height: 16), ], ), @@ -1203,6 +1217,9 @@ class _ProfileScreenState extends State with TickerProviderStateM } Widget _buildComposeFab(BuildContext context) { + if (_isPublic) { + return const SizedBox.shrink(key: ValueKey('profile-compose-fab-empty')); + } return BlocBuilder( builder: (context, state) { final profile = state.profile; @@ -1308,7 +1325,8 @@ class _ProfileScreenState extends State with TickerProviderStateM } Widget _buildRepliesFeed(BuildContext context, FeedState feedState, {required FeedFilter requestFilter}) { - final accountDid = context.read().state.tokens?.did ?? ''; + final publicProviderKey = widget.publicProviderKey; + final accountDid = publicProviderKey == null ? context.read().state.tokens?.did ?? '' : ''; return RefreshIndicator( onRefresh: _refresh, child: NotificationListener( @@ -1332,7 +1350,11 @@ class _ProfileScreenState extends State with TickerProviderStateM child: Center(child: CircularProgressIndicator()), ); } - return _ProfileReplyThreadItem(feedViewPost: feedState.posts[index], accountDid: accountDid); + return _ProfileReplyThreadItem( + feedViewPost: feedState.posts[index], + accountDid: accountDid, + publicProviderKey: publicProviderKey, + ); }, ), ), @@ -1369,7 +1391,8 @@ class _ProfileScreenState extends State with TickerProviderStateM required FeedFilter requestFilter, required _ProfileFeedSlice slice, }) { - final accountDid = context.read().state.tokens?.did ?? ''; + final publicProviderKey = widget.publicProviderKey; + final accountDid = publicProviderKey == null ? context.read().state.tokens?.did ?? '' : ''; final scrollKey = slice == _ProfileFeedSlice.posts ? const ValueKey('profile_compact_feed') : PageStorageKey('profile_compact_feed_${slice.name}'); @@ -1402,12 +1425,18 @@ class _ProfileScreenState extends State with TickerProviderStateM return Padding( key: ValueKey('profile_compact_card_$index'), padding: EdgeInsets.only(bottom: index == feedState.posts.length - 1 ? 0 : 2), - child: PostCardWithActions( - feedViewPost: post, - accountDid: accountDid, - variant: PostCardVariant.compact, - moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, - ), + child: publicProviderKey == null + ? PostCardWithActions( + feedViewPost: post, + accountDid: accountDid, + variant: PostCardVariant.compact, + moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, + ) + : PublicPostCard( + feedViewPost: post, + providerKey: publicProviderKey, + variant: PostCardVariant.compact, + ), ); }, ), @@ -1421,7 +1450,8 @@ class _ProfileScreenState extends State with TickerProviderStateM required FeedFilter requestFilter, required _ProfileFeedSlice slice, }) { - final accountDid = context.read().state.tokens?.did ?? ''; + final publicProviderKey = widget.publicProviderKey; + final accountDid = publicProviderKey == null ? context.read().state.tokens?.did ?? '' : ''; return RefreshIndicator( onRefresh: _refresh, child: NotificationListener( @@ -1445,8 +1475,12 @@ class _ProfileScreenState extends State with TickerProviderStateM child: Center(child: CircularProgressIndicator()), ); } + final post = feedState.posts[index]; + if (publicProviderKey != null) { + return PublicPostCard(feedViewPost: post, providerKey: publicProviderKey, variant: PostCardVariant.card); + } return PostCardWithActions( - feedViewPost: feedState.posts[index], + feedViewPost: post, accountDid: accountDid, variant: PostCardVariant.card, moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, @@ -1508,10 +1542,11 @@ class _ProfileScreenState extends State with TickerProviderStateM } class _ProfileReplyThreadItem extends StatelessWidget { - const _ProfileReplyThreadItem({required this.feedViewPost, required this.accountDid}); + const _ProfileReplyThreadItem({required this.feedViewPost, required this.accountDid, this.publicProviderKey}); final FeedViewPost feedViewPost; final String accountDid; + final String? publicProviderKey; @override Widget build(BuildContext context) { @@ -1519,6 +1554,13 @@ class _ProfileReplyThreadItem extends StatelessWidget { final hasParentPost = parent?.isPostView == true; if (!hasParentPost) { + if (publicProviderKey != null) { + return PublicPostCard( + feedViewPost: feedViewPost, + providerKey: publicProviderKey!, + variant: PostCardVariant.card, + ); + } return PostCardWithActions( feedViewPost: feedViewPost, accountDid: accountDid, @@ -1530,19 +1572,35 @@ class _ProfileReplyThreadItem extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - PostCardWithActions( - key: ValueKey('profile_reply_parent_${parentFeedViewPost.post.uri}'), - feedViewPost: parentFeedViewPost, - accountDid: accountDid, - moderationContext: bsky_moderation.ModerationBehaviorContext.contentView, - ), + if (publicProviderKey == null) + PostCardWithActions( + key: ValueKey('profile_reply_parent_${parentFeedViewPost.post.uri}'), + feedViewPost: parentFeedViewPost, + accountDid: accountDid, + moderationContext: bsky_moderation.ModerationBehaviorContext.contentView, + ) + else + PublicPostCard( + key: ValueKey('profile_reply_parent_${parentFeedViewPost.post.uri}'), + feedViewPost: parentFeedViewPost, + providerKey: publicProviderKey!, + variant: PostCardVariant.card, + ), _buildThreadConnector(context), - PostCardWithActions( - key: ValueKey('profile_reply_child_${feedViewPost.post.uri}'), - feedViewPost: feedViewPost, - accountDid: accountDid, - moderationContext: bsky_moderation.ModerationBehaviorContext.contentView, - ), + if (publicProviderKey == null) + PostCardWithActions( + key: ValueKey('profile_reply_child_${feedViewPost.post.uri}'), + feedViewPost: feedViewPost, + accountDid: accountDid, + moderationContext: bsky_moderation.ModerationBehaviorContext.contentView, + ) + else + PublicPostCard( + key: ValueKey('profile_reply_child_${feedViewPost.post.uri}'), + feedViewPost: feedViewPost, + providerKey: publicProviderKey!, + variant: PostCardVariant.card, + ), ], ); } diff --git a/lib/features/public/data/public_content_repository.dart b/lib/features/public/data/public_content_repository.dart new file mode 100644 index 0000000..6890de6 --- /dev/null +++ b/lib/features/public/data/public_content_repository.dart @@ -0,0 +1,98 @@ +import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; +import 'package:bluesky_poptart/app/bsky/unspecced/defs.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/features/feed/data/feed_repository.dart'; +import 'package:lazurite/features/search/data/search_repository.dart'; +import 'package:poptart_core/poptart_core.dart' as atcore; + +abstract interface class PublicContentRepository { + Future loadDiscover({String? cursor, int limit = 25}); + + Future loadFeeds({String? cursor, int limit = 25}); + + Future searchFeeds({required String query, String? cursor, int limit = 25}); +} + +class RepositoryPublicContentRepository implements PublicContentRepository { + const RepositoryPublicContentRepository({ + required this.providerKey, + required FeedRepository feedRepository, + required SearchRepository searchRepository, + }) : _feedRepository = feedRepository, + _searchRepository = searchRepository; + + final String providerKey; + final FeedRepository _feedRepository; + final SearchRepository _searchRepository; + + /// Curated list of feeds shown on blacksky.community + /// instead of popular feed generators + static const List blackskyFeedUris = [ + 'at://did:plc:w4xbfzo7kqfes5zb7r6qv3rw/app.bsky.feed.generator/blacksky-trend', + 'at://did:plc:w4xbfzo7kqfes5zb7r6qv3rw/app.bsky.feed.generator/blacksky', + 'at://did:plc:w4xbfzo7kqfes5zb7r6qv3rw/app.bsky.feed.generator/blacksky-edu', + 'at://did:plc:w4xbfzo7kqfes5zb7r6qv3rw/app.bsky.feed.generator/blacksky-op', + 'at://did:plc:w4xbfzo7kqfes5zb7r6qv3rw/app.bsky.feed.generator/blacksky-videos', + 'at://did:plc:w4xbfzo7kqfes5zb7r6qv3rw/app.bsky.feed.generator/blacksky-photos', + 'at://did:plc:3guzzweuqraryl3rdkimjamk/app.bsky.feed.generator/for-you', + ]; + + static List get blackSkyFeedAtUris => blackskyFeedUris.map(atcore.AtUri.parse).toList(growable: false); + + @override + Future loadDiscover({String? cursor, int limit = 25}) async { + if (providerKey == AppViewProviders.blackskyKey) { + final trends = await _feedRepository.getTrends(limit: limit); + return PublicDiscoverResult(trends: trends); + } + + final feeds = await _feedRepository.getSuggestedFeeds(cursor: cursor, limit: limit); + return PublicDiscoverResult(feeds: feeds); + } + + @override + Future loadFeeds({String? cursor, int limit = 25}) async => + (providerKey == AppViewProviders.blackskyKey) + ? PublicFeedsResult(feeds: await _feedRepository.getFeedGenerators(blackSkyFeedAtUris)) + : PublicFeedsResult( + feeds: await _feedRepository.getSuggestedFeeds(cursor: cursor, limit: limit), + ); + + @override + Future searchFeeds({required String query, String? cursor, int limit = 25}) async { + final result = await _searchRepository.searchFeedGenerators(query: query, cursor: cursor, limit: limit); + return PublicFeedsResult(feeds: result.feeds, cursor: result.cursor); + } +} + +class EmptyPublicContentRepository implements PublicContentRepository { + const EmptyPublicContentRepository(); + + @override + Future loadDiscover({String? cursor, int limit = 25}) async => const PublicDiscoverResult(); + + @override + Future loadFeeds({String? cursor, int limit = 25}) async => const PublicFeedsResult(feeds: []); + + @override + Future searchFeeds({required String query, String? cursor, int limit = 25}) async { + return const PublicFeedsResult(feeds: []); + } +} + +class PublicDiscoverResult { + const PublicDiscoverResult({this.feeds = const [], this.trends = const [], this.cursor}); + + final List feeds; + final List trends; + final String? cursor; + + bool get isEmpty => feeds.isEmpty && trends.isEmpty; +} + +class PublicFeedsResult { + const PublicFeedsResult({required this.feeds, this.cursor}); + + final List feeds; + final String? cursor; +} diff --git a/lib/features/public/data/public_provider_context.dart b/lib/features/public/data/public_provider_context.dart new file mode 100644 index 0000000..e3c28e1 --- /dev/null +++ b/lib/features/public/data/public_provider_context.dart @@ -0,0 +1,35 @@ +import 'package:lazurite/core/network/app_view_provider.dart'; + +class PublicProviderContext { + const PublicProviderContext({required this.providerKey}); + + final String providerKey; + + static PublicProviderContext fromRoute({String? pathProvider, String? queryProvider, String? fallbackProvider}) { + final query = _supportedOrNull(queryProvider); + if (query != null) { + return PublicProviderContext(providerKey: query); + } + + final path = _supportedOrNull(pathProvider); + if (path != null) { + return PublicProviderContext(providerKey: path); + } + + return PublicProviderContext(providerKey: AppViewProviders.normalizeSettingKey(fallbackProvider)); + } + + Uri appendTo(Uri uri) { + final query = Map.from(uri.queryParameters); + query['provider'] = providerKey; + return uri.replace(queryParameters: query); + } + + static String? _supportedOrNull(String? rawProvider) { + final normalized = rawProvider?.trim().toLowerCase(); + if (normalized == null || !AppViewProviders.supportedKeys.contains(normalized)) { + return null; + } + return normalized; + } +} diff --git a/lib/features/public/data/public_repository_factory.dart b/lib/features/public/data/public_repository_factory.dart new file mode 100644 index 0000000..6d13ba8 --- /dev/null +++ b/lib/features/public/data/public_repository_factory.dart @@ -0,0 +1,60 @@ +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; +import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; +import 'package:lazurite/features/feed/data/feed_repository.dart'; +import 'package:lazurite/features/feed/data/post_thread_repository.dart'; +import 'package:lazurite/features/profile/data/profile_repository.dart'; +import 'package:lazurite/features/public/data/public_content_repository.dart'; +import 'package:lazurite/features/search/data/search_repository.dart'; + +class PublicRepositoryFactory { + const PublicRepositoryFactory({required this.database}); + + final AppDatabase database; + + Bluesky bluesky(String providerKey) { + final provider = AppViewProviders.descriptorForSetting(providerKey); + return Bluesky.anonymous( + service: provider.publicBaseUrl.host, + getClient: XrpcNetworkInterceptor.wrapGetClient(), + postClient: XrpcNetworkInterceptor.wrapPostClient(), + ); + } + + FeedRepository feedRepository(String providerKey) { + return FeedRepository( + bluesky: bluesky(providerKey), + database: database, + accountDid: _publicAccountDid(providerKey), + appViewProvider: providerKey, + ); + } + + SearchRepository searchRepository(String providerKey) { + return SearchRepository(bluesky: bluesky(providerKey), appViewProvider: providerKey); + } + + ProfileRepository profileRepository(String providerKey) { + return ProfileRepository(database: database, bluesky: bluesky(providerKey), appViewProvider: providerKey); + } + + PostThreadRepository postThreadRepository(String providerKey) { + return PostThreadRepository( + bluesky: bluesky(providerKey), + database: database, + accountDid: _publicAccountDid(providerKey), + appViewProvider: providerKey, + ); + } + + PublicContentRepository contentRepository(String providerKey) { + return RepositoryPublicContentRepository( + providerKey: providerKey, + feedRepository: feedRepository(providerKey), + searchRepository: searchRepository(providerKey), + ); + } + + String _publicAccountDid(String providerKey) => 'public:${AppViewProviders.normalizeSettingKey(providerKey)}'; +} diff --git a/lib/features/public/presentation/public_home_screen.dart b/lib/features/public/presentation/public_home_screen.dart index c84772f..a3cb238 100644 --- a/lib/features/public/presentation/public_home_screen.dart +++ b/lib/features/public/presentation/public_home_screen.dart @@ -1,9 +1,22 @@ +import 'dart:async'; + +import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; +import 'package:bluesky_poptart/app/bsky/unspecced/defs.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/cache/lazurite_image_cache.dart'; import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/features/public/data/public_content_repository.dart'; +import 'package:lazurite/features/public/data/public_provider_context.dart'; +import 'package:lazurite/features/public/presentation/public_navigation.dart'; import 'package:lazurite/features/public/presentation/public_route_state.dart'; +import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; +import 'package:lazurite/shared/presentation/widgets/error_state.dart'; +import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; class PublicHomeScreen extends StatefulWidget { const PublicHomeScreen({super.key, required this.providerKey, required this.contentTab}); @@ -25,79 +38,82 @@ class _PublicHomeScreenState extends State { final activeContentIndex = widget.contentTab.index; final activeIndex = activeProviderIndex * PublicContentTab.values.length + activeContentIndex; - return SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), - child: Column( - children: [ - SvgPicture.asset( - 'assets/logo.svg', - height: 48, - colorFilter: ColorFilter.mode(theme.colorScheme.primary, BlendMode.srcIn), - ), - const SizedBox(height: 10), - Text( - context.l10n.appTitle, - style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w800), - ), - const SizedBox(height: 4), - Text( - context.l10n.labelRoamTheAtmosphere, - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), - const SizedBox(height: 16), - SegmentedButton( - key: const ValueKey('public-provider-switch'), - segments: const [ - ButtonSegment( - value: AppViewProviders.blueskyKey, - label: _ProviderLabel(assetPath: 'assets/bluesky.svg', name: 'BlueSky'), - ), - ButtonSegment( - value: AppViewProviders.blackskyKey, - label: _ProviderLabel(assetPath: 'assets/blacksky.svg', name: 'BlackSky'), - ), - ], - selected: {widget.providerKey}, - onSelectionChanged: (selection) => _go(context, providerKey: selection.first), - ), - const SizedBox(height: 12), - SegmentedButton( - key: const ValueKey('public-content-switch'), - segments: [ - ButtonSegment( - value: PublicContentTab.discover, - label: Text(widget.providerKey == AppViewProviders.blackskyKey ? 'Trending' : 'Discover'), - ), - const ButtonSegment(value: PublicContentTab.feeds, label: Text('Feeds')), + return Material( + color: theme.colorScheme.surface, + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), + child: Column( + children: [ + SvgPicture.asset( + 'assets/logo.svg', + height: 48, + colorFilter: ColorFilter.mode(theme.colorScheme.primary, BlendMode.srcIn), + ), + const SizedBox(height: 10), + Text( + context.l10n.appTitle, + style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 4), + Text( + context.l10n.labelRoamTheAtmosphere, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 16), + SegmentedButton( + key: const ValueKey('public-provider-switch'), + segments: const [ + ButtonSegment( + value: AppViewProviders.blueskyKey, + label: _ProviderLabel(assetPath: 'assets/bluesky.svg', name: 'BlueSky'), + ), + ButtonSegment( + value: AppViewProviders.blackskyKey, + label: _ProviderLabel(assetPath: 'assets/blacksky.svg', name: 'BlackSky'), + ), + ], + selected: {widget.providerKey}, + onSelectionChanged: (selection) => _go(context, providerKey: selection.first), + ), + const SizedBox(height: 12), + SegmentedButton( + key: const ValueKey('public-content-switch'), + segments: [ + ButtonSegment( + value: PublicContentTab.discover, + label: Text(widget.providerKey == AppViewProviders.blackskyKey ? 'Trending' : 'Discover'), + ), + const ButtonSegment(value: PublicContentTab.feeds, label: Text('Feeds')), + ], + selected: {widget.contentTab}, + onSelectionChanged: (selection) => _go(context, contentTab: selection.first), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: PageStorage( + bucket: _bucket, + child: IndexedStack( + key: const ValueKey('public-home-indexed-stack'), + index: activeIndex, + children: const [ + _PublicDiscoverTab(providerKey: AppViewProviders.blueskyKey), + _PublicFeedsTab(providerKey: AppViewProviders.blueskyKey), + _PublicDiscoverTab(providerKey: AppViewProviders.blackskyKey), + _PublicFeedsTab(providerKey: AppViewProviders.blackskyKey), ], - selected: {widget.contentTab}, - onSelectionChanged: (selection) => _go(context, contentTab: selection.first), ), - ], - ), - ), - const Divider(height: 1), - Expanded( - child: PageStorage( - bucket: _bucket, - child: IndexedStack( - key: const ValueKey('public-home-indexed-stack'), - index: activeIndex, - children: const [ - _PublicTabList(providerKey: AppViewProviders.blueskyKey, contentTab: PublicContentTab.discover), - _PublicTabList(providerKey: AppViewProviders.blueskyKey, contentTab: PublicContentTab.feeds), - _PublicTabList(providerKey: AppViewProviders.blackskyKey, contentTab: PublicContentTab.discover), - _PublicTabList(providerKey: AppViewProviders.blackskyKey, contentTab: PublicContentTab.feeds), - ], ), ), - ), - ], + ], + ), ), ); } @@ -113,6 +129,248 @@ class _PublicHomeScreenState extends State { } } +class _PublicDiscoverTab extends StatefulWidget { + const _PublicDiscoverTab({required this.providerKey}); + + final String providerKey; + + @override + State<_PublicDiscoverTab> createState() => _PublicDiscoverTabState(); +} + +class _PublicDiscoverTabState extends State<_PublicDiscoverTab> with AutomaticKeepAliveClientMixin { + PublicDiscoverResult? _result; + Object? _error; + var _loading = true; + var _loadingMore = false; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final result = await context.read().loadDiscover(); + if (!mounted) { + return; + } + setState(() { + _result = result; + _loading = false; + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _error = error; + _loading = false; + }); + } + } + + Future _loadMore() async { + final cursor = _result?.cursor; + if (_loadingMore || cursor == null || cursor.isEmpty) { + return; + } + setState(() => _loadingMore = true); + try { + final next = await context.read().loadDiscover(cursor: cursor); + if (!mounted) { + return; + } + setState(() { + _result = PublicDiscoverResult( + feeds: [...?_result?.feeds, ...next.feeds], + trends: [...?_result?.trends, ...next.trends], + cursor: next.cursor, + ); + _loadingMore = false; + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _error = error; + _loadingMore = false; + }); + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + final label = widget.providerKey == AppViewProviders.blackskyKey ? 'BlackSky Trending' : 'BlueSky Discover'; + if (_loading) { + return LoadingState(key: ValueKey('public-${widget.providerKey}-discover-loading')); + } + final error = _error; + if (error != null) { + return ErrorState(title: 'Failed to load $label', message: '$error', onRetry: _load); + } + final result = _result; + if (result == null || result.isEmpty) { + return EmptyState( + key: ValueKey('public-${widget.providerKey}-discover-empty'), + message: 'No public ${widget.providerKey == AppViewProviders.blackskyKey ? 'trends' : 'feeds'} available', + icon: Icons.travel_explore_outlined, + ); + } + + final children = [_PublicSectionHeader(label: label)]; + children.addAll(result.trends.map((trend) => _TrendCard(trend: trend, providerKey: widget.providerKey))); + children.addAll(result.feeds.map((feed) => _FeedCard(feed: feed, providerKey: widget.providerKey))); + if (result.cursor != null) { + children.add(_LoadMoreButton(loading: _loadingMore, onPressed: _loadMore)); + } + + return RefreshIndicator( + onRefresh: _load, + child: ListView( + key: PageStorageKey('public-${widget.providerKey}-discover-scroll'), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + children: children, + ), + ); + } +} + +class _PublicFeedsTab extends StatefulWidget { + const _PublicFeedsTab({required this.providerKey}); + + final String providerKey; + + @override + State<_PublicFeedsTab> createState() => _PublicFeedsTabState(); +} + +class _PublicFeedsTabState extends State<_PublicFeedsTab> with AutomaticKeepAliveClientMixin { + final _searchController = TextEditingController(); + PublicFeedsResult? _result; + Object? _error; + var _loading = true; + var _loadingMore = false; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _load({String? cursor}) async { + final query = _searchController.text.trim(); + setState(() { + if (cursor == null) { + _loading = true; + } else { + _loadingMore = true; + } + _error = null; + }); + try { + final repository = context.read(); + final result = query.isEmpty + ? await repository.loadFeeds(cursor: cursor) + : await repository.searchFeeds(query: query, cursor: cursor); + if (!mounted) { + return; + } + setState(() { + _result = cursor == null + ? result + : PublicFeedsResult(feeds: [...?_result?.feeds, ...result.feeds], cursor: result.cursor); + _loading = false; + _loadingMore = false; + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _error = error; + _loading = false; + _loadingMore = false; + }); + } + } + + Future _submitSearch() => _load(); + + @override + Widget build(BuildContext context) { + super.build(context); + final label = widget.providerKey == AppViewProviders.blackskyKey ? 'BlackSky Feeds' : 'BlueSky Feeds'; + final error = _error; + final result = _result; + + return RefreshIndicator( + onRefresh: _load, + child: ListView( + key: PageStorageKey('public-${widget.providerKey}-feeds-scroll'), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + children: [ + _PublicSectionHeader(label: label), + TextField( + key: ValueKey('public-${widget.providerKey}-feed-search'), + controller: _searchController, + decoration: InputDecoration( + labelText: 'Search feeds', + prefixIcon: const Icon(Icons.search), + suffixIcon: IconButton( + tooltip: 'Search feeds', + icon: const Icon(Icons.arrow_forward), + onPressed: _submitSearch, + ), + border: const OutlineInputBorder(), + ), + textInputAction: TextInputAction.search, + onSubmitted: (_) => unawaited(_submitSearch()), + ), + const SizedBox(height: 12), + if (_loading) + LoadingState(key: ValueKey('public-${widget.providerKey}-feeds-loading')) + else if (error != null) + ErrorState(title: 'Failed to load $label', message: '$error', onRetry: _load) + else if (result == null || result.feeds.isEmpty) + EmptyState( + key: ValueKey('public-${widget.providerKey}-feeds-empty'), + message: 'No public feeds available', + icon: Icons.rss_feed_outlined, + ) + else ...[ + ...result.feeds.map((feed) => _FeedCard(feed: feed, providerKey: widget.providerKey)), + if (result.cursor != null) + _LoadMoreButton( + loading: _loadingMore, + onPressed: () => _load(cursor: result.cursor), + ), + ], + ], + ), + ); + } +} + class _ProviderLabel extends StatelessWidget { const _ProviderLabel({required this.assetPath, required this.name}); @@ -139,53 +397,140 @@ class _ProviderLabel extends StatelessWidget { ); } -class _PublicTabList extends StatelessWidget { - const _PublicTabList({required this.providerKey, required this.contentTab}); +class _PublicSectionHeader extends StatelessWidget { + const _PublicSectionHeader({required this.label}); + + final String label; + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text(label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), + ); + } +} + +class _FeedCard extends StatelessWidget { + const _FeedCard({required this.feed, required this.providerKey}); + + final GeneratorView feed; final String providerKey; - final PublicContentTab contentTab; @override Widget build(BuildContext context) { - final providerName = providerKey == AppViewProviders.blackskyKey ? 'BlackSky' : 'BlueSky'; - final label = contentTab == PublicContentTab.feeds - ? '$providerName Feeds' - : providerKey == AppViewProviders.blackskyKey - ? '$providerName Trending' - : '$providerName Discover'; - return ListView.builder( - key: PageStorageKey('public-$providerKey-${contentTab.routeValue}-scroll'), - padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), - itemCount: 36, - itemBuilder: (context, index) { - if (index == 0) { - return _PublicSectionHeader(label: label); - } - return Card( - margin: const EdgeInsets.only(bottom: 10), - child: ListTile( - key: ValueKey('public-$providerKey-${contentTab.routeValue}-item-$index'), - leading: Icon(contentTab == PublicContentTab.feeds ? Icons.rss_feed : Icons.public), - title: Text('$label item $index'), - subtitle: const Text('Public browsing preview'), - ), - ); - }, + final avatarUrl = feed.avatar ?? feed.creator.avatar; + final description = feed.description?.trim(); + return Card( + margin: const EdgeInsets.only(bottom: 10), + child: ListTile( + key: ValueKey('public-feed-${feed.uri}'), + onTap: () => navigateToPublicFeed(context, feed, PublicProviderContext(providerKey: providerKey)), + leading: _FeedAvatar(avatarUrl: avatarUrl), + title: Text(_feedDisplayName(feed)), + subtitle: Text( + [ + 'by ${feed.creator.displayName ?? feed.creator.handle}', + if (description != null && description.isNotEmpty) description, + if (feed.likeCount != null) '${feed.likeCount} likes', + ].join('\n'), + ), + isThreeLine: description != null && description.isNotEmpty, + trailing: const Text('Open'), + ), ); } + + String _feedDisplayName(GeneratorView feed) { + final displayName = feed.displayName.trim(); + if (displayName.isNotEmpty) { + return displayName; + } + return feed.uri.toString(); + } } -class _PublicSectionHeader extends StatelessWidget { - const _PublicSectionHeader({required this.label}); +class _TrendCard extends StatelessWidget { + const _TrendCard({required this.trend, required this.providerKey}); - final String label; + final TrendView trend; + final String providerKey; + + @override + Widget build(BuildContext context) { + final category = trend.category?.trim(); + final displayName = trend.displayName.trim(); + final topic = displayName.isNotEmpty ? displayName : trend.topic; + return Card( + margin: const EdgeInsets.only(bottom: 10), + child: ListTile( + key: ValueKey('public-trend-${trend.topic}'), + onTap: () { + final link = trend.link.trim(); + if (link.startsWith('/topic/')) { + final topicId = link.substring('/topic/'.length); + context.push('/topic?topic=${Uri.encodeQueryComponent(topicId)}&provider=$providerKey'); + } + }, + leading: const Icon(Icons.trending_up), + title: Text(topic), + subtitle: Text(['${trend.postCount} posts', if (category != null && category.isNotEmpty) category].join(' · ')), + trailing: const Icon(Icons.chevron_right), + ), + ); + } +} + +class _FeedAvatar extends StatelessWidget { + const _FeedAvatar({required this.avatarUrl}); + + final String? avatarUrl; + + @override + Widget build(BuildContext context) { + final url = avatarUrl; + return Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [Color(0xFF08BDBA), Color(0xFF3DDBD9)]), + borderRadius: BorderRadius.circular(10), + ), + child: url == null || url.isEmpty + ? const Icon(Icons.rss_feed, color: Colors.white) + : ClipRRect( + borderRadius: BorderRadius.circular(10), + child: CachedNetworkImage( + imageUrl: url, + cacheManager: LazuriteImageCacheManager.instance, + fit: BoxFit.cover, + errorWidget: (_, _, _) => const Icon(Icons.rss_feed, color: Colors.white), + ), + ), + ); + } +} + +class _LoadMoreButton extends StatelessWidget { + const _LoadMoreButton({required this.loading, required this.onPressed}); + + final bool loading; + final VoidCallback onPressed; @override Widget build(BuildContext context) { - final theme = Theme.of(context); return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text(label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), + padding: const EdgeInsets.symmetric(vertical: 8), + child: Center( + child: OutlinedButton( + key: const ValueKey('public-load-more-button'), + onPressed: loading ? null : onPressed, + child: loading + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Load more'), + ), + ), ); } } diff --git a/lib/features/public/presentation/public_navigation.dart b/lib/features/public/presentation/public_navigation.dart new file mode 100644 index 0000000..5f74deb --- /dev/null +++ b/lib/features/public/presentation/public_navigation.dart @@ -0,0 +1,22 @@ +import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/features/public/data/public_provider_context.dart'; + +void navigateToPublicFeed(BuildContext context, GeneratorView feed, PublicProviderContext providerContext) { + final uri = Uri( + path: '/feed', + queryParameters: {'uri': feed.uri.toString(), 'provider': providerContext.providerKey}, + ); + context.push(uri.toString()); +} + +void navigateToPublicProfile(BuildContext context, String actor, PublicProviderContext providerContext) { + final uri = providerContext.appendTo(Uri(path: '/profile/$actor')); + context.push(uri.toString()); +} + +void navigateToPublicPost(BuildContext context, String postUri, PublicProviderContext providerContext) { + final uri = Uri(path: '/post', queryParameters: {'uri': postUri, 'provider': providerContext.providerKey}); + context.push(uri.toString()); +} diff --git a/test/core/router/app_router_test.dart b/test/core/router/app_router_test.dart index 7b0a125..6f071fd 100644 --- a/test/core/router/app_router_test.dart +++ b/test/core/router/app_router_test.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:bloc_test/bloc_test.dart'; import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; +import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; +import 'package:bluesky_poptart/app/bsky/feed/post.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -18,17 +20,20 @@ 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/feed/data/post_thread_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/public/data/public_content_repository.dart'; import 'package:lazurite/features/search/data/search_repository.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:poptart_core/poptart_core.dart' as atcore; class MockAuthBloc extends MockBloc implements AuthBloc {} @@ -40,6 +45,8 @@ class MockFeedBloc extends MockBloc implements FeedBloc {} class MockFeedRepository extends Mock implements FeedRepository {} +class MockPostThreadRepository extends Mock implements PostThreadRepository {} + class MockSettingsCubit extends MockCubit implements SettingsCubit {} class MockConnectivityCubit extends MockCubit implements ConnectivityCubit {} @@ -62,12 +69,49 @@ class MockTypeaheadRepository extends Mock implements TypeaheadRepository {} class MockAppDatabase extends Mock implements AppDatabase {} +class FakePublicContentRepository implements PublicContentRepository { + const FakePublicContentRepository(); + + @override + Future loadDiscover({String? cursor, int limit = 25}) async { + return PublicDiscoverResult( + feeds: [for (var index = 0; index < 36; index += 1) _feed('discover-$index')], + cursor: cursor == null ? 'next' : null, + ); + } + + @override + Future loadFeeds({String? cursor, int limit = 25}) async { + return PublicFeedsResult( + feeds: [for (var index = 0; index < 36; index += 1) _feed('suggested-$index')], + cursor: cursor == null ? 'next' : null, + ); + } + + @override + Future searchFeeds({required String query, String? cursor, int limit = 25}) async { + return PublicFeedsResult(feeds: [_feed(query)], cursor: null); + } + + static GeneratorView _feed(String rkey) { + return GeneratorView( + uri: atcore.AtUri.parse('at://did:plc:feed/app.bsky.feed.generator/$rkey'), + cid: 'cid-$rkey', + did: 'did:web:feeds.example', + creator: const ProfileView(did: 'did:plc:feed', handle: 'feeds.example', displayName: 'Feeds'), + displayName: 'Feed $rkey', + indexedAt: DateTime.utc(2026, 5, 18), + ); + } +} + void main() { late MockAuthBloc authBloc; late MockFeedPreferencesCubit feedPreferencesCubit; late MockProfileBloc profileBloc; late MockFeedBloc feedBloc; late MockFeedRepository feedRepository; + late MockPostThreadRepository postThreadRepository; late MockSettingsCubit settingsCubit; late MockConnectivityCubit connectivityCubit; late MockAccountSwitcherCubit accountSwitcherCubit; @@ -111,6 +155,7 @@ void main() { profileBloc = MockProfileBloc(); feedBloc = MockFeedBloc(); feedRepository = MockFeedRepository(); + postThreadRepository = MockPostThreadRepository(); settingsCubit = MockSettingsCubit(); connectivityCubit = MockConnectivityCubit(); accountSwitcherCubit = MockAccountSwitcherCubit(); @@ -240,6 +285,7 @@ void main() { RepositoryProvider.value(value: profileRepository), RepositoryProvider.value(value: profileActionRepository), RepositoryProvider.value(value: feedRepository), + RepositoryProvider.value(value: const FakePublicContentRepository()), RepositoryProvider.value(value: tokens.did), ], child: MaterialApp.router(theme: theme, routerConfig: router), @@ -552,6 +598,25 @@ void main() { router.dispose(); }); + testWidgets('public provider switching does not persist app view settings', (tester) async { + currentAuthState = const AuthState.unauthenticated(); + 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('BlackSky')); + await tester.pumpAndSettle(); + + expect(router.routerDelegate.currentConfiguration.uri.path, '/public/blacksky/discover'); + verifyNever(() => settingsCubit.setAppViewProvider(any())); + + router.dispose(); + }); + testWidgets('unauthenticated bottom navigation maps destinations and login action', (tester) async { currentAuthState = const AuthState.unauthenticated(); when(() => authBloc.state).thenReturn(currentAuthState); @@ -634,6 +699,152 @@ void main() { router.dispose(); }); + testWidgets('logged-out feed detail route uses provider query for public read-only rendering', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 1200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + currentAuthState = const AuthState.unauthenticated(); + when(() => authBloc.state).thenReturn(currentAuthState); + final feedUri = atcore.AtUri.parse('at://did:plc:feed/app.bsky.feed.generator/blacksky'); + when( + () => feedRepository.getFeedGenerator(feedUri), + ).thenAnswer((_) async => FakePublicContentRepository._feed('blacksky')); + when( + () => feedRepository.getFeed( + feedUri: feedUri, + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => FeedResult(posts: [_publicFeedPost()])); + + final router = AppRouter(authBloc: authBloc).router; + + await tester.pumpWidget( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: feedPreferencesCubit), + BlocProvider.value(value: feedBloc), + BlocProvider.value(value: settingsCubit), + BlocProvider.value(value: connectivityCubit), + BlocProvider.value(value: accountSwitcherCubit), + ], + child: MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: feedRepository), + RepositoryProvider.value(value: const FakePublicContentRepository()), + ], + child: MaterialApp.router(routerConfig: router), + ), + ), + ); + await tester.pumpAndSettle(); + + router.go('/feed?uri=${Uri.encodeQueryComponent(feedUri.toString())}&provider=blacksky'); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('public_post_card_footer')), findsOneWidget); + expect(find.byTooltip('Share post'), findsOneWidget); + expect(find.byIcon(Icons.bookmark_outline), findsNothing); + + router.dispose(); + }); + + testWidgets('logged-out post route uses provider query for public read-only rendering', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 1200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + currentAuthState = const AuthState.unauthenticated(); + when(() => authBloc.state).thenReturn(currentAuthState); + final thread = _publicThread(); + when(() => postThreadRepository.getPostThread(thread.post.uri.toString())).thenAnswer((_) async => thread); + final router = AppRouter(authBloc: authBloc).router; + + await tester.pumpWidget( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: settingsCubit), + ], + child: RepositoryProvider.value( + value: postThreadRepository, + child: RepositoryProvider.value( + value: const FakePublicContentRepository(), + child: MaterialApp.router(routerConfig: router), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + router.go('/post?uri=${Uri.encodeQueryComponent(thread.post.uri.toString())}&provider=blacksky'); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('public_post_card_footer')), findsOneWidget); + expect(find.byTooltip('Share post'), findsOneWidget); + expect(find.byIcon(Icons.bookmark_outline), findsNothing); + + router.dispose(); + }); + + testWidgets('logged-out profile route resolves actor and hides authenticated controls', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 1200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + currentAuthState = const AuthState.unauthenticated(); + when(() => authBloc.state).thenReturn(currentAuthState); + final post = _publicFeedPost(); + when(() => profileRepository.getProfile('alice.bsky.social')).thenAnswer( + (_) async => const ProfileViewDetailed( + did: 'did:plc:alice', + handle: 'alice.bsky.social', + displayName: 'Alice', + postsCount: 1, + ), + ); + when( + () => feedRepository.getAuthorFeed( + actor: any(named: 'actor'), + filter: any(named: 'filter'), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => FeedResult(posts: [post])); + final router = AppRouter(authBloc: authBloc).router; + + await tester.pumpWidget( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: settingsCubit), + BlocProvider.value(value: connectivityCubit), + ], + child: MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: profileRepository), + RepositoryProvider.value(value: feedRepository), + RepositoryProvider.value(value: const FakePublicContentRepository()), + ], + child: MaterialApp.router(routerConfig: router), + ), + ), + ); + await tester.pumpAndSettle(); + + router.go('/profile/alice.bsky.social?provider=blacksky'); + await tester.pumpAndSettle(); + + expect(find.text('Alice'), findsWidgets); + expect(find.text('POSTS'), findsWidgets); + expect(find.text('REPLIES'), findsOneWidget); + expect(find.text('MEDIA'), findsOneWidget); + expect(find.text('QUOTES'), findsNothing); + expect(find.byKey(const ValueKey('public_post_card_footer')), findsOneWidget); + expect(find.byKey(const Key('profile_search_posts_button')), findsNothing); + + router.dispose(); + }); + testWidgets('unauthenticated settings login falls back to BlueSky for invalid persisted provider', (tester) async { currentAuthState = const AuthState.unauthenticated(); when(() => authBloc.state).thenReturn(currentAuthState); @@ -1058,3 +1269,23 @@ void main() { router.dispose(); }); } + +FeedViewPost _publicFeedPost() { + final record = FeedPostRecord(text: 'Public route post', createdAt: DateTime.utc(2026, 5, 18)); + return FeedViewPost( + post: PostView( + uri: atcore.AtUri.parse('at://did:plc:author/app.bsky.feed.post/route'), + cid: 'cid-route', + author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'), + record: record.toJson(), + indexedAt: DateTime.utc(2026, 5, 18), + replyCount: 1, + repostCount: 2, + likeCount: 3, + ), + ); +} + +ThreadViewPost _publicThread() { + return ThreadViewPost(post: _publicFeedPost().post); +} diff --git a/test/features/feed/presentation/feed_detail_screen_test.dart b/test/features/feed/presentation/feed_detail_screen_test.dart index c96a52d..335ec11 100644 --- a/test/features/feed/presentation/feed_detail_screen_test.dart +++ b/test/features/feed/presentation/feed_detail_screen_test.dart @@ -4,6 +4,7 @@ import 'package:poptart_core/poptart_core.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; +import 'package:bluesky_poptart/app/bsky/feed/post.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -61,7 +62,7 @@ void main() { ); }); - Widget buildSubject() { + Widget buildSubject({String? publicProviderKey}) { return MaterialApp( home: RepositoryProvider.value( value: feedRepository, @@ -70,7 +71,7 @@ void main() { BlocProvider.value(value: authBloc), BlocProvider.value(value: settingsCubit), ], - child: FeedDetailScreen(feedUri: feedUri), + child: FeedDetailScreen(feedUri: feedUri, publicProviderKey: publicProviderKey), ), ), ); @@ -128,7 +129,7 @@ void main() { await tester.pumpWidget(buildSubject()); await tester.pumpAndSettle(); - expect(find.text('My Feed'), findsOneWidget); + expect(find.text('My Feed'), findsWidgets); }); testWidgets('shows error state and supports retry', (tester) async { @@ -190,15 +191,62 @@ void main() { verify(() => feedRepository.getFeedGenerator(feedUri)).called(1); verify(() => feedRepository.getFeed(feedUri: feedUri, cursor: null, limit: 50)).called(1); }); + + testWidgets('renders logged-out public feed detail with read-only cards', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 1200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); + whenListen(authBloc, const Stream.empty(), initialState: const AuthState.unauthenticated()); + when( + () => feedRepository.getFeedGenerator(feedUri), + ).thenAnswer((_) async => _generatorView(displayName: 'Blacksky', description: 'Public community feed')); + when( + () => feedRepository.getFeed( + feedUri: feedUri, + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => FeedResult(posts: [_feedViewPost()])); + + await tester.pumpWidget(buildSubject(publicProviderKey: 'blacksky')); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('feed_detail_header')), findsOneWidget); + expect(find.text('Blacksky'), findsWidgets); + expect(find.text('Public community feed'), findsOneWidget); + expect(find.text('Test User'), findsOneWidget); + expect(find.byKey(const ValueKey('public_post_card_footer')), findsOneWidget); + expect(find.byTooltip('Share post'), findsOneWidget); + expect(find.byIcon(Icons.bookmark_outline), findsNothing); + }); } -GeneratorView _generatorView({required String displayName}) { +GeneratorView _generatorView({required String displayName, String? description}) { return GeneratorView( uri: AtUri.parse('at://did:plc:creator/app.bsky.feed.generator/discover'), cid: 'cid-gen', creator: const ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social'), did: 'did:plc:creator', displayName: displayName, + description: description, + likeCount: 42, indexedAt: DateTime.utc(2026, 1, 1), ); } + +FeedViewPost _feedViewPost() { + final record = FeedPostRecord(text: 'Public post body', createdAt: DateTime.utc(2026, 3, 16)); + return FeedViewPost( + post: PostView( + uri: const AtUri('at://did:plc:test/app.bsky.feed.post/xyz'), + cid: 'cid-xyz', + author: const ProfileViewBasic(did: 'did:plc:test', handle: 'test.bsky.social', displayName: 'Test User'), + record: record.toJson(), + indexedAt: DateTime.utc(2026, 3, 16), + replyCount: 2, + repostCount: 3, + likeCount: 5, + ), + ); +} diff --git a/test/features/feed/presentation/post_thread_screen_test.dart b/test/features/feed/presentation/post_thread_screen_test.dart index edf2ddb..30050d9 100644 --- a/test/features/feed/presentation/post_thread_screen_test.dart +++ b/test/features/feed/presentation/post_thread_screen_test.dart @@ -5,19 +5,33 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; +import 'package:lazurite/features/feed/data/post_thread_repository.dart'; import 'package:lazurite/features/feed/presentation/post_thread_screen.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/settings/bloc/settings_state.dart'; import 'package:mocktail/mocktail.dart'; class MockPostActionRepository extends Mock implements PostActionRepository {} +class MockPostThreadRepository extends Mock implements PostThreadRepository {} + class MockSavedPostsCubit extends MockCubit implements SavedPostsCubit {} class MockConnectivityCubit extends MockCubit implements ConnectivityCubit {} +class MockSettingsCubit extends MockCubit implements SettingsCubit {} + +const _settingsState = SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, +); + PostView _makePost({ required String did, required String handle, @@ -122,23 +136,59 @@ class _ReplyTreeHarnessState extends State<_ReplyTreeHarness> { void main() { late MockPostActionRepository mockPostActionRepository; + late MockPostThreadRepository mockPostThreadRepository; late MockSavedPostsCubit mockSavedPostsCubit; late MockConnectivityCubit mockConnectivityCubit; + late MockSettingsCubit mockSettingsCubit; setUp(() { mockPostActionRepository = MockPostActionRepository(); + mockPostThreadRepository = MockPostThreadRepository(); mockSavedPostsCubit = MockSavedPostsCubit(); mockConnectivityCubit = MockConnectivityCubit(); + mockSettingsCubit = MockSettingsCubit(); const savedState = SavedPostsState(status: SavedPostsStatus.loaded, savedPosts: [], savedUris: {}); when(() => mockSavedPostsCubit.state).thenReturn(savedState); whenListen(mockSavedPostsCubit, const Stream.empty(), initialState: savedState); when(() => mockConnectivityCubit.state).thenReturn(const ConnectivityState.online()); + when(() => mockSettingsCubit.state).thenReturn(_settingsState); whenListen( mockConnectivityCubit, const Stream.empty(), initialState: const ConnectivityState.online(), ); + whenListen(mockSettingsCubit, const Stream.empty(), initialState: _settingsState); + }); + + testWidgets('renders public thread with read-only post cards and no action providers', (tester) async { + final child = _makeThread(did: 'did:plc:child', handle: 'child.bsky.social', rkey: 'child', text: 'Child reply'); + final root = _makeThread( + did: 'did:plc:root', + handle: 'root.bsky.social', + rkey: 'root', + text: 'Root post', + replies: [child], + ); + when(() => mockPostThreadRepository.getPostThread(root.post.uri.toString())).thenAnswer((_) async => root); + + await tester.pumpWidget( + MaterialApp( + home: RepositoryProvider.value( + value: mockPostThreadRepository, + child: BlocProvider.value( + value: mockSettingsCubit, + child: PostThreadScreen(postUri: root.post.uri.toString(), publicProviderKey: 'blacksky'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + 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.byIcon(Icons.bookmark_outline), findsNothing); }); testWidgets('renders nested threaded replies recursively', (tester) async { diff --git a/test/features/feed/presentation/public_post_card_test.dart b/test/features/feed/presentation/public_post_card_test.dart new file mode 100644 index 0000000..ae046e9 --- /dev/null +++ b/test/features/feed/presentation/public_post_card_test.dart @@ -0,0 +1,63 @@ +import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; +import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; +import 'package:bluesky_poptart/app/bsky/feed/post.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/theme/app_theme.dart'; +import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; +import 'package:lazurite/features/feed/presentation/widgets/public_post_card.dart'; +import 'package:poptart_core/poptart_core.dart'; + +void main() { + testWidgets('renders passive public counts and share action', (tester) async { + await tester.pumpWidget(_buildSubject(_makePost(), variant: PostCardVariant.card)); + + expect(find.byKey(const ValueKey('public_post_card_footer')), findsOneWidget); + expect(find.byIcon(Icons.chat_bubble_outline), findsOneWidget); + expect(find.byIcon(Icons.repeat), findsOneWidget); + expect(find.byIcon(Icons.favorite_outline), findsOneWidget); + expect(find.text('2'), findsOneWidget); + expect(find.text('3'), findsOneWidget); + expect(find.text('5'), findsOneWidget); + expect(find.byTooltip('Share post'), findsOneWidget); + expect(find.byIcon(Icons.bookmark_outline), findsNothing); + }); + + testWidgets('passes public footer into compact and grid card variants', (tester) async { + await tester.pumpWidget(_buildSubject(_makePost(), variant: PostCardVariant.compact)); + + expect(find.byKey(const ValueKey('public_post_card_footer')), findsOneWidget); + expect(find.byTooltip('Share post'), findsOneWidget); + + await tester.pumpWidget(_buildSubject(_makePost(), variant: PostCardVariant.grid)); + + expect(find.byKey(const ValueKey('public_post_card_footer')), findsOneWidget); + expect(find.byTooltip('Share post'), findsOneWidget); + }); +} + +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), + ), + ); +} + +FeedViewPost _makePost() { + final record = FeedPostRecord(text: 'Public post', createdAt: DateTime.utc(2026, 3, 16)); + return FeedViewPost( + post: PostView( + uri: const AtUri('at://did:plc:test/app.bsky.feed.post/xyz'), + cid: 'cid-xyz', + author: const ProfileViewBasic(did: 'did:plc:test', handle: 'test.bsky.social', displayName: 'Test User'), + record: record.toJson(), + indexedAt: DateTime.utc(2026, 3, 16), + replyCount: 2, + repostCount: 3, + likeCount: 5, + ), + ); +} diff --git a/test/features/profile/presentation/profile_screen_test.dart b/test/features/profile/presentation/profile_screen_test.dart index d5caba0..97f30fd 100644 --- a/test/features/profile/presentation/profile_screen_test.dart +++ b/test/features/profile/presentation/profile_screen_test.dart @@ -129,7 +129,7 @@ void main() { ); }); - Widget buildSubject() { + Widget buildSubject({String? publicProviderKey, String? actor}) { return MultiBlocProvider( providers: [ BlocProvider.value(value: authBloc), @@ -138,7 +138,9 @@ void main() { BlocProvider.value(value: settingsCubit), BlocProvider.value(value: connectivityCubit), ], - child: const MaterialApp(home: ProfileScreen()), + child: MaterialApp( + home: ProfileScreen(actor: actor, publicProviderKey: publicProviderKey), + ), ); } @@ -194,6 +196,57 @@ void main() { expect(find.text('Liked'), findsOneWidget); }); + testWidgets('logged-out public profile shows only public tabs and read-only cards', (tester) async { + useLargeScreen(tester); + const publicProfile = ProfileViewDetailed( + did: 'did:plc:public', + handle: 'public.bsky.social', + displayName: 'Public User', + followersCount: 12, + followsCount: 8, + postsCount: 3, + ); + final post = _publicProfilePost(); + when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); + whenListen(authBloc, const Stream.empty(), initialState: const AuthState.unauthenticated()); + when(() => profileBloc.state).thenReturn(const ProfileState.loaded(profile: publicProfile)); + whenListen( + profileBloc, + const Stream.empty(), + initialState: const ProfileState.loaded(profile: publicProfile), + ); + when(() => feedBloc.state).thenReturn( + FeedState.loaded(actor: publicProfile.did, posts: [post], filter: FeedFilter.postsNoReplies, hasMore: false), + ); + whenListen( + feedBloc, + Stream.value( + FeedState.loaded(actor: publicProfile.did, posts: [post], filter: FeedFilter.postsNoReplies, hasMore: false), + ), + initialState: FeedState.loaded( + actor: publicProfile.did, + posts: [post], + filter: FeedFilter.postsNoReplies, + hasMore: false, + ), + ); + + await tester.pumpWidget(buildSubject(actor: publicProfile.did, publicProviderKey: 'blacksky')); + await tester.pumpAndSettle(); + + expect(find.text('POSTS'), findsWidgets); + expect(find.text('REPLIES'), findsOneWidget); + expect(find.text('MEDIA'), findsOneWidget); + expect(find.text('QUOTES'), findsNothing); + expect(find.text('REPOSTS'), findsNothing); + expect(find.text('LISTS'), findsNothing); + expect(find.text('STARTER PACKS'), findsNothing); + expect(find.byKey(const ValueKey('profile-compose-fab')), findsNothing); + expect(find.byKey(const Key('profile_search_posts_button')), findsNothing); + expect(find.byKey(const ValueKey('public_post_card_footer')), findsOneWidget); + expect(find.byIcon(Icons.bookmark_outline), findsNothing); + }); + testWidgets('header edit profile action opens the profile edit route', (tester) async { useLargeScreen(tester); final router = GoRouter( @@ -1235,3 +1288,19 @@ void main() { }); }); } + +FeedViewPost _publicProfilePost() { + final record = FeedPostRecord(text: 'Public profile post', createdAt: DateTime.utc(2026, 5, 18)); + return FeedViewPost( + post: PostView( + uri: const AtUri('at://did:plc:public/app.bsky.feed.post/public'), + cid: 'cid-public', + author: const ProfileViewBasic(did: 'did:plc:public', handle: 'public.bsky.social', displayName: 'Public User'), + record: record.toJson(), + indexedAt: DateTime.utc(2026, 5, 18), + replyCount: 1, + repostCount: 2, + likeCount: 3, + ), + ); +} diff --git a/test/features/public/data/public_content_repository_test.dart b/test/features/public/data/public_content_repository_test.dart new file mode 100644 index 0000000..701aa2b --- /dev/null +++ b/test/features/public/data/public_content_repository_test.dart @@ -0,0 +1,135 @@ +import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; +import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; +import 'package:bluesky_poptart/app/bsky/unspecced/defs.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/features/feed/data/feed_repository.dart'; +import 'package:lazurite/features/public/data/public_content_repository.dart'; +import 'package:lazurite/features/search/data/search_repository.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:poptart_core/poptart_core.dart' as atcore; + +class MockFeedRepository extends Mock implements FeedRepository {} + +class MockSearchRepository extends Mock implements SearchRepository {} + +void main() { + late MockFeedRepository feedRepository; + late MockSearchRepository searchRepository; + + setUpAll(() { + registerFallbackValue([]); + }); + + setUp(() { + feedRepository = MockFeedRepository(); + searchRepository = MockSearchRepository(); + }); + + test('loads BlueSky discover and feeds from suggested feeds', () async { + when( + () => feedRepository.getSuggestedFeeds( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => [_feed('discover')]); + final repository = RepositoryPublicContentRepository( + providerKey: AppViewProviders.blueskyKey, + feedRepository: feedRepository, + searchRepository: searchRepository, + ); + + final discover = await repository.loadDiscover(); + final feeds = await repository.loadFeeds(); + + expect(discover.feeds.single.displayName, 'Feed discover'); + expect(feeds.feeds.single.displayName, 'Feed discover'); + verify(() => feedRepository.getSuggestedFeeds(cursor: null, limit: 25)).called(2); + }); + + test('loads BlackSky discover from trends', () async { + when(() => feedRepository.getTrends(limit: any(named: 'limit'))).thenAnswer((_) async => [_trend()]); + final repository = RepositoryPublicContentRepository( + providerKey: AppViewProviders.blackskyKey, + feedRepository: feedRepository, + searchRepository: searchRepository, + ); + + final discover = await repository.loadDiscover(); + + expect(discover.trends.single.displayName, 'BlackSky Topic'); + verify(() => feedRepository.getTrends(limit: 25)).called(1); + }); + + test('loads BlackSky feeds from fixed feed generator list', () async { + when(() => feedRepository.getFeedGenerators(any())).thenAnswer((_) async => [_feed('blacksky')]); + final repository = RepositoryPublicContentRepository( + providerKey: AppViewProviders.blackskyKey, + feedRepository: feedRepository, + searchRepository: searchRepository, + ); + + final result = await repository.loadFeeds(); + + expect(result.feeds.single.displayName, 'Feed blacksky'); + final captured = verify(() => feedRepository.getFeedGenerators(captureAny())).captured.single as List; + expect(captured.map((uri) => uri.toString()), [ + 'at://did:plc:w4xbfzo7kqfes5zb7r6qv3rw/app.bsky.feed.generator/blacksky-trend', + 'at://did:plc:w4xbfzo7kqfes5zb7r6qv3rw/app.bsky.feed.generator/blacksky', + 'at://did:plc:w4xbfzo7kqfes5zb7r6qv3rw/app.bsky.feed.generator/blacksky-edu', + 'at://did:plc:w4xbfzo7kqfes5zb7r6qv3rw/app.bsky.feed.generator/blacksky-op', + 'at://did:plc:w4xbfzo7kqfes5zb7r6qv3rw/app.bsky.feed.generator/blacksky-videos', + 'at://did:plc:w4xbfzo7kqfes5zb7r6qv3rw/app.bsky.feed.generator/blacksky-photos', + 'at://did:plc:3guzzweuqraryl3rdkimjamk/app.bsky.feed.generator/for-you', + ]); + verifyNever( + () => feedRepository.getSuggestedFeeds( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ); + }); + + test('searches feeds through SearchRepository', () async { + when( + () => searchRepository.searchFeedGenerators( + query: any(named: 'query'), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => SearchFeedsResult(feeds: [_feed('news')], cursor: 'next')); + final repository = RepositoryPublicContentRepository( + providerKey: AppViewProviders.blackskyKey, + feedRepository: feedRepository, + searchRepository: searchRepository, + ); + + final result = await repository.searchFeeds(query: 'news'); + + expect(result.feeds.single.displayName, 'Feed news'); + expect(result.cursor, 'next'); + verify(() => searchRepository.searchFeedGenerators(query: 'news', cursor: null, limit: 25)).called(1); + }); +} + +GeneratorView _feed(String rkey) { + return GeneratorView( + uri: atcore.AtUri.parse('at://did:plc:feed/app.bsky.feed.generator/$rkey'), + cid: 'cid-$rkey', + did: 'did:web:feeds.example', + creator: const ProfileView(did: 'did:plc:feed', handle: 'feeds.example'), + displayName: 'Feed $rkey', + indexedAt: DateTime.utc(2026, 5, 18), + ); +} + +TrendView _trend() { + return TrendView( + topic: 'topic', + displayName: 'BlackSky Topic', + link: '/topic/1', + startedAt: DateTime.utc(2026, 5, 18), + postCount: 1, + actors: const [], + ); +} diff --git a/test/features/public/data/public_provider_context_test.dart b/test/features/public/data/public_provider_context_test.dart new file mode 100644 index 0000000..73c8e71 --- /dev/null +++ b/test/features/public/data/public_provider_context_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/features/public/data/public_provider_context.dart'; + +void main() { + group('PublicProviderContext', () { + test('uses query provider before path and fallback providers', () { + final context = PublicProviderContext.fromRoute( + pathProvider: AppViewProviders.blueskyKey, + queryProvider: AppViewProviders.blackskyKey, + fallbackProvider: AppViewProviders.blueskyKey, + ); + + expect(context.providerKey, AppViewProviders.blackskyKey); + }); + + test('falls back to path provider when query provider is invalid', () { + final context = PublicProviderContext.fromRoute( + pathProvider: AppViewProviders.blackskyKey, + queryProvider: 'weird', + fallbackProvider: AppViewProviders.blueskyKey, + ); + + expect(context.providerKey, AppViewProviders.blackskyKey); + }); + + test('falls back to normalized fallback provider', () { + final context = PublicProviderContext.fromRoute(pathProvider: 'bad', queryProvider: null, fallbackProvider: null); + + expect(context.providerKey, AppViewProviders.blueskyKey); + }); + + test('appends provider query without dropping existing query values', () { + final uri = const PublicProviderContext( + providerKey: AppViewProviders.blackskyKey, + ).appendTo(Uri(path: '/post', queryParameters: {'uri': 'at://did:plc:alice/app.bsky.feed.post/abc'})); + + expect(uri.toString(), '/post?uri=at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2Fabc&provider=blacksky'); + }); + }); +} diff --git a/test/features/public/data/public_repository_factory_test.dart b/test/features/public/data/public_repository_factory_test.dart new file mode 100644 index 0000000..4f71a23 --- /dev/null +++ b/test/features/public/data/public_repository_factory_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/features/feed/data/feed_repository.dart'; +import 'package:lazurite/features/feed/data/post_thread_repository.dart'; +import 'package:lazurite/features/profile/data/profile_repository.dart'; +import 'package:lazurite/features/public/data/public_content_repository.dart'; +import 'package:lazurite/features/public/data/public_repository_factory.dart'; +import 'package:lazurite/features/search/data/search_repository.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAppDatabase extends Mock implements AppDatabase {} + +void main() { + test('creates anonymous clients and public repositories for provider context', () { + final factory = PublicRepositoryFactory(database: MockAppDatabase()); + + expect(factory.bluesky(AppViewProviders.blackskyKey).service, 'api.blacksky.community'); + expect(factory.feedRepository(AppViewProviders.blackskyKey), isA()); + expect(factory.searchRepository(AppViewProviders.blackskyKey), isA()); + expect(factory.profileRepository(AppViewProviders.blackskyKey), isA()); + expect(factory.postThreadRepository(AppViewProviders.blackskyKey), isA()); + expect(factory.contentRepository(AppViewProviders.blackskyKey), isA()); + }); +} diff --git a/test/features/public/presentation/public_home_screen_test.dart b/test/features/public/presentation/public_home_screen_test.dart new file mode 100644 index 0000000..55707a2 --- /dev/null +++ b/test/features/public/presentation/public_home_screen_test.dart @@ -0,0 +1,156 @@ +import 'dart:async'; + +import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; +import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; +import 'package:bluesky_poptart/app/bsky/unspecced/defs.dart'; +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/core/network/app_view_provider.dart'; +import 'package:lazurite/features/public/data/public_content_repository.dart'; +import 'package:lazurite/features/public/presentation/public_home_screen.dart'; +import 'package:lazurite/features/public/presentation/public_route_state.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:poptart_core/poptart_core.dart' as atcore; + +class MockPublicContentRepository extends Mock implements PublicContentRepository {} + +void main() { + late MockPublicContentRepository repository; + + setUp(() { + repository = MockPublicContentRepository(); + when( + () => repository.loadDiscover( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => PublicDiscoverResult(feeds: [_feed('discover')], cursor: 'cursor-1')); + when( + () => repository.loadFeeds( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => PublicFeedsResult(feeds: [_feed('suggested')], cursor: 'cursor-1')); + when( + () => repository.searchFeeds( + query: any(named: 'query'), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((invocation) async { + final query = invocation.namedArguments[#query] as String; + return PublicFeedsResult(feeds: [_feed(query)]); + }); + }); + + Widget buildSubject({ + String providerKey = AppViewProviders.blueskyKey, + PublicContentTab contentTab = PublicContentTab.discover, + }) { + final router = GoRouter( + initialLocation: '/public/$providerKey/${contentTab.routeValue}', + routes: [ + GoRoute( + path: '/public/:provider/:tab', + builder: (context, state) => RepositoryProvider.value( + value: repository, + child: PublicHomeScreen(providerKey: providerKey, contentTab: contentTab), + ), + ), + GoRoute( + path: '/feed', + builder: (_, _) => const Scaffold(body: Text('feed-detail')), + ), + ], + ); + + return MaterialApp.router(routerConfig: router); + } + + testWidgets('loads BlueSky Discover feeds and opens feed details with provider context', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.text('BlueSky Discover'), findsOneWidget); + expect(find.text('Feed discover'), findsOneWidget); + + await tester.tap(find.text('Feed discover')); + await tester.pumpAndSettle(); + + expect(find.text('feed-detail'), findsOneWidget); + }); + + testWidgets('renders BlackSky Trending from public trend data', (tester) async { + when( + () => repository.loadDiscover( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => PublicDiscoverResult(trends: [_trend('Cookout Etiquette Debate')])); + + await tester.pumpWidget(buildSubject(providerKey: AppViewProviders.blackskyKey)); + await tester.pumpAndSettle(); + + expect(find.text('BlackSky Trending'), findsOneWidget); + expect(find.text('Cookout Etiquette Debate'), findsOneWidget); + }); + + testWidgets('loads public Feeds and searches through SearchRepository path', (tester) async { + await tester.pumpWidget(buildSubject(contentTab: PublicContentTab.feeds)); + await tester.pumpAndSettle(); + + expect(find.text('BlueSky Feeds'), findsOneWidget); + expect(find.text('Feed suggested'), findsOneWidget); + + await tester.enterText(find.byKey(const ValueKey('public-bluesky-feed-search')), 'news'); + await tester.tap(find.byTooltip('Search feeds')); + await tester.pumpAndSettle(); + + expect(find.text('Feed news'), findsOneWidget); + verify(() => repository.searchFeeds(query: 'news', cursor: null, limit: 25)).called(1); + }); + + testWidgets('shows loading and empty states for public feeds', (tester) async { + final completer = Completer(); + when( + () => repository.loadFeeds( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) => completer.future); + + await tester.pumpWidget(buildSubject(contentTab: PublicContentTab.feeds)); + await tester.pump(); + + expect(find.byKey(const ValueKey('public-bluesky-feeds-loading')), findsOneWidget); + + completer.complete(const PublicFeedsResult(feeds: [])); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('public-bluesky-feeds-empty')), findsOneWidget); + }); +} + +GeneratorView _feed(String rkey) { + return GeneratorView( + uri: atcore.AtUri.parse('at://did:plc:feed/app.bsky.feed.generator/$rkey'), + cid: 'cid-$rkey', + did: 'did:web:feeds.example', + creator: const ProfileView(did: 'did:plc:feed', handle: 'feeds.example', displayName: 'Feeds'), + displayName: 'Feed $rkey', + indexedAt: DateTime.utc(2026, 5, 18), + ); +} + +TrendView _trend(String topic) { + return TrendView( + topic: topic, + displayName: topic, + link: '/topic/1972', + startedAt: DateTime.utc(2026, 5, 18), + postCount: 212, + category: 'culture', + actors: const [], + ); +} diff --git a/test/features/public/presentation/public_navigation_test.dart b/test/features/public/presentation/public_navigation_test.dart new file mode 100644 index 0000000..43d2599 --- /dev/null +++ b/test/features/public/presentation/public_navigation_test.dart @@ -0,0 +1,71 @@ +import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; +import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/features/public/data/public_provider_context.dart'; +import 'package:lazurite/features/public/presentation/public_navigation.dart'; +import 'package:poptart_core/poptart_core.dart' as atcore; + +void main() { + testWidgets('public navigation helpers append provider query context', (tester) async { + const context = PublicProviderContext(providerKey: AppViewProviders.blackskyKey); + late BuildContext buttonContext; + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) { + buttonContext = context; + return const Scaffold(body: Text('home')); + }, + ), + GoRoute( + path: '/feed', + builder: (_, state) => Scaffold(body: Text(state.uri.toString())), + ), + GoRoute( + path: '/post', + builder: (_, state) => Scaffold(body: Text(state.uri.toString())), + ), + GoRoute( + path: '/profile/:actor', + builder: (_, state) => Scaffold(body: Text(state.uri.toString())), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + navigateToPublicFeed(buttonContext, _feed(), context); + await tester.pumpAndSettle(); + expect(find.textContaining('provider=blacksky'), findsOneWidget); + expect(find.textContaining('/feed?'), findsOneWidget); + + router.go('/'); + await tester.pumpAndSettle(); + 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); + + router.go('/'); + await tester.pumpAndSettle(); + navigateToPublicProfile(buttonContext, 'alice.bsky.social', context); + await tester.pumpAndSettle(); + expect(find.text('/profile/alice.bsky.social?provider=blacksky'), findsOneWidget); + }); +} + +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), + ); +}