diff --git a/CHANGELOG.md b/CHANGELOG.md index 924dab1..d102657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,3 +40,7 @@ - Moderation service integration - Labels added to users in posts + +#### 2026-03-22 + +- Starter packs & lists diff --git a/docs/specs/phase-5.md b/docs/specs/phase-5.md new file mode 100644 index 0000000..7589037 --- /dev/null +++ b/docs/specs/phase-5.md @@ -0,0 +1,13 @@ +--- +title: Phase 5 Spec +updated: 2026-03-23 +--- + +## Feature Parity + +- Endpoints to build UI around: + - In search screen: `/xrpc/app.bsky.graph.searchStarterPacks` + - In profile screen: `/xrpc/app.bsky.graph.getSuggestedFollowsByActor`, should be a + sheet accessible via overflow menu + - In settings screen: `/xrpc/app.bsky.video.getUploadLimits` to show remaining daily + video upload limits diff --git a/docs/tasks/phase-4.md b/docs/tasks/phase-4.md index 82fe429..7d12ccb 100644 --- a/docs/tasks/phase-4.md +++ b/docs/tasks/phase-4.md @@ -11,13 +11,13 @@ Completed [2026-03-19](../../CHANGELOG.md#2026-03-19) ## M14 — Account Switching - [x] `AccountSwitcherCubit` exposing account list and active DID -- [ ] Account switcher bottom sheet UI — list accounts with avatars and handles +- [x] Account switcher bottom sheet UI — list accounts with avatars and handles - [x] Store `active_account_did` in Drift `settings` table - [x] Drift migration: add `account_did` column to `cached_posts` if not present -- [ ] All user-scoped queries filter by active account DID -- [ ] Broadcast `AccountSwitched` event to all Blocs on switch -- [ ] "Add Account" button triggers OAuth flow, inserts new `accounts` row -- [ ] Silent token refresh on account switch; navigate to login on failure +- [x] All user-scoped queries filter by active account DID +- [x] Broadcast `AccountSwitched` event to all Blocs on switch +- [x] "Add Account" button triggers OAuth flow, inserts new `accounts` row +- [x] Silent token refresh on account switch; navigate to login on failure ## M15 — Offline Reading & Network Resilience @@ -40,59 +40,6 @@ Completed [2026-03-21](../../CHANGELOG.md#2026-03-21) Completed [2026-03-21](../../CHANGELOG.md#2026-03-21) -### Core - -- [x] `ListBloc` — events: `ListRequested`, `ListRefreshed`, `ListItemAdded`, `ListItemRemoved`, `ListMuted`, `ListUnmuted`, `ListBlocked`, `ListUnblocked` -- [x] `MyListsCubit` — load user's lists via `getLists` -- [x] `ListFeedBloc` — paginated feed via `getListFeed`, reuse existing feed pattern - -### List CRUD - -- [x] Create list — name, description, avatar, purpose selector (curation/moderation) via `com.atproto.repo.createRecord` -- [x] Edit list — update name, description, avatar via `com.atproto.repo.putRecord` -- [x] Delete list via `com.atproto.repo.deleteRecord` -- [x] Add members — search via `searchActorsTypeahead`, create `listitem` records -- [x] Remove members — delete `listitem` records - -### Moderation Actions - -- [x] Mute list via `muteActorList` / unmute via `unmuteActorList` -- [x] Block via list — create `listblock` record; unblock — delete `listblock` record - -### Screens - -- [x] My Lists screen — curation and moderation tabs, FAB to create new list -- [x] List detail screen — header (name, avatar, description, creator, member count), Feed tab (curation lists), Members tab -- [x] Add/remove members screen — search field + current members with remove buttons -- [x] Create/edit list dialog — name, description, avatar picker, purpose selector - -### Profile Integration - -- [x] "Lists" tab on profile screens via `getLists` -- [x] "Add to list" option in profile overflow menu using `getListsWithMembership` - ## M19 — Starter Packs -### Core - -- [x] `StarterPackBloc` — events: `StarterPackRequested`, `StarterPackCreated`, `StarterPackUpdated`, `StarterPackDeleted`, `MemberAdded`, `MemberRemoved` -- [x] `ActorStarterPacksCubit` — load starter packs for an actor via `getActorStarterPacks` - -### Viewing - -- [x] Starter pack detail screen — name, description, creator, join stats, member sample (up to 12), recommended feeds (up to 3) -- [x] "See all members" — navigate to full member list via backing reference list -- [x] "Follow all" button — follow every member in the pack -- [x] Actor starter packs screen — paginated list via `getActorStarterPacks` - -### Creation & Editing - -- [x] Create starter pack — name (max 50 graphemes), description, member search, feed picker (up to 3) -- [x] Creation flow: create reference list → add `listitem` records → create starter pack record -- [x] Edit starter pack — update name/description/feeds via `putRecord`, add/remove members via `listitem` CRUD -- [x] Delete starter pack and its backing reference list - -### Profile Integration - -- [x] "Starter Packs" section on profile screens showing packs created by actor -- [x] Starter pack cards — name, creator, member count, join stats +Completed [2026-03-22](../../CHANGELOG.md#2026-03-22) diff --git a/docs/tasks/phase-5.md b/docs/tasks/phase-5.md new file mode 100644 index 0000000..6b49e5e --- /dev/null +++ b/docs/tasks/phase-5.md @@ -0,0 +1,4 @@ +--- +title: Phase 5 Task Breakdown +updated: 2026-03-23 +--- diff --git a/lib/features/account/cubit/account_switcher_cubit.dart b/lib/features/account/cubit/account_switcher_cubit.dart index ddc33d1..a21a78d 100644 --- a/lib/features/account/cubit/account_switcher_cubit.dart +++ b/lib/features/account/cubit/account_switcher_cubit.dart @@ -2,16 +2,19 @@ import 'package:drift/drift.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/features/auth/data/auth_repository.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; part 'account_switcher_state.dart'; class AccountSwitcherCubit extends Cubit { - AccountSwitcherCubit({required AppDatabase database}) + AccountSwitcherCubit({required AppDatabase database, required AuthRepository authRepository}) : _database = database, + _authRepository = authRepository, super(const AccountSwitcherState.initial()); final AppDatabase _database; + final AuthRepository _authRepository; static const String _keyActiveAccountDid = 'active_account_did'; @@ -35,11 +38,51 @@ class AccountSwitcherCubit extends Cubit { } } - Future switchAccount(String did) async { - if (state.status != AccountSwitcherStatus.ready) return; + Future switchAccount(String did) async { + if (state.status != AccountSwitcherStatus.ready) return null; await _database.setSetting(_keyActiveAccountDid, did); emit(state.copyWith(activeDid: did)); + + final account = await _database.getAccount(did); + if (account == null) return null; + + final tokens = AuthTokens( + accessToken: account.accessToken, + refreshToken: account.refreshToken, + expiresAt: account.expiresAt, + did: account.did, + handle: account.handle, + displayName: account.displayName, + service: account.service, + dpopNonce: account.dpopNonce, + dpopPublicKey: account.dpopPublicKey, + dpopPrivateKey: account.dpopPrivateKey, + authMethod: account.dpopPrivateKey != null && account.dpopPublicKey != null + ? AuthMethod.oauth + : AuthMethod.appPassword, + ); + + if (!tokens.isExpired) return tokens; + + if (tokens.refreshToken == null) return null; + + try { + return await _authRepository.refreshSession(tokens); + } catch (_) { + return null; + } + } + + Future addAccountWithOAuth(String handle) async { + try { + final tokens = await _authRepository.loginWithOAuth(handle); + if (tokens == null) return null; + await addAccountCompleted(tokens); + return tokens; + } catch (_) { + return null; + } } Future addAccountCompleted(AuthTokens tokens) async { diff --git a/lib/features/account/presentation/account_switcher_sheet.dart b/lib/features/account/presentation/account_switcher_sheet.dart new file mode 100644 index 0000000..5aff127 --- /dev/null +++ b/lib/features/account/presentation/account_switcher_sheet.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; + +void showAccountSwitcherSheet(BuildContext context) { + final cubit = context.read(); + final authBloc = context.read(); + + showModalBottomSheet( + context: context, + builder: (sheetContext) => BlocProvider.value( + value: cubit, + child: _AccountSwitcherSheet(authBloc: authBloc), + ), + ); +} + +class _AccountSwitcherSheet extends StatelessWidget { + const _AccountSwitcherSheet({required this.authBloc}); + + final AuthBloc authBloc; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Accounts', style: Theme.of(context).textTheme.titleMedium), + ), + const Divider(), + BlocBuilder( + builder: (context, state) { + if (state.status == AccountSwitcherStatus.loading || state.status == AccountSwitcherStatus.initial) { + return const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ); + } + + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: state.accounts.length, + itemBuilder: (context, index) { + final account = state.accounts[index]; + final isActive = account.did == state.activeDid; + final label = account.displayName ?? account.handle; + + return ListTile( + leading: CircleAvatar(child: Text(label.substring(0, 1).toUpperCase())), + title: Text(label), + subtitle: Text('@${account.handle}'), + trailing: isActive ? const Icon(Icons.check) : null, + onTap: isActive ? null : () => _onSwitchAccount(context, account.did), + ); + }, + ); + }, + ), + const Divider(), + ListTile( + leading: const Icon(Icons.person_add_outlined), + title: const Text('Add Account'), + onTap: () => _onAddAccount(context), + ), + ], + ), + ); + } + + Future _onSwitchAccount(BuildContext context, String did) async { + final cubit = context.read(); + Navigator.pop(context); + final tokens = await cubit.switchAccount(did); + if (tokens == null) { + authBloc.add(const LogoutRequested()); + } else { + authBloc.add(SessionRestored(tokens: tokens)); + } + } + + Future _onAddAccount(BuildContext context) async { + final messenger = ScaffoldMessenger.of(context); + final cubit = context.read(); + Navigator.pop(context); + + final handle = await showDialog( + context: context, + builder: (dialogContext) { + final controller = TextEditingController(); + return AlertDialog( + title: const Text('Add Account'), + content: TextField( + controller: controller, + decoration: const InputDecoration(labelText: 'Handle or DID'), + autofocus: true, + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(dialogContext, controller.text.trim()), + child: const Text('Continue'), + ), + ], + ); + }, + ); + + if (handle == null || handle.isEmpty) return; + + final tokens = await cubit.addAccountWithOAuth(handle); + if (tokens != null) { + authBloc.add(SessionRestored(tokens: tokens)); + } else { + messenger.showSnackBar(const SnackBar(content: Text('Failed to add account'))); + } + } +} diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index af8b3f6..d4c7820 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -5,6 +5,8 @@ import 'package:lazurite/core/router/app_shell.dart'; import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/core/theme/feed_architecture.dart'; import 'package:lazurite/core/theme/ui_density.dart'; +import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; +import 'package:lazurite/features/account/presentation/account_switcher_sheet.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; @@ -33,18 +35,28 @@ class SettingsScreen extends StatelessWidget { body: ListView( children: [ BlocBuilder( - builder: (context, state) { - final tokens = state.tokens; - if (!state.isAuthenticated || tokens == null) { + builder: (context, authState) { + final tokens = authState.tokens; + if (!authState.isAuthenticated || tokens == null) { return const SizedBox.shrink(); } - return ListTile( - leading: CircleAvatar(child: Text((tokens.displayName ?? tokens.handle).substring(0, 1).toUpperCase())), - title: Text(tokens.displayName ?? tokens.handle), - subtitle: Text('@${tokens.handle}'), - trailing: const Icon(Icons.chevron_right), - onTap: () => context.go('/profile'), + return BlocBuilder( + builder: (context, switcherState) { + final subtitle = switcherState.accounts.length > 1 + ? '${switcherState.accounts.length} accounts — tap to switch' + : '@${tokens.handle}'; + + return ListTile( + leading: CircleAvatar( + child: Text((tokens.displayName ?? tokens.handle).substring(0, 1).toUpperCase()), + ), + title: Text(tokens.displayName ?? tokens.handle), + subtitle: Text(subtitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => showAccountSwitcherSheet(context), + ); + }, ); }, ), diff --git a/lib/main.dart b/lib/main.dart index 974c6a1..e8781b1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -34,6 +34,7 @@ import 'package:lazurite/features/profile/data/profile_action_repository.dart'; import 'package:lazurite/features/profile/data/profile_repository.dart'; import 'package:lazurite/features/search/bloc/search_bloc.dart'; import 'package:lazurite/features/search/data/search_repository.dart'; +import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; @@ -57,17 +58,34 @@ Future main() async { final settingsCubit = SettingsCubit(database: database); await settingsCubit.loadSettings(); + final accountSwitcherCubit = AccountSwitcherCubit(database: database, authRepository: authRepository); + await accountSwitcherCubit.loadAccounts(); + log.i('AppLogger: App started'); - runApp(LazuriteApp(authBloc: authBloc, database: database, settingsCubit: settingsCubit)); + runApp( + LazuriteApp( + authBloc: authBloc, + database: database, + settingsCubit: settingsCubit, + accountSwitcherCubit: accountSwitcherCubit, + ), + ); } class LazuriteApp extends StatefulWidget { - const LazuriteApp({super.key, required this.authBloc, required this.database, required this.settingsCubit}); + const LazuriteApp({ + super.key, + required this.authBloc, + required this.database, + required this.settingsCubit, + required this.accountSwitcherCubit, + }); final AuthBloc authBloc; final AppDatabase database; final SettingsCubit settingsCubit; + final AccountSwitcherCubit accountSwitcherCubit; @override State createState() => _LazuriteAppState(); @@ -105,6 +123,7 @@ class _LazuriteAppState extends State { providers: [ BlocProvider.value(value: widget.authBloc), BlocProvider.value(value: widget.settingsCubit), + BlocProvider.value(value: widget.accountSwitcherCubit), ], child: BlocBuilder( builder: (context, authState) { diff --git a/test/core/router/app_router_test.dart b/test/core/router/app_router_test.dart index 40d3125..f6aad00 100644 --- a/test/core/router/app_router_test.dart +++ b/test/core/router/app_router_test.dart @@ -15,6 +15,7 @@ import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; import 'package:lazurite/features/notifications/data/notification_repository.dart'; import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; +import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; import 'package:mocktail/mocktail.dart'; @@ -29,6 +30,8 @@ class MockFeedBloc extends MockBloc implements FeedBloc {} class MockSettingsCubit extends MockCubit implements SettingsCubit {} +class MockAccountSwitcherCubit extends MockCubit implements AccountSwitcherCubit {} + class MockUnreadCountCubit extends MockCubit implements UnreadCountCubit {} class MockConvoListBloc extends MockBloc implements ConvoListBloc {} @@ -41,6 +44,7 @@ void main() { late MockProfileBloc profileBloc; late MockFeedBloc feedBloc; late MockSettingsCubit settingsCubit; + late MockAccountSwitcherCubit accountSwitcherCubit; late MockUnreadCountCubit unreadCountCubit; late MockConvoListBloc convoListBloc; late MockNotificationRepository notificationRepository; @@ -71,6 +75,7 @@ void main() { profileBloc = MockProfileBloc(); feedBloc = MockFeedBloc(); settingsCubit = MockSettingsCubit(); + accountSwitcherCubit = MockAccountSwitcherCubit(); unreadCountCubit = MockUnreadCountCubit(); convoListBloc = MockConvoListBloc(); notificationRepository = MockNotificationRepository(); @@ -90,6 +95,7 @@ void main() { useSystemTheme: false, ), ); + when(() => accountSwitcherCubit.state).thenReturn(const AccountSwitcherState.ready(accounts: [])); when(() => unreadCountCubit.state).thenReturn(const UnreadCountState(0)); when(() => convoListBloc.state).thenReturn(const ConvoListState.loaded(convos: [], cursor: null, hasMore: false)); when(() => notificationRepository.getUnreadCount()).thenAnswer((_) async => 0); @@ -120,6 +126,11 @@ void main() { useSystemTheme: false, ), ); + whenListen( + accountSwitcherCubit, + const Stream.empty(), + initialState: const AccountSwitcherState.ready(accounts: []), + ); whenListen(unreadCountCubit, const Stream.empty(), initialState: const UnreadCountState(0)); whenListen( convoListBloc, @@ -139,6 +150,7 @@ void main() { BlocProvider.value(value: profileBloc), BlocProvider.value(value: feedBloc), BlocProvider.value(value: settingsCubit), + BlocProvider.value(value: accountSwitcherCubit), BlocProvider.value(value: unreadCountCubit), BlocProvider.value(value: convoListBloc), ], @@ -264,6 +276,7 @@ void main() { BlocProvider.value(value: profileBloc), BlocProvider.value(value: feedBloc), BlocProvider.value(value: settingsCubit), + BlocProvider.value(value: accountSwitcherCubit), ], child: BlocBuilder( builder: (context, state) { diff --git a/test/features/account/cubit/account_switcher_cubit_test.dart b/test/features/account/cubit/account_switcher_cubit_test.dart index ee6a37e..59f5c09 100644 --- a/test/features/account/cubit/account_switcher_cubit_test.dart +++ b/test/features/account/cubit/account_switcher_cubit_test.dart @@ -2,36 +2,54 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; +import 'package:lazurite/features/auth/data/auth_repository.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:mocktail/mocktail.dart'; class MockAppDatabase extends Mock implements AppDatabase {} +class MockAuthRepository extends Mock implements AuthRepository {} + class AccountsCompanionFake extends Fake implements AccountsCompanion {} void main() { late MockAppDatabase mockDatabase; + late MockAuthRepository mockAuthRepository; setUpAll(() { registerFallbackValue(AccountsCompanionFake()); + registerFallbackValue( + const AuthTokens(accessToken: 'token', did: 'did:plc:fallback', handle: 'fallback.bsky.social'), + ); }); setUp(() { mockDatabase = MockAppDatabase(); + mockAuthRepository = MockAuthRepository(); }); - Account makeAccount({required String did, String handle = 'user.bsky.social'}) { + AccountSwitcherCubit buildCubit() => AccountSwitcherCubit(database: mockDatabase, authRepository: mockAuthRepository); + + Account makeAccount({ + required String did, + String handle = 'user.bsky.social', + String accessToken = 'token', + String? refreshToken, + DateTime? expiresAt, + String? dpopPrivateKey, + String? dpopPublicKey, + }) { return Account( did: did, handle: handle, displayName: null, service: null, - accessToken: 'token', - refreshToken: null, - dpopPublicKey: null, - dpopPrivateKey: null, + accessToken: accessToken, + refreshToken: refreshToken, + dpopPublicKey: dpopPublicKey, + dpopPrivateKey: dpopPrivateKey, dpopNonce: null, - expiresAt: null, + expiresAt: expiresAt, createdAt: DateTime.utc(2026, 1, 1), updatedAt: DateTime.utc(2026, 1, 1), ); @@ -41,7 +59,7 @@ void main() { group('loadAccounts', () { blocTest( 'emits loading then ready with accounts when accounts exist', - build: () => AccountSwitcherCubit(database: mockDatabase), + build: buildCubit, setUp: () { final accounts = [makeAccount(did: 'did:plc:user1'), makeAccount(did: 'did:plc:user2')]; when(() => mockDatabase.getAllAccounts()).thenAnswer((_) async => accounts); @@ -61,7 +79,7 @@ void main() { blocTest( 'defaults to first account when no saved active did', - build: () => AccountSwitcherCubit(database: mockDatabase), + build: buildCubit, setUp: () { final accounts = [makeAccount(did: 'did:plc:user1'), makeAccount(did: 'did:plc:user2')]; when(() => mockDatabase.getAllAccounts()).thenAnswer((_) async => accounts); @@ -78,7 +96,7 @@ void main() { blocTest( 'defaults to first account when saved did not in accounts', - build: () => AccountSwitcherCubit(database: mockDatabase), + build: buildCubit, setUp: () { final accounts = [makeAccount(did: 'did:plc:user1')]; when(() => mockDatabase.getAllAccounts()).thenAnswer((_) async => accounts); @@ -95,7 +113,7 @@ void main() { blocTest( 'emits ready with empty accounts on failure', - build: () => AccountSwitcherCubit(database: mockDatabase), + build: buildCubit, setUp: () { when(() => mockDatabase.getAllAccounts()).thenThrow(Exception('DB error')); }, @@ -110,9 +128,107 @@ void main() { }); group('switchAccount', () { + blocTest( + 'does nothing when state is not ready', + build: buildCubit, + act: (cubit) => cubit.switchAccount('did:plc:user1'), + expect: () => [], + verify: (_) { + verifyNever(() => mockDatabase.setSetting(any(), any())); + }, + ); + + test('returns tokens for valid (non-expired) account', () async { + when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); + when(() => mockDatabase.getAccount('did:plc:user1')).thenAnswer((_) async => makeAccount(did: 'did:plc:user1')); + + final cubit = buildCubit(); + cubit.emit( + AccountSwitcherState.ready( + accounts: [makeAccount(did: 'did:plc:user1')], + activeDid: 'did:plc:user1', + ), + ); + + final tokens = await cubit.switchAccount('did:plc:user1'); + expect(tokens, isNotNull); + expect(tokens!.did, 'did:plc:user1'); + verifyNever(() => mockAuthRepository.refreshSession(any())); + }); + + test('calls refreshSession when account is expired with refresh token', () async { + final expiredAt = DateTime.now().subtract(const Duration(hours: 1)); + final refreshedTokens = AuthTokens( + accessToken: 'new-token', + did: 'did:plc:user1', + handle: 'user.bsky.social', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + ); + + when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); + when(() => mockDatabase.getAccount('did:plc:user1')).thenAnswer( + (_) async => makeAccount(did: 'did:plc:user1', expiresAt: expiredAt, refreshToken: 'refresh-token'), + ); + when(() => mockAuthRepository.refreshSession(any())).thenAnswer((_) async => refreshedTokens); + + final cubit = buildCubit(); + cubit.emit( + AccountSwitcherState.ready( + accounts: [makeAccount(did: 'did:plc:user1')], + activeDid: 'did:plc:user1', + ), + ); + + final tokens = await cubit.switchAccount('did:plc:user1'); + expect(tokens, refreshedTokens); + verify(() => mockAuthRepository.refreshSession(any())).called(1); + }); + + test('returns null when account is expired and refresh throws', () async { + final expiredAt = DateTime.now().subtract(const Duration(hours: 1)); + + when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); + when(() => mockDatabase.getAccount('did:plc:user1')).thenAnswer( + (_) async => makeAccount(did: 'did:plc:user1', expiresAt: expiredAt, refreshToken: 'refresh-token'), + ); + when(() => mockAuthRepository.refreshSession(any())).thenThrow(Exception('refresh failed')); + + final cubit = buildCubit(); + cubit.emit( + AccountSwitcherState.ready( + accounts: [makeAccount(did: 'did:plc:user1')], + activeDid: 'did:plc:user1', + ), + ); + + final tokens = await cubit.switchAccount('did:plc:user1'); + expect(tokens, isNull); + }); + + test('returns null when account is expired and has no refresh token', () async { + final expiredAt = DateTime.now().subtract(const Duration(hours: 1)); + + when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); + when( + () => mockDatabase.getAccount('did:plc:user1'), + ).thenAnswer((_) async => makeAccount(did: 'did:plc:user1', expiresAt: expiredAt)); + + final cubit = buildCubit(); + cubit.emit( + AccountSwitcherState.ready( + accounts: [makeAccount(did: 'did:plc:user1')], + activeDid: 'did:plc:user1', + ), + ); + + final tokens = await cubit.switchAccount('did:plc:user1'); + expect(tokens, isNull); + verifyNever(() => mockAuthRepository.refreshSession(any())); + }); + blocTest( 'updates activeDid when switching accounts', - build: () => AccountSwitcherCubit(database: mockDatabase), + build: buildCubit, seed: () => AccountSwitcherState.ready( accounts: [ makeAccount(did: 'did:plc:user1'), @@ -122,6 +238,9 @@ void main() { ), setUp: () { when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); + when( + () => mockDatabase.getAccount('did:plc:user2'), + ).thenAnswer((_) async => makeAccount(did: 'did:plc:user2')); }, act: (cubit) => cubit.switchAccount('did:plc:user2'), expect: () => [predicate((state) => state.activeDid == 'did:plc:user2')], @@ -129,22 +248,12 @@ void main() { verify(() => mockDatabase.setSetting('active_account_did', 'did:plc:user2')).called(1); }, ); - - blocTest( - 'does nothing when state is not ready', - build: () => AccountSwitcherCubit(database: mockDatabase), - act: (cubit) => cubit.switchAccount('did:plc:user1'), - expect: () => [], - verify: (_) { - verifyNever(() => mockDatabase.setSetting(any(), any())); - }, - ); }); group('addAccountCompleted', () { blocTest( 'inserts account, reloads, and activeDid is set to the new account', - build: () => AccountSwitcherCubit(database: mockDatabase), + build: buildCubit, setUp: () { when(() => mockDatabase.insertAccount(any())).thenAnswer((_) async => 1); when( @@ -152,6 +261,9 @@ void main() { ).thenAnswer((_) async => [makeAccount(did: 'did:plc:newuser', handle: 'new.bsky.social')]); when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => null); when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); + when( + () => mockDatabase.getAccount('did:plc:newuser'), + ).thenAnswer((_) async => makeAccount(did: 'did:plc:newuser', handle: 'new.bsky.social')); }, act: (cubit) => cubit.addAccountCompleted( const AuthTokens(accessToken: 'token', did: 'did:plc:newuser', handle: 'new.bsky.social'), @@ -173,7 +285,7 @@ void main() { blocTest( 'switches to newly added account even when another was active', - build: () => AccountSwitcherCubit(database: mockDatabase), + build: buildCubit, seed: () => AccountSwitcherState.ready( accounts: [makeAccount(did: 'did:plc:user1')], activeDid: 'did:plc:user1', @@ -188,6 +300,9 @@ void main() { ); when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => 'did:plc:user1'); when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); + when( + () => mockDatabase.getAccount('did:plc:user2'), + ).thenAnswer((_) async => makeAccount(did: 'did:plc:user2', handle: 'user2.bsky.social')); }, act: (cubit) => cubit.addAccountCompleted( const AuthTokens(accessToken: 'token', did: 'did:plc:user2', handle: 'user2.bsky.social'), @@ -204,5 +319,39 @@ void main() { ], ); }); + + group('addAccountWithOAuth', () { + test('calls addAccountCompleted and returns tokens on success', () async { + const tokens = AuthTokens(accessToken: 'new-token', did: 'did:plc:newuser', handle: 'new.bsky.social'); + + when(() => mockAuthRepository.loginWithOAuth(any())).thenAnswer((_) async => tokens); + when(() => mockDatabase.insertAccount(any())).thenAnswer((_) async => 1); + when( + () => mockDatabase.getAllAccounts(), + ).thenAnswer((_) async => [makeAccount(did: 'did:plc:newuser', handle: 'new.bsky.social')]); + when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => null); + when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); + when( + () => mockDatabase.getAccount('did:plc:newuser'), + ).thenAnswer((_) async => makeAccount(did: 'did:plc:newuser', handle: 'new.bsky.social')); + + final cubit = buildCubit(); + final result = await cubit.addAccountWithOAuth('new.bsky.social'); + + expect(result, tokens); + verify(() => mockAuthRepository.loginWithOAuth('new.bsky.social')).called(1); + verify(() => mockDatabase.insertAccount(any())).called(1); + }); + + test('returns null when loginWithOAuth throws', () async { + when(() => mockAuthRepository.loginWithOAuth(any())).thenThrow(Exception('OAuth failed')); + + final cubit = buildCubit(); + final result = await cubit.addAccountWithOAuth('bad.handle'); + + expect(result, isNull); + verifyNever(() => mockDatabase.insertAccount(any())); + }); + }); }); } diff --git a/test/features/account/presentation/account_switcher_sheet_test.dart b/test/features/account/presentation/account_switcher_sheet_test.dart new file mode 100644 index 0000000..4b8e523 --- /dev/null +++ b/test/features/account/presentation/account_switcher_sheet_test.dart @@ -0,0 +1,186 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; +import 'package:lazurite/features/account/presentation/account_switcher_sheet.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAccountSwitcherCubit extends MockCubit implements AccountSwitcherCubit {} + +class MockAuthBloc extends MockBloc implements AuthBloc {} + +void main() { + late MockAccountSwitcherCubit cubit; + late MockAuthBloc authBloc; + + const tokens = AuthTokens(accessToken: 'token', did: 'did:plc:me', handle: 'me.bsky.social'); + + setUpAll(() { + registerFallbackValue(const LogoutRequested()); + registerFallbackValue(const SessionRestored(tokens: tokens)); + }); + + setUp(() { + cubit = MockAccountSwitcherCubit(); + authBloc = MockAuthBloc(); + when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); + whenListen(authBloc, const Stream.empty(), initialState: const AuthState.authenticated(tokens)); + }); + + Account makeAccount({required String did, String handle = 'user.bsky.social', String? displayName}) { + return Account( + did: did, + handle: handle, + displayName: displayName, + service: null, + accessToken: 'token', + refreshToken: null, + dpopPublicKey: null, + dpopPrivateKey: null, + dpopNonce: null, + expiresAt: null, + createdAt: DateTime.utc(2026, 1, 1), + updatedAt: DateTime.utc(2026, 1, 1), + ); + } + + Widget buildSubject() { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: cubit), + ], + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => + TextButton(onPressed: () => showAccountSwitcherSheet(context), child: const Text('Open')), + ), + ), + ), + ); + } + + Future openSheet(WidgetTester tester) async { + await tester.pumpWidget(buildSubject()); + await tester.tap(find.text('Open')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + } + + group('AccountSwitcherSheet', () { + testWidgets('shows CircularProgressIndicator during loading state', (tester) async { + when(() => cubit.state).thenReturn(const AccountSwitcherState.loading()); + + await openSheet(tester); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('renders account rows from cubit state', (tester) async { + when(() => cubit.state).thenReturn( + AccountSwitcherState.ready( + accounts: [ + makeAccount(did: 'did:plc:user1', handle: 'alice.bsky.social', displayName: 'Alice'), + makeAccount(did: 'did:plc:user2', handle: 'bob.bsky.social'), + ], + activeDid: 'did:plc:user1', + ), + ); + + await openSheet(tester); + + expect(find.text('Alice'), findsOneWidget); + expect(find.text('@alice.bsky.social'), findsOneWidget); + expect(find.text('bob.bsky.social'), findsOneWidget); + expect(find.text('@bob.bsky.social'), findsOneWidget); + }); + + testWidgets('shows checkmark only on active account', (tester) async { + when(() => cubit.state).thenReturn( + AccountSwitcherState.ready( + accounts: [ + makeAccount(did: 'did:plc:user1', handle: 'alice.bsky.social'), + makeAccount(did: 'did:plc:user2', handle: 'bob.bsky.social'), + ], + activeDid: 'did:plc:user1', + ), + ); + + await openSheet(tester); + + expect(find.byIcon(Icons.check), findsOneWidget); + }); + + testWidgets('shows Add Account tile', (tester) async { + when(() => cubit.state).thenReturn(const AccountSwitcherState.ready(accounts: [])); + + await openSheet(tester); + + expect(find.text('Add Account'), findsOneWidget); + expect(find.byIcon(Icons.person_add_outlined), findsOneWidget); + }); + + testWidgets('tapping inactive account calls switchAccount and dispatches SessionRestored', (tester) async { + const switchedTokens = AuthTokens(accessToken: 'token2', did: 'did:plc:user2', handle: 'bob.bsky.social'); + + when(() => cubit.state).thenReturn( + AccountSwitcherState.ready( + accounts: [ + makeAccount(did: 'did:plc:user1', handle: 'alice.bsky.social'), + makeAccount(did: 'did:plc:user2', handle: 'bob.bsky.social'), + ], + activeDid: 'did:plc:user1', + ), + ); + when(() => cubit.switchAccount('did:plc:user2')).thenAnswer((_) async => switchedTokens); + + await openSheet(tester); + await tester.tap(find.text('bob.bsky.social')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + verify(() => cubit.switchAccount('did:plc:user2')).called(1); + verify(() => authBloc.add(any(that: isA()))).called(1); + }); + + testWidgets('dispatches LogoutRequested when switchAccount returns null', (tester) async { + when(() => cubit.state).thenReturn( + AccountSwitcherState.ready( + accounts: [ + makeAccount(did: 'did:plc:user1', handle: 'alice.bsky.social'), + makeAccount(did: 'did:plc:user2', handle: 'bob.bsky.social'), + ], + activeDid: 'did:plc:user1', + ), + ); + when(() => cubit.switchAccount('did:plc:user2')).thenAnswer((_) async => null); + + await openSheet(tester); + await tester.tap(find.text('bob.bsky.social')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + verify(() => authBloc.add(any(that: isA()))).called(1); + }); + + testWidgets('tapping active account does nothing', (tester) async { + when(() => cubit.state).thenReturn( + AccountSwitcherState.ready( + accounts: [makeAccount(did: 'did:plc:user1', handle: 'alice.bsky.social')], + activeDid: 'did:plc:user1', + ), + ); + + await openSheet(tester); + await tester.tap(find.text('alice.bsky.social')); + await tester.pump(); + + verifyNever(() => cubit.switchAccount(any())); + }); + }); +} diff --git a/test/features/starter_packs/presentation/starter_pack_detail_screen_test.dart b/test/features/starter_packs/presentation/starter_pack_detail_screen_test.dart index fb8db8c..3e08866 100644 --- a/test/features/starter_packs/presentation/starter_pack_detail_screen_test.dart +++ b/test/features/starter_packs/presentation/starter_pack_detail_screen_test.dart @@ -313,7 +313,6 @@ void main() { ), ).thenAnswer((_) async {}); - // Use router so context.canPop() works when state transitions to deleted. await tester.pumpWidget(buildSubjectWithRouter(currentUserDid: 'did:plc:creator')); await tester.pumpAndSettle();