diff --git a/lib/app/lazurite_app.dart b/lib/app/lazurite_app.dart new file mode 100644 index 0000000..9f6dff7 --- /dev/null +++ b/lib/app/lazurite_app.dart @@ -0,0 +1,670 @@ +import 'dart:async'; + +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/cache/local_cache_maintenance_service.dart'; +import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/embedding/embedding_service.dart'; +import 'package:lazurite/core/l10n/app_localizations.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/core/logging/logging_navigator_observer.dart'; +import 'package:lazurite/core/network/app_view_fallback_service.dart'; +import 'package:lazurite/core/network/poptart_client_adapter.dart'; +import 'package:lazurite/core/network/xrpc_client_factory.dart'; +import 'package:lazurite/core/objectbox/objectbox_store.dart'; +import 'package:lazurite/core/router/app_router.dart'; +import 'package:lazurite/core/theme/app_theme.dart'; +import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/auth/data/auth_repository.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; +import 'package:lazurite/features/connectivity/presentation/connectivity_banner_host.dart'; +import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; +import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; +import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; +import 'package:lazurite/features/feed/cubit/liked_posts_sync_cubit.dart'; +import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; +import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; +import 'package:lazurite/features/feed/data/feed_repository.dart'; +import 'package:lazurite/features/feed/data/liked_posts_repository.dart'; +import 'package:lazurite/features/feed/data/post_action_repository.dart'; +import 'package:lazurite/features/feed/data/post_thread_repository.dart'; +import 'package:lazurite/features/lists/data/list_repository.dart'; +import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; +import 'package:lazurite/features/messages/data/convo_repository.dart'; +import 'package:lazurite/features/moderation/data/moderation_service.dart'; +import 'package:lazurite/features/notifications/background/notification_background_worker.dart'; +import 'package:lazurite/features/notifications/data/notification_repository.dart'; +import 'package:lazurite/features/notifications/domain/local_notification_adapter.dart'; +import 'package:lazurite/features/notifications/domain/notification_deep_link_navigator.dart'; +import 'package:lazurite/features/notifications/domain/notification_domain_service.dart'; +import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; +import 'package:lazurite/features/notifications/domain/push_registration_service.dart'; +import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; +import 'package:lazurite/features/profile/data/profile_action_repository.dart'; +import 'package:lazurite/features/profile/data/profile_repository.dart'; +import 'package:lazurite/features/search/bloc/search_bloc.dart'; +import 'package:lazurite/features/search/cubit/semantic_index_cubit.dart'; +import 'package:lazurite/features/search/cubit/semantic_search_cubit.dart'; +import 'package:lazurite/features/search/data/embedding_repository.dart'; +import 'package:lazurite/features/search/data/search_repository.dart'; +import 'package:lazurite/features/search/data/semantic_indexer.dart'; +import 'package:lazurite/features/search/data/semantic_search_repository.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/settings/bloc/settings_state.dart'; +import 'package:lazurite/features/settings/data/video_repository.dart'; +import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; +import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; +import 'package:lazurite/shared/presentation/widgets/global_tap_outside_unfocus.dart'; + +class LazuriteApp extends StatefulWidget { + const LazuriteApp({ + super.key, + required this.authBloc, + required this.authRepository, + required this.database, + required this.appViewFallbackService, + required this.objectBoxStore, + required this.embeddingService, + required this.settingsCubit, + required this.connectivityCubit, + required this.accountSwitcherCubit, + required this.localNotificationAdapter, + required this.pushRegistrationService, + required this.crashReportingService, + required this.firebaseAvailable, + }); + + final AuthBloc authBloc; + final AuthRepository authRepository; + final AppDatabase database; + final AppViewFallbackService appViewFallbackService; + final ObjectBoxStore objectBoxStore; + final EmbeddingService embeddingService; + final SettingsCubit settingsCubit; + final ConnectivityCubit connectivityCubit; + final AccountSwitcherCubit accountSwitcherCubit; + final LocalNotificationAdapter localNotificationAdapter; + final PushRegistrationService pushRegistrationService; + final CrashReportingService crashReportingService; + final bool firebaseAvailable; + + /// factory constructor with positional params + static LazuriteApp from( + AuthBloc authBloc, + AuthRepository authRepository, + AppDatabase database, + AppViewFallbackService appViewFallbackService, + ObjectBoxStore objectBoxStore, + EmbeddingService embeddingService, + SettingsCubit settingsCubit, + ConnectivityCubit connectivityCubit, + AccountSwitcherCubit accountSwitcherCubit, + LocalNotificationAdapter localNotificationAdapter, + PushRegistrationService pushRegistrationService, + CrashReportingService crashReportingService, + bool firebaseAvailable, + ) => LazuriteApp( + authBloc: authBloc, + authRepository: authRepository, + database: database, + appViewFallbackService: appViewFallbackService, + objectBoxStore: objectBoxStore, + embeddingService: embeddingService, + settingsCubit: settingsCubit, + connectivityCubit: connectivityCubit, + accountSwitcherCubit: accountSwitcherCubit, + localNotificationAdapter: localNotificationAdapter, + pushRegistrationService: pushRegistrationService, + crashReportingService: crashReportingService, + firebaseAvailable: firebaseAvailable, + ); + + @override + State createState() => _LazuriteAppState(); +} + +class _LazuriteAppState extends State with WidgetsBindingObserver { + static final _navigatorObserver = LoggingNavigatorObserver(); + late GoRouter _router; + late String _routerSessionKey; + late final StreamSubscription _authSubscription; + late final StreamSubscription _pushRegistrationSubscription; + StreamSubscription? _pushForegroundMessageSubscription; + late final StreamSubscription _simulateOfflineSubscription; + late final StreamSubscription _appViewProviderSubscription; + late final StreamSubscription _appViewEventSubscription; + late String _observedAppViewProvider; + var _routerGeneration = 0; + var _isSoftRestarting = false; + Completer? _authRecoveryCompleter; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _routerSessionKey = _sessionKeyFor(widget.authBloc.state); + _observedAppViewProvider = widget.settingsCubit.state.appViewProvider; + _router = _createRouter(); + unawaited( + widget.localNotificationAdapter.initialize(onTap: _handleNotificationDeepLink).then((_) { + return widget.localNotificationAdapter.requestPermissions(); + }), + ); + widget.pushRegistrationService.configureAuthRecovery( + () => _recoverAuthSession(trigger: 'push_registration_unauthorized'), + ); + unawaited(widget.pushRegistrationService.start(initialTokens: widget.authBloc.state.tokens)); + _pushRegistrationSubscription = widget.authBloc.stream.map((state) => state.tokens).listen((tokens) { + unawaited(widget.pushRegistrationService.updateSession(tokens)); + }); + if (widget.firebaseAvailable) { + _pushForegroundMessageSubscription = FirebaseMessaging.onMessage.listen((message) { + unawaited(notificationPushPayloadEntrypoint(message.data)); + }); + } + _authSubscription = widget.authBloc.stream.map(_sessionKeyFor).distinct().listen(_handleSessionKeyChanged); + _simulateOfflineSubscription = widget.settingsCubit.stream + .map((state) => state.simulateOffline) + .distinct() + .listen(widget.connectivityCubit.setSimulatedOffline); + _appViewProviderSubscription = widget.settingsCubit.stream.map((state) => state.appViewProvider).listen((provider) { + if (provider == _observedAppViewProvider) { + return; + } + + _observedAppViewProvider = provider; + + if (!widget.authBloc.state.isAuthenticated) { + return; + } + + unawaited(_softRestartForProviderChange()); + }); + _appViewEventSubscription = widget.appViewFallbackService.events.listen( + widget.settingsCubit.recordAppViewRoutingEvent, + ); + + unawaited(widget.settingsCubit.refreshAppViewHealth()); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _authSubscription.cancel(); + _pushRegistrationSubscription.cancel(); + _pushForegroundMessageSubscription?.cancel(); + _simulateOfflineSubscription.cancel(); + _appViewProviderSubscription.cancel(); + _appViewEventSubscription.cancel(); + unawaited(widget.pushRegistrationService.dispose()); + + widget.connectivityCubit.close(); + widget.appViewFallbackService.dispose(); + + _router.dispose(); + + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + if (state == AppLifecycleState.resumed) { + unawaited(_refreshExpiredSessionOnResume()); + } + } + + Future _refreshExpiredSessionOnResume() async { + final authState = widget.authBloc.state; + final tokens = authState.tokens; + if (!authState.isAuthenticated || tokens == null || !tokens.isExpired) { + return; + } + await _recoverAuthSession(trigger: 'app_resumed'); + } + + Future _recoverAuthSession({required String trigger}) async { + final inFlight = _authRecoveryCompleter; + if (inFlight != null) { + return inFlight.future; + } + + final completer = Completer(); + _authRecoveryCompleter = completer; + String? refreshingDid; + try { + final authState = widget.authBloc.state; + final tokens = authState.tokens; + if (!authState.isAuthenticated || tokens == null || tokens.refreshToken == null) { + completer.complete(null); + return null; + } + refreshingDid = tokens.did; + + final refreshed = await widget.authRepository.refreshSession(tokens); + if (!_canPublishRecoveryForDid(refreshingDid)) { + completer.complete(null); + return null; + } + + if (refreshed == null || refreshed.did != refreshingDid) { + completer.complete(null); + return null; + } + widget.authBloc.add(SessionRestored(tokens: refreshed)); + completer.complete(refreshed); + return refreshed; + } catch (error, stackTrace) { + log.w('Auth recovery failed (trigger=$trigger)', error: error, stackTrace: stackTrace); + if (_canPublishRecoveryForDid(refreshingDid)) { + widget.authBloc.add(const CheckSessionRequested()); + } + completer.complete(null); + return null; + } finally { + if (identical(_authRecoveryCompleter, completer)) { + _authRecoveryCompleter = null; + } + } + } + + bool _canPublishRecoveryForDid(String? refreshingDid) { + if (!mounted || refreshingDid == null) { + return false; + } + + final state = widget.authBloc.state; + return state.isAuthenticated && state.tokens?.did == refreshingDid; + } + + GoRouter _createRouter() { + return AppRouter( + authBloc: widget.authBloc, + navigatorObserver: _navigatorObserver, + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), + ).router; + } + + String _sessionKeyFor(AuthState state) => state.tokens?.did ?? 'guest'; + + void _handleSessionKeyChanged(String sessionKey) { + if (!mounted || sessionKey == _routerSessionKey) { + return; + } + + final previousRouter = _router; + setState(() { + _routerSessionKey = sessionKey; + _router = _createRouter(); + }); + previousRouter.dispose(); + } + + Future _softRestartForProviderChange() async { + if (!mounted || _isSoftRestarting) { + return; + } + + setState(() { + _isSoftRestarting = true; + }); + + final previousRouter = _router; + + widget.settingsCubit.bumpRoutingEpoch(); + + setState(() { + _routerGeneration += 1; + _router = _createRouter(); + }); + + previousRouter.dispose(); + await Future.delayed(const Duration(milliseconds: 200)); + + if (mounted) { + setState(() { + _isSoftRestarting = false; + }); + unawaited(widget.settingsCubit.refreshAppViewHealth()); + } + } + + void _handleNotificationDeepLink(NotificationDeepLink deepLink) { + if (!mounted) { + return; + } + NotificationDeepLinkNavigator.navigate(_router, deepLink); + } + + bool _isAlertsRouteActive() { + final path = _router.routerDelegate.currentConfiguration.uri.path; + return path.startsWith('/alerts'); + } + + Bluesky? _createBluesky(AuthState state) => state.isAuthenticated ? createBlueskyClient(state.tokens) : null; + + BlueskyChat? _createBlueskyChat(AuthState state) => + state.isAuthenticated ? createBlueSkyChatClient(state.tokens) : null; + + @override + Widget build(BuildContext context) { + return MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: widget.crashReportingService), + RepositoryProvider.value(value: widget.database), + RepositoryProvider( + create: (_) => LocalCacheMaintenanceService(database: widget.database, objectBoxStore: widget.objectBoxStore), + ), + ], + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: widget.authBloc), + BlocProvider.value(value: widget.settingsCubit), + BlocProvider.value(value: widget.connectivityCubit), + BlocProvider.value(value: widget.accountSwitcherCubit), + ], + child: BlocBuilder( + builder: (context, authState) { + final bluesky = _createBluesky(authState); + final blueskyChat = _createBlueskyChat(authState); + final appShell = BlocBuilder( + builder: (context, settingsState) { + final themeMode = settingsState.useSystemTheme + ? ThemeMode.system + : (settingsState.themeVariant == AppThemeVariant.light ? ThemeMode.light : ThemeMode.dark); + + final lightTheme = AppTheme.getTheme( + settingsState.themePalette, + AppThemeVariant.light, + headingFontFamily: settingsState.headingFontFamily, + contentFontFamily: settingsState.contentFontFamily, + codeFontFamily: settingsState.codeFontFamily, + ); + final darkTheme = AppTheme.getTheme( + settingsState.themePalette, + AppThemeVariant.dark, + headingFontFamily: settingsState.headingFontFamily, + contentFontFamily: settingsState.contentFontFamily, + codeFontFamily: settingsState.codeFontFamily, + ); + + return MaterialApp.router( + key: ValueKey('router-$_routerSessionKey-$_routerGeneration'), + title: 'Lazurite', + onGenerateTitle: (context) => AppLocalizations.of(context).appTitle, + debugShowCheckedModeBanner: false, + theme: lightTheme, + darkTheme: darkTheme, + themeMode: themeMode, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: _router, + builder: (context, child) => GlobalTapOutsideUnfocus( + child: Stack( + children: [ + ConnectivityBannerHost(child: child ?? const SizedBox.shrink()), + if (_isSoftRestarting) + const ColoredBox( + color: Color(0xC0000000), + child: Center( + child: Card( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2.5), + ), + SizedBox(width: 12), + Text('Applying provider change...'), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + + if (bluesky == null || blueskyChat == null) { + return appShell; + } + + final accountDid = authState.tokens?.did ?? ''; + + return KeyedSubtree( + key: ValueKey('account-$accountDid-routing-${context.read().state.routingEpoch}'), + child: MultiRepositoryProvider( + providers: [ + RepositoryProvider( + create: (_) { + final settingsCubit = context.read(); + final moderationService = ModerationService( + bluesky: bluesky, + database: widget.database, + accountDid: accountDid, + userDid: accountDid, + appViewProviderResolver: () => settingsCubit.state.appViewProvider, + ); + unawaited(moderationService.ensureInitialized()); + return moderationService; + }, + dispose: (moderationService) => moderationService.dispose(), + ), + RepositoryProvider( + create: (context) => FeedRepository( + bluesky: bluesky, + database: widget.database, + accountDid: accountDid, + moderationService: context.read(), + appViewProviderResolver: () => context.read().state.appViewProvider, + crossProviderFallbackEnabledResolver: () => + context.read().state.crossProviderFallbackEnabled, + appViewFallbackService: widget.appViewFallbackService, + routingEpoch: context.read().state.routingEpoch, + routingEpochResolver: () => context.read().state.routingEpoch, + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), + ), + ), + RepositoryProvider( + create: (context) { + final settingsCubit = context.read(); + return SearchRepository( + bluesky: bluesky, + moderationService: context.read(), + appViewProviderResolver: () => settingsCubit.state.appViewProvider, + crossProviderFallbackEnabledResolver: () => settingsCubit.state.crossProviderFallbackEnabled, + appViewFallbackService: widget.appViewFallbackService, + routingEpoch: settingsCubit.state.routingEpoch, + routingEpochResolver: () => settingsCubit.state.routingEpoch, + ); + }, + ), + RepositoryProvider( + create: (context) { + final settingsCubit = context.read(); + return TypeaheadRepository( + bluesky: bluesky, + providerResolver: () => settingsCubit.state.typeaheadProvider, + appViewProviderResolver: () => settingsCubit.state.appViewProvider, + moderationService: context.read(), + ); + }, + ), + RepositoryProvider( + create: (context) => ListRepository( + bluesky: bluesky, + moderationService: context.read(), + appViewProviderResolver: () => context.read().state.appViewProvider, + ), + ), + RepositoryProvider( + create: (context) { + final service = context.read(); + return ProfileRepository( + database: widget.database, + bluesky: bluesky, + moderationService: service, + appViewProviderResolver: () => context.read().state.appViewProvider, + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), + ); + }, + ), + RepositoryProvider( + create: (context) => NotificationRepository( + bluesky: bluesky, + moderationService: context.read(), + appViewProviderResolver: () => context.read().state.appViewProvider, + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), + ), + ), + RepositoryProvider( + create: (context) => NotificationDomainService( + notificationRepository: context.read(), + database: widget.database, + accountDid: accountDid, + localNotificationAdapter: widget.localNotificationAdapter, + shouldSuppressLocalNotifications: _isAlertsRouteActive, + ), + ), + RepositoryProvider( + create: (context) => PostThreadRepository( + bluesky: bluesky, + database: widget.database, + accountDid: accountDid, + moderationService: context.read(), + appViewProviderResolver: () => context.read().state.appViewProvider, + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), + ), + ), + RepositoryProvider( + create: (context) => StarterPackRepository( + bluesky: bluesky, + moderationService: context.read(), + appViewProviderResolver: () => context.read().state.appViewProvider, + ), + ), + RepositoryProvider( + create: (context) => PostActionRepository( + bluesky: bluesky, + appViewProviderResolver: () => context.read().state.appViewProvider, + ), + ), + RepositoryProvider( + create: (context) => ProfileActionRepository( + bluesky: bluesky, + appViewProviderResolver: () => context.read().state.appViewProvider, + ), + ), + RepositoryProvider( + create: (_) => ConvoRepository( + chat: blueskyChat, + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), + ), + ), + RepositoryProvider(create: (_) => PostActionCache()), + RepositoryProvider(create: (_) => VideoRepository(bluesky: bluesky)), + RepositoryProvider.value(value: bluesky), + RepositoryProvider.value(value: widget.objectBoxStore), + RepositoryProvider.value(value: widget.embeddingService), + RepositoryProvider(create: (context) => EmbeddingRepository(context.read())), + RepositoryProvider( + create: (context) => SemanticIndexer( + embeddingService: context.read(), + embeddingRepository: context.read(), + database: widget.database, + ), + ), + RepositoryProvider( + create: (context) => LikedPostsRepository( + bluesky: bluesky, + database: widget.database, + semanticIndexer: context.read(), + appViewProviderResolver: () => context.read().state.appViewProvider, + ), + ), + RepositoryProvider( + create: (context) => SemanticSearchRepository( + embeddingService: context.read(), + embeddingRepository: context.read(), + database: widget.database, + ), + ), + RepositoryProvider.value(value: accountDid), + ], + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => ProfileBloc(profileRepository: context.read()), + ), + BlocProvider(create: (context) => FeedBloc(feedRepository: context.read())), + BlocProvider( + create: (context) => FeedPreferencesCubit( + feedRepository: context.read(), + database: widget.database, + accountDid: accountDid, + )..loadPreferences(), + ), + BlocProvider(create: (_) => DevToolsCubit(atproto: bluesky.atproto)), + BlocProvider( + create: (context) => SearchBloc( + searchRepository: context.read(), + typeaheadRepository: context.read(), + database: widget.database, + accountDid: accountDid, + ), + ), + BlocProvider( + create: (context) => + ConvoListBloc(convoRepository: context.read()) + ..add(const ConvosRequested(limit: 100)), + ), + BlocProvider( + create: (context) => SavedPostsCubit( + database: widget.database, + accountDid: accountDid, + postActionRepository: context.read(), + semanticIndexer: context.read(), + ), + ), + BlocProvider( + create: (context) => SemanticSearchCubit( + repository: context.read(), + embeddingService: context.read(), + accountDid: accountDid, + ), + ), + BlocProvider( + create: (context) => SemanticIndexCubit( + indexer: context.read(), + embeddingRepository: context.read(), + accountDid: accountDid, + ), + ), + BlocProvider( + create: (context) => + LikedPostsSyncCubit(repository: context.read(), accountDid: accountDid), + ), + ], + child: appShell, + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index 7ef78a3..f10cdbb 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -207,15 +207,11 @@ class AppDatabase extends _$AppDatabase { Future getActiveAccount() async { final activeDid = await getSetting(activeAccountDidSettingKey); - if (activeDid != null) { - final activeAccount = await getAccount(activeDid); - if (activeAccount != null) { - return activeAccount; - } + if (activeDid == null) { + return null; } - final all = await (select(accounts)..orderBy([(a) => OrderingTerm.desc(a.updatedAt)])).get(); - return all.isNotEmpty ? all.first : null; + return getAccount(activeDid); } Future> getAllAccounts() => select(accounts).get(); diff --git a/lib/core/router/app_route_page.dart b/lib/core/router/app_route_page.dart index 570d3eb..4aba218 100644 --- a/lib/core/router/app_route_page.dart +++ b/lib/core/router/app_route_page.dart @@ -1,14 +1,47 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:lazurite/core/router/fade_through_page.dart'; +import 'package:lazurite/core/theme/animation_tokens.dart'; +import 'package:lazurite/core/theme/animation_utils.dart'; bool useCupertinoRoutePage(TargetPlatform platform) => platform == TargetPlatform.iOS; -Page buildAppRoutePage({required BuildContext context, required GoRouterState state, required Widget child}) { +Page buildAppRoutePage(BuildContext context, GoRouterState state, Widget child) { + return _buildAppRoutePage(context: context, state: state, child: child); +} + +Page _buildAppRoutePage({required BuildContext context, required GoRouterState state, required Widget child}) { if (useCupertinoRoutePage(Theme.of(context).platform)) { return CupertinoPage(key: state.pageKey, child: child); } - return buildFadeThroughPage(context: context, state: state, child: child); + return _buildFadeThroughPage(context: context, state: state, child: child); +} + +Page _buildFadeThroughPage({required BuildContext context, required GoRouterState state, required Widget child}) { + final reducedMotion = !animationsAllowed(context); + final duration = reducedMotion ? Anim.fast : Anim.screenTransition; + + return CustomTransitionPage( + key: state.pageKey, + transitionDuration: duration, + reverseTransitionDuration: Anim.fast, + child: child, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + if (reducedMotion) { + return FadeTransition(opacity: animation, child: child); + } + + final fade = CurvedAnimation( + parent: animation, + curve: const Interval(0.2, 1, curve: Anim.enter), + ); + + final scale = Tween(begin: 0.96, end: 1).animate(CurvedAnimation(parent: animation, curve: Anim.enter)); + return FadeTransition( + opacity: fade, + child: ScaleTransition(scale: scale, child: child), + ); + }, + ); } diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index dfd9f58..6e49b49 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -45,6 +45,7 @@ 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'; @@ -112,8 +113,8 @@ class AppRouter { _profileNavigatorKey, ]; - Page _page(BuildContext context, GoRouterState state, Widget child) => - buildAppRoutePage(context: context, state: state, child: child); + // Page _page(BuildContext context, GoRouterState state, Widget child) => + // buildAppRoutePage(context: context, state: state, child: child); GoRouter get router => GoRouter( navigatorKey: _rootNavigatorKey, @@ -132,6 +133,7 @@ class AppRouter { '/privacy', '/feed', '/post', + '/topic', OAuthCallbackScreen.routePath, OAuthCallbackScreen.compatibilityRoutePath, }; @@ -165,13 +167,17 @@ class AppRouter { final initialHandle = state.uri.queryParameters['handle']?.trim(); final hasInitialHandle = initialHandle != null && initialHandle.isNotEmpty; final autoStartOAuth = state.uri.queryParameters['reauth'] == '1' && hasInitialHandle; - return _page( + return buildAppRoutePage( context, state, - LoginScreen( - initialHandle: hasInitialHandle ? initialHandle : null, - initialProviderKey: state.uri.queryParameters['provider'], - autoStartOAuth: autoStartOAuth, + _buildUnauthenticatedRouteShell( + context, + state, + LoginScreen( + initialHandle: hasInitialHandle ? initialHandle : null, + initialProviderKey: state.uri.queryParameters['provider'], + autoStartOAuth: autoStartOAuth, + ), ), ); }, @@ -209,28 +215,31 @@ class AppRouter { GoRoute( path: '/settings', pageBuilder: (context, state) => - _page(context, state, _buildUnauthenticatedRouteShell(context, state, const SettingsScreen())), + buildAppRoutePage(context, state, _buildUnauthenticatedRouteShell(context, state, const SettingsScreen())), routes: [ GoRoute( path: 'moderation', - pageBuilder: (context, state) => _page(context, state, const ModerationSettingsScreen()), + pageBuilder: (context, state) => buildAppRoutePage(context, state, const ModerationSettingsScreen()), routes: [ GoRoute( path: 'detail', pageBuilder: (context, state) => - _page(context, state, LabelerDetailScreen(did: state.uri.queryParameters['did'] ?? '')), + buildAppRoutePage(context, state, LabelerDetailScreen(did: state.uri.queryParameters['did'] ?? '')), ), ], ), GoRoute( path: 'account', - pageBuilder: (context, state) => _page(context, state, const SettingsAccountScreen()), + pageBuilder: (context, state) => buildAppRoutePage(context, state, const SettingsAccountScreen()), + ), + GoRoute( + path: 'about', + pageBuilder: (context, state) => buildAppRoutePage(context, state, const AboutScreen()), ), - GoRoute(path: 'about', pageBuilder: (context, state) => _page(context, state, const AboutScreen())), - GoRoute(path: 'logs', pageBuilder: (context, state) => _page(context, state, const LogsScreen())), + GoRoute(path: 'logs', pageBuilder: (context, state) => buildAppRoutePage(context, state, const LogsScreen())), GoRoute( path: 'clean-follows', - pageBuilder: (context, state) => _page( + pageBuilder: (context, state) => buildAppRoutePage( context, state, BlocProvider( @@ -247,7 +256,7 @@ class AppRouter { ), GoRoute( path: 'devtools', - pageBuilder: (context, state) => _page( + pageBuilder: (context, state) => buildAppRoutePage( context, state, _buildUnauthenticatedRouteShell(context, state, _buildDevToolsRoute(context, state)), @@ -255,7 +264,7 @@ class AppRouter { ), GoRoute( path: 'video-limits', - pageBuilder: (context, state) => _page( + pageBuilder: (context, state) => buildAppRoutePage( context, state, BlocProvider( @@ -269,15 +278,21 @@ class AppRouter { GoRoute( path: OAuthCallbackScreen.routePath, parentNavigatorKey: _rootNavigatorKey, - pageBuilder: (context, state) => _page(context, state, OAuthCallbackScreen(callbackUri: state.uri)), + pageBuilder: (context, state) => buildAppRoutePage(context, state, OAuthCallbackScreen(callbackUri: state.uri)), ), GoRoute( path: OAuthCallbackScreen.compatibilityRoutePath, parentNavigatorKey: _rootNavigatorKey, - pageBuilder: (context, state) => _page(context, state, OAuthCallbackScreen(callbackUri: state.uri)), + pageBuilder: (context, state) => buildAppRoutePage(context, state, OAuthCallbackScreen(callbackUri: state.uri)), + ), + GoRoute( + path: '/terms', + pageBuilder: (context, state) => buildAppRoutePage(context, state, const TermsOfServiceScreen()), + ), + GoRoute( + path: '/privacy', + pageBuilder: (context, state) => buildAppRoutePage(context, state, const PrivacyPolicyScreen()), ), - GoRoute(path: '/terms', pageBuilder: (context, state) => _page(context, state, const TermsOfServiceScreen())), - GoRoute(path: '/privacy', pageBuilder: (context, state) => _page(context, state, const PrivacyPolicyScreen())), GoRoute(path: '/notifications', redirect: (_, _) => '/alerts'), GoRoute(path: '/messages', redirect: (_, _) => '/alerts/messages'), GoRoute( @@ -285,7 +300,7 @@ class AppRouter { parentNavigatorKey: _rootNavigatorKey, pageBuilder: (context, state) { final args = ComposeRouteArgs.parseExtra(state.extra); - return _page( + return buildAppRoutePage( context, state, BlocProvider( @@ -320,7 +335,7 @@ class AppRouter { pageBuilder: (context, state) { final uri = state.uri.queryParameters['uri'] ?? ''; final provider = state.uri.queryParameters['provider']; - return _page( + return buildAppRoutePage( context, state, _buildPostThreadRoute(context, postUri: Uri.decodeComponent(uri), provider: provider), @@ -332,7 +347,7 @@ class AppRouter { parentNavigatorKey: _rootNavigatorKey, pageBuilder: (context, state) { final normalizedTag = normalizeHashtag(state.uri.queryParameters['tag'] ?? ''); - return _page( + return buildAppRoutePage( context, state, BlocProvider( @@ -349,14 +364,10 @@ class AppRouter { pageBuilder: (context, state) { final rawTopic = state.uri.queryParameters['topic'] ?? ''; final topic = Uri.decodeComponent(rawTopic).trim(); - return _page( + return buildAppRoutePage( context, state, - BlocProvider( - key: ValueKey('topic-$topic'), - create: (_) => TopicCubit(searchRepository: context.read(), topic: topic), - child: TopicScreen(topic: topic), - ), + _buildTopicRoute(context, topic: topic, provider: state.uri.queryParameters['provider']), ); }, ), @@ -365,7 +376,7 @@ class AppRouter { parentNavigatorKey: _rootNavigatorKey, pageBuilder: (context, state) { final args = state.extra as ImageViewerRouteArgs; - return _page(context, state, ImageViewerScreen(args: args)); + return buildAppRoutePage(context, state, ImageViewerScreen(args: args)); }, ), GoRoute( @@ -373,12 +384,12 @@ class AppRouter { parentNavigatorKey: _rootNavigatorKey, pageBuilder: (context, state) { final args = state.extra as VideoPlayerRouteArgs; - return _page(context, state, VideoPlayerScreen(args: args)); + return buildAppRoutePage(context, state, VideoPlayerScreen(args: args)); }, ), GoRoute( path: '/bookmarks', - pageBuilder: (context, state) => _page( + pageBuilder: (context, state) => buildAppRoutePage( context, state, SavedPostsScreen(accountDid: context.read(), initialTab: SavedPostsInitialTab.bookmarks), @@ -386,17 +397,20 @@ class AppRouter { ), GoRoute( path: '/liked', - pageBuilder: (context, state) => _page( + pageBuilder: (context, state) => buildAppRoutePage( context, state, SavedPostsScreen(accountDid: context.read(), initialTab: SavedPostsInitialTab.liked), ), ), - GoRoute(path: '/lists', pageBuilder: (context, state) => _page(context, state, const MyListsScreen())), + GoRoute( + path: '/lists', + pageBuilder: (context, state) => buildAppRoutePage(context, state, const MyListsScreen()), + ), GoRoute( path: '/create-starter-pack', pageBuilder: (context, state) { - return _page( + return buildAppRoutePage( context, state, BlocProvider( @@ -411,14 +425,14 @@ class AppRouter { pageBuilder: (context, state) { final uriStr = Uri.decodeComponent(state.uri.queryParameters['uri'] ?? ''); final packUri = AtUri.parse(uriStr); - return _page(context, state, StarterPackDetailScreen(packUri: packUri)); + return buildAppRoutePage(context, state, StarterPackDetailScreen(packUri: packUri)); }, ), GoRoute( path: '/starter-packs', pageBuilder: (context, state) { final actor = state.uri.queryParameters['actor'] ?? ''; - return _page(context, state, ActorStarterPacksScreen(actor: actor)); + return buildAppRoutePage(context, state, ActorStarterPacksScreen(actor: actor)); }, ), GoRoute( @@ -426,7 +440,7 @@ class AppRouter { pageBuilder: (context, state) { final uriStr = Uri.decodeComponent(state.uri.queryParameters['uri'] ?? ''); final listUri = AtUri.parse(uriStr); - return _page(context, state, ListDetailScreen(listUri: listUri)); + return buildAppRoutePage(context, state, ListDetailScreen(listUri: listUri)); }, routes: [ GoRoute( @@ -434,7 +448,7 @@ class AppRouter { pageBuilder: (context, state) { final uriStr = Uri.decodeComponent(state.uri.queryParameters['uri'] ?? ''); final listUri = AtUri.parse(uriStr); - return _page( + return buildAppRoutePage( context, state, BlocProvider( @@ -465,7 +479,7 @@ class AppRouter { ), constellationClient: ConstellationClient(baseUrl: constellationUrl), ); - return _page( + return buildAppRoutePage( context, state, BlocProvider( @@ -485,7 +499,7 @@ class AppRouter { } return null; }, - pageBuilder: (context, state) => _page( + pageBuilder: (context, state) => buildAppRoutePage( context, state, _buildContextualProfileRoute( @@ -497,7 +511,8 @@ class AppRouter { routes: [ GoRoute( path: 'connections', - pageBuilder: (context, state) => _page( + redirect: (_, state) => authBloc.state.isAuthenticated ? null : _publicProfileLocation(state), + pageBuilder: (context, state) => buildAppRoutePage( context, state, _buildProfileConnectionsRoute(context, state, Uri.decodeComponent(state.pathParameters['actor'] ?? '')), @@ -505,9 +520,10 @@ class AppRouter { ), GoRoute( path: 'search-posts', + redirect: (_, state) => authBloc.state.isAuthenticated ? null : _publicProfileLocation(state), pageBuilder: (context, state) { final actor = Uri.decodeComponent(state.pathParameters['actor'] ?? ''); - return _page( + return buildAppRoutePage( context, state, BlocProvider( @@ -567,11 +583,11 @@ class AppRouter { routes: [ GoRoute( path: '/', - pageBuilder: (context, state) => _page(context, state, const HomeFeedScreen()), + pageBuilder: (context, state) => buildAppRoutePage(context, state, const HomeFeedScreen()), routes: [ GoRoute( path: 'feeds', - pageBuilder: (context, state) => _page(context, state, const FeedManagementScreen()), + pageBuilder: (context, state) => buildAppRoutePage(context, state, const FeedManagementScreen()), ), GoRoute( path: 'feed', @@ -590,7 +606,7 @@ class AppRouter { 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 _page( + return buildAppRoutePage( context, state, _buildFeedDetailRoute(context, feedUri: feedUri, actor: actor, rkey: rkey, provider: provider), @@ -599,7 +615,7 @@ class AppRouter { ), GoRoute( path: 'trending', - pageBuilder: (context, state) => _page(context, state, const TrendingScreen()), + pageBuilder: (context, state) => buildAppRoutePage(context, state, const TrendingScreen()), ), ], ), @@ -608,7 +624,10 @@ class AppRouter { StatefulShellBranch( navigatorKey: _searchNavigatorKey, routes: [ - GoRoute(path: '/search', pageBuilder: (context, state) => _page(context, state, const SearchScreen())), + GoRoute( + path: '/search', + pageBuilder: (context, state) => buildAppRoutePage(context, state, const SearchScreen()), + ), ], ), StatefulShellBranch( @@ -616,7 +635,7 @@ class AppRouter { routes: [ GoRoute( path: '/at-explorer', - pageBuilder: (context, state) => _page(context, state, _buildDevToolsRoute(context, state)), + pageBuilder: (context, state) => buildAppRoutePage(context, state, _buildDevToolsRoute(context, state)), ), ], ), @@ -626,11 +645,11 @@ class AppRouter { GoRoute( path: '/alerts', pageBuilder: (context, state) => - _page(context, state, _buildAlertsRoute(context, const AlertsScreen())), + buildAppRoutePage(context, state, _buildAlertsRoute(context, const AlertsScreen())), routes: [ GoRoute( path: 'messages', - pageBuilder: (context, state) => _page( + pageBuilder: (context, state) => buildAppRoutePage( context, state, _buildAlertsRoute(context, const AlertsScreen(initialTab: AlertsTab.messages)), @@ -641,7 +660,7 @@ class AppRouter { pageBuilder: (context, state) { final convoId = state.pathParameters['id']!; final args = state.extra as MessageThreadRouteArgs?; - return _page( + return buildAppRoutePage( context, state, BlocProvider( @@ -658,7 +677,7 @@ class AppRouter { ), GoRoute( path: 'requests', - pageBuilder: (context, state) => _page( + pageBuilder: (context, state) => buildAppRoutePage( context, state, _buildAlertsRoute(context, const AlertsScreen(initialTab: AlertsTab.requests)), @@ -673,22 +692,25 @@ class AppRouter { routes: [ GoRoute( path: '/profile/me', - pageBuilder: (context, state) => _page(context, state, const ProfileScreen(actor: 'me')), + pageBuilder: (context, state) => buildAppRoutePage(context, state, const ProfileScreen(actor: 'me')), routes: [ GoRoute( path: 'connections', - pageBuilder: (context, state) => - _page(context, state, _buildProfileConnectionsRoute(context, state, context.read())), + pageBuilder: (context, state) => buildAppRoutePage( + context, + state, + _buildProfileConnectionsRoute(context, state, context.read()), + ), ), GoRoute( path: 'edit', - pageBuilder: (context, state) => _page(context, state, const ProfileEditScreen()), + pageBuilder: (context, state) => buildAppRoutePage(context, state, const ProfileEditScreen()), ), GoRoute( path: 'search-posts', pageBuilder: (context, state) { final actor = context.read(); - return _page( + return buildAppRoutePage( context, state, BlocProvider( @@ -733,17 +755,27 @@ class AppRouter { return UnauthenticatedShell( location: state.uri.path, publicProviderKey: publicProviderKey, - publicHomeLocation: publicHomeLocation, + publicHomeLocation: publicHomeLocation ?? _publicHomeLocationFromQuery(state), child: child, ); } Widget _buildPublicHomeRoute(BuildContext context, PublicRouteState routeState) { try { - context.read(); + context.read(); return PublicHomeScreen(providerKey: routeState.providerKey, contentTab: routeState.contentTab); } catch (_) { - log.d('PublicContentRepository not found, creating public repository for route'); + 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; @@ -751,16 +783,15 @@ class AppRouter { database = context.read(); } catch (error, stackTrace) { log.d('AppDatabase not found for public route provider setup', error: error, stackTrace: stackTrace); - return RepositoryProvider.value( - value: const EmptyPublicContentRepository(), + return RepositoryProvider.value( + value: const SinglePublicContentRepositoryResolver(EmptyPublicContentRepository()), child: PublicHomeScreen(providerKey: routeState.providerKey, contentTab: routeState.contentTab), ); } final factory = PublicRepositoryFactory(database: database); - return RepositoryProvider( - key: ValueKey('public-content-repository-${routeState.providerKey}'), - create: (_) => factory.contentRepository(routeState.providerKey), + return RepositoryProvider( + create: (_) => FactoryPublicContentRepositoryResolver(factory: factory), child: PublicHomeScreen(providerKey: routeState.providerKey, contentTab: routeState.contentTab), ); } @@ -773,7 +804,17 @@ class AppRouter { required String? provider, }) { if (authBloc.state.isAuthenticated) { - return FeedDetailScreen(feedUri: feedUri, actor: actor, rkey: rkey); + 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; @@ -839,7 +880,17 @@ class AppRouter { Widget _buildPostThreadRoute(BuildContext context, {required String postUri, required String? provider}) { if (authBloc.state.isAuthenticated) { - return PostThreadScreen(postUri: postUri); + 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( @@ -927,6 +978,112 @@ class AppRouter { } } + 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; + } + + 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, + ); + } + + 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']); diff --git a/lib/core/router/fade_through_page.dart b/lib/core/router/fade_through_page.dart deleted file mode 100644 index f03b914..0000000 --- a/lib/core/router/fade_through_page.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:lazurite/core/theme/animation_tokens.dart'; -import 'package:lazurite/core/theme/animation_utils.dart'; - -Page buildFadeThroughPage({required BuildContext context, required GoRouterState state, required Widget child}) { - final reducedMotion = !animationsAllowed(context); - final duration = reducedMotion ? Anim.fast : Anim.screenTransition; - - return CustomTransitionPage( - key: state.pageKey, - transitionDuration: duration, - reverseTransitionDuration: Anim.fast, - child: child, - transitionsBuilder: (context, animation, secondaryAnimation, child) { - if (reducedMotion) { - return FadeTransition(opacity: animation, child: child); - } - - final fade = CurvedAnimation( - parent: animation, - curve: const Interval(0.2, 1, curve: Anim.enter), - ); - - final scale = Tween(begin: 0.96, end: 1).animate(CurvedAnimation(parent: animation, curve: Anim.enter)); - return FadeTransition( - opacity: fade, - child: ScaleTransition(scale: scale, child: child), - ); - }, - ); -} diff --git a/lib/features/account/cubit/account_switcher_cubit.dart b/lib/features/account/cubit/account_switcher_cubit.dart index 174a361..51f7a21 100644 --- a/lib/features/account/cubit/account_switcher_cubit.dart +++ b/lib/features/account/cubit/account_switcher_cubit.dart @@ -49,8 +49,6 @@ class AccountSwitcherCubit extends Cubit { String? activeDid; if (savedDid != null && accounts.any((a) => a.did == savedDid)) { activeDid = savedDid; - } else if (accounts.isNotEmpty) { - activeDid = accounts.first.did; } emit(AccountSwitcherState.ready(accounts: accounts, activeDid: activeDid)); diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart index 424ccc8..0e89ba9 100644 --- a/lib/features/auth/data/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -561,18 +561,8 @@ class AuthRepository { final storedSession = await getStoredSession(); log.i('AuthRepository: Logging out ${storedSession?.handle ?? 'current user'}'); - try { - if (storedSession?.refreshToken != null && storedSession?.usesOAuth == false) { - await atp.deleteSession(refreshJwt: storedSession!.refreshToken!, service: storedSession.service); - } - } finally { - if (storedSession != null) { - await _invalidateSession(storedSession); - } else { - await _database.deleteSetting(AppDatabase.activeAccountDidSettingKey); - } - log.i('AuthRepository: Logout complete'); - } + await _database.deleteSetting(AppDatabase.activeAccountDidSettingKey); + log.i('AuthRepository: Logout complete'); } Future _handleOAuthCallback(String callbackUrl) async { diff --git a/lib/features/feed/presentation/widgets/public_post_card.dart b/lib/features/feed/presentation/widgets/public_post_card.dart index fe9de70..0cdbe8b 100644 --- a/lib/features/feed/presentation/widgets/public_post_card.dart +++ b/lib/features/feed/presentation/widgets/public_post_card.dart @@ -9,6 +9,7 @@ import 'package:lazurite/features/feed/presentation/widgets/grid_post_card.dart' import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; +import 'package:lazurite/features/public/presentation/public_navigation.dart'; import 'package:lazurite/shared/presentation/helpers/share_helper.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; import 'package:lazurite/shared/utils/parse_utils.dart'; @@ -33,11 +34,13 @@ class PublicPostCard extends StatelessWidget { Future onTap() => context.push('/post?uri=${Uri.encodeQueryComponent(feedViewPost.post.uri.toString())}&provider=$providerKey'); - return switch (resolvedVariant) { + final card = switch (resolvedVariant) { PostCardVariant.grid => GridPostCard(feedViewPost: feedViewPost, footer: footer, onTap: onTap), PostCardVariant.compact => CompactPostCard(feedViewPost: feedViewPost, footer: footer, onTap: onTap), _ => PostCard(feedViewPost: feedViewPost, actionBar: footer, onTap: onTap), }; + + return PublicProviderScope(providerKey: providerKey, child: card); } } diff --git a/lib/features/public/data/public_content_repository.dart b/lib/features/public/data/public_content_repository.dart index 6890de6..256d3a3 100644 --- a/lib/features/public/data/public_content_repository.dart +++ b/lib/features/public/data/public_content_repository.dart @@ -13,6 +13,19 @@ abstract interface class PublicContentRepository { Future searchFeeds({required String query, String? cursor, int limit = 25}); } +abstract interface class PublicContentRepositoryResolver { + PublicContentRepository repositoryFor(String providerKey); +} + +class SinglePublicContentRepositoryResolver implements PublicContentRepositoryResolver { + const SinglePublicContentRepositoryResolver(this.repository); + + final PublicContentRepository repository; + + @override + PublicContentRepository repositoryFor(String providerKey) => repository; +} + class RepositoryPublicContentRepository implements PublicContentRepository { const RepositoryPublicContentRepository({ required this.providerKey, diff --git a/lib/features/public/data/public_repository_factory.dart b/lib/features/public/data/public_repository_factory.dart index 61f9419..649d001 100644 --- a/lib/features/public/data/public_repository_factory.dart +++ b/lib/features/public/data/public_repository_factory.dart @@ -35,7 +35,12 @@ class PublicRepositoryFactory { } SearchRepository searchRepository(String providerKey) { - return SearchRepository(bluesky: bluesky(providerKey), appViewProvider: providerKey); + final client = bluesky(providerKey); + return SearchRepository( + bluesky: client, + moderationService: _publicModerationService(client, providerKey), + appViewProvider: providerKey, + ); } ProfileRepository profileRepository(String providerKey) { @@ -73,3 +78,16 @@ class PublicRepositoryFactory { return ModerationService.public(bluesky: client, database: database, appViewProvider: providerKey); } } + +class FactoryPublicContentRepositoryResolver implements PublicContentRepositoryResolver { + FactoryPublicContentRepositoryResolver({required this.factory}); + + final PublicRepositoryFactory factory; + final Map _repositories = {}; + + @override + PublicContentRepository repositoryFor(String providerKey) { + final normalizedProvider = AppViewProviders.normalizeSettingKey(providerKey); + return _repositories.putIfAbsent(normalizedProvider, () => factory.contentRepository(normalizedProvider)); + } +} diff --git a/lib/features/public/presentation/public_home_screen.dart b/lib/features/public/presentation/public_home_screen.dart index a3cb238..1602f41 100644 --- a/lib/features/public/presentation/public_home_screen.dart +++ b/lib/features/public/presentation/public_home_screen.dart @@ -159,7 +159,7 @@ class _PublicDiscoverTabState extends State<_PublicDiscoverTab> with AutomaticKe _error = null; }); try { - final result = await context.read().loadDiscover(); + final result = await _repository(context, widget.providerKey).loadDiscover(); if (!mounted) { return; } @@ -185,7 +185,7 @@ class _PublicDiscoverTabState extends State<_PublicDiscoverTab> with AutomaticKe } setState(() => _loadingMore = true); try { - final next = await context.read().loadDiscover(cursor: cursor); + final next = await _repository(context, widget.providerKey).loadDiscover(cursor: cursor); if (!mounted) { return; } @@ -288,7 +288,7 @@ class _PublicFeedsTabState extends State<_PublicFeedsTab> with AutomaticKeepAliv _error = null; }); try { - final repository = context.read(); + final repository = _repository(context, widget.providerKey); final result = query.isEmpty ? await repository.loadFeeds(cursor: cursor) : await repository.searchFeeds(query: query, cursor: cursor); @@ -371,6 +371,14 @@ class _PublicFeedsTabState extends State<_PublicFeedsTab> with AutomaticKeepAliv } } +PublicContentRepository _repository(BuildContext context, String providerKey) { + try { + return context.read().repositoryFor(providerKey); + } catch (_) { + return context.read(); + } +} + class _ProviderLabel extends StatelessWidget { const _ProviderLabel({required this.assetPath, required this.name}); diff --git a/lib/features/public/presentation/public_navigation.dart b/lib/features/public/presentation/public_navigation.dart index 5f74deb..af05ce9 100644 --- a/lib/features/public/presentation/public_navigation.dart +++ b/lib/features/public/presentation/public_navigation.dart @@ -3,6 +3,19 @@ import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/features/public/data/public_provider_context.dart'; +class PublicProviderScope extends InheritedWidget { + const PublicProviderScope({super.key, required this.providerKey, required super.child}); + + final String providerKey; + + static PublicProviderScope? maybeOf(BuildContext context) { + return context.findAncestorWidgetOfExactType(); + } + + @override + bool updateShouldNotify(covariant PublicProviderScope oldWidget) => oldWidget.providerKey != providerKey; +} + void navigateToPublicFeed(BuildContext context, GeneratorView feed, PublicProviderContext providerContext) { final uri = Uri( path: '/feed', diff --git a/lib/features/public/presentation/unauthenticated_shell.dart b/lib/features/public/presentation/unauthenticated_shell.dart index 6ff0375..0db03ed 100644 --- a/lib/features/public/presentation/unauthenticated_shell.dart +++ b/lib/features/public/presentation/unauthenticated_shell.dart @@ -31,46 +31,36 @@ class UnauthenticatedShell extends StatelessWidget { ), child: SafeArea( top: false, - child: Row( - children: [ - Expanded( - child: NavigationBar( - key: const ValueKey('unauthenticated-navigation-bar'), - height: 72, - backgroundColor: Colors.transparent, - surfaceTintColor: Colors.transparent, - indicatorColor: context.colorScheme.secondaryContainer, - selectedIndex: _selectedIndex, - labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, - onDestinationSelected: (index) => _goDestination(context, index), - destinations: [ - NavigationDestination( - icon: const Icon(Icons.home_outlined), - selectedIcon: const Icon(Icons.home), - label: context.l10n.labelHome, - ), - NavigationDestination( - icon: const Icon(Icons.explore_outlined), - selectedIcon: const Icon(Icons.explore), - label: context.l10n.labelAtExplorer, - ), - NavigationDestination( - icon: const Icon(Icons.settings_outlined), - selectedIcon: const Icon(Icons.settings), - label: context.l10n.labelSettings, - ), - ], - ), + child: NavigationBar( + key: const ValueKey('unauthenticated-navigation-bar'), + height: 72, + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + indicatorColor: context.colorScheme.secondaryContainer, + selectedIndex: _selectedIndex, + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + onDestinationSelected: (index) => _goDestination(context, index), + destinations: [ + NavigationDestination( + icon: const Icon(Icons.home_outlined), + selectedIcon: const Icon(Icons.home), + label: context.l10n.labelHome, ), - const SizedBox(width: 4), - Padding( - padding: const EdgeInsets.only(right: 12), - child: FilledButton.icon( - key: const ValueKey('unauthenticated-login-button'), - onPressed: () => context.go('/login?provider=${_loginProvider(context)}'), - icon: const Icon(Icons.login), - label: Text(context.l10n.buttonSignIn), - ), + NavigationDestination( + icon: const Icon(Icons.explore_outlined), + selectedIcon: const Icon(Icons.explore), + label: context.l10n.labelAtExplorer, + ), + NavigationDestination( + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings), + label: context.l10n.labelSettings, + ), + NavigationDestination( + key: const ValueKey('unauthenticated-login-button'), + icon: const Icon(Icons.login_outlined), + selectedIcon: const Icon(Icons.login), + label: context.l10n.buttonSignIn, ), ], ), @@ -79,7 +69,9 @@ class UnauthenticatedShell extends StatelessWidget { ); int get _selectedIndex { - if (location == '/settings/devtools') { + if (location == '/login') { + return 3; + } else if (location == '/settings/devtools') { return 1; } else if (location == '/settings') { return 2; @@ -87,19 +79,26 @@ class UnauthenticatedShell extends StatelessWidget { return 0; } - void _goDestination(BuildContext context, int index) => switch (index) { - 0 => context.go( + String get _homeLocation => publicHomeLocation ?? - const PublicRouteState( - providerKey: AppViewProviders.blueskyKey, - contentTab: PublicContentTab.discover, - ).location, - ), - 1 => context.go('/settings/devtools'), - 2 => context.go('/settings'), + const PublicRouteState(providerKey: AppViewProviders.blueskyKey, contentTab: PublicContentTab.discover).location; + + void _goDestination(BuildContext context, int index) => switch (index) { + 0 => context.go(_homeLocation), + 1 => context.go(_routeWithPublicHome('/settings/devtools')), + 2 => context.go(_routeWithPublicHome('/settings')), + 3 => context.go(_routeWithPublicHome('/login', provider: _loginProvider(context))), _ => null, }; + String _routeWithPublicHome(String path, {String? provider}) { + final queryParameters = {'publicHome': _homeLocation}; + if (provider != null) { + queryParameters['provider'] = provider; + } + return Uri(path: path, queryParameters: queryParameters).toString(); + } + String _loginProvider(BuildContext context) => (publicProviderKey != null) ? PublicRouteState.normalizeProvider(publicProviderKey) : PublicRouteState.normalizeProvider(context.read().state.appViewProvider); diff --git a/lib/features/search/presentation/topic_screen.dart b/lib/features/search/presentation/topic_screen.dart index 6b19380..f1aa089 100644 --- a/lib/features/search/presentation/topic_screen.dart +++ b/lib/features/search/presentation/topic_screen.dart @@ -3,14 +3,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; +import 'package:lazurite/features/feed/presentation/widgets/public_post_card.dart'; import 'package:lazurite/features/search/cubit/topic_cubit.dart'; import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; class TopicScreen extends StatefulWidget { - const TopicScreen({super.key, required this.topic}); + const TopicScreen({super.key, required this.topic, this.publicProviderKey}); final String topic; + final String? publicProviderKey; @override State createState() => _TopicScreenState(); @@ -117,7 +119,8 @@ class _TopicScreenState extends State { Widget _buildBody(BuildContext context, TopicState state) { final timeline = state.currentTimeline; - final accountDid = context.read().state.tokens?.did ?? ''; + final publicProviderKey = widget.publicProviderKey; + final accountDid = publicProviderKey == null ? context.read().state.tokens?.did ?? '' : ''; if (timeline.isLoading && timeline.posts.isEmpty) { return const Center(child: CircularProgressIndicator()); } @@ -156,10 +159,15 @@ class _TopicScreenState extends State { itemKey: postUri, index: index, seenKeys: _seenPostUris, - child: PostCardWithActions( - feedViewPost: FeedViewPost(post: post), - accountDid: accountDid, - ), + child: publicProviderKey == null + ? PostCardWithActions( + feedViewPost: FeedViewPost(post: post), + accountDid: accountDid, + ) + : PublicPostCard( + feedViewPost: FeedViewPost(post: post), + providerKey: publicProviderKey, + ), ); }, ), diff --git a/lib/main.dart b/lib/main.dart index 169847d..573de8e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,72 +5,31 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; +import 'package:lazurite/app/lazurite_app.dart'; import 'package:lazurite/core/bootstrap/auth_bootstrap.dart'; -import 'package:lazurite/core/cache/local_cache_maintenance_service.dart'; import 'package:lazurite/core/cache/offline_cache_policy.dart'; import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/embedding/embedding_service.dart'; import 'package:lazurite/core/error_reporting/crash_report_screen.dart'; -import 'package:lazurite/core/l10n/app_localizations.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/logging/logging_bloc_observer.dart'; -import 'package:lazurite/core/logging/logging_navigator_observer.dart'; import 'package:lazurite/core/network/app_view_fallback_service.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/core/network/app_view_router.dart'; -import 'package:lazurite/core/network/poptart_client_adapter.dart'; import 'package:lazurite/core/network/xrpc_client_factory.dart'; import 'package:lazurite/core/objectbox/objectbox_store.dart'; -import 'package:lazurite/core/router/app_router.dart'; import 'package:lazurite/core/scheduler/post_scheduler.dart'; -import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/auth/data/auth_repository.dart'; -import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; -import 'package:lazurite/features/connectivity/presentation/connectivity_banner_host.dart'; -import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; -import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; -import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; -import 'package:lazurite/features/feed/cubit/liked_posts_sync_cubit.dart'; -import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; -import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; -import 'package:lazurite/features/feed/data/feed_repository.dart'; -import 'package:lazurite/features/feed/data/liked_posts_repository.dart'; -import 'package:lazurite/features/feed/data/post_action_repository.dart'; -import 'package:lazurite/features/feed/data/post_thread_repository.dart'; -import 'package:lazurite/features/lists/data/list_repository.dart'; -import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; -import 'package:lazurite/features/messages/data/convo_repository.dart'; -import 'package:lazurite/features/moderation/data/moderation_service.dart'; import 'package:lazurite/features/notifications/background/notification_background_worker.dart'; import 'package:lazurite/features/notifications/data/firebase_push_token_provider.dart'; import 'package:lazurite/features/notifications/data/flutter_local_notification_adapter.dart'; import 'package:lazurite/features/notifications/data/notification_repository.dart'; -import 'package:lazurite/features/notifications/domain/local_notification_adapter.dart'; -import 'package:lazurite/features/notifications/domain/notification_deep_link_navigator.dart'; -import 'package:lazurite/features/notifications/domain/notification_domain_service.dart'; -import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; import 'package:lazurite/features/notifications/domain/push_registration_service.dart'; -import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; -import 'package:lazurite/features/profile/data/profile_action_repository.dart'; -import 'package:lazurite/features/profile/data/profile_repository.dart'; -import 'package:lazurite/features/search/bloc/search_bloc.dart'; -import 'package:lazurite/features/search/cubit/semantic_index_cubit.dart'; -import 'package:lazurite/features/search/cubit/semantic_search_cubit.dart'; -import 'package:lazurite/features/search/data/embedding_repository.dart'; -import 'package:lazurite/features/search/data/search_repository.dart'; -import 'package:lazurite/features/search/data/semantic_indexer.dart'; -import 'package:lazurite/features/search/data/semantic_search_repository.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; -import 'package:lazurite/features/settings/bloc/settings_state.dart'; -import 'package:lazurite/features/settings/data/video_repository.dart'; -import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; -import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; -import 'package:lazurite/shared/presentation/widgets/global_tap_outside_unfocus.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -189,611 +148,3 @@ Future main() async { }, ); } - -class LazuriteApp extends StatefulWidget { - const LazuriteApp({ - super.key, - required this.authBloc, - required this.authRepository, - required this.database, - required this.appViewFallbackService, - required this.objectBoxStore, - required this.embeddingService, - required this.settingsCubit, - required this.connectivityCubit, - required this.accountSwitcherCubit, - required this.localNotificationAdapter, - required this.pushRegistrationService, - required this.crashReportingService, - required this.firebaseAvailable, - }); - - final AuthBloc authBloc; - final AuthRepository authRepository; - final AppDatabase database; - final AppViewFallbackService appViewFallbackService; - final ObjectBoxStore objectBoxStore; - final EmbeddingService embeddingService; - final SettingsCubit settingsCubit; - final ConnectivityCubit connectivityCubit; - final AccountSwitcherCubit accountSwitcherCubit; - final LocalNotificationAdapter localNotificationAdapter; - final PushRegistrationService pushRegistrationService; - final CrashReportingService crashReportingService; - final bool firebaseAvailable; - - /// factory constructor with positional params - static LazuriteApp from( - AuthBloc authBloc, - AuthRepository authRepository, - AppDatabase database, - AppViewFallbackService appViewFallbackService, - ObjectBoxStore objectBoxStore, - EmbeddingService embeddingService, - SettingsCubit settingsCubit, - ConnectivityCubit connectivityCubit, - AccountSwitcherCubit accountSwitcherCubit, - LocalNotificationAdapter localNotificationAdapter, - PushRegistrationService pushRegistrationService, - CrashReportingService crashReportingService, - bool firebaseAvailable, - ) => LazuriteApp( - authBloc: authBloc, - authRepository: authRepository, - database: database, - appViewFallbackService: appViewFallbackService, - objectBoxStore: objectBoxStore, - embeddingService: embeddingService, - settingsCubit: settingsCubit, - connectivityCubit: connectivityCubit, - accountSwitcherCubit: accountSwitcherCubit, - localNotificationAdapter: localNotificationAdapter, - pushRegistrationService: pushRegistrationService, - crashReportingService: crashReportingService, - firebaseAvailable: firebaseAvailable, - ); - - @override - State createState() => _LazuriteAppState(); -} - -class _LazuriteAppState extends State with WidgetsBindingObserver { - static final _navigatorObserver = LoggingNavigatorObserver(); - late GoRouter _router; - late String _routerSessionKey; - late final StreamSubscription _authSubscription; - late final StreamSubscription _pushRegistrationSubscription; - StreamSubscription? _pushForegroundMessageSubscription; - late final StreamSubscription _simulateOfflineSubscription; - late final StreamSubscription _appViewProviderSubscription; - late final StreamSubscription _appViewEventSubscription; - late String _observedAppViewProvider; - var _routerGeneration = 0; - var _isSoftRestarting = false; - Completer? _authRecoveryCompleter; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - _routerSessionKey = _sessionKeyFor(widget.authBloc.state); - _observedAppViewProvider = widget.settingsCubit.state.appViewProvider; - _router = _createRouter(); - unawaited( - widget.localNotificationAdapter.initialize(onTap: _handleNotificationDeepLink).then((_) { - return widget.localNotificationAdapter.requestPermissions(); - }), - ); - widget.pushRegistrationService.configureAuthRecovery( - () => _recoverAuthSession(trigger: 'push_registration_unauthorized'), - ); - unawaited(widget.pushRegistrationService.start(initialTokens: widget.authBloc.state.tokens)); - _pushRegistrationSubscription = widget.authBloc.stream.map((state) => state.tokens).listen((tokens) { - unawaited(widget.pushRegistrationService.updateSession(tokens)); - }); - if (widget.firebaseAvailable) { - _pushForegroundMessageSubscription = FirebaseMessaging.onMessage.listen((message) { - unawaited(notificationPushPayloadEntrypoint(message.data)); - }); - } - _authSubscription = widget.authBloc.stream.map(_sessionKeyFor).distinct().listen(_handleSessionKeyChanged); - _simulateOfflineSubscription = widget.settingsCubit.stream - .map((state) => state.simulateOffline) - .distinct() - .listen(widget.connectivityCubit.setSimulatedOffline); - _appViewProviderSubscription = widget.settingsCubit.stream.map((state) => state.appViewProvider).listen((provider) { - if (provider == _observedAppViewProvider) { - return; - } - - _observedAppViewProvider = provider; - - if (!widget.authBloc.state.isAuthenticated) { - return; - } - - unawaited(_softRestartForProviderChange()); - }); - _appViewEventSubscription = widget.appViewFallbackService.events.listen( - widget.settingsCubit.recordAppViewRoutingEvent, - ); - - unawaited(widget.settingsCubit.refreshAppViewHealth()); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _authSubscription.cancel(); - _pushRegistrationSubscription.cancel(); - _pushForegroundMessageSubscription?.cancel(); - _simulateOfflineSubscription.cancel(); - _appViewProviderSubscription.cancel(); - _appViewEventSubscription.cancel(); - unawaited(widget.pushRegistrationService.dispose()); - - widget.connectivityCubit.close(); - widget.appViewFallbackService.dispose(); - - _router.dispose(); - - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - super.didChangeAppLifecycleState(state); - if (state == AppLifecycleState.resumed) { - unawaited(_refreshExpiredSessionOnResume()); - } - } - - Future _refreshExpiredSessionOnResume() async { - final authState = widget.authBloc.state; - final tokens = authState.tokens; - if (!authState.isAuthenticated || tokens == null || !tokens.isExpired) { - return; - } - await _recoverAuthSession(trigger: 'app_resumed'); - } - - Future _recoverAuthSession({required String trigger}) async { - final inFlight = _authRecoveryCompleter; - if (inFlight != null) { - return inFlight.future; - } - - final completer = Completer(); - _authRecoveryCompleter = completer; - String? refreshingDid; - try { - final authState = widget.authBloc.state; - final tokens = authState.tokens; - if (!authState.isAuthenticated || tokens == null || tokens.refreshToken == null) { - completer.complete(null); - return null; - } - refreshingDid = tokens.did; - - final refreshed = await widget.authRepository.refreshSession(tokens); - if (!_canPublishRecoveryForDid(refreshingDid)) { - completer.complete(null); - return null; - } - - if (refreshed == null || refreshed.did != refreshingDid) { - completer.complete(null); - return null; - } - widget.authBloc.add(SessionRestored(tokens: refreshed)); - completer.complete(refreshed); - return refreshed; - } catch (error, stackTrace) { - log.w('Auth recovery failed (trigger=$trigger)', error: error, stackTrace: stackTrace); - if (_canPublishRecoveryForDid(refreshingDid)) { - widget.authBloc.add(const CheckSessionRequested()); - } - completer.complete(null); - return null; - } finally { - if (identical(_authRecoveryCompleter, completer)) { - _authRecoveryCompleter = null; - } - } - } - - bool _canPublishRecoveryForDid(String? refreshingDid) { - if (!mounted || refreshingDid == null) { - return false; - } - - final state = widget.authBloc.state; - return state.isAuthenticated && state.tokens?.did == refreshingDid; - } - - GoRouter _createRouter() { - return AppRouter( - authBloc: widget.authBloc, - navigatorObserver: _navigatorObserver, - onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), - ).router; - } - - String _sessionKeyFor(AuthState state) => state.tokens?.did ?? 'guest'; - - void _handleSessionKeyChanged(String sessionKey) { - if (!mounted || sessionKey == _routerSessionKey) { - return; - } - - final previousRouter = _router; - setState(() { - _routerSessionKey = sessionKey; - _router = _createRouter(); - }); - previousRouter.dispose(); - } - - Future _softRestartForProviderChange() async { - if (!mounted || _isSoftRestarting) { - return; - } - - setState(() { - _isSoftRestarting = true; - }); - - final previousRouter = _router; - - widget.settingsCubit.bumpRoutingEpoch(); - - setState(() { - _routerGeneration += 1; - _router = _createRouter(); - }); - - previousRouter.dispose(); - await Future.delayed(const Duration(milliseconds: 200)); - - if (mounted) { - setState(() { - _isSoftRestarting = false; - }); - unawaited(widget.settingsCubit.refreshAppViewHealth()); - } - } - - void _handleNotificationDeepLink(NotificationDeepLink deepLink) { - if (!mounted) { - return; - } - NotificationDeepLinkNavigator.navigate(_router, deepLink); - } - - bool _isAlertsRouteActive() { - final path = _router.routerDelegate.currentConfiguration.uri.path; - return path.startsWith('/alerts'); - } - - Bluesky? _createBluesky(AuthState state) => state.isAuthenticated ? createBlueskyClient(state.tokens) : null; - - BlueskyChat? _createBlueskyChat(AuthState state) => - state.isAuthenticated ? createBlueSkyChatClient(state.tokens) : null; - - @override - Widget build(BuildContext context) { - return MultiRepositoryProvider( - providers: [ - RepositoryProvider.value(value: widget.crashReportingService), - RepositoryProvider( - create: (_) => LocalCacheMaintenanceService(database: widget.database, objectBoxStore: widget.objectBoxStore), - ), - ], - child: MultiBlocProvider( - providers: [ - BlocProvider.value(value: widget.authBloc), - BlocProvider.value(value: widget.settingsCubit), - BlocProvider.value(value: widget.connectivityCubit), - BlocProvider.value(value: widget.accountSwitcherCubit), - ], - child: BlocBuilder( - builder: (context, authState) { - final bluesky = _createBluesky(authState); - final blueskyChat = _createBlueskyChat(authState); - final appShell = BlocBuilder( - builder: (context, settingsState) { - final themeMode = settingsState.useSystemTheme - ? ThemeMode.system - : (settingsState.themeVariant == AppThemeVariant.light ? ThemeMode.light : ThemeMode.dark); - - final lightTheme = AppTheme.getTheme( - settingsState.themePalette, - AppThemeVariant.light, - headingFontFamily: settingsState.headingFontFamily, - contentFontFamily: settingsState.contentFontFamily, - codeFontFamily: settingsState.codeFontFamily, - ); - final darkTheme = AppTheme.getTheme( - settingsState.themePalette, - AppThemeVariant.dark, - headingFontFamily: settingsState.headingFontFamily, - contentFontFamily: settingsState.contentFontFamily, - codeFontFamily: settingsState.codeFontFamily, - ); - - return MaterialApp.router( - key: ValueKey('router-$_routerSessionKey-$_routerGeneration'), - title: 'Lazurite', - onGenerateTitle: (context) => AppLocalizations.of(context).appTitle, - debugShowCheckedModeBanner: false, - theme: lightTheme, - darkTheme: darkTheme, - themeMode: themeMode, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - routerConfig: _router, - builder: (context, child) => GlobalTapOutsideUnfocus( - child: Stack( - children: [ - ConnectivityBannerHost(child: child ?? const SizedBox.shrink()), - if (_isSoftRestarting) - const ColoredBox( - color: Color(0xC0000000), - child: Center( - child: Card( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 20, vertical: 16), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2.5), - ), - SizedBox(width: 12), - Text('Applying provider change...'), - ], - ), - ), - ), - ), - ), - ], - ), - ), - ); - }, - ); - - if (bluesky == null || blueskyChat == null) { - return appShell; - } - - final accountDid = authState.tokens?.did ?? ''; - - return KeyedSubtree( - key: ValueKey('account-$accountDid-routing-${context.read().state.routingEpoch}'), - child: MultiRepositoryProvider( - providers: [ - RepositoryProvider( - create: (_) { - final settingsCubit = context.read(); - final moderationService = ModerationService( - bluesky: bluesky, - database: widget.database, - accountDid: accountDid, - userDid: accountDid, - appViewProviderResolver: () => settingsCubit.state.appViewProvider, - ); - unawaited(moderationService.ensureInitialized()); - return moderationService; - }, - dispose: (moderationService) => moderationService.dispose(), - ), - RepositoryProvider( - create: (context) => FeedRepository( - bluesky: bluesky, - database: widget.database, - accountDid: accountDid, - moderationService: context.read(), - appViewProviderResolver: () => context.read().state.appViewProvider, - crossProviderFallbackEnabledResolver: () => - context.read().state.crossProviderFallbackEnabled, - appViewFallbackService: widget.appViewFallbackService, - routingEpoch: context.read().state.routingEpoch, - routingEpochResolver: () => context.read().state.routingEpoch, - onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), - ), - ), - RepositoryProvider( - create: (context) { - final settingsCubit = context.read(); - return SearchRepository( - bluesky: bluesky, - moderationService: context.read(), - appViewProviderResolver: () => settingsCubit.state.appViewProvider, - crossProviderFallbackEnabledResolver: () => settingsCubit.state.crossProviderFallbackEnabled, - appViewFallbackService: widget.appViewFallbackService, - routingEpoch: settingsCubit.state.routingEpoch, - routingEpochResolver: () => settingsCubit.state.routingEpoch, - ); - }, - ), - RepositoryProvider( - create: (context) { - final settingsCubit = context.read(); - return TypeaheadRepository( - bluesky: bluesky, - providerResolver: () => settingsCubit.state.typeaheadProvider, - appViewProviderResolver: () => settingsCubit.state.appViewProvider, - moderationService: context.read(), - ); - }, - ), - RepositoryProvider( - create: (context) => ListRepository( - bluesky: bluesky, - moderationService: context.read(), - appViewProviderResolver: () => context.read().state.appViewProvider, - ), - ), - RepositoryProvider( - create: (context) { - final service = context.read(); - return ProfileRepository( - database: widget.database, - bluesky: bluesky, - moderationService: service, - appViewProviderResolver: () => context.read().state.appViewProvider, - onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), - ); - }, - ), - RepositoryProvider( - create: (context) => NotificationRepository( - bluesky: bluesky, - moderationService: context.read(), - appViewProviderResolver: () => context.read().state.appViewProvider, - onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), - ), - ), - RepositoryProvider( - create: (context) => NotificationDomainService( - notificationRepository: context.read(), - database: widget.database, - accountDid: accountDid, - localNotificationAdapter: widget.localNotificationAdapter, - shouldSuppressLocalNotifications: _isAlertsRouteActive, - ), - ), - RepositoryProvider( - create: (context) => PostThreadRepository( - bluesky: bluesky, - database: widget.database, - accountDid: accountDid, - moderationService: context.read(), - appViewProviderResolver: () => context.read().state.appViewProvider, - onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), - ), - ), - RepositoryProvider( - create: (context) => StarterPackRepository( - bluesky: bluesky, - moderationService: context.read(), - appViewProviderResolver: () => context.read().state.appViewProvider, - ), - ), - RepositoryProvider( - create: (context) => PostActionRepository( - bluesky: bluesky, - appViewProviderResolver: () => context.read().state.appViewProvider, - ), - ), - RepositoryProvider( - create: (context) => ProfileActionRepository( - bluesky: bluesky, - appViewProviderResolver: () => context.read().state.appViewProvider, - ), - ), - RepositoryProvider( - create: (_) => ConvoRepository( - chat: blueskyChat, - onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), - ), - ), - RepositoryProvider(create: (_) => PostActionCache()), - RepositoryProvider(create: (_) => VideoRepository(bluesky: bluesky)), - RepositoryProvider.value(value: bluesky), - RepositoryProvider.value(value: widget.database), - RepositoryProvider.value(value: widget.objectBoxStore), - RepositoryProvider.value(value: widget.embeddingService), - RepositoryProvider(create: (context) => EmbeddingRepository(context.read())), - RepositoryProvider( - create: (context) => SemanticIndexer( - embeddingService: context.read(), - embeddingRepository: context.read(), - database: widget.database, - ), - ), - RepositoryProvider( - create: (context) => LikedPostsRepository( - bluesky: bluesky, - database: widget.database, - semanticIndexer: context.read(), - appViewProviderResolver: () => context.read().state.appViewProvider, - ), - ), - RepositoryProvider( - create: (context) => SemanticSearchRepository( - embeddingService: context.read(), - embeddingRepository: context.read(), - database: widget.database, - ), - ), - RepositoryProvider.value(value: accountDid), - ], - child: MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => ProfileBloc(profileRepository: context.read()), - ), - BlocProvider(create: (context) => FeedBloc(feedRepository: context.read())), - BlocProvider( - create: (context) => FeedPreferencesCubit( - feedRepository: context.read(), - database: widget.database, - accountDid: accountDid, - )..loadPreferences(), - ), - BlocProvider(create: (_) => DevToolsCubit(atproto: bluesky.atproto)), - BlocProvider( - create: (context) => SearchBloc( - searchRepository: context.read(), - typeaheadRepository: context.read(), - database: widget.database, - accountDid: accountDid, - ), - ), - BlocProvider( - create: (context) => - ConvoListBloc(convoRepository: context.read()) - ..add(const ConvosRequested(limit: 100)), - ), - BlocProvider( - create: (context) => SavedPostsCubit( - database: widget.database, - accountDid: accountDid, - postActionRepository: context.read(), - semanticIndexer: context.read(), - ), - ), - BlocProvider( - create: (context) => SemanticSearchCubit( - repository: context.read(), - embeddingService: context.read(), - accountDid: accountDid, - ), - ), - BlocProvider( - create: (context) => SemanticIndexCubit( - indexer: context.read(), - embeddingRepository: context.read(), - accountDid: accountDid, - ), - ), - BlocProvider( - create: (context) => - LikedPostsSyncCubit(repository: context.read(), accountDid: accountDid), - ), - ], - child: appShell, - ), - ), - ); - }, - ), - ), - ); - } -} diff --git a/lib/shared/presentation/helpers/navigation_helpers.dart b/lib/shared/presentation/helpers/navigation_helpers.dart index d0f40e0..3067c83 100644 --- a/lib/shared/presentation/helpers/navigation_helpers.dart +++ b/lib/shared/presentation/helpers/navigation_helpers.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/features/public/presentation/public_navigation.dart'; /// Profile navigation helper /// @@ -31,7 +32,10 @@ Future? navigateToProfile(BuildContext context, String actorDid) { return null; } - final location = '/profile/${Uri.encodeComponent(normalizedActor)}'; + final encodedActor = Uri.encodeComponent(normalizedActor); + final publicProviderKey = PublicProviderScope.maybeOf(context)?.providerKey; + final providerQuery = publicProviderKey == null ? '' : '?provider=${Uri.encodeQueryComponent(publicProviderKey)}'; + final location = '/profile/$encodedActor$providerQuery'; return router.push(location); } diff --git a/test/app/lazurite_app_test.dart b/test/app/lazurite_app_test.dart new file mode 100644 index 0000000..17a2916 --- /dev/null +++ b/test/app/lazurite_app_test.dart @@ -0,0 +1,124 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/embedding/embedding_service.dart'; +import 'package:lazurite/core/network/app_view_fallback_service.dart'; +import 'package:lazurite/core/objectbox/objectbox_store.dart'; +import 'package:lazurite/core/theme/app_theme.dart'; +import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/auth/data/auth_repository.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; +import 'package:lazurite/features/notifications/domain/local_notification_adapter.dart'; +import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; +import 'package:lazurite/features/notifications/domain/push_registration_service.dart'; +import 'package:lazurite/features/public/presentation/public_home_screen.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/settings/bloc/settings_state.dart'; +import 'package:lazurite/app/lazurite_app.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthBloc extends MockBloc implements AuthBloc {} + +class MockAuthRepository extends Mock implements AuthRepository {} + +class MockAppDatabase extends Mock implements AppDatabase {} + +class MockObjectBoxStore extends Mock implements ObjectBoxStore {} + +class MockEmbeddingService extends Mock implements EmbeddingService {} + +class MockSettingsCubit extends MockCubit implements SettingsCubit {} + +class MockConnectivityCubit extends MockCubit implements ConnectivityCubit {} + +class MockAccountSwitcherCubit extends MockCubit implements AccountSwitcherCubit {} + +class MockLocalNotificationAdapter extends Mock implements LocalNotificationAdapter {} + +class MockPushRegistrationService extends Mock implements PushRegistrationService {} + +class MockCrashReportingService extends Mock implements CrashReportingService {} + +void main() { + setUpAll(() { + registerFallbackValue(const NotificationDeepLink(route: '/', navigationMode: NotificationTapNavigationMode.go)); + }); + + testWidgets('keeps AppDatabase available to public routes after logout', (tester) async { + final authBloc = MockAuthBloc(); + final authRepository = MockAuthRepository(); + final database = MockAppDatabase(); + final appViewFallbackService = AppViewFallbackService(); + final objectBoxStore = MockObjectBoxStore(); + final embeddingService = MockEmbeddingService(); + final settingsCubit = MockSettingsCubit(); + final connectivityCubit = MockConnectivityCubit(); + final accountSwitcherCubit = MockAccountSwitcherCubit(); + final localNotificationAdapter = MockLocalNotificationAdapter(); + final pushRegistrationService = MockPushRegistrationService(); + final crashReportingService = MockCrashReportingService(); + final authStream = StreamController.broadcast(); + + const settingsState = SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + ); + + when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); + whenListen(authBloc, authStream.stream, initialState: const AuthState.unauthenticated()); + when(() => settingsCubit.state).thenReturn(settingsState); + whenListen(settingsCubit, const Stream.empty(), initialState: settingsState); + when(() => settingsCubit.refreshAppViewHealth()).thenAnswer((_) async {}); + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); + whenListen( + connectivityCubit, + const Stream.empty(), + initialState: const ConnectivityState.online(), + ); + when(() => connectivityCubit.setSimulatedOffline(any())).thenReturn(null); + when(() => connectivityCubit.close()).thenAnswer((_) async {}); + when(() => accountSwitcherCubit.state).thenReturn(const AccountSwitcherState.ready(accounts: [])); + whenListen( + accountSwitcherCubit, + const Stream.empty(), + initialState: const AccountSwitcherState.ready(accounts: []), + ); + when(() => localNotificationAdapter.initialize(onTap: any(named: 'onTap'))).thenAnswer((_) async {}); + when(() => localNotificationAdapter.requestPermissions()).thenAnswer((_) async {}); + when(() => pushRegistrationService.configureAuthRecovery(any())).thenReturn(null); + when(() => pushRegistrationService.start(initialTokens: any(named: 'initialTokens'))).thenAnswer((_) async {}); + when(() => pushRegistrationService.dispose()).thenAnswer((_) async {}); + + addTearDown(() async { + await authStream.close(); + appViewFallbackService.dispose(); + }); + + await tester.pumpWidget( + LazuriteApp( + authBloc: authBloc, + authRepository: authRepository, + database: database, + appViewFallbackService: appViewFallbackService, + objectBoxStore: objectBoxStore, + embeddingService: embeddingService, + settingsCubit: settingsCubit, + connectivityCubit: connectivityCubit, + accountSwitcherCubit: accountSwitcherCubit, + localNotificationAdapter: localNotificationAdapter, + pushRegistrationService: pushRegistrationService, + crashReportingService: crashReportingService, + firebaseAvailable: false, + ), + ); + + final publicContext = tester.element(find.byType(PublicHomeScreen)); + expect(publicContext.read(), same(database)); + }); +} diff --git a/test/core/database/app_database_test.dart b/test/core/database/app_database_test.dart index 4b2ea26..f1c8294 100644 --- a/test/core/database/app_database_test.dart +++ b/test/core/database/app_database_test.dart @@ -82,7 +82,7 @@ void main() { expect(result, isNull); }); - test('should get active account', () async { + test('should return null when accounts exist but no account is active', () async { final account = AccountsCompanion.insert( did: 'did:plc:abc123', handle: 'user.bsky.social', @@ -92,8 +92,7 @@ void main() { await database.insertAccount(account); final active = await database.getActiveAccount(); - expect(active, isNotNull); - expect(active!.did, equals('did:plc:abc123')); + expect(active, isNull); }); test('should prefer the account selected in settings', () async { @@ -115,7 +114,7 @@ void main() { expect(active!.did, equals('did:plc:older')); }); - test('should fall back when the selected active account no longer exists', () async { + test('should return null when the selected active account no longer exists', () async { await database.insertAccount( AccountsCompanion.insert( did: 'did:plc:available', @@ -127,8 +126,7 @@ void main() { final active = await database.getActiveAccount(); - expect(active, isNotNull); - expect(active!.did, equals('did:plc:available')); + expect(active, isNull); }); test('should return null when no active account exists', () async { diff --git a/test/core/router/app_route_page_test.dart b/test/core/router/app_route_page_test.dart index 218ece5..609db24 100644 --- a/test/core/router/app_route_page_test.dart +++ b/test/core/router/app_route_page_test.dart @@ -30,7 +30,7 @@ void main() { theme: ThemeData(platform: TargetPlatform.iOS), home: Builder( builder: (context) { - page = buildAppRoutePage(context: context, state: state, child: const SizedBox()); + page = buildAppRoutePage(context, state, const SizedBox()); return const SizedBox(); }, ), @@ -48,7 +48,7 @@ void main() { theme: ThemeData(platform: TargetPlatform.android), home: Builder( builder: (context) { - page = buildAppRoutePage(context: context, state: state, child: const SizedBox()); + page = buildAppRoutePage(context, state, const SizedBox()); return const SizedBox(); }, ), diff --git a/test/core/router/app_router_test.dart b/test/core/router/app_router_test.dart index 6f071fd..72dc56b 100644 --- a/test/core/router/app_router_test.dart +++ b/test/core/router/app_router_test.dart @@ -634,23 +634,26 @@ void main() { 'HOME', 'AT Explorer', 'Settings', + 'Sign In', ]); await tester.tap(find.text('AT Explorer').last); await tester.pumpAndSettle(); expect(router.routerDelegate.currentConfiguration.uri.path, '/settings/devtools'); + expect(router.routerDelegate.currentConfiguration.uri.queryParameters['publicHome'], '/public/blacksky/discover'); navBar = tester.widget(find.byKey(const ValueKey('unauthenticated-navigation-bar'))); expect(navBar.selectedIndex, 1); await tester.tap(find.text('Settings').last); await tester.pumpAndSettle(); expect(router.routerDelegate.currentConfiguration.uri.path, '/settings'); + expect(router.routerDelegate.currentConfiguration.uri.queryParameters['publicHome'], '/public/blacksky/discover'); navBar = tester.widget(find.byKey(const ValueKey('unauthenticated-navigation-bar'))); expect(navBar.selectedIndex, 2); await tester.tap(find.text('HOME').last); await tester.pumpAndSettle(); - expect(router.routerDelegate.currentConfiguration.uri.path, '/public/bluesky/discover'); + expect(router.routerDelegate.currentConfiguration.uri.path, '/public/blacksky/discover'); router.go('/public/blacksky/feeds'); await tester.pumpAndSettle(); @@ -659,6 +662,13 @@ void main() { expect(router.routerDelegate.currentConfiguration.uri.path, '/login'); expect(router.routerDelegate.currentConfiguration.uri.queryParameters['provider'], 'blacksky'); + expect(router.routerDelegate.currentConfiguration.uri.queryParameters['publicHome'], '/public/blacksky/feeds'); + navBar = tester.widget(find.byKey(const ValueKey('unauthenticated-navigation-bar'))); + expect(navBar.selectedIndex, 3); + + await tester.tap(find.text('HOME').last); + await tester.pumpAndSettle(); + expect(router.routerDelegate.currentConfiguration.uri.path, '/public/blacksky/feeds'); router.dispose(); }); @@ -787,6 +797,35 @@ void main() { router.dispose(); }); + testWidgets('logged-out topic route uses provider query for public read-only rendering', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 1200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + currentAuthState = const AuthState.unauthenticated(); + when(() => authBloc.state).thenReturn(currentAuthState); + whenListen(authBloc, Stream.value(currentAuthState), initialState: currentAuthState); + when( + () => searchRepository.searchTopicPosts( + topic: any(named: 'topic'), + sort: any(named: 'sort'), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => TopicPostsResult(posts: [_publicFeedPost().post], topicName: 'Cookout')); + final router = AppRouter(authBloc: authBloc).router; + + await tester.pumpWidget(buildSubjectWithRouter(router)); + router.go('/topic?topic=1972&provider=blacksky'); + await tester.pumpAndSettle(); + + expect(find.text('Cookout'), findsWidgets); + expect(find.byKey(const ValueKey('public_post_card_footer')), findsOneWidget); + expect(find.byTooltip('Share post'), findsOneWidget); + expect(find.byIcon(Icons.bookmark_outline), findsNothing); + + router.dispose(); + }); + testWidgets('logged-out profile route resolves actor and hides authenticated controls', (tester) async { await tester.binding.setSurfaceSize(const Size(800, 1200)); addTearDown(() => tester.binding.setSurfaceSize(null)); @@ -845,6 +884,29 @@ void main() { router.dispose(); }); + testWidgets('logged-out authenticated-only profile child routes redirect to public profile', (tester) async { + currentAuthState = const AuthState.unauthenticated(); + when(() => authBloc.state).thenReturn(currentAuthState); + whenListen(authBloc, Stream.value(currentAuthState), initialState: currentAuthState); + final router = AppRouter(authBloc: authBloc).router; + + await tester.pumpWidget(buildSubjectWithRouter(router)); + router.go('/profile/alice.bsky.social/connections?tab=followers&provider=blacksky'); + await tester.pumpAndSettle(); + + expect(router.routerDelegate.currentConfiguration.uri.path, '/profile/alice.bsky.social'); + expect(router.routerDelegate.currentConfiguration.uri.queryParameters['provider'], 'blacksky'); + expect(router.routerDelegate.currentConfiguration.uri.queryParameters.containsKey('tab'), isFalse); + + router.go('/profile/alice.bsky.social/search-posts?provider=blacksky'); + await tester.pumpAndSettle(); + + expect(router.routerDelegate.currentConfiguration.uri.path, '/profile/alice.bsky.social'); + expect(router.routerDelegate.currentConfiguration.uri.queryParameters['provider'], 'blacksky'); + + router.dispose(); + }); + testWidgets('unauthenticated settings login falls back to BlueSky for invalid persisted provider', (tester) async { currentAuthState = const AuthState.unauthenticated(); when(() => authBloc.state).thenReturn(currentAuthState); @@ -1163,6 +1225,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('login-continue-button')), findsOneWidget); + expect(find.byKey(const ValueKey('unauthenticated-navigation-bar')), findsNothing); router.dispose(); }); diff --git a/test/features/account/cubit/account_switcher_cubit_test.dart b/test/features/account/cubit/account_switcher_cubit_test.dart index cbe54ae..9263c18 100644 --- a/test/features/account/cubit/account_switcher_cubit_test.dart +++ b/test/features/account/cubit/account_switcher_cubit_test.dart @@ -78,7 +78,7 @@ void main() { ); blocTest( - 'defaults to first account when no saved active did', + 'keeps activeDid null when no active account is saved', build: buildCubit, setUp: () { final accounts = [makeAccount(did: 'did:plc:user1'), makeAccount(did: 'did:plc:user2')]; @@ -89,13 +89,14 @@ void main() { expect: () => [ const AccountSwitcherState.loading(), predicate( - (state) => state.status == AccountSwitcherStatus.ready && state.activeDid == 'did:plc:user1', + (state) => + state.status == AccountSwitcherStatus.ready && state.accounts.length == 2 && state.activeDid == null, ), ], ); blocTest( - 'defaults to first account when saved did not in accounts', + 'keeps activeDid null when saved did is not in accounts', build: buildCubit, setUp: () { final accounts = [makeAccount(did: 'did:plc:user1')]; @@ -106,7 +107,8 @@ void main() { expect: () => [ const AccountSwitcherState.loading(), predicate( - (state) => state.status == AccountSwitcherStatus.ready && state.activeDid == 'did:plc:user1', + (state) => + state.status == AccountSwitcherStatus.ready && state.accounts.length == 1 && state.activeDid == null, ), ], ); @@ -270,6 +272,10 @@ void main() { ), expect: () => [ const AccountSwitcherState.loading(), + predicate( + (state) => + state.status == AccountSwitcherStatus.ready && state.accounts.length == 1 && state.activeDid == null, + ), predicate( (state) => state.status == AccountSwitcherStatus.ready && diff --git a/test/features/auth/data/auth_repository_test.dart b/test/features/auth/data/auth_repository_test.dart index e860b50..f2f06ed 100644 --- a/test/features/auth/data/auth_repository_test.dart +++ b/test/features/auth/data/auth_repository_test.dart @@ -1446,7 +1446,7 @@ void main() { }); group('logout', () { - test('removes only the active account session', () async { + test('clears the active account marker without deleting saved accounts', () async { final account = Account( did: 'did:plc:active', handle: 'active.bsky.social', @@ -1473,12 +1473,12 @@ void main() { await authRepository.logout(); verify(() => mockDatabase.getActiveAccount()).called(1); - verify(() => mockDatabase.deleteAccount(account.did)).called(1); verify(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)).called(1); + verifyNever(() => mockDatabase.deleteAccount(any())); verifyNever(() => mockDatabase.deleteAllAccounts()); }); - test('clears stale active account setting when no active account exists', () async { + test('clears the active account marker when no active account exists', () async { when(() => mockDatabase.getActiveAccount()).thenAnswer((_) async => null); when(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)).thenAnswer((_) async => 1); diff --git a/test/features/feed/presentation/public_post_card_test.dart b/test/features/feed/presentation/public_post_card_test.dart index ae046e9..e7a538f 100644 --- a/test/features/feed/presentation/public_post_card_test.dart +++ b/test/features/feed/presentation/public_post_card_test.dart @@ -3,6 +3,7 @@ import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; import 'package:bluesky_poptart/app/bsky/feed/post.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; import 'package:lazurite/features/feed/presentation/widgets/public_post_card.dart'; @@ -34,6 +35,40 @@ void main() { expect(find.byKey(const ValueKey('public_post_card_footer')), findsOneWidget); expect(find.byTooltip('Share post'), findsOneWidget); }); + + testWidgets('author taps preserve public provider context', (tester) async { + final visitedProfileUris = []; + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, _) => Scaffold( + body: PublicPostCard(feedViewPost: _makePost(), providerKey: 'blacksky', variant: PostCardVariant.card), + ), + ), + GoRoute( + path: '/profile/:actor', + builder: (_, state) { + visitedProfileUris.add(state.uri); + return const Scaffold(body: Text('profile')); + }, + ), + ], + ); + + final theme = AppTheme.getTheme(AppThemePalette.oxocarbon, AppThemeVariant.dark); + await tester.pumpWidget(MaterialApp.router(theme: theme, routerConfig: router)); + + final avatar = tester.widget(find.byKey(const ValueKey('post_card_avatar'))); + avatar.onTap!(); + await tester.pumpAndSettle(); + + expect(find.text('profile'), findsOneWidget); + expect(visitedProfileUris.last.pathSegments, ['profile', 'did:plc:test']); + expect(visitedProfileUris.last.queryParameters['provider'], 'blacksky'); + + router.dispose(); + }); } Widget _buildSubject(FeedViewPost post, {required PostCardVariant variant}) { diff --git a/test/features/public/presentation/public_home_screen_test.dart b/test/features/public/presentation/public_home_screen_test.dart index 55707a2..6aeba27 100644 --- a/test/features/public/presentation/public_home_screen_test.dart +++ b/test/features/public/presentation/public_home_screen_test.dart @@ -16,6 +16,15 @@ import 'package:poptart_core/poptart_core.dart' as atcore; class MockPublicContentRepository extends Mock implements PublicContentRepository {} +class _MapPublicContentRepositoryResolver implements PublicContentRepositoryResolver { + const _MapPublicContentRepositoryResolver(this.repositories); + + final Map repositories; + + @override + PublicContentRepository repositoryFor(String providerKey) => repositories[providerKey]!; +} + void main() { late MockPublicContentRepository repository; @@ -48,16 +57,24 @@ void main() { Widget buildSubject({ String providerKey = AppViewProviders.blueskyKey, PublicContentTab contentTab = PublicContentTab.discover, + PublicContentRepositoryResolver? resolver, }) { final router = GoRouter( initialLocation: '/public/$providerKey/${contentTab.routeValue}', routes: [ GoRoute( path: '/public/:provider/:tab', - builder: (context, state) => RepositoryProvider.value( - value: repository, - child: PublicHomeScreen(providerKey: providerKey, contentTab: contentTab), - ), + builder: (context, state) { + final routeState = PublicRouteState.parse( + provider: state.pathParameters['provider'], + tab: state.pathParameters['tab'], + ); + final screen = PublicHomeScreen(providerKey: routeState.providerKey, contentTab: routeState.contentTab); + if (resolver != null) { + return RepositoryProvider.value(value: resolver, child: screen); + } + return RepositoryProvider.value(value: repository, child: screen); + }, ), GoRoute( path: '/feed', @@ -130,6 +147,71 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('public-bluesky-feeds-empty')), findsOneWidget); }); + + testWidgets('provider tabs load through provider-specific repositories', (tester) async { + final blueskyRepository = MockPublicContentRepository(); + final blackskyRepository = MockPublicContentRepository(); + + when( + () => blueskyRepository.loadDiscover( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => PublicDiscoverResult(feeds: [_feed('blue-discover')])); + when( + () => blueskyRepository.loadFeeds( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => PublicFeedsResult(feeds: [_feed('blue-feeds')])); + when( + () => blueskyRepository.searchFeeds( + query: any(named: 'query'), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => PublicFeedsResult(feeds: [_feed('blue-search')])); + + when( + () => blackskyRepository.loadDiscover( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => PublicDiscoverResult(trends: [_trend('Black Topic')])); + when( + () => blackskyRepository.loadFeeds( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => PublicFeedsResult(feeds: [_feed('black-feeds')])); + when( + () => blackskyRepository.searchFeeds( + query: any(named: 'query'), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => PublicFeedsResult(feeds: [_feed('black-search')])); + + await tester.pumpWidget( + buildSubject( + resolver: _MapPublicContentRepositoryResolver({ + AppViewProviders.blueskyKey: blueskyRepository, + AppViewProviders.blackskyKey: blackskyRepository, + }), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Feed blue-discover'), findsOneWidget); + + await tester.tap(find.text('BlackSky')); + await tester.pumpAndSettle(); + + expect(find.text('BlackSky Trending'), findsOneWidget); + expect(find.text('Black Topic'), findsOneWidget); + verify(() => blueskyRepository.loadDiscover(cursor: null, limit: 25)).called(1); + verify(() => blackskyRepository.loadDiscover(cursor: null, limit: 25)).called(1); + }); } GeneratorView _feed(String rkey) {