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: account switching

+609 -97
+4
CHANGELOG.md
··· 40 40 41 41 - Moderation service integration 42 42 - Labels added to users in posts 43 + 44 + #### 2026-03-22 45 + 46 + - Starter packs & lists
+13
docs/specs/phase-5.md
··· 1 + --- 2 + title: Phase 5 Spec 3 + updated: 2026-03-23 4 + --- 5 + 6 + ## Feature Parity 7 + 8 + - Endpoints to build UI around: 9 + - In search screen: `/xrpc/app.bsky.graph.searchStarterPacks` 10 + - In profile screen: `/xrpc/app.bsky.graph.getSuggestedFollowsByActor`, should be a 11 + sheet accessible via overflow menu 12 + - In settings screen: `/xrpc/app.bsky.video.getUploadLimits` to show remaining daily 13 + video upload limits
+6 -59
docs/tasks/phase-4.md
··· 11 11 ## M14 — Account Switching 12 12 13 13 - [x] `AccountSwitcherCubit` exposing account list and active DID 14 - - [ ] Account switcher bottom sheet UI — list accounts with avatars and handles 14 + - [x] Account switcher bottom sheet UI — list accounts with avatars and handles 15 15 - [x] Store `active_account_did` in Drift `settings` table 16 16 - [x] Drift migration: add `account_did` column to `cached_posts` if not present 17 - - [ ] All user-scoped queries filter by active account DID 18 - - [ ] Broadcast `AccountSwitched` event to all Blocs on switch 19 - - [ ] "Add Account" button triggers OAuth flow, inserts new `accounts` row 20 - - [ ] Silent token refresh on account switch; navigate to login on failure 17 + - [x] All user-scoped queries filter by active account DID 18 + - [x] Broadcast `AccountSwitched` event to all Blocs on switch 19 + - [x] "Add Account" button triggers OAuth flow, inserts new `accounts` row 20 + - [x] Silent token refresh on account switch; navigate to login on failure 21 21 22 22 ## M15 — Offline Reading & Network Resilience 23 23 ··· 40 40 41 41 Completed [2026-03-21](../../CHANGELOG.md#2026-03-21) 42 42 43 - ### Core 44 - 45 - - [x] `ListBloc` — events: `ListRequested`, `ListRefreshed`, `ListItemAdded`, `ListItemRemoved`, `ListMuted`, `ListUnmuted`, `ListBlocked`, `ListUnblocked` 46 - - [x] `MyListsCubit` — load user's lists via `getLists` 47 - - [x] `ListFeedBloc` — paginated feed via `getListFeed`, reuse existing feed pattern 48 - 49 - ### List CRUD 50 - 51 - - [x] Create list — name, description, avatar, purpose selector (curation/moderation) via `com.atproto.repo.createRecord` 52 - - [x] Edit list — update name, description, avatar via `com.atproto.repo.putRecord` 53 - - [x] Delete list via `com.atproto.repo.deleteRecord` 54 - - [x] Add members — search via `searchActorsTypeahead`, create `listitem` records 55 - - [x] Remove members — delete `listitem` records 56 - 57 - ### Moderation Actions 58 - 59 - - [x] Mute list via `muteActorList` / unmute via `unmuteActorList` 60 - - [x] Block via list — create `listblock` record; unblock — delete `listblock` record 61 - 62 - ### Screens 63 - 64 - - [x] My Lists screen — curation and moderation tabs, FAB to create new list 65 - - [x] List detail screen — header (name, avatar, description, creator, member count), Feed tab (curation lists), Members tab 66 - - [x] Add/remove members screen — search field + current members with remove buttons 67 - - [x] Create/edit list dialog — name, description, avatar picker, purpose selector 68 - 69 - ### Profile Integration 70 - 71 - - [x] "Lists" tab on profile screens via `getLists` 72 - - [x] "Add to list" option in profile overflow menu using `getListsWithMembership` 73 - 74 43 ## M19 — Starter Packs 75 44 76 - ### Core 77 - 78 - - [x] `StarterPackBloc` — events: `StarterPackRequested`, `StarterPackCreated`, `StarterPackUpdated`, `StarterPackDeleted`, `MemberAdded`, `MemberRemoved` 79 - - [x] `ActorStarterPacksCubit` — load starter packs for an actor via `getActorStarterPacks` 80 - 81 - ### Viewing 82 - 83 - - [x] Starter pack detail screen — name, description, creator, join stats, member sample (up to 12), recommended feeds (up to 3) 84 - - [x] "See all members" — navigate to full member list via backing reference list 85 - - [x] "Follow all" button — follow every member in the pack 86 - - [x] Actor starter packs screen — paginated list via `getActorStarterPacks` 87 - 88 - ### Creation & Editing 89 - 90 - - [x] Create starter pack — name (max 50 graphemes), description, member search, feed picker (up to 3) 91 - - [x] Creation flow: create reference list → add `listitem` records → create starter pack record 92 - - [x] Edit starter pack — update name/description/feeds via `putRecord`, add/remove members via `listitem` CRUD 93 - - [x] Delete starter pack and its backing reference list 94 - 95 - ### Profile Integration 96 - 97 - - [x] "Starter Packs" section on profile screens showing packs created by actor 98 - - [x] Starter pack cards — name, creator, member count, join stats 45 + Completed [2026-03-22](../../CHANGELOG.md#2026-03-22)
+4
docs/tasks/phase-5.md
··· 1 + --- 2 + title: Phase 5 Task Breakdown 3 + updated: 2026-03-23 4 + ---
+46 -3
lib/features/account/cubit/account_switcher_cubit.dart
··· 2 2 import 'package:equatable/equatable.dart'; 3 3 import 'package:flutter_bloc/flutter_bloc.dart'; 4 4 import 'package:lazurite/core/database/app_database.dart'; 5 + import 'package:lazurite/features/auth/data/auth_repository.dart'; 5 6 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 6 7 7 8 part 'account_switcher_state.dart'; 8 9 9 10 class AccountSwitcherCubit extends Cubit<AccountSwitcherState> { 10 - AccountSwitcherCubit({required AppDatabase database}) 11 + AccountSwitcherCubit({required AppDatabase database, required AuthRepository authRepository}) 11 12 : _database = database, 13 + _authRepository = authRepository, 12 14 super(const AccountSwitcherState.initial()); 13 15 14 16 final AppDatabase _database; 17 + final AuthRepository _authRepository; 15 18 16 19 static const String _keyActiveAccountDid = 'active_account_did'; 17 20 ··· 35 38 } 36 39 } 37 40 38 - Future<void> switchAccount(String did) async { 39 - if (state.status != AccountSwitcherStatus.ready) return; 41 + Future<AuthTokens?> switchAccount(String did) async { 42 + if (state.status != AccountSwitcherStatus.ready) return null; 40 43 41 44 await _database.setSetting(_keyActiveAccountDid, did); 42 45 emit(state.copyWith(activeDid: did)); 46 + 47 + final account = await _database.getAccount(did); 48 + if (account == null) return null; 49 + 50 + final tokens = AuthTokens( 51 + accessToken: account.accessToken, 52 + refreshToken: account.refreshToken, 53 + expiresAt: account.expiresAt, 54 + did: account.did, 55 + handle: account.handle, 56 + displayName: account.displayName, 57 + service: account.service, 58 + dpopNonce: account.dpopNonce, 59 + dpopPublicKey: account.dpopPublicKey, 60 + dpopPrivateKey: account.dpopPrivateKey, 61 + authMethod: account.dpopPrivateKey != null && account.dpopPublicKey != null 62 + ? AuthMethod.oauth 63 + : AuthMethod.appPassword, 64 + ); 65 + 66 + if (!tokens.isExpired) return tokens; 67 + 68 + if (tokens.refreshToken == null) return null; 69 + 70 + try { 71 + return await _authRepository.refreshSession(tokens); 72 + } catch (_) { 73 + return null; 74 + } 75 + } 76 + 77 + Future<AuthTokens?> addAccountWithOAuth(String handle) async { 78 + try { 79 + final tokens = await _authRepository.loginWithOAuth(handle); 80 + if (tokens == null) return null; 81 + await addAccountCompleted(tokens); 82 + return tokens; 83 + } catch (_) { 84 + return null; 85 + } 43 86 } 44 87 45 88 Future<void> addAccountCompleted(AuthTokens tokens) async {
+123
lib/features/account/presentation/account_switcher_sheet.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 4 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 5 + 6 + void showAccountSwitcherSheet(BuildContext context) { 7 + final cubit = context.read<AccountSwitcherCubit>(); 8 + final authBloc = context.read<AuthBloc>(); 9 + 10 + showModalBottomSheet<void>( 11 + context: context, 12 + builder: (sheetContext) => BlocProvider.value( 13 + value: cubit, 14 + child: _AccountSwitcherSheet(authBloc: authBloc), 15 + ), 16 + ); 17 + } 18 + 19 + class _AccountSwitcherSheet extends StatelessWidget { 20 + const _AccountSwitcherSheet({required this.authBloc}); 21 + 22 + final AuthBloc authBloc; 23 + 24 + @override 25 + Widget build(BuildContext context) { 26 + return SafeArea( 27 + child: Column( 28 + mainAxisSize: MainAxisSize.min, 29 + crossAxisAlignment: CrossAxisAlignment.start, 30 + children: [ 31 + Padding( 32 + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), 33 + child: Text('Accounts', style: Theme.of(context).textTheme.titleMedium), 34 + ), 35 + const Divider(), 36 + BlocBuilder<AccountSwitcherCubit, AccountSwitcherState>( 37 + builder: (context, state) { 38 + if (state.status == AccountSwitcherStatus.loading || state.status == AccountSwitcherStatus.initial) { 39 + return const Padding( 40 + padding: EdgeInsets.all(24), 41 + child: Center(child: CircularProgressIndicator()), 42 + ); 43 + } 44 + 45 + return ListView.builder( 46 + shrinkWrap: true, 47 + physics: const NeverScrollableScrollPhysics(), 48 + itemCount: state.accounts.length, 49 + itemBuilder: (context, index) { 50 + final account = state.accounts[index]; 51 + final isActive = account.did == state.activeDid; 52 + final label = account.displayName ?? account.handle; 53 + 54 + return ListTile( 55 + leading: CircleAvatar(child: Text(label.substring(0, 1).toUpperCase())), 56 + title: Text(label), 57 + subtitle: Text('@${account.handle}'), 58 + trailing: isActive ? const Icon(Icons.check) : null, 59 + onTap: isActive ? null : () => _onSwitchAccount(context, account.did), 60 + ); 61 + }, 62 + ); 63 + }, 64 + ), 65 + const Divider(), 66 + ListTile( 67 + leading: const Icon(Icons.person_add_outlined), 68 + title: const Text('Add Account'), 69 + onTap: () => _onAddAccount(context), 70 + ), 71 + ], 72 + ), 73 + ); 74 + } 75 + 76 + Future<void> _onSwitchAccount(BuildContext context, String did) async { 77 + final cubit = context.read<AccountSwitcherCubit>(); 78 + Navigator.pop(context); 79 + final tokens = await cubit.switchAccount(did); 80 + if (tokens == null) { 81 + authBloc.add(const LogoutRequested()); 82 + } else { 83 + authBloc.add(SessionRestored(tokens: tokens)); 84 + } 85 + } 86 + 87 + Future<void> _onAddAccount(BuildContext context) async { 88 + final messenger = ScaffoldMessenger.of(context); 89 + final cubit = context.read<AccountSwitcherCubit>(); 90 + Navigator.pop(context); 91 + 92 + final handle = await showDialog<String>( 93 + context: context, 94 + builder: (dialogContext) { 95 + final controller = TextEditingController(); 96 + return AlertDialog( 97 + title: const Text('Add Account'), 98 + content: TextField( 99 + controller: controller, 100 + decoration: const InputDecoration(labelText: 'Handle or DID'), 101 + autofocus: true, 102 + ), 103 + actions: [ 104 + TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancel')), 105 + TextButton( 106 + onPressed: () => Navigator.pop(dialogContext, controller.text.trim()), 107 + child: const Text('Continue'), 108 + ), 109 + ], 110 + ); 111 + }, 112 + ); 113 + 114 + if (handle == null || handle.isEmpty) return; 115 + 116 + final tokens = await cubit.addAccountWithOAuth(handle); 117 + if (tokens != null) { 118 + authBloc.add(SessionRestored(tokens: tokens)); 119 + } else { 120 + messenger.showSnackBar(const SnackBar(content: Text('Failed to add account'))); 121 + } 122 + } 123 + }
+21 -9
lib/features/settings/presentation/settings_screen.dart
··· 5 5 import 'package:lazurite/core/theme/app_theme.dart'; 6 6 import 'package:lazurite/core/theme/feed_architecture.dart'; 7 7 import 'package:lazurite/core/theme/ui_density.dart'; 8 + import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 9 + import 'package:lazurite/features/account/presentation/account_switcher_sheet.dart'; 8 10 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 9 11 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 10 12 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; ··· 33 35 body: ListView( 34 36 children: [ 35 37 BlocBuilder<AuthBloc, AuthState>( 36 - builder: (context, state) { 37 - final tokens = state.tokens; 38 - if (!state.isAuthenticated || tokens == null) { 38 + builder: (context, authState) { 39 + final tokens = authState.tokens; 40 + if (!authState.isAuthenticated || tokens == null) { 39 41 return const SizedBox.shrink(); 40 42 } 41 43 42 - return ListTile( 43 - leading: CircleAvatar(child: Text((tokens.displayName ?? tokens.handle).substring(0, 1).toUpperCase())), 44 - title: Text(tokens.displayName ?? tokens.handle), 45 - subtitle: Text('@${tokens.handle}'), 46 - trailing: const Icon(Icons.chevron_right), 47 - onTap: () => context.go('/profile'), 44 + return BlocBuilder<AccountSwitcherCubit, AccountSwitcherState>( 45 + builder: (context, switcherState) { 46 + final subtitle = switcherState.accounts.length > 1 47 + ? '${switcherState.accounts.length} accounts — tap to switch' 48 + : '@${tokens.handle}'; 49 + 50 + return ListTile( 51 + leading: CircleAvatar( 52 + child: Text((tokens.displayName ?? tokens.handle).substring(0, 1).toUpperCase()), 53 + ), 54 + title: Text(tokens.displayName ?? tokens.handle), 55 + subtitle: Text(subtitle), 56 + trailing: const Icon(Icons.chevron_right), 57 + onTap: () => showAccountSwitcherSheet(context), 58 + ); 59 + }, 48 60 ); 49 61 }, 50 62 ),
+21 -2
lib/main.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'; 37 38 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 38 39 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 39 40 ··· 57 58 final settingsCubit = SettingsCubit(database: database); 58 59 await settingsCubit.loadSettings(); 59 60 61 + final accountSwitcherCubit = AccountSwitcherCubit(database: database, authRepository: authRepository); 62 + await accountSwitcherCubit.loadAccounts(); 63 + 60 64 log.i('AppLogger: App started'); 61 65 62 - runApp(LazuriteApp(authBloc: authBloc, database: database, settingsCubit: settingsCubit)); 66 + runApp( 67 + LazuriteApp( 68 + authBloc: authBloc, 69 + database: database, 70 + settingsCubit: settingsCubit, 71 + accountSwitcherCubit: accountSwitcherCubit, 72 + ), 73 + ); 63 74 } 64 75 65 76 class LazuriteApp extends StatefulWidget { 66 - const LazuriteApp({super.key, required this.authBloc, required this.database, required this.settingsCubit}); 77 + const LazuriteApp({ 78 + super.key, 79 + required this.authBloc, 80 + required this.database, 81 + required this.settingsCubit, 82 + required this.accountSwitcherCubit, 83 + }); 67 84 68 85 final AuthBloc authBloc; 69 86 final AppDatabase database; 70 87 final SettingsCubit settingsCubit; 88 + final AccountSwitcherCubit accountSwitcherCubit; 71 89 72 90 @override 73 91 State<LazuriteApp> createState() => _LazuriteAppState(); ··· 105 123 providers: [ 106 124 BlocProvider.value(value: widget.authBloc), 107 125 BlocProvider.value(value: widget.settingsCubit), 126 + BlocProvider.value(value: widget.accountSwitcherCubit), 108 127 ], 109 128 child: BlocBuilder<AuthBloc, AuthState>( 110 129 builder: (context, authState) {
+13
test/core/router/app_router_test.dart
··· 15 15 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 16 16 import 'package:lazurite/features/notifications/data/notification_repository.dart'; 17 17 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 18 + import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 18 19 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 19 20 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 20 21 import 'package:mocktail/mocktail.dart'; ··· 29 30 30 31 class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 31 32 33 + class MockAccountSwitcherCubit extends MockCubit<AccountSwitcherState> implements AccountSwitcherCubit {} 34 + 32 35 class MockUnreadCountCubit extends MockCubit<UnreadCountState> implements UnreadCountCubit {} 33 36 34 37 class MockConvoListBloc extends MockBloc<ConvoListEvent, ConvoListState> implements ConvoListBloc {} ··· 41 44 late MockProfileBloc profileBloc; 42 45 late MockFeedBloc feedBloc; 43 46 late MockSettingsCubit settingsCubit; 47 + late MockAccountSwitcherCubit accountSwitcherCubit; 44 48 late MockUnreadCountCubit unreadCountCubit; 45 49 late MockConvoListBloc convoListBloc; 46 50 late MockNotificationRepository notificationRepository; ··· 71 75 profileBloc = MockProfileBloc(); 72 76 feedBloc = MockFeedBloc(); 73 77 settingsCubit = MockSettingsCubit(); 78 + accountSwitcherCubit = MockAccountSwitcherCubit(); 74 79 unreadCountCubit = MockUnreadCountCubit(); 75 80 convoListBloc = MockConvoListBloc(); 76 81 notificationRepository = MockNotificationRepository(); ··· 90 95 useSystemTheme: false, 91 96 ), 92 97 ); 98 + when(() => accountSwitcherCubit.state).thenReturn(const AccountSwitcherState.ready(accounts: [])); 93 99 when(() => unreadCountCubit.state).thenReturn(const UnreadCountState(0)); 94 100 when(() => convoListBloc.state).thenReturn(const ConvoListState.loaded(convos: [], cursor: null, hasMore: false)); 95 101 when(() => notificationRepository.getUnreadCount()).thenAnswer((_) async => 0); ··· 120 126 useSystemTheme: false, 121 127 ), 122 128 ); 129 + whenListen( 130 + accountSwitcherCubit, 131 + const Stream<AccountSwitcherState>.empty(), 132 + initialState: const AccountSwitcherState.ready(accounts: []), 133 + ); 123 134 whenListen(unreadCountCubit, const Stream<UnreadCountState>.empty(), initialState: const UnreadCountState(0)); 124 135 whenListen( 125 136 convoListBloc, ··· 139 150 BlocProvider<ProfileBloc>.value(value: profileBloc), 140 151 BlocProvider<FeedBloc>.value(value: feedBloc), 141 152 BlocProvider<SettingsCubit>.value(value: settingsCubit), 153 + BlocProvider<AccountSwitcherCubit>.value(value: accountSwitcherCubit), 142 154 BlocProvider<UnreadCountCubit>.value(value: unreadCountCubit), 143 155 BlocProvider<ConvoListBloc>.value(value: convoListBloc), 144 156 ], ··· 264 276 BlocProvider<ProfileBloc>.value(value: profileBloc), 265 277 BlocProvider<FeedBloc>.value(value: feedBloc), 266 278 BlocProvider<SettingsCubit>.value(value: settingsCubit), 279 + BlocProvider<AccountSwitcherCubit>.value(value: accountSwitcherCubit), 267 280 ], 268 281 child: BlocBuilder<AuthBloc, AuthState>( 269 282 builder: (context, state) {
+172 -23
test/features/account/cubit/account_switcher_cubit_test.dart
··· 2 2 import 'package:flutter_test/flutter_test.dart'; 3 3 import 'package:lazurite/core/database/app_database.dart'; 4 4 import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 5 + import 'package:lazurite/features/auth/data/auth_repository.dart'; 5 6 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 6 7 import 'package:mocktail/mocktail.dart'; 7 8 8 9 class MockAppDatabase extends Mock implements AppDatabase {} 9 10 11 + class MockAuthRepository extends Mock implements AuthRepository {} 12 + 10 13 class AccountsCompanionFake extends Fake implements AccountsCompanion {} 11 14 12 15 void main() { 13 16 late MockAppDatabase mockDatabase; 17 + late MockAuthRepository mockAuthRepository; 14 18 15 19 setUpAll(() { 16 20 registerFallbackValue(AccountsCompanionFake()); 21 + registerFallbackValue( 22 + const AuthTokens(accessToken: 'token', did: 'did:plc:fallback', handle: 'fallback.bsky.social'), 23 + ); 17 24 }); 18 25 19 26 setUp(() { 20 27 mockDatabase = MockAppDatabase(); 28 + mockAuthRepository = MockAuthRepository(); 21 29 }); 22 30 23 - Account makeAccount({required String did, String handle = 'user.bsky.social'}) { 31 + AccountSwitcherCubit buildCubit() => AccountSwitcherCubit(database: mockDatabase, authRepository: mockAuthRepository); 32 + 33 + Account makeAccount({ 34 + required String did, 35 + String handle = 'user.bsky.social', 36 + String accessToken = 'token', 37 + String? refreshToken, 38 + DateTime? expiresAt, 39 + String? dpopPrivateKey, 40 + String? dpopPublicKey, 41 + }) { 24 42 return Account( 25 43 did: did, 26 44 handle: handle, 27 45 displayName: null, 28 46 service: null, 29 - accessToken: 'token', 30 - refreshToken: null, 31 - dpopPublicKey: null, 32 - dpopPrivateKey: null, 47 + accessToken: accessToken, 48 + refreshToken: refreshToken, 49 + dpopPublicKey: dpopPublicKey, 50 + dpopPrivateKey: dpopPrivateKey, 33 51 dpopNonce: null, 34 - expiresAt: null, 52 + expiresAt: expiresAt, 35 53 createdAt: DateTime.utc(2026, 1, 1), 36 54 updatedAt: DateTime.utc(2026, 1, 1), 37 55 ); ··· 41 59 group('loadAccounts', () { 42 60 blocTest<AccountSwitcherCubit, AccountSwitcherState>( 43 61 'emits loading then ready with accounts when accounts exist', 44 - build: () => AccountSwitcherCubit(database: mockDatabase), 62 + build: buildCubit, 45 63 setUp: () { 46 64 final accounts = [makeAccount(did: 'did:plc:user1'), makeAccount(did: 'did:plc:user2')]; 47 65 when(() => mockDatabase.getAllAccounts()).thenAnswer((_) async => accounts); ··· 61 79 62 80 blocTest<AccountSwitcherCubit, AccountSwitcherState>( 63 81 'defaults to first account when no saved active did', 64 - build: () => AccountSwitcherCubit(database: mockDatabase), 82 + build: buildCubit, 65 83 setUp: () { 66 84 final accounts = [makeAccount(did: 'did:plc:user1'), makeAccount(did: 'did:plc:user2')]; 67 85 when(() => mockDatabase.getAllAccounts()).thenAnswer((_) async => accounts); ··· 78 96 79 97 blocTest<AccountSwitcherCubit, AccountSwitcherState>( 80 98 'defaults to first account when saved did not in accounts', 81 - build: () => AccountSwitcherCubit(database: mockDatabase), 99 + build: buildCubit, 82 100 setUp: () { 83 101 final accounts = [makeAccount(did: 'did:plc:user1')]; 84 102 when(() => mockDatabase.getAllAccounts()).thenAnswer((_) async => accounts); ··· 95 113 96 114 blocTest<AccountSwitcherCubit, AccountSwitcherState>( 97 115 'emits ready with empty accounts on failure', 98 - build: () => AccountSwitcherCubit(database: mockDatabase), 116 + build: buildCubit, 99 117 setUp: () { 100 118 when(() => mockDatabase.getAllAccounts()).thenThrow(Exception('DB error')); 101 119 }, ··· 111 129 112 130 group('switchAccount', () { 113 131 blocTest<AccountSwitcherCubit, AccountSwitcherState>( 132 + 'does nothing when state is not ready', 133 + build: buildCubit, 134 + act: (cubit) => cubit.switchAccount('did:plc:user1'), 135 + expect: () => [], 136 + verify: (_) { 137 + verifyNever(() => mockDatabase.setSetting(any(), any())); 138 + }, 139 + ); 140 + 141 + test('returns tokens for valid (non-expired) account', () async { 142 + when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); 143 + when(() => mockDatabase.getAccount('did:plc:user1')).thenAnswer((_) async => makeAccount(did: 'did:plc:user1')); 144 + 145 + final cubit = buildCubit(); 146 + cubit.emit( 147 + AccountSwitcherState.ready( 148 + accounts: [makeAccount(did: 'did:plc:user1')], 149 + activeDid: 'did:plc:user1', 150 + ), 151 + ); 152 + 153 + final tokens = await cubit.switchAccount('did:plc:user1'); 154 + expect(tokens, isNotNull); 155 + expect(tokens!.did, 'did:plc:user1'); 156 + verifyNever(() => mockAuthRepository.refreshSession(any())); 157 + }); 158 + 159 + test('calls refreshSession when account is expired with refresh token', () async { 160 + final expiredAt = DateTime.now().subtract(const Duration(hours: 1)); 161 + final refreshedTokens = AuthTokens( 162 + accessToken: 'new-token', 163 + did: 'did:plc:user1', 164 + handle: 'user.bsky.social', 165 + expiresAt: DateTime.now().add(const Duration(hours: 1)), 166 + ); 167 + 168 + when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); 169 + when(() => mockDatabase.getAccount('did:plc:user1')).thenAnswer( 170 + (_) async => makeAccount(did: 'did:plc:user1', expiresAt: expiredAt, refreshToken: 'refresh-token'), 171 + ); 172 + when(() => mockAuthRepository.refreshSession(any())).thenAnswer((_) async => refreshedTokens); 173 + 174 + final cubit = buildCubit(); 175 + cubit.emit( 176 + AccountSwitcherState.ready( 177 + accounts: [makeAccount(did: 'did:plc:user1')], 178 + activeDid: 'did:plc:user1', 179 + ), 180 + ); 181 + 182 + final tokens = await cubit.switchAccount('did:plc:user1'); 183 + expect(tokens, refreshedTokens); 184 + verify(() => mockAuthRepository.refreshSession(any())).called(1); 185 + }); 186 + 187 + test('returns null when account is expired and refresh throws', () async { 188 + final expiredAt = DateTime.now().subtract(const Duration(hours: 1)); 189 + 190 + when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); 191 + when(() => mockDatabase.getAccount('did:plc:user1')).thenAnswer( 192 + (_) async => makeAccount(did: 'did:plc:user1', expiresAt: expiredAt, refreshToken: 'refresh-token'), 193 + ); 194 + when(() => mockAuthRepository.refreshSession(any())).thenThrow(Exception('refresh failed')); 195 + 196 + final cubit = buildCubit(); 197 + cubit.emit( 198 + AccountSwitcherState.ready( 199 + accounts: [makeAccount(did: 'did:plc:user1')], 200 + activeDid: 'did:plc:user1', 201 + ), 202 + ); 203 + 204 + final tokens = await cubit.switchAccount('did:plc:user1'); 205 + expect(tokens, isNull); 206 + }); 207 + 208 + test('returns null when account is expired and has no refresh token', () async { 209 + final expiredAt = DateTime.now().subtract(const Duration(hours: 1)); 210 + 211 + when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); 212 + when( 213 + () => mockDatabase.getAccount('did:plc:user1'), 214 + ).thenAnswer((_) async => makeAccount(did: 'did:plc:user1', expiresAt: expiredAt)); 215 + 216 + final cubit = buildCubit(); 217 + cubit.emit( 218 + AccountSwitcherState.ready( 219 + accounts: [makeAccount(did: 'did:plc:user1')], 220 + activeDid: 'did:plc:user1', 221 + ), 222 + ); 223 + 224 + final tokens = await cubit.switchAccount('did:plc:user1'); 225 + expect(tokens, isNull); 226 + verifyNever(() => mockAuthRepository.refreshSession(any())); 227 + }); 228 + 229 + blocTest<AccountSwitcherCubit, AccountSwitcherState>( 114 230 'updates activeDid when switching accounts', 115 - build: () => AccountSwitcherCubit(database: mockDatabase), 231 + build: buildCubit, 116 232 seed: () => AccountSwitcherState.ready( 117 233 accounts: [ 118 234 makeAccount(did: 'did:plc:user1'), ··· 122 238 ), 123 239 setUp: () { 124 240 when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); 241 + when( 242 + () => mockDatabase.getAccount('did:plc:user2'), 243 + ).thenAnswer((_) async => makeAccount(did: 'did:plc:user2')); 125 244 }, 126 245 act: (cubit) => cubit.switchAccount('did:plc:user2'), 127 246 expect: () => [predicate<AccountSwitcherState>((state) => state.activeDid == 'did:plc:user2')], ··· 129 248 verify(() => mockDatabase.setSetting('active_account_did', 'did:plc:user2')).called(1); 130 249 }, 131 250 ); 132 - 133 - blocTest<AccountSwitcherCubit, AccountSwitcherState>( 134 - 'does nothing when state is not ready', 135 - build: () => AccountSwitcherCubit(database: mockDatabase), 136 - act: (cubit) => cubit.switchAccount('did:plc:user1'), 137 - expect: () => [], 138 - verify: (_) { 139 - verifyNever(() => mockDatabase.setSetting(any(), any())); 140 - }, 141 - ); 142 251 }); 143 252 144 253 group('addAccountCompleted', () { 145 254 blocTest<AccountSwitcherCubit, AccountSwitcherState>( 146 255 'inserts account, reloads, and activeDid is set to the new account', 147 - build: () => AccountSwitcherCubit(database: mockDatabase), 256 + build: buildCubit, 148 257 setUp: () { 149 258 when(() => mockDatabase.insertAccount(any())).thenAnswer((_) async => 1); 150 259 when( ··· 152 261 ).thenAnswer((_) async => [makeAccount(did: 'did:plc:newuser', handle: 'new.bsky.social')]); 153 262 when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => null); 154 263 when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); 264 + when( 265 + () => mockDatabase.getAccount('did:plc:newuser'), 266 + ).thenAnswer((_) async => makeAccount(did: 'did:plc:newuser', handle: 'new.bsky.social')); 155 267 }, 156 268 act: (cubit) => cubit.addAccountCompleted( 157 269 const AuthTokens(accessToken: 'token', did: 'did:plc:newuser', handle: 'new.bsky.social'), ··· 173 285 174 286 blocTest<AccountSwitcherCubit, AccountSwitcherState>( 175 287 'switches to newly added account even when another was active', 176 - build: () => AccountSwitcherCubit(database: mockDatabase), 288 + build: buildCubit, 177 289 seed: () => AccountSwitcherState.ready( 178 290 accounts: [makeAccount(did: 'did:plc:user1')], 179 291 activeDid: 'did:plc:user1', ··· 188 300 ); 189 301 when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => 'did:plc:user1'); 190 302 when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); 303 + when( 304 + () => mockDatabase.getAccount('did:plc:user2'), 305 + ).thenAnswer((_) async => makeAccount(did: 'did:plc:user2', handle: 'user2.bsky.social')); 191 306 }, 192 307 act: (cubit) => cubit.addAccountCompleted( 193 308 const AuthTokens(accessToken: 'token', did: 'did:plc:user2', handle: 'user2.bsky.social'), ··· 203 318 predicate<AccountSwitcherState>((state) => state.activeDid == 'did:plc:user2'), 204 319 ], 205 320 ); 321 + }); 322 + 323 + group('addAccountWithOAuth', () { 324 + test('calls addAccountCompleted and returns tokens on success', () async { 325 + const tokens = AuthTokens(accessToken: 'new-token', did: 'did:plc:newuser', handle: 'new.bsky.social'); 326 + 327 + when(() => mockAuthRepository.loginWithOAuth(any())).thenAnswer((_) async => tokens); 328 + when(() => mockDatabase.insertAccount(any())).thenAnswer((_) async => 1); 329 + when( 330 + () => mockDatabase.getAllAccounts(), 331 + ).thenAnswer((_) async => [makeAccount(did: 'did:plc:newuser', handle: 'new.bsky.social')]); 332 + when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => null); 333 + when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); 334 + when( 335 + () => mockDatabase.getAccount('did:plc:newuser'), 336 + ).thenAnswer((_) async => makeAccount(did: 'did:plc:newuser', handle: 'new.bsky.social')); 337 + 338 + final cubit = buildCubit(); 339 + final result = await cubit.addAccountWithOAuth('new.bsky.social'); 340 + 341 + expect(result, tokens); 342 + verify(() => mockAuthRepository.loginWithOAuth('new.bsky.social')).called(1); 343 + verify(() => mockDatabase.insertAccount(any())).called(1); 344 + }); 345 + 346 + test('returns null when loginWithOAuth throws', () async { 347 + when(() => mockAuthRepository.loginWithOAuth(any())).thenThrow(Exception('OAuth failed')); 348 + 349 + final cubit = buildCubit(); 350 + final result = await cubit.addAccountWithOAuth('bad.handle'); 351 + 352 + expect(result, isNull); 353 + verifyNever(() => mockDatabase.insertAccount(any())); 354 + }); 206 355 }); 207 356 }); 208 357 }
+186
test/features/account/presentation/account_switcher_sheet_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/core/database/app_database.dart'; 6 + import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 7 + import 'package:lazurite/features/account/presentation/account_switcher_sheet.dart'; 8 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 9 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 10 + import 'package:mocktail/mocktail.dart'; 11 + 12 + class MockAccountSwitcherCubit extends MockCubit<AccountSwitcherState> implements AccountSwitcherCubit {} 13 + 14 + class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 15 + 16 + void main() { 17 + late MockAccountSwitcherCubit cubit; 18 + late MockAuthBloc authBloc; 19 + 20 + const tokens = AuthTokens(accessToken: 'token', did: 'did:plc:me', handle: 'me.bsky.social'); 21 + 22 + setUpAll(() { 23 + registerFallbackValue(const LogoutRequested()); 24 + registerFallbackValue(const SessionRestored(tokens: tokens)); 25 + }); 26 + 27 + setUp(() { 28 + cubit = MockAccountSwitcherCubit(); 29 + authBloc = MockAuthBloc(); 30 + when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); 31 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.authenticated(tokens)); 32 + }); 33 + 34 + Account makeAccount({required String did, String handle = 'user.bsky.social', String? displayName}) { 35 + return Account( 36 + did: did, 37 + handle: handle, 38 + displayName: displayName, 39 + service: null, 40 + accessToken: 'token', 41 + refreshToken: null, 42 + dpopPublicKey: null, 43 + dpopPrivateKey: null, 44 + dpopNonce: null, 45 + expiresAt: null, 46 + createdAt: DateTime.utc(2026, 1, 1), 47 + updatedAt: DateTime.utc(2026, 1, 1), 48 + ); 49 + } 50 + 51 + Widget buildSubject() { 52 + return MultiBlocProvider( 53 + providers: [ 54 + BlocProvider<AuthBloc>.value(value: authBloc), 55 + BlocProvider<AccountSwitcherCubit>.value(value: cubit), 56 + ], 57 + child: MaterialApp( 58 + home: Scaffold( 59 + body: Builder( 60 + builder: (context) => 61 + TextButton(onPressed: () => showAccountSwitcherSheet(context), child: const Text('Open')), 62 + ), 63 + ), 64 + ), 65 + ); 66 + } 67 + 68 + Future<void> openSheet(WidgetTester tester) async { 69 + await tester.pumpWidget(buildSubject()); 70 + await tester.tap(find.text('Open')); 71 + await tester.pump(); 72 + await tester.pump(const Duration(milliseconds: 500)); 73 + } 74 + 75 + group('AccountSwitcherSheet', () { 76 + testWidgets('shows CircularProgressIndicator during loading state', (tester) async { 77 + when(() => cubit.state).thenReturn(const AccountSwitcherState.loading()); 78 + 79 + await openSheet(tester); 80 + 81 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 82 + }); 83 + 84 + testWidgets('renders account rows from cubit state', (tester) async { 85 + when(() => cubit.state).thenReturn( 86 + AccountSwitcherState.ready( 87 + accounts: [ 88 + makeAccount(did: 'did:plc:user1', handle: 'alice.bsky.social', displayName: 'Alice'), 89 + makeAccount(did: 'did:plc:user2', handle: 'bob.bsky.social'), 90 + ], 91 + activeDid: 'did:plc:user1', 92 + ), 93 + ); 94 + 95 + await openSheet(tester); 96 + 97 + expect(find.text('Alice'), findsOneWidget); 98 + expect(find.text('@alice.bsky.social'), findsOneWidget); 99 + expect(find.text('bob.bsky.social'), findsOneWidget); 100 + expect(find.text('@bob.bsky.social'), findsOneWidget); 101 + }); 102 + 103 + testWidgets('shows checkmark only on active account', (tester) async { 104 + when(() => cubit.state).thenReturn( 105 + AccountSwitcherState.ready( 106 + accounts: [ 107 + makeAccount(did: 'did:plc:user1', handle: 'alice.bsky.social'), 108 + makeAccount(did: 'did:plc:user2', handle: 'bob.bsky.social'), 109 + ], 110 + activeDid: 'did:plc:user1', 111 + ), 112 + ); 113 + 114 + await openSheet(tester); 115 + 116 + expect(find.byIcon(Icons.check), findsOneWidget); 117 + }); 118 + 119 + testWidgets('shows Add Account tile', (tester) async { 120 + when(() => cubit.state).thenReturn(const AccountSwitcherState.ready(accounts: [])); 121 + 122 + await openSheet(tester); 123 + 124 + expect(find.text('Add Account'), findsOneWidget); 125 + expect(find.byIcon(Icons.person_add_outlined), findsOneWidget); 126 + }); 127 + 128 + testWidgets('tapping inactive account calls switchAccount and dispatches SessionRestored', (tester) async { 129 + const switchedTokens = AuthTokens(accessToken: 'token2', did: 'did:plc:user2', handle: 'bob.bsky.social'); 130 + 131 + when(() => cubit.state).thenReturn( 132 + AccountSwitcherState.ready( 133 + accounts: [ 134 + makeAccount(did: 'did:plc:user1', handle: 'alice.bsky.social'), 135 + makeAccount(did: 'did:plc:user2', handle: 'bob.bsky.social'), 136 + ], 137 + activeDid: 'did:plc:user1', 138 + ), 139 + ); 140 + when(() => cubit.switchAccount('did:plc:user2')).thenAnswer((_) async => switchedTokens); 141 + 142 + await openSheet(tester); 143 + await tester.tap(find.text('bob.bsky.social')); 144 + await tester.pump(); 145 + await tester.pump(const Duration(milliseconds: 300)); 146 + 147 + verify(() => cubit.switchAccount('did:plc:user2')).called(1); 148 + verify(() => authBloc.add(any(that: isA<SessionRestored>()))).called(1); 149 + }); 150 + 151 + testWidgets('dispatches LogoutRequested when switchAccount returns null', (tester) async { 152 + when(() => cubit.state).thenReturn( 153 + AccountSwitcherState.ready( 154 + accounts: [ 155 + makeAccount(did: 'did:plc:user1', handle: 'alice.bsky.social'), 156 + makeAccount(did: 'did:plc:user2', handle: 'bob.bsky.social'), 157 + ], 158 + activeDid: 'did:plc:user1', 159 + ), 160 + ); 161 + when(() => cubit.switchAccount('did:plc:user2')).thenAnswer((_) async => null); 162 + 163 + await openSheet(tester); 164 + await tester.tap(find.text('bob.bsky.social')); 165 + await tester.pump(); 166 + await tester.pump(const Duration(milliseconds: 300)); 167 + 168 + verify(() => authBloc.add(any(that: isA<LogoutRequested>()))).called(1); 169 + }); 170 + 171 + testWidgets('tapping active account does nothing', (tester) async { 172 + when(() => cubit.state).thenReturn( 173 + AccountSwitcherState.ready( 174 + accounts: [makeAccount(did: 'did:plc:user1', handle: 'alice.bsky.social')], 175 + activeDid: 'did:plc:user1', 176 + ), 177 + ); 178 + 179 + await openSheet(tester); 180 + await tester.tap(find.text('alice.bsky.social')); 181 + await tester.pump(); 182 + 183 + verifyNever(() => cubit.switchAccount(any())); 184 + }); 185 + }); 186 + }
-1
test/features/starter_packs/presentation/starter_pack_detail_screen_test.dart
··· 313 313 ), 314 314 ).thenAnswer((_) async {}); 315 315 316 - // Use router so context.canPop() works when state transitions to deleted. 317 316 await tester.pumpWidget(buildSubjectWithRouter(currentUserDid: 'did:plc:creator')); 318 317 await tester.pumpAndSettle(); 319 318