mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: appview routing data model

+442 -36
+6 -14
docs/tasks/routing.md
··· 3 3 updated: 2026-04-29 4 4 --- 5 5 6 - ## M0 - Research + Planning (Complete) 7 - 8 - - [x] Validate Bluesky and Blacksky AppView DID/service identities 9 - - [x] Validate live compatibility for trends/trending-topics endpoints 10 - - [x] Capture provider divergence (link formats, `suggested` behavior) 11 - - [x] Update routing spec with provider-switch UX contract and state safety rules 12 - - [x] Update routing/task plan to include Trending button + `/trending` screen 13 - 14 6 ## M1 - Core Routing Model 15 7 16 - - [ ] Add `appview_provider` setting with defaults and validation 17 - - [ ] Add login-screen provider selector (Bluesky + Blacksky visible by default) 18 - - [ ] Persist login-screen provider choice before any auth/network call 19 - - [ ] Add provider descriptor (`serviceDid`, `publicBaseUrl`, `entrywayUrl`, `webBaseUrl`) 20 - - [ ] Add `AppViewRouter` abstraction for endpoint/header/link resolution 21 - - [ ] Add unit tests for provider normalization/defaults and bootstrap ordering 8 + - [x] Add `appview_provider` setting with defaults and validation 9 + - [x] Add login-screen provider selector (Bluesky + Blacksky visible by default) 10 + - [x] Persist login-screen provider choice before any auth/network call 11 + - [x] Add provider descriptor (`serviceDid`, `publicBaseUrl`, `entrywayUrl`, `webBaseUrl`) 12 + - [x] Add `AppViewRouter` abstraction for endpoint/header/link resolution 13 + - [x] Add unit tests for provider normalization/defaults and bootstrap ordering 22 14 23 15 ## M2 - Header + Request Integration 24 16
+20
lib/core/bootstrap/auth_bootstrap.dart
··· 1 + import 'package:lazurite/features/auth/data/auth_repository.dart'; 2 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 3 + 4 + class AuthBootstrapResult { 5 + const AuthBootstrapResult({required this.authRepository, required this.restoredSession}); 6 + 7 + final AuthRepository authRepository; 8 + final AuthTokens? restoredSession; 9 + } 10 + 11 + Future<AuthBootstrapResult> bootstrapAuthDependencies({ 12 + required Future<void> Function() loadSettings, 13 + required AuthRepository Function() createAuthRepository, 14 + required Future<AuthTokens?> Function(AuthRepository authRepository) restoreSession, 15 + }) async { 16 + await loadSettings(); 17 + final authRepository = createAuthRepository(); 18 + final restoredSession = await restoreSession(authRepository); 19 + return AuthBootstrapResult(authRepository: authRepository, restoredSession: restoredSession); 20 + }
+5 -1
lib/core/database/app_database.dart
··· 26 26 static const activeAccountDidSettingKey = 'active_account_did'; 27 27 28 28 @override 29 - int get schemaVersion => 17; 29 + int get schemaVersion => 18; 30 30 31 31 @override 32 32 MigrationStrategy get migration => MigrationStrategy( 33 33 onCreate: (migrator) async { 34 34 await migrator.createAll(); 35 35 await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('typeahead_provider', 'bluesky')"); 36 + await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('appview_provider', 'bluesky')"); 36 37 }, 37 38 onUpgrade: (migrator, from, to) async { 38 39 if (from < 2) { ··· 118 119 } 119 120 if (from < 17) { 120 121 await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('typeahead_provider', 'bluesky')"); 122 + } 123 + if (from < 18) { 124 + await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('appview_provider', 'bluesky')"); 121 125 } 122 126 }, 123 127 );
+53
lib/core/network/app_view_provider.dart
··· 1 + class AppViewProviderDescriptor { 2 + const AppViewProviderDescriptor({ 3 + required this.key, 4 + required this.serviceDid, 5 + required this.publicBaseUrl, 6 + required this.entrywayUrl, 7 + required this.webBaseUrl, 8 + }); 9 + 10 + final String key; 11 + final String serviceDid; 12 + final Uri publicBaseUrl; 13 + final Uri entrywayUrl; 14 + final Uri webBaseUrl; 15 + } 16 + 17 + abstract final class AppViewProviders { 18 + static const String blueskyKey = 'bluesky'; 19 + static const String blackskyKey = 'blacksky'; 20 + static const String defaultKey = blueskyKey; 21 + static const Set<String> supportedKeys = {blueskyKey, blackskyKey}; 22 + 23 + static final AppViewProviderDescriptor bluesky = AppViewProviderDescriptor( 24 + key: blueskyKey, 25 + serviceDid: 'did:web:api.bsky.app#bsky_appview', 26 + publicBaseUrl: Uri.https('public.api.bsky.app'), 27 + entrywayUrl: Uri.https('bsky.social'), 28 + webBaseUrl: Uri.https('bsky.app'), 29 + ); 30 + 31 + static final AppViewProviderDescriptor blacksky = AppViewProviderDescriptor( 32 + key: blackskyKey, 33 + serviceDid: 'did:web:api.blacksky.community#bsky_appview', 34 + publicBaseUrl: Uri.https('api.blacksky.community'), 35 + entrywayUrl: Uri.https('blacksky.app'), 36 + webBaseUrl: Uri.https('blacksky.app'), 37 + ); 38 + 39 + static final Map<String, AppViewProviderDescriptor> _builtIns = {blueskyKey: bluesky, blackskyKey: blacksky}; 40 + 41 + static String normalizeSettingKey(String? rawKey) { 42 + final normalized = rawKey?.trim().toLowerCase(); 43 + if (normalized == null || !supportedKeys.contains(normalized)) { 44 + return defaultKey; 45 + } 46 + return normalized; 47 + } 48 + 49 + static AppViewProviderDescriptor descriptorForSetting(String? rawKey) { 50 + final normalizedKey = normalizeSettingKey(rawKey); 51 + return _builtIns[normalizedKey] ?? bluesky; 52 + } 53 + }
+30
lib/core/network/app_view_router.dart
··· 1 + import 'package:lazurite/core/network/app_view_provider.dart'; 2 + 3 + class AppViewRouter { 4 + AppViewRouter({required this.provider}); 5 + 6 + final AppViewProviderDescriptor provider; 7 + 8 + Map<String, String> appBskyProxyHeaders() => {'atproto-proxy': provider.serviceDid}; 9 + 10 + Uri publicEndpoint(String xrpcPath, [Map<String, String> queryParameters = const {}]) { 11 + final normalizedPath = xrpcPath.startsWith('/') ? xrpcPath : '/$xrpcPath'; 12 + return provider.publicBaseUrl.replace(path: normalizedPath, queryParameters: queryParameters); 13 + } 14 + 15 + Uri entrywayForAuth() => provider.entrywayUrl; 16 + 17 + Uri resolveWebLink(String relativeOrAbsolute) { 18 + final trimmed = relativeOrAbsolute.trim(); 19 + if (trimmed.isEmpty) { 20 + return provider.webBaseUrl; 21 + } 22 + 23 + final parsed = Uri.tryParse(trimmed); 24 + if (parsed != null && parsed.hasScheme) { 25 + return parsed; 26 + } 27 + 28 + return provider.webBaseUrl.resolve(trimmed); 29 + } 30 + }
+10 -2
lib/features/auth/data/auth_repository.dart
··· 11 11 import 'package:lazurite/core/database/app_database.dart'; 12 12 import 'package:lazurite/core/logging/app_logger.dart'; 13 13 import 'package:lazurite/core/network/atproto_host_resolver.dart'; 14 + import 'package:lazurite/core/network/app_view_provider.dart'; 14 15 import 'package:lazurite/core/network/xrpc_client_factory.dart'; 15 16 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 16 17 import 'package:url_launcher/url_launcher.dart'; ··· 33 34 SupportsCloseForMode supportsCloseForMode = supportsCloseForLaunchMode, 34 35 OAuthRefreshSession oauthRefreshSession = _defaultOAuthRefreshSession, 35 36 Future<OAuthClientMetadata> Function(String clientId) loadClientMetadata = getClientMetadata, 37 + String Function()? oauthServiceResolver, 36 38 }) : _database = database, 37 39 _launchUrlWithMode = launchUrlWithMode, 38 40 _closeInAppBrowser = closeInAppBrowser, 39 41 _supportsCloseForMode = supportsCloseForMode, 40 42 _oauthRefreshSession = oauthRefreshSession, 41 - _loadClientMetadata = loadClientMetadata; 43 + _loadClientMetadata = loadClientMetadata, 44 + _oauthServiceResolver = oauthServiceResolver ?? _defaultOAuthServiceResolver; 42 45 43 46 static const String kClientId = 'https://lazurite.stormlightlabs.org/client-metadata.json'; 44 47 static const String _oauthService = 'bsky.social'; ··· 51 54 final SupportsCloseForMode _supportsCloseForMode; 52 55 final OAuthRefreshSession _oauthRefreshSession; 53 56 final Future<OAuthClientMetadata> Function(String clientId) _loadClientMetadata; 57 + final String Function() _oauthServiceResolver; 54 58 55 59 HttpServer? _callbackServer; 56 60 StreamSubscription<HttpRequest>? _callbackSubscription; ··· 148 152 try { 149 153 _oauthCompleter = Completer<AuthTokens?>(); 150 154 _pendingHandle = handle.trim(); 151 - _pendingService = _oauthService; 155 + _pendingService = normalizeAtprotoServiceHost(_oauthServiceResolver()) ?? _oauthService; 152 156 log.i('AuthRepository: Starting OAuth login for ${_pendingHandle!}'); 153 157 154 158 final metadata = await _loadClientMetadata(kClientId); ··· 721 725 }) { 722 726 final oauthClient = OAuthClient(metadata, service: service); 723 727 return oauthClient.refresh(session); 728 + } 729 + 730 + static String _defaultOAuthServiceResolver() { 731 + return AppViewProviders.descriptorForSetting(AppViewProviders.defaultKey).entrywayUrl.host; 724 732 } 725 733 726 734 String _summarizeOAuthRefreshError(Object error) {
+98 -7
lib/features/auth/presentation/login_screen.dart
··· 1 + import 'dart:async'; 2 + 1 3 import 'package:flutter/foundation.dart'; 2 4 import 'package:flutter/material.dart'; 3 5 import 'package:flutter_bloc/flutter_bloc.dart'; 4 6 import 'package:flutter_svg/flutter_svg.dart'; 5 7 import 'package:go_router/go_router.dart'; 8 + import 'package:lazurite/core/network/app_view_provider.dart'; 6 9 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 10 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 11 + import 'package:lazurite/features/settings/bloc/settings_state.dart'; 7 12 import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 8 13 import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; 9 14 import 'package:lazurite/features/typeahead/presentation/typeahead_text_field.dart'; ··· 22 27 final _appPasswordController = TextEditingController(); 23 28 final _formKey = GlobalKey<FormState>(); 24 29 bool _showDebugForm = false; 30 + bool _isPersistingProvider = false; 25 31 late final TypeaheadRepository _typeaheadRepository; 26 32 27 33 @override ··· 38 44 super.dispose(); 39 45 } 40 46 41 - void _onOAuthLogin() { 47 + Future<void> _onOAuthLogin() async { 42 48 if (!_isHandleValid()) { 43 49 return; 44 50 } 45 51 52 + final persisted = await _persistSelectedProvider(); 53 + if (!persisted) { 54 + return; 55 + } 56 + if (!mounted) { 57 + return; 58 + } 46 59 context.read<AuthBloc>().add(OAuthLoginRequested(handle: _handleController.text.trim())); 47 60 } 48 61 49 - void _onAppPasswordLogin() { 62 + Future<void> _onAppPasswordLogin() async { 50 63 if (!(_formKey.currentState?.validate() ?? false)) { 51 64 return; 52 65 } 53 66 67 + final persisted = await _persistSelectedProvider(); 68 + if (!persisted) { 69 + return; 70 + } 71 + if (!mounted) { 72 + return; 73 + } 54 74 context.read<AuthBloc>().add( 55 75 LoginRequested(handle: _handleController.text.trim(), appPassword: _appPasswordController.text.trim()), 56 76 ); ··· 62 82 63 83 void _onTypeaheadSelected(TypeaheadResult result) { 64 84 _handleController.text = result.handle; 65 - _onOAuthLogin(); 85 + unawaited(_onOAuthLogin()); 86 + } 87 + 88 + Future<bool> _persistSelectedProvider() async { 89 + final settingsCubit = context.read<SettingsCubit>(); 90 + if (_isPersistingProvider) { 91 + return false; 92 + } 93 + 94 + setState(() { 95 + _isPersistingProvider = true; 96 + }); 97 + try { 98 + await settingsCubit.setAppViewProvider(settingsCubit.state.appViewProvider); 99 + return true; 100 + } catch (error) { 101 + if (mounted) { 102 + ScaffoldMessenger.of( 103 + context, 104 + ).showSnackBar(SnackBar(content: Text('Failed to save provider selection: $error'))); 105 + } 106 + return false; 107 + } finally { 108 + if (mounted) { 109 + setState(() { 110 + _isPersistingProvider = false; 111 + }); 112 + } 113 + } 66 114 } 67 115 68 116 @override ··· 111 159 style: theme.textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant), 112 160 ), 113 161 const SizedBox(height: 32), 162 + BlocBuilder<SettingsCubit, SettingsState>( 163 + builder: (context, settingsState) { 164 + final selectedProvider = settingsState.appViewProvider; 165 + return Column( 166 + crossAxisAlignment: CrossAxisAlignment.start, 167 + children: [ 168 + Text( 169 + 'AppView Provider', 170 + style: theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), 171 + ), 172 + const SizedBox(height: 8), 173 + SegmentedButton<String>( 174 + segments: const [ 175 + ButtonSegment<String>(value: AppViewProviders.blueskyKey, label: Text('Bluesky')), 176 + ButtonSegment<String>(value: AppViewProviders.blackskyKey, label: Text('Blacksky')), 177 + ], 178 + selected: {selectedProvider}, 179 + onSelectionChanged: (selection) { 180 + unawaited(context.read<SettingsCubit>().setAppViewProvider(selection.first)); 181 + }, 182 + ), 183 + const SizedBox(height: 8), 184 + Text( 185 + selectedProvider == AppViewProviders.blackskyKey 186 + ? 'Sign-in will use Blacksky entryway defaults.' 187 + : 'Sign-in will use Bluesky entryway defaults.', 188 + style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 189 + ), 190 + const SizedBox(height: 16), 191 + ], 192 + ); 193 + }, 194 + ), 114 195 TypeaheadTextField( 115 196 controller: _handleController, 116 197 repository: _typeaheadRepository, ··· 136 217 const SizedBox(height: 20), 137 218 BlocBuilder<AuthBloc, AuthState>( 138 219 builder: (context, state) { 220 + final busy = state.isLoading || _isPersistingProvider; 139 221 return FilledButton.icon( 140 - onPressed: state.isLoading ? null : _onOAuthLogin, 141 - icon: state.isLoading 222 + onPressed: busy 223 + ? null 224 + : () { 225 + unawaited(_onOAuthLogin()); 226 + }, 227 + icon: busy 142 228 ? const SizedBox( 143 229 width: 18, 144 230 height: 18, 145 231 child: CircularProgressIndicator(strokeWidth: 2), 146 232 ) 147 233 : const Icon(Icons.language), 148 - label: Text(state.isLoading ? 'Starting sign in...' : 'Continue'), 234 + label: Text(busy ? 'Starting sign in...' : 'Continue'), 149 235 style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 18)), 150 236 ); 151 237 }, ··· 206 292 const SizedBox(height: 12), 207 293 BlocBuilder<AuthBloc, AuthState>( 208 294 builder: (context, state) { 295 + final busy = state.isLoading || _isPersistingProvider; 209 296 return OutlinedButton.icon( 210 - onPressed: state.isLoading ? null : _onAppPasswordLogin, 297 + onPressed: busy 298 + ? null 299 + : () { 300 + unawaited(_onAppPasswordLogin()); 301 + }, 211 302 icon: const Icon(Icons.login), 212 303 label: const Text('Sign In'), 213 304 );
+11
lib/features/settings/bloc/settings_cubit.dart
··· 1 1 import 'package:flutter_bloc/flutter_bloc.dart'; 2 2 import 'package:lazurite/core/database/app_database.dart'; 3 + import 'package:lazurite/core/network/app_view_provider.dart'; 3 4 import 'package:lazurite/core/theme/app_theme.dart'; 4 5 import 'package:lazurite/core/theme/feed_layout.dart'; 5 6 import 'package:lazurite/features/search/data/search_scope.dart'; ··· 47 48 static const String _keyTypeaheadProvider = 'typeahead_provider'; 48 49 static const String _defaultTypeaheadProvider = 'bluesky'; 49 50 static const Set<String> _supportedTypeaheadProviders = {'bluesky', 'community'}; 51 + static const String _keyAppViewProvider = 'appview_provider'; 50 52 51 53 Future<void> loadSettings() async { 52 54 final paletteStr = await database.getSetting(_keyThemePalette); ··· 62 64 final searchScopeStr = await database.getSetting(_keySearchScope); 63 65 final semanticSearchMaxResultsStr = await database.getSetting(_keySemanticSearchMaxResults); 64 66 final typeaheadProviderStr = await database.getSetting(_keyTypeaheadProvider); 67 + final appViewProviderStr = await database.getSetting(_keyAppViewProvider); 65 68 final resolvedTypeaheadProvider = _supportedTypeaheadProviders.contains(typeaheadProviderStr) 66 69 ? typeaheadProviderStr! 67 70 : _defaultTypeaheadProvider; 71 + final resolvedAppViewProvider = AppViewProviders.normalizeSettingKey(appViewProviderStr); 68 72 69 73 emit( 70 74 state.copyWith( ··· 80 84 searchScope: SearchScope.values.firstWhere((s) => s.name == searchScopeStr, orElse: () => SearchScope.both), 81 85 semanticSearchMaxResults: int.tryParse(semanticSearchMaxResultsStr ?? '') ?? 20, 82 86 typeaheadProvider: resolvedTypeaheadProvider, 87 + appViewProvider: resolvedAppViewProvider, 83 88 ), 84 89 ); 85 90 } ··· 162 167 163 168 await database.setSetting(_keyTypeaheadProvider, normalizedProvider); 164 169 emit(state.copyWith(typeaheadProvider: normalizedProvider)); 170 + } 171 + 172 + Future<void> setAppViewProvider(String provider) async { 173 + final normalizedProvider = AppViewProviders.normalizeSettingKey(provider); 174 + await database.setSetting(_keyAppViewProvider, normalizedProvider); 175 + emit(state.copyWith(appViewProvider: normalizedProvider)); 165 176 } 166 177 }
+7
lib/features/settings/bloc/settings_state.dart
··· 19 19 this.searchScope = SearchScope.both, 20 20 this.semanticSearchMaxResults = 20, 21 21 this.typeaheadProvider = 'bluesky', 22 + this.appViewProvider = 'bluesky', 22 23 }); 23 24 24 25 final AppThemePalette themePalette; ··· 41 42 42 43 /// Configured typeahead backend provider (`bluesky` or `community`). 43 44 final String typeaheadProvider; 45 + 46 + /// Configured AppView provider (`bluesky` or `blacksky`). 47 + final String appViewProvider; 44 48 45 49 SettingsState copyWith({ 46 50 AppThemePalette? themePalette, ··· 55 59 SearchScope? searchScope, 56 60 int? semanticSearchMaxResults, 57 61 String? typeaheadProvider, 62 + String? appViewProvider, 58 63 }) { 59 64 return SettingsState( 60 65 themePalette: themePalette ?? this.themePalette, ··· 71 76 searchScope: searchScope ?? this.searchScope, 72 77 semanticSearchMaxResults: semanticSearchMaxResults ?? this.semanticSearchMaxResults, 73 78 typeaheadProvider: typeaheadProvider ?? this.typeaheadProvider, 79 + appViewProvider: appViewProvider ?? this.appViewProvider, 74 80 ); 75 81 } 76 82 ··· 88 94 searchScope, 89 95 semanticSearchMaxResults, 90 96 typeaheadProvider, 97 + appViewProvider, 91 98 ]; 92 99 }
+18 -5
lib/main.dart
··· 5 5 import 'package:flutter/material.dart'; 6 6 import 'package:flutter_bloc/flutter_bloc.dart'; 7 7 import 'package:go_router/go_router.dart'; 8 + import 'package:lazurite/core/bootstrap/auth_bootstrap.dart'; 8 9 import 'package:lazurite/core/database/app_database.dart'; 9 10 import 'package:lazurite/core/embedding/embedding_service.dart'; 10 11 import 'package:lazurite/core/logging/app_logger.dart'; 11 12 import 'package:lazurite/core/logging/logging_bloc_observer.dart'; 12 13 import 'package:lazurite/core/logging/logging_navigator_observer.dart'; 14 + import 'package:lazurite/core/network/app_view_provider.dart'; 15 + import 'package:lazurite/core/network/app_view_router.dart'; 13 16 import 'package:lazurite/core/network/xrpc_client_factory.dart'; 14 17 import 'package:lazurite/core/objectbox/objectbox_store.dart'; 15 18 import 'package:lazurite/core/router/app_router.dart'; ··· 62 65 final objectBoxStore = await ObjectBoxStore.open(); 63 66 final embeddingService = EmbeddingService(); 64 67 unawaited(embeddingService.initialize()); 65 - final authRepository = AuthRepository(database: database); 66 - final restoredSession = await authRepository.restoreSession(); 68 + final settingsCubit = SettingsCubit(database: database); 69 + final authBootstrap = await bootstrapAuthDependencies( 70 + loadSettings: settingsCubit.loadSettings, 71 + createAuthRepository: () => AuthRepository( 72 + database: database, 73 + oauthServiceResolver: () { 74 + final provider = AppViewProviders.descriptorForSetting(settingsCubit.state.appViewProvider); 75 + final router = AppViewRouter(provider: provider); 76 + return router.entrywayForAuth().host; 77 + }, 78 + ), 79 + restoreSession: (authRepository) => authRepository.restoreSession(), 80 + ); 81 + final authRepository = authBootstrap.authRepository; 82 + final restoredSession = authBootstrap.restoredSession; 67 83 final authBloc = AuthBloc( 68 84 authRepository: authRepository, 69 85 initialState: restoredSession != null 70 86 ? AuthState.authenticated(restoredSession) 71 87 : const AuthState.unauthenticated(), 72 88 ); 73 - 74 - final settingsCubit = SettingsCubit(database: database); 75 - await settingsCubit.loadSettings(); 76 89 final connectivityCubit = ConnectivityCubit(simulateOffline: settingsCubit.state.simulateOffline); 77 90 78 91 final accountSwitcherCubit = AccountSwitcherCubit(database: database, authRepository: authRepository);
+36
test/core/bootstrap/auth_bootstrap_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:lazurite/core/bootstrap/auth_bootstrap.dart'; 3 + import 'package:lazurite/features/auth/data/auth_repository.dart'; 4 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 5 + import 'package:mocktail/mocktail.dart'; 6 + 7 + class MockAuthRepository extends Mock implements AuthRepository {} 8 + 9 + void main() { 10 + group('bootstrapAuthDependencies', () { 11 + test('loads settings before creating and restoring auth repository', () async { 12 + final callOrder = <String>[]; 13 + final authRepository = MockAuthRepository(); 14 + const restoredSession = AuthTokens(accessToken: 'token', did: 'did:plc:test', handle: 'user.bsky.social'); 15 + 16 + final result = await bootstrapAuthDependencies( 17 + loadSettings: () async { 18 + callOrder.add('loadSettings'); 19 + }, 20 + createAuthRepository: () { 21 + callOrder.add('createAuthRepository'); 22 + return authRepository; 23 + }, 24 + restoreSession: (repository) async { 25 + expect(repository, same(authRepository)); 26 + callOrder.add('restoreSession'); 27 + return restoredSession; 28 + }, 29 + ); 30 + 31 + expect(callOrder, equals(<String>['loadSettings', 'createAuthRepository', 'restoreSession'])); 32 + expect(result.authRepository, same(authRepository)); 33 + expect(result.restoredSession, same(restoredSession)); 34 + }); 35 + }); 36 + }
+5
test/core/database/app_database_test.dart
··· 228 228 expect(value, equals('bluesky')); 229 229 }); 230 230 231 + test('should seed default appview provider on database creation', () async { 232 + final value = await database.getSetting('appview_provider'); 233 + expect(value, equals('bluesky')); 234 + }); 235 + 231 236 test('should set and get setting', () async { 232 237 await database.setSetting('theme', 'dark'); 233 238 final value = await database.getSetting('theme');
+31
test/core/network/app_view_provider_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:lazurite/core/network/app_view_provider.dart'; 3 + 4 + void main() { 5 + group('AppViewProviders', () { 6 + test('normalizes known provider keys', () { 7 + expect(AppViewProviders.normalizeSettingKey(' BLACKSKY '), equals('blacksky')); 8 + expect(AppViewProviders.normalizeSettingKey('bluesky'), equals('bluesky')); 9 + }); 10 + 11 + test('falls back to default for unknown keys', () { 12 + expect(AppViewProviders.normalizeSettingKey('unknown'), equals(AppViewProviders.defaultKey)); 13 + expect(AppViewProviders.normalizeSettingKey(null), equals(AppViewProviders.defaultKey)); 14 + }); 15 + 16 + test('returns descriptors with expected fields', () { 17 + final bluesky = AppViewProviders.descriptorForSetting('bluesky'); 18 + final blacksky = AppViewProviders.descriptorForSetting('blacksky'); 19 + 20 + expect(bluesky.serviceDid, equals('did:web:api.bsky.app#bsky_appview')); 21 + expect(bluesky.publicBaseUrl.host, equals('public.api.bsky.app')); 22 + expect(bluesky.entrywayUrl.host, equals('bsky.social')); 23 + expect(bluesky.webBaseUrl.host, equals('bsky.app')); 24 + 25 + expect(blacksky.serviceDid, equals('did:web:api.blacksky.community#bsky_appview')); 26 + expect(blacksky.publicBaseUrl.host, equals('api.blacksky.community')); 27 + expect(blacksky.entrywayUrl.host, equals('blacksky.app')); 28 + expect(blacksky.webBaseUrl.host, equals('blacksky.app')); 29 + }); 30 + }); 31 + }
+32
test/core/network/app_view_router_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:lazurite/core/network/app_view_provider.dart'; 3 + import 'package:lazurite/core/network/app_view_router.dart'; 4 + 5 + void main() { 6 + group('AppViewRouter', () { 7 + test('builds proxy headers and public endpoint URI', () { 8 + final router = AppViewRouter(provider: AppViewProviders.blacksky); 9 + 10 + expect(router.appBskyProxyHeaders(), equals({'atproto-proxy': 'did:web:api.blacksky.community#bsky_appview'})); 11 + expect( 12 + router.publicEndpoint('/xrpc/app.bsky.unspecced.getTrends', {'limit': '10'}).toString(), 13 + equals('https://api.blacksky.community/xrpc/app.bsky.unspecced.getTrends?limit=10'), 14 + ); 15 + }); 16 + 17 + test('resolves relative links against provider web base', () { 18 + final router = AppViewRouter(provider: AppViewProviders.bluesky); 19 + 20 + expect(router.resolveWebLink('/topic/abc123').toString(), equals('https://bsky.app/topic/abc123')); 21 + expect( 22 + router.resolveWebLink('/profile/alice.bsky.social/feed/aaabbb').toString(), 23 + equals('https://bsky.app/profile/alice.bsky.social/feed/aaabbb'), 24 + ); 25 + }); 26 + 27 + test('returns absolute links unchanged', () { 28 + final router = AppViewRouter(provider: AppViewProviders.bluesky); 29 + expect(router.resolveWebLink('https://example.com/path').toString(), equals('https://example.com/path')); 30 + }); 31 + }); 32 + }
+5 -2
test/core/router/app_router_test.dart
··· 363 363 final router = AppRouter(authBloc: authBloc).router; 364 364 365 365 await tester.pumpWidget( 366 - BlocProvider<AuthBloc>.value( 367 - value: authBloc, 366 + MultiBlocProvider( 367 + providers: [ 368 + BlocProvider<AuthBloc>.value(value: authBloc), 369 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 370 + ], 368 371 child: MaterialApp.router(routerConfig: router), 369 372 ), 370 373 );
+39 -4
test/features/auth/presentation/login_screen_test.dart
··· 3 3 import 'package:flutter_bloc/flutter_bloc.dart'; 4 4 import 'package:flutter_test/flutter_test.dart'; 5 5 import 'package:go_router/go_router.dart'; 6 + import 'package:lazurite/core/theme/app_theme.dart'; 6 7 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 8 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 9 + import 'package:lazurite/features/settings/bloc/settings_state.dart'; 7 10 import 'package:lazurite/features/auth/presentation/login_screen.dart'; 8 11 import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 9 12 import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; ··· 11 14 12 15 class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 13 16 17 + class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 18 + 14 19 void main() { 15 20 late MockAuthBloc authBloc; 21 + late MockSettingsCubit settingsCubit; 16 22 17 23 setUp(() { 18 24 authBloc = MockAuthBloc(); 25 + settingsCubit = MockSettingsCubit(); 19 26 when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); 20 27 whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.unauthenticated()); 28 + const settingsState = SettingsState( 29 + themePalette: AppThemePalette.oxocarbon, 30 + themeVariant: AppThemeVariant.dark, 31 + useSystemTheme: false, 32 + ); 33 + when(() => settingsCubit.state).thenReturn(settingsState); 34 + whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: settingsState); 35 + when(() => settingsCubit.setAppViewProvider(any())).thenAnswer((_) async {}); 21 36 }); 22 37 23 38 Widget buildSubject() { ··· 29 44 routes: [ 30 45 GoRoute( 31 46 path: '/login', 32 - builder: (context, state) => BlocProvider<AuthBloc>.value( 33 - value: authBloc, 47 + builder: (context, state) => MultiBlocProvider( 48 + providers: [ 49 + BlocProvider<AuthBloc>.value(value: authBloc), 50 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 51 + ], 34 52 child: LoginScreen(typeaheadRepository: typeaheadRepository), 35 53 ), 36 54 ), ··· 102 120 routes: [ 103 121 GoRoute( 104 122 path: '/login', 105 - builder: (context, state) => BlocProvider<AuthBloc>.value( 106 - value: authBloc, 123 + builder: (context, state) => MultiBlocProvider( 124 + providers: [ 125 + BlocProvider<AuthBloc>.value(value: authBloc), 126 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 127 + ], 107 128 child: LoginScreen(typeaheadRepository: typeaheadRepository), 108 129 ), 109 130 ), ··· 123 144 await tester.pumpAndSettle(); 124 145 125 146 verify(() => authBloc.add(const OAuthLoginRequested(handle: 'river.bsky.social'))).called(1); 147 + }); 148 + 149 + testWidgets('persists selected provider before triggering OAuth login', (tester) async { 150 + await tester.pumpWidget(buildSubject()); 151 + await tester.pumpAndSettle(); 152 + 153 + await tester.enterText(find.byType(TextFormField).first, 'river.bsky.social'); 154 + await tester.tap(find.text('Continue')); 155 + await tester.pumpAndSettle(); 156 + 157 + verifyInOrder([ 158 + () => settingsCubit.setAppViewProvider('bluesky'), 159 + () => authBloc.add(const OAuthLoginRequested(handle: 'river.bsky.social')), 160 + ]); 126 161 }); 127 162 } 128 163
+23 -1
test/features/settings/bloc/settings_cubit_test.dart
··· 28 28 expect(cubit.state.animationsEnabled, true); 29 29 expect(cubit.state.simulateOffline, false); 30 30 expect(cubit.state.threadAutoCollapseDepth, isNull); 31 + expect(cubit.state.appViewProvider, 'bluesky'); 31 32 }); 32 33 33 34 test('accepts initial values via constructor', () { ··· 88 89 .having((s) => s.animationsEnabled, 'animationsEnabled', true) 89 90 .having((s) => s.simulateOffline, 'simulateOffline', false) 90 91 .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', isNull) 91 - .having((s) => s.typeaheadProvider, 'typeaheadProvider', 'bluesky'), 92 + .having((s) => s.typeaheadProvider, 'typeaheadProvider', 'bluesky') 93 + .having((s) => s.appViewProvider, 'appViewProvider', 'bluesky'), 92 94 ], 93 95 ); 94 96 ··· 304 306 }, 305 307 act: (cubit) => cubit.loadSettings(), 306 308 expect: () => [isA<SettingsState>().having((s) => s.typeaheadProvider, 'typeaheadProvider', 'community')], 309 + ); 310 + 311 + blocTest<SettingsCubit, SettingsState>( 312 + 'setAppViewProvider normalizes and persists provider key', 313 + build: () => SettingsCubit(database: database), 314 + act: (cubit) => cubit.setAppViewProvider(' BlackSky '), 315 + expect: () => [isA<SettingsState>().having((s) => s.appViewProvider, 'appViewProvider', 'blacksky')], 316 + verify: (_) async { 317 + expect(await database.getSetting('appview_provider'), 'blacksky'); 318 + }, 319 + ); 320 + 321 + blocTest<SettingsCubit, SettingsState>( 322 + 'loadSettings falls back to default app view provider for invalid persisted value', 323 + build: () => SettingsCubit(database: database), 324 + setUp: () async { 325 + await database.setSetting('appview_provider', 'unknown-provider'); 326 + }, 327 + act: (cubit) => cubit.loadSettings(), 328 + expect: () => [isA<SettingsState>().having((s) => s.appViewProvider, 'appViewProvider', 'bluesky')], 307 329 ); 308 330 }); 309 331 }
+13
test/features/settings/bloc/settings_state_test.dart
··· 148 148 animationsEnabled: false, 149 149 simulateOffline: true, 150 150 threadAutoCollapseDepth: 3, 151 + appViewProvider: 'blacksky', 151 152 ); 152 153 153 154 expect(updated.themePalette, AppThemePalette.nord); ··· 157 158 expect(updated.animationsEnabled, false); 158 159 expect(updated.simulateOffline, true); 159 160 expect(updated.threadAutoCollapseDepth, 3); 161 + expect(updated.appViewProvider, 'blacksky'); 160 162 expect(original.themePalette, AppThemePalette.oxocarbon); 161 163 }); 162 164 ··· 180 182 expect(updated.animationsEnabled, false); 181 183 expect(updated.simulateOffline, true); 182 184 expect(updated.threadAutoCollapseDepth, 4); 185 + expect(updated.appViewProvider, 'bluesky'); 183 186 }); 184 187 185 188 test('copyWith can clear threadAutoCollapseDepth', () { ··· 213 216 expect(state.props, contains(false)); 214 217 expect(state.props, contains(true)); 215 218 expect(state.props, contains(6)); 219 + expect(state.props, contains('bluesky')); 216 220 }); 217 221 218 222 test('defaults feedLayout to card', () { ··· 249 253 useSystemTheme: false, 250 254 ); 251 255 expect(state.threadAutoCollapseDepth, isNull); 256 + }); 257 + 258 + test('defaults appViewProvider to bluesky', () { 259 + const state = SettingsState( 260 + themePalette: AppThemePalette.oxocarbon, 261 + themeVariant: AppThemeVariant.dark, 262 + useSystemTheme: false, 263 + ); 264 + expect(state.appViewProvider, 'bluesky'); 252 265 }); 253 266 }); 254 267 }