diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 1b97b1c..d0ec432 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -62,6 +62,9 @@ import 'package:lazurite/features/profile/presentation/profile_connections_scree import 'package:lazurite/features/profile/presentation/profile_context_screen.dart'; import 'package:lazurite/features/profile/presentation/profile_edit_screen.dart'; import 'package:lazurite/features/profile/presentation/profile_screen.dart'; +import 'package:lazurite/features/public/presentation/public_home_screen.dart'; +import 'package:lazurite/features/public/presentation/public_route_state.dart'; +import 'package:lazurite/features/public/presentation/unauthenticated_shell.dart'; import 'package:lazurite/features/search/bloc/search_bloc.dart'; import 'package:lazurite/features/search/cubit/hashtag_cubit.dart'; import 'package:lazurite/features/search/cubit/topic_cubit.dart'; @@ -128,7 +131,15 @@ class AppRouter { }; final isLoggingIn = path == '/login'; final isReauthLogin = state.uri.queryParameters['reauth'] == '1'; - final isPublicPath = publicPaths.contains(path); + final isPublicBrowsingPath = path == '/public' || path.startsWith('/public/'); + final isPublicPath = publicPaths.contains(path) || isPublicBrowsingPath; + + if (!isAuthenticated && path == '/') { + return const PublicRouteState( + providerKey: AppViewProviders.blueskyKey, + contentTab: PublicContentTab.discover, + ).location; + } if (!isAuthenticated && !isPublicPath) { return '/login'; @@ -150,13 +161,48 @@ class AppRouter { return _page( context, state, - LoginScreen(initialHandle: hasInitialHandle ? initialHandle : null, autoStartOAuth: autoStartOAuth), + LoginScreen( + initialHandle: hasInitialHandle ? initialHandle : null, + initialProviderKey: state.uri.queryParameters['provider'], + autoStartOAuth: autoStartOAuth, + ), + ); + }, + ), + GoRoute(path: '/public', redirect: (_, _) => '/public/bluesky/discover'), + GoRoute( + path: '/public/:provider/:tab', + redirect: (_, state) { + final routeState = PublicRouteState.parse( + provider: state.pathParameters['provider'], + tab: state.pathParameters['tab'], + ); + if (state.uri.path != routeState.location) { + return routeState.location; + } + return null; + }, + pageBuilder: (context, state) { + final routeState = PublicRouteState.parse( + provider: state.pathParameters['provider'], + tab: state.pathParameters['tab'], + ); + return MaterialPage( + key: const ValueKey('public-home-route'), + child: _buildUnauthenticatedRouteShell( + context, + state, + PublicHomeScreen(providerKey: routeState.providerKey, contentTab: routeState.contentTab), + publicProviderKey: routeState.providerKey, + publicHomeLocation: routeState.location, + ), ); }, ), GoRoute( path: '/settings', - pageBuilder: (context, state) => _page(context, state, const SettingsScreen()), + pageBuilder: (context, state) => + _page(context, state, _buildUnauthenticatedRouteShell(context, state, const SettingsScreen())), routes: [ GoRoute( path: 'moderation', @@ -194,7 +240,11 @@ class AppRouter { ), GoRoute( path: 'devtools', - pageBuilder: (context, state) => _page(context, state, _buildDevToolsRoute(context, state)), + pageBuilder: (context, state) => _page( + context, + state, + _buildUnauthenticatedRouteShell(context, state, _buildDevToolsRoute(context, state)), + ), ), GoRoute( path: 'video-limits', @@ -647,6 +697,25 @@ class AppRouter { ], ); + Widget _buildUnauthenticatedRouteShell( + BuildContext context, + GoRouterState state, + Widget child, { + String? publicProviderKey, + String? publicHomeLocation, + }) { + if (authBloc.state.isAuthenticated) { + return child; + } + + return UnauthenticatedShell( + location: state.uri.path, + publicProviderKey: publicProviderKey, + publicHomeLocation: publicHomeLocation, + child: child, + ); + } + Widget _buildAlertsRoute(BuildContext context, Widget child) { NotificationBloc? existingNotificationBloc; try { diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart index 9527a20..fe58253 100644 --- a/lib/features/auth/presentation/login_screen.dart +++ b/lib/features/auth/presentation/login_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -13,16 +14,22 @@ 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/atproto_identifier.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; -import 'package:lazurite/features/settings/bloc/settings_state.dart'; import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; import 'package:lazurite/features/typeahead/presentation/typeahead_text_field.dart'; import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; class LoginScreen extends StatefulWidget { - const LoginScreen({this.initialHandle, this.autoStartOAuth = false, this.typeaheadRepository, super.key}); + const LoginScreen({ + this.initialHandle, + this.initialProviderKey, + this.autoStartOAuth = false, + this.typeaheadRepository, + super.key, + }); final String? initialHandle; + final String? initialProviderKey; final bool autoStartOAuth; final TypeaheadRepository? typeaheadRepository; @@ -42,6 +49,7 @@ class _LoginScreenState extends State { bool _didLogMissingAccountSwitcherProvider = false; bool _didLogAvatarLookupFailure = false; late final TypeaheadRepository _typeaheadRepository; + String? _selectedProviderKey; AccountSwitcherCubit? _maybeAccountSwitcherCubit(BuildContext context) { try { @@ -70,10 +78,22 @@ class _LoginScreenState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); + _initializeSelectedProvider(); _requestAccountsLoadIfAvailable(); _requestAutoOAuthIfNeeded(); } + void _initializeSelectedProvider() { + if (_selectedProviderKey != null) { + return; + } + + final routeProvider = widget.initialProviderKey; + _selectedProviderKey = routeProvider == null + ? AppViewProviders.normalizeSettingKey(context.read().state.appViewProvider) + : AppViewProviders.normalizeSettingKey(routeProvider); + } + void _requestAccountsLoadIfAvailable() { if (_didRequestAccountsLoad) { return; @@ -189,6 +209,8 @@ class _LoginScreenState extends State { Future _persistSelectedProvider() async { final settingsCubit = context.read(); + final selectedProvider = + _selectedProviderKey ?? AppViewProviders.normalizeSettingKey(settingsCubit.state.appViewProvider); if (_isPersistingProvider) { return false; } @@ -197,7 +219,7 @@ class _LoginScreenState extends State { _isPersistingProvider = true; }); try { - await settingsCubit.setAppViewProvider(settingsCubit.state.appViewProvider); + await settingsCubit.setAppViewProvider(selectedProvider); return true; } catch (error) { if (mounted) { @@ -354,40 +376,38 @@ class _LoginScreenState extends State { style: theme.textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant), ), const SizedBox(height: 32), - BlocBuilder( - builder: (context, settingsState) { - final selectedProvider = settingsState.appViewProvider; - return Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - l10n.labelChooseYourPortal, - textAlign: TextAlign.center, - style: theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 8), - Center( - child: SegmentedButton( - segments: const [ - ButtonSegment( - value: AppViewProviders.blueskyKey, - label: _ProviderTabLabel(assetPath: 'assets/bluesky.svg', name: 'BlueSky'), - ), - ButtonSegment( - value: AppViewProviders.blackskyKey, - label: _ProviderTabLabel(assetPath: 'assets/blacksky.svg', name: 'BlackSky'), - ), - ], - selected: {selectedProvider}, - onSelectionChanged: (selection) { - unawaited(context.read().setAppViewProvider(selection.first)); - }, + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + l10n.labelChooseYourPortal, + textAlign: TextAlign.center, + style: theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Center( + child: SegmentedButton( + key: const ValueKey('login-provider-switch'), + segments: const [ + ButtonSegment( + value: AppViewProviders.blueskyKey, + label: _ProviderTabLabel(assetPath: 'assets/bluesky.svg', name: 'BlueSky'), ), - ), - const SizedBox(height: 8), - ], - ); - }, + ButtonSegment( + value: AppViewProviders.blackskyKey, + label: _ProviderTabLabel(assetPath: 'assets/blacksky.svg', name: 'BlackSky'), + ), + ], + selected: {_selectedProviderKey ?? AppViewProviders.blueskyKey}, + onSelectionChanged: (selection) { + setState(() { + _selectedProviderKey = AppViewProviders.normalizeSettingKey(selection.first); + }); + }, + ), + ), + const SizedBox(height: 8), + ], ), BlocBuilder( builder: (context, state) { diff --git a/lib/features/public/presentation/public_home_screen.dart b/lib/features/public/presentation/public_home_screen.dart new file mode 100644 index 0000000..c84772f --- /dev/null +++ b/lib/features/public/presentation/public_home_screen.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/features/public/presentation/public_route_state.dart'; + +class PublicHomeScreen extends StatefulWidget { + const PublicHomeScreen({super.key, required this.providerKey, required this.contentTab}); + + final String providerKey; + final PublicContentTab contentTab; + + @override + State createState() => _PublicHomeScreenState(); +} + +class _PublicHomeScreenState extends State { + final PageStorageBucket _bucket = PageStorageBucket(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final activeProviderIndex = _providerIndex(widget.providerKey); + final activeContentIndex = widget.contentTab.index; + final activeIndex = activeProviderIndex * PublicContentTab.values.length + activeContentIndex; + + return SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), + child: Column( + children: [ + SvgPicture.asset( + 'assets/logo.svg', + height: 48, + colorFilter: ColorFilter.mode(theme.colorScheme.primary, BlendMode.srcIn), + ), + const SizedBox(height: 10), + Text( + context.l10n.appTitle, + style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 4), + Text( + context.l10n.labelRoamTheAtmosphere, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 16), + SegmentedButton( + key: const ValueKey('public-provider-switch'), + segments: const [ + ButtonSegment( + value: AppViewProviders.blueskyKey, + label: _ProviderLabel(assetPath: 'assets/bluesky.svg', name: 'BlueSky'), + ), + ButtonSegment( + value: AppViewProviders.blackskyKey, + label: _ProviderLabel(assetPath: 'assets/blacksky.svg', name: 'BlackSky'), + ), + ], + selected: {widget.providerKey}, + onSelectionChanged: (selection) => _go(context, providerKey: selection.first), + ), + const SizedBox(height: 12), + SegmentedButton( + key: const ValueKey('public-content-switch'), + segments: [ + ButtonSegment( + value: PublicContentTab.discover, + label: Text(widget.providerKey == AppViewProviders.blackskyKey ? 'Trending' : 'Discover'), + ), + const ButtonSegment(value: PublicContentTab.feeds, label: Text('Feeds')), + ], + selected: {widget.contentTab}, + onSelectionChanged: (selection) => _go(context, contentTab: selection.first), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: PageStorage( + bucket: _bucket, + child: IndexedStack( + key: const ValueKey('public-home-indexed-stack'), + index: activeIndex, + children: const [ + _PublicTabList(providerKey: AppViewProviders.blueskyKey, contentTab: PublicContentTab.discover), + _PublicTabList(providerKey: AppViewProviders.blueskyKey, contentTab: PublicContentTab.feeds), + _PublicTabList(providerKey: AppViewProviders.blackskyKey, contentTab: PublicContentTab.discover), + _PublicTabList(providerKey: AppViewProviders.blackskyKey, contentTab: PublicContentTab.feeds), + ], + ), + ), + ), + ], + ), + ); + } + + int _providerIndex(String providerKey) => providerKey == AppViewProviders.blackskyKey ? 1 : 0; + + void _go(BuildContext context, {String? providerKey, PublicContentTab? contentTab}) { + final route = PublicRouteState( + providerKey: providerKey ?? widget.providerKey, + contentTab: contentTab ?? widget.contentTab, + ); + context.go(route.location); + } +} + +class _ProviderLabel extends StatelessWidget { + const _ProviderLabel({required this.assetPath, required this.name}); + + static const _blackSkyAssetPath = 'assets/blacksky.svg'; + static const _blackSkyDarkModeColor = Color(0xFF6868B6); + + final String assetPath; + final String name; + + @override + Widget build(BuildContext context) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + assetPath, + height: 16, + colorFilter: assetPath == _blackSkyAssetPath && Theme.of(context).brightness == Brightness.dark + ? const ColorFilter.mode(_blackSkyDarkModeColor, BlendMode.srcIn) + : null, + ), + const SizedBox(width: 8), + Text(name), + ], + ); +} + +class _PublicTabList extends StatelessWidget { + const _PublicTabList({required this.providerKey, required this.contentTab}); + + final String providerKey; + final PublicContentTab contentTab; + + @override + Widget build(BuildContext context) { + final providerName = providerKey == AppViewProviders.blackskyKey ? 'BlackSky' : 'BlueSky'; + final label = contentTab == PublicContentTab.feeds + ? '$providerName Feeds' + : providerKey == AppViewProviders.blackskyKey + ? '$providerName Trending' + : '$providerName Discover'; + return ListView.builder( + key: PageStorageKey('public-$providerKey-${contentTab.routeValue}-scroll'), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + itemCount: 36, + itemBuilder: (context, index) { + if (index == 0) { + return _PublicSectionHeader(label: label); + } + return Card( + margin: const EdgeInsets.only(bottom: 10), + child: ListTile( + key: ValueKey('public-$providerKey-${contentTab.routeValue}-item-$index'), + leading: Icon(contentTab == PublicContentTab.feeds ? Icons.rss_feed : Icons.public), + title: Text('$label item $index'), + subtitle: const Text('Public browsing preview'), + ), + ); + }, + ); + } +} + +class _PublicSectionHeader extends StatelessWidget { + const _PublicSectionHeader({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text(label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), + ); + } +} diff --git a/lib/features/public/presentation/public_route_state.dart b/lib/features/public/presentation/public_route_state.dart new file mode 100644 index 0000000..09b4bb7 --- /dev/null +++ b/lib/features/public/presentation/public_route_state.dart @@ -0,0 +1,38 @@ +import 'package:lazurite/core/network/app_view_provider.dart'; + +enum PublicContentTab { + discover('discover'), + feeds('feeds'); + + const PublicContentTab(this.routeValue); + + final String routeValue; + + static PublicContentTab fromRouteValue(String? rawValue) { + final normalized = rawValue?.trim().toLowerCase(); + return PublicContentTab.values.firstWhere( + (tab) => tab.routeValue == normalized, + orElse: () => PublicContentTab.discover, + ); + } +} + +class PublicRouteState { + const PublicRouteState({required this.providerKey, required this.contentTab}); + + final String providerKey; + final PublicContentTab contentTab; + + String get location => '/public/$providerKey/${contentTab.routeValue}'; + + static PublicRouteState parse({required String? provider, required String? tab}) { + return PublicRouteState(providerKey: normalizeProvider(provider), contentTab: PublicContentTab.fromRouteValue(tab)); + } + + static String normalizeProvider(String? rawProvider) => AppViewProviders.normalizeSettingKey(rawProvider); + + static bool isSupportedProvider(String? rawProvider) { + final normalized = rawProvider?.trim().toLowerCase(); + return normalized != null && AppViewProviders.supportedKeys.contains(normalized); + } +} diff --git a/lib/features/public/presentation/unauthenticated_shell.dart b/lib/features/public/presentation/unauthenticated_shell.dart new file mode 100644 index 0000000..6ff0375 --- /dev/null +++ b/lib/features/public/presentation/unauthenticated_shell.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; +import 'package:lazurite/features/public/presentation/public_route_state.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; + +class UnauthenticatedShell extends StatelessWidget { + const UnauthenticatedShell({ + super.key, + required this.child, + required this.location, + this.publicProviderKey, + this.publicHomeLocation, + }); + + final Widget child; + final String location; + final String? publicProviderKey; + final String? publicHomeLocation; + + @override + Widget build(BuildContext context) => Scaffold( + body: child, + bottomNavigationBar: Container( + decoration: BoxDecoration( + color: context.colorScheme.surface.withValues(alpha: 0.94), + border: Border(top: BorderSide(color: context.colorScheme.outlineVariant)), + ), + 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, + ), + ], + ), + ), + 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), + ), + ), + ], + ), + ), + ), + ); + + int get _selectedIndex { + if (location == '/settings/devtools') { + return 1; + } else if (location == '/settings') { + return 2; + } + return 0; + } + + void _goDestination(BuildContext context, int index) => switch (index) { + 0 => context.go( + publicHomeLocation ?? + const PublicRouteState( + providerKey: AppViewProviders.blueskyKey, + contentTab: PublicContentTab.discover, + ).location, + ), + 1 => context.go('/settings/devtools'), + 2 => context.go('/settings'), + _ => null, + }; + + String _loginProvider(BuildContext context) => (publicProviderKey != null) + ? PublicRouteState.normalizeProvider(publicProviderKey) + : PublicRouteState.normalizeProvider(context.read().state.appViewProvider); +} diff --git a/test/core/router/app_router_test.dart b/test/core/router/app_router_test.dart index 45acff9..7b0a125 100644 --- a/test/core/router/app_router_test.dart +++ b/test/core/router/app_router_test.dart @@ -503,6 +503,191 @@ void main() { expect(find.byTooltip('Open menu'), findsOneWidget); }); + testWidgets('logged-out root opens public Bluesky discover', (tester) async { + currentAuthState = const AuthState.unauthenticated(); + when(() => authBloc.state).thenReturn(currentAuthState); + whenListen(authBloc, Stream.value(currentAuthState), initialState: currentAuthState); + + final router = AppRouter(authBloc: authBloc).router; + + await tester.pumpWidget(buildSubjectWithRouter(router)); + await tester.pumpAndSettle(); + + expect(router.routerDelegate.currentConfiguration.uri.path, '/public/bluesky/discover'); + expect(find.text('BlueSky Discover'), findsOneWidget); + + router.dispose(); + }); + + testWidgets('authenticated root remains on the home feed', (tester) async { + currentAuthState = const AuthState.authenticated(tokens); + when(() => authBloc.state).thenReturn(currentAuthState); + whenListen(authBloc, Stream.value(currentAuthState), initialState: currentAuthState); + + final router = AppRouter(authBloc: authBloc).router; + + await tester.pumpWidget(buildSubjectWithRouter(router)); + await tester.pumpAndSettle(); + + expect(router.routerDelegate.currentConfiguration.uri.path, '/'); + expect(find.text('No feeds pinned'), findsOneWidget); + + router.dispose(); + }); + + testWidgets('public provider routes normalize invalid values', (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('/public/mastodon/feeds'); + await tester.pumpAndSettle(); + + expect(router.routerDelegate.currentConfiguration.uri.path, '/public/bluesky/feeds'); + expect(find.text('BlueSky Feeds'), findsOneWidget); + + router.dispose(); + }); + + testWidgets('unauthenticated bottom navigation maps destinations and login action', (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('/public/blacksky/discover'); + await tester.pumpAndSettle(); + + var navBar = tester.widget(find.byKey(const ValueKey('unauthenticated-navigation-bar'))); + expect(navBar.selectedIndex, 0); + expect(navBar.labelBehavior, NavigationDestinationLabelBehavior.alwaysShow); + expect(navBar.destinations.map((destination) => (destination as NavigationDestination).label), [ + 'HOME', + 'AT Explorer', + 'Settings', + ]); + + await tester.tap(find.text('AT Explorer').last); + await tester.pumpAndSettle(); + expect(router.routerDelegate.currentConfiguration.uri.path, '/settings/devtools'); + 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'); + 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'); + + router.go('/public/blacksky/feeds'); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('unauthenticated-login-button'))); + await tester.pumpAndSettle(); + + expect(router.routerDelegate.currentConfiguration.uri.path, '/login'); + expect(router.routerDelegate.currentConfiguration.uri.queryParameters['provider'], 'blacksky'); + + router.dispose(); + }); + + testWidgets('unauthenticated settings and AT Explorer login use persisted provider', (tester) async { + currentAuthState = const AuthState.unauthenticated(); + when(() => authBloc.state).thenReturn(currentAuthState); + whenListen(authBloc, Stream.value(currentAuthState), initialState: currentAuthState); + const blackskySettings = SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + appViewProvider: 'blacksky', + ); + when(() => settingsCubit.state).thenReturn(blackskySettings); + whenListen(settingsCubit, const Stream.empty(), initialState: blackskySettings); + final router = AppRouter(authBloc: authBloc).router; + + await tester.pumpWidget(buildSubjectWithRouter(router)); + router.go('/settings'); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('unauthenticated-login-button'))); + await tester.pumpAndSettle(); + + expect(router.routerDelegate.currentConfiguration.uri.path, '/login'); + expect(router.routerDelegate.currentConfiguration.uri.queryParameters['provider'], 'blacksky'); + + router.go('/settings/devtools'); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('unauthenticated-login-button'))); + await tester.pumpAndSettle(); + + expect(router.routerDelegate.currentConfiguration.uri.path, '/login'); + 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); + whenListen(authBloc, Stream.value(currentAuthState), initialState: currentAuthState); + const invalidSettings = SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + appViewProvider: 'unknown', + ); + when(() => settingsCubit.state).thenReturn(invalidSettings); + whenListen(settingsCubit, const Stream.empty(), initialState: invalidSettings); + final router = AppRouter(authBloc: authBloc).router; + + await tester.pumpWidget(buildSubjectWithRouter(router)); + router.go('/settings'); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('unauthenticated-login-button'))); + await tester.pumpAndSettle(); + + expect(router.routerDelegate.currentConfiguration.uri.path, '/login'); + expect(router.routerDelegate.currentConfiguration.uri.queryParameters['provider'], 'bluesky'); + + router.dispose(); + }); + + testWidgets('public tab switch preserves discover scroll position', (tester) async { + currentAuthState = const AuthState.unauthenticated(); + when(() => authBloc.state).thenReturn(currentAuthState); + whenListen(authBloc, Stream.value(currentAuthState), initialState: currentAuthState); + final router = AppRouter(authBloc: authBloc).router; + + await tester.pumpWidget(buildSubjectWithRouter(router)); + await tester.pumpAndSettle(); + + await tester.drag(find.byType(Scrollable).first, const Offset(0, -700)); + await tester.pumpAndSettle(); + final scrolledOffset = tester.state(find.byType(Scrollable).first).position.pixels; + expect(scrolledOffset, greaterThan(0)); + + await tester.tap(find.text('Feeds').last); + await tester.pumpAndSettle(); + expect(router.routerDelegate.currentConfiguration.uri.path, '/public/bluesky/feeds'); + + await tester.tap(find.text('Discover').last); + await tester.pumpAndSettle(); + + final restoredOffset = tester.state(find.byType(Scrollable).first).position.pixels; + expect(restoredOffset, scrolledOffset); + + router.dispose(); + }); + testWidgets('stays on public settings after logout without crashing', (tester) async { await tester.binding.setSurfaceSize(const Size(430, 932)); addTearDown(() => tester.binding.setSurfaceSize(null)); diff --git a/test/features/auth/presentation/login_screen_test.dart b/test/features/auth/presentation/login_screen_test.dart index 165b30d..158fe8c 100644 --- a/test/features/auth/presentation/login_screen_test.dart +++ b/test/features/auth/presentation/login_screen_test.dart @@ -56,6 +56,7 @@ void main() { ThemeMode themeMode = ThemeMode.system, MockAccountSwitcherCubit? accountCubit, String? initialHandle, + String? initialProviderKey, bool autoStartOAuth = false, }) { final typeaheadRepository = _FakeTypeaheadRepository( @@ -74,6 +75,7 @@ void main() { ], child: LoginScreen( initialHandle: initialHandle, + initialProviderKey: initialProviderKey, autoStartOAuth: autoStartOAuth, typeaheadRepository: typeaheadRepository, ), @@ -214,6 +216,64 @@ void main() { ]); }); + testWidgets('preselects BlueSky from provider query value', (tester) async { + const blackskySettings = SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + appViewProvider: 'blacksky', + ); + when(() => settingsCubit.state).thenReturn(blackskySettings); + whenListen(settingsCubit, const Stream.empty(), initialState: blackskySettings); + + await tester.pumpWidget(buildSubject(initialProviderKey: 'bluesky')); + await tester.pumpAndSettle(); + + final providerSwitch = tester.widget>( + find.byKey(const ValueKey('login-provider-switch')), + ); + expect(providerSwitch.selected, {'bluesky'}); + verifyNever(() => settingsCubit.setAppViewProvider(any())); + }); + + testWidgets('preselects BlackSky from provider query value', (tester) async { + await tester.pumpWidget(buildSubject(initialProviderKey: 'blacksky')); + await tester.pumpAndSettle(); + + final providerSwitch = tester.widget>( + find.byKey(const ValueKey('login-provider-switch')), + ); + expect(providerSwitch.selected, {'blacksky'}); + verifyNever(() => settingsCubit.setAppViewProvider(any())); + }); + + testWidgets('manual provider switching persists selected login provider on submit', (tester) async { + await tester.pumpWidget(buildSubject(initialProviderKey: 'bluesky')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('BlackSky')); + await tester.pumpAndSettle(); + + var providerSwitch = tester.widget>( + find.byKey(const ValueKey('login-provider-switch')), + ); + expect(providerSwitch.selected, {'blacksky'}); + verifyNever(() => settingsCubit.setAppViewProvider(any())); + + await tester.enterText(find.byType(TextFormField).first, 'river.bsky.social'); + await tester.tap(find.byKey(const ValueKey('login-continue-button'))); + await tester.pumpAndSettle(); + + verifyInOrder([ + () => settingsCubit.setAppViewProvider('blacksky'), + () => authBloc.add(const OAuthLoginRequested(handle: 'river.bsky.social')), + ]); + providerSwitch = tester.widget>( + find.byKey(const ValueKey('login-provider-switch')), + ); + expect(providerSwitch.selected, {'blacksky'}); + }); + testWidgets('handle field exposes persistent label and continue tooltip', (tester) async { await tester.pumpWidget(buildSubject()); await tester.pumpAndSettle(); diff --git a/test/features/public/presentation/public_route_state_test.dart b/test/features/public/presentation/public_route_state_test.dart new file mode 100644 index 0000000..8ece319 --- /dev/null +++ b/test/features/public/presentation/public_route_state_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/features/public/presentation/public_route_state.dart'; + +void main() { + group('PublicRouteState', () { + test('normalizes supported providers and tabs', () { + final state = PublicRouteState.parse(provider: ' BLACKSKY ', tab: 'feeds'); + + expect(state.providerKey, AppViewProviders.blackskyKey); + expect(state.contentTab, PublicContentTab.feeds); + expect(state.location, '/public/blacksky/feeds'); + }); + + test('falls back to Bluesky discover for invalid route values', () { + final state = PublicRouteState.parse(provider: 'unknown', tab: 'posts'); + + expect(state.providerKey, AppViewProviders.blueskyKey); + expect(state.contentTab, PublicContentTab.discover); + expect(state.location, '/public/bluesky/discover'); + }); + }); +}