From 89bc8fbbe873df23d2f1028cf79c89aff41836ff Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Wed, 27 May 2026 12:04:37 -0500 Subject: [PATCH] feat: provider specific settings for feeds & AI --- lib/core/network/app_view_provider.dart | 8 +- lib/features/feed/data/feed_repository.dart | 50 +++- .../presentation/feed_management_screen.dart | 238 ++++++++--------- .../settings/bloc/account_settings_cubit.dart | 243 ++++++++++++++++-- .../presentation/settings_account_screen.dart | 29 ++- .../account_feed_display_preferences.dart | 150 ++++++++++- ..._repository_blacksky_preferences_test.dart | 110 ++++++++ .../bloc/account_settings_cubit_test.dart | 133 +++++++++- ...account_feed_display_preferences_test.dart | 113 ++++++++ 9 files changed, 898 insertions(+), 176 deletions(-) create mode 100644 test/features/feed/data/feed_repository_blacksky_preferences_test.dart create mode 100644 test/features/settings/presentation/account_feed_display_preferences_test.dart diff --git a/lib/core/network/app_view_provider.dart b/lib/core/network/app_view_provider.dart index 9d483d8..ac204b8 100644 --- a/lib/core/network/app_view_provider.dart +++ b/lib/core/network/app_view_provider.dart @@ -62,10 +62,6 @@ abstract final class AppViewProviders { return null; } - static String providerDisplayName(String providerKey) { - if (providerKey == AppViewProviders.blackskyKey) { - return 'Blacksky'; - } - return 'Bluesky'; - } + static String providerDisplayName(String providerKey) => + (providerKey == AppViewProviders.blackskyKey) ? 'BlackSky' : 'Bluesky'; } diff --git a/lib/features/feed/data/feed_repository.dart b/lib/features/feed/data/feed_repository.dart index 291c560..51cbfa5 100644 --- a/lib/features/feed/data/feed_repository.dart +++ b/lib/features/feed/data/feed_repository.dart @@ -70,6 +70,8 @@ class FeedRepository { static const String timelineCacheKey = 'timeline'; static const String homeFeedPreferenceId = 'home'; + static const String blackskyAiPreferenceCollection = 'community.lexicon.preference.ai'; + static const String blackskyAiPreferenceRkey = 'self'; static const int _minTrendingLimit = 1; static const int _maxTrendingLimit = 25; @@ -203,6 +205,36 @@ class FeedRepository { _preferencesCache = preferences; } + Future?> getBlackskyAiPreferenceRecord() async { + try { + final response = await _authRecovery.run( + (client) => client.atproto.repo.getRecord( + repo: _accountDid, + collection: blackskyAiPreferenceCollection, + rkey: blackskyAiPreferenceRkey, + ), + ); + return response.data.value; + } on XRPCException catch (error) { + final errorCode = error.response.data.error; + if (errorCode == 'RecordNotFound' || errorCode == 'NotFound') { + return null; + } + rethrow; + } + } + + Future putBlackskyAiPreferenceRecord({required Map record}) async { + await _authRecovery.run( + (client) => client.atproto.repo.putRecord( + repo: _accountDid, + collection: blackskyAiPreferenceCollection, + rkey: blackskyAiPreferenceRkey, + record: record, + ), + ); + } + Future> getSuggestedFeeds({String? cursor, int limit = 50}) async { final headers = _appViewContext.appBskyHeadersForEndpoint( 'app.bsky.feed.getSuggestedFeeds', @@ -593,18 +625,12 @@ enum FeedFilter { postsWithMedia, postsAndAuthorThreads; - String get emptyLabel { - switch (this) { - case FeedFilter.postsWithReplies: - return 'No posts or replies yet'; - case FeedFilter.postsNoReplies: - return 'No posts yet'; - case FeedFilter.postsAndAuthorThreads: - return 'No replies or threads yet'; - case FeedFilter.postsWithMedia: - return 'No media posts yet'; - } - } + String get emptyLabel => switch (this) { + FeedFilter.postsWithReplies => 'No posts or replies yet', + FeedFilter.postsNoReplies => 'No posts yet', + FeedFilter.postsAndAuthorThreads => 'No replies or threads yet', + FeedFilter.postsWithMedia => 'No media posts yet', + }; FeedGetAuthorFeedFilter get bskyFilter { switch (this) { diff --git a/lib/features/feed/presentation/feed_management_screen.dart b/lib/features/feed/presentation/feed_management_screen.dart index e4829ac..7056029 100644 --- a/lib/features/feed/presentation/feed_management_screen.dart +++ b/lib/features/feed/presentation/feed_management_screen.dart @@ -58,93 +58,87 @@ class _FeedManagementScreenState extends State { } @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('My Feeds'), - actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Done'))], - ), - body: BlocConsumer( - listenWhen: (previous, current) => - current.status == FeedPreferencesStatus.saveError || - (current.status == FeedPreferencesStatus.loaded && - current.message != null && - current.message != previous.message), - listener: (context, state) { - if (state.status == FeedPreferencesStatus.saveError) { - showAppSnackBar( - context, - 'Failed to sync: ${state.message}', - actionLabel: 'Dismiss', - onAction: () => context.read().clearError(), - ); - } else if (state.status == FeedPreferencesStatus.loaded && state.message != null) { - showAppSnackBar(context, state.message!); - } - }, - builder: (context, state) { - if (state.status == FeedPreferencesStatus.loading) { - return const LoadingState(); - } - - return ListView( - children: [ - _buildSectionHeader( - context, - 'Pinned Feeds', - showReorder: state.pinnedFeeds.length > 1 && !_isReordering, - isReordering: _isReordering, - onAction: () => setState(() => _isReordering = !_isReordering), - ), - if (_isReordering && state.pinnedFeeds.length > 1) - _buildReorderablePinnedFeeds(context, state) - else - ...state.pinnedFeeds.map((feed) => _buildPinnedFeedItem(context, feed, state)), - const SizedBox(height: 16), - _buildSectionHeader(context, 'Saved Feeds'), - if (state.unpinnedFeeds.isEmpty) - const EmptyState(message: 'No saved feeds', icon: Icons.bookmark_border, padding: EdgeInsets.all(16)) - else - ...state.unpinnedFeeds.map((feed) => _buildSavedFeedItem(context, feed)), - const SizedBox(height: 16), - _buildSectionHeader(context, 'Discover Feeds', actionText: 'Refresh', onAction: _loadSuggestedFeeds), - _buildDiscoverSection(context), - const SizedBox(height: 24), - ], + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('My Feeds'), + actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Done'))], + ), + body: BlocConsumer( + listenWhen: (previous, current) => + current.status == FeedPreferencesStatus.saveError || + (current.status == FeedPreferencesStatus.loaded && + current.message != null && + current.message != previous.message), + listener: (context, state) { + if (state.status == FeedPreferencesStatus.saveError) { + showAppSnackBar( + context, + 'Failed to sync: ${state.message}', + actionLabel: 'Dismiss', + onAction: () => context.read().clearError(), ); - }, - ), - ); - } - - Widget _buildReorderablePinnedFeeds(BuildContext context, FeedPreferencesState state) { - final pinnedFeeds = state.pinnedFeeds; - - return ReorderableListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - buildDefaultDragHandles: false, - itemCount: pinnedFeeds.length, - onReorderItem: (oldIndex, newIndex) { - context.read().reorderPinnedFeeds(oldIndex, newIndex); + } else if (state.status == FeedPreferencesStatus.loaded && state.message != null) { + showAppSnackBar(context, state.message!); + } }, - itemBuilder: (context, index) { - final feed = pinnedFeeds[index]; - final isTimeline = state.isTimeline(feed); - final generator = state.generatorFor(feed); + builder: (context, state) { + if (state.status == FeedPreferencesStatus.loading) { + return const LoadingState(); + } - return ListTile( - key: ValueKey(feed.id), - leading: isTimeline - ? _buildTimelineIcon(context) - : (generator != null ? _buildGeneratorIcon(context, generator) : _buildFeedIcon(context, feed.value)), - title: Text(state.displayNameFor(feed)), - subtitle: Text(state.subtitleFor(feed)), - trailing: ReorderableDragStartListener(index: index, child: const Icon(Icons.drag_handle)), + return ListView( + children: [ + _buildSectionHeader( + context, + 'Pinned Feeds', + showReorder: state.pinnedFeeds.length > 1 && !_isReordering, + isReordering: _isReordering, + onAction: () => setState(() => _isReordering = !_isReordering), + ), + if (_isReordering && state.pinnedFeeds.length > 1) + _buildReorderablePinnedFeeds(context, state) + else + ...state.pinnedFeeds.map((feed) => _buildPinnedFeedItem(context, feed, state)), + const SizedBox(height: 16), + _buildSectionHeader(context, 'Saved Feeds'), + if (state.unpinnedFeeds.isEmpty) + const EmptyState(message: 'No saved feeds', icon: Icons.bookmark_border, padding: EdgeInsets.all(16)) + else + ...state.unpinnedFeeds.map((feed) => _buildSavedFeedItem(context, feed)), + const SizedBox(height: 16), + _buildSectionHeader(context, 'Discover Feeds', actionText: 'Refresh', onAction: _loadSuggestedFeeds), + _buildDiscoverSection(context), + const SizedBox(height: 24), + ], ); }, - ); - } + ), + ); + + Widget _buildReorderablePinnedFeeds(BuildContext context, FeedPreferencesState state) => ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + itemCount: state.pinnedFeeds.length, + onReorderItem: (oldIndex, newIndex) { + context.read().reorderPinnedFeeds(oldIndex, newIndex); + }, + itemBuilder: (context, index) { + final feed = state.pinnedFeeds[index]; + final isTimeline = state.isTimeline(feed); + final generator = state.generatorFor(feed); + + return ListTile( + key: ValueKey(feed.id), + leading: isTimeline + ? _buildTimelineIcon(context) + : (generator != null ? _buildGeneratorIcon(context, generator) : _buildFeedIcon(context, feed.value)), + title: Text(state.displayNameFor(feed)), + subtitle: Text(state.subtitleFor(feed)), + trailing: ReorderableDragStartListener(index: index, child: const Icon(Icons.drag_handle)), + ); + }, + ); Widget _buildSectionHeader( BuildContext context, @@ -153,22 +147,20 @@ class _FeedManagementScreenState extends State { VoidCallback? onAction, bool showReorder = false, bool isReordering = false, - }) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row( - children: [ - Text( - title.toUpperCase(), - style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), - ), - const Spacer(), - if (showReorder) TextButton(onPressed: onAction, child: Text(isReordering ? 'Done' : 'Reorder')), - if (actionText != null && !showReorder) TextButton(onPressed: onAction, child: Text(actionText)), - ], - ), - ); - } + }) => Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Text( + title.toUpperCase(), + style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), + ), + const Spacer(), + if (showReorder) TextButton(onPressed: onAction, child: Text(isReordering ? 'Done' : 'Reorder')), + if (actionText != null && !showReorder) TextButton(onPressed: onAction, child: Text(actionText)), + ], + ), + ); Widget _buildPinnedFeedItem(BuildContext context, SavedFeed feed, FeedPreferencesState state) { final isTimeline = state.isTimeline(feed); @@ -202,13 +194,11 @@ class _FeedManagementScreenState extends State { Widget _buildSavedFeedItem(BuildContext context, SavedFeed feed) { final state = context.watch().state; - final description = state.descriptionFor(feed); final generator = state.generatorFor(feed); - return ListTile( leading: generator != null ? _buildGeneratorIcon(context, generator) : _buildFeedIcon(context, feed.value), title: Text(state.displayNameFor(feed)), - subtitle: Text(description ?? state.subtitleFor(feed)), + subtitle: Text(state.descriptionFor(feed) ?? state.subtitleFor(feed)), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -230,8 +220,6 @@ class _FeedManagementScreenState extends State { Future _showFeedDisplaySettings(BuildContext context, SavedFeed feed, FeedPreferencesState state) { final feedRepository = context.read(); final feedPreferenceId = state.isTimeline(feed) ? homeFeedPreferenceId : feed.value; - final feedDisplayName = state.displayNameFor(feed); - return showModalBottomSheet( context: context, isScrollControlled: true, @@ -240,7 +228,7 @@ class _FeedManagementScreenState extends State { create: (_) => AccountSettingsCubit( feedRepository: feedRepository, feed: feedPreferenceId, - feedDisplayName: feedDisplayName, + feedDisplayName: state.displayNameFor(feed), )..loadPreferences(), child: DraggableScrollableSheet( expand: false, @@ -267,6 +255,7 @@ class _FeedManagementScreenState extends State { child: AccountFeedDisplayPreferences( padding: const EdgeInsets.only(bottom: 24), scrollController: scrollController, + showThreadSettings: false, ), ), ], @@ -295,7 +284,6 @@ class _FeedManagementScreenState extends State { Widget _buildDiscoverCard(BuildContext context, GeneratorView feed) { final prefsState = context.watch().state; final isAdded = prefsState.containsFeedValue(feed.uri.toString()); - return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: Padding( @@ -352,17 +340,15 @@ class _FeedManagementScreenState extends State { ); } - Widget _buildTimelineIcon(BuildContext context) { - return Container( - width: 40, - height: 40, - decoration: BoxDecoration( - gradient: const LinearGradient(colors: [Color(0xFF0066FF), Color(0xFF0EA5E9)]), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon(Icons.people, color: Colors.white), - ); - } + Widget _buildTimelineIcon(BuildContext context) => Container( + width: 40, + height: 40, + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [Color(0xFF0066FF), Color(0xFF0EA5E9)]), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.people, color: Colors.white), + ); Widget _buildFeedIcon(BuildContext context, String feedUri) { final gradients = [ @@ -371,8 +357,8 @@ class _FeedManagementScreenState extends State { const [Color(0xFF22C55E), Color(0xFF42BE65)], const [Color(0xFFEE5396), Color(0xFFFF7EB6)], ]; - final index = feedUri.hashCode % gradients.length; + final index = feedUri.hashCode % gradients.length; return Container( width: 40, height: 40, @@ -407,14 +393,12 @@ class _FeedManagementScreenState extends State { ); } - Future _confirmRemoveFeed(BuildContext context, String feedId) async { - await showConfirmationDialog( - context: context, - title: const Text('Remove Feed'), - content: const Text('Are you sure you want to remove this feed from your saved feeds?'), - confirmLabel: 'Remove', - confirmDestructive: true, - onConfirmed: () => context.read().removeFeed(feedId), - ); - } + Future _confirmRemoveFeed(BuildContext context, String feedId) async => showConfirmationDialog( + context: context, + title: const Text('Remove Feed'), + content: const Text('Are you sure you want to remove this feed from your saved feeds?'), + confirmLabel: 'Remove', + confirmDestructive: true, + onConfirmed: () => context.read().removeFeed(feedId), + ); } diff --git a/lib/features/settings/bloc/account_settings_cubit.dart b/lib/features/settings/bloc/account_settings_cubit.dart index 65b95f3..3039714 100644 --- a/lib/features/settings/bloc/account_settings_cubit.dart +++ b/lib/features/settings/bloc/account_settings_cubit.dart @@ -8,22 +8,126 @@ const String homeFeedPreferenceId = 'home'; enum AccountSettingsStatus { initial, loading, loaded, saving, error, saveError } +enum BlackskyAiPreferenceCategory { training, inference, syntheticContent, embedding } + +enum BlackskyAiPreferenceValue { unset, allow, deny } + +class BlackskyAiPreferences extends Equatable { + const BlackskyAiPreferences({ + this.training = BlackskyAiPreferenceValue.unset, + this.inference = BlackskyAiPreferenceValue.unset, + this.syntheticContent = BlackskyAiPreferenceValue.unset, + this.embedding = BlackskyAiPreferenceValue.unset, + }); + + factory BlackskyAiPreferences.fromRecord(Map? record) { + final preferences = record?['preferences']; + final preferenceMap = preferences is Map ? preferences : const {}; + return BlackskyAiPreferences( + training: _valueFromField(preferenceMap['training']), + inference: _valueFromField(preferenceMap['inference']), + syntheticContent: _valueFromField(preferenceMap['syntheticContent']), + embedding: _valueFromField(preferenceMap['embedding']), + ); + } + + final BlackskyAiPreferenceValue training; + final BlackskyAiPreferenceValue inference; + final BlackskyAiPreferenceValue syntheticContent; + final BlackskyAiPreferenceValue embedding; + + BlackskyAiPreferences copyWithCategory(BlackskyAiPreferenceCategory category, BlackskyAiPreferenceValue value) { + return switch (category) { + BlackskyAiPreferenceCategory.training => copyWith(training: value), + BlackskyAiPreferenceCategory.inference => copyWith(inference: value), + BlackskyAiPreferenceCategory.syntheticContent => copyWith(syntheticContent: value), + BlackskyAiPreferenceCategory.embedding => copyWith(embedding: value), + }; + } + + BlackskyAiPreferences copyWith({ + BlackskyAiPreferenceValue? training, + BlackskyAiPreferenceValue? inference, + BlackskyAiPreferenceValue? syntheticContent, + BlackskyAiPreferenceValue? embedding, + }) { + return BlackskyAiPreferences( + training: training ?? this.training, + inference: inference ?? this.inference, + syntheticContent: syntheticContent ?? this.syntheticContent, + embedding: embedding ?? this.embedding, + ); + } + + Map toRecord({required DateTime updatedAt}) { + final timestamp = updatedAt.toUtc().toIso8601String(); + final preferences = {}; + _writeField(preferences, 'training', training, timestamp); + _writeField(preferences, 'inference', inference, timestamp); + _writeField(preferences, 'syntheticContent', syntheticContent, timestamp); + _writeField(preferences, 'embedding', embedding, timestamp); + + return { + r'$type': FeedRepository.blackskyAiPreferenceCollection, + 'updatedAt': timestamp, + 'scope': {r'$type': '${FeedRepository.blackskyAiPreferenceCollection}#globalScope'}, + 'preferences': preferences, + }; + } + + static BlackskyAiPreferenceValue _valueFromField(Object? field) { + if (field is! Map) { + return BlackskyAiPreferenceValue.unset; + } + return field['allow'] == true ? BlackskyAiPreferenceValue.allow : BlackskyAiPreferenceValue.deny; + } + + static void _writeField(Map target, String key, BlackskyAiPreferenceValue value, String timestamp) { + switch (value) { + case BlackskyAiPreferenceValue.unset: + return; + case BlackskyAiPreferenceValue.allow: + target[key] = {'allow': true, 'updatedAt': timestamp}; + case BlackskyAiPreferenceValue.deny: + target[key] = {'allow': false, 'updatedAt': timestamp}; + } + } + + @override + List get props => [training, inference, syntheticContent, embedding]; +} + class AccountSettingsState extends Equatable { const AccountSettingsState._({ required this.status, required this.feed, required this.feedDisplayName, + required this.supportsBlackskyAiPreferences, this.feedViewPref, + this.threadViewPref, + this.blackskyAiPreferences, this.message, }); - const AccountSettingsState.initial({required String feed, required String feedDisplayName}) - : this._(status: AccountSettingsStatus.initial, feed: feed, feedDisplayName: feedDisplayName); + const AccountSettingsState.initial({ + required String feed, + required String feedDisplayName, + bool supportsBlackskyAiPreferences = false, + }) : this._( + status: AccountSettingsStatus.initial, + feed: feed, + feedDisplayName: feedDisplayName, + supportsBlackskyAiPreferences: supportsBlackskyAiPreferences, + threadViewPref: null, + ); final AccountSettingsStatus status; final String feed; final String feedDisplayName; + final bool supportsBlackskyAiPreferences; final FeedViewPref? feedViewPref; + final ThreadViewPref? threadViewPref; + final BlackskyAiPreferences? blackskyAiPreferences; final String? message; bool get isBusy => status == AccountSettingsStatus.loading || status == AccountSettingsStatus.saving; @@ -31,28 +135,53 @@ class AccountSettingsState extends Equatable { AccountSettingsState copyWith({ AccountSettingsStatus? status, FeedViewPref? feedViewPref, + ThreadViewPref? threadViewPref, + BlackskyAiPreferences? blackskyAiPreferences, String? message, bool clearMessage = false, - }) { - return AccountSettingsState._( - status: status ?? this.status, - feed: feed, - feedDisplayName: feedDisplayName, - feedViewPref: feedViewPref ?? this.feedViewPref, - message: clearMessage ? null : message ?? this.message, - ); - } + }) => AccountSettingsState._( + status: status ?? this.status, + feed: feed, + feedDisplayName: feedDisplayName, + supportsBlackskyAiPreferences: supportsBlackskyAiPreferences, + feedViewPref: feedViewPref ?? this.feedViewPref, + threadViewPref: threadViewPref ?? this.threadViewPref, + blackskyAiPreferences: blackskyAiPreferences ?? this.blackskyAiPreferences, + message: clearMessage ? null : message ?? this.message, + ); @override - List get props => [status, feed, feedDisplayName, feedViewPref, message]; + List get props => [ + status, + feed, + feedDisplayName, + supportsBlackskyAiPreferences, + feedViewPref, + threadViewPref, + blackskyAiPreferences, + message, + ]; } class AccountSettingsCubit extends Cubit { - AccountSettingsCubit({required FeedRepository feedRepository, required String feed, required String feedDisplayName}) - : _feedRepository = feedRepository, - super(AccountSettingsState.initial(feed: feed, feedDisplayName: feedDisplayName)); + AccountSettingsCubit({ + required FeedRepository feedRepository, + required String feed, + required String feedDisplayName, + bool supportsBlackskyAiPreferences = false, + DateTime Function()? clock, + }) : _feedRepository = feedRepository, + _clock = clock ?? DateTime.now, + super( + AccountSettingsState.initial( + feed: feed, + feedDisplayName: feedDisplayName, + supportsBlackskyAiPreferences: supportsBlackskyAiPreferences, + ), + ); final FeedRepository _feedRepository; + final DateTime Function() _clock; Future loadPreferences() async { _safeEmit(state.copyWith(status: AccountSettingsStatus.loading, clearMessage: true)); @@ -60,10 +189,22 @@ class AccountSettingsCubit extends Cubit { try { final result = await _feedRepository.getPreferences(); final feedViewPref = _feedViewPrefFrom(result.preferences) ?? FeedViewPref(feed: state.feed); - _safeEmit(state.copyWith(status: AccountSettingsStatus.loaded, feedViewPref: feedViewPref, clearMessage: true)); + final threadViewPref = _threadViewPrefFrom(result.preferences) ?? const ThreadViewPref(); + final blackskyAiPreferences = state.supportsBlackskyAiPreferences + ? BlackskyAiPreferences.fromRecord(await _feedRepository.getBlackskyAiPreferenceRecord()) + : null; + _safeEmit( + state.copyWith( + status: AccountSettingsStatus.loaded, + feedViewPref: feedViewPref, + threadViewPref: threadViewPref, + blackskyAiPreferences: blackskyAiPreferences, + clearMessage: true, + ), + ); } catch (error, stackTrace) { log.e( - 'AccountSettingsCubit: Failed to load feed display preferences for ${state.feed}', + 'AccountSettingsCubit: Failed to load account display preferences for ${state.feed}', error: error, stackTrace: stackTrace, ); @@ -71,6 +212,10 @@ class AccountSettingsCubit extends Cubit { state.copyWith( status: AccountSettingsStatus.error, feedViewPref: state.feedViewPref ?? FeedViewPref(feed: state.feed), + threadViewPref: state.threadViewPref ?? const ThreadViewPref(), + blackskyAiPreferences: + state.blackskyAiPreferences ?? + (state.supportsBlackskyAiPreferences ? const BlackskyAiPreferences() : null), message: error.toString(), ), ); @@ -89,6 +234,34 @@ class AccountSettingsCubit extends Cubit { Future setHideQuotePosts(bool value) => _updatePreference((pref) => pref.copyWith(hideQuotePosts: value)); + Future setThreadSort(ThreadViewPrefSort? value) => + _updateThreadPreference((pref) => pref.copyWith(sort: value)); + + Future setBlackskyAiPreference(BlackskyAiPreferenceCategory category, BlackskyAiPreferenceValue value) async { + if (!state.supportsBlackskyAiPreferences) { + return; + } + final current = state.blackskyAiPreferences ?? const BlackskyAiPreferences(); + final updated = current.copyWithCategory(category, value); + _safeEmit(state.copyWith(status: AccountSettingsStatus.saving, blackskyAiPreferences: updated, clearMessage: true)); + + try { + await _feedRepository.putBlackskyAiPreferenceRecord(record: updated.toRecord(updatedAt: _clock())); + _safeEmit( + state.copyWith(status: AccountSettingsStatus.loaded, blackskyAiPreferences: updated, clearMessage: true), + ); + } catch (error, stackTrace) { + log.e('AccountSettingsCubit: Failed to save BlackSky AI preferences', error: error, stackTrace: stackTrace); + _safeEmit( + state.copyWith( + status: AccountSettingsStatus.saveError, + blackskyAiPreferences: updated, + message: error.toString(), + ), + ); + } + } + Future _updatePreference(FeedViewPref Function(FeedViewPref current) update) async { final current = state.feedViewPref ?? FeedViewPref(feed: state.feed); final updated = update(current); @@ -111,6 +284,24 @@ class AccountSettingsCubit extends Cubit { } } + Future _updateThreadPreference(ThreadViewPref Function(ThreadViewPref current) update) async { + final current = state.threadViewPref ?? const ThreadViewPref(); + final updated = update(current); + _safeEmit(state.copyWith(status: AccountSettingsStatus.saving, threadViewPref: updated, clearMessage: true)); + + try { + final result = await _feedRepository.getPreferences(); + final preferences = _replaceThreadViewPref(result.preferences, updated); + await _feedRepository.putPreferences(preferences: preferences); + _safeEmit(state.copyWith(status: AccountSettingsStatus.loaded, threadViewPref: updated, clearMessage: true)); + } catch (error, stackTrace) { + log.e('AccountSettingsCubit: Failed to save thread preferences', error: error, stackTrace: stackTrace); + _safeEmit( + state.copyWith(status: AccountSettingsStatus.saveError, threadViewPref: updated, message: error.toString()), + ); + } + } + FeedViewPref? _feedViewPrefFrom(List preferences) { for (final preference in preferences) { final feedViewPref = preference.feedViewPref; @@ -121,6 +312,16 @@ class AccountSettingsCubit extends Cubit { return null; } + ThreadViewPref? _threadViewPrefFrom(List preferences) { + for (final preference in preferences) { + final threadViewPref = preference.threadViewPref; + if (threadViewPref != null) { + return threadViewPref; + } + } + return null; + } + List _replaceFeedViewPref(List preferences, FeedViewPref updated) { return [ for (final preference in preferences) @@ -129,6 +330,14 @@ class AccountSettingsCubit extends Cubit { ]; } + List _replaceThreadViewPref(List preferences, ThreadViewPref updated) { + return [ + for (final preference in preferences) + if (preference.threadViewPref == null) preference, + UPreferences.threadViewPref(data: updated), + ]; + } + void _safeEmit(AccountSettingsState nextState) { if (isClosed) { return; diff --git a/lib/features/settings/presentation/settings_account_screen.dart b/lib/features/settings/presentation/settings_account_screen.dart index 54b5097..413d1c7 100644 --- a/lib/features/settings/presentation/settings_account_screen.dart +++ b/lib/features/settings/presentation/settings_account_screen.dart @@ -1,22 +1,29 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/features/feed/data/feed_repository.dart'; import 'package:lazurite/features/settings/bloc/account_settings_cubit.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/presentation/widgets/account_feed_display_preferences.dart'; class SettingsAccountScreen extends StatelessWidget { const SettingsAccountScreen({super.key}); @override - Widget build(BuildContext context) => BlocProvider( - create: (context) => AccountSettingsCubit( - feedRepository: context.read(), - feed: homeFeedPreferenceId, - feedDisplayName: 'Following', - )..loadPreferences(), - child: Scaffold( - appBar: AppBar(title: const Text('Account settings')), - body: const AccountFeedDisplayPreferences(), - ), - ); + Widget build(BuildContext context) { + final providerKey = context.watch().state.appViewProvider; + final providerName = AppViewProviders.providerDisplayName(providerKey); + return BlocProvider( + create: (context) => AccountSettingsCubit( + feedRepository: context.read(), + feed: homeFeedPreferenceId, + feedDisplayName: 'Following', + supportsBlackskyAiPreferences: providerKey == AppViewProviders.blackskyKey, + )..loadPreferences(), + child: Scaffold( + appBar: AppBar(title: Text('$providerName settings')), + body: AccountFeedDisplayPreferences(providerDisplayName: providerName), + ), + ); + } } 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 95d5b1c..26675f4 100644 --- a/lib/features/settings/presentation/widgets/account_feed_display_preferences.dart +++ b/lib/features/settings/presentation/widgets/account_feed_display_preferences.dart @@ -1,3 +1,4 @@ +import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; @@ -10,10 +11,14 @@ class AccountFeedDisplayPreferences extends StatelessWidget { super.key, this.padding = const EdgeInsets.only(bottom: 24), this.scrollController, + this.providerDisplayName = 'Bluesky', + this.showThreadSettings = true, }); final EdgeInsetsGeometry padding; final ScrollController? scrollController; + final String providerDisplayName; + final bool showThreadSettings; @override Widget build(BuildContext context) => BlocBuilder( @@ -24,6 +29,8 @@ class AccountFeedDisplayPreferences extends StatelessWidget { final likeThreshold = preference?.hideRepliesByLikeCount; final hideReposts = preference?.hideReposts ?? false; final hideQuotePosts = preference?.hideQuotePosts ?? false; + final threadSort = state.threadViewPref?.sort; + final blackskyAiPreferences = state.blackskyAiPreferences; return ListView( controller: scrollController, @@ -34,11 +41,18 @@ class AccountFeedDisplayPreferences extends StatelessWidget { Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), child: Text( - state.feedDisplayName, + '$providerDisplayName settings', style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), ), ), - const SettingsSectionHeader('Feed display'), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Text( + 'Synced with $providerDisplayName via app.bsky.actor.getPreferences and putPreferences.', + style: context.textTheme.bodySmall, + ), + ), + SettingsSectionHeader('${state.feedDisplayName} feed display'), SettingsGroup( children: [ SettingsTile( @@ -96,6 +110,70 @@ class AccountFeedDisplayPreferences extends StatelessWidget { ), ], ), + if (showThreadSettings) ...[ + const SizedBox(height: 24), + const SettingsSectionHeader('Thread display'), + SettingsGroup( + children: [ + _ThreadSortTile( + value: threadSort, + enabled: !state.isBusy, + onChanged: (value) => context.read().setThreadSort(value), + ), + ], + ), + ], + if (blackskyAiPreferences != null) ...[ + const SizedBox(height: 24), + const SettingsSectionHeader('BlackSky AI preferences'), + SettingsGroup( + children: [ + _BlackskyAiPreferenceTile( + title: 'Training', + subtitle: 'Preference for AI model training uses.', + value: blackskyAiPreferences.training, + enabled: !state.isBusy, + onChanged: (value) => context.read().setBlackskyAiPreference( + BlackskyAiPreferenceCategory.training, + value, + ), + ), + const Divider(height: 1), + _BlackskyAiPreferenceTile( + title: 'Inference', + subtitle: 'Preference for AI inference uses.', + value: blackskyAiPreferences.inference, + enabled: !state.isBusy, + onChanged: (value) => context.read().setBlackskyAiPreference( + BlackskyAiPreferenceCategory.inference, + value, + ), + ), + const Divider(height: 1), + _BlackskyAiPreferenceTile( + title: 'Synthetic content', + subtitle: 'Preference for synthetic content generation uses.', + value: blackskyAiPreferences.syntheticContent, + enabled: !state.isBusy, + onChanged: (value) => context.read().setBlackskyAiPreference( + BlackskyAiPreferenceCategory.syntheticContent, + value, + ), + ), + const Divider(height: 1), + _BlackskyAiPreferenceTile( + title: 'Embedding', + subtitle: 'Preference for embedding/vectorization uses.', + value: blackskyAiPreferences.embedding, + enabled: !state.isBusy, + onChanged: (value) => context.read().setBlackskyAiPreference( + BlackskyAiPreferenceCategory.embedding, + value, + ), + ), + ], + ), + ], if (state.status == AccountSettingsStatus.saving) Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), @@ -115,6 +193,74 @@ class AccountFeedDisplayPreferences extends StatelessWidget { ); } +class _BlackskyAiPreferenceTile extends StatelessWidget { + const _BlackskyAiPreferenceTile({ + required this.title, + required this.subtitle, + required this.value, + required this.enabled, + required this.onChanged, + }); + + final String title; + final String subtitle; + final BlackskyAiPreferenceValue value; + final bool enabled; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) => SettingsDropdownTile( + title: title, + subtitle: subtitle, + value: value, + options: BlackskyAiPreferenceValue.values, + labelBuilder: _labelFor, + onChanged: enabled ? (value) => onChanged(value ?? BlackskyAiPreferenceValue.unset) : null, + ); + + static String _labelFor(BlackskyAiPreferenceValue value) => switch (value) { + BlackskyAiPreferenceValue.unset => 'Unset', + BlackskyAiPreferenceValue.allow => 'Allow', + BlackskyAiPreferenceValue.deny => 'Deny', + }; +} + +class _ThreadSortTile extends StatelessWidget { + const _ThreadSortTile({required this.value, required this.enabled, required this.onChanged}); + + static const String _defaultSort = 'default'; + + /// TODO: this could be an enum + static const List _options = [_defaultSort, 'oldest', 'newest', 'most-likes', 'random', 'hotness']; + + final ThreadViewPrefSort? value; + final bool enabled; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) => SettingsDropdownTile( + title: 'Thread reply sort', + subtitle: 'Choose the default order for replies in post threads.', + value: _sortValue(value), + options: _options, + labelBuilder: _labelFor, + onChanged: enabled ? (sort) => onChanged(sort == _defaultSort ? null : ThreadViewPrefSort.valueOf(sort)) : null, + ); + + static String _sortValue(ThreadViewPrefSort? sort) => sort?.toJson() ?? _defaultSort; + + /// TODO: this could be a getter on the enum made from [_options] + static String _labelFor(String sort) => switch (sort) { + _defaultSort => 'Default', + 'oldest' => 'Oldest first', + 'newest' => 'Newest first', + 'most-likes' => 'Most likes', + 'random' => 'Random', + 'hotness' => 'Hotness', + _ => sort, + }; +} + class _ReplyLikeThresholdTile extends StatelessWidget { const _ReplyLikeThresholdTile({required this.value, required this.enabled, required this.onChanged}); diff --git a/test/features/feed/data/feed_repository_blacksky_preferences_test.dart b/test/features/feed/data/feed_repository_blacksky_preferences_test.dart new file mode 100644 index 0000000..2740f33 --- /dev/null +++ b/test/features/feed/data/feed_repository_blacksky_preferences_test.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/features/feed/data/feed_repository.dart'; + +import '../../../helpers/test_bluesky_client.dart'; + +void main() { + late AppDatabase database; + + setUp(() { + database = AppDatabase(executor: NativeDatabase.memory()); + }); + + tearDown(() async { + await database.close(); + }); + + test('getBlackskyAiPreferenceRecord reads community lexicon preference record', () async { + final transport = _BlackskyAiPreferenceTransport( + getResponse: { + 'uri': 'at://did:plc:test/${FeedRepository.blackskyAiPreferenceCollection}/self', + 'value': { + r'$type': FeedRepository.blackskyAiPreferenceCollection, + 'preferences': { + 'training': {'allow': false, 'updatedAt': '2026-01-01T00:00:00.000Z'}, + }, + }, + }, + ); + final repository = FeedRepository( + bluesky: testBluesky(getClient: transport.get), + database: database, + accountDid: 'did:plc:test', + ); + + final record = await repository.getBlackskyAiPreferenceRecord(); + + expect(record?[r'$type'], FeedRepository.blackskyAiPreferenceCollection); + expect(record?['preferences'], { + 'training': {'allow': false, 'updatedAt': '2026-01-01T00:00:00.000Z'}, + }); + expect(transport.lastGet?.queryParameters['collection'], FeedRepository.blackskyAiPreferenceCollection); + expect(transport.lastGet?.queryParameters['rkey'], FeedRepository.blackskyAiPreferenceRkey); + }); + + test('getBlackskyAiPreferenceRecord returns null when the record is missing', () async { + final transport = _BlackskyAiPreferenceTransport( + getStatusCode: 400, + getResponse: {'error': 'RecordNotFound', 'message': 'Could not locate record'}, + ); + final repository = FeedRepository( + bluesky: testBluesky(getClient: transport.get), + database: database, + accountDid: 'did:plc:test', + ); + + expect(await repository.getBlackskyAiPreferenceRecord(), isNull); + }); + + test('putBlackskyAiPreferenceRecord writes community lexicon preference record', () async { + final transport = _BlackskyAiPreferenceTransport(putResponse: {'uri': 'at://did:plc:test/record', 'cid': 'cid'}); + final repository = FeedRepository( + bluesky: testBluesky(postClient: transport.post), + database: database, + accountDid: 'did:plc:test', + ); + + await repository.putBlackskyAiPreferenceRecord( + record: {r'$type': FeedRepository.blackskyAiPreferenceCollection, 'preferences': const {}}, + ); + + expect(transport.lastPost?.pathSegments.last, 'com.atproto.repo.putRecord'); + expect(transport.lastBody?['repo'], 'did:plc:test'); + expect(transport.lastBody?['collection'], FeedRepository.blackskyAiPreferenceCollection); + expect(transport.lastBody?['rkey'], FeedRepository.blackskyAiPreferenceRkey); + expect(transport.lastBody?['record'][r'$type'], FeedRepository.blackskyAiPreferenceCollection); + }); +} + +class _BlackskyAiPreferenceTransport { + _BlackskyAiPreferenceTransport({this.getResponse, this.putResponse, this.getStatusCode = 200}); + + final Map? getResponse; + final Map? putResponse; + final int getStatusCode; + Uri? lastGet; + Uri? lastPost; + Map? lastBody; + + Future get(Uri url, {Map? headers}) async { + lastGet = url; + if (url.pathSegments.last != 'com.atproto.repo.getRecord') { + return unexpectedGetClient(url, headers: headers); + } + return jsonResponse(url, 'GET', getResponse ?? const {}, statusCode: getStatusCode); + } + + Future post(Uri url, {Map? headers, Object? body, Encoding? encoding}) async { + lastPost = url; + if (url.pathSegments.last != 'com.atproto.repo.putRecord') { + return unexpectedPostClient(url, headers: headers, body: body, encoding: encoding); + } + lastBody = jsonDecode(body! as String) as Map; + return jsonResponse(url, 'POST', putResponse ?? const {}); + } +} diff --git a/test/features/settings/bloc/account_settings_cubit_test.dart b/test/features/settings/bloc/account_settings_cubit_test.dart index d27f524..df99196 100644 --- a/test/features/settings/bloc/account_settings_cubit_test.dart +++ b/test/features/settings/bloc/account_settings_cubit_test.dart @@ -58,6 +58,9 @@ void main() { when(() => feedRepository.getPreferences()).thenAnswer( (_) async => PreferencesResult( preferences: const [ + UPreferences.threadViewPref( + data: ThreadViewPref(sort: ThreadViewPrefSort.knownValue(data: KnownThreadViewPrefSort.newest)), + ), UPreferences.feedViewPref(data: FeedViewPref(feed: homeFeedPreferenceId, hideReplies: true)), UPreferences.feedViewPref( data: FeedViewPref( @@ -77,8 +80,136 @@ void main() { .having((state) => state.status, 'status', AccountSettingsStatus.loaded) .having((state) => state.feedViewPref?.hideReplies, 'hideReplies', null) .having((state) => state.feedViewPref?.hideReposts, 'hideReposts', true) - .having((state) => state.feedViewPref?.hideQuotePosts, 'hideQuotePosts', true), + .having((state) => state.feedViewPref?.hideQuotePosts, 'hideQuotePosts', true) + .having( + (state) => state.threadViewPref?.sort?.knownValue, + 'threadViewPref.sort', + KnownThreadViewPrefSort.newest, + ), + ], + ); + + blocTest( + 'loadPreferences loads BlackSky AI preference record when enabled', + build: () => AccountSettingsCubit( + feedRepository: feedRepository, + feed: homeFeedPreferenceId, + feedDisplayName: 'Following', + supportsBlackskyAiPreferences: true, + ), + setUp: () { + when(() => feedRepository.getPreferences()).thenAnswer((_) async => PreferencesResult(preferences: [])); + when(() => feedRepository.getBlackskyAiPreferenceRecord()).thenAnswer( + (_) async => { + r'$type': FeedRepository.blackskyAiPreferenceCollection, + 'preferences': { + 'training': {'allow': false, 'updatedAt': '2026-01-01T00:00:00.000Z'}, + 'embedding': {'allow': true, 'updatedAt': '2026-01-01T00:00:00.000Z'}, + }, + }, + ); + }, + act: (cubit) => cubit.loadPreferences(), + expect: () => [ + isA().having((state) => state.status, 'status', AccountSettingsStatus.loading), + isA() + .having((state) => state.status, 'status', AccountSettingsStatus.loaded) + .having((state) => state.blackskyAiPreferences?.training, 'training', BlackskyAiPreferenceValue.deny) + .having((state) => state.blackskyAiPreferences?.embedding, 'embedding', BlackskyAiPreferenceValue.allow) + .having((state) => state.blackskyAiPreferences?.inference, 'inference', BlackskyAiPreferenceValue.unset), + ], + ); + + blocTest( + 'setBlackskyAiPreference writes the BlackSky preference record', + build: () => AccountSettingsCubit( + feedRepository: feedRepository, + feed: homeFeedPreferenceId, + feedDisplayName: 'Following', + supportsBlackskyAiPreferences: true, + clock: () => DateTime.utc(2026, 1, 2, 3, 4, 5), + ), + seed: () => + const AccountSettingsState.initial( + feed: homeFeedPreferenceId, + feedDisplayName: 'Following', + supportsBlackskyAiPreferences: true, + ).copyWith( + status: AccountSettingsStatus.loaded, + blackskyAiPreferences: const BlackskyAiPreferences(training: BlackskyAiPreferenceValue.allow), + ), + setUp: () { + when(() => feedRepository.putBlackskyAiPreferenceRecord(record: any(named: 'record'))).thenAnswer((_) async {}); + }, + act: (cubit) => + cubit.setBlackskyAiPreference(BlackskyAiPreferenceCategory.training, BlackskyAiPreferenceValue.deny), + expect: () => [ + isA() + .having((state) => state.status, 'status', AccountSettingsStatus.saving) + .having((state) => state.blackskyAiPreferences?.training, 'training', BlackskyAiPreferenceValue.deny), + isA() + .having((state) => state.status, 'status', AccountSettingsStatus.loaded) + .having((state) => state.blackskyAiPreferences?.training, 'training', BlackskyAiPreferenceValue.deny), + ], + verify: (_) { + final record = + verify( + () => feedRepository.putBlackskyAiPreferenceRecord(record: captureAny(named: 'record')), + ).captured.single + as Map; + expect(record[r'$type'], FeedRepository.blackskyAiPreferenceCollection); + expect(record['scope'], {r'$type': '${FeedRepository.blackskyAiPreferenceCollection}#globalScope'}); + expect(record['preferences'], { + 'training': {'allow': false, 'updatedAt': '2026-01-02T03:04:05.000Z'}, + }); + }, + ); + + blocTest( + 'setThreadSort replaces only the thread preference', + build: () => AccountSettingsCubit( + feedRepository: feedRepository, + feed: homeFeedPreferenceId, + feedDisplayName: 'Following', + ), + seed: () => const AccountSettingsState.initial(feed: homeFeedPreferenceId, feedDisplayName: 'Following').copyWith( + status: AccountSettingsStatus.loaded, + threadViewPref: const ThreadViewPref(sort: ThreadViewPrefSort.knownValue(data: KnownThreadViewPrefSort.oldest)), + ), + setUp: () { + when(() => feedRepository.getPreferences()).thenAnswer( + (_) async => PreferencesResult( + preferences: const [ + UPreferences.feedViewPref(data: FeedViewPref(feed: homeFeedPreferenceId, hideReplies: true)), + UPreferences.threadViewPref( + data: ThreadViewPref(sort: ThreadViewPrefSort.knownValue(data: KnownThreadViewPrefSort.oldest)), + ), + ], + ), + ); + when(() => feedRepository.putPreferences(preferences: any(named: 'preferences'))).thenAnswer((_) async {}); + }, + act: (cubit) => cubit.setThreadSort(const ThreadViewPrefSort.knownValue(data: KnownThreadViewPrefSort.mostLikes)), + expect: () => [ + isA() + .having((state) => state.status, 'status', AccountSettingsStatus.saving) + .having((state) => state.threadViewPref?.sort?.knownValue, 'sort', KnownThreadViewPrefSort.mostLikes), + isA() + .having((state) => state.status, 'status', AccountSettingsStatus.loaded) + .having((state) => state.threadViewPref?.sort?.knownValue, 'sort', KnownThreadViewPrefSort.mostLikes), ], + verify: (_) { + final captured = + verify(() => feedRepository.putPreferences(preferences: captureAny(named: 'preferences'))).captured.single + as List; + + expect(captured.map((preference) => preference.threadViewPref).nonNulls, hasLength(1)); + expect( + captured.singleWhere((preference) => preference.threadViewPref != null).threadViewPref!.sort!.knownValue, + KnownThreadViewPrefSort.mostLikes, + ); + expect(captured.singleWhere((preference) => preference.feedViewPref != null).feedViewPref!.hideReplies, true); + }, ); blocTest( diff --git a/test/features/settings/presentation/account_feed_display_preferences_test.dart b/test/features/settings/presentation/account_feed_display_preferences_test.dart new file mode 100644 index 0000000..62fa636 --- /dev/null +++ b/test/features/settings/presentation/account_feed_display_preferences_test.dart @@ -0,0 +1,113 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/settings/bloc/account_settings_cubit.dart'; +import 'package:lazurite/features/settings/presentation/widgets/account_feed_display_preferences.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAccountSettingsCubit extends MockCubit implements AccountSettingsCubit {} + +void main() { + setUpAll(() { + registerFallbackValue(BlackskyAiPreferenceCategory.training); + registerFallbackValue(BlackskyAiPreferenceValue.unset); + }); + + late MockAccountSettingsCubit cubit; + + setUp(() { + cubit = MockAccountSettingsCubit(); + final state = const AccountSettingsState.initial(feed: homeFeedPreferenceId, feedDisplayName: 'Following').copyWith( + status: AccountSettingsStatus.loaded, + feedViewPref: const FeedViewPref(feed: homeFeedPreferenceId), + threadViewPref: const ThreadViewPref(sort: ThreadViewPrefSort.knownValue(data: KnownThreadViewPrefSort.newest)), + ); + when(() => cubit.state).thenReturn(state); + whenListen(cubit, const Stream.empty(), initialState: state); + when(() => cubit.setThreadSort(any())).thenAnswer((_) async {}); + when(() => cubit.setBlackskyAiPreference(any(), any())).thenAnswer((_) async {}); + }); + + testWidgets('renders provider-specific account settings and updates thread sort', (tester) async { + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: const MaterialApp( + home: Scaffold(body: AccountFeedDisplayPreferences(providerDisplayName: 'BlackSky')), + ), + ), + ); + + expect(find.text('BlackSky settings'), findsOneWidget); + expect(find.textContaining('app.bsky.actor.getPreferences'), findsOneWidget); + + await tester.drag(find.byType(ListView), const Offset(0, -1200)); + await tester.pumpAndSettle(); + + expect(find.text('THREAD DISPLAY'), findsOneWidget); + expect(find.text('Newest first'), findsOneWidget); + + await tester.tap(find.text('Newest first')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Most likes').last); + await tester.pumpAndSettle(); + + verify( + () => cubit.setThreadSort(const ThreadViewPrefSort.knownValue(data: KnownThreadViewPrefSort.mostLikes)), + ).called(1); + }); + + testWidgets('renders BlackSky AI preferences and updates them', (tester) async { + final state = + const AccountSettingsState.initial( + feed: homeFeedPreferenceId, + feedDisplayName: 'Following', + supportsBlackskyAiPreferences: true, + ).copyWith( + status: AccountSettingsStatus.loaded, + feedViewPref: const FeedViewPref(feed: homeFeedPreferenceId), + threadViewPref: const ThreadViewPref(), + blackskyAiPreferences: const BlackskyAiPreferences(training: BlackskyAiPreferenceValue.unset), + ); + when(() => cubit.state).thenReturn(state); + whenListen(cubit, const Stream.empty(), initialState: state); + + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: const MaterialApp( + home: Scaffold(body: AccountFeedDisplayPreferences(providerDisplayName: 'BlackSky')), + ), + ), + ); + + await tester.drag(find.byType(ListView), const Offset(0, -1600)); + await tester.pumpAndSettle(); + + expect(find.text('BLACKSKY AI PREFERENCES'), findsOneWidget); + expect(find.text('Training'), findsOneWidget); + expect(find.text('Unset'), findsAtLeastNWidgets(1)); + + await tester.tap(find.text('Unset').last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Deny').last); + await tester.pumpAndSettle(); + + verify( + () => cubit.setBlackskyAiPreference(BlackskyAiPreferenceCategory.embedding, BlackskyAiPreferenceValue.deny), + ).called(1); + }); + + testWidgets('can hide thread settings when used from feed management', (tester) async { + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: const MaterialApp(home: Scaffold(body: AccountFeedDisplayPreferences(showThreadSettings: false))), + ), + ); + + expect(find.text('THREAD DISPLAY'), findsNothing); + }); +} -- 2.51.2