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: remount on account switch

+332 -150
+11 -2
lib/core/database/app_database.dart
··· 1 1 import 'package:drift/drift.dart'; 2 2 import 'package:drift_flutter/drift_flutter.dart'; 3 + import 'package:lazurite/core/database/tables.dart'; 3 4 import 'package:path_provider/path_provider.dart'; 4 - 5 - import 'package:lazurite/core/database/tables.dart'; 6 5 7 6 part 'app_database.g.dart'; 8 7 ··· 21 20 ) 22 21 class AppDatabase extends _$AppDatabase { 23 22 AppDatabase({QueryExecutor? executor}) : super(executor ?? _openConnection()); 23 + 24 + static const activeAccountDidSettingKey = 'active_account_did'; 24 25 25 26 @override 26 27 int get schemaVersion => 11; ··· 83 84 Future<Account?> getAccount(String did) => (select(accounts)..where((a) => a.did.equals(did))).getSingleOrNull(); 84 85 85 86 Future<Account?> getActiveAccount() async { 87 + final activeDid = await getSetting(activeAccountDidSettingKey); 88 + if (activeDid != null) { 89 + final activeAccount = await getAccount(activeDid); 90 + if (activeAccount != null) { 91 + return activeAccount; 92 + } 93 + } 94 + 86 95 final all = await (select(accounts)..orderBy([(a) => OrderingTerm.desc(a.updatedAt)])).get(); 87 96 return all.isNotEmpty ? all.first : null; 88 97 }
+14 -10
lib/features/account/cubit/account_switcher_cubit.dart
··· 16 16 final AppDatabase _database; 17 17 final AuthRepository _authRepository; 18 18 19 - static const String _keyActiveAccountDid = 'active_account_did'; 20 - 21 19 Future<void> loadAccounts() async { 22 20 emit(const AccountSwitcherState.loading()); 23 21 24 22 try { 25 23 final accounts = await _database.getAllAccounts(); 26 - final savedDid = await _database.getSetting(_keyActiveAccountDid); 24 + final savedDid = await _database.getSetting(AppDatabase.activeAccountDidSettingKey); 27 25 28 26 String? activeDid; 29 27 if (savedDid != null && accounts.any((a) => a.did == savedDid)) { ··· 41 39 Future<AuthTokens?> switchAccount(String did) async { 42 40 if (state.status != AccountSwitcherStatus.ready) return null; 43 41 44 - await _database.setSetting(_keyActiveAccountDid, did); 45 - emit(state.copyWith(activeDid: did)); 46 - 47 42 final account = await _database.getAccount(did); 48 43 if (account == null) return null; 49 44 ··· 63 58 : AuthMethod.appPassword, 64 59 ); 65 60 66 - if (!tokens.isExpired) return tokens; 61 + try { 62 + final nextTokens = tokens.isExpired 63 + ? tokens.refreshToken == null 64 + ? null 65 + : await _authRepository.refreshSession(tokens) 66 + : tokens; 67 67 68 - if (tokens.refreshToken == null) return null; 68 + if (nextTokens == null) { 69 + return null; 70 + } 69 71 70 - try { 71 - return await _authRepository.refreshSession(tokens); 72 + await _database.setSetting(AppDatabase.activeAccountDidSettingKey, did); 73 + emit(state.copyWith(activeDid: did)); 74 + return nextTokens; 72 75 } catch (_) { 73 76 return null; 74 77 } ··· 95 98 accessToken: Value(tokens.accessToken), 96 99 refreshToken: tokens.refreshToken != null ? Value(tokens.refreshToken!) : const Value.absent(), 97 100 dpopPublicKey: tokens.dpopPublicKey != null ? Value(tokens.dpopPublicKey!) : const Value.absent(), 101 + dpopPrivateKey: tokens.dpopPrivateKey != null ? Value(tokens.dpopPrivateKey!) : const Value.absent(), 98 102 dpopNonce: tokens.dpopNonce != null ? Value(tokens.dpopNonce!) : const Value.absent(), 99 103 expiresAt: tokens.expiresAt != null ? Value(tokens.expiresAt!) : const Value.absent(), 100 104 ),
+5 -3
lib/features/account/presentation/account_switcher_sheet.dart
··· 74 74 } 75 75 76 76 Future<void> _onSwitchAccount(BuildContext context, String did) async { 77 + final messenger = ScaffoldMessenger.of(context); 77 78 final cubit = context.read<AccountSwitcherCubit>(); 78 79 Navigator.pop(context); 79 80 final tokens = await cubit.switchAccount(did); 80 - if (tokens == null) { 81 - authBloc.add(const LogoutRequested()); 82 - } else { 81 + if (tokens != null) { 83 82 authBloc.add(SessionRestored(tokens: tokens)); 83 + return; 84 84 } 85 + 86 + messenger.showSnackBar(const SnackBar(content: Text('Unable to switch accounts. Sign in again for that account.'))); 85 87 } 86 88 87 89 Future<void> _onAddAccount(BuildContext context) async {
+101 -47
lib/features/auth/data/auth_repository.dart
··· 23 23 final AppDatabase _database; 24 24 25 25 HttpServer? _callbackServer; 26 + StreamSubscription<HttpRequest>? _callbackSubscription; 26 27 Completer<AuthTokens?>? _oauthCompleter; 27 28 OAuthClient? _pendingOAuthClient; 28 29 OAuthContext? _pendingOAuthContext; ··· 66 67 } 67 68 68 69 if (storedSession.refreshToken == null) { 69 - log.w('AuthRepository: Stored session expired without refresh token, clearing session'); 70 - await clearSession(); 71 - return null; 70 + log.w('AuthRepository: Stored session expired without refresh token, removing account'); 71 + await _invalidateSession(storedSession); 72 + return restoreSession(); 72 73 } 73 74 74 75 try { ··· 76 77 return await refreshSession(storedSession); 77 78 } catch (error, stackTrace) { 78 79 log.e('AuthRepository: Failed to restore expired session', error: error, stackTrace: stackTrace); 79 - await clearSession(); 80 - return null; 80 + return restoreSession(); 81 81 } 82 82 } 83 83 84 - Future<void> saveSession(AuthTokens tokens) async { 84 + Future<void> saveSession(AuthTokens tokens, {bool makeActive = false}) async { 85 85 await _database.insertAccount( 86 86 AccountsCompanion( 87 87 did: Value(tokens.did), ··· 97 97 updatedAt: Value(DateTime.now()), 98 98 ), 99 99 ); 100 + 101 + if (makeActive) { 102 + await _database.setSetting(AppDatabase.activeAccountDidSettingKey, tokens.did); 103 + } 100 104 } 101 105 102 106 Future<void> clearSession() async { 103 107 await _database.deleteAllAccounts(); 108 + await _database.deleteSetting(AppDatabase.activeAccountDidSettingKey); 104 109 } 105 110 106 111 Future<AuthTokens?> loginWithOAuth(String handle) async { ··· 152 157 authMethod: AuthMethod.appPassword, 153 158 ); 154 159 155 - await saveSession(tokens); 160 + await saveSession(tokens, makeActive: true); 156 161 log.i('AuthRepository: App password login succeeded for ${tokens.handle}'); 157 162 return tokens; 158 163 } catch (error, stackTrace) { ··· 192 197 oauthService: currentSession.service ?? _fallbackService, 193 198 ); 194 199 195 - await saveSession(refreshedTokens); 200 + await saveSession( 201 + refreshedTokens, 202 + makeActive: await _database.getSetting(AppDatabase.activeAccountDidSettingKey) == currentSession.did, 203 + ); 196 204 log.i('AuthRepository: OAuth session refresh succeeded for ${refreshedTokens.handle}'); 197 205 return refreshedTokens; 198 206 } catch (error, stackTrace) { 199 207 log.e('AuthRepository: OAuth session refresh failed', error: error, stackTrace: stackTrace); 200 - await clearSession(); 208 + await _invalidateSession(currentSession); 201 209 throw Exception('Failed to refresh OAuth session: $error'); 202 210 } 203 211 } ··· 220 228 authMethod: AuthMethod.appPassword, 221 229 ); 222 230 223 - await saveSession(tokens); 231 + await saveSession( 232 + tokens, 233 + makeActive: await _database.getSetting(AppDatabase.activeAccountDidSettingKey) == currentSession.did, 234 + ); 224 235 log.i('AuthRepository: App password session refresh succeeded for ${tokens.handle}'); 225 236 return tokens; 226 237 } catch (error, stackTrace) { 227 238 log.e('AuthRepository: App password session refresh failed', error: error, stackTrace: stackTrace); 228 - await clearSession(); 239 + await _invalidateSession(currentSession); 229 240 throw Exception('Failed to refresh session: $error'); 230 241 } 231 242 } ··· 266 277 ); 267 278 log.i('AuthRepository: OAuth callback server listening on ${_sanitizeUriForLog(redirectUri)}'); 268 279 269 - unawaited( 270 - _callbackServer!.forEach((request) async { 271 - final uri = request.requestedUri; 272 - log.d( 273 - 'AuthRepository: OAuth callback request received at ' 274 - '${uri.path} with query keys: ${uri.queryParameters.keys.join(', ')}', 275 - ); 280 + _callbackSubscription = _callbackServer!.listen( 281 + (request) { 282 + unawaited(_handleCallbackRequest(request, redirectUri)); 283 + }, 284 + onError: (Object error, StackTrace stackTrace) { 285 + log.e('AuthRepository: OAuth callback server stream failed', error: error, stackTrace: stackTrace); 286 + }, 287 + ); 276 288 277 - if (uri.path != redirectUri.path) { 278 - request.response.statusCode = HttpStatus.notFound; 279 - await request.response.close(); 280 - return; 281 - } 289 + return redirectUri; 290 + } 282 291 283 - request.response 284 - ..statusCode = HttpStatus.ok 285 - ..headers.contentType = ContentType.html 286 - ..write(_callbackPageHtml); 287 - await request.response.close(); 292 + Future<void> _handleCallbackRequest(HttpRequest request, Uri redirectUri) async { 293 + final uri = request.requestedUri; 294 + log.d( 295 + 'AuthRepository: OAuth callback request received at ' 296 + '${uri.path} with query keys: ${uri.queryParameters.keys.join(', ')}', 297 + ); 288 298 289 - final callbackUrl = uri.replace(scheme: redirectUri.scheme, host: redirectUri.host, port: redirectUri.port); 299 + if (uri.path != redirectUri.path) { 300 + request.response.statusCode = HttpStatus.notFound; 301 + await request.response.close(); 302 + return; 303 + } 290 304 291 - await _stopCallbackServer(); 305 + await _callbackSubscription?.cancel(); 306 + _callbackSubscription = null; 292 307 293 - try { 294 - log.i('AuthRepository: Processing OAuth callback'); 295 - final tokens = await _handleOAuthCallback(callbackUrl.toString()); 296 - _oauthCompleter?.complete(tokens); 297 - } catch (error, stackTrace) { 298 - log.e('AuthRepository: OAuth callback handling failed', error: error, stackTrace: stackTrace); 299 - _oauthCompleter?.completeError(error); 300 - } finally { 301 - _resetPendingOAuthState(); 302 - } 303 - }), 304 - ); 308 + final callbackBody = utf8.encode(_callbackPageHtml); 309 + request.response 310 + ..statusCode = HttpStatus.ok 311 + ..headers.contentType = ContentType.html 312 + ..headers.contentLength = callbackBody.length 313 + ..write(_callbackPageHtml); 314 + await request.response.close(); 315 + 316 + final callbackUrl = uri.replace(scheme: redirectUri.scheme, host: redirectUri.host, port: redirectUri.port); 305 317 306 - return redirectUri; 318 + try { 319 + log.i('AuthRepository: Processing OAuth callback'); 320 + final tokens = await _handleOAuthCallback(callbackUrl.toString()); 321 + if (_oauthCompleter?.isCompleted == false) { 322 + _oauthCompleter?.complete(tokens); 323 + } 324 + } catch (error, stackTrace) { 325 + log.e('AuthRepository: OAuth callback handling failed', error: error, stackTrace: stackTrace); 326 + if (_oauthCompleter?.isCompleted == false) { 327 + _oauthCompleter?.completeError(error, stackTrace); 328 + } 329 + } finally { 330 + await _stopCallbackServer(); 331 + _resetPendingOAuthState(); 332 + } 307 333 } 308 334 309 335 Future<AuthTokens> _handleOAuthCallback(String callbackUrl) async { ··· 324 350 final oauthSession = await oauthClient.callback(callbackUrl, oauthContext); 325 351 log.i('AuthRepository: OAuth token exchange succeeded for DID ${oauthSession.sub}'); 326 352 final tokens = await _buildOAuthTokens(oauthSession, fallbackHandle: fallbackHandle, oauthService: service); 327 - await saveSession(tokens); 353 + await saveSession(tokens, makeActive: true); 328 354 log.i('AuthRepository: OAuth login completed for ${tokens.handle}'); 329 355 return tokens; 330 356 } ··· 379 405 if (_callbackServer != null) { 380 406 log.d('AuthRepository: Stopping OAuth callback server on port ${_callbackServer!.port}'); 381 407 } 382 - await _callbackServer?.close(force: true); 408 + await _callbackSubscription?.cancel(); 409 + _callbackSubscription = null; 410 + await _callbackServer?.close(); 383 411 _callbackServer = null; 384 412 } 385 413 ··· 486 514 return uri.replace(query: null, fragment: null).toString(); 487 515 } 488 516 517 + Future<void> _invalidateSession(AuthTokens tokens) async { 518 + await _database.deleteAccount(tokens.did); 519 + if (await _database.getSetting(AppDatabase.activeAccountDidSettingKey) == tokens.did) { 520 + await _database.deleteSetting(AppDatabase.activeAccountDidSettingKey); 521 + } 522 + } 523 + 489 524 void _resetPendingOAuthState() { 525 + _oauthCompleter = null; 490 526 _pendingOAuthClient = null; 491 527 _pendingOAuthContext = null; 492 528 _pendingHandle = null; ··· 529 565 p { 530 566 color: #dde1e6; 531 567 line-height: 1.5; 532 - margin: 0; 568 + margin: 0 0 12px; 569 + } 570 + 571 + button { 572 + appearance: none; 573 + background: #0f62fe; 574 + border: 0; 575 + border-radius: 999px; 576 + color: white; 577 + cursor: pointer; 578 + font: inherit; 579 + font-weight: 600; 580 + padding: 12px 20px; 533 581 } 534 582 </style> 535 583 </head> 536 584 <body> 537 585 <main> 538 586 <h1>Authentication Complete</h1> 539 - <p>You can close this window and return to Lazurite.</p> 587 + <p>If this page does not close automatically, switch back to Lazurite.</p> 588 + <button type="button" onclick="window.close()">Close This Tab</button> 540 589 </main> 590 + <script> 591 + window.setTimeout(function () { 592 + window.close(); 593 + }, 250); 594 + </script> 541 595 </body> 542 596 </html> 543 597 ''';
+110 -82
lib/main.dart
··· 13 13 import 'package:lazurite/core/router/app_router.dart'; 14 14 import 'package:lazurite/core/scheduler/post_scheduler.dart'; 15 15 import 'package:lazurite/core/theme/app_theme.dart'; 16 + import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 16 17 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 17 18 import 'package:lazurite/features/auth/data/auth_repository.dart'; 18 19 import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; ··· 24 25 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 25 26 import 'package:lazurite/features/feed/data/post_thread_repository.dart'; 26 27 import 'package:lazurite/features/lists/data/list_repository.dart'; 27 - import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 28 28 import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 29 29 import 'package:lazurite/features/messages/data/convo_repository.dart'; 30 30 import 'package:lazurite/features/moderation/data/moderation_service.dart'; ··· 34 34 import 'package:lazurite/features/profile/data/profile_repository.dart'; 35 35 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 36 36 import 'package:lazurite/features/search/data/search_repository.dart'; 37 - import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 38 37 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 39 38 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 39 + import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 40 40 41 41 Future<void> main() async { 42 42 WidgetsFlutterBinding.ensureInitialized(); ··· 93 93 94 94 class _LazuriteAppState extends State<LazuriteApp> { 95 95 static final _navigatorObserver = LoggingNavigatorObserver(); 96 - late final GoRouter _router; 96 + late GoRouter _router; 97 + late String _routerSessionKey; 98 + late final StreamSubscription<String> _authSubscription; 97 99 98 100 @override 99 101 void initState() { 100 102 super.initState(); 101 - _router = AppRouter(authBloc: widget.authBloc, navigatorObserver: _navigatorObserver).router; 103 + _routerSessionKey = _sessionKeyFor(widget.authBloc.state); 104 + _router = _createRouter(); 105 + _authSubscription = widget.authBloc.stream.map(_sessionKeyFor).distinct().listen(_handleSessionKeyChanged); 102 106 } 103 107 104 108 @override 105 109 void dispose() { 110 + _authSubscription.cancel(); 106 111 _router.dispose(); 107 112 super.dispose(); 108 113 } 109 114 115 + GoRouter _createRouter() { 116 + return AppRouter(authBloc: widget.authBloc, navigatorObserver: _navigatorObserver).router; 117 + } 118 + 119 + String _sessionKeyFor(AuthState state) => state.tokens?.did ?? 'guest'; 120 + 121 + void _handleSessionKeyChanged(String sessionKey) { 122 + if (!mounted || sessionKey == _routerSessionKey) { 123 + return; 124 + } 125 + 126 + final previousRouter = _router; 127 + setState(() { 128 + _routerSessionKey = sessionKey; 129 + _router = _createRouter(); 130 + }); 131 + previousRouter.dispose(); 132 + } 133 + 110 134 Bluesky? _createBluesky(AuthState state) { 111 135 if (!state.isAuthenticated) return null; 112 136 return createBlueskyClient(state.tokens); ··· 139 163 final darkTheme = AppTheme.getTheme(settingsState.themePalette, AppThemeVariant.dark); 140 164 141 165 return MaterialApp.router( 166 + key: ValueKey('router-$_routerSessionKey'), 142 167 title: 'Lazurite', 143 168 debugShowCheckedModeBanner: false, 144 169 theme: lightTheme, ··· 155 180 156 181 final accountDid = authState.tokens?.did ?? ''; 157 182 158 - return MultiRepositoryProvider( 159 - providers: [ 160 - RepositoryProvider( 161 - create: (_) { 162 - final moderationService = ModerationService( 163 - bluesky: bluesky, 164 - database: widget.database, 165 - accountDid: accountDid, 166 - userDid: accountDid, 167 - ); 168 - unawaited(moderationService.ensureInitialized()); 169 - return moderationService; 170 - }, 171 - dispose: (moderationService) => moderationService.dispose(), 172 - ), 173 - RepositoryProvider( 174 - create: (context) => 175 - FeedRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 176 - ), 177 - RepositoryProvider( 178 - create: (context) => 179 - SearchRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 180 - ), 181 - RepositoryProvider( 182 - create: (context) => 183 - ListRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 184 - ), 185 - RepositoryProvider( 186 - create: (context) => ProfileRepository( 187 - database: widget.database, 188 - bluesky: bluesky, 189 - moderationService: context.read<ModerationService>(), 190 - ), 191 - ), 192 - RepositoryProvider( 193 - create: (context) => 194 - NotificationRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 195 - ), 196 - RepositoryProvider( 197 - create: (context) => 198 - PostThreadRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 199 - ), 200 - RepositoryProvider( 201 - create: (context) => 202 - StarterPackRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 203 - ), 204 - RepositoryProvider(create: (_) => PostActionRepository(bluesky: bluesky)), 205 - RepositoryProvider(create: (_) => ProfileActionRepository(bluesky: bluesky)), 206 - RepositoryProvider(create: (_) => ConvoRepository(chat: blueskyChat)), 207 - RepositoryProvider(create: (_) => PostActionCache()), 208 - RepositoryProvider.value(value: bluesky), 209 - RepositoryProvider.value(value: widget.database), 210 - RepositoryProvider.value(value: accountDid), 211 - ], 212 - child: MultiBlocProvider( 183 + return KeyedSubtree( 184 + key: ValueKey('account-$accountDid'), 185 + child: MultiRepositoryProvider( 213 186 providers: [ 214 - BlocProvider(create: (context) => ProfileBloc(profileRepository: context.read<ProfileRepository>())), 215 - BlocProvider(create: (context) => FeedBloc(feedRepository: context.read<FeedRepository>())), 216 - BlocProvider( 217 - create: (context) => FeedPreferencesCubit( 218 - feedRepository: context.read<FeedRepository>(), 219 - database: widget.database, 220 - accountDid: accountDid, 221 - )..loadPreferences(), 187 + RepositoryProvider( 188 + create: (_) { 189 + final moderationService = ModerationService( 190 + bluesky: bluesky, 191 + database: widget.database, 192 + accountDid: accountDid, 193 + userDid: accountDid, 194 + ); 195 + unawaited(moderationService.ensureInitialized()); 196 + return moderationService; 197 + }, 198 + dispose: (moderationService) => moderationService.dispose(), 222 199 ), 223 - BlocProvider(create: (_) => DevToolsCubit(atproto: bluesky.atproto)), 224 - BlocProvider( 225 - create: (context) => SearchBloc( 226 - searchRepository: context.read<SearchRepository>(), 227 - database: widget.database, 228 - accountDid: accountDid, 229 - ), 200 + RepositoryProvider( 201 + create: (context) => 202 + FeedRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 230 203 ), 231 - BlocProvider( 204 + RepositoryProvider( 232 205 create: (context) => 233 - ConvoListBloc(convoRepository: context.read<ConvoRepository>()) 234 - ..add(const ConvosRequested(limit: 100)), 206 + SearchRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 235 207 ), 236 - BlocProvider( 237 - create: (context) => SavedPostsCubit( 208 + RepositoryProvider( 209 + create: (context) => 210 + ListRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 211 + ), 212 + RepositoryProvider( 213 + create: (context) => ProfileRepository( 238 214 database: widget.database, 239 - accountDid: accountDid, 240 - postActionRepository: context.read<PostActionRepository>(), 215 + bluesky: bluesky, 216 + moderationService: context.read<ModerationService>(), 241 217 ), 242 218 ), 219 + RepositoryProvider( 220 + create: (context) => 221 + NotificationRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 222 + ), 223 + RepositoryProvider( 224 + create: (context) => 225 + PostThreadRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 226 + ), 227 + RepositoryProvider( 228 + create: (context) => 229 + StarterPackRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 230 + ), 231 + RepositoryProvider(create: (_) => PostActionRepository(bluesky: bluesky)), 232 + RepositoryProvider(create: (_) => ProfileActionRepository(bluesky: bluesky)), 233 + RepositoryProvider(create: (_) => ConvoRepository(chat: blueskyChat)), 234 + RepositoryProvider(create: (_) => PostActionCache()), 235 + RepositoryProvider.value(value: bluesky), 236 + RepositoryProvider.value(value: widget.database), 237 + RepositoryProvider.value(value: accountDid), 243 238 ], 244 - child: appShell, 239 + child: MultiBlocProvider( 240 + providers: [ 241 + BlocProvider(create: (context) => ProfileBloc(profileRepository: context.read<ProfileRepository>())), 242 + BlocProvider(create: (context) => FeedBloc(feedRepository: context.read<FeedRepository>())), 243 + BlocProvider( 244 + create: (context) => FeedPreferencesCubit( 245 + feedRepository: context.read<FeedRepository>(), 246 + database: widget.database, 247 + accountDid: accountDid, 248 + )..loadPreferences(), 249 + ), 250 + BlocProvider(create: (_) => DevToolsCubit(atproto: bluesky.atproto)), 251 + BlocProvider( 252 + create: (context) => SearchBloc( 253 + searchRepository: context.read<SearchRepository>(), 254 + database: widget.database, 255 + accountDid: accountDid, 256 + ), 257 + ), 258 + BlocProvider( 259 + create: (context) => 260 + ConvoListBloc(convoRepository: context.read<ConvoRepository>()) 261 + ..add(const ConvosRequested(limit: 100)), 262 + ), 263 + BlocProvider( 264 + create: (context) => SavedPostsCubit( 265 + database: widget.database, 266 + accountDid: accountDid, 267 + postActionRepository: context.read<PostActionRepository>(), 268 + ), 269 + ), 270 + ], 271 + child: appShell, 272 + ), 245 273 ), 246 274 ); 247 275 },
+35
test/core/database/app_database_test.dart
··· 50 50 expect(active!.did, equals('did:plc:abc123')); 51 51 }); 52 52 53 + test('should prefer the account selected in settings', () async { 54 + await database.insertAccount( 55 + AccountsCompanion.insert(did: 'did:plc:older', handle: 'older.bsky.social', accessToken: 'older-token'), 56 + ); 57 + await database.insertAccount( 58 + AccountsCompanion.insert( 59 + did: 'did:plc:selected', 60 + handle: 'selected.bsky.social', 61 + accessToken: 'selected-token', 62 + ), 63 + ); 64 + await database.setSetting(AppDatabase.activeAccountDidSettingKey, 'did:plc:older'); 65 + 66 + final active = await database.getActiveAccount(); 67 + 68 + expect(active, isNotNull); 69 + expect(active!.did, equals('did:plc:older')); 70 + }); 71 + 72 + test('should fall back when the selected active account no longer exists', () async { 73 + await database.insertAccount( 74 + AccountsCompanion.insert( 75 + did: 'did:plc:available', 76 + handle: 'available.bsky.social', 77 + accessToken: 'available-token', 78 + ), 79 + ); 80 + await database.setSetting(AppDatabase.activeAccountDidSettingKey, 'did:plc:missing'); 81 + 82 + final active = await database.getActiveAccount(); 83 + 84 + expect(active, isNotNull); 85 + expect(active!.did, equals('did:plc:available')); 86 + }); 87 + 53 88 test('should return null when no active account exists', () async { 54 89 final active = await database.getActiveAccount(); 55 90 expect(active, isNull);
+35 -4
test/features/account/cubit/account_switcher_cubit_test.dart
··· 187 187 test('returns null when account is expired and refresh throws', () async { 188 188 final expiredAt = DateTime.now().subtract(const Duration(hours: 1)); 189 189 190 - when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); 191 190 when(() => mockDatabase.getAccount('did:plc:user1')).thenAnswer( 192 191 (_) async => makeAccount(did: 'did:plc:user1', expiresAt: expiredAt, refreshToken: 'refresh-token'), 193 192 ); ··· 203 202 204 203 final tokens = await cubit.switchAccount('did:plc:user1'); 205 204 expect(tokens, isNull); 205 + verifyNever(() => mockDatabase.setSetting(any(), any())); 206 206 }); 207 207 208 208 test('returns null when account is expired and has no refresh token', () async { 209 209 final expiredAt = DateTime.now().subtract(const Duration(hours: 1)); 210 210 211 - when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); 212 211 when( 213 212 () => mockDatabase.getAccount('did:plc:user1'), 214 213 ).thenAnswer((_) async => makeAccount(did: 'did:plc:user1', expiresAt: expiredAt)); ··· 224 223 final tokens = await cubit.switchAccount('did:plc:user1'); 225 224 expect(tokens, isNull); 226 225 verifyNever(() => mockAuthRepository.refreshSession(any())); 226 + verifyNever(() => mockDatabase.setSetting(any(), any())); 227 227 }); 228 228 229 229 blocTest<AccountSwitcherCubit, AccountSwitcherState>( ··· 245 245 act: (cubit) => cubit.switchAccount('did:plc:user2'), 246 246 expect: () => [predicate<AccountSwitcherState>((state) => state.activeDid == 'did:plc:user2')], 247 247 verify: (_) { 248 - verify(() => mockDatabase.setSetting('active_account_did', 'did:plc:user2')).called(1); 248 + verify(() => mockDatabase.setSetting(AppDatabase.activeAccountDidSettingKey, 'did:plc:user2')).called(1); 249 249 }, 250 250 ); 251 251 }); ··· 279 279 ], 280 280 verify: (_) { 281 281 verify(() => mockDatabase.insertAccount(any())).called(1); 282 - verify(() => mockDatabase.setSetting('active_account_did', 'did:plc:newuser')).called(1); 282 + verify(() => mockDatabase.setSetting(AppDatabase.activeAccountDidSettingKey, 'did:plc:newuser')).called(1); 283 283 }, 284 284 ); 285 + 286 + test('persists OAuth private keys when adding an account', () async { 287 + final captured = <AccountsCompanion>[]; 288 + const tokens = AuthTokens( 289 + accessToken: 'token', 290 + did: 'did:plc:newuser', 291 + handle: 'new.bsky.social', 292 + dpopPublicKey: 'public-key', 293 + dpopPrivateKey: 'private-key', 294 + authMethod: AuthMethod.oauth, 295 + ); 296 + 297 + when(() => mockDatabase.insertAccount(any())).thenAnswer((invocation) async { 298 + captured.add(invocation.positionalArguments.first as AccountsCompanion); 299 + return 1; 300 + }); 301 + when(() => mockDatabase.getAllAccounts()).thenAnswer( 302 + (_) async => [makeAccount(did: 'did:plc:newuser', handle: 'new.bsky.social', dpopPublicKey: 'public-key')], 303 + ); 304 + when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => null); 305 + when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); 306 + when( 307 + () => mockDatabase.getAccount('did:plc:newuser'), 308 + ).thenAnswer((_) async => makeAccount(did: 'did:plc:newuser', handle: 'new.bsky.social')); 309 + 310 + await buildCubit().addAccountCompleted(tokens); 311 + 312 + expect(captured, hasLength(1)); 313 + expect(captured.single.dpopPublicKey.value, 'public-key'); 314 + expect(captured.single.dpopPrivateKey.value, 'private-key'); 315 + }); 285 316 286 317 blocTest<AccountSwitcherCubit, AccountSwitcherState>( 287 318 'switches to newly added account even when another was active',
+3 -2
test/features/account/presentation/account_switcher_sheet_test.dart
··· 148 148 verify(() => authBloc.add(any(that: isA<SessionRestored>()))).called(1); 149 149 }); 150 150 151 - testWidgets('dispatches LogoutRequested when switchAccount returns null', (tester) async { 151 + testWidgets('shows an error when switchAccount returns null', (tester) async { 152 152 when(() => cubit.state).thenReturn( 153 153 AccountSwitcherState.ready( 154 154 accounts: [ ··· 165 165 await tester.pump(); 166 166 await tester.pump(const Duration(milliseconds: 300)); 167 167 168 - verify(() => authBloc.add(any(that: isA<LogoutRequested>()))).called(1); 168 + verifyNever(() => authBloc.add(any(that: isA<LogoutRequested>()))); 169 + expect(find.text('Unable to switch accounts. Sign in again for that account.'), findsOneWidget); 169 170 }); 170 171 171 172 testWidgets('tapping active account does nothing', (tester) async {
+18
test/features/auth/data/auth_repository_test.dart
··· 80 80 81 81 verify(() => mockDatabase.insertAccount(any())).called(1); 82 82 }); 83 + 84 + test('should mark the saved session active when requested', () async { 85 + const tokens = AuthTokens(accessToken: 'access_token', did: 'did:plc:abc123', handle: 'user.bsky.social'); 86 + 87 + when(() => mockDatabase.insertAccount(any())).thenAnswer((_) async => 1); 88 + when( 89 + () => mockDatabase.setSetting(AppDatabase.activeAccountDidSettingKey, tokens.did), 90 + ).thenAnswer((_) async => 1); 91 + 92 + await authRepository.saveSession(tokens, makeActive: true); 93 + 94 + verify(() => mockDatabase.insertAccount(any())).called(1); 95 + verify(() => mockDatabase.setSetting(AppDatabase.activeAccountDidSettingKey, tokens.did)).called(1); 96 + }); 83 97 }); 84 98 85 99 group('restoreSession', () { ··· 112 126 group('clearSession', () { 113 127 test('should delete all accounts', () async { 114 128 when(() => mockDatabase.deleteAllAccounts()).thenAnswer((_) async => 1); 129 + when(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)).thenAnswer((_) async => 1); 115 130 116 131 await authRepository.clearSession(); 117 132 118 133 verify(() => mockDatabase.deleteAllAccounts()).called(1); 134 + verify(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)).called(1); 119 135 }); 120 136 }); 121 137 ··· 123 139 test('should clear session', () async { 124 140 when(() => mockDatabase.getActiveAccount()).thenAnswer((_) async => null); 125 141 when(() => mockDatabase.deleteAllAccounts()).thenAnswer((_) async => 1); 142 + when(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)).thenAnswer((_) async => 1); 126 143 127 144 await authRepository.logout(); 128 145 129 146 verify(() => mockDatabase.getActiveAccount()).called(1); 130 147 verify(() => mockDatabase.deleteAllAccounts()).called(1); 148 + verify(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)).called(1); 131 149 }); 132 150 }); 133 151 });