diff --git a/lib/core/router/app_redirect_policy.dart b/lib/core/router/app_redirect_policy.dart new file mode 100644 index 0000000..4474c3c --- /dev/null +++ b/lib/core/router/app_redirect_policy.dart @@ -0,0 +1,71 @@ +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/core/router/app_route_paths.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/public/presentation/public_route_state.dart'; + +/// Applies global authentication and public-browsing redirects for the app. +/// +/// This policy sits in front of the route graph. It decides whether a location +/// may be viewed without an authenticated account, where logged-out users should +/// land, and how to keep authenticated users out of the normal login flow. +/// Route builders remain responsible for feature-specific validation after this +/// policy allows navigation to continue. +class AppRedirectPolicy { + const AppRedirectPolicy({required this.authBloc}); + + final AuthBloc authBloc; + + /// Returns a replacement location for [state], or `null` to allow navigation. + /// + /// The data flow is intentionally one-way: auth state and URL state are read, + /// then a canonical location is returned. No repositories or feature widgets + /// are created here, which keeps redirect behavior cheap and predictable. + String? redirect(BuildContext context, GoRouterState state) { + final isAuthenticated = authBloc.state.isAuthenticated; + final path = state.uri.path; + final isLoggingIn = path == AppRoutePath.login.path; + final isReauthLogin = state.uri.queryParameters['reauth'] == '1'; + final isPublicPath = _isPublicPath(path); + + if (!isAuthenticated && path == AppRoutePath.home.path) { + return const PublicRouteState( + providerKey: AppViewProviders.blueskyKey, + contentTab: PublicContentTab.discover, + ).location; + } + + if (!isAuthenticated && !isPublicPath) { + return AppRoutePath.login.path; + } + + if (isAuthenticated && isLoggingIn && !isReauthLogin) { + return AppRoutePath.home.path; + } + + return null; + } + + bool _isPublicPath(String path) { + final publicPaths = { + AppRoutePath.login.path, + AppRoutePath.settings.path, + AppRoutePath.settingsAbout.path, + AppRoutePath.settingsLogs.path, + AppRoutePath.settingsDevTools.path, + AppRoutePath.terms.path, + AppRoutePath.privacy.path, + AppRoutePath.feed.path, + AppRoutePath.post.path, + AppRoutePath.topic.path, + AppRoutePath.images.path, + AppRoutePath.video.path, + AppRoutePath.oauthCallback.path, + AppRoutePath.oauthCallbackCompatibility.path, + }; + final isPublicBrowsingPath = path == AppRoutePath.public.path || path.startsWith('${AppRoutePath.public.path}/'); + final isPublicProfilePath = path.startsWith('/profile/') && path != AppRoutePath.profileMe.path; + return publicPaths.contains(path) || isPublicBrowsingPath || isPublicProfilePath; + } +} diff --git a/lib/core/router/app_route_paths.dart b/lib/core/router/app_route_paths.dart new file mode 100644 index 0000000..badfcca --- /dev/null +++ b/lib/core/router/app_route_paths.dart @@ -0,0 +1,149 @@ +/// Canonical route paths shared by router policy, route declarations, +/// and feature screens. +enum AppRoutePath { + /// Authenticated home timeline root. + home, + + /// Login screen. + login, + + /// Public browsing redirect root. + public, + + /// Public browsing route pattern for provider/tab state. + publicProviderTab, + + /// Compose route. + compose, + + /// Hashtag timeline route. + hashtag, + + /// Feed detail route shared by authenticated and public contexts. + feed, + + /// Post thread route shared by authenticated and public contexts. + post, + + /// Topic timeline route shared by authenticated and public contexts. + topic, + + /// Public profile route pattern that excludes `/profile/me`. + publicProfile, + + /// Current account profile route. + profileMe, + + /// Profile context route. + profileContext, + + /// Full-screen image viewer route. + images, + + /// Full-screen video viewer route. + video, + + /// Bookmarked posts route. + bookmarks, + + /// Liked posts route. + liked, + + /// Lists overview route. + lists, + + /// List detail route. + list, + + /// Create starter pack route. + createStarterPack, + + /// Starter pack detail route. + starterPack, + + /// Actor starter packs route. + starterPacks, + + /// Alerts route. + alerts, + + /// Compatibility notifications route. + notifications, + + /// Compatibility messages route. + messages, + + /// Search route. + search, + + /// AT Explorer route. + atExplorer, + + /// Settings root. + settings, + + /// Settings about page. + settingsAbout, + + /// Settings logs page. + settingsLogs, + + /// Developer tools route exposed under settings for logged-out users. + settingsDevTools, + + /// Terms of service route. + terms, + + /// Privacy policy route. + privacy, + + /// OAuth callback path used by mobile custom-scheme and HTTPS app links. + oauthCallback, + + /// Older callback path still accepted for compatibility with existing links. + oauthCallbackCompatibility; + + /// URL path used by GoRouter route declarations, redirects, and navigation. + String get path => switch (this) { + AppRoutePath.home => '/', + AppRoutePath.login => '/login', + AppRoutePath.public => '/public', + AppRoutePath.publicProviderTab => '/public/:provider/:tab', + AppRoutePath.compose => '/compose', + AppRoutePath.hashtag => '/hashtag', + AppRoutePath.feed => '/feed', + AppRoutePath.post => '/post', + AppRoutePath.topic => '/topic', + AppRoutePath.publicProfile => r'/profile/:actor(m|[^m][^/]*|m[^e][^/]*|me[^/]+)', + AppRoutePath.profileMe => '/profile/me', + AppRoutePath.profileContext => '/profile-context', + AppRoutePath.images => '/images', + AppRoutePath.video => '/video', + AppRoutePath.bookmarks => '/bookmarks', + AppRoutePath.liked => '/liked', + AppRoutePath.lists => '/lists', + AppRoutePath.list => '/list', + AppRoutePath.createStarterPack => '/create-starter-pack', + AppRoutePath.starterPack => '/starter-pack', + AppRoutePath.starterPacks => '/starter-packs', + AppRoutePath.alerts => '/alerts', + AppRoutePath.notifications => '/notifications', + AppRoutePath.messages => '/messages', + AppRoutePath.search => '/search', + AppRoutePath.atExplorer => '/at-explorer', + AppRoutePath.settings => '/settings', + AppRoutePath.settingsAbout => '/settings/about', + AppRoutePath.settingsLogs => '/settings/logs', + AppRoutePath.settingsDevTools => '/settings/devtools', + AppRoutePath.terms => '/terms', + AppRoutePath.privacy => '/privacy', + AppRoutePath.oauthCallback => '/oauth/callback', + AppRoutePath.oauthCallbackCompatibility => '/callback', + }; + + /// Last path segment for declaring nested GoRouter child routes. + /// + /// For example, [settingsAbout] has the absolute [path] `/settings/about` + /// but must be declared as `about` inside the `/settings` parent route. + String get childPath => path.substring(path.lastIndexOf('/') + 1); +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 47318a6..ee14013 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -3,96 +3,25 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; -import 'package:lazurite/core/network/constellation_client.dart'; import 'package:lazurite/core/network/poptart_client_adapter.dart' as atp; import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; -import 'package:lazurite/core/router/app_route_page.dart'; -import 'package:lazurite/core/router/app_shell.dart'; -import 'package:lazurite/features/alerts/presentation/alerts_screen.dart'; +import 'package:lazurite/core/router/app_redirect_policy.dart'; +import 'package:lazurite/core/router/content_route_factory.dart'; +import 'package:lazurite/core/router/routes/authenticated_shell_routes.dart'; +import 'package:lazurite/core/router/routes/media_routes.dart'; +import 'package:lazurite/core/router/routes/root_routes.dart'; +import 'package:lazurite/core/router/routes/unauthenticated_shell_routes.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; -import 'package:lazurite/features/auth/presentation/login_screen.dart'; -import 'package:lazurite/features/auth/presentation/oauth_callback_screen.dart'; -import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; -import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; -import 'package:lazurite/features/compose/presentation/compose_screen.dart'; import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; import 'package:lazurite/features/devtools/presentation/dev_tools_screen.dart'; -import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; -import 'package:lazurite/features/feed/data/feed_repository.dart'; -import 'package:lazurite/features/feed/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'; -import 'package:lazurite/features/feed/presentation/media/image_viewer_route_args.dart'; -import 'package:lazurite/features/feed/presentation/media/image_viewer_screen.dart'; -import 'package:lazurite/features/feed/presentation/media/video_player_route_args.dart'; -import 'package:lazurite/features/feed/presentation/media/video_player_screen.dart'; -import 'package:lazurite/features/feed/presentation/post_thread_screen.dart'; -import 'package:lazurite/features/feed/presentation/saved_posts_screen.dart'; -import 'package:lazurite/features/feed/presentation/trending_screen.dart'; -import 'package:lazurite/features/lists/bloc/list_bloc.dart'; -import 'package:lazurite/features/lists/data/list_repository.dart'; -import 'package:lazurite/features/lists/presentation/list_detail_screen.dart'; -import 'package:lazurite/features/lists/presentation/list_members_screen.dart'; -import 'package:lazurite/features/lists/presentation/my_lists_screen.dart'; -import 'package:lazurite/features/logs/presentation/logs_screen.dart'; -import 'package:lazurite/features/messages/bloc/message_bloc.dart'; -import 'package:lazurite/features/messages/data/convo_repository.dart'; -import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; -import 'package:lazurite/features/messages/presentation/message_thread_screen.dart'; -import 'package:lazurite/features/moderation/data/moderation_service.dart'; -import 'package:lazurite/features/moderation/presentation/screens/labeler_detail_screen.dart'; -import 'package:lazurite/features/moderation/presentation/screens/moderation_settings_screen.dart'; import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; -import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; import 'package:lazurite/features/notifications/data/notification_repository.dart'; import 'package:lazurite/features/notifications/domain/notification_domain_service.dart'; -import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; -import 'package:lazurite/features/profile/cubit/follow_audit_cubit.dart'; -import 'package:lazurite/features/profile/cubit/profile_connections_cubit.dart'; -import 'package:lazurite/features/profile/cubit/profile_context_cubit.dart'; -import 'package:lazurite/features/profile/data/follow_audit_repository.dart'; -import 'package:lazurite/features/profile/data/profile_context_repository.dart'; -import 'package:lazurite/features/profile/data/profile_repository.dart'; -import 'package:lazurite/features/profile/presentation/follow_audit_screen.dart'; -import 'package:lazurite/features/profile/presentation/profile_connections_screen.dart'; -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'; -import 'package:lazurite/features/search/bloc/search_bloc.dart'; -import 'package:lazurite/features/search/cubit/hashtag_cubit.dart'; -import 'package:lazurite/features/search/cubit/topic_cubit.dart'; -import 'package:lazurite/features/search/data/hashtag_utils.dart'; -import 'package:lazurite/features/search/data/search_repository.dart'; -import 'package:lazurite/features/search/presentation/hashtag_screen.dart'; -import 'package:lazurite/features/search/presentation/search_screen.dart'; -import 'package:lazurite/features/search/presentation/topic_screen.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; -import 'package:lazurite/features/settings/cubit/video_upload_limits_cubit.dart'; -import 'package:lazurite/features/settings/data/video_repository.dart'; -import 'package:lazurite/features/settings/presentation/about_screen.dart'; -import 'package:lazurite/features/settings/presentation/privacy_policy_screen.dart'; -import 'package:lazurite/features/settings/presentation/settings_account_screen.dart'; -import 'package:lazurite/features/settings/presentation/settings_screen.dart'; -import 'package:lazurite/features/settings/presentation/terms_of_service_screen.dart'; -import 'package:lazurite/features/settings/presentation/video_upload_limits_screen.dart'; -import 'package:lazurite/features/starter_packs/bloc/starter_pack_bloc.dart'; -import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; -import 'package:lazurite/features/starter_packs/presentation/actor_starter_packs_screen.dart'; -import 'package:lazurite/features/starter_packs/presentation/create_edit_starter_pack_screen.dart'; -import 'package:lazurite/features/starter_packs/presentation/starter_pack_detail_screen.dart'; -import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; class AppRouter { AppRouter({required this.authBloc, this.navigatorObserver, this.onUnauthorized}); @@ -113,6 +42,9 @@ class AppRouter { debugLabel: 'unauth-settings', ); final GlobalKey _unauthLoginNavigatorKey = GlobalKey(debugLabel: 'unauth-login'); + ContentRouteFactory get _contentRouteFactory => + ContentRouteFactory(authBloc: authBloc, onUnauthorized: onUnauthorized); + List> get _branchNavigatorKeys => [ _homeNavigatorKey, _searchNavigatorKey, @@ -125,812 +57,29 @@ class AppRouter { navigatorKey: _rootNavigatorKey, refreshListenable: GoRouterRefreshStream(authBloc.stream), observers: navigatorObserver != null ? [navigatorObserver!] : null, - redirect: (context, state) { - final isAuthenticated = authBloc.state.isAuthenticated; - final path = state.uri.path; - final publicPaths = { - '/login', - '/settings', - '/settings/about', - '/settings/logs', - '/settings/devtools', - '/terms', - '/privacy', - '/feed', - '/post', - '/topic', - '/images', - '/video', - OAuthCallbackScreen.routePath, - OAuthCallbackScreen.compatibilityRoutePath, - }; - final isLoggingIn = path == '/login'; - final isReauthLogin = state.uri.queryParameters['reauth'] == '1'; - final isPublicBrowsingPath = path == '/public' || path.startsWith('/public/'); - final isPublicProfilePath = path.startsWith('/profile/') && path != '/profile/me'; - final isPublicPath = publicPaths.contains(path) || isPublicBrowsingPath || isPublicProfilePath; - - if (!isAuthenticated && path == '/') { - return const PublicRouteState( - providerKey: AppViewProviders.blueskyKey, - contentTab: PublicContentTab.discover, - ).location; - } - - if (!isAuthenticated && !isPublicPath) { - return '/login'; - } - - if (isAuthenticated && isLoggingIn && !isReauthLogin) { - return '/'; - } - - return null; - }, + redirect: AppRedirectPolicy(authBloc: authBloc).redirect, routes: [ - _buildUnauthenticatedShellRoute(), - GoRoute( - path: OAuthCallbackScreen.routePath, - parentNavigatorKey: _rootNavigatorKey, - pageBuilder: (context, state) => buildAppRoutePage(context, state, OAuthCallbackScreen(callbackUri: state.uri)), - ), - GoRoute( - path: OAuthCallbackScreen.compatibilityRoutePath, - parentNavigatorKey: _rootNavigatorKey, - pageBuilder: (context, state) => buildAppRoutePage(context, state, OAuthCallbackScreen(callbackUri: state.uri)), - ), - GoRoute(path: '/notifications', redirect: (_, _) => '/alerts'), - GoRoute(path: '/messages', redirect: (_, _) => '/alerts/messages'), - GoRoute( - path: '/compose', - parentNavigatorKey: _rootNavigatorKey, - pageBuilder: (context, state) { - final args = ComposeRouteArgs.parseExtra(state.extra); - return buildAppRoutePage( - context, - state, - BlocProvider( - create: (_) => ComposeBloc( - composeRepository: ComposeRepository(bluesky: context.read(), onUnauthorized: onUnauthorized), - database: context.read(), - accountDid: context.read(), - ), - child: ComposeScreen( - replyParentUri: args.replyParentUri, - replyParentCid: args.replyParentCid, - replyRootUri: args.replyRootUri, - replyRootCid: args.replyRootCid, - replyAuthorHandle: args.replyAuthorHandle, - quoteUri: args.quoteUri, - quoteCid: args.quoteCid, - quoteAuthorHandle: args.quoteAuthorHandle, - quoteText: args.quoteText, - draftId: args.draftId, - initialText: args.initialText, - editPostUri: args.editPostUri, - editPostCid: args.editPostCid, - editRecord: args.editRecord, - ), - ), - ); - }, - ), - GoRoute( - path: '/hashtag', - parentNavigatorKey: _rootNavigatorKey, - pageBuilder: (context, state) { - final normalizedTag = normalizeHashtag(state.uri.queryParameters['tag'] ?? ''); - return buildAppRoutePage( - context, - state, - BlocProvider( - key: ValueKey('hashtag-$normalizedTag'), - create: (_) => HashtagCubit(searchRepository: context.read(), tag: normalizedTag), - child: HashtagScreen(tag: normalizedTag), - ), - ); - }, - ), - GoRoute( - path: '/images', - parentNavigatorKey: _rootNavigatorKey, - redirect: (context, state) { - if (ImageViewerRouteArgs.tryParse(state.extra, state.uri) != null) { - return null; - } - log.w('Image viewer route opened without valid route arguments; redirecting home. uri=${state.uri}'); - return '/'; - }, - pageBuilder: (context, state) { - final args = ImageViewerRouteArgs.tryParse(state.extra, state.uri)!; - return buildAppRoutePage(context, state, ImageViewerScreen(args: args)); - }, - ), - GoRoute( - path: '/video', - parentNavigatorKey: _rootNavigatorKey, - redirect: (context, state) { - if (VideoPlayerRouteArgs.tryParse(state.extra, state.uri) != null) { - return null; - } - log.w('Video player route opened without valid route arguments; redirecting home. uri=${state.uri}'); - return '/'; - }, - pageBuilder: (context, state) { - final args = VideoPlayerRouteArgs.tryParse(state.extra, state.uri)!; - return buildAppRoutePage(context, state, VideoPlayerScreen(args: args)); - }, - ), - GoRoute( - path: '/bookmarks', - pageBuilder: (context, state) => buildAppRoutePage( - context, - state, - SavedPostsScreen(accountDid: context.read(), initialTab: SavedPostsInitialTab.bookmarks), - ), - ), - GoRoute( - path: '/liked', - pageBuilder: (context, state) => buildAppRoutePage( - context, - state, - SavedPostsScreen(accountDid: context.read(), initialTab: SavedPostsInitialTab.liked), - ), - ), - GoRoute( - path: '/lists', - pageBuilder: (context, state) => buildAppRoutePage(context, state, const MyListsScreen()), - ), - GoRoute( - path: '/create-starter-pack', - pageBuilder: (context, state) { - return buildAppRoutePage( - context, - state, - BlocProvider( - create: (_) => StarterPackBloc(starterPackRepository: context.read()), - child: CreateStarterPackScreen(userDid: context.read()), - ), - ); - }, - ), - GoRoute( - path: '/starter-pack', - pageBuilder: (context, state) { - final uriStr = Uri.decodeComponent(state.uri.queryParameters['uri'] ?? ''); - final packUri = AtUri.parse(uriStr); - return buildAppRoutePage(context, state, StarterPackDetailScreen(packUri: packUri)); - }, - ), - GoRoute( - path: '/starter-packs', - pageBuilder: (context, state) { - final actor = state.uri.queryParameters['actor'] ?? ''; - return buildAppRoutePage(context, state, ActorStarterPacksScreen(actor: actor)); - }, - ), - GoRoute( - path: '/list', - pageBuilder: (context, state) { - final uriStr = Uri.decodeComponent(state.uri.queryParameters['uri'] ?? ''); - final listUri = AtUri.parse(uriStr); - return buildAppRoutePage(context, state, ListDetailScreen(listUri: listUri)); - }, - routes: [ - GoRoute( - path: 'members', - pageBuilder: (context, state) { - final uriStr = Uri.decodeComponent(state.uri.queryParameters['uri'] ?? ''); - final listUri = AtUri.parse(uriStr); - return buildAppRoutePage( - context, - state, - BlocProvider( - create: (_) => - ListBloc(listRepository: context.read())..add(ListRequested(listUri: listUri)), - child: ListMembersScreen(listUri: listUri), - ), - ); - }, - ), - ], - ), - GoRoute( - path: '/profile-context', - pageBuilder: (context, state) { - final did = state.uri.queryParameters['did'] ?? ''; - final handle = state.uri.queryParameters['handle'] ?? ''; - final isOwnProfile = did == context.read(); - final settingsState = context.read().state; - final constellationUrl = settingsState.constellationUrl; - final appViewProvider = AppViewProviders.descriptorForSetting(settingsState.appViewProvider); - final repository = ProfileContextRepository( - bluesky: context.read(), - publicBluesky: Bluesky.anonymous( - service: appViewProvider.publicBaseUrl.host, - getClient: XrpcNetworkInterceptor.wrapGetClient(), - postClient: XrpcNetworkInterceptor.wrapPostClient(), - ), - constellationClient: ConstellationClient(baseUrl: constellationUrl), - onUnauthorized: onUnauthorized, - ); - return buildAppRoutePage( - context, - state, - BlocProvider( - create: (_) => ProfileContextCubit(repository: repository, did: did, isOwnProfile: isOwnProfile), - child: ProfileContextScreen(handle: handle), - ), - ); - }, - ), - StatefulShellRoute.indexedStack( - builder: (context, state, navigationShell) { - if (!context.read().state.isAuthenticated) { - return AppShell(navigationShell: navigationShell, branchNavigatorKeys: _branchNavigatorKeys); - } - - UnreadCountCubit? existingUnreadCubit; - try { - existingUnreadCubit = context.read(); - } catch (_) { - log.d('UnreadCountCubit not found, creating new one'); - } - - if (existingUnreadCubit != null) { - return AppShell(navigationShell: navigationShell, branchNavigatorKeys: _branchNavigatorKeys); - } - - return MultiBlocProvider( - providers: [ - if (existingUnreadCubit == null) - BlocProvider( - create: (_) => UnreadCountCubit( - notificationDomainService: _readNotificationDomainService(context), - notificationRepository: context.read(), - ), - ), - ], - child: AppShell(navigationShell: navigationShell, branchNavigatorKeys: _branchNavigatorKeys), - ); - }, - branches: [ - StatefulShellBranch( - navigatorKey: _homeNavigatorKey, - routes: [ - GoRoute( - path: '/', - pageBuilder: (context, state) => buildAppRoutePage(context, state, const HomeFeedScreen()), - routes: [ - GoRoute( - path: 'feeds', - pageBuilder: (context, state) => buildAppRoutePage(context, state, const FeedManagementScreen()), - ), - GoRoute( - path: 'feed', - pageBuilder: (context, state) { - 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) { - final rawUri = Uri.decodeComponent(encodedUri); - feedUri = AtUri.parse(rawUri); - } - - final actor = encodedActor == null ? null : Uri.decodeComponent(encodedActor); - final rkey = encodedRkey == null ? null : Uri.decodeComponent(encodedRkey); - final provider = encodedProvider == null ? null : Uri.decodeComponent(encodedProvider); - return buildAppRoutePage( - context, - state, - _buildFeedDetailRoute(context, feedUri: feedUri, actor: actor, rkey: rkey, provider: provider), - ); - }, - ), - GoRoute( - path: 'trending', - pageBuilder: (context, state) => buildAppRoutePage(context, state, const TrendingScreen()), - ), - ], - ), - ], - ), - StatefulShellBranch( - navigatorKey: _searchNavigatorKey, - routes: [ - GoRoute( - path: '/search', - pageBuilder: (context, state) => buildAppRoutePage(context, state, const SearchScreen()), - ), - ], - ), - StatefulShellBranch( - navigatorKey: _atExplorerNavigatorKey, - routes: [ - GoRoute( - path: '/at-explorer', - pageBuilder: (context, state) => buildAppRoutePage(context, state, _buildDevToolsRoute(context, state)), - ), - ], - ), - StatefulShellBranch( - navigatorKey: _notificationsNavigatorKey, - routes: [ - GoRoute( - path: '/alerts', - pageBuilder: (context, state) => - buildAppRoutePage(context, state, _buildAlertsRoute(context, const AlertsScreen())), - routes: [ - GoRoute( - path: 'messages', - pageBuilder: (context, state) => buildAppRoutePage( - context, - state, - _buildAlertsRoute(context, const AlertsScreen(initialTab: AlertsTab.messages)), - ), - routes: [ - GoRoute( - path: ':id', - pageBuilder: (context, state) { - final convoId = state.pathParameters['id']!; - final args = state.extra as MessageThreadRouteArgs?; - return buildAppRoutePage( - context, - state, - BlocProvider( - create: (_) => MessageBloc( - convoRepository: context.read(), - currentUserDid: context.read(), - ), - child: MessageThreadScreen(convoId: convoId, title: args?.title ?? 'Conversation'), - ), - ); - }, - ), - ], - ), - GoRoute( - path: 'requests', - pageBuilder: (context, state) => buildAppRoutePage( - context, - state, - _buildAlertsRoute(context, const AlertsScreen(initialTab: AlertsTab.requests)), - ), - ), - ], - ), - ], - ), - StatefulShellBranch( - navigatorKey: _profileNavigatorKey, - routes: [ - GoRoute( - path: '/profile/me', - pageBuilder: (context, state) => buildAppRoutePage(context, state, const ProfileScreen(actor: 'me')), - routes: [ - GoRoute( - path: 'connections', - pageBuilder: (context, state) => buildAppRoutePage( - context, - state, - _buildProfileConnectionsRoute(context, state, context.read()), - ), - ), - GoRoute( - path: 'edit', - pageBuilder: (context, state) => buildAppRoutePage(context, state, const ProfileEditScreen()), - ), - GoRoute( - path: 'search-posts', - pageBuilder: (context, state) { - final actor = context.read(); - return buildAppRoutePage( - context, - state, - BlocProvider( - create: (_) => SearchBloc( - searchRepository: context.read(), - typeaheadRepository: context.read(), - database: context.read(), - accountDid: context.read(), - config: SearchBlocConfig.profileScoped(fixedPostAuthor: actor), - ), - child: SearchScreen( - postsOnlyMode: true, - fixedPostAuthor: actor, - showBackButton: true, - title: 'Search @${actor.startsWith('did:') ? actor : actor}', - showJumpToProfileAction: false, - ), - ), - ); - }, - ), - ], - ), - ], - ), - ], + buildUnauthenticatedShellRoute( + authBloc: authBloc, + homeNavigatorKey: _unauthHomeNavigatorKey, + explorerNavigatorKey: _unauthExplorerNavigatorKey, + settingsNavigatorKey: _unauthSettingsNavigatorKey, + loginNavigatorKey: _unauthLoginNavigatorKey, + contentRouteFactory: _contentRouteFactory, + onUnauthorized: onUnauthorized, + buildDevToolsRoute: _buildDevToolsRoute, + ), + ...buildRootRoutes(rootNavigatorKey: _rootNavigatorKey, onUnauthorized: onUnauthorized), + ...buildMediaRoutes(rootNavigatorKey: _rootNavigatorKey), + buildAuthenticatedShellRoute( + branchNavigatorKeys: _branchNavigatorKeys, + contentRouteFactory: _contentRouteFactory, + buildAlertsRoute: _buildAlertsRoute, + buildDevToolsRoute: _buildDevToolsRoute, ), ], ); - StatefulShellRoute _buildUnauthenticatedShellRoute() { - return StatefulShellRoute.indexedStack( - builder: (context, state, navigationShell) { - if (authBloc.state.isAuthenticated) { - return navigationShell; - } - - return UnauthenticatedShell( - location: state.uri.path, - publicProviderKey: _publicProviderFromState(state), - publicHomeLocation: _publicHomeLocationForState(state), - navigationShell: navigationShell, - ); - }, - branches: [ - StatefulShellBranch( - navigatorKey: _unauthHomeNavigatorKey, - routes: [ - GoRoute(path: '/public', redirect: (_, _) => '/public/bluesky/discover'), - _buildPublicHomeRouteConfig(), - _buildPublicFeedRouteConfig(), - _buildPublicPostRouteConfig(), - _buildPublicTopicRouteConfig(), - _buildPublicProfileRouteConfig(), - ], - ), - StatefulShellBranch( - navigatorKey: _unauthExplorerNavigatorKey, - routes: [ - GoRoute( - path: '/settings/devtools', - pageBuilder: (context, state) => buildAppRoutePage(context, state, _buildDevToolsRoute(context, state)), - ), - ], - ), - StatefulShellBranch( - navigatorKey: _unauthSettingsNavigatorKey, - routes: [ - _buildSettingsRouteConfig(), - GoRoute( - path: '/terms', - pageBuilder: (context, state) => buildAppRoutePage(context, state, const TermsOfServiceScreen()), - ), - GoRoute( - path: '/privacy', - pageBuilder: (context, state) => buildAppRoutePage(context, state, const PrivacyPolicyScreen()), - ), - ], - ), - StatefulShellBranch( - navigatorKey: _unauthLoginNavigatorKey, - routes: [ - GoRoute( - path: '/login', - pageBuilder: (context, state) { - final initialHandle = state.uri.queryParameters['handle']?.trim(); - final hasInitialHandle = initialHandle != null && initialHandle.isNotEmpty; - final autoStartOAuth = state.uri.queryParameters['reauth'] == '1' && hasInitialHandle; - return buildAppRoutePage( - context, - state, - LoginScreen( - initialHandle: hasInitialHandle ? initialHandle : null, - initialProviderKey: state.uri.queryParameters['provider'], - autoStartOAuth: autoStartOAuth, - ), - ); - }, - ), - ], - ), - ], - ); - } - - GoRoute _buildPublicHomeRouteConfig() { - return GoRoute( - path: '/public/:provider/:tab', - redirect: (_, state) { - final routeState = PublicRouteState.parse( - provider: state.pathParameters['provider'], - tab: state.pathParameters['tab'], - ); - if (state.uri.path != routeState.location) { - return routeState.location; - } - return null; - }, - pageBuilder: (context, state) { - final routeState = PublicRouteState.parse( - provider: state.pathParameters['provider'], - tab: state.pathParameters['tab'], - ); - return MaterialPage( - key: const ValueKey('public-home-route'), - child: _buildPublicHomeRoute(context, routeState), - ); - }, - ); - } - - GoRoute _buildSettingsRouteConfig() { - return GoRoute( - path: '/settings', - pageBuilder: (context, state) => buildAppRoutePage(context, state, const SettingsScreen()), - routes: [ - GoRoute( - path: 'moderation', - pageBuilder: (context, state) => buildAppRoutePage(context, state, const ModerationSettingsScreen()), - routes: [ - GoRoute( - path: 'detail', - pageBuilder: (context, state) => - buildAppRoutePage(context, state, LabelerDetailScreen(did: state.uri.queryParameters['did'] ?? '')), - ), - ], - ), - GoRoute( - path: 'account', - pageBuilder: (context, state) => buildAppRoutePage(context, state, const SettingsAccountScreen()), - ), - GoRoute(path: 'about', pageBuilder: (context, state) => buildAppRoutePage(context, state, const AboutScreen())), - GoRoute(path: 'logs', pageBuilder: (context, state) => buildAppRoutePage(context, state, const LogsScreen())), - GoRoute( - path: 'clean-follows', - pageBuilder: (context, state) => buildAppRoutePage( - context, - state, - BlocProvider( - create: (_) => FollowAuditCubit( - repository: FollowAuditRepository( - bluesky: context.read(), - appViewProviderResolver: () => context.read().state.appViewProvider, - onUnauthorized: onUnauthorized, - ), - ownDid: context.read(), - ), - child: const FollowAuditScreen(), - ), - ), - ), - GoRoute( - path: 'video-limits', - pageBuilder: (context, state) => buildAppRoutePage( - context, - state, - BlocProvider( - create: (_) => VideoUploadLimitsCubit(repository: context.read()), - child: const VideoUploadLimitsScreen(), - ), - ), - ), - ], - ); - } - - GoRoute _buildPublicFeedRouteConfig() { - return GoRoute( - path: '/feed', - pageBuilder: (context, state) { - 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) { - final rawUri = Uri.decodeComponent(encodedUri); - feedUri = AtUri.parse(rawUri); - } - - final actor = encodedActor == null ? null : Uri.decodeComponent(encodedActor); - final rkey = encodedRkey == null ? null : Uri.decodeComponent(encodedRkey); - final provider = encodedProvider == null ? null : Uri.decodeComponent(encodedProvider); - return buildAppRoutePage( - context, - state, - _buildFeedDetailRoute(context, feedUri: feedUri, actor: actor, rkey: rkey, provider: provider), - ); - }, - ); - } - - GoRoute _buildPublicPostRouteConfig() { - return GoRoute( - path: '/post', - pageBuilder: (context, state) { - final uri = state.uri.queryParameters['uri'] ?? ''; - final provider = state.uri.queryParameters['provider']; - return buildAppRoutePage( - context, - state, - _buildPostThreadRoute(context, postUri: Uri.decodeComponent(uri), provider: provider), - ); - }, - ); - } - - GoRoute _buildPublicTopicRouteConfig() { - return GoRoute( - path: '/topic', - pageBuilder: (context, state) { - final rawTopic = state.uri.queryParameters['topic'] ?? ''; - final topic = Uri.decodeComponent(rawTopic).trim(); - return buildAppRoutePage( - context, - state, - _buildTopicRoute(context, topic: topic, provider: state.uri.queryParameters['provider']), - ); - }, - ); - } - - GoRoute _buildPublicProfileRouteConfig() { - return GoRoute( - path: r'/profile/:actor(m|[^m][^/]*|m[^e][^/]*|me[^/]+)', - redirect: (_, state) { - final actor = (state.pathParameters['actor'] ?? '').trim().toLowerCase(); - if (actor == 'me') { - return '/profile/me'; - } - return null; - }, - pageBuilder: (context, state) { - final provider = state.uri.queryParameters['provider']; - return buildAppRoutePage( - context, - state, - _buildContextualProfileRoute( - context, - Uri.decodeComponent(state.pathParameters['actor'] ?? ''), - provider: provider, - ), - ); - }, - routes: [ - GoRoute( - path: 'connections', - redirect: (_, state) => authBloc.state.isAuthenticated ? null : _publicProfileLocation(state), - pageBuilder: (context, state) => buildAppRoutePage( - context, - state, - _buildProfileConnectionsRoute(context, state, Uri.decodeComponent(state.pathParameters['actor'] ?? '')), - ), - ), - GoRoute( - path: 'search-posts', - redirect: (_, state) => authBloc.state.isAuthenticated ? null : _publicProfileLocation(state), - pageBuilder: (context, state) { - final actor = Uri.decodeComponent(state.pathParameters['actor'] ?? ''); - return buildAppRoutePage( - context, - state, - BlocProvider( - create: (_) => SearchBloc( - searchRepository: context.read(), - typeaheadRepository: context.read(), - database: context.read(), - accountDid: context.read(), - config: SearchBlocConfig.profileScoped(fixedPostAuthor: actor), - ), - child: SearchScreen( - postsOnlyMode: true, - fixedPostAuthor: actor, - showBackButton: true, - title: 'Search @${actor.startsWith('did:') ? actor : actor}', - showJumpToProfileAction: false, - ), - ), - ); - }, - ), - ], - ); - } - - Widget _buildPublicHomeRoute(BuildContext context, PublicRouteState routeState) { - try { - context.read(); - return PublicHomeScreen(providerKey: routeState.providerKey, contentTab: routeState.contentTab); - } catch (_) { - log.d('PublicContentRepositoryResolver not found, creating public repository resolver for route'); - } - - try { - final repository = context.read(); - return RepositoryProvider.value( - value: SinglePublicContentRepositoryResolver(repository), - child: PublicHomeScreen(providerKey: routeState.providerKey, contentTab: routeState.contentTab), - ); - } catch (_) { - log.d('PublicContentRepository not found, creating public repository resolver 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 SinglePublicContentRepositoryResolver(EmptyPublicContentRepository()), - child: PublicHomeScreen(providerKey: routeState.providerKey, contentTab: routeState.contentTab), - ); - } - - final factory = PublicRepositoryFactory(database: database); - return RepositoryProvider( - create: (_) => FactoryPublicContentRepositoryResolver(factory: factory), - 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) { - final providerKey = PublicRouteState.isSupportedProvider(provider) - ? PublicRouteState.normalizeProvider(provider) - : null; - if (providerKey == null) { - return FeedDetailScreen(feedUri: feedUri, actor: actor, rkey: rkey); - } - return RepositoryProvider( - key: ValueKey('authenticated-feed-repository-$providerKey'), - create: (_) => _authenticatedFeedRepository(context, providerKey), - child: 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 { @@ -952,263 +101,6 @@ class AppRouter { ); } - Widget _buildPostThreadRoute(BuildContext context, {required String postUri, required String? provider}) { - if (authBloc.state.isAuthenticated) { - final providerKey = PublicRouteState.isSupportedProvider(provider) - ? PublicRouteState.normalizeProvider(provider) - : null; - if (providerKey == null) { - return PostThreadScreen(postUri: postUri); - } - return RepositoryProvider( - key: ValueKey('authenticated-post-thread-repository-$providerKey'), - create: (_) => _authenticatedPostThreadRepository(context, providerKey), - child: 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())), - BlocProvider(create: (_) => FeedBloc(feedRepository: context.read())), - ], - child: ProfileScreen(actor: actor, showBackButton: true), - ); - } - - String? _settingsProviderOrNull(BuildContext context) { - try { - return context.read().state.appViewProvider; - } catch (_) { - log.d('SettingsCubit not found for public provider fallback'); - return null; - } - } - - String _publicProfileLocation(GoRouterState state) { - final actor = Uri.decodeComponent(state.pathParameters['actor'] ?? '').trim(); - final encodedActor = Uri.encodeComponent(actor); - final provider = state.uri.queryParameters['provider']; - final providerQuery = provider == null ? '' : '?provider=${Uri.encodeQueryComponent(provider)}'; - return '/profile/$encodedActor$providerQuery'; - } - - String? _publicHomeLocationFromQuery(GoRouterState state) { - final rawLocation = state.uri.queryParameters['publicHome']?.trim(); - if (rawLocation == null || rawLocation.isEmpty) { - return null; - } - - final uri = Uri.tryParse(rawLocation); - final segments = uri?.pathSegments; - if (segments == null || segments.length != 3 || segments.first != 'public') { - return null; - } - - final routeState = PublicRouteState.parse(provider: segments[1], tab: segments[2]); - return routeState.location; - } - - String? _publicHomeLocationForState(GoRouterState state) { - final queryHome = _publicHomeLocationFromQuery(state); - if (queryHome != null) { - return queryHome; - } - - final segments = state.uri.pathSegments; - if (segments.length == 3 && segments.first == 'public') { - return PublicRouteState.parse(provider: segments[1], tab: segments[2]).location; - } - - final provider = _publicProviderFromState(state); - if (provider == null) { - return null; - } - - return PublicRouteState(providerKey: provider, contentTab: PublicContentTab.discover).location; - } - - String? _publicProviderFromState(GoRouterState state) { - final queryProvider = state.uri.queryParameters['provider']; - if (PublicRouteState.isSupportedProvider(queryProvider)) { - return PublicRouteState.normalizeProvider(queryProvider); - } - - final segments = state.uri.pathSegments; - if (segments.length >= 2 && segments.first == 'public' && PublicRouteState.isSupportedProvider(segments[1])) { - return PublicRouteState.normalizeProvider(segments[1]); - } - - return null; - } - - Widget _buildTopicRoute(BuildContext context, {required String topic, required String? provider}) { - final providerContext = PublicProviderContext.fromRoute( - queryProvider: provider, - fallbackProvider: _settingsProviderOrNull(context), - ); - - Widget screenWithRepository(SearchRepository repository, {String? publicProviderKey}) { - return BlocProvider( - key: ValueKey('topic-$topic-${publicProviderKey ?? 'authenticated'}'), - create: (_) => TopicCubit(searchRepository: repository, topic: topic), - child: TopicScreen(topic: topic, publicProviderKey: publicProviderKey), - ); - } - - if (authBloc.state.isAuthenticated) { - if (PublicRouteState.isSupportedProvider(provider)) { - return screenWithRepository(_authenticatedSearchRepository(context, providerContext.providerKey)); - } - return screenWithRepository(context.read()); - } - - try { - return screenWithRepository(context.read(), publicProviderKey: providerContext.providerKey); - } catch (_) { - log.d('SearchRepository not found for public topic route'); - } - - final database = context.read(); - final factory = PublicRepositoryFactory(database: database); - return screenWithRepository( - factory.searchRepository(providerContext.providerKey), - publicProviderKey: providerContext.providerKey, - ); - } - - FeedRepository _authenticatedFeedRepository(BuildContext context, String providerKey) { - final settingsCubit = context.read(); - return FeedRepository( - bluesky: context.read(), - database: context.read(), - accountDid: context.read(), - moderationService: _moderationServiceOrNull(context), - appViewProvider: providerKey, - crossProviderFallbackEnabledResolver: () => settingsCubit.state.crossProviderFallbackEnabled, - routingEpoch: settingsCubit.state.routingEpoch, - routingEpochResolver: () => settingsCubit.state.routingEpoch, - onUnauthorized: onUnauthorized, - ); - } - - SearchRepository _authenticatedSearchRepository(BuildContext context, String providerKey) { - final settingsCubit = context.read(); - return SearchRepository( - bluesky: context.read(), - moderationService: _moderationServiceOrNull(context), - appViewProvider: providerKey, - crossProviderFallbackEnabledResolver: () => settingsCubit.state.crossProviderFallbackEnabled, - routingEpoch: settingsCubit.state.routingEpoch, - routingEpochResolver: () => settingsCubit.state.routingEpoch, - onUnauthorized: onUnauthorized, - ); - } - - PostThreadRepository _authenticatedPostThreadRepository(BuildContext context, String providerKey) { - return PostThreadRepository( - bluesky: context.read(), - database: context.read(), - accountDid: context.read(), - moderationService: _moderationServiceOrNull(context), - appViewProvider: providerKey, - onUnauthorized: onUnauthorized, - ); - } - - ModerationService? _moderationServiceOrNull(BuildContext context) { - try { - return context.read(); - } catch (error, stackTrace) { - log.d('ModerationService not found for provider-scoped route', error: error, stackTrace: stackTrace); - return null; - } - } - - Widget _buildProfileConnectionsRoute(BuildContext context, GoRouterState state, String actor) { - final normalizedActor = actor.trim(); - final initialTab = ProfileConnectionsTabX.fromRouteValue(state.uri.queryParameters['tab']); - final constellationUrl = context.read().state.constellationUrl; - final handle = normalizedActor.startsWith('did:') || normalizedActor == context.read() - ? null - : normalizedActor; - return BlocProvider( - create: (_) => ProfileConnectionsCubit( - repository: context.read(), - actor: normalizedActor, - constellationClient: ConstellationClient(baseUrl: constellationUrl), - ), - child: ProfileConnectionsScreen(actor: normalizedActor, handle: handle, initialTab: initialTab), - ); - } - NotificationDomainService? _readNotificationDomainService(BuildContext context) { try { return context.read(); diff --git a/lib/core/router/content_route_factory.dart b/lib/core/router/content_route_factory.dart new file mode 100644 index 0000000..2bcd664 --- /dev/null +++ b/lib/core/router/content_route_factory.dart @@ -0,0 +1,365 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/core/network/constellation_client.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.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/post_thread_screen.dart'; +import 'package:lazurite/features/moderation/data/moderation_service.dart'; +import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; +import 'package:lazurite/features/profile/cubit/profile_connections_cubit.dart'; +import 'package:lazurite/features/profile/data/profile_repository.dart'; +import 'package:lazurite/features/profile/presentation/profile_connections_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/search/bloc/search_bloc.dart'; +import 'package:lazurite/features/search/cubit/topic_cubit.dart'; +import 'package:lazurite/features/search/data/search_repository.dart'; +import 'package:lazurite/features/search/presentation/search_screen.dart'; +import 'package:lazurite/features/search/presentation/topic_screen.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; + +/// Builds content screens with the repositories and BLoCs required by routes. +/// +/// Route group files own URL shapes and parameter decoding. This factory owns +/// the next step in the flow: turning decoded route data into feature widgets, +/// choosing authenticated vs public repositories, and applying provider fallback +/// behavior when a route can render in public read-only mode. +class ContentRouteFactory { + const ContentRouteFactory({required this.authBloc, required this.onUnauthorized}); + + final AuthBloc authBloc; + final Future Function()? onUnauthorized; + + /// Builds the public home tab and supplies a public-content resolver. + Widget publicHome(BuildContext context, PublicRouteState routeState) { + try { + context.read(); + return PublicHomeScreen(providerKey: routeState.providerKey, contentTab: routeState.contentTab); + } catch (_) { + log.d('PublicContentRepositoryResolver not found, creating public repository resolver for route'); + } + + try { + final repository = context.read(); + return RepositoryProvider.value( + value: SinglePublicContentRepositoryResolver(repository), + child: PublicHomeScreen(providerKey: routeState.providerKey, contentTab: routeState.contentTab), + ); + } catch (_) { + log.d('PublicContentRepository not found, creating public repository resolver 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 SinglePublicContentRepositoryResolver(EmptyPublicContentRepository()), + child: PublicHomeScreen(providerKey: routeState.providerKey, contentTab: routeState.contentTab), + ); + } + + final factory = PublicRepositoryFactory(database: database); + return RepositoryProvider( + create: (_) => FactoryPublicContentRepositoryResolver(factory: factory), + child: PublicHomeScreen(providerKey: routeState.providerKey, contentTab: routeState.contentTab), + ); + } + + /// Builds a feed detail route using authenticated or public provider routing. + Widget feedDetail( + BuildContext context, { + required AtUri? feedUri, + required String? actor, + required String? rkey, + required String? provider, + }) { + if (authBloc.state.isAuthenticated) { + final providerKey = PublicRouteState.isSupportedProvider(provider) + ? PublicRouteState.normalizeProvider(provider) + : null; + if (providerKey == null) { + return FeedDetailScreen(feedUri: feedUri, actor: actor, rkey: rkey); + } + return RepositoryProvider( + key: ValueKey('authenticated-feed-repository-$providerKey'), + create: (_) => _authenticatedFeedRepository(context, providerKey), + child: 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; + } + + /// Builds a post thread route using authenticated or public provider routing. + Widget postThread(BuildContext context, {required String postUri, required String? provider}) { + if (authBloc.state.isAuthenticated) { + final providerKey = PublicRouteState.isSupportedProvider(provider) + ? PublicRouteState.normalizeProvider(provider) + : null; + if (providerKey == null) { + return PostThreadScreen(postUri: postUri); + } + return RepositoryProvider( + key: ValueKey('authenticated-post-thread-repository-$providerKey'), + create: (_) => _authenticatedPostThreadRepository(context, providerKey), + child: 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; + } + + /// Builds a profile route and wires the matching profile/feed dependencies. + Widget contextualProfile(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())), + BlocProvider(create: (_) => FeedBloc(feedRepository: context.read())), + ], + child: ProfileScreen(actor: actor, showBackButton: true), + ); + } + + /// Builds a topic route using authenticated search or public provider search. + Widget topic(BuildContext context, {required String topic, required String? provider}) { + final providerContext = PublicProviderContext.fromRoute( + queryProvider: provider, + fallbackProvider: _settingsProviderOrNull(context), + ); + + Widget screenWithRepository(SearchRepository repository, {String? publicProviderKey}) => BlocProvider( + key: ValueKey('topic-$topic-${publicProviderKey ?? 'authenticated'}'), + create: (_) => TopicCubit(searchRepository: repository, topic: topic), + child: TopicScreen(topic: topic, publicProviderKey: publicProviderKey), + ); + + if (authBloc.state.isAuthenticated) { + if (PublicRouteState.isSupportedProvider(provider)) { + return screenWithRepository(_authenticatedSearchRepository(context, providerContext.providerKey)); + } + return screenWithRepository(context.read()); + } + + try { + return screenWithRepository(context.read(), publicProviderKey: providerContext.providerKey); + } catch (_) { + log.d('SearchRepository not found for public topic route'); + } + + final database = context.read(); + final factory = PublicRepositoryFactory(database: database); + return screenWithRepository( + factory.searchRepository(providerContext.providerKey), + publicProviderKey: providerContext.providerKey, + ); + } + + /// Builds authenticated-only profile-scoped post search. + Widget profileSearch(BuildContext context, String actor) { + return BlocProvider( + create: (_) => SearchBloc( + searchRepository: context.read(), + typeaheadRepository: context.read(), + database: context.read(), + accountDid: context.read(), + config: SearchBlocConfig.profileScoped(fixedPostAuthor: actor), + ), + child: SearchScreen( + postsOnlyMode: true, + fixedPostAuthor: actor, + showBackButton: true, + title: 'Search @${actor.startsWith('did:') ? actor : actor}', + showJumpToProfileAction: false, + ), + ); + } + + /// Builds authenticated-only profile relationship routes. + Widget profileConnections(BuildContext context, GoRouterState state, String actor) { + final normalizedActor = actor.trim(); + final initialTab = ProfileConnectionsTabX.fromRouteValue(state.uri.queryParameters['tab']); + final constellationUrl = context.read().state.constellationUrl; + final handle = normalizedActor.startsWith('did:') || normalizedActor == context.read() + ? null + : normalizedActor; + return BlocProvider( + create: (_) => ProfileConnectionsCubit( + repository: context.read(), + actor: normalizedActor, + constellationClient: ConstellationClient(baseUrl: constellationUrl), + ), + child: ProfileConnectionsScreen(actor: normalizedActor, handle: handle, initialTab: initialTab), + ); + } + + String? _settingsProviderOrNull(BuildContext context) { + try { + return context.read().state.appViewProvider; + } catch (_) { + log.d('SettingsCubit not found for public provider fallback'); + return null; + } + } + + FeedRepository _authenticatedFeedRepository(BuildContext context, String providerKey) { + final settingsCubit = context.read(); + return FeedRepository( + bluesky: context.read(), + database: context.read(), + accountDid: context.read(), + moderationService: _moderationServiceOrNull(context), + appViewProvider: providerKey, + crossProviderFallbackEnabledResolver: () => settingsCubit.state.crossProviderFallbackEnabled, + routingEpoch: settingsCubit.state.routingEpoch, + routingEpochResolver: () => settingsCubit.state.routingEpoch, + onUnauthorized: onUnauthorized, + ); + } + + SearchRepository _authenticatedSearchRepository(BuildContext context, String providerKey) { + final settingsCubit = context.read(); + return SearchRepository( + bluesky: context.read(), + moderationService: _moderationServiceOrNull(context), + appViewProvider: providerKey, + crossProviderFallbackEnabledResolver: () => settingsCubit.state.crossProviderFallbackEnabled, + routingEpoch: settingsCubit.state.routingEpoch, + routingEpochResolver: () => settingsCubit.state.routingEpoch, + onUnauthorized: onUnauthorized, + ); + } + + PostThreadRepository _authenticatedPostThreadRepository(BuildContext context, String providerKey) => + PostThreadRepository( + bluesky: context.read(), + database: context.read(), + accountDid: context.read(), + moderationService: _moderationServiceOrNull(context), + appViewProvider: providerKey, + onUnauthorized: onUnauthorized, + ); + + ModerationService? _moderationServiceOrNull(BuildContext context) { + try { + return context.read(); + } catch (error, stackTrace) { + log.d('ModerationService not found for provider-scoped route', error: error, stackTrace: stackTrace); + return null; + } + } +} diff --git a/lib/core/router/route_query.dart b/lib/core/router/route_query.dart new file mode 100644 index 0000000..479a334 --- /dev/null +++ b/lib/core/router/route_query.dart @@ -0,0 +1,33 @@ +import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; + +/// Convenience reader for decoded route query and path values. +/// +/// Route files should use this for repeated URL decoding and typed parsing so +/// screen builders receive normalized values instead of raw query strings. +class RouteQuery { + const RouteQuery(this.state); + + final GoRouterState state; + + /// Returns a decoded query value, or `null` when the value is absent. + String? decoded(String key) { + final value = state.uri.queryParameters[key]; + return value == null ? null : Uri.decodeComponent(value); + } + + /// Returns a decoded query value trimmed, or an empty string when absent. + String decodedOrEmpty(String key) => decoded(key)?.trim() ?? ''; + + /// Returns a decoded path parameter, or an empty string when absent. + String decodedPathOrEmpty(String key) => Uri.decodeComponent(state.pathParameters[key] ?? ''); + + /// Parses a decoded AT-URI query value, or returns `null` when absent/blank. + AtUri? atUri(String key) { + final value = decoded(key); + if (value == null || value.trim().isEmpty) { + return null; + } + return AtUri.parse(value); + } +} diff --git a/lib/core/router/routes/authenticated_shell_routes.dart b/lib/core/router/routes/authenticated_shell_routes.dart new file mode 100644 index 0000000..f1af899 --- /dev/null +++ b/lib/core/router/routes/authenticated_shell_routes.dart @@ -0,0 +1,171 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/router/app_route_page.dart'; +import 'package:lazurite/core/router/app_route_paths.dart'; +import 'package:lazurite/core/router/app_shell.dart'; +import 'package:lazurite/core/router/content_route_factory.dart'; +import 'package:lazurite/core/router/route_query.dart'; +import 'package:lazurite/features/alerts/presentation/alerts_screen.dart'; +import 'package:lazurite/features/feed/presentation/feed_management_screen.dart'; +import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; +import 'package:lazurite/features/feed/presentation/trending_screen.dart'; +import 'package:lazurite/features/messages/bloc/message_bloc.dart'; +import 'package:lazurite/features/messages/data/convo_repository.dart'; +import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; +import 'package:lazurite/features/messages/presentation/message_thread_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/search/presentation/search_screen.dart'; + +/// Builds the authenticated tab shell and its branch routes. +/// +/// The shell owns branch navigator wiring and top-level tab structure. Feature +/// dependency wiring flows through [contentRouteFactory] or the small callbacks +/// for cross-cutting wrappers such as alerts and developer tools. +StatefulShellRoute buildAuthenticatedShellRoute({ + required List> branchNavigatorKeys, + required ContentRouteFactory contentRouteFactory, + required Widget Function(BuildContext context, Widget child) buildAlertsRoute, + required Widget Function(BuildContext context, GoRouterState state) buildDevToolsRoute, +}) { + return StatefulShellRoute.indexedStack( + builder: (context, state, navigationShell) { + return AppShell(navigationShell: navigationShell, branchNavigatorKeys: branchNavigatorKeys); + }, + branches: [ + StatefulShellBranch( + navigatorKey: branchNavigatorKeys[0], + routes: [ + GoRoute( + path: AppRoutePath.home.path, + pageBuilder: (context, state) => buildAppRoutePage(context, state, const HomeFeedScreen()), + routes: [ + GoRoute( + path: 'feeds', + pageBuilder: (context, state) => buildAppRoutePage(context, state, const FeedManagementScreen()), + ), + GoRoute( + path: AppRoutePath.feed.childPath, + pageBuilder: (context, state) { + final query = RouteQuery(state); + return buildAppRoutePage( + context, + state, + contentRouteFactory.feedDetail( + context, + feedUri: query.atUri('uri'), + actor: query.decoded('actor'), + rkey: query.decoded('rkey'), + provider: query.decoded('provider'), + ), + ); + }, + ), + GoRoute( + path: 'trending', + pageBuilder: (context, state) => buildAppRoutePage(context, state, const TrendingScreen()), + ), + ], + ), + ], + ), + StatefulShellBranch( + navigatorKey: branchNavigatorKeys[1], + routes: [ + GoRoute( + path: AppRoutePath.search.path, + pageBuilder: (context, state) => buildAppRoutePage(context, state, const SearchScreen()), + ), + ], + ), + StatefulShellBranch( + navigatorKey: branchNavigatorKeys[2], + routes: [ + GoRoute( + path: AppRoutePath.atExplorer.path, + pageBuilder: (context, state) => buildAppRoutePage(context, state, buildDevToolsRoute(context, state)), + ), + ], + ), + StatefulShellBranch( + navigatorKey: branchNavigatorKeys[3], + routes: [ + GoRoute( + path: AppRoutePath.alerts.path, + pageBuilder: (context, state) => + buildAppRoutePage(context, state, buildAlertsRoute(context, const AlertsScreen())), + routes: [ + GoRoute( + path: 'messages', + pageBuilder: (context, state) => buildAppRoutePage( + context, + state, + buildAlertsRoute(context, const AlertsScreen(initialTab: AlertsTab.messages)), + ), + routes: [ + GoRoute( + path: ':id', + pageBuilder: (context, state) { + final convoId = state.pathParameters['id']!; + final args = state.extra as MessageThreadRouteArgs?; + return buildAppRoutePage( + context, + state, + BlocProvider( + create: (_) => MessageBloc( + convoRepository: context.read(), + currentUserDid: context.read(), + ), + child: MessageThreadScreen(convoId: convoId, title: args?.title ?? 'Conversation'), + ), + ); + }, + ), + ], + ), + GoRoute( + path: 'requests', + pageBuilder: (context, state) => buildAppRoutePage( + context, + state, + buildAlertsRoute(context, const AlertsScreen(initialTab: AlertsTab.requests)), + ), + ), + ], + ), + ], + ), + StatefulShellBranch( + navigatorKey: branchNavigatorKeys[4], + routes: [ + GoRoute( + path: AppRoutePath.profileMe.path, + pageBuilder: (context, state) => buildAppRoutePage(context, state, const ProfileScreen(actor: 'me')), + routes: [ + GoRoute( + path: 'connections', + pageBuilder: (context, state) => buildAppRoutePage( + context, + state, + contentRouteFactory.profileConnections(context, state, context.read()), + ), + ), + GoRoute( + path: 'edit', + pageBuilder: (context, state) => buildAppRoutePage(context, state, const ProfileEditScreen()), + ), + GoRoute( + path: 'search-posts', + pageBuilder: (context, state) { + final actor = context.read(); + return buildAppRoutePage(context, state, contentRouteFactory.profileSearch(context, actor)); + }, + ), + ], + ), + ], + ), + ], + ); +} diff --git a/lib/core/router/routes/media_routes.dart b/lib/core/router/routes/media_routes.dart new file mode 100644 index 0000000..80c44c0 --- /dev/null +++ b/lib/core/router/routes/media_routes.dart @@ -0,0 +1,47 @@ +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/core/router/app_route_page.dart'; +import 'package:lazurite/features/feed/presentation/media/image_viewer_route_args.dart'; +import 'package:lazurite/features/feed/presentation/media/image_viewer_screen.dart'; +import 'package:lazurite/features/feed/presentation/media/video_player_route_args.dart'; +import 'package:lazurite/features/feed/presentation/media/video_player_screen.dart'; + +/// Builds full-screen media viewer routes that sit above the app shells. +/// +/// These routes intentionally parse arguments from either [GoRouter] `extra` +/// or a URL payload so image/video viewers survive route restoration. +List buildMediaRoutes({required GlobalKey rootNavigatorKey}) { + return [ + GoRoute( + path: '/images', + parentNavigatorKey: rootNavigatorKey, + redirect: (context, state) { + if (ImageViewerRouteArgs.tryParse(state.extra, state.uri) != null) { + return null; + } + log.w('Image viewer route opened without valid route arguments; redirecting home. uri=${state.uri}'); + return '/'; + }, + pageBuilder: (context, state) { + final args = ImageViewerRouteArgs.tryParse(state.extra, state.uri)!; + return buildAppRoutePage(context, state, ImageViewerScreen(args: args)); + }, + ), + GoRoute( + path: '/video', + parentNavigatorKey: rootNavigatorKey, + redirect: (context, state) { + if (VideoPlayerRouteArgs.tryParse(state.extra, state.uri) != null) { + return null; + } + log.w('Video player route opened without valid route arguments; redirecting home. uri=${state.uri}'); + return '/'; + }, + pageBuilder: (context, state) { + final args = VideoPlayerRouteArgs.tryParse(state.extra, state.uri)!; + return buildAppRoutePage(context, state, VideoPlayerScreen(args: args)); + }, + ), + ]; +} diff --git a/lib/core/router/routes/public_routes.dart b/lib/core/router/routes/public_routes.dart new file mode 100644 index 0000000..551a86b --- /dev/null +++ b/lib/core/router/routes/public_routes.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; +import 'package:lazurite/core/router/app_route_page.dart'; +import 'package:lazurite/core/router/app_route_paths.dart'; +import 'package:lazurite/core/router/route_query.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/public/presentation/public_route_state.dart'; + +/// Builds public content routes used by the unauthenticated shell. +/// +/// This file owns the route shapes, URL normalization, and query/path decoding +/// for public browsing. The actual screen and repository wiring still flows +/// through callbacks supplied by `AppRouter`; that keeps this extraction small +/// while preserving existing provider fallback behavior during the larger router +/// refactor. +List buildPublicRoutes({ + required AuthBloc authBloc, + required PublicHomeRouteBuilder buildPublicHomeRoute, + required FeedDetailRouteBuilder buildFeedDetailRoute, + required PostThreadRouteBuilder buildPostThreadRoute, + required TopicRouteBuilder buildTopicRoute, + required ContextualProfileRouteBuilder buildContextualProfileRoute, + required ProfileConnectionsRouteBuilder buildProfileConnectionsRoute, + required ProfileSearchRouteBuilder buildProfileSearchRoute, + required PublicProfileLocationBuilder publicProfileLocation, +}) { + return [ + GoRoute(path: AppRoutePath.public.path, redirect: (_, _) => '/public/bluesky/discover'), + GoRoute( + path: AppRoutePath.publicProviderTab.path, + redirect: (_, state) { + final routeState = PublicRouteState.parse( + provider: state.pathParameters['provider'], + tab: state.pathParameters['tab'], + ); + if (state.uri.path != routeState.location) { + return routeState.location; + } + return null; + }, + pageBuilder: (context, state) { + final routeState = PublicRouteState.parse( + provider: state.pathParameters['provider'], + tab: state.pathParameters['tab'], + ); + return MaterialPage( + key: const ValueKey('public-home-route'), + child: buildPublicHomeRoute(context, routeState), + ); + }, + ), + GoRoute( + path: AppRoutePath.feed.path, + pageBuilder: (context, state) { + final query = RouteQuery(state); + final feedUri = query.atUri('uri'); + final actor = query.decoded('actor'); + final rkey = query.decoded('rkey'); + final provider = query.decoded('provider'); + return buildAppRoutePage( + context, + state, + buildFeedDetailRoute(context, feedUri: feedUri, actor: actor, rkey: rkey, provider: provider), + ); + }, + ), + GoRoute( + path: AppRoutePath.post.path, + pageBuilder: (context, state) { + final query = RouteQuery(state); + final uri = query.decoded('uri') ?? ''; + final provider = state.uri.queryParameters['provider']; + return buildAppRoutePage( + context, + state, + buildPostThreadRoute(context, postUri: uri, provider: provider), + ); + }, + ), + GoRoute( + path: AppRoutePath.topic.path, + pageBuilder: (context, state) { + final query = RouteQuery(state); + final topic = query.decodedOrEmpty('topic'); + return buildAppRoutePage( + context, + state, + buildTopicRoute(context, topic: topic, provider: state.uri.queryParameters['provider']), + ); + }, + ), + GoRoute( + path: AppRoutePath.publicProfile.path, + redirect: (_, state) { + final actor = (state.pathParameters['actor'] ?? '').trim().toLowerCase(); + if (actor == 'me') { + return AppRoutePath.profileMe.path; + } + return null; + }, + pageBuilder: (context, state) { + final provider = state.uri.queryParameters['provider']; + return buildAppRoutePage( + context, + state, + buildContextualProfileRoute( + context, + RouteQuery(state).decodedPathOrEmpty('actor'), + provider: provider, + ), + ); + }, + routes: [ + GoRoute( + path: 'connections', + redirect: (_, state) => authBloc.state.isAuthenticated ? null : publicProfileLocation(state), + pageBuilder: (context, state) => buildAppRoutePage( + context, + state, + buildProfileConnectionsRoute(context, state, RouteQuery(state).decodedPathOrEmpty('actor')), + ), + ), + GoRoute( + path: 'search-posts', + redirect: (_, state) => authBloc.state.isAuthenticated ? null : publicProfileLocation(state), + pageBuilder: (context, state) { + final actor = RouteQuery(state).decodedPathOrEmpty('actor'); + return buildAppRoutePage( + context, + state, + buildProfileSearchRoute(context, actor), + ); + }, + ), + ], + ), + ]; +} + +/// Builds the public home screen once the provider/tab route state is canonical. +typedef PublicHomeRouteBuilder = Widget Function(BuildContext context, PublicRouteState routeState); + +/// Builds a feed detail screen with provider-aware repository wiring. +typedef FeedDetailRouteBuilder = Widget Function( + BuildContext context, { + required AtUri? feedUri, + required String? actor, + required String? rkey, + required String? provider, +}); + +/// Builds a post thread screen with provider-aware repository wiring. +typedef PostThreadRouteBuilder = Widget Function(BuildContext context, {required String postUri, required String? provider}); + +/// Builds a topic timeline with authenticated or public search dependencies. +typedef TopicRouteBuilder = Widget Function(BuildContext context, {required String topic, required String? provider}); + +/// Builds a profile route, optionally scoped to a public provider. +typedef ContextualProfileRouteBuilder = Widget Function(BuildContext context, String actor, {String? provider}); + +/// Builds profile relationship routes that remain authenticated-only. +typedef ProfileConnectionsRouteBuilder = Widget Function(BuildContext context, GoRouterState state, String actor); + +/// Builds authenticated-only profile-scoped post search. +typedef ProfileSearchRouteBuilder = Widget Function(BuildContext context, String actor); + +/// Returns the public profile route to use when blocking authenticated-only child routes. +typedef PublicProfileLocationBuilder = String Function(GoRouterState state); diff --git a/lib/core/router/routes/root_routes.dart b/lib/core/router/routes/root_routes.dart new file mode 100644 index 0000000..ed4bdeb --- /dev/null +++ b/lib/core/router/routes/root_routes.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/core/network/constellation_client.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; +import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; +import 'package:lazurite/core/router/app_route_page.dart'; +import 'package:lazurite/core/router/app_route_paths.dart'; +import 'package:lazurite/core/router/route_query.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; +import 'package:lazurite/features/auth/presentation/oauth_callback_screen.dart'; +import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; +import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; +import 'package:lazurite/features/compose/presentation/compose_screen.dart'; +import 'package:lazurite/features/feed/presentation/saved_posts_screen.dart'; +import 'package:lazurite/features/lists/bloc/list_bloc.dart'; +import 'package:lazurite/features/lists/data/list_repository.dart'; +import 'package:lazurite/features/lists/presentation/list_detail_screen.dart'; +import 'package:lazurite/features/lists/presentation/list_members_screen.dart'; +import 'package:lazurite/features/lists/presentation/my_lists_screen.dart'; +import 'package:lazurite/features/profile/cubit/profile_context_cubit.dart'; +import 'package:lazurite/features/profile/data/profile_context_repository.dart'; +import 'package:lazurite/features/profile/presentation/profile_context_screen.dart'; +import 'package:lazurite/features/search/cubit/hashtag_cubit.dart'; +import 'package:lazurite/features/search/data/hashtag_utils.dart'; +import 'package:lazurite/features/search/data/search_repository.dart'; +import 'package:lazurite/features/search/presentation/hashtag_screen.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/starter_packs/bloc/starter_pack_bloc.dart'; +import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; +import 'package:lazurite/features/starter_packs/presentation/actor_starter_packs_screen.dart'; +import 'package:lazurite/features/starter_packs/presentation/create_edit_starter_pack_screen.dart'; +import 'package:lazurite/features/starter_packs/presentation/starter_pack_detail_screen.dart'; + +/// Builds root-level routes that sit outside the tabbed app shells. +/// +/// These routes are opened as overlays or global destinations from many parts +/// of the app. They still read repositories/BLoCs from the route context, but +/// the URL shapes and query parsing live here instead of in `AppRouter`. +List buildRootRoutes({ + required GlobalKey rootNavigatorKey, + required Future Function()? onUnauthorized, +}) { + return [ + GoRoute( + path: AppRoutePath.oauthCallback.path, + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (context, state) => buildAppRoutePage(context, state, OAuthCallbackScreen(callbackUri: state.uri)), + ), + GoRoute( + path: AppRoutePath.oauthCallbackCompatibility.path, + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (context, state) => buildAppRoutePage(context, state, OAuthCallbackScreen(callbackUri: state.uri)), + ), + GoRoute(path: AppRoutePath.notifications.path, redirect: (_, _) => AppRoutePath.alerts.path), + GoRoute(path: AppRoutePath.messages.path, redirect: (_, _) => '${AppRoutePath.alerts.path}/messages'), + GoRoute( + path: AppRoutePath.compose.path, + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (context, state) { + final args = ComposeRouteArgs.parseExtra(state.extra); + return buildAppRoutePage( + context, + state, + BlocProvider( + create: (_) => ComposeBloc( + composeRepository: ComposeRepository(bluesky: context.read(), onUnauthorized: onUnauthorized), + database: context.read(), + accountDid: context.read(), + ), + child: ComposeScreen.fromArgs(args), + ), + ); + }, + ), + GoRoute( + path: AppRoutePath.hashtag.path, + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (context, state) { + final normalizedTag = normalizeHashtag(state.uri.queryParameters['tag'] ?? ''); + return buildAppRoutePage( + context, + state, + BlocProvider( + key: ValueKey('hashtag-$normalizedTag'), + create: (_) => HashtagCubit(searchRepository: context.read(), tag: normalizedTag), + child: HashtagScreen(tag: normalizedTag), + ), + ); + }, + ), + GoRoute( + path: AppRoutePath.bookmarks.path, + pageBuilder: (context, state) => buildAppRoutePage( + context, + state, + SavedPostsScreen(accountDid: context.read(), initialTab: SavedPostsInitialTab.bookmarks), + ), + ), + GoRoute( + path: AppRoutePath.liked.path, + pageBuilder: (context, state) => buildAppRoutePage( + context, + state, + SavedPostsScreen(accountDid: context.read(), initialTab: SavedPostsInitialTab.liked), + ), + ), + GoRoute( + path: AppRoutePath.lists.path, + pageBuilder: (context, state) => buildAppRoutePage(context, state, const MyListsScreen()), + ), + GoRoute( + path: AppRoutePath.createStarterPack.path, + pageBuilder: (context, state) { + return buildAppRoutePage( + context, + state, + BlocProvider( + create: (_) => StarterPackBloc(starterPackRepository: context.read()), + child: CreateStarterPackScreen(userDid: context.read()), + ), + ); + }, + ), + GoRoute( + path: AppRoutePath.starterPack.path, + pageBuilder: (context, state) { + final packUri = AtUri.parse(RouteQuery(state).decodedOrEmpty('uri')); + return buildAppRoutePage(context, state, StarterPackDetailScreen(packUri: packUri)); + }, + ), + GoRoute( + path: AppRoutePath.starterPacks.path, + pageBuilder: (context, state) { + final actor = state.uri.queryParameters['actor'] ?? ''; + return buildAppRoutePage(context, state, ActorStarterPacksScreen(actor: actor)); + }, + ), + GoRoute( + path: AppRoutePath.list.path, + pageBuilder: (context, state) { + final listUri = AtUri.parse(RouteQuery(state).decodedOrEmpty('uri')); + return buildAppRoutePage(context, state, ListDetailScreen(listUri: listUri)); + }, + routes: [ + GoRoute( + path: 'members', + pageBuilder: (context, state) { + final listUri = AtUri.parse(RouteQuery(state).decodedOrEmpty('uri')); + return buildAppRoutePage( + context, + state, + BlocProvider( + create: (_) => + ListBloc(listRepository: context.read())..add(ListRequested(listUri: listUri)), + child: ListMembersScreen(listUri: listUri), + ), + ); + }, + ), + ], + ), + GoRoute( + path: AppRoutePath.profileContext.path, + pageBuilder: (context, state) { + final did = state.uri.queryParameters['did'] ?? ''; + final handle = state.uri.queryParameters['handle'] ?? ''; + final isOwnProfile = did == context.read(); + final settingsState = context.read().state; + final constellationUrl = settingsState.constellationUrl; + final appViewProvider = AppViewProviders.descriptorForSetting(settingsState.appViewProvider); + final repository = ProfileContextRepository( + bluesky: context.read(), + publicBluesky: Bluesky.anonymous( + service: appViewProvider.publicBaseUrl.host, + getClient: XrpcNetworkInterceptor.wrapGetClient(), + postClient: XrpcNetworkInterceptor.wrapPostClient(), + ), + constellationClient: ConstellationClient(baseUrl: constellationUrl), + onUnauthorized: onUnauthorized, + ); + return buildAppRoutePage( + context, + state, + BlocProvider( + create: (_) => ProfileContextCubit(repository: repository, did: did, isOwnProfile: isOwnProfile), + child: ProfileContextScreen(handle: handle), + ), + ); + }, + ), + ]; +} diff --git a/lib/core/router/routes/settings_routes.dart b/lib/core/router/routes/settings_routes.dart new file mode 100644 index 0000000..565cbcd --- /dev/null +++ b/lib/core/router/routes/settings_routes.dart @@ -0,0 +1,99 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; +import 'package:lazurite/core/router/app_route_page.dart'; +import 'package:lazurite/core/router/app_route_paths.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; +import 'package:lazurite/features/moderation/presentation/screens/labeler_detail_screen.dart'; +import 'package:lazurite/features/logs/presentation/logs_screen.dart'; +import 'package:lazurite/features/moderation/presentation/screens/moderation_settings_screen.dart'; +import 'package:lazurite/features/profile/cubit/follow_audit_cubit.dart'; +import 'package:lazurite/features/profile/data/follow_audit_repository.dart'; +import 'package:lazurite/features/profile/presentation/follow_audit_screen.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/settings/cubit/video_upload_limits_cubit.dart'; +import 'package:lazurite/features/settings/data/video_repository.dart'; +import 'package:lazurite/features/settings/presentation/about_screen.dart'; +import 'package:lazurite/features/settings/presentation/privacy_policy_screen.dart'; +import 'package:lazurite/features/settings/presentation/settings_account_screen.dart'; +import 'package:lazurite/features/settings/presentation/settings_screen.dart'; +import 'package:lazurite/features/settings/presentation/terms_of_service_screen.dart'; +import 'package:lazurite/features/settings/presentation/video_upload_limits_screen.dart'; + +/// Builds settings and legal-document routes. +/// +/// The settings tree is shared by authenticated users and the unauthenticated +/// shell. Authenticated-only child screens read account-scoped dependencies from +/// the route context when they are opened; public settings children stay simple +/// screen routes. Keeping the group here lets [AppRouter] compose settings as a +/// route group without also owning every settings screen dependency. +List buildSettingsRoutes({required Future Function()? onUnauthorized}) { + return [ + GoRoute( + path: AppRoutePath.settings.path, + pageBuilder: (context, state) => buildAppRoutePage(context, state, const SettingsScreen()), + routes: [ + GoRoute( + path: 'moderation', + pageBuilder: (context, state) => buildAppRoutePage(context, state, const ModerationSettingsScreen()), + routes: [ + GoRoute( + path: 'detail', + pageBuilder: (context, state) => + buildAppRoutePage(context, state, LabelerDetailScreen(did: state.uri.queryParameters['did'] ?? '')), + ), + ], + ), + GoRoute( + path: 'account', + pageBuilder: (context, state) => buildAppRoutePage(context, state, const SettingsAccountScreen()), + ), + GoRoute( + path: AppRoutePath.settingsAbout.childPath, + pageBuilder: (context, state) => buildAppRoutePage(context, state, const AboutScreen()), + ), + GoRoute( + path: AppRoutePath.settingsLogs.childPath, + pageBuilder: (context, state) => buildAppRoutePage(context, state, const LogsScreen()), + ), + GoRoute( + path: 'clean-follows', + pageBuilder: (context, state) => buildAppRoutePage( + context, + state, + BlocProvider( + create: (_) => FollowAuditCubit( + repository: FollowAuditRepository( + bluesky: context.read(), + appViewProviderResolver: () => context.read().state.appViewProvider, + onUnauthorized: onUnauthorized, + ), + ownDid: context.read(), + ), + child: const FollowAuditScreen(), + ), + ), + ), + GoRoute( + path: 'video-limits', + pageBuilder: (context, state) => buildAppRoutePage( + context, + state, + BlocProvider( + create: (_) => VideoUploadLimitsCubit(repository: context.read()), + child: const VideoUploadLimitsScreen(), + ), + ), + ), + ], + ), + GoRoute( + path: AppRoutePath.terms.path, + pageBuilder: (context, state) => buildAppRoutePage(context, state, const TermsOfServiceScreen()), + ), + GoRoute( + path: AppRoutePath.privacy.path, + pageBuilder: (context, state) => buildAppRoutePage(context, state, const PrivacyPolicyScreen()), + ), + ]; +} diff --git a/lib/core/router/routes/unauthenticated_shell_routes.dart b/lib/core/router/routes/unauthenticated_shell_routes.dart new file mode 100644 index 0000000..db1553a --- /dev/null +++ b/lib/core/router/routes/unauthenticated_shell_routes.dart @@ -0,0 +1,155 @@ +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/router/app_route_page.dart'; +import 'package:lazurite/core/router/app_route_paths.dart'; +import 'package:lazurite/core/router/content_route_factory.dart'; +import 'package:lazurite/core/router/routes/public_routes.dart'; +import 'package:lazurite/core/router/routes/settings_routes.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; +import 'package:lazurite/features/auth/presentation/login_screen.dart'; +import 'package:lazurite/features/public/presentation/public_route_state.dart'; +import 'package:lazurite/features/public/presentation/unauthenticated_shell.dart'; + +/// Builds the logged-out shell and its public browsing branches. +/// +/// The unauthenticated shell has its own branch navigators and receives enough +/// route state to keep the public home tab/provider selection coherent while +/// blocking authenticated-only child routes back to their public parent. +StatefulShellRoute buildUnauthenticatedShellRoute({ + required AuthBloc authBloc, + required GlobalKey homeNavigatorKey, + required GlobalKey explorerNavigatorKey, + required GlobalKey settingsNavigatorKey, + required GlobalKey loginNavigatorKey, + required ContentRouteFactory contentRouteFactory, + required Future Function()? onUnauthorized, + required Widget Function(BuildContext context, GoRouterState state) buildDevToolsRoute, +}) { + return StatefulShellRoute.indexedStack( + builder: (context, state, navigationShell) { + if (authBloc.state.isAuthenticated) { + return navigationShell; + } + + return UnauthenticatedShell( + location: state.uri.path, + publicProviderKey: _publicProviderFromState(state), + publicHomeLocation: _publicHomeLocationForState(state), + navigationShell: navigationShell, + ); + }, + branches: [ + StatefulShellBranch( + navigatorKey: homeNavigatorKey, + routes: [ + ...buildPublicRoutes( + authBloc: authBloc, + buildPublicHomeRoute: contentRouteFactory.publicHome, + buildFeedDetailRoute: contentRouteFactory.feedDetail, + buildPostThreadRoute: contentRouteFactory.postThread, + buildTopicRoute: contentRouteFactory.topic, + buildContextualProfileRoute: contentRouteFactory.contextualProfile, + buildProfileConnectionsRoute: contentRouteFactory.profileConnections, + buildProfileSearchRoute: contentRouteFactory.profileSearch, + publicProfileLocation: _publicProfileLocation, + ), + ], + ), + StatefulShellBranch( + navigatorKey: explorerNavigatorKey, + routes: [ + GoRoute( + path: AppRoutePath.settingsDevTools.path, + pageBuilder: (context, state) => buildAppRoutePage(context, state, buildDevToolsRoute(context, state)), + ), + ], + ), + StatefulShellBranch( + navigatorKey: settingsNavigatorKey, + routes: [ + ...buildSettingsRoutes(onUnauthorized: onUnauthorized), + ], + ), + StatefulShellBranch( + navigatorKey: loginNavigatorKey, + routes: [ + GoRoute( + path: AppRoutePath.login.path, + pageBuilder: (context, state) { + final initialHandle = state.uri.queryParameters['handle']?.trim(); + final hasInitialHandle = initialHandle != null && initialHandle.isNotEmpty; + final autoStartOAuth = state.uri.queryParameters['reauth'] == '1' && hasInitialHandle; + return buildAppRoutePage( + context, + state, + LoginScreen( + initialHandle: hasInitialHandle ? initialHandle : null, + initialProviderKey: state.uri.queryParameters['provider'], + autoStartOAuth: autoStartOAuth, + ), + ); + }, + ), + ], + ), + ], + ); +} + +String _publicProfileLocation(GoRouterState state) { + final actor = Uri.decodeComponent(state.pathParameters['actor'] ?? '').trim(); + final encodedActor = Uri.encodeComponent(actor); + final provider = state.uri.queryParameters['provider']; + final providerQuery = provider == null ? '' : '?provider=${Uri.encodeQueryComponent(provider)}'; + return '/profile/$encodedActor$providerQuery'; +} + +String? _publicHomeLocationFromQuery(GoRouterState state) { + final rawLocation = state.uri.queryParameters['publicHome']?.trim(); + if (rawLocation == null || rawLocation.isEmpty) { + return null; + } + + final uri = Uri.tryParse(rawLocation); + final segments = uri?.pathSegments; + if (segments == null || segments.length != 3 || segments.first != 'public') { + return null; + } + + final routeState = PublicRouteState.parse(provider: segments[1], tab: segments[2]); + return routeState.location; +} + +String? _publicHomeLocationForState(GoRouterState state) { + final queryHome = _publicHomeLocationFromQuery(state); + if (queryHome != null) { + return queryHome; + } + + final segments = state.uri.pathSegments; + if (segments.length == 3 && segments.first == 'public') { + return PublicRouteState.parse(provider: segments[1], tab: segments[2]).location; + } + + final provider = _publicProviderFromState(state); + if (provider == null) { + return null; + } + + return PublicRouteState(providerKey: provider, contentTab: PublicContentTab.discover).location; +} + +String? _publicProviderFromState(GoRouterState state) { + final queryProvider = state.uri.queryParameters['provider']; + if (PublicRouteState.isSupportedProvider(queryProvider)) { + return PublicRouteState.normalizeProvider(queryProvider); + } + + final segments = state.uri.pathSegments; + if (segments.length >= 2 && segments.first == 'public' && PublicRouteState.isSupportedProvider(segments[1])) { + return PublicRouteState.normalizeProvider(segments[1]); + } + + return null; +} diff --git a/lib/features/auth/presentation/oauth_callback_screen.dart b/lib/features/auth/presentation/oauth_callback_screen.dart index 63ae9b6..d858ca2 100644 --- a/lib/features/auth/presentation/oauth_callback_screen.dart +++ b/lib/features/auth/presentation/oauth_callback_screen.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/router/app_route_paths.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; /// Thin route used by custom-scheme and HTTPS app-link callbacks. @@ -12,8 +13,8 @@ import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; class OAuthCallbackScreen extends StatefulWidget { const OAuthCallbackScreen({required this.callbackUri, super.key}); - static const String routePath = '/oauth/callback'; - static const String compatibilityRoutePath = '/callback'; + static String get routePath => AppRoutePath.oauthCallback.path; + static String get compatibilityRoutePath => AppRoutePath.oauthCallbackCompatibility.path; final Uri callbackUri; diff --git a/lib/features/compose/presentation/compose_screen.dart b/lib/features/compose/presentation/compose_screen.dart index 6fa8945..b907c36 100644 --- a/lib/features/compose/presentation/compose_screen.dart +++ b/lib/features/compose/presentation/compose_screen.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'dart:io'; import 'dart:ui' as ui; -import 'package:poptart_bluesky_text/poptart_bluesky_text.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -16,6 +15,8 @@ import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; import 'package:lazurite/features/compose/data/link_preview_service.dart'; +import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; +import 'package:lazurite/features/compose/presentation/widgets/compose_alt_text_dialog.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/profile/data/profile_repository.dart'; import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; @@ -25,7 +26,7 @@ import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; import 'package:lazurite/shared/presentation/widgets/external_link_preview_card.dart'; import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; -import 'package:video_player/video_player.dart'; +import 'package:poptart_bluesky_text/poptart_bluesky_text.dart'; const int _composerMinTextAreaLines = 2; @@ -50,6 +51,23 @@ class ComposeScreen extends StatefulWidget { this.linkPreviewService, }); + factory ComposeScreen.fromArgs(ComposeRouteArgs args) => ComposeScreen( + replyParentUri: args.replyParentUri, + replyParentCid: args.replyParentCid, + replyRootUri: args.replyRootUri, + replyRootCid: args.replyRootCid, + replyAuthorHandle: args.replyAuthorHandle, + quoteUri: args.quoteUri, + quoteCid: args.quoteCid, + quoteAuthorHandle: args.quoteAuthorHandle, + quoteText: args.quoteText, + draftId: args.draftId, + initialText: args.initialText, + editPostUri: args.editPostUri, + editPostCid: args.editPostCid, + editRecord: args.editRecord, + ); + final String? replyParentUri; final String? replyParentCid; final String? replyRootUri; @@ -170,1340 +188,1340 @@ class _ComposeScreenState extends State { _syncComposerAvatarFuture(); } - void _syncComposerAvatarFuture() { - AuthState? authState; - try { - authState = context.read().state; - } catch (_) { - if (kDebugMode && !_didLogMissingAuthProviderForAvatar) { - log.d('ComposeScreen: auth provider unavailable for composer avatar.'); - _didLogMissingAuthProviderForAvatar = true; - } - } - - final did = authState?.tokens?.did.trim(); - if (did == null || did.isEmpty) { - _composerAvatarDid = null; - _composerAvatarFuture = null; - return; - } - - if (_composerAvatarDid == did && _composerAvatarFuture != null) { - return; - } - - _composerAvatarDid = did; - _composerAvatarFuture = _loadComposerAvatarUrl(did); - } + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + if (state.text != _textController.text) { + _textController.text = state.text; + _textController.selection = TextSelection.collapsed(offset: state.text.length); + } - Future _loadComposerAvatarUrl(String did) async { - ProfileRepository repository; - try { - repository = context.read(); - } catch (_) { - if (kDebugMode && !_didLogMissingProfileRepositoryForAvatar) { - log.d('ComposeScreen: profile repository unavailable for composer avatar.'); - _didLogMissingProfileRepositoryForAvatar = true; - } - return null; - } + if (state.isSuccess) { + if (state.isEditing) { + showAppSnackBar(context, context.l10n.messageChangesSaved, behavior: SnackBarBehavior.floating); + } + Navigator.of(context).pop( + state.isEditing + ? {'editedText': state.text} + : { + 'status': state.hasScheduledTime ? 'scheduled' : 'posted', + 'isReply': state.isReply, + 'replyParentUri': state.replyParentUri, + 'replyRootUri': state.replyRootUri, + }, + ); + } - try { - final profile = await repository.getProfile(did); - return profile.avatar; - } catch (_) { - if (kDebugMode && !_didLogComposerAvatarLookupFailure) { - log.d('ComposeScreen: composer avatar lookup failed.'); - _didLogComposerAvatarLookupFailure = true; - } - return null; - } - } + if (state.hasError && state.errorMessage != null) { + showAppSnackBar( + context, + _localizedComposeError(context, state.errorMessage!), + behavior: SnackBarBehavior.floating, + isError: true, + ); + } + }, + child: PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, dynamic result) { + if (didPop) return; + _handleBackNavigation(context); + }, + child: Scaffold( + appBar: AppBar( + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: context.colorScheme.surface, + surfaceTintColor: Colors.transparent, + shape: Border(bottom: BorderSide(color: context.colorScheme.outlineVariant)), + leading: TextButton( + onPressed: () => _handleBackNavigation(context), + child: Text(context.l10n.buttonCancel), + ), + leadingWidth: 80, + title: BlocBuilder( + builder: (context, state) => Text( + state.isEditing ? context.l10n.labelEditPost : context.l10n.labelNewPost, + style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + ), + centerTitle: true, + actions: [ + BlocBuilder( + builder: (context, state) { + final isOffline = context.select((cubit) => cubit.state.isOffline); + final button = FilledButton( + onPressed: !isOffline && state.canSubmit && !state.isSubmitting ? _submitPost : null, + style: FilledButton.styleFrom( + minimumSize: const Size(64, 36), + padding: const EdgeInsets.symmetric(horizontal: 18), + visualDensity: VisualDensity.compact, + ), + child: state.isSubmitting + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : Text(state.isEditing ? context.l10n.buttonSaveChanges : context.l10n.buttonPost), + ); - void _onTextChanged() { - final bloc = context.read(); - final text = _textController.text; - _scheduleMentionLookup(text); - _scheduleLinkPreviewLookup(text); - if (bloc.state.text == text) { - return; - } - bloc.add(TextChanged(text)); - } + return Padding( + padding: const EdgeInsets.only(right: 8), + child: isOffline + ? Tooltip( + message: context.l10n.formatOfflineReconnectAction(context.l10n.actionPublishYourPost), + child: button, + ) + : button, + ); + }, + ), + ], + ), + body: SafeArea( + child: Column( + children: [ + BlocBuilder( + builder: (context, state) { + if (state.isEditing) { + return Container( + margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainerHighest, + border: Border.all(color: context.colorScheme.outlineVariant), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: context.colorScheme.onSurfaceVariant, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + context.l10n.messageComposeEditNotice, + style: context.textTheme.bodySmall?.copyWith( + color: context.colorScheme.onSurfaceVariant, + ), + ), + ), + IconButton( + onPressed: _showEditAlgorithmInfo, + icon: const Icon(Icons.help_outline), + tooltip: context.l10n.labelMoreInfo, + ), + ], + ), + ); + } - void _scheduleMentionLookup(String text) { - final repository = _typeaheadRepository; - if (repository == null || !_textFocusNode.hasFocus) { - _clearMentionSuggestions(); - return; - } + if (!state.isReply || widget.replyAuthorHandle == null) { + return _buildQuotePreview(state); + } + return Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainerHighest, + border: Border(bottom: BorderSide(color: context.theme.dividerColor)), + ), + child: Row( + children: [ + Icon(Icons.reply, size: 16, color: context.colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Flexible( + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: '${context.l10n.messageReplyingTo} ', + style: context.textTheme.bodySmall?.copyWith( + color: context.colorScheme.onSurfaceVariant, + ), + ), + TextSpan( + text: '@${widget.replyAuthorHandle}', + style: context.textTheme.bodySmall?.copyWith( + color: context.colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + _buildQuotePreview(state), + ], + ); + }, + ), + Expanded( + child: SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + child: Column( + children: [ + _buildComposerTextArea(), + _buildMentionAutocompletePanel(), + BlocBuilder( + builder: (context, state) => _buildComposerLinkPreview(state), + ), + if (_isLoadingLinkPreview) + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)), + ), + BlocBuilder(builder: (context, state) => _buildScheduledPill(state)), - final activeMention = _activeMentionQuery(text, _textController.selection); - if (activeMention == null || activeMention.query.length < 2) { - _clearMentionSuggestions(); - return; - } + BlocBuilder( + builder: (context, state) { + if (state.isEditing) { + return const SizedBox.shrink(); + } + if (state.mediaAttachments.isEmpty) return const SizedBox.shrink(); - _mentionQueryStart = activeMention.start; - _mentionQueryEnd = activeMention.end; - _mentionDebounce?.cancel(); - _mentionDebounce = Timer(const Duration(milliseconds: 220), () async { - final generation = ++_mentionSearchGeneration; - if (!mounted) { - return; - } - setState(() => _isSearchingMentions = true); - try { - final results = await repository.search(query: activeMention.query, limit: 6); - if (!mounted || generation != _mentionSearchGeneration) { - return; - } - setState(() { - _mentionSuggestions = results; - _isSearchingMentions = false; - }); - } catch (_) { - if (!mounted || generation != _mentionSearchGeneration) { - return; - } - setState(() { - _mentionSuggestions = const []; - _isSearchingMentions = false; - }); - } - }); - } - - void _scheduleLinkPreviewLookup(String text) { - if (context.read().state.isEditing) { - _clearLinkPreview(); - return; - } - - if (_hiddenPreviewUrl != null && !text.contains(_hiddenPreviewUrl!)) { - _hiddenPreviewUrl = null; - } - - final firstLink = LinkPreviewService.firstLink(text); - if (firstLink == null) { - _clearLinkPreview(); - return; - } - - if (_hiddenPreviewUrl != null && _hiddenPreviewUrl == firstLink) { - _clearLinkPreview(); - return; - } - - if (_linkPreview?.uri == firstLink) { - return; - } - - _linkPreviewDebounce?.cancel(); - _linkPreviewDebounce = Timer(const Duration(milliseconds: 320), () async { - final generation = ++_linkPreviewGeneration; - if (!mounted) { - return; - } - setState(() => _isLoadingLinkPreview = true); - try { - final preview = await _linkPreviewService.fetch(firstLink); - if (!mounted || generation != _linkPreviewGeneration) { - return; - } - setState(() { - _linkPreview = preview; - _isLoadingLinkPreview = false; - }); - } catch (_) { - if (!mounted || generation != _linkPreviewGeneration) { - return; - } - setState(() { - _linkPreview = null; - _isLoadingLinkPreview = false; - }); - } - }); - } - - void _clearMentionSuggestions() { - _mentionDebounce?.cancel(); - _mentionQueryStart = -1; - _mentionQueryEnd = -1; - _mentionSearchGeneration++; - if (_mentionSuggestions.isNotEmpty || _isSearchingMentions) { - setState(() { - _mentionSuggestions = const []; - _isSearchingMentions = false; - }); - } - } - - void _clearLinkPreview() { - _linkPreviewDebounce?.cancel(); - _linkPreviewGeneration++; - if (_linkPreview != null || _isLoadingLinkPreview) { - setState(() { - _linkPreview = null; - _isLoadingLinkPreview = false; - }); - } - } - - void _applyMention(TypeaheadResult result) { - final start = _mentionQueryStart; - final end = _mentionQueryEnd; - if (start < 0 || end < start || end > _textController.text.length) { - return; - } - - final currentText = _textController.text; - final replacement = '@${result.handle} '; - final nextText = '${currentText.substring(0, start)}$replacement${currentText.substring(end)}'; - final cursorOffset = start + replacement.length; - _textController.value = TextEditingValue( - text: nextText, - selection: TextSelection.collapsed(offset: cursorOffset), - ); - _clearMentionSuggestions(); - } - - ({int start, int end, String query})? _activeMentionQuery(String text, TextSelection selection) { - if (!selection.isValid || !selection.isCollapsed) { - return null; - } - - final cursor = selection.baseOffset; - if (cursor <= 0 || cursor > text.length) { - return null; - } - - final left = text.substring(0, cursor); - final mentionStart = left.lastIndexOf('@'); - if (mentionStart < 0) { - return null; - } - - if (mentionStart > 0) { - final prefixChar = text[mentionStart - 1]; - const allowedPrefix = '([{"\''; - if (!RegExp(r'\s').hasMatch(prefixChar) && !allowedPrefix.contains(prefixChar)) { - return null; - } - } - - final candidate = text.substring(mentionStart + 1, cursor); - if (candidate.isEmpty || candidate.contains(RegExp(r'\s'))) { - return null; - } - - final suffix = cursor < text.length ? text[cursor] : ''; - if (suffix.isNotEmpty && RegExp(r'[A-Za-z0-9._-]').hasMatch(suffix)) { - return null; - } - - return (start: mentionStart, end: cursor, query: candidate); - } - - Future _pickImage() async { - final state = context.read().state; - if (!state.canAddMoreMedia) { - if (mounted) { - showAppSnackBar(context, context.l10n.messageComposeImageMaxCount, isError: true); - } - return; - } - - try { - final XFile? image = await _imagePicker.pickImage( - source: ImageSource.gallery, - maxWidth: 2048, - maxHeight: 2048, - imageQuality: 85, - ); - - if (image != null && mounted) { - final file = File(image.path); - final fileSize = await file.length(); - const maxSize = 1 * 1024 * 1024; - if (fileSize > maxSize) { - if (mounted) { - showAppSnackBar(context, context.l10n.messageComposeImageMustBeUnder1Mb, isError: true); - } - return; - } - - final extension = image.path.toLowerCase().split('.').last; - const validExtensions = ['jpg', 'jpeg', 'png', 'webp']; - if (!validExtensions.contains(extension)) { - if (mounted) { - showAppSnackBar(context, context.l10n.messageComposeImageMustBeJpegPngWebp, isError: true); - } - return; - } + return Padding( + padding: const EdgeInsets.fromLTRB(68, 0, 16, 16), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: state.mediaAttachments.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 4 / 3, + ), + itemBuilder: (context, index) { + final attachment = state.mediaAttachments[index]; + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + ), + child: const SizedBox.expand(), + ), + ), + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file(File(attachment.localPath), fit: BoxFit.cover), + ), + ), + Positioned( + left: 6, + bottom: 6, + child: Material( + color: attachment.altText.isNotEmpty + ? context.colorScheme.primary + : Colors.black54, + borderRadius: BorderRadius.circular(4), + child: InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () => _showAltTextDialog(index, attachment), + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 40, minHeight: 30), + child: Center( + child: Text( + context.l10n.labelAlt, + style: context.textTheme.labelSmall?.copyWith( + color: attachment.altText.isNotEmpty + ? context.colorScheme.onPrimary + : Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ), + ), + Positioned( + top: 6, + right: 6, + child: SizedBox( + width: 32, + height: 32, + child: IconButton( + padding: EdgeInsets.zero, + style: IconButton.styleFrom(backgroundColor: Colors.black54), + onPressed: () => context.read().add(MediaRemoved(index)), + icon: const Icon(Icons.close, size: 16, color: Colors.white), + tooltip: context.l10n.messageComposeRemoveImage, + ), + ), + ), + ], + ); + }, + ), + ); + }, + ), + BlocBuilder( + builder: (context, state) { + if (state.isEditing) { + return const SizedBox.shrink(); + } + final video = state.videoAttachment; + if (video == null) return const SizedBox.shrink(); - final bytes = await file.readAsBytes(); - final ui.Codec codec = await ui.instantiateImageCodec(bytes); - final ui.FrameInfo frameInfo = await codec.getNextFrame(); - final int width = frameInfo.image.width; - final int height = frameInfo.image.height; + return Container( + margin: const EdgeInsets.fromLTRB(68, 0, 16, 16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: video.hasError ? context.colorScheme.error : context.theme.dividerColor, + ), + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: context.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: video.isActive + ? Padding( + padding: const EdgeInsets.all(12), + child: CircularProgressIndicator( + strokeWidth: 2, + value: video.isActive && video.uploadProgress > 0 + ? video.uploadProgress / 100 + : null, + ), + ) + : Icon( + video.hasError ? Icons.error_outline : Icons.videocam_outlined, + color: video.hasError + ? context.colorScheme.error + : context.colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + video.localPath.split('/').last, + style: context.textTheme.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + _videoStatusLabel(context, video), + style: context.textTheme.bodySmall?.copyWith( + color: video.hasError + ? context.colorScheme.error + : context.colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (video.isActive && video.uploadProgress > 0) ...[ + const SizedBox(height: 4), + LinearProgressIndicator( + value: video.uploadProgress / 100, + borderRadius: BorderRadius.circular(2), + ), + ], + ], + ), + ), + if (video.isReady) ...[ + IconButton( + icon: const Icon(Icons.subtitles_outlined), + tooltip: context.l10n.messageComposeAddAltText, + onPressed: () => _showVideoAltTextDialog(video), + color: video.altText.isNotEmpty + ? context.colorScheme.primary + : context.colorScheme.onSurfaceVariant, + ), + ], + IconButton( + icon: const Icon(Icons.close), + onPressed: () => context.read().add(const VideoRemoved()), + color: context.colorScheme.onSurfaceVariant, + tooltip: context.l10n.buttonRemove, + ), + ], + ), + ); + }, + ), + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: context.select((bloc) => bloc.state.isEditing) + ? const SizedBox.shrink() + : (_showDrafts ? _buildDraftsPanel() : const SizedBox.shrink()), + ), + ], + ), + ), + ), + Container( + decoration: BoxDecoration( + color: context.colorScheme.surface, + border: Border(top: BorderSide(color: context.colorScheme.outlineVariant)), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: SafeArea( + top: false, + child: Row( + children: [ + BlocBuilder( + builder: (context, state) { + if (state.isEditing) return const SizedBox.shrink(); + return _buildToolbarIconButton( + icon: Icons.image_outlined, + tooltip: context.l10n.messageComposeAddImage, + onPressed: state.canAddMoreMedia ? _pickImage : null, + ); + }, + ), + BlocBuilder( + builder: (context, state) { + if (state.isEditing) return const SizedBox.shrink(); + return _buildToolbarIconButton( + icon: Icons.videocam_outlined, + tooltip: context.l10n.messageComposeAddVideo, + onPressed: state.canAddVideo ? _pickVideo : null, + ); + }, + ), + BlocBuilder( + builder: (context, state) { + if (state.isEditing) return const SizedBox.shrink(); + final hasDraftableContent = + state.text.trim().isNotEmpty || + state.hasMedia || + state.hasVideo || + state.hasScheduledTime; + return _buildToolbarIconButton( + icon: Icons.save_outlined, + tooltip: context.l10n.messageComposeSaveDraft, + onPressed: hasDraftableContent ? _saveDraft : null, + ); + }, + ), + BlocBuilder( + builder: (context, state) { + if (state.isEditing) return const SizedBox.shrink(); + return _buildToolbarIconButton( + icon: Icons.drive_file_rename_outline, + tooltip: context.l10n.messageComposeDrafts, + onPressed: _toggleDrafts, + isActive: _showDrafts, + ); + }, + ), + BlocBuilder( + builder: (context, state) { + if (state.isEditing) return const SizedBox.shrink(); + return _buildToolbarIconButton( + icon: Icons.schedule, + tooltip: context.l10n.labelSchedule, + onPressed: _showSchedulePicker, + isActive: state.hasScheduledTime, + ); + }, + ), + const Spacer(), + BlocBuilder( + builder: (context, state) { + return _CharCounter(count: state.graphemeCount, maxCount: kMaxGraphemes); + }, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } - if (mounted) { - context.read().add(MediaAttached(image.path, width: width, height: height)); - } - } - } catch (e) { - if (mounted) { - showAppSnackBar(context, context.l10n.formatComposeFailedToPickImage(e), isError: true); + void _syncComposerAvatarFuture() { + AuthState? authState; + try { + authState = context.read().state; + } catch (_) { + if (kDebugMode && !_didLogMissingAuthProviderForAvatar) { + log.d('ComposeScreen: auth provider unavailable for composer avatar.'); + _didLogMissingAuthProviderForAvatar = true; } } - } - Future _pickVideo() async { - final state = context.read().state; - if (!state.canAddVideo) { - if (mounted) { - showAppSnackBar(context, context.l10n.messageComposeRemoveExistingMediaBeforeVideo, isError: true); - } + final did = authState?.tokens?.did.trim(); + if (did == null || did.isEmpty) { + _composerAvatarDid = null; + _composerAvatarFuture = null; return; } - try { - final XFile? video = await _imagePicker.pickVideo(source: ImageSource.gallery); - if (video != null && mounted) { - context.read().add(VideoAttached(video.path)); - } - } catch (e) { - if (mounted) { - showAppSnackBar(context, context.l10n.formatComposeFailedToPickVideo(e), isError: true); - } + if (_composerAvatarDid == did && _composerAvatarFuture != null) { + return; } - } - - Future _showVideoAltTextDialog(VideoAttachment video) async { - final result = await showDialog( - context: context, - builder: (dialogContext) => _VideoAltTextDialog( - video: video, - onCancel: () => Navigator.pop(dialogContext), - onSave: (altText) => Navigator.pop(dialogContext, altText), - ), - ); - if (result != null && mounted) { - context.read().add(VideoAltTextUpdated(result)); - } + _composerAvatarDid = did; + _composerAvatarFuture = _loadComposerAvatarUrl(did); } - Future _showAltTextDialog(int index, MediaAttachment attachment) async { - final result = await showDialog( - context: context, - builder: (dialogContext) => _ImageAltTextDialog( - imagePath: attachment.localPath, - initialAltText: attachment.altText, - onCancel: () => Navigator.pop(dialogContext), - onSave: (altText) => Navigator.pop(dialogContext, altText), - ), - ); - - if (result != null && mounted) { - context.read().add(AltTextUpdated(index: index, altText: result)); + Future _loadComposerAvatarUrl(String did) async { + ProfileRepository repository; + try { + repository = context.read(); + } catch (_) { + if (kDebugMode && !_didLogMissingProfileRepositoryForAvatar) { + log.d('ComposeScreen: profile repository unavailable for composer avatar.'); + _didLogMissingProfileRepositoryForAvatar = true; + } + return null; } - } - - Future _showSchedulePicker() async { - final now = DateTime.now(); - final initialDate = now.add(const Duration(minutes: 5)); - - final DateTime? date = await showDatePicker( - context: context, - initialDate: initialDate, - firstDate: now, - lastDate: now.add(const Duration(days: 365)), - ); - - if (date == null) return; - - if (!mounted) return; - final TimeOfDay? time = await showTimePicker(context: context, initialTime: TimeOfDay.fromDateTime(initialDate)); - - if (time == null) return; - if (!mounted) return; - final scheduledDateTime = DateTime(date.year, date.month, date.day, time.hour, time.minute); - - if (scheduledDateTime.isAfter(now)) { - context.read().add(PostScheduled(scheduledDateTime)); + try { + final profile = await repository.getProfile(did); + return profile.avatar; + } catch (_) { + if (kDebugMode && !_didLogComposerAvatarLookupFailure) { + log.d('ComposeScreen: composer avatar lookup failed.'); + _didLogComposerAvatarLookupFailure = true; + } + return null; } } - void _toggleDrafts() { - if (context.read().state.isEditing) return; - final willShow = !_showDrafts; - setState(() => _showDrafts = willShow); - if (willShow) { - context.read().add(const DraftsRequested()); + void _onTextChanged() { + final bloc = context.read(); + final text = _textController.text; + _scheduleMentionLookup(text); + _scheduleLinkPreviewLookup(text); + if (bloc.state.text == text) { + return; } + bloc.add(TextChanged(text)); } - String _formatDraftTime(BuildContext context, DateTime dateTime) { - return formatRelativeTime( - dateTime, - nowLabel: context.l10n.commonJustNow, - includeAgo: true, - locale: Localizations.localeOf(context).toString(), - ); - } + void _scheduleMentionLookup(String text) { + final repository = _typeaheadRepository; + if (repository == null || !_textFocusNode.hasFocus) { + _clearMentionSuggestions(); + return; + } - String _videoStatusLabel(BuildContext context, VideoAttachment video) { - return switch (video.status) { - VideoUploadStatus.idle => context.l10n.messageVideoReadyToUpload, - VideoUploadStatus.checkingLimits => context.l10n.messageVideoCheckingUploadLimits, - VideoUploadStatus.uploading => - video.uploadProgress > 0 - ? '${context.l10n.messageVideoUploading} ${video.uploadProgress}%' - : context.l10n.messageVideoUploading, - VideoUploadStatus.processing => - video.uploadProgress > 0 - ? '${context.l10n.messageVideoProcessing} ${video.uploadProgress}%' - : context.l10n.messageVideoProcessing, - VideoUploadStatus.ready => - video.altText.isNotEmpty - ? context.l10n.formatComposeVideoReadyWithAltText(video.altText) - : context.l10n.messageVideoReady, - VideoUploadStatus.error => _localizedComposeError( - context, - video.errorMessage ?? context.l10n.messageVideoUploadFailed, - ), - }; - } + final activeMention = _activeMentionQuery(text, _textController.selection); + if (activeMention == null || activeMention.query.length < 2) { + _clearMentionSuggestions(); + return; + } - Widget _buildDraftsPanel() { - return BlocBuilder( - builder: (context, state) { - final colorScheme = theme.colorScheme; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - height: 8, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerLow, - border: Border( - top: BorderSide(color: colorScheme.outlineVariant), - bottom: BorderSide(color: colorScheme.outlineVariant), - ), - ), - ), - Container( - constraints: const BoxConstraints(maxHeight: 292), - decoration: BoxDecoration( - color: colorScheme.surface, - border: Border(bottom: BorderSide(color: colorScheme.outlineVariant)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - context.l10n.messageComposeDrafts, - style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), - ), - Text( - context.l10n.formatDraftCount(state.drafts.length), - style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], - ), - ), - if (state.isLoadingDrafts) - const Padding( - padding: EdgeInsets.symmetric(vertical: 26), - child: Center(child: CircularProgressIndicator()), - ) - else if (state.drafts.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 26), - child: Center( - child: Text( - context.l10n.messageComposeNoDraftsSaved, - style: theme.textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ), - ) - else - Flexible( - child: ListView.separated( - shrinkWrap: true, - itemCount: state.drafts.length, - separatorBuilder: (_, _) => Divider(height: 1, color: colorScheme.outlineVariant), - itemBuilder: (context, index) { - final draft = state.drafts[index]; - return _DraftListItem( - draft: draft, - formattedTime: _formatDraftTime(context, draft.updatedAt), - onTap: () { - setState(() => _showDrafts = false); - context.read().add(DraftLoaded(draft.id)); - }, - onDelete: () { - final bloc = context.read(); - showConfirmationDialog( - context: context, - title: Text(context.l10n.dialogDeleteDraftTitle), - content: Text(context.l10n.dialogDeletePostContent), - confirmLabel: context.l10n.buttonDelete, - confirmDestructive: true, - ).then((confirmed) { - if (confirmed && mounted) { - bloc.add(DraftDeleted(draft.id)); - } - }); - }, - ); - }, - ), - ), - ], - ), - ), - ], - ); - }, - ); + _mentionQueryStart = activeMention.start; + _mentionQueryEnd = activeMention.end; + _mentionDebounce?.cancel(); + _mentionDebounce = Timer(const Duration(milliseconds: 220), () async { + final generation = ++_mentionSearchGeneration; + if (!mounted) { + return; + } + setState(() => _isSearchingMentions = true); + try { + final results = await repository.search(query: activeMention.query, limit: 6); + if (!mounted || generation != _mentionSearchGeneration) { + return; + } + setState(() { + _mentionSuggestions = results; + _isSearchingMentions = false; + }); + } catch (_) { + if (!mounted || generation != _mentionSearchGeneration) { + return; + } + setState(() { + _mentionSuggestions = const []; + _isSearchingMentions = false; + }); + } + }); } - Widget _buildMentionAutocompletePanel() { - if (!_textFocusNode.hasFocus) { - return const SizedBox.shrink(); + void _scheduleLinkPreviewLookup(String text) { + if (context.read().state.isEditing) { + _clearLinkPreview(); + return; } - if (!_isSearchingMentions && _mentionSuggestions.isEmpty) { - return const SizedBox.shrink(); + + if (_hiddenPreviewUrl != null && !text.contains(_hiddenPreviewUrl!)) { + _hiddenPreviewUrl = null; } - return Container( - margin: const EdgeInsets.fromLTRB(16, 0, 16, 8), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: theme.colorScheme.outlineVariant), - ), - constraints: const BoxConstraints(maxHeight: 220), - child: _isSearchingMentions - ? const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))), - ) - : ListView.separated( - shrinkWrap: true, - itemCount: _mentionSuggestions.length, - separatorBuilder: (_, _) => Divider(height: 1, color: theme.colorScheme.outlineVariant), - itemBuilder: (context, index) { - final actor = _mentionSuggestions[index]; - return ListTile( - dense: true, - leading: CircleAvatar( - radius: 14, - backgroundImage: actor.avatarUrl != null ? NetworkImage(actor.avatarUrl!) : null, - child: actor.avatarUrl == null - ? Text((actor.displayName ?? actor.handle).substring(0, 1).toUpperCase()) - : null, - ), - title: Text( - actor.displayName ?? actor.handle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium, - ), - subtitle: Text('@${actor.handle}', style: theme.textTheme.bodySmall), - onTap: () => _applyMention(actor), - ); - }, - ), - ); - } + final firstLink = LinkPreviewService.firstLink(text); + if (firstLink == null) { + _clearLinkPreview(); + return; + } - Widget _buildQuotePreview(ComposeState state) { - if (!state.isQuote) { - return const SizedBox.shrink(); + if (_hiddenPreviewUrl != null && _hiddenPreviewUrl == firstLink) { + _clearLinkPreview(); + return; } - final quotedHandle = widget.quoteAuthorHandle?.trim(); - final quotedText = widget.quoteText?.trim() ?? ''; + if (_linkPreview?.uri == firstLink) { + return; + } - return Container( - margin: const EdgeInsets.fromLTRB(16, 8, 16, 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: theme.colorScheme.outlineVariant), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.format_quote, size: 18, color: theme.colorScheme.onSurfaceVariant), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - quotedHandle != null && quotedHandle.isNotEmpty - ? context.l10n.formatComposeQuotingHandle(quotedHandle) - : context.l10n.messageComposeQuotingPost, - style: theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), - ), - if (quotedText.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - quotedText, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), - ], - ], - ), - ), - IconButton( - onPressed: () => context.read().add(const QuoteContextCleared()), - icon: const Icon(Icons.close), - visualDensity: VisualDensity.compact, - tooltip: context.l10n.messageComposeRemoveQuotedPost, - ), - ], - ), - ); + _linkPreviewDebounce?.cancel(); + _linkPreviewDebounce = Timer(const Duration(milliseconds: 320), () async { + final generation = ++_linkPreviewGeneration; + if (!mounted) { + return; + } + setState(() => _isLoadingLinkPreview = true); + try { + final preview = await _linkPreviewService.fetch(firstLink); + if (!mounted || generation != _linkPreviewGeneration) { + return; + } + setState(() { + _linkPreview = preview; + _isLoadingLinkPreview = false; + }); + } catch (_) { + if (!mounted || generation != _linkPreviewGeneration) { + return; + } + setState(() { + _linkPreview = null; + _isLoadingLinkPreview = false; + }); + } + }); } - Widget _buildComposerLinkPreview(ComposeState state) { - if (_linkPreview == null || state.isQuote || state.hasMedia || state.hasVideo || state.isEditing) { - return const SizedBox.shrink(); + void _clearMentionSuggestions() { + _mentionDebounce?.cancel(); + _mentionQueryStart = -1; + _mentionQueryEnd = -1; + _mentionSearchGeneration++; + if (_mentionSuggestions.isNotEmpty || _isSearchingMentions) { + setState(() { + _mentionSuggestions = const []; + _isSearchingMentions = false; + }); } + } - return Padding( - padding: const EdgeInsets.fromLTRB(68, 0, 16, 8), - child: ExternalLinkPreviewCard( - uri: _linkPreview!.uri, - title: _linkPreview!.title, - description: _linkPreview!.description, - thumbUrl: _linkPreview!.thumbnailUrl, - compact: true, - onRemove: () { - setState(() { - _hiddenPreviewUrl = _linkPreview!.uri; - _linkPreview = null; - }); - }, - ), - ); + void _clearLinkPreview() { + _linkPreviewDebounce?.cancel(); + _linkPreviewGeneration++; + if (_linkPreview != null || _isLoadingLinkPreview) { + setState(() { + _linkPreview = null; + _isLoadingLinkPreview = false; + }); + } } - ThemeData get theme => Theme.of(context); + void _applyMention(TypeaheadResult result) { + final start = _mentionQueryStart; + final end = _mentionQueryEnd; + if (start < 0 || end < start || end > _textController.text.length) { + return; + } - void _submitPost() { - context.read().add(PostSubmitted(suppressedLinkUri: _hiddenPreviewUrl)); + final currentText = _textController.text; + final replacement = '@${result.handle} '; + final nextText = '${currentText.substring(0, start)}$replacement${currentText.substring(end)}'; + final cursorOffset = start + replacement.length; + _textController.value = TextEditingValue( + text: nextText, + selection: TextSelection.collapsed(offset: cursorOffset), + ); + _clearMentionSuggestions(); } - void _saveDraft() { - if (context.read().state.isEditing) return; - context.read().add(const DraftSaved()); - if (mounted) { - showAppSnackBar(context, context.l10n.messageComposeDraftSaved); + ({int start, int end, String query})? _activeMentionQuery(String text, TextSelection selection) { + if (!selection.isValid || !selection.isCollapsed) { + return null; } - } - Future _showEditAlgorithmInfo() async { - await showConfirmationDialog( - context: context, - title: Text(context.l10n.dialogEditAlgorithmTitle), - content: Text(context.l10n.dialogEditAlgorithmContent), - confirmLabel: context.l10n.buttonOk, - showCancel: false, - ); + final cursor = selection.baseOffset; + if (cursor <= 0 || cursor > text.length) { + return null; + } + + final left = text.substring(0, cursor); + final mentionStart = left.lastIndexOf('@'); + if (mentionStart < 0) { + return null; + } + + if (mentionStart > 0) { + final prefixChar = text[mentionStart - 1]; + const allowedPrefix = '([{"\''; + if (!RegExp(r'\s').hasMatch(prefixChar) && !allowedPrefix.contains(prefixChar)) { + return null; + } + } + + final candidate = text.substring(mentionStart + 1, cursor); + if (candidate.isEmpty || candidate.contains(RegExp(r'\s'))) { + return null; + } + + final suffix = cursor < text.length ? text[cursor] : ''; + if (suffix.isNotEmpty && RegExp(r'[A-Za-z0-9._-]').hasMatch(suffix)) { + return null; + } + + return (start: mentionStart, end: cursor, query: candidate); } - void _handleBackNavigation(BuildContext context) { + Future _pickImage() async { final state = context.read().state; - final navigator = Navigator.of(context); - - final hasContent = state.text.trim().isNotEmpty || state.mediaAttachments.isNotEmpty; - - if (state.isEditing) { - if (state.isDraftDirty) { - showConfirmationDialog( - context: context, - title: Text(context.l10n.dialogDiscardChangesTitle), - content: Text(context.l10n.dialogDiscardChangesContent), - confirmLabel: context.l10n.buttonDiscard, - ).then((shouldDiscard) { - if (shouldDiscard && mounted) { - navigator.pop(false); - } - }); - } else { - navigator.pop(false); + if (!state.canAddMoreMedia) { + if (mounted) { + showAppSnackBar(context, context.l10n.messageComposeImageMaxCount, isError: true); } return; } - if (hasContent && state.isDraftDirty) { - showConfirmationDialog( - context: context, - title: Text(context.l10n.dialogSaveDraftTitle), - content: Text(context.l10n.dialogSaveDraftContent), - cancelLabel: context.l10n.buttonDiscard, - confirmLabel: context.l10n.buttonSave, - ).then((shouldSave) { - if (shouldSave) { - _saveDraft(); + try { + final XFile? image = await _imagePicker.pickImage( + source: ImageSource.gallery, + maxWidth: 2048, + maxHeight: 2048, + imageQuality: 85, + ); + + if (image != null && mounted) { + final file = File(image.path); + final fileSize = await file.length(); + const maxSize = 1 * 1024 * 1024; + if (fileSize > maxSize) { + if (mounted) { + showAppSnackBar(context, context.l10n.messageComposeImageMustBeUnder1Mb, isError: true); + } + return; + } + + final extension = image.path.toLowerCase().split('.').last; + const validExtensions = ['jpg', 'jpeg', 'png', 'webp']; + if (!validExtensions.contains(extension)) { + if (mounted) { + showAppSnackBar(context, context.l10n.messageComposeImageMustBeJpegPngWebp, isError: true); + } + return; } + + final bytes = await file.readAsBytes(); + final ui.Codec codec = await ui.instantiateImageCodec(bytes); + final ui.FrameInfo frameInfo = await codec.getNextFrame(); + final int width = frameInfo.image.width; + final int height = frameInfo.image.height; + if (mounted) { - navigator.pop(); + context.read().add(MediaAttached(image.path, width: width, height: height)); } - }); - } else { - navigator.pop(); + } + } catch (e) { + if (mounted) { + showAppSnackBar(context, context.l10n.formatComposeFailedToPickImage(e), isError: true); + } } } - String _composerAvatarFallbackText() { - try { - final tokens = context.read().state.tokens; - final displayName = tokens?.displayName?.trim(); - if (displayName != null && displayName.isNotEmpty) { - return displayName; + Future _pickVideo() async { + final state = context.read().state; + if (!state.canAddVideo) { + if (mounted) { + showAppSnackBar(context, context.l10n.messageComposeRemoveExistingMediaBeforeVideo, isError: true); } + return; + } - final handle = tokens?.handle.trim(); - if (handle != null && handle.isNotEmpty) { - return handle; + try { + final XFile? video = await _imagePicker.pickVideo(source: ImageSource.gallery); + if (video != null && mounted) { + context.read().add(VideoAttached(video.path)); } - } catch (_) { - if (kDebugMode && !_didLogMissingAuthProviderForAvatar) { - log.d('ComposeScreen: auth provider unavailable for avatar fallback.'); - _didLogMissingAuthProviderForAvatar = true; + } catch (e) { + if (mounted) { + showAppSnackBar(context, context.l10n.formatComposeFailedToPickVideo(e), isError: true); } } - - return context.l10n.appTitle; } - Widget _buildComposerAvatar() { - final avatar = ProfileAvatar( - key: const ValueKey('compose_author_avatar'), - size: 40, - imageUrl: null, - fallbackText: _composerAvatarFallbackText(), - backgroundColor: theme.colorScheme.surfaceContainerHighest, - placeholderTextStyle: theme.textTheme.labelMedium?.copyWith( - color: theme.colorScheme.onSurface, - fontWeight: FontWeight.w700, + Future _showVideoAltTextDialog(VideoAttachment video) async { + final result = await showDialog( + context: context, + builder: (dialogContext) => VideoAltTextDialog( + video: video, + onCancel: () => Navigator.pop(dialogContext), + onSave: (altText) => Navigator.pop(dialogContext, altText), ), ); - final avatarFuture = _composerAvatarFuture; - if (avatarFuture == null) { - return avatar; - } - - return FutureBuilder( - future: avatarFuture, - builder: (context, snapshot) { - return ProfileAvatar( - key: const ValueKey('compose_author_avatar'), - size: 40, - imageUrl: snapshot.data, - fallbackText: _composerAvatarFallbackText(), - backgroundColor: theme.colorScheme.surfaceContainerHighest, - placeholderTextStyle: theme.textTheme.labelMedium?.copyWith( - color: theme.colorScheme.onSurface, - fontWeight: FontWeight.w700, - ), - ); - }, - ); - } - - Widget _buildComposerTextArea() => Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildComposerAvatar(), - const SizedBox(width: 12), - Expanded( - child: TextField( - controller: _textController, - focusNode: _textFocusNode, - autofocus: true, - minLines: _composerMinTextAreaLines, - maxLines: null, - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.newline, - cursorHeight: 20, - scrollPadding: const EdgeInsets.only(bottom: 96), - decoration: InputDecoration( - hintText: context.l10n.messageComposePlaceholder, - hintStyle: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.onSurfaceVariant), - border: InputBorder.none, - contentPadding: const EdgeInsets.fromLTRB(4, 4, 4, 10), - ), - style: theme.textTheme.bodyLarge?.copyWith(height: 1.5, fontSize: 16), - ), - ), - ], - ), - ); - - Widget _buildScheduledPill(ComposeState state) { - if (!state.hasScheduledTime) { - return const SizedBox.shrink(); + if (result != null && mounted) { + context.read().add(VideoAltTextUpdated(result)); } - - final colorScheme = theme.colorScheme; - return Align( - alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.fromLTRB(68, 4, 16, 12), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), - decoration: BoxDecoration( - color: colorScheme.primary, - borderRadius: BorderRadius.circular(999), - border: Border.all(color: colorScheme.primary), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.schedule, size: 15, color: colorScheme.onPrimary), - const SizedBox(width: 7), - Text( - context.l10n.formatComposeScheduledFor( - DateFormat.yMMMd(Localizations.localeOf(context).toString()).add_jm().format(state.scheduledAt!), - ), - style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onPrimary, fontWeight: FontWeight.w600), - ), - const SizedBox(width: 2), - IconButton( - onPressed: () => context.read().add(const ScheduleCleared()), - icon: Icon(Icons.close, size: 16, color: colorScheme.onPrimary), - tooltip: context.l10n.messageComposeClearScheduledTime, - constraints: const BoxConstraints(minWidth: 40, minHeight: 40), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - style: IconButton.styleFrom( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size(40, 40), - ), - ), - ], - ), - ), - ); - } - - Widget _buildToolbarIconButton({ - required IconData icon, - required String tooltip, - required VoidCallback? onPressed, - bool isActive = false, - }) { - final colorScheme = theme.colorScheme; - final foregroundColor = onPressed == null - ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) - : colorScheme.primary; - - return IconButton( - onPressed: onPressed, - icon: Icon(icon, color: foregroundColor), - tooltip: tooltip, - constraints: const BoxConstraints.tightFor(width: 40, height: 40), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - style: IconButton.styleFrom( - fixedSize: const Size(40, 40), - minimumSize: const Size(40, 40), - padding: EdgeInsets.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - backgroundColor: isActive ? colorScheme.surfaceContainerLow : Colors.transparent, - shape: const CircleBorder(), - ), - ); } - @override - Widget build(BuildContext context) { - return BlocListener( - listener: (context, state) { - if (state.text != _textController.text) { - _textController.text = state.text; - _textController.selection = TextSelection.collapsed(offset: state.text.length); - } - - if (state.isSuccess) { - if (state.isEditing) { - showAppSnackBar(context, context.l10n.messageChangesSaved, behavior: SnackBarBehavior.floating); - } - Navigator.of(context).pop( - state.isEditing - ? {'editedText': state.text} - : { - 'status': state.hasScheduledTime ? 'scheduled' : 'posted', - 'isReply': state.isReply, - 'replyParentUri': state.replyParentUri, - 'replyRootUri': state.replyRootUri, - }, - ); - } + Future _showAltTextDialog(int index, MediaAttachment attachment) async { + final result = await showDialog( + context: context, + builder: (dialogContext) => ImageAltTextDialog( + imagePath: attachment.localPath, + initialAltText: attachment.altText, + onCancel: () => Navigator.pop(dialogContext), + onSave: (altText) => Navigator.pop(dialogContext, altText), + ), + ); - if (state.hasError && state.errorMessage != null) { - showAppSnackBar( - context, - _localizedComposeError(context, state.errorMessage!), - behavior: SnackBarBehavior.floating, - isError: true, - ); - } - }, - child: PopScope( - canPop: false, - onPopInvokedWithResult: (bool didPop, dynamic result) { - if (didPop) return; - _handleBackNavigation(context); - }, - child: Scaffold( - appBar: AppBar( - elevation: 0, - scrolledUnderElevation: 0, - backgroundColor: theme.colorScheme.surface, - surfaceTintColor: Colors.transparent, - shape: Border(bottom: BorderSide(color: theme.colorScheme.outlineVariant)), - leading: TextButton( - onPressed: () => _handleBackNavigation(context), - child: Text(context.l10n.buttonCancel), - ), - leadingWidth: 80, - title: BlocBuilder( - builder: (context, state) => Text( - state.isEditing ? context.l10n.labelEditPost : context.l10n.labelNewPost, - style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), - ), - ), - centerTitle: true, - actions: [ - BlocBuilder( - builder: (context, state) { - final isOffline = context.select((cubit) => cubit.state.isOffline); - final button = FilledButton( - onPressed: !isOffline && state.canSubmit && !state.isSubmitting ? _submitPost : null, - style: FilledButton.styleFrom( - minimumSize: const Size(64, 36), - padding: const EdgeInsets.symmetric(horizontal: 18), - visualDensity: VisualDensity.compact, - ), - child: state.isSubmitting - ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) - : Text(state.isEditing ? context.l10n.buttonSaveChanges : context.l10n.buttonPost), - ); + if (result != null && mounted) { + context.read().add(AltTextUpdated(index: index, altText: result)); + } + } - return Padding( - padding: const EdgeInsets.only(right: 8), - child: isOffline - ? Tooltip( - message: context.l10n.formatOfflineReconnectAction(context.l10n.actionPublishYourPost), - child: button, - ) - : button, - ); - }, - ), - ], - ), - body: SafeArea( - child: Column( - children: [ - BlocBuilder( - builder: (context, state) { - if (state.isEditing) { - return Container( - margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - border: Border.all(color: theme.colorScheme.outlineVariant), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant, size: 20), - const SizedBox(width: 12), - Expanded( - child: Text( - context.l10n.messageComposeEditNotice, - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), - ), - IconButton( - onPressed: _showEditAlgorithmInfo, - icon: const Icon(Icons.help_outline), - tooltip: context.l10n.labelMoreInfo, - ), - ], - ), - ); - } + Future _showSchedulePicker() async { + final now = DateTime.now(); + final initialDate = now.add(const Duration(minutes: 5)); - if (!state.isReply || widget.replyAuthorHandle == null) { - return _buildQuotePreview(state); - } - return Column( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - border: Border(bottom: BorderSide(color: theme.dividerColor)), - ), - child: Row( - children: [ - Icon(Icons.reply, size: 16, color: theme.colorScheme.onSurfaceVariant), - const SizedBox(width: 8), - Flexible( - child: Text.rich( - TextSpan( - children: [ - TextSpan( - text: '${context.l10n.messageReplyingTo} ', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - TextSpan( - text: '@${widget.replyAuthorHandle}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - _buildQuotePreview(state), - ], - ); - }, - ), - Expanded( - child: SingleChildScrollView( - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - child: Column( - children: [ - _buildComposerTextArea(), - _buildMentionAutocompletePanel(), - BlocBuilder( - builder: (context, state) => _buildComposerLinkPreview(state), - ), - if (_isLoadingLinkPreview) - const Padding( - padding: EdgeInsets.only(bottom: 8), - child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)), - ), - BlocBuilder(builder: (context, state) => _buildScheduledPill(state)), + final DateTime? date = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: now, + lastDate: now.add(const Duration(days: 365)), + ); - BlocBuilder( - builder: (context, state) { - if (state.isEditing) { - return const SizedBox.shrink(); - } - if (state.mediaAttachments.isEmpty) return const SizedBox.shrink(); + if (date == null) return; - return Padding( - padding: const EdgeInsets.fromLTRB(68, 0, 16, 16), - child: GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: state.mediaAttachments.length, - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - childAspectRatio: 4 / 3, - ), - itemBuilder: (context, index) { - final attachment = state.mediaAttachments[index]; - return Stack( - fit: StackFit.expand, - children: [ - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(12), - ), - child: const SizedBox.expand(), - ), - ), - Positioned.fill( - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.file(File(attachment.localPath), fit: BoxFit.cover), - ), - ), - Positioned( - left: 6, - bottom: 6, - child: Material( - color: attachment.altText.isNotEmpty - ? theme.colorScheme.primary - : Colors.black54, - borderRadius: BorderRadius.circular(4), - child: InkWell( - borderRadius: BorderRadius.circular(4), - onTap: () => _showAltTextDialog(index, attachment), - child: ConstrainedBox( - constraints: const BoxConstraints(minWidth: 40, minHeight: 30), - child: Center( - child: Text( - context.l10n.labelAlt, - style: theme.textTheme.labelSmall?.copyWith( - color: attachment.altText.isNotEmpty - ? theme.colorScheme.onPrimary - : Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - ), - ), - Positioned( - top: 6, - right: 6, - child: SizedBox( - width: 32, - height: 32, - child: IconButton( - padding: EdgeInsets.zero, - style: IconButton.styleFrom(backgroundColor: Colors.black54), - onPressed: () => context.read().add(MediaRemoved(index)), - icon: const Icon(Icons.close, size: 16, color: Colors.white), - tooltip: context.l10n.messageComposeRemoveImage, - ), - ), - ), - ], - ); - }, - ), - ); - }, - ), - BlocBuilder( - builder: (context, state) { - if (state.isEditing) { - return const SizedBox.shrink(); - } - final video = state.videoAttachment; - if (video == null) return const SizedBox.shrink(); + if (!mounted) return; + final TimeOfDay? time = await showTimePicker(context: context, initialTime: TimeOfDay.fromDateTime(initialDate)); - return Container( - margin: const EdgeInsets.fromLTRB(68, 0, 16, 16), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: video.hasError ? theme.colorScheme.error : theme.dividerColor, - ), - ), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(8), - ), - child: video.isActive - ? Padding( - padding: const EdgeInsets.all(12), - child: CircularProgressIndicator( - strokeWidth: 2, - value: video.isActive && video.uploadProgress > 0 - ? video.uploadProgress / 100 - : null, - ), - ) - : Icon( - video.hasError ? Icons.error_outline : Icons.videocam_outlined, - color: video.hasError - ? theme.colorScheme.error - : theme.colorScheme.onPrimaryContainer, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - video.localPath.split('/').last, - style: theme.textTheme.bodyMedium, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - _videoStatusLabel(context, video), - style: theme.textTheme.bodySmall?.copyWith( - color: video.hasError - ? theme.colorScheme.error - : theme.colorScheme.onSurfaceVariant, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - if (video.isActive && video.uploadProgress > 0) ...[ - const SizedBox(height: 4), - LinearProgressIndicator( - value: video.uploadProgress / 100, - borderRadius: BorderRadius.circular(2), - ), - ], - ], - ), - ), - if (video.isReady) ...[ - IconButton( - icon: const Icon(Icons.subtitles_outlined), - tooltip: context.l10n.messageComposeAddAltText, - onPressed: () => _showVideoAltTextDialog(video), - color: video.altText.isNotEmpty - ? theme.colorScheme.primary - : theme.colorScheme.onSurfaceVariant, - ), - ], - IconButton( - icon: const Icon(Icons.close), - onPressed: () => context.read().add(const VideoRemoved()), - color: theme.colorScheme.onSurfaceVariant, - tooltip: context.l10n.buttonRemove, - ), - ], - ), - ); - }, - ), - AnimatedSize( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - child: context.select((bloc) => bloc.state.isEditing) - ? const SizedBox.shrink() - : (_showDrafts ? _buildDraftsPanel() : const SizedBox.shrink()), - ), - ], - ), - ), + if (time == null) return; + + if (!mounted) return; + final scheduledDateTime = DateTime(date.year, date.month, date.day, time.hour, time.minute); + + if (scheduledDateTime.isAfter(now)) { + context.read().add(PostScheduled(scheduledDateTime)); + } + } + + void _toggleDrafts() { + if (context.read().state.isEditing) return; + final willShow = !_showDrafts; + setState(() => _showDrafts = willShow); + if (willShow) { + context.read().add(const DraftsRequested()); + } + } + + String _formatDraftTime(BuildContext context, DateTime dateTime) { + return formatRelativeTime( + dateTime, + nowLabel: context.l10n.commonJustNow, + includeAgo: true, + locale: Localizations.localeOf(context).toString(), + ); + } + + String _videoStatusLabel(BuildContext context, VideoAttachment video) { + return switch (video.status) { + VideoUploadStatus.idle => context.l10n.messageVideoReadyToUpload, + VideoUploadStatus.checkingLimits => context.l10n.messageVideoCheckingUploadLimits, + VideoUploadStatus.uploading => + video.uploadProgress > 0 + ? '${context.l10n.messageVideoUploading} ${video.uploadProgress}%' + : context.l10n.messageVideoUploading, + VideoUploadStatus.processing => + video.uploadProgress > 0 + ? '${context.l10n.messageVideoProcessing} ${video.uploadProgress}%' + : context.l10n.messageVideoProcessing, + VideoUploadStatus.ready => + video.altText.isNotEmpty + ? context.l10n.formatComposeVideoReadyWithAltText(video.altText) + : context.l10n.messageVideoReady, + VideoUploadStatus.error => _localizedComposeError( + context, + video.errorMessage ?? context.l10n.messageVideoUploadFailed, + ), + }; + } + + Widget _buildDraftsPanel() { + return BlocBuilder( + builder: (context, state) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 8, + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainerLow, + border: Border( + top: BorderSide(color: context.colorScheme.outlineVariant), + bottom: BorderSide(color: context.colorScheme.outlineVariant), ), - Container( - decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border(top: BorderSide(color: theme.colorScheme.outlineVariant)), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: SafeArea( - top: false, + ), + ), + Container( + constraints: const BoxConstraints(maxHeight: 292), + decoration: BoxDecoration( + color: context.colorScheme.surface, + border: Border(bottom: BorderSide(color: context.colorScheme.outlineVariant)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - BlocBuilder( - builder: (context, state) { - if (state.isEditing) return const SizedBox.shrink(); - return _buildToolbarIconButton( - icon: Icons.image_outlined, - tooltip: context.l10n.messageComposeAddImage, - onPressed: state.canAddMoreMedia ? _pickImage : null, - ); - }, - ), - BlocBuilder( - builder: (context, state) { - if (state.isEditing) return const SizedBox.shrink(); - return _buildToolbarIconButton( - icon: Icons.videocam_outlined, - tooltip: context.l10n.messageComposeAddVideo, - onPressed: state.canAddVideo ? _pickVideo : null, - ); - }, - ), - BlocBuilder( - builder: (context, state) { - if (state.isEditing) return const SizedBox.shrink(); - final hasDraftableContent = - state.text.trim().isNotEmpty || - state.hasMedia || - state.hasVideo || - state.hasScheduledTime; - return _buildToolbarIconButton( - icon: Icons.save_outlined, - tooltip: context.l10n.messageComposeSaveDraft, - onPressed: hasDraftableContent ? _saveDraft : null, - ); - }, - ), - BlocBuilder( - builder: (context, state) { - if (state.isEditing) return const SizedBox.shrink(); - return _buildToolbarIconButton( - icon: Icons.drive_file_rename_outline, - tooltip: context.l10n.messageComposeDrafts, - onPressed: _toggleDrafts, - isActive: _showDrafts, - ); - }, - ), - BlocBuilder( - builder: (context, state) { - if (state.isEditing) return const SizedBox.shrink(); - return _buildToolbarIconButton( - icon: Icons.schedule, - tooltip: context.l10n.labelSchedule, - onPressed: _showSchedulePicker, - isActive: state.hasScheduledTime, - ); - }, + Text( + context.l10n.messageComposeDrafts, + style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), ), - const Spacer(), - BlocBuilder( - builder: (context, state) { - return _CharCounter(count: state.graphemeCount, maxCount: kMaxGraphemes); - }, + Text( + context.l10n.formatDraftCount(state.drafts.length), + style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), ), ], ), ), + if (state.isLoadingDrafts) + const Padding( + padding: EdgeInsets.symmetric(vertical: 26), + child: Center(child: CircularProgressIndicator()), + ) + else if (state.drafts.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 26), + child: Center( + child: Text( + context.l10n.messageComposeNoDraftsSaved, + style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant), + ), + ), + ) + else + Flexible( + child: ListView.separated( + shrinkWrap: true, + itemCount: state.drafts.length, + separatorBuilder: (_, _) => Divider(height: 1, color: context.colorScheme.outlineVariant), + itemBuilder: (context, index) { + final draft = state.drafts[index]; + return _DraftListItem( + draft: draft, + formattedTime: _formatDraftTime(context, draft.updatedAt), + onTap: () { + setState(() => _showDrafts = false); + context.read().add(DraftLoaded(draft.id)); + }, + onDelete: () { + final bloc = context.read(); + showConfirmationDialog( + context: context, + title: Text(context.l10n.dialogDeleteDraftTitle), + content: Text(context.l10n.dialogDeletePostContent), + confirmLabel: context.l10n.buttonDelete, + confirmDestructive: true, + ).then((confirmed) { + if (confirmed && mounted) { + bloc.add(DraftDeleted(draft.id)); + } + }); + }, + ); + }, + ), + ), + ], + ), + ), + ], + ); + }, + ); + } + + Widget _buildMentionAutocompletePanel() { + if (!_textFocusNode.hasFocus) { + return const SizedBox.shrink(); + } + if (!_isSearchingMentions && _mentionSuggestions.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 8), + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: context.colorScheme.outlineVariant), + ), + constraints: const BoxConstraints(maxHeight: 220), + child: _isSearchingMentions + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))), + ) + : ListView.separated( + shrinkWrap: true, + itemCount: _mentionSuggestions.length, + separatorBuilder: (_, _) => Divider(height: 1, color: context.colorScheme.outlineVariant), + itemBuilder: (context, index) { + final actor = _mentionSuggestions[index]; + return ListTile( + dense: true, + leading: CircleAvatar( + radius: 14, + backgroundImage: actor.avatarUrl != null ? NetworkImage(actor.avatarUrl!) : null, + child: actor.avatarUrl == null + ? Text((actor.displayName ?? actor.handle).substring(0, 1).toUpperCase()) + : null, + ), + title: Text( + actor.displayName ?? actor.handle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium, + ), + subtitle: Text('@${actor.handle}', style: context.textTheme.bodySmall), + onTap: () => _applyMention(actor), + ); + }, + ), + ); + } + + Widget _buildQuotePreview(ComposeState state) { + if (!state.isQuote) { + return const SizedBox.shrink(); + } + + final quotedHandle = widget.quoteAuthorHandle?.trim(); + final quotedText = widget.quoteText?.trim() ?? ''; + + return Container( + margin: const EdgeInsets.fromLTRB(16, 8, 16, 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: context.colorScheme.outlineVariant), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.format_quote, size: 18, color: context.colorScheme.onSurfaceVariant), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + quotedHandle != null && quotedHandle.isNotEmpty + ? context.l10n.formatComposeQuotingHandle(quotedHandle) + : context.l10n.messageComposeQuotingPost, + style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), ), + if (quotedText.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + quotedText, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), + ), + ], ], ), ), + IconButton( + onPressed: () => context.read().add(const QuoteContextCleared()), + icon: const Icon(Icons.close), + visualDensity: VisualDensity.compact, + tooltip: context.l10n.messageComposeRemoveQuotedPost, + ), + ], + ), + ); + } + + Widget _buildComposerLinkPreview(ComposeState state) { + if (_linkPreview == null || state.isQuote || state.hasMedia || state.hasVideo || state.isEditing) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.fromLTRB(68, 0, 16, 8), + child: ExternalLinkPreviewCard( + uri: _linkPreview!.uri, + title: _linkPreview!.title, + description: _linkPreview!.description, + thumbUrl: _linkPreview!.thumbnailUrl, + compact: true, + onRemove: () { + setState(() { + _hiddenPreviewUrl = _linkPreview!.uri; + _linkPreview = null; + }); + }, + ), + ); + } + + void _submitPost() { + context.read().add(PostSubmitted(suppressedLinkUri: _hiddenPreviewUrl)); + } + + void _saveDraft() { + if (context.read().state.isEditing) return; + context.read().add(const DraftSaved()); + if (mounted) { + showAppSnackBar(context, context.l10n.messageComposeDraftSaved); + } + } + + Future _showEditAlgorithmInfo() async { + await showConfirmationDialog( + context: context, + title: Text(context.l10n.dialogEditAlgorithmTitle), + content: Text(context.l10n.dialogEditAlgorithmContent), + confirmLabel: context.l10n.buttonOk, + showCancel: false, + ); + } + + void _handleBackNavigation(BuildContext context) { + final state = context.read().state; + final navigator = Navigator.of(context); + + final hasContent = state.text.trim().isNotEmpty || state.mediaAttachments.isNotEmpty; + + if (state.isEditing) { + if (state.isDraftDirty) { + showConfirmationDialog( + context: context, + title: Text(context.l10n.dialogDiscardChangesTitle), + content: Text(context.l10n.dialogDiscardChangesContent), + confirmLabel: context.l10n.buttonDiscard, + ).then((shouldDiscard) { + if (shouldDiscard && mounted) { + navigator.pop(false); + } + }); + } else { + navigator.pop(false); + } + return; + } + + if (hasContent && state.isDraftDirty) { + showConfirmationDialog( + context: context, + title: Text(context.l10n.dialogSaveDraftTitle), + content: Text(context.l10n.dialogSaveDraftContent), + cancelLabel: context.l10n.buttonDiscard, + confirmLabel: context.l10n.buttonSave, + ).then((shouldSave) { + if (shouldSave) { + _saveDraft(); + } + if (mounted) { + navigator.pop(); + } + }); + } else { + navigator.pop(); + } + } + + String _composerAvatarFallbackText() { + try { + final tokens = context.read().state.tokens; + final displayName = tokens?.displayName?.trim(); + if (displayName != null && displayName.isNotEmpty) { + return displayName; + } + + final handle = tokens?.handle.trim(); + if (handle != null && handle.isNotEmpty) { + return handle; + } + } catch (_) { + if (kDebugMode && !_didLogMissingAuthProviderForAvatar) { + log.d('ComposeScreen: auth provider unavailable for avatar fallback.'); + _didLogMissingAuthProviderForAvatar = true; + } + } + + return context.l10n.appTitle; + } + + Widget _buildComposerAvatar() { + final avatar = ProfileAvatar( + key: const ValueKey('compose_author_avatar'), + size: 40, + imageUrl: null, + fallbackText: _composerAvatarFallbackText(), + backgroundColor: context.colorScheme.surfaceContainerHighest, + placeholderTextStyle: context.textTheme.labelMedium?.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.w700, + ), + ); + + final avatarFuture = _composerAvatarFuture; + if (avatarFuture == null) { + return avatar; + } + + return FutureBuilder( + future: avatarFuture, + builder: (context, snapshot) { + return ProfileAvatar( + key: const ValueKey('compose_author_avatar'), + size: 40, + imageUrl: snapshot.data, + fallbackText: _composerAvatarFallbackText(), + backgroundColor: context.colorScheme.surfaceContainerHighest, + placeholderTextStyle: context.textTheme.labelMedium?.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.w700, + ), + ); + }, + ); + } + + Widget _buildComposerTextArea() => Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildComposerAvatar(), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _textController, + focusNode: _textFocusNode, + autofocus: true, + minLines: _composerMinTextAreaLines, + maxLines: null, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + cursorHeight: 20, + scrollPadding: const EdgeInsets.only(bottom: 96), + decoration: InputDecoration( + hintText: context.l10n.messageComposePlaceholder, + hintStyle: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurfaceVariant), + border: InputBorder.none, + contentPadding: const EdgeInsets.fromLTRB(4, 4, 4, 10), + ), + style: context.textTheme.bodyLarge?.copyWith(height: 1.5, fontSize: 16), + ), + ), + ], + ), + ); + + Widget _buildScheduledPill(ComposeState state) { + if (!state.hasScheduledTime) { + return const SizedBox.shrink(); + } + + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.fromLTRB(68, 4, 16, 12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: context.colorScheme.primary, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: context.colorScheme.primary), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.schedule, size: 15, color: context.colorScheme.onPrimary), + const SizedBox(width: 7), + Text( + context.l10n.formatComposeScheduledFor( + DateFormat.yMMMd(Localizations.localeOf(context).toString()).add_jm().format(state.scheduledAt!), + ), + style: context.textTheme.bodySmall?.copyWith( + color: context.colorScheme.onPrimary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 2), + IconButton( + onPressed: () => context.read().add(const ScheduleCleared()), + icon: Icon(Icons.close, size: 16, color: context.colorScheme.onPrimary), + tooltip: context.l10n.messageComposeClearScheduledTime, + constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(40, 40), + ), + ), + ], ), ), ); } + Widget _buildToolbarIconButton({ + required IconData icon, + required String tooltip, + required VoidCallback? onPressed, + bool isActive = false, + }) { + final foregroundColor = onPressed == null + ? context.colorScheme.onSurfaceVariant.withValues(alpha: 0.5) + : context.colorScheme.primary; + + return IconButton( + onPressed: onPressed, + icon: Icon(icon, color: foregroundColor), + tooltip: tooltip, + constraints: const BoxConstraints.tightFor(width: 40, height: 40), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + fixedSize: const Size(40, 40), + minimumSize: const Size(40, 40), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + backgroundColor: isActive ? context.colorScheme.surfaceContainerLow : Colors.transparent, + shape: const CircleBorder(), + ), + ); + } + String _localizedComposeError(BuildContext context, String message) { final imageTooLarge = RegExp(r'^Image "(.+)" is ([0-9.]+) MB .+ max 1 MB\.$').firstMatch(message); if (imageTooLarge != null) { @@ -1564,9 +1582,6 @@ class _DraftListItem extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - return InkWell( onTap: onTap, child: Padding( @@ -1582,24 +1597,27 @@ class _DraftListItem extends StatelessWidget { draft.content.isEmpty ? context.l10n.messageComposeNoText : draft.content, maxLines: 2, overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith(height: 1.35), + style: context.textTheme.bodyMedium?.copyWith(height: 1.35), ), const SizedBox(height: 6), Row( children: [ Text( formattedTime, - style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), ), if (draft.scheduledAt != null) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration(color: colorScheme.primary, borderRadius: BorderRadius.circular(4)), + decoration: BoxDecoration( + color: context.colorScheme.primary, + borderRadius: BorderRadius.circular(4), + ), child: Text( context.l10n.labelScheduled, - style: theme.textTheme.labelSmall?.copyWith( - color: colorScheme.onPrimary, + style: context.textTheme.labelSmall?.copyWith( + color: context.colorScheme.onPrimary, fontWeight: FontWeight.w700, ), ), @@ -1611,7 +1629,7 @@ class _DraftListItem extends StatelessWidget { ), ), IconButton( - icon: Icon(Icons.delete_outline, color: colorScheme.error), + icon: Icon(Icons.delete_outline, color: context.colorScheme.error), onPressed: onDelete, tooltip: context.l10n.labelDeleteDraft, visualDensity: VisualDensity.compact, @@ -1637,7 +1655,6 @@ class _FacetHighlightController extends TextEditingController { final entities = BlueskyText(text).entities.where((e) => e.type != EntityType.markdownLink).toList(); if (entities.isEmpty) return TextSpan(style: style, text: text); - final colorScheme = context.colorScheme; final textBytes = utf8.encode(text); final spans = []; int lastCharEnd = 0; @@ -1654,11 +1671,11 @@ class _FacetHighlightController extends TextEditingController { final Color entityColor; if (entity.isHandle) { - entityColor = colorScheme.primary; + entityColor = context.colorScheme.primary; } else if (entity.isLink) { - entityColor = colorScheme.tertiary; + entityColor = context.colorScheme.tertiary; } else { - entityColor = colorScheme.secondary; + entityColor = context.colorScheme.secondary; } spans.add( @@ -1685,385 +1702,6 @@ class _FacetHighlightController extends TextEditingController { } } -class _ImageAltTextDialog extends StatefulWidget { - const _ImageAltTextDialog({ - required this.imagePath, - required this.initialAltText, - required this.onCancel, - required this.onSave, - }); - - final String imagePath; - final String initialAltText; - final VoidCallback onCancel; - final ValueChanged onSave; - - @override - State<_ImageAltTextDialog> createState() => _ImageAltTextDialogState(); -} - -class _ImageAltTextDialogState extends State<_ImageAltTextDialog> { - late final TextEditingController _controller = TextEditingController(text: widget.initialAltText); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final size = MediaQuery.sizeOf(context); - final imageHeight = (size.height * 0.32).clamp(140.0, 280.0).toDouble(); - - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) { - if (!didPop) widget.onSave(_controller.text); - }, - child: Dialog( - clipBehavior: Clip.antiAlias, - insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: 560, maxHeight: size.height * 0.9), - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text(context.l10n.messageComposeImageAltTextTitle, style: theme.textTheme.titleLarge), - ), - IconButton( - tooltip: context.l10n.labelClose, - onPressed: () => widget.onSave(_controller.text), - icon: const Icon(Icons.close), - ), - ], - ), - const SizedBox(height: 12), - Container( - height: imageHeight, - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.6)), - ), - child: Image.file( - key: const ValueKey('alt-text-image-preview'), - File(widget.imagePath), - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) => - Center(child: Icon(Icons.broken_image_outlined, size: 40, color: colorScheme.onSurfaceVariant)), - ), - ), - const SizedBox(height: 16), - TextField( - key: const ValueKey('alt-text-field'), - controller: _controller, - minLines: 3, - maxLines: 5, - maxLength: 1000, - textInputAction: TextInputAction.newline, - decoration: InputDecoration( - hintText: context.l10n.messageComposeDescribeImage, - border: const OutlineInputBorder(), - ), - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton(onPressed: widget.onCancel, child: Text(context.l10n.buttonCancel)), - const SizedBox(width: 8), - FilledButton( - onPressed: () => widget.onSave(_controller.text), - child: Text(context.l10n.buttonSave), - ), - ], - ), - ], - ), - ), - ), - ), - ); - } -} - -class _VideoAltTextDialog extends StatefulWidget { - const _VideoAltTextDialog({required this.video, required this.onCancel, required this.onSave}); - - final VideoAttachment video; - final VoidCallback onCancel; - final ValueChanged onSave; - - @override - State<_VideoAltTextDialog> createState() => _VideoAltTextDialogState(); -} - -class _VideoAltTextDialogState extends State<_VideoAltTextDialog> { - late final TextEditingController _controller = TextEditingController(text: widget.video.altText); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final size = MediaQuery.sizeOf(context); - final previewHeight = (size.height * 0.32).clamp(140.0, 280.0).toDouble(); - - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) { - if (!didPop) widget.onSave(_controller.text); - }, - child: Dialog( - clipBehavior: Clip.antiAlias, - insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: 560, maxHeight: size.height * 0.9), - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text(context.l10n.messageComposeVideoAltTextTitle, style: theme.textTheme.titleLarge), - ), - IconButton( - tooltip: context.l10n.labelClose, - onPressed: () => widget.onSave(_controller.text), - icon: const Icon(Icons.close), - ), - ], - ), - const SizedBox(height: 12), - _LocalVideoPreview(videoPath: widget.video.localPath, height: previewHeight), - const SizedBox(height: 16), - TextField( - key: const ValueKey('video-alt-text-field'), - controller: _controller, - minLines: 3, - maxLines: 5, - maxLength: 1000, - textInputAction: TextInputAction.newline, - decoration: InputDecoration( - hintText: context.l10n.messageComposeDescribeVideo, - border: const OutlineInputBorder(), - ), - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton(onPressed: widget.onCancel, child: Text(context.l10n.buttonCancel)), - const SizedBox(width: 8), - FilledButton( - onPressed: () => widget.onSave(_controller.text), - child: Text(context.l10n.buttonSave), - ), - ], - ), - ], - ), - ), - ), - ), - ); - } -} - -class _LocalVideoPreview extends StatefulWidget { - const _LocalVideoPreview({required this.videoPath, required this.height}); - - final String videoPath; - final double height; - - @override - State<_LocalVideoPreview> createState() => _LocalVideoPreviewState(); -} - -class _LocalVideoPreviewState extends State<_LocalVideoPreview> { - VideoPlayerController? _controller; - Object? _error; - bool _isInitializing = false; - - @override - void initState() { - super.initState(); - _initialize(); - } - - @override - void dispose() { - _controller?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final controller = _controller; - final filename = widget.videoPath.split('/').last; - - return Container( - key: const ValueKey('video-alt-preview'), - height: widget.height, - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.6)), - ), - child: GestureDetector( - onTap: controller != null && controller.value.isInitialized ? _togglePlayback : null, - child: Stack( - fit: StackFit.expand, - alignment: Alignment.center, - children: [ - if (controller != null && controller.value.isInitialized) - FittedBox( - fit: BoxFit.contain, - child: SizedBox( - width: controller.value.size.width, - height: controller.value.size.height, - child: VideoPlayer(controller), - ), - ) - else - _VideoPreviewFallback(filename: filename, isLoading: _isInitializing, error: _error), - Center( - child: Container( - width: 56, - height: 56, - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.62), shape: BoxShape.circle), - child: Icon( - controller?.value.isPlaying == true ? Icons.pause : Icons.play_arrow, - color: Colors.white, - size: 30, - ), - ), - ), - ], - ), - ), - ); - } - - Future _initialize() async { - final file = File(widget.videoPath); - if (!file.existsSync()) { - return; - } - - setState(() { - _isInitializing = true; - }); - - try { - final controller = VideoPlayerController.file(file); - await controller.initialize(); - await controller.setLooping(true); - - if (!mounted) { - await controller.dispose(); - return; - } - - setState(() { - _controller = controller; - _isInitializing = false; - }); - } catch (error) { - if (!mounted) { - return; - } - setState(() { - _error = error; - _isInitializing = false; - }); - } - } - - Future _togglePlayback() async { - final controller = _controller; - if (controller == null || !controller.value.isInitialized) { - return; - } - - if (controller.value.isPlaying) { - await controller.pause(); - } else { - await controller.play(); - } - - if (mounted) { - setState(() {}); - } - } -} - -class _VideoPreviewFallback extends StatelessWidget { - const _VideoPreviewFallback({required this.filename, required this.isLoading, required this.error}); - - final String filename; - final bool isLoading; - final Object? error; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - - return Center( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (isLoading) - const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.4)) - else - Icon(Icons.videocam_outlined, size: 40, color: colorScheme.onSurfaceVariant), - const SizedBox(height: 10), - Text( - filename.isEmpty ? context.l10n.labelVideo : filename, - key: const ValueKey('video-alt-preview-filename'), - style: theme.textTheme.bodyMedium, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ), - if (error != null) ...[ - const SizedBox(height: 4), - Text( - context.l10n.messageComposePreviewUnavailable, - style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), - textAlign: TextAlign.center, - ), - ], - ], - ), - ), - ); - } -} - class _CharCounter extends StatelessWidget { const _CharCounter({required this.count, required this.maxCount}); @@ -2074,14 +1712,13 @@ class _CharCounter extends StatelessWidget { Widget build(BuildContext context) { final remaining = maxCount - count; final progress = count / maxCount; - final theme = Theme.of(context); Color color; if (progress < 0.8) { - color = theme.colorScheme.primary; + color = context.colorScheme.primary; } else if (progress < 0.95) { - color = theme.colorScheme.error.withValues(alpha: 0.7); + color = context.colorScheme.error.withValues(alpha: 0.7); } else { - color = theme.colorScheme.error; + color = context.colorScheme.error; } return Row( @@ -2089,9 +1726,10 @@ class _CharCounter extends StatelessWidget { children: [ Text( '$remaining', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: color, fontFeatures: const [FontFeature.tabularFigures()]), + style: context.textTheme.bodySmall?.copyWith( + color: color, + fontFeatures: const [FontFeature.tabularFigures()], + ), ), const SizedBox(width: 8), SizedBox( @@ -2101,7 +1739,7 @@ class _CharCounter extends StatelessWidget { painter: _ProgressRingPainter( progress: progress.clamp(0.0, 1.0), color: color, - backgroundColor: theme.colorScheme.surfaceContainerHighest, + backgroundColor: context.colorScheme.surfaceContainerHighest, ), ), ), diff --git a/lib/features/compose/presentation/widgets/compose_alt_text_dialog.dart b/lib/features/compose/presentation/widgets/compose_alt_text_dialog.dart new file mode 100644 index 0000000..beae26c --- /dev/null +++ b/lib/features/compose/presentation/widgets/compose_alt_text_dialog.dart @@ -0,0 +1,209 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; +import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; +import 'package:lazurite/features/compose/presentation/widgets/compose_video_preview.dart'; + +class ImageAltTextDialog extends StatefulWidget { + const ImageAltTextDialog({ + super.key, + required this.imagePath, + required this.initialAltText, + required this.onCancel, + required this.onSave, + }); + + final String imagePath; + final String initialAltText; + final VoidCallback onCancel; + final ValueChanged onSave; + + @override + State createState() => _ImageAltTextDialogState(); +} + +class _ImageAltTextDialogState extends State { + late final TextEditingController _controller = TextEditingController(text: widget.initialAltText); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final size = MediaQuery.sizeOf(context); + final imageHeight = (size.height * 0.32).clamp(140.0, 280.0).toDouble(); + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) widget.onSave(_controller.text); + }, + child: Dialog( + clipBehavior: Clip.antiAlias, + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 560, maxHeight: size.height * 0.9), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text(context.l10n.messageComposeImageAltTextTitle, style: theme.textTheme.titleLarge), + ), + IconButton( + tooltip: context.l10n.labelClose, + onPressed: () => widget.onSave(_controller.text), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 12), + Container( + height: imageHeight, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.6)), + ), + child: Image.file( + key: const ValueKey('alt-text-image-preview'), + File(widget.imagePath), + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) => + Center(child: Icon(Icons.broken_image_outlined, size: 40, color: colorScheme.onSurfaceVariant)), + ), + ), + const SizedBox(height: 16), + TextField( + key: const ValueKey('alt-text-field'), + controller: _controller, + minLines: 3, + maxLines: 5, + maxLength: 1000, + textInputAction: TextInputAction.newline, + decoration: InputDecoration( + hintText: context.l10n.messageComposeDescribeImage, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton(onPressed: widget.onCancel, child: Text(context.l10n.buttonCancel)), + const SizedBox(width: 8), + FilledButton( + onPressed: () => widget.onSave(_controller.text), + child: Text(context.l10n.buttonSave), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +class VideoAltTextDialog extends StatefulWidget { + const VideoAltTextDialog({super.key, required this.video, required this.onCancel, required this.onSave}); + + final VideoAttachment video; + final VoidCallback onCancel; + final ValueChanged onSave; + + @override + State createState() => _VideoAltTextDialogState(); +} + +class _VideoAltTextDialogState extends State { + late final TextEditingController _controller = TextEditingController(text: widget.video.altText); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final size = MediaQuery.sizeOf(context); + final previewHeight = (size.height * 0.32).clamp(140.0, 280.0).toDouble(); + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) widget.onSave(_controller.text); + }, + child: Dialog( + clipBehavior: Clip.antiAlias, + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 560, maxHeight: size.height * 0.9), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text(context.l10n.messageComposeVideoAltTextTitle, style: theme.textTheme.titleLarge), + ), + IconButton( + tooltip: context.l10n.labelClose, + onPressed: () => widget.onSave(_controller.text), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 12), + LocalVideoPreview(videoPath: widget.video.localPath, height: previewHeight), + const SizedBox(height: 16), + TextField( + key: const ValueKey('video-alt-text-field'), + controller: _controller, + minLines: 3, + maxLines: 5, + maxLength: 1000, + textInputAction: TextInputAction.newline, + decoration: InputDecoration( + hintText: context.l10n.messageComposeDescribeVideo, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton(onPressed: widget.onCancel, child: Text(context.l10n.buttonCancel)), + const SizedBox(width: 8), + FilledButton( + onPressed: () => widget.onSave(_controller.text), + child: Text(context.l10n.buttonSave), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/compose/presentation/widgets/compose_video_preview.dart b/lib/features/compose/presentation/widgets/compose_video_preview.dart new file mode 100644 index 0000000..ec4cb0d --- /dev/null +++ b/lib/features/compose/presentation/widgets/compose_video_preview.dart @@ -0,0 +1,183 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; +import 'package:video_player/video_player.dart'; + +class LocalVideoPreview extends StatefulWidget { + const LocalVideoPreview({super.key, required this.videoPath, required this.height}); + + final String videoPath; + final double height; + + @override + State createState() => _LocalVideoPreviewState(); +} + +class _LocalVideoPreviewState extends State { + VideoPlayerController? _controller; + Object? _error; + bool _isInitializing = false; + + @override + void initState() { + super.initState(); + _initialize(); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final controller = _controller; + final filename = widget.videoPath.split('/').last; + + return Container( + key: const ValueKey('video-alt-preview'), + height: widget.height, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.6)), + ), + child: GestureDetector( + onTap: controller != null && controller.value.isInitialized ? _togglePlayback : null, + child: Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + if (controller != null && controller.value.isInitialized) + FittedBox( + fit: BoxFit.contain, + child: SizedBox( + width: controller.value.size.width, + height: controller.value.size.height, + child: VideoPlayer(controller), + ), + ) + else + VideoPreviewFallback(filename: filename, isLoading: _isInitializing, error: _error), + Center( + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.62), shape: BoxShape.circle), + child: Icon( + controller?.value.isPlaying == true ? Icons.pause : Icons.play_arrow, + color: Colors.white, + size: 30, + ), + ), + ), + ], + ), + ), + ); + } + + Future _initialize() async { + final file = File(widget.videoPath); + if (!file.existsSync()) { + return; + } + + setState(() { + _isInitializing = true; + }); + + try { + final controller = VideoPlayerController.file(file); + await controller.initialize(); + await controller.setLooping(true); + + if (!mounted) { + await controller.dispose(); + return; + } + + setState(() { + _controller = controller; + _isInitializing = false; + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _error = error; + _isInitializing = false; + }); + } + } + + Future _togglePlayback() async { + final controller = _controller; + if (controller == null || !controller.value.isInitialized) { + return; + } + + if (controller.value.isPlaying) { + await controller.pause(); + } else { + await controller.play(); + } + + if (mounted) { + setState(() {}); + } + } +} + +class VideoPreviewFallback extends StatelessWidget { + const VideoPreviewFallback({super.key, required this.filename, required this.isLoading, required this.error}); + + final String filename; + final bool isLoading; + final Object? error; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isLoading) + const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.4)) + else + Icon(Icons.videocam_outlined, size: 40, color: colorScheme.onSurfaceVariant), + const SizedBox(height: 10), + Text( + filename.isEmpty ? context.l10n.labelVideo : filename, + key: const ValueKey('video-alt-preview-filename'), + style: theme.textTheme.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + if (error != null) ...[ + const SizedBox(height: 4), + Text( + context.l10n.messageComposePreviewUnavailable, + style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/feed/presentation/media/image_viewer_route_args.dart b/lib/features/feed/presentation/media/image_viewer_route_args.dart index 9d96999..42f44ef 100644 --- a/lib/features/feed/presentation/media/image_viewer_route_args.dart +++ b/lib/features/feed/presentation/media/image_viewer_route_args.dart @@ -1,13 +1,20 @@ import 'package:lazurite/features/feed/presentation/media/media_route_payload_codec.dart'; +/// Route arguments needed to open the full-screen image gallery. +/// +/// These args are passed through `GoRouterState.extra` during normal in-app +/// taps, but they also know how to serialize themselves into the route URL so +/// `/images` can be restored after router refreshes or app process restoration. class ImageViewerRouteArgs { const ImageViewerRouteArgs({required this.images, required this.initialIndex}); final List images; final int initialIndex; + /// Whether the route has at least one image and points at an existing item. bool get isValid => images.isNotEmpty && initialIndex >= 0 && initialIndex < images.length; + /// Reconstructable route location for this image viewer state. String get location => MediaRoutePayloadCodec.location( path: '/images', payload: { @@ -16,10 +23,12 @@ class ImageViewerRouteArgs { }, ); + /// Parses args from transient router [extra], falling back to [uri]. static ImageViewerRouteArgs? tryParse(Object? extra, Uri uri) { return tryParseExtra(extra) ?? tryParseUri(uri); } + /// Parses the in-memory route args supplied by an in-app image tap. static ImageViewerRouteArgs? tryParseExtra(Object? extra) { if (extra is! ImageViewerRouteArgs || !extra.isValid) { return null; @@ -27,6 +36,7 @@ class ImageViewerRouteArgs { return extra; } + /// Parses route args encoded into `/images?payload=...`. static ImageViewerRouteArgs? tryParseUri(Uri uri) { final decoded = MediaRoutePayloadCodec.tryDecode(uri); if (decoded == null) { @@ -53,6 +63,11 @@ class ImageViewerRouteArgs { } } +/// A single image entry in the full-screen gallery. +/// +/// The viewer keeps both full-size and thumbnail URLs such that the full-size +/// URL is shown/downloaded, while the thumbnail URL preserves enough source +/// metadata for route restoration and future viewer UI changes. class ImageViewerItem { const ImageViewerItem({required this.fullsizeUrl, required this.thumbnailUrl, required this.heroTag, this.altText}); @@ -61,6 +76,7 @@ class ImageViewerItem { final String heroTag; final String? altText; + /// Encodes this item into the image viewer route payload. Map toJson() => { 'fullsizeUrl': fullsizeUrl, 'thumbnailUrl': thumbnailUrl, @@ -68,6 +84,7 @@ class ImageViewerItem { if (altText != null) 'altText': altText, }; + /// Parses an image item from a route payload object. static ImageViewerItem? tryParseJson(Object? json) { if (json is! Map) { return null; diff --git a/lib/features/feed/presentation/media/image_viewer_screen.dart b/lib/features/feed/presentation/media/image_viewer_screen.dart index 47ec9af..d58469d 100644 --- a/lib/features/feed/presentation/media/image_viewer_screen.dart +++ b/lib/features/feed/presentation/media/image_viewer_screen.dart @@ -5,6 +5,11 @@ import 'package:lazurite/features/feed/presentation/media/image_viewer_route_arg import 'package:lazurite/features/feed/presentation/media/media_actions.dart'; import 'package:photo_view/photo_view_gallery.dart'; +/// Full-screen, zoomable gallery for one or more images in a post embed. +/// +/// The screen assumes [args] have already been validated by the router. It owns +/// viewer-only interaction state such as the current page, swipe-to-dismiss +/// offset, and download progress. class ImageViewerScreen extends StatefulWidget { const ImageViewerScreen({super.key, required this.args}); @@ -14,6 +19,10 @@ class ImageViewerScreen extends StatefulWidget { State createState() => _ImageViewerScreenState(); } +/// Runtime state for the image viewer overlay. +/// +/// Keeps the selected image index in sync with the page view and handles the +/// lightweight vertical drag gesture used to dismiss the viewer. class _ImageViewerScreenState extends State { late final PageController _pageController; late int _currentIndex; diff --git a/lib/features/feed/presentation/media/media_actions.dart b/lib/features/feed/presentation/media/media_actions.dart index bc841be..235c11e 100644 --- a/lib/features/feed/presentation/media/media_actions.dart +++ b/lib/features/feed/presentation/media/media_actions.dart @@ -9,8 +9,13 @@ import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; +/// Media categories that require gallery/storage permissions before saving. enum MediaAssetType { image, video } +/// Share and download actions used by the full-screen media viewers and embeds. +/// +/// This class keeps platform permission handling, temporary downloads, gallery +/// writes, and user-facing snack bars out of the viewer widgets themselves. class MediaActions { MediaActions._(); diff --git a/lib/features/feed/presentation/media/media_route_payload_codec.dart b/lib/features/feed/presentation/media/media_route_payload_codec.dart index 646bcf6..e168774 100644 --- a/lib/features/feed/presentation/media/media_route_payload_codec.dart +++ b/lib/features/feed/presentation/media/media_route_payload_codec.dart @@ -1,10 +1,19 @@ import 'dart:convert'; +/// Encodes and decodes media viewer route payloads. +/// +/// The image and video viewers are full-screen routes that are often opened +/// with rich in-memory arguments. GoRouter's `extra` field is transient, so it +/// can disappear when a route is restored from its URL. This codec stores the +/// minimum viewer state in a URL-safe `payload` query parameter so the route can +/// be reconstructed without relying only on `extra`. class MediaRoutePayloadCodec { const MediaRoutePayloadCodec._(); + /// Query parameter containing the base64url-encoded JSON payload. static const payloadQueryParameter = 'payload'; + /// Builds a route location for [path] with [payload] encoded in the query. static String location({required String path, required Map payload}) { return Uri( path: path, @@ -12,6 +21,10 @@ class MediaRoutePayloadCodec { ).toString(); } + /// Decodes a route payload from [uri]. + /// + /// Returns `null` instead of throwing for missing, malformed, or non-object + /// payloads because route parsing must be safe for arbitrary external URLs. static Map? tryDecode(Uri uri) { final encodedPayload = uri.queryParameters[payloadQueryParameter]; if (encodedPayload == null || encodedPayload.isEmpty) { diff --git a/lib/features/feed/presentation/media/video_player_route_args.dart b/lib/features/feed/presentation/media/video_player_route_args.dart index 9f9f26b..a0a1b46 100644 --- a/lib/features/feed/presentation/media/video_player_route_args.dart +++ b/lib/features/feed/presentation/media/video_player_route_args.dart @@ -1,5 +1,10 @@ import 'package:lazurite/features/feed/presentation/media/media_route_payload_codec.dart'; +/// Route arguments needed to open the full-screen video player. +/// +/// The route receives these args through `GoRouterState.extra` for normal +/// in-app navigation and encodes the same state into `/video?payload=...` so the +/// player can be restored if `extra` is unavailable. class VideoPlayerRouteArgs { const VideoPlayerRouteArgs({ required this.playlistUrl, @@ -17,10 +22,13 @@ class VideoPlayerRouteArgs { final double? aspectRatio; final bool isGif; + /// Whether the route has a playable source URL. bool get isValid => playlistUrl.isNotEmpty; + /// Reconstructable route location for this video player state. String get location => MediaRoutePayloadCodec.location(path: '/video', payload: toJson()); + /// Encodes this video into the route payload. Map toJson() => { 'playlistUrl': playlistUrl, if (downloadUrl != null) 'downloadUrl': downloadUrl, @@ -30,10 +38,12 @@ class VideoPlayerRouteArgs { 'isGif': isGif, }; + /// Parses args from transient router [extra], falling back to [uri]. static VideoPlayerRouteArgs? tryParse(Object? extra, Uri uri) { return tryParseExtra(extra) ?? tryParseUri(uri); } + /// Parses the in-memory route args supplied by an in-app video tap. static VideoPlayerRouteArgs? tryParseExtra(Object? extra) { if (extra is! VideoPlayerRouteArgs || !extra.isValid) { return null; @@ -41,6 +51,7 @@ class VideoPlayerRouteArgs { return extra; } + /// Parses route args encoded into `/video?payload=...`. static VideoPlayerRouteArgs? tryParseUri(Uri uri) { final decoded = MediaRoutePayloadCodec.tryDecode(uri); if (decoded == null) { @@ -49,6 +60,7 @@ class VideoPlayerRouteArgs { return tryParseJson(decoded); } + /// Parses a video route payload object. static VideoPlayerRouteArgs? tryParseJson(Map json) { final playlistUrl = json['playlistUrl']; final downloadUrl = json['downloadUrl']; diff --git a/lib/features/feed/presentation/media/video_player_screen.dart b/lib/features/feed/presentation/media/video_player_screen.dart index 578dc85..5dcc45e 100644 --- a/lib/features/feed/presentation/media/video_player_screen.dart +++ b/lib/features/feed/presentation/media/video_player_screen.dart @@ -9,6 +9,11 @@ import 'package:lazurite/features/feed/presentation/media/video_layout.dart'; import 'package:lazurite/features/feed/presentation/media/video_player_route_args.dart'; import 'package:video_player/video_player.dart'; +/// Full-screen player for a video embedded in a feed post. +/// +/// The screen receives validated [VideoPlayerRouteArgs] from the router, then +/// creates and owns the platform video controller and Chewie UI controller for +/// the lifetime of the route. class VideoPlayerScreen extends StatefulWidget { const VideoPlayerScreen({super.key, required this.args}); @@ -18,6 +23,10 @@ class VideoPlayerScreen extends StatefulWidget { State createState() => _VideoPlayerScreenState(); } +/// Runtime state for video playback, initialization errors, and downloads. +/// +/// Controller setup is asynchronous, so this state keeps the placeholder, +/// failure UI, and playback UI mutually exclusive while initialization runs. class _VideoPlayerScreenState extends State { VideoPlayerController? _videoController; ChewieController? _chewieController;