diff --git a/docs/tasks/routing.md b/docs/tasks/routing.md index 87696d1..fdd2272 100644 --- a/docs/tasks/routing.md +++ b/docs/tasks/routing.md @@ -3,22 +3,14 @@ title: AppView Routing + Trending Milestones updated: 2026-04-29 --- -## M0 - Research + Planning (Complete) - -- [x] Validate Bluesky and Blacksky AppView DID/service identities -- [x] Validate live compatibility for trends/trending-topics endpoints -- [x] Capture provider divergence (link formats, `suggested` behavior) -- [x] Update routing spec with provider-switch UX contract and state safety rules -- [x] Update routing/task plan to include Trending button + `/trending` screen - ## M1 - Core Routing Model -- [ ] Add `appview_provider` setting with defaults and validation -- [ ] Add login-screen provider selector (Bluesky + Blacksky visible by default) -- [ ] Persist login-screen provider choice before any auth/network call -- [ ] Add provider descriptor (`serviceDid`, `publicBaseUrl`, `entrywayUrl`, `webBaseUrl`) -- [ ] Add `AppViewRouter` abstraction for endpoint/header/link resolution -- [ ] Add unit tests for provider normalization/defaults and bootstrap ordering +- [x] Add `appview_provider` setting with defaults and validation +- [x] Add login-screen provider selector (Bluesky + Blacksky visible by default) +- [x] Persist login-screen provider choice before any auth/network call +- [x] Add provider descriptor (`serviceDid`, `publicBaseUrl`, `entrywayUrl`, `webBaseUrl`) +- [x] Add `AppViewRouter` abstraction for endpoint/header/link resolution +- [x] Add unit tests for provider normalization/defaults and bootstrap ordering ## M2 - Header + Request Integration diff --git a/lib/core/bootstrap/auth_bootstrap.dart b/lib/core/bootstrap/auth_bootstrap.dart new file mode 100644 index 0000000..e4e3e06 --- /dev/null +++ b/lib/core/bootstrap/auth_bootstrap.dart @@ -0,0 +1,20 @@ +import 'package:lazurite/features/auth/data/auth_repository.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; + +class AuthBootstrapResult { + const AuthBootstrapResult({required this.authRepository, required this.restoredSession}); + + final AuthRepository authRepository; + final AuthTokens? restoredSession; +} + +Future bootstrapAuthDependencies({ + required Future Function() loadSettings, + required AuthRepository Function() createAuthRepository, + required Future Function(AuthRepository authRepository) restoreSession, +}) async { + await loadSettings(); + final authRepository = createAuthRepository(); + final restoredSession = await restoreSession(authRepository); + return AuthBootstrapResult(authRepository: authRepository, restoredSession: restoredSession); +} diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index 5deb11c..3c258a7 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -26,13 +26,14 @@ class AppDatabase extends _$AppDatabase { static const activeAccountDidSettingKey = 'active_account_did'; @override - int get schemaVersion => 17; + int get schemaVersion => 18; @override MigrationStrategy get migration => MigrationStrategy( onCreate: (migrator) async { await migrator.createAll(); await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('typeahead_provider', 'bluesky')"); + await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('appview_provider', 'bluesky')"); }, onUpgrade: (migrator, from, to) async { if (from < 2) { @@ -119,6 +120,9 @@ class AppDatabase extends _$AppDatabase { if (from < 17) { await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('typeahead_provider', 'bluesky')"); } + if (from < 18) { + await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('appview_provider', 'bluesky')"); + } }, ); diff --git a/lib/core/network/app_view_provider.dart b/lib/core/network/app_view_provider.dart new file mode 100644 index 0000000..5d3c04f --- /dev/null +++ b/lib/core/network/app_view_provider.dart @@ -0,0 +1,53 @@ +class AppViewProviderDescriptor { + const AppViewProviderDescriptor({ + required this.key, + required this.serviceDid, + required this.publicBaseUrl, + required this.entrywayUrl, + required this.webBaseUrl, + }); + + final String key; + final String serviceDid; + final Uri publicBaseUrl; + final Uri entrywayUrl; + final Uri webBaseUrl; +} + +abstract final class AppViewProviders { + static const String blueskyKey = 'bluesky'; + static const String blackskyKey = 'blacksky'; + static const String defaultKey = blueskyKey; + static const Set supportedKeys = {blueskyKey, blackskyKey}; + + static final AppViewProviderDescriptor bluesky = AppViewProviderDescriptor( + key: blueskyKey, + serviceDid: 'did:web:api.bsky.app#bsky_appview', + publicBaseUrl: Uri.https('public.api.bsky.app'), + entrywayUrl: Uri.https('bsky.social'), + webBaseUrl: Uri.https('bsky.app'), + ); + + static final AppViewProviderDescriptor blacksky = AppViewProviderDescriptor( + key: blackskyKey, + serviceDid: 'did:web:api.blacksky.community#bsky_appview', + publicBaseUrl: Uri.https('api.blacksky.community'), + entrywayUrl: Uri.https('blacksky.app'), + webBaseUrl: Uri.https('blacksky.app'), + ); + + static final Map _builtIns = {blueskyKey: bluesky, blackskyKey: blacksky}; + + static String normalizeSettingKey(String? rawKey) { + final normalized = rawKey?.trim().toLowerCase(); + if (normalized == null || !supportedKeys.contains(normalized)) { + return defaultKey; + } + return normalized; + } + + static AppViewProviderDescriptor descriptorForSetting(String? rawKey) { + final normalizedKey = normalizeSettingKey(rawKey); + return _builtIns[normalizedKey] ?? bluesky; + } +} diff --git a/lib/core/network/app_view_router.dart b/lib/core/network/app_view_router.dart new file mode 100644 index 0000000..7792dd4 --- /dev/null +++ b/lib/core/network/app_view_router.dart @@ -0,0 +1,30 @@ +import 'package:lazurite/core/network/app_view_provider.dart'; + +class AppViewRouter { + AppViewRouter({required this.provider}); + + final AppViewProviderDescriptor provider; + + Map appBskyProxyHeaders() => {'atproto-proxy': provider.serviceDid}; + + Uri publicEndpoint(String xrpcPath, [Map queryParameters = const {}]) { + final normalizedPath = xrpcPath.startsWith('/') ? xrpcPath : '/$xrpcPath'; + return provider.publicBaseUrl.replace(path: normalizedPath, queryParameters: queryParameters); + } + + Uri entrywayForAuth() => provider.entrywayUrl; + + Uri resolveWebLink(String relativeOrAbsolute) { + final trimmed = relativeOrAbsolute.trim(); + if (trimmed.isEmpty) { + return provider.webBaseUrl; + } + + final parsed = Uri.tryParse(trimmed); + if (parsed != null && parsed.hasScheme) { + return parsed; + } + + return provider.webBaseUrl.resolve(trimmed); + } +} diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart index aca0132..a574aa5 100644 --- a/lib/features/auth/data/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -11,6 +11,7 @@ import 'package:http/http.dart' as http; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/atproto_host_resolver.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/core/network/xrpc_client_factory.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -33,12 +34,14 @@ class AuthRepository { SupportsCloseForMode supportsCloseForMode = supportsCloseForLaunchMode, OAuthRefreshSession oauthRefreshSession = _defaultOAuthRefreshSession, Future Function(String clientId) loadClientMetadata = getClientMetadata, + String Function()? oauthServiceResolver, }) : _database = database, _launchUrlWithMode = launchUrlWithMode, _closeInAppBrowser = closeInAppBrowser, _supportsCloseForMode = supportsCloseForMode, _oauthRefreshSession = oauthRefreshSession, - _loadClientMetadata = loadClientMetadata; + _loadClientMetadata = loadClientMetadata, + _oauthServiceResolver = oauthServiceResolver ?? _defaultOAuthServiceResolver; static const String kClientId = 'https://lazurite.stormlightlabs.org/client-metadata.json'; static const String _oauthService = 'bsky.social'; @@ -51,6 +54,7 @@ class AuthRepository { final SupportsCloseForMode _supportsCloseForMode; final OAuthRefreshSession _oauthRefreshSession; final Future Function(String clientId) _loadClientMetadata; + final String Function() _oauthServiceResolver; HttpServer? _callbackServer; StreamSubscription? _callbackSubscription; @@ -148,7 +152,7 @@ class AuthRepository { try { _oauthCompleter = Completer(); _pendingHandle = handle.trim(); - _pendingService = _oauthService; + _pendingService = normalizeAtprotoServiceHost(_oauthServiceResolver()) ?? _oauthService; log.i('AuthRepository: Starting OAuth login for ${_pendingHandle!}'); final metadata = await _loadClientMetadata(kClientId); @@ -723,6 +727,10 @@ class AuthRepository { return oauthClient.refresh(session); } + static String _defaultOAuthServiceResolver() { + return AppViewProviders.descriptorForSetting(AppViewProviders.defaultKey).entrywayUrl.host; + } + String _summarizeOAuthRefreshError(Object error) { final message = error.toString().replaceAll('\n', ' ').trim(); diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart index 1512c7b..fc29f35 100644 --- a/lib/features/auth/presentation/login_screen.dart +++ b/lib/features/auth/presentation/login_screen.dart @@ -1,9 +1,14 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.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'; @@ -22,6 +27,7 @@ class _LoginScreenState extends State { final _appPasswordController = TextEditingController(); final _formKey = GlobalKey(); bool _showDebugForm = false; + bool _isPersistingProvider = false; late final TypeaheadRepository _typeaheadRepository; @override @@ -38,19 +44,33 @@ class _LoginScreenState extends State { super.dispose(); } - void _onOAuthLogin() { + Future _onOAuthLogin() async { if (!_isHandleValid()) { return; } + final persisted = await _persistSelectedProvider(); + if (!persisted) { + return; + } + if (!mounted) { + return; + } context.read().add(OAuthLoginRequested(handle: _handleController.text.trim())); } - void _onAppPasswordLogin() { + Future _onAppPasswordLogin() async { if (!(_formKey.currentState?.validate() ?? false)) { return; } + final persisted = await _persistSelectedProvider(); + if (!persisted) { + return; + } + if (!mounted) { + return; + } context.read().add( LoginRequested(handle: _handleController.text.trim(), appPassword: _appPasswordController.text.trim()), ); @@ -62,7 +82,35 @@ class _LoginScreenState extends State { void _onTypeaheadSelected(TypeaheadResult result) { _handleController.text = result.handle; - _onOAuthLogin(); + unawaited(_onOAuthLogin()); + } + + Future _persistSelectedProvider() async { + final settingsCubit = context.read(); + if (_isPersistingProvider) { + return false; + } + + setState(() { + _isPersistingProvider = true; + }); + try { + await settingsCubit.setAppViewProvider(settingsCubit.state.appViewProvider); + return true; + } catch (error) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to save provider selection: $error'))); + } + return false; + } finally { + if (mounted) { + setState(() { + _isPersistingProvider = false; + }); + } + } } @override @@ -111,6 +159,39 @@ 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.start, + children: [ + Text( + 'AppView Provider', + style: theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment(value: AppViewProviders.blueskyKey, label: Text('Bluesky')), + ButtonSegment(value: AppViewProviders.blackskyKey, label: Text('Blacksky')), + ], + selected: {selectedProvider}, + onSelectionChanged: (selection) { + unawaited(context.read().setAppViewProvider(selection.first)); + }, + ), + const SizedBox(height: 8), + Text( + selectedProvider == AppViewProviders.blackskyKey + ? 'Sign-in will use Blacksky entryway defaults.' + : 'Sign-in will use Bluesky entryway defaults.', + style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 16), + ], + ); + }, + ), TypeaheadTextField( controller: _handleController, repository: _typeaheadRepository, @@ -136,16 +217,21 @@ class _LoginScreenState extends State { const SizedBox(height: 20), BlocBuilder( builder: (context, state) { + final busy = state.isLoading || _isPersistingProvider; return FilledButton.icon( - onPressed: state.isLoading ? null : _onOAuthLogin, - icon: state.isLoading + onPressed: busy + ? null + : () { + unawaited(_onOAuthLogin()); + }, + icon: busy ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.language), - label: Text(state.isLoading ? 'Starting sign in...' : 'Continue'), + label: Text(busy ? 'Starting sign in...' : 'Continue'), style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 18)), ); }, @@ -206,8 +292,13 @@ class _LoginScreenState extends State { const SizedBox(height: 12), BlocBuilder( builder: (context, state) { + final busy = state.isLoading || _isPersistingProvider; return OutlinedButton.icon( - onPressed: state.isLoading ? null : _onAppPasswordLogin, + onPressed: busy + ? null + : () { + unawaited(_onAppPasswordLogin()); + }, icon: const Icon(Icons.login), label: const Text('Sign In'), ); diff --git a/lib/features/settings/bloc/settings_cubit.dart b/lib/features/settings/bloc/settings_cubit.dart index 6a244e9..e30d5f2 100644 --- a/lib/features/settings/bloc/settings_cubit.dart +++ b/lib/features/settings/bloc/settings_cubit.dart @@ -1,5 +1,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/core/theme/feed_layout.dart'; import 'package:lazurite/features/search/data/search_scope.dart'; @@ -47,6 +48,7 @@ class SettingsCubit extends Cubit { static const String _keyTypeaheadProvider = 'typeahead_provider'; static const String _defaultTypeaheadProvider = 'bluesky'; static const Set _supportedTypeaheadProviders = {'bluesky', 'community'}; + static const String _keyAppViewProvider = 'appview_provider'; Future loadSettings() async { final paletteStr = await database.getSetting(_keyThemePalette); @@ -62,9 +64,11 @@ class SettingsCubit extends Cubit { final searchScopeStr = await database.getSetting(_keySearchScope); final semanticSearchMaxResultsStr = await database.getSetting(_keySemanticSearchMaxResults); final typeaheadProviderStr = await database.getSetting(_keyTypeaheadProvider); + final appViewProviderStr = await database.getSetting(_keyAppViewProvider); final resolvedTypeaheadProvider = _supportedTypeaheadProviders.contains(typeaheadProviderStr) ? typeaheadProviderStr! : _defaultTypeaheadProvider; + final resolvedAppViewProvider = AppViewProviders.normalizeSettingKey(appViewProviderStr); emit( state.copyWith( @@ -80,6 +84,7 @@ class SettingsCubit extends Cubit { searchScope: SearchScope.values.firstWhere((s) => s.name == searchScopeStr, orElse: () => SearchScope.both), semanticSearchMaxResults: int.tryParse(semanticSearchMaxResultsStr ?? '') ?? 20, typeaheadProvider: resolvedTypeaheadProvider, + appViewProvider: resolvedAppViewProvider, ), ); } @@ -163,4 +168,10 @@ class SettingsCubit extends Cubit { await database.setSetting(_keyTypeaheadProvider, normalizedProvider); emit(state.copyWith(typeaheadProvider: normalizedProvider)); } + + Future setAppViewProvider(String provider) async { + final normalizedProvider = AppViewProviders.normalizeSettingKey(provider); + await database.setSetting(_keyAppViewProvider, normalizedProvider); + emit(state.copyWith(appViewProvider: normalizedProvider)); + } } diff --git a/lib/features/settings/bloc/settings_state.dart b/lib/features/settings/bloc/settings_state.dart index 9d1bbdf..5a49c7c 100644 --- a/lib/features/settings/bloc/settings_state.dart +++ b/lib/features/settings/bloc/settings_state.dart @@ -19,6 +19,7 @@ class SettingsState extends Equatable { this.searchScope = SearchScope.both, this.semanticSearchMaxResults = 20, this.typeaheadProvider = 'bluesky', + this.appViewProvider = 'bluesky', }); final AppThemePalette themePalette; @@ -42,6 +43,9 @@ class SettingsState extends Equatable { /// Configured typeahead backend provider (`bluesky` or `community`). final String typeaheadProvider; + /// Configured AppView provider (`bluesky` or `blacksky`). + final String appViewProvider; + SettingsState copyWith({ AppThemePalette? themePalette, AppThemeVariant? themeVariant, @@ -55,6 +59,7 @@ class SettingsState extends Equatable { SearchScope? searchScope, int? semanticSearchMaxResults, String? typeaheadProvider, + String? appViewProvider, }) { return SettingsState( themePalette: themePalette ?? this.themePalette, @@ -71,6 +76,7 @@ class SettingsState extends Equatable { searchScope: searchScope ?? this.searchScope, semanticSearchMaxResults: semanticSearchMaxResults ?? this.semanticSearchMaxResults, typeaheadProvider: typeaheadProvider ?? this.typeaheadProvider, + appViewProvider: appViewProvider ?? this.appViewProvider, ); } @@ -88,5 +94,6 @@ class SettingsState extends Equatable { searchScope, semanticSearchMaxResults, typeaheadProvider, + appViewProvider, ]; } diff --git a/lib/main.dart b/lib/main.dart index 7a032f0..508a5f9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,11 +5,14 @@ import 'package:bluesky/bluesky_chat.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/bootstrap/auth_bootstrap.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/embedding/embedding_service.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_provider.dart'; +import 'package:lazurite/core/network/app_view_router.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'; @@ -62,17 +65,27 @@ Future main() async { final objectBoxStore = await ObjectBoxStore.open(); final embeddingService = EmbeddingService(); unawaited(embeddingService.initialize()); - final authRepository = AuthRepository(database: database); - final restoredSession = await authRepository.restoreSession(); + final settingsCubit = SettingsCubit(database: database); + final authBootstrap = await bootstrapAuthDependencies( + loadSettings: settingsCubit.loadSettings, + createAuthRepository: () => AuthRepository( + database: database, + oauthServiceResolver: () { + final provider = AppViewProviders.descriptorForSetting(settingsCubit.state.appViewProvider); + final router = AppViewRouter(provider: provider); + return router.entrywayForAuth().host; + }, + ), + restoreSession: (authRepository) => authRepository.restoreSession(), + ); + final authRepository = authBootstrap.authRepository; + final restoredSession = authBootstrap.restoredSession; final authBloc = AuthBloc( authRepository: authRepository, initialState: restoredSession != null ? AuthState.authenticated(restoredSession) : const AuthState.unauthenticated(), ); - - final settingsCubit = SettingsCubit(database: database); - await settingsCubit.loadSettings(); final connectivityCubit = ConnectivityCubit(simulateOffline: settingsCubit.state.simulateOffline); final accountSwitcherCubit = AccountSwitcherCubit(database: database, authRepository: authRepository); diff --git a/test/core/bootstrap/auth_bootstrap_test.dart b/test/core/bootstrap/auth_bootstrap_test.dart new file mode 100644 index 0000000..e240ac9 --- /dev/null +++ b/test/core/bootstrap/auth_bootstrap_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/bootstrap/auth_bootstrap.dart'; +import 'package:lazurite/features/auth/data/auth_repository.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +void main() { + group('bootstrapAuthDependencies', () { + test('loads settings before creating and restoring auth repository', () async { + final callOrder = []; + final authRepository = MockAuthRepository(); + const restoredSession = AuthTokens(accessToken: 'token', did: 'did:plc:test', handle: 'user.bsky.social'); + + final result = await bootstrapAuthDependencies( + loadSettings: () async { + callOrder.add('loadSettings'); + }, + createAuthRepository: () { + callOrder.add('createAuthRepository'); + return authRepository; + }, + restoreSession: (repository) async { + expect(repository, same(authRepository)); + callOrder.add('restoreSession'); + return restoredSession; + }, + ); + + expect(callOrder, equals(['loadSettings', 'createAuthRepository', 'restoreSession'])); + expect(result.authRepository, same(authRepository)); + expect(result.restoredSession, same(restoredSession)); + }); + }); +} diff --git a/test/core/database/app_database_test.dart b/test/core/database/app_database_test.dart index cad12fa..4f0611b 100644 --- a/test/core/database/app_database_test.dart +++ b/test/core/database/app_database_test.dart @@ -228,6 +228,11 @@ void main() { expect(value, equals('bluesky')); }); + test('should seed default appview provider on database creation', () async { + final value = await database.getSetting('appview_provider'); + expect(value, equals('bluesky')); + }); + test('should set and get setting', () async { await database.setSetting('theme', 'dark'); final value = await database.getSetting('theme'); diff --git a/test/core/network/app_view_provider_test.dart b/test/core/network/app_view_provider_test.dart new file mode 100644 index 0000000..dccab04 --- /dev/null +++ b/test/core/network/app_view_provider_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; + +void main() { + group('AppViewProviders', () { + test('normalizes known provider keys', () { + expect(AppViewProviders.normalizeSettingKey(' BLACKSKY '), equals('blacksky')); + expect(AppViewProviders.normalizeSettingKey('bluesky'), equals('bluesky')); + }); + + test('falls back to default for unknown keys', () { + expect(AppViewProviders.normalizeSettingKey('unknown'), equals(AppViewProviders.defaultKey)); + expect(AppViewProviders.normalizeSettingKey(null), equals(AppViewProviders.defaultKey)); + }); + + test('returns descriptors with expected fields', () { + final bluesky = AppViewProviders.descriptorForSetting('bluesky'); + final blacksky = AppViewProviders.descriptorForSetting('blacksky'); + + expect(bluesky.serviceDid, equals('did:web:api.bsky.app#bsky_appview')); + expect(bluesky.publicBaseUrl.host, equals('public.api.bsky.app')); + expect(bluesky.entrywayUrl.host, equals('bsky.social')); + expect(bluesky.webBaseUrl.host, equals('bsky.app')); + + expect(blacksky.serviceDid, equals('did:web:api.blacksky.community#bsky_appview')); + expect(blacksky.publicBaseUrl.host, equals('api.blacksky.community')); + expect(blacksky.entrywayUrl.host, equals('blacksky.app')); + expect(blacksky.webBaseUrl.host, equals('blacksky.app')); + }); + }); +} diff --git a/test/core/network/app_view_router_test.dart b/test/core/network/app_view_router_test.dart new file mode 100644 index 0000000..cf01e6e --- /dev/null +++ b/test/core/network/app_view_router_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/core/network/app_view_router.dart'; + +void main() { + group('AppViewRouter', () { + test('builds proxy headers and public endpoint URI', () { + final router = AppViewRouter(provider: AppViewProviders.blacksky); + + expect(router.appBskyProxyHeaders(), equals({'atproto-proxy': 'did:web:api.blacksky.community#bsky_appview'})); + expect( + router.publicEndpoint('/xrpc/app.bsky.unspecced.getTrends', {'limit': '10'}).toString(), + equals('https://api.blacksky.community/xrpc/app.bsky.unspecced.getTrends?limit=10'), + ); + }); + + test('resolves relative links against provider web base', () { + final router = AppViewRouter(provider: AppViewProviders.bluesky); + + expect(router.resolveWebLink('/topic/abc123').toString(), equals('https://bsky.app/topic/abc123')); + expect( + router.resolveWebLink('/profile/alice.bsky.social/feed/aaabbb').toString(), + equals('https://bsky.app/profile/alice.bsky.social/feed/aaabbb'), + ); + }); + + test('returns absolute links unchanged', () { + final router = AppViewRouter(provider: AppViewProviders.bluesky); + expect(router.resolveWebLink('https://example.com/path').toString(), equals('https://example.com/path')); + }); + }); +} diff --git a/test/core/router/app_router_test.dart b/test/core/router/app_router_test.dart index aac5fa9..66a3872 100644 --- a/test/core/router/app_router_test.dart +++ b/test/core/router/app_router_test.dart @@ -363,8 +363,11 @@ void main() { final router = AppRouter(authBloc: authBloc).router; await tester.pumpWidget( - BlocProvider.value( - value: authBloc, + MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: settingsCubit), + ], child: MaterialApp.router(routerConfig: router), ), ); diff --git a/test/features/auth/presentation/login_screen_test.dart b/test/features/auth/presentation/login_screen_test.dart index 32bef22..704ed39 100644 --- a/test/features/auth/presentation/login_screen_test.dart +++ b/test/features/auth/presentation/login_screen_test.dart @@ -3,7 +3,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/settings/bloc/settings_state.dart'; import 'package:lazurite/features/auth/presentation/login_screen.dart'; import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; @@ -11,13 +14,25 @@ import 'package:mocktail/mocktail.dart'; class MockAuthBloc extends MockBloc implements AuthBloc {} +class MockSettingsCubit extends MockCubit implements SettingsCubit {} + void main() { late MockAuthBloc authBloc; + late MockSettingsCubit settingsCubit; setUp(() { authBloc = MockAuthBloc(); + settingsCubit = MockSettingsCubit(); when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); whenListen(authBloc, const Stream.empty(), initialState: const AuthState.unauthenticated()); + const settingsState = SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + ); + when(() => settingsCubit.state).thenReturn(settingsState); + whenListen(settingsCubit, const Stream.empty(), initialState: settingsState); + when(() => settingsCubit.setAppViewProvider(any())).thenAnswer((_) async {}); }); Widget buildSubject() { @@ -29,8 +44,11 @@ void main() { routes: [ GoRoute( path: '/login', - builder: (context, state) => BlocProvider.value( - value: authBloc, + builder: (context, state) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: settingsCubit), + ], child: LoginScreen(typeaheadRepository: typeaheadRepository), ), ), @@ -102,8 +120,11 @@ void main() { routes: [ GoRoute( path: '/login', - builder: (context, state) => BlocProvider.value( - value: authBloc, + builder: (context, state) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: settingsCubit), + ], child: LoginScreen(typeaheadRepository: typeaheadRepository), ), ), @@ -124,6 +145,20 @@ void main() { verify(() => authBloc.add(const OAuthLoginRequested(handle: 'river.bsky.social'))).called(1); }); + + testWidgets('persists selected provider before triggering OAuth login', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextFormField).first, 'river.bsky.social'); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + + verifyInOrder([ + () => settingsCubit.setAppViewProvider('bluesky'), + () => authBloc.add(const OAuthLoginRequested(handle: 'river.bsky.social')), + ]); + }); } class _FakeTypeaheadRepository extends TypeaheadRepository { diff --git a/test/features/settings/bloc/settings_cubit_test.dart b/test/features/settings/bloc/settings_cubit_test.dart index 8fd232f..c9f55d7 100644 --- a/test/features/settings/bloc/settings_cubit_test.dart +++ b/test/features/settings/bloc/settings_cubit_test.dart @@ -28,6 +28,7 @@ void main() { expect(cubit.state.animationsEnabled, true); expect(cubit.state.simulateOffline, false); expect(cubit.state.threadAutoCollapseDepth, isNull); + expect(cubit.state.appViewProvider, 'bluesky'); }); test('accepts initial values via constructor', () { @@ -88,7 +89,8 @@ void main() { .having((s) => s.animationsEnabled, 'animationsEnabled', true) .having((s) => s.simulateOffline, 'simulateOffline', false) .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', isNull) - .having((s) => s.typeaheadProvider, 'typeaheadProvider', 'bluesky'), + .having((s) => s.typeaheadProvider, 'typeaheadProvider', 'bluesky') + .having((s) => s.appViewProvider, 'appViewProvider', 'bluesky'), ], ); @@ -305,5 +307,25 @@ void main() { act: (cubit) => cubit.loadSettings(), expect: () => [isA().having((s) => s.typeaheadProvider, 'typeaheadProvider', 'community')], ); + + blocTest( + 'setAppViewProvider normalizes and persists provider key', + build: () => SettingsCubit(database: database), + act: (cubit) => cubit.setAppViewProvider(' BlackSky '), + expect: () => [isA().having((s) => s.appViewProvider, 'appViewProvider', 'blacksky')], + verify: (_) async { + expect(await database.getSetting('appview_provider'), 'blacksky'); + }, + ); + + blocTest( + 'loadSettings falls back to default app view provider for invalid persisted value', + build: () => SettingsCubit(database: database), + setUp: () async { + await database.setSetting('appview_provider', 'unknown-provider'); + }, + act: (cubit) => cubit.loadSettings(), + expect: () => [isA().having((s) => s.appViewProvider, 'appViewProvider', 'bluesky')], + ); }); } diff --git a/test/features/settings/bloc/settings_state_test.dart b/test/features/settings/bloc/settings_state_test.dart index e0ffc03..3e2320c 100644 --- a/test/features/settings/bloc/settings_state_test.dart +++ b/test/features/settings/bloc/settings_state_test.dart @@ -148,6 +148,7 @@ void main() { animationsEnabled: false, simulateOffline: true, threadAutoCollapseDepth: 3, + appViewProvider: 'blacksky', ); expect(updated.themePalette, AppThemePalette.nord); @@ -157,6 +158,7 @@ void main() { expect(updated.animationsEnabled, false); expect(updated.simulateOffline, true); expect(updated.threadAutoCollapseDepth, 3); + expect(updated.appViewProvider, 'blacksky'); expect(original.themePalette, AppThemePalette.oxocarbon); }); @@ -180,6 +182,7 @@ void main() { expect(updated.animationsEnabled, false); expect(updated.simulateOffline, true); expect(updated.threadAutoCollapseDepth, 4); + expect(updated.appViewProvider, 'bluesky'); }); test('copyWith can clear threadAutoCollapseDepth', () { @@ -213,6 +216,7 @@ void main() { expect(state.props, contains(false)); expect(state.props, contains(true)); expect(state.props, contains(6)); + expect(state.props, contains('bluesky')); }); test('defaults feedLayout to card', () { @@ -250,5 +254,14 @@ void main() { ); expect(state.threadAutoCollapseDepth, isNull); }); + + test('defaults appViewProvider to bluesky', () { + const state = SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + ); + expect(state.appViewProvider, 'bluesky'); + }); }); }