From edc83e67ad1cd17bfccb53153be877821e8e8201 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Wed, 27 May 2026 14:36:47 -0500 Subject: [PATCH] feat: add active avatar URL handling in settings account switcher --- .../account/cubit/account_switcher_cubit.dart | 74 +++++-- .../account/cubit/account_switcher_state.dart | 18 +- .../settings/bloc/account_settings_cubit.dart | 14 +- .../presentation/settings_screen.dart | 207 +++++++++--------- .../account_feed_display_preferences.dart | 15 +- .../cubit/account_switcher_cubit_test.dart | 22 ++ ...account_feed_display_preferences_test.dart | 27 +++ .../presentation/settings_screen_test.dart | 43 ++++ 8 files changed, 278 insertions(+), 142 deletions(-) diff --git a/lib/features/account/cubit/account_switcher_cubit.dart b/lib/features/account/cubit/account_switcher_cubit.dart index 1bc28f7..8564ea4 100644 --- a/lib/features/account/cubit/account_switcher_cubit.dart +++ b/lib/features/account/cubit/account_switcher_cubit.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:drift/drift.dart'; import 'package:equatable/equatable.dart'; @@ -51,7 +52,13 @@ class AccountSwitcherCubit extends Cubit { activeDid = savedDid; } - emit(AccountSwitcherState.ready(accounts: accounts, activeDid: activeDid)); + emit( + AccountSwitcherState.ready( + accounts: accounts, + activeDid: activeDid, + activeAvatarUrl: await _cachedAvatarUrlForDid(activeDid), + ), + ); } catch (error) { emit(const AccountSwitcherState.ready(accounts: [])); } @@ -82,37 +89,58 @@ class AccountSwitcherCubit extends Cubit { } await _database.setSetting(AppDatabase.activeAccountDidSettingKey, did); - emit(state.copyWith(activeDid: did)); + emit(state.copyWith(activeDid: did, activeAvatarUrl: await _cachedAvatarUrlForDid(did))); return nextTokens; } catch (_) { return null; } } - AuthTokens _tokensFromAccount(Account account) { - final tokens = AuthTokens( - accessToken: account.accessToken, - refreshToken: account.refreshToken, - expiresAt: account.expiresAt, - did: account.did, - handle: account.handle, - displayName: account.displayName, - service: account.service, - oauthService: account.oauthService, - oauthClientId: account.oauthClientId, - oauthTokenType: account.oauthTokenType, - oauthScope: account.oauthScope, - dpopNonce: account.dpopNonce, - dpopPublicKey: account.dpopPublicKey, - dpopPrivateKey: account.dpopPrivateKey, - authMethod: account.dpopPrivateKey != null && account.dpopPublicKey != null - ? AuthMethod.oauth - : AuthMethod.appPassword, - ); + Future _cachedAvatarUrlForDid(String? did) async { + if (did == null) { + return null; + } - return tokens; + try { + final cachedProfile = await (_database.select( + _database.cachedProfiles, + )..where((profile) => profile.did.equals(did))).getSingleOrNull(); + final payload = cachedProfile?.payload; + if (payload == null) { + return null; + } + final decoded = jsonDecode(payload); + if (decoded is! Map) { + return null; + } + final avatar = decoded['avatar']; + return avatar is String && avatar.isNotEmpty ? avatar : null; + } catch (error, stackTrace) { + log.d('AccountSwitcherCubit: Failed to read cached avatar for $did', error: error, stackTrace: stackTrace); + return null; + } } + AuthTokens _tokensFromAccount(Account account) => AuthTokens( + accessToken: account.accessToken, + refreshToken: account.refreshToken, + expiresAt: account.expiresAt, + did: account.did, + handle: account.handle, + displayName: account.displayName, + service: account.service, + oauthService: account.oauthService, + oauthClientId: account.oauthClientId, + oauthTokenType: account.oauthTokenType, + oauthScope: account.oauthScope, + dpopNonce: account.dpopNonce, + dpopPublicKey: account.dpopPublicKey, + dpopPrivateKey: account.dpopPrivateKey, + authMethod: account.dpopPrivateKey != null && account.dpopPublicKey != null + ? AuthMethod.oauth + : AuthMethod.appPassword, + ); + Future addAccountWithOAuth(String handle) async { _lastAddAccountErrorMessage = null; try { diff --git a/lib/features/account/cubit/account_switcher_state.dart b/lib/features/account/cubit/account_switcher_state.dart index d85484f..cef6762 100644 --- a/lib/features/account/cubit/account_switcher_state.dart +++ b/lib/features/account/cubit/account_switcher_state.dart @@ -5,18 +5,24 @@ enum AccountSwitcherStatus { initial, loading, ready } const _accountSwitcherNoValue = Object(); class AccountSwitcherState extends Equatable { - const AccountSwitcherState._({required this.status, this.accounts = const [], this.activeDid}); + const AccountSwitcherState._({required this.status, this.accounts = const [], this.activeDid, this.activeAvatarUrl}); const AccountSwitcherState.initial() : this._(status: AccountSwitcherStatus.initial); const AccountSwitcherState.loading() : this._(status: AccountSwitcherStatus.loading); - const AccountSwitcherState.ready({required List accounts, String? activeDid}) - : this._(status: AccountSwitcherStatus.ready, accounts: accounts, activeDid: activeDid); + const AccountSwitcherState.ready({required List accounts, String? activeDid, String? activeAvatarUrl}) + : this._( + status: AccountSwitcherStatus.ready, + accounts: accounts, + activeDid: activeDid, + activeAvatarUrl: activeAvatarUrl, + ); final AccountSwitcherStatus status; final List accounts; final String? activeDid; + final String? activeAvatarUrl; Account? get activeAccount => accounts.where((a) => a.did == activeDid).firstOrNull; @@ -24,12 +30,16 @@ class AccountSwitcherState extends Equatable { AccountSwitcherStatus? status, List? accounts, Object? activeDid = _accountSwitcherNoValue, + Object? activeAvatarUrl = _accountSwitcherNoValue, }) => AccountSwitcherState._( status: status ?? this.status, accounts: accounts ?? this.accounts, activeDid: identical(activeDid, _accountSwitcherNoValue) ? this.activeDid : activeDid as String?, + activeAvatarUrl: identical(activeAvatarUrl, _accountSwitcherNoValue) + ? this.activeAvatarUrl + : activeAvatarUrl as String?, ); @override - List get props => [status, accounts, activeDid]; + List get props => [status, accounts, activeDid, activeAvatarUrl]; } diff --git a/lib/features/settings/bloc/account_settings_cubit.dart b/lib/features/settings/bloc/account_settings_cubit.dart index 3039714..4b677bb 100644 --- a/lib/features/settings/bloc/account_settings_cubit.dart +++ b/lib/features/settings/bloc/account_settings_cubit.dart @@ -10,7 +10,19 @@ enum AccountSettingsStatus { initial, loading, loaded, saving, error, saveError enum BlackskyAiPreferenceCategory { training, inference, syntheticContent, embedding } -enum BlackskyAiPreferenceValue { unset, allow, deny } +enum BlackskyAiPreferenceValue { + unset, + allow, + deny; + + String get label => switch (this) { + BlackskyAiPreferenceValue.unset => 'Not Set', + BlackskyAiPreferenceValue.allow => 'Allow', + BlackskyAiPreferenceValue.deny => 'Deny', + }; + + static String labelFor(BlackskyAiPreferenceValue value) => value.label; +} class BlackskyAiPreferences extends Equatable { const BlackskyAiPreferences({ diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index d58055e..f59bee8 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -87,6 +87,7 @@ class SettingsScreen extends StatelessWidget { leading: ProfileAvatar( size: 40, fallbackText: authenticatedTokens.displayName ?? authenticatedTokens.handle, + imageUrl: switcherState.activeAvatarUrl, ), title: Text(authenticatedTokens.displayName ?? authenticatedTokens.handle), subtitle: Text(subtitle), @@ -206,116 +207,112 @@ class SettingsScreen extends StatelessWidget { Widget _title(BuildContext context) => Text(context.l10n.labelSettings, style: context.textTheme.titleLarge); - Widget _buildThemeSelector(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return SettingsGroup( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Center( - child: SegmentedButton( - style: SegmentedButton.styleFrom( - selectedBackgroundColor: context.colorScheme.primary, - selectedForegroundColor: context.colorScheme.onPrimary, - ), - segments: [ - ButtonSegment(value: AppearanceMode.system, label: Text(context.l10n.labelSystem)), - ButtonSegment(value: AppearanceMode.light, label: Text(context.l10n.labelLight)), - ButtonSegment(value: AppearanceMode.dark, label: Text(context.l10n.labelDark)), - ], - selected: {AppearanceMode.fromState(state)}, - onSelectionChanged: (selected) { - final settingsCubit = context.read(); - switch (selected.first) { - case AppearanceMode.system: - settingsCubit.setUseSystemTheme(true); - case AppearanceMode.light: - settingsCubit.setUseSystemTheme(false); - settingsCubit.setThemeVariant(AppThemeVariant.light); - case AppearanceMode.dark: - settingsCubit.setUseSystemTheme(false); - settingsCubit.setThemeVariant(AppThemeVariant.dark); - } - }, - ), - ), - ), - const Divider(height: 1), - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - context.l10n.labelTheme, - style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), - ), + Widget _buildThemeSelector(BuildContext context) => BlocBuilder( + builder: (context, state) => SettingsGroup( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: SegmentedButton( + style: SegmentedButton.styleFrom( + selectedBackgroundColor: context.colorScheme.primary, + selectedForegroundColor: context.colorScheme.onPrimary, ), - ), - for (final palette in AppThemePalette.values) - ThemePaletteRow( - palette: palette, - isSelected: state.themePalette == palette, - onTap: () => context.read().setThemePalette(palette), - ), - const Divider(height: 1), - SettingsDropdownTile( - title: context.l10n.labelHeadingFont, - value: state.headingFontFamily, - options: AppHeadingFontFamily.values, - labelBuilder: (fontFamily) => fontFamily.label, - optionBuilder: _headingFontOption, - onChanged: (value) { - if (value != null) { - context.read().setHeadingFontFamily(value); - } - }, - ), - const Divider(height: 1), - SettingsDropdownTile( - title: context.l10n.labelContentFont, - value: state.contentFontFamily, - options: AppContentFontFamily.values, - labelBuilder: (fontFamily) => fontFamily.label, - optionBuilder: _contentFontOption, - onChanged: (value) { - if (value != null) { - context.read().setContentFontFamily(value); - } - }, - ), - const Divider(height: 1), - SettingsDropdownTile( - title: context.l10n.labelFontSize, - value: state.contentFontSize, - options: AppFontSize.values, - labelBuilder: (fontSize) => _fontSizeLabel(context, fontSize), - optionBuilder: _fontSizeOption, - onChanged: (value) { - if (value != null) { - context.read().setContentFontSize(value); + segments: [ + ButtonSegment(value: AppearanceMode.system, label: Text(context.l10n.labelSystem)), + ButtonSegment(value: AppearanceMode.light, label: Text(context.l10n.labelLight)), + ButtonSegment(value: AppearanceMode.dark, label: Text(context.l10n.labelDark)), + ], + selected: {AppearanceMode.fromState(state)}, + onSelectionChanged: (selected) { + final settingsCubit = context.read(); + switch (selected.first) { + case AppearanceMode.system: + settingsCubit.setUseSystemTheme(true); + case AppearanceMode.light: + settingsCubit.setUseSystemTheme(false); + settingsCubit.setThemeVariant(AppThemeVariant.light); + case AppearanceMode.dark: + settingsCubit.setUseSystemTheme(false); + settingsCubit.setThemeVariant(AppThemeVariant.dark); } }, ), - const Divider(height: 1), - SettingsDropdownTile( - title: context.l10n.labelCodeFont, - value: state.codeFontFamily, - options: AppCodeFontFamily.values, - labelBuilder: (fontFamily) => fontFamily.label, - optionBuilder: _codeFontOption, - onChanged: (value) { - if (value != null) { - context.read().setCodeFontFamily(value); - } - }, + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + context.l10n.labelTheme, + style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), ), - const SizedBox(height: 8), - ], - ); - }, - ); - } + ), + ), + for (final palette in AppThemePalette.values) + ThemePaletteRow( + palette: palette, + isSelected: state.themePalette == palette, + onTap: () => context.read().setThemePalette(palette), + ), + const Divider(height: 1), + SettingsDropdownTile( + title: context.l10n.labelHeadingFont, + value: state.headingFontFamily, + options: AppHeadingFontFamily.values, + labelBuilder: (fontFamily) => fontFamily.label, + optionBuilder: _headingFontOption, + onChanged: (value) { + if (value != null) { + context.read().setHeadingFontFamily(value); + } + }, + ), + const Divider(height: 1), + SettingsDropdownTile( + title: context.l10n.labelContentFont, + value: state.contentFontFamily, + options: AppContentFontFamily.values, + labelBuilder: (fontFamily) => fontFamily.label, + optionBuilder: _contentFontOption, + onChanged: (value) { + if (value != null) { + context.read().setContentFontFamily(value); + } + }, + ), + const Divider(height: 1), + SettingsDropdownTile( + title: context.l10n.labelFontSize, + value: state.contentFontSize, + options: AppFontSize.values, + labelBuilder: (fontSize) => _fontSizeLabel(context, fontSize), + optionBuilder: _fontSizeOption, + onChanged: (value) { + if (value != null) { + context.read().setContentFontSize(value); + } + }, + ), + const Divider(height: 1), + SettingsDropdownTile( + title: context.l10n.labelCodeFont, + value: state.codeFontFamily, + options: AppCodeFontFamily.values, + labelBuilder: (fontFamily) => fontFamily.label, + optionBuilder: _codeFontOption, + onChanged: (value) { + if (value != null) { + context.read().setCodeFontFamily(value); + } + }, + ), + const SizedBox(height: 8), + ], + ), + ); Widget _headingFontOption(BuildContext context, AppHeadingFontFamily fontFamily) => Text( fontFamily.label, diff --git a/lib/features/settings/presentation/widgets/account_feed_display_preferences.dart b/lib/features/settings/presentation/widgets/account_feed_display_preferences.dart index 21c3cfe..14e8af0 100644 --- a/lib/features/settings/presentation/widgets/account_feed_display_preferences.dart +++ b/lib/features/settings/presentation/widgets/account_feed_display_preferences.dart @@ -5,6 +5,7 @@ import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/settings/bloc/account_settings_cubit.dart'; import 'package:lazurite/features/settings/presentation/widgets/settings_section.dart'; import 'package:lazurite/features/settings/presentation/widgets/settings_tiles.dart'; +import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; class AccountFeedDisplayPreferences extends StatelessWidget { const AccountFeedDisplayPreferences({ @@ -21,7 +22,10 @@ class AccountFeedDisplayPreferences extends StatelessWidget { final bool showThreadSettings; @override - Widget build(BuildContext context) => BlocBuilder( + Widget build(BuildContext context) => BlocConsumer( + listenWhen: (previous, current) => + previous.status == AccountSettingsStatus.saving && current.status == AccountSettingsStatus.loaded, + listener: (context, state) => showAppSnackBar(context, '$providerDisplayName Settings updated.'), builder: (context, state) { final preference = state.feedViewPref; final hideReplies = preference?.hideReplies ?? false; @@ -31,7 +35,6 @@ class AccountFeedDisplayPreferences extends StatelessWidget { final hideQuotePosts = preference?.hideQuotePosts ?? false; final threadSort = state.threadViewPref?.sort; final blackskyAiPreferences = state.blackskyAiPreferences; - return ListView( controller: scrollController, padding: padding, @@ -214,15 +217,9 @@ class _BlackskyAiPreferenceTile extends StatelessWidget { subtitle: subtitle, value: value, options: BlackskyAiPreferenceValue.values, - labelBuilder: _labelFor, + labelBuilder: BlackskyAiPreferenceValue.labelFor, onChanged: enabled ? (value) => onChanged(value ?? BlackskyAiPreferenceValue.unset) : null, ); - - static String _labelFor(BlackskyAiPreferenceValue value) => switch (value) { - BlackskyAiPreferenceValue.unset => 'Not Set', - BlackskyAiPreferenceValue.allow => 'Allow', - BlackskyAiPreferenceValue.deny => 'Deny', - }; } class _ThreadSortTile extends StatelessWidget { diff --git a/test/features/account/cubit/account_switcher_cubit_test.dart b/test/features/account/cubit/account_switcher_cubit_test.dart index 323abf2..c27b346 100644 --- a/test/features/account/cubit/account_switcher_cubit_test.dart +++ b/test/features/account/cubit/account_switcher_cubit_test.dart @@ -1,4 +1,5 @@ import 'package:bloc_test/bloc_test.dart'; +import 'package:drift/native.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'; @@ -85,6 +86,27 @@ void main() { ], ); + test('loads cached avatar URL for active account', () async { + final database = AppDatabase(executor: NativeDatabase.memory()); + addTearDown(database.close); + await database.insertAccount( + AccountsCompanion.insert(did: 'did:plc:user1', handle: 'user1.bsky.social', accessToken: 'token'), + ); + await database.setSetting(AppDatabase.activeAccountDidSettingKey, 'did:plc:user1'); + await database.cacheProfile( + did: 'did:plc:user1', + handle: 'user1.bsky.social', + payload: '{"did":"did:plc:user1","handle":"user1.bsky.social","avatar":"https://cdn.example/avatar.jpg"}', + ); + + final cubit = AccountSwitcherCubit(database: database, authRepository: mockAuthRepository); + addTearDown(cubit.close); + await cubit.loadAccounts(); + + expect(cubit.state.activeDid, 'did:plc:user1'); + expect(cubit.state.activeAvatarUrl, 'https://cdn.example/avatar.jpg'); + }); + blocTest( 'keeps activeDid null when no active account is saved', build: buildCubit, diff --git a/test/features/settings/presentation/account_feed_display_preferences_test.dart b/test/features/settings/presentation/account_feed_display_preferences_test.dart index 6df017a..741ec8f 100644 --- a/test/features/settings/presentation/account_feed_display_preferences_test.dart +++ b/test/features/settings/presentation/account_feed_display_preferences_test.dart @@ -98,6 +98,33 @@ void main() { ).called(1); }); + testWidgets('shows confirmation snackbar after settings save succeeds', (tester) async { + final savingState = const AccountSettingsState.initial(feed: homeFeedPreferenceId, feedDisplayName: 'Following') + .copyWith( + status: AccountSettingsStatus.saving, + feedViewPref: const FeedViewPref(feed: homeFeedPreferenceId), + ); + final loadedState = const AccountSettingsState.initial(feed: homeFeedPreferenceId, feedDisplayName: 'Following') + .copyWith( + status: AccountSettingsStatus.loaded, + feedViewPref: const FeedViewPref(feed: homeFeedPreferenceId), + ); + when(() => cubit.state).thenReturn(loadedState); + whenListen(cubit, Stream.fromIterable([loadedState]), initialState: savingState); + + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: const MaterialApp( + home: Scaffold(body: AccountFeedDisplayPreferences(providerDisplayName: 'Bluesky')), + ), + ), + ); + await tester.pump(); + + expect(find.text('Bluesky Settings updated.'), findsOneWidget); + }); + testWidgets('can hide thread settings when used from feed management', (tester) async { await tester.pumpWidget( BlocProvider.value( diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index ea891d3..a3c50f4 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -14,6 +14,7 @@ import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; import 'package:lazurite/features/settings/presentation/settings_screen.dart'; +import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; import 'package:mocktail/mocktail.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -275,6 +276,48 @@ void main() { expect(find.text('Lazurite v1.0.0 alpha 6'), findsOneWidget); }); + testWidgets('account switcher row shows cached active account avatar', (tester) async { + final tokens = _authenticatedTokens(); + final account = Account( + did: tokens.did, + handle: tokens.handle, + displayName: 'Owais', + service: tokens.service, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + dpopPublicKey: null, + dpopPrivateKey: null, + dpopNonce: null, + expiresAt: null, + createdAt: DateTime.utc(2026, 1, 1), + updatedAt: DateTime.utc(2026, 1, 1), + ); + when(() => authBloc.state).thenReturn(AuthState.authenticated(tokens)); + whenListen(authBloc, const Stream.empty(), initialState: AuthState.authenticated(tokens)); + when(() => accountSwitcherCubit.state).thenReturn( + AccountSwitcherState.ready( + accounts: [account], + activeDid: account.did, + activeAvatarUrl: 'https://cdn.example/avatar.jpg', + ), + ); + whenListen( + accountSwitcherCubit, + const Stream.empty(), + initialState: AccountSwitcherState.ready( + accounts: [account], + activeDid: account.did, + activeAvatarUrl: 'https://cdn.example/avatar.jpg', + ), + ); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + final avatar = tester.widget(find.byType(ProfileAvatar).first); + expect(avatar.imageUrl, 'https://cdn.example/avatar.jpg'); + }); + testWidgets('shows the AT Protocol connection card for the authenticated account', (tester) async { final tokens = testAuthTokens( accessToken: buildJwt( -- 2.51.2