diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -75,6 +75,7 @@ import 'package:lazurite/features/settings/data/video_repository.dart'; import 'package:lazurite/features/settings/presentation/about_screen.dart'; import 'package:lazurite/features/settings/presentation/privacy_policy_screen.dart'; +import 'package:lazurite/features/settings/presentation/settings_account_screen.dart'; import 'package:lazurite/features/settings/presentation/settings_screen.dart'; import 'package:lazurite/features/settings/presentation/terms_of_service_screen.dart'; import 'package:lazurite/features/settings/presentation/video_upload_limits_screen.dart'; @@ -165,6 +166,10 @@ _page(context, state, LabelerDetailScreen(did: state.uri.queryParameters['did'] ?? '')), ), ], + ), + GoRoute( + path: 'account', + pageBuilder: (context, state) => _page(context, state, const SettingsAccountScreen()), ), GoRoute(path: 'about', pageBuilder: (context, state) => _page(context, state, const AboutScreen())), GoRoute(path: 'logs', pageBuilder: (context, state) => _page(context, state, const LogsScreen())), diff --git a/lib/features/feed/data/feed_repository.dart b/lib/features/feed/data/feed_repository.dart --- a/lib/features/feed/data/feed_repository.dart +++ b/lib/features/feed/data/feed_repository.dart @@ -1,5 +1,6 @@ import 'package:poptart_core/poptart_core.dart' as atcore show AtUri; import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; +import 'package:bluesky_poptart/app/bsky/embed/record.dart'; import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; import 'package:bluesky_poptart/app/bsky/feed/get_author_feed.dart'; import 'package:bluesky_poptart/app/bsky/unspecced/defs.dart'; @@ -64,8 +65,10 @@ final AppViewFallbackService _appViewFallbackService; final int _routingEpoch; final int Function()? _routingEpochResolver; + List? _preferencesCache; static const String timelineCacheKey = 'timeline'; + static const String homeFeedPreferenceId = 'home'; static const int _minTrendingLimit = 1; static const int _maxTrendingLimit = 25; @@ -95,7 +98,7 @@ client.feed.getAuthorFeed(actor: actor, cursor: cursor, limit: limit, filter: bskyFilter, $headers: headers), ); - return FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); + return FeedResult(posts: _filterModeratedFeedPosts(response.data.feed), cursor: response.data.cursor); } Future getTimeline({String? cursor, int limit = 50}) async { @@ -107,9 +110,17 @@ (client) => client.feed.getTimeline(cursor: cursor, limit: limit, $headers: headers), ); - final result = FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); - await _cacheFeedWindow(feedKey: timelineCacheKey, result: result, cursor: cursor); - return result; + final moderatedPosts = _filterModeratedFeedPosts(response.data.feed); + final rawResult = FeedResult(posts: moderatedPosts, cursor: response.data.cursor); + await _cacheFeedWindow(feedKey: timelineCacheKey, result: rawResult, cursor: cursor); + return FeedResult( + posts: filterFeedViewPostsByPreference( + moderatedPosts, + await _feedViewPreferenceFor(homeFeedPreferenceId), + currentAccountDid: _accountDid, + ), + cursor: response.data.cursor, + ); } Future getFeed({required atcore.AtUri feedUri, String? cursor, int limit = 50}) async { @@ -121,9 +132,11 @@ (client) => client.feed.getFeed(feed: feedUri, cursor: cursor, limit: limit, $headers: headers), ); - final result = FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); - await _cacheFeedWindow(feedKey: 'feed:${feedUri.toString()}', result: result, cursor: cursor); - return result; + final feedPreferenceId = feedUri.toString(); + final moderatedPosts = _filterModeratedFeedPosts(response.data.feed); + final rawResult = FeedResult(posts: moderatedPosts, cursor: response.data.cursor); + await _cacheFeedWindow(feedKey: 'feed:$feedPreferenceId', result: rawResult, cursor: cursor); + return rawResult; } Future getCachedFeedPage(String feedKey) async { @@ -162,18 +175,31 @@ } } - return FeedResult(posts: posts, cursor: cursor); + if (feedKey != timelineCacheKey) { + return FeedResult(posts: posts, cursor: cursor); + } + + return FeedResult( + posts: filterFeedViewPostsByPreference( + posts, + await _feedViewPreferenceFor(homeFeedPreferenceId), + currentAccountDid: _accountDid, + ), + cursor: cursor, + ); } Future getPreferences() async { final headers = _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()); final response = await _authRecovery.run((client) => client.actor.getPreferences($headers: headers)); + _preferencesCache = response.data.preferences; return PreferencesResult(preferences: response.data.preferences); } Future putPreferences({required List preferences}) async { final headers = _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()); await _authRecovery.run((client) => client.actor.putPreferences(preferences: preferences, $headers: headers)); + _preferencesCache = preferences; } Future> getSuggestedFeeds({String? cursor, int limit = 50}) async { @@ -332,13 +358,49 @@ return response.data.feeds; } - List _filterFeedPosts(List posts) { + List _filterModeratedFeedPosts(List posts) { final moderationService = _moderationService; if (moderationService == null) { return posts; } return posts.where((post) => !moderationService.shouldFilterFeedViewPostInList(post)).toList(); + } + + Future _feedViewPreferenceFor(String feed) async { + if (_preferencesCache != null) { + return _cachedFeedViewPreferenceFor(feed); + } + + try { + final result = await getPreferences(); + return _feedViewPreferenceFrom(result.preferences, feed); + } catch (error, stackTrace) { + log.w( + 'feed.feedViewPreference unavailable account=$_accountDid feed=$feed', + error: error, + stackTrace: stackTrace, + ); + return null; + } + } + + FeedViewPref? _cachedFeedViewPreferenceFor(String feed) { + final preferences = _preferencesCache; + if (preferences == null) { + return null; + } + return _feedViewPreferenceFrom(preferences, feed); + } + + FeedViewPref? _feedViewPreferenceFrom(List preferences, String feed) { + for (final preference in preferences) { + final feedViewPref = preference.feedViewPref; + if (feedViewPref != null && feedViewPref.feed == feed) { + return feedViewPref; + } + } + return null; } /// When refreshing, the newest page goes first @@ -429,6 +491,70 @@ ); } } +} + +@visibleForTesting +List filterFeedViewPostsByPreference( + List posts, + FeedViewPref? preference, { + String? currentAccountDid, +}) { + if (preference == null) { + return posts; + } + + return posts + .where((feedViewPost) { + if (preference.hideReposts == true && feedViewPost.reason?.isReasonRepost == true) { + return false; + } + + final isReply = _isReply(feedViewPost); + if (isReply) { + if (preference.hideReplies == true) { + return false; + } + if (preference.hideRepliesByUnfollowed && !_isSelfOrFollowed(feedViewPost.post.author, currentAccountDid)) { + return false; + } + + final likeThreshold = preference.hideRepliesByLikeCount; + if (likeThreshold != null && (feedViewPost.post.likeCount ?? 0) < likeThreshold) { + return false; + } + } + + if (preference.hideQuotePosts == true && _isQuotePost(feedViewPost.post.embed)) { + return false; + } + + return true; + }) + .toList(growable: false); +} + +bool _isReply(FeedViewPost feedViewPost) => feedViewPost.reply != null || feedViewPost.post.record['reply'] != null; + +bool _isSelfOrFollowed(ProfileViewBasic author, String? currentAccountDid) { + if (author.did == currentAccountDid) { + return true; + } + return author.viewer?.following != null; +} + +bool _isQuotePost(UPostViewEmbed? embed) { + if (embed == null) { + return false; + } + if (embed.isEmbedRecordWithMediaView) { + return true; + } + final record = embed.embedRecordView?.record; + return record != null && + (record.isEmbedRecordViewRecord || + record.isEmbedRecordViewNotFound || + record.isEmbedRecordViewBlocked || + record.isEmbedRecordViewDetached); } class FeedResult { diff --git a/lib/features/feed/presentation/feed_management_screen.dart b/lib/features/feed/presentation/feed_management_screen.dart --- a/lib/features/feed/presentation/feed_management_screen.dart +++ b/lib/features/feed/presentation/feed_management_screen.dart @@ -7,6 +7,8 @@ import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.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/presentation/widgets/account_feed_display_preferences.dart'; import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; @@ -170,10 +172,22 @@ : (generator != null ? _buildGeneratorIcon(context, generator) : _buildFeedIcon(context, feed.value)), title: Text(state.displayNameFor(feed)), subtitle: Text(state.subtitleFor(feed)), - trailing: IconButton( - icon: const Icon(Icons.check_circle), - color: context.colorScheme.primary, - onPressed: () => context.read().unpinFeed(feed.id), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isTimeline) + IconButton( + tooltip: 'Feed display', + icon: const Icon(Icons.tune_outlined), + onPressed: () => _showFeedDisplaySettings(context, feed, state), + ), + IconButton( + tooltip: 'Unpin feed', + icon: const Icon(Icons.check_circle), + color: context.colorScheme.primary, + onPressed: () => context.read().unpinFeed(feed.id), + ), + ], ), ); } @@ -191,14 +205,65 @@ mainAxisSize: MainAxisSize.min, children: [ IconButton( + tooltip: 'Pin feed', icon: const Icon(Icons.pin_end_outlined), onPressed: () => context.read().pinFeed(feed.id), ), IconButton( + tooltip: 'Remove feed', icon: Icon(Icons.close, color: context.colorScheme.error), onPressed: () => _confirmRemoveFeed(context, feed.id), ), ], + ), + ); + } + + 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, + useSafeArea: true, + builder: (sheetContext) => BlocProvider( + create: (_) => AccountSettingsCubit( + feedRepository: feedRepository, + feed: feedPreferenceId, + feedDisplayName: feedDisplayName, + )..loadPreferences(), + child: DraggableScrollableSheet( + expand: false, + initialChildSize: 0.82, + minChildSize: 0.45, + maxChildSize: 0.95, + builder: (context, scrollController) => Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 0), + child: Row( + children: [ + Text('Feed display', style: context.textTheme.titleLarge), + const Spacer(), + IconButton( + tooltip: 'Close', + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + Expanded( + child: AccountFeedDisplayPreferences( + padding: const EdgeInsets.only(bottom: 24), + scrollController: scrollController, + ), + ), + ], + ), + ), ), ); } diff --git a/lib/features/settings/bloc/account_settings_cubit.dart b/lib/features/settings/bloc/account_settings_cubit.dart new file mode 100644 --- /dev/null +++ b/lib/features/settings/bloc/account_settings_cubit.dart @@ -0,0 +1,138 @@ +import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/features/feed/data/feed_repository.dart'; + +const String homeFeedPreferenceId = 'home'; + +enum AccountSettingsStatus { initial, loading, loaded, saving, error, saveError } + +class AccountSettingsState extends Equatable { + const AccountSettingsState._({ + required this.status, + required this.feed, + required this.feedDisplayName, + this.feedViewPref, + this.message, + }); + + const AccountSettingsState.initial({required String feed, required String feedDisplayName}) + : this._(status: AccountSettingsStatus.initial, feed: feed, feedDisplayName: feedDisplayName); + + final AccountSettingsStatus status; + final String feed; + final String feedDisplayName; + final FeedViewPref? feedViewPref; + final String? message; + + bool get isBusy => status == AccountSettingsStatus.loading || status == AccountSettingsStatus.saving; + + AccountSettingsState copyWith({ + AccountSettingsStatus? status, + FeedViewPref? feedViewPref, + 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, + ); + } + + @override + List get props => [status, feed, feedDisplayName, feedViewPref, message]; +} + +class AccountSettingsCubit extends Cubit { + AccountSettingsCubit({required FeedRepository feedRepository, required String feed, required String feedDisplayName}) + : _feedRepository = feedRepository, + super(AccountSettingsState.initial(feed: feed, feedDisplayName: feedDisplayName)); + + final FeedRepository _feedRepository; + + Future loadPreferences() async { + _safeEmit(state.copyWith(status: AccountSettingsStatus.loading, clearMessage: true)); + + 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)); + } catch (error, stackTrace) { + log.e( + 'AccountSettingsCubit: Failed to load feed display preferences for ${state.feed}', + error: error, + stackTrace: stackTrace, + ); + _safeEmit( + state.copyWith( + status: AccountSettingsStatus.error, + feedViewPref: state.feedViewPref ?? FeedViewPref(feed: state.feed), + message: error.toString(), + ), + ); + } + } + + Future setHideReplies(bool value) => _updatePreference((pref) => pref.copyWith(hideReplies: value)); + + Future setHideRepliesByUnfollowed(bool value) => + _updatePreference((pref) => pref.copyWith(hideRepliesByUnfollowed: value)); + + Future setHideRepliesByLikeCount(int? value) => + _updatePreference((pref) => pref.copyWith(hideRepliesByLikeCount: value)); + + Future setHideReposts(bool value) => _updatePreference((pref) => pref.copyWith(hideReposts: value)); + + Future setHideQuotePosts(bool value) => _updatePreference((pref) => pref.copyWith(hideQuotePosts: value)); + + Future _updatePreference(FeedViewPref Function(FeedViewPref current) update) async { + final current = state.feedViewPref ?? FeedViewPref(feed: state.feed); + final updated = update(current); + _safeEmit(state.copyWith(status: AccountSettingsStatus.saving, feedViewPref: updated, clearMessage: true)); + + try { + final result = await _feedRepository.getPreferences(); + final preferences = _replaceFeedViewPref(result.preferences, updated); + await _feedRepository.putPreferences(preferences: preferences); + _safeEmit(state.copyWith(status: AccountSettingsStatus.loaded, feedViewPref: updated, clearMessage: true)); + } catch (error, stackTrace) { + log.e( + 'AccountSettingsCubit: Failed to save feed display preferences for ${state.feed}', + error: error, + stackTrace: stackTrace, + ); + _safeEmit( + state.copyWith(status: AccountSettingsStatus.saveError, feedViewPref: updated, message: error.toString()), + ); + } + } + + FeedViewPref? _feedViewPrefFrom(List preferences) { + for (final preference in preferences) { + final feedViewPref = preference.feedViewPref; + if (feedViewPref != null && feedViewPref.feed == state.feed) { + return feedViewPref; + } + } + return null; + } + + List _replaceFeedViewPref(List preferences, FeedViewPref updated) { + return [ + for (final preference in preferences) + if (preference.feedViewPref?.feed != updated.feed) preference, + UPreferences.feedViewPref(data: updated), + ]; + } + + void _safeEmit(AccountSettingsState nextState) { + if (isClosed) { + return; + } + emit(nextState); + } +} diff --git a/lib/features/settings/presentation/settings_account_screen.dart b/lib/features/settings/presentation/settings_account_screen.dart new file mode 100644 --- /dev/null +++ b/lib/features/settings/presentation/settings_account_screen.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.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/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(), + ), + ); +} diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -24,6 +24,7 @@ import 'package:lazurite/features/settings/presentation/screens/recoverable_crash_test_screen.dart'; import 'package:lazurite/features/settings/presentation/widgets/atproto_connection.dart'; import 'package:lazurite/features/settings/presentation/widgets/connection_detail.dart'; +import 'package:lazurite/features/settings/presentation/widgets/settings_section.dart'; import 'package:lazurite/features/settings/presentation/widgets/settings_tiles.dart'; import 'package:lazurite/features/settings/presentation/widgets/theme_palette_row.dart'; import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; @@ -91,24 +92,30 @@ }, ), const SizedBox(height: 24), - _buildSectionHeader(context, l10n.labelAppearance), + SettingsSectionHeader(l10n.labelAppearance), _buildThemeSelector(context), const SizedBox(height: 24), - _buildSectionHeader(context, l10n.labelLayout), + SettingsSectionHeader(l10n.labelLayout), _buildLayoutSettings(context), if (showAccountSettings) ...[ const SizedBox(height: 24), - _buildSectionHeader(context, l10n.labelModeration), + SettingsSectionHeader(l10n.labelModeration), const _ModerationSettingsPreview(), ], const SizedBox(height: 24), - _buildSectionHeader(context, l10n.labelSearch), + SettingsSectionHeader(l10n.labelSearch), _buildSearchSettings(context, showTypeaheadSettings: showAccountSettings), if (showAccountSettings) ...[ const SizedBox(height: 24), - _buildSectionHeader(context, l10n.labelAccount), + SettingsSectionHeader(l10n.labelAccount), const AtProtoConnectionCard(), const SizedBox(height: 12), + SettingsTile( + icon: Icons.manage_accounts_outlined, + title: 'Account settings', + subtitle: 'Feed display preferences and account defaults', + onTap: () => context.push('/settings/account'), + ), SettingsTile( icon: Icons.dynamic_feed_outlined, title: l10n.labelFeeds, @@ -128,7 +135,7 @@ onTap: () => context.push('/settings/video-limits'), ), const SizedBox(height: 24), - _buildSectionHeader(context, l10n.labelAccountMaintenance), + SettingsSectionHeader(l10n.labelAccountMaintenance), SettingsTile( icon: Icons.cleaning_services_outlined, title: l10n.labelCleanFollows, @@ -137,18 +144,18 @@ ), ], const SizedBox(height: 24), - _buildSectionHeader(context, l10n.labelAdvanced), + SettingsSectionHeader(l10n.labelAdvanced), _buildAdvancedSettings(context), const SizedBox(height: 24), - _buildSectionHeader(context, l10n.labelTroubleshooting), + SettingsSectionHeader(l10n.labelTroubleshooting), _buildTroubleshootingSettings(context), const SizedBox(height: 24), if (!kReleaseMode) ...[ - _buildSectionHeader(context, l10n.labelDeveloper), + SettingsSectionHeader(l10n.labelDeveloper), _buildDeveloperSettings(context), const SizedBox(height: 24), ], - _buildSectionHeader(context, l10n.labelAbout), + SettingsSectionHeader(l10n.labelAbout), SettingsTile( icon: Icons.explore_outlined, title: l10n.labelAtExplorer, @@ -175,7 +182,7 @@ ), if (showAccountSettings) ...[ const SizedBox(height: 24), - _buildSectionHeader(context, l10n.labelDangerZone), + SettingsSectionHeader(l10n.labelDangerZone), SettingsTile( icon: Icons.logout, title: l10n.labelLogOut, @@ -193,14 +200,6 @@ ); } - Widget _buildSectionHeader(BuildContext context, String title) => Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Text( - title.toUpperCase(), - style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), - ), - ); - Widget _title(BuildContext context) => Text(context.l10n.labelSettings, style: context.textTheme.titleLarge); Widget _buildThemeSelector(BuildContext context) { @@ -210,105 +209,95 @@ return BlocBuilder( builder: (context, state) { - return Container( - decoration: BoxDecoration( - border: Border( - top: BorderSide(color: theme.dividerColor), - bottom: BorderSide(color: theme.dividerColor), + return SettingsGroup( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: SegmentedButton( + style: SegmentedButton.styleFrom( + selectedBackgroundColor: theme.colorScheme.primary, + selectedForegroundColor: theme.colorScheme.onPrimary, + ), + segments: [ + ButtonSegment(value: AppearanceMode.system, label: Text(l10n.labelSystem)), + ButtonSegment(value: AppearanceMode.light, label: Text(l10n.labelLight)), + ButtonSegment(value: AppearanceMode.dark, label: Text(l10n.labelDark)), + ], + selected: {AppearanceMode.fromState(state)}, + onSelectionChanged: (selected) { + 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); + } + }, + ), + ), ), - color: theme.cardColor, - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Center( - child: SegmentedButton( - style: SegmentedButton.styleFrom( - selectedBackgroundColor: theme.colorScheme.primary, - selectedForegroundColor: theme.colorScheme.onPrimary, - ), - segments: [ - ButtonSegment(value: AppearanceMode.system, label: Text(l10n.labelSystem)), - ButtonSegment(value: AppearanceMode.light, label: Text(l10n.labelLight)), - ButtonSegment(value: AppearanceMode.dark, label: Text(l10n.labelDark)), - ], - selected: {AppearanceMode.fromState(state)}, - onSelectionChanged: (selected) { - final mode = selected.first; - switch (mode) { - 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( + l10n.labelTheme, + style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), ), ), - const Divider(height: 1), - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - l10n.labelTheme, - style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), - ), - ), + ), + for (final palette in AppThemePalette.values) + ThemePaletteRow( + palette: palette, + isSelected: state.themePalette == palette, + onTap: () => settingsCubit.setThemePalette(palette), ), - for (final palette in AppThemePalette.values) - ThemePaletteRow( - palette: palette, - isSelected: state.themePalette == palette, - onTap: () => settingsCubit.setThemePalette(palette), - ), - const Divider(height: 1), - SettingsDropdownTile( - title: 'Heading Font', - value: state.headingFontFamily, - options: AppHeadingFontFamily.values, - labelBuilder: (fontFamily) => fontFamily.label, - optionBuilder: _headingFontOption, - onChanged: (value) { - if (value != null) { - settingsCubit.setHeadingFontFamily(value); - } - }, - ), - const Divider(height: 1), - SettingsDropdownTile( - title: 'Content Font', - value: state.contentFontFamily, - options: AppContentFontFamily.values, - labelBuilder: (fontFamily) => fontFamily.label, - optionBuilder: _contentFontOption, - onChanged: (value) { - if (value != null) { - settingsCubit.setContentFontFamily(value); - } - }, - ), - const Divider(height: 1), - SettingsDropdownTile( - title: 'Code Font', - value: state.codeFontFamily, - options: AppCodeFontFamily.values, - labelBuilder: (fontFamily) => fontFamily.label, - optionBuilder: _codeFontOption, - onChanged: (value) { - if (value != null) { - settingsCubit.setCodeFontFamily(value); - } - }, - ), - const SizedBox(height: 8), - ], - ), + const Divider(height: 1), + SettingsDropdownTile( + title: 'Heading Font', + value: state.headingFontFamily, + options: AppHeadingFontFamily.values, + labelBuilder: (fontFamily) => fontFamily.label, + optionBuilder: _headingFontOption, + onChanged: (value) { + if (value != null) { + settingsCubit.setHeadingFontFamily(value); + } + }, + ), + const Divider(height: 1), + SettingsDropdownTile( + title: 'Content Font', + value: state.contentFontFamily, + options: AppContentFontFamily.values, + labelBuilder: (fontFamily) => fontFamily.label, + optionBuilder: _contentFontOption, + onChanged: (value) { + if (value != null) { + settingsCubit.setContentFontFamily(value); + } + }, + ), + const Divider(height: 1), + SettingsDropdownTile( + title: 'Code Font', + value: state.codeFontFamily, + options: AppCodeFontFamily.values, + labelBuilder: (fontFamily) => fontFamily.label, + optionBuilder: _codeFontOption, + onChanged: (value) { + if (value != null) { + settingsCubit.setCodeFontFamily(value); + } + }, + ), + const SizedBox(height: 8), + ], ); }, ); diff --git a/test/features/feed/data/feed_repository_test.dart b/test/features/feed/data/feed_repository_test.dart --- a/test/features/feed/data/feed_repository_test.dart +++ b/test/features/feed/data/feed_repository_test.dart @@ -1,5 +1,7 @@ import 'package:poptart_core/poptart_core.dart'; -import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; +import 'package:bluesky_poptart/app/bsky/actor/defs.dart' hide ViewerState; +import 'package:bluesky_poptart/app/bsky/actor/defs/viewer_state.dart' as actor_defs; +import 'package:bluesky_poptart/app/bsky/embed/record.dart'; import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/features/feed/data/feed_repository.dart'; @@ -222,6 +224,111 @@ final result = PreferencesResult(preferences: []); expect(result.preferences, isEmpty); + }); + }); + + group('filterFeedViewPostsByPreference', () { + FeedViewPost post({ + required String id, + String authorDid = 'did:plc:author', + bool followed = false, + bool reply = false, + bool repost = false, + bool quote = false, + int likeCount = 0, + }) { + final author = ProfileViewBasic( + did: authorDid, + handle: '$id.bsky.social', + viewer: followed + ? const actor_defs.ViewerState(following: AtUri('at://did:plc:self/app.bsky.graph.follow/follow')) + : null, + ); + final postView = PostView( + uri: AtUri.parse('at://$authorDid/app.bsky.feed.post/$id'), + cid: 'cid-$id', + author: author, + record: {r'$type': 'app.bsky.feed.post', 'text': id, if (reply) 'reply': {}}, + embed: quote + ? UPostViewEmbed.embedRecordView( + data: EmbedRecordView( + record: UEmbedRecordViewRecord.embedRecordViewRecord( + data: EmbedRecordViewRecord( + uri: const AtUri('at://did:plc:quoted/app.bsky.feed.post/quoted'), + cid: 'quoted-cid', + author: const ProfileViewBasic(did: 'did:plc:quoted', handle: 'quoted.bsky.social'), + value: const {r'$type': 'app.bsky.feed.post', 'text': 'quoted'}, + indexedAt: DateTime.utc(2026), + ), + ), + ), + ) + : null, + likeCount: likeCount, + indexedAt: DateTime.utc(2026), + ); + return FeedViewPost( + post: postView, + reason: repost + ? UFeedViewPostReason.reasonRepost( + data: ReasonRepost(by: author, indexedAt: DateTime.utc(2026)), + ) + : null, + ); + } + + test('hides replies when hideReplies is enabled', () { + final topLevel = post(id: 'top-level'); + final reply = post(id: 'reply', reply: true, followed: true); + + final filtered = filterFeedViewPostsByPreference( + [topLevel, reply], + const FeedViewPref(feed: 'home', hideReplies: true), + currentAccountDid: 'did:plc:self', + ); + + expect(filtered, [topLevel]); + }); + + test('hides replies from unfollowed accounts while keeping self and followed replies', () { + final selfReply = post(id: 'self-reply', authorDid: 'did:plc:self', reply: true); + final followedReply = post(id: 'followed-reply', reply: true, followed: true); + final unfollowedReply = post(id: 'unfollowed-reply', reply: true); + + final filtered = filterFeedViewPostsByPreference( + [selfReply, followedReply, unfollowedReply], + const FeedViewPref(feed: 'home', hideRepliesByUnfollowed: true), + currentAccountDid: 'did:plc:self', + ); + + expect(filtered, [selfReply, followedReply]); + }); + + test('hides replies below the configured like threshold', () { + final lowLikeReply = post(id: 'low-like-reply', reply: true, followed: true, likeCount: 4); + final highLikeReply = post(id: 'high-like-reply', reply: true, followed: true, likeCount: 5); + + final filtered = filterFeedViewPostsByPreference( + [lowLikeReply, highLikeReply], + const FeedViewPref(feed: 'home', hideRepliesByUnfollowed: false, hideRepliesByLikeCount: 5), + currentAccountDid: 'did:plc:self', + ); + + expect(filtered, [highLikeReply]); + }); + + test('hides reposts and quote posts independently', () { + final topLevel = post(id: 'top-level'); + final repost = post(id: 'repost', repost: true); + final quote = post(id: 'quote', quote: true); + + final filtered = filterFeedViewPostsByPreference( + [topLevel, repost, quote], + const FeedViewPref(feed: 'home', hideReposts: true, hideQuotePosts: true), + currentAccountDid: 'did:plc:self', + ); + + expect(filtered, [topLevel]); }); }); diff --git a/test/features/settings/bloc/account_settings_cubit_test.dart b/test/features/settings/bloc/account_settings_cubit_test.dart new file mode 100644 --- /dev/null +++ b/test/features/settings/bloc/account_settings_cubit_test.dart @@ -0,0 +1,137 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/feed/data/feed_repository.dart'; +import 'package:lazurite/features/settings/bloc/account_settings_cubit.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockFeedRepository extends Mock implements FeedRepository {} + +void main() { + late MockFeedRepository feedRepository; + + setUp(() { + feedRepository = MockFeedRepository(); + }); + + group('AccountSettingsCubit', () { + test('initial state uses requested feed', () { + final cubit = AccountSettingsCubit( + feedRepository: feedRepository, + feed: homeFeedPreferenceId, + feedDisplayName: 'Following', + ); + + expect(cubit.state.status, AccountSettingsStatus.initial); + expect(cubit.state.feed, homeFeedPreferenceId); + expect(cubit.state.feedDisplayName, 'Following'); + }); + + blocTest( + 'loadPreferences creates default feed preference when one does not exist', + build: () => AccountSettingsCubit( + feedRepository: feedRepository, + feed: homeFeedPreferenceId, + feedDisplayName: 'Following', + ), + setUp: () { + when(() => feedRepository.getPreferences()).thenAnswer((_) async => PreferencesResult(preferences: [])); + }, + act: (cubit) => cubit.loadPreferences(), + expect: () => [ + isA().having((state) => state.status, 'status', AccountSettingsStatus.loading), + isA() + .having((state) => state.status, 'status', AccountSettingsStatus.loaded) + .having((state) => state.feedViewPref?.feed, 'feed', homeFeedPreferenceId) + .having((state) => state.feedViewPref?.hideRepliesByUnfollowed, 'hideRepliesByUnfollowed', true), + ], + ); + + blocTest( + 'loadPreferences uses existing feed preference for the requested feed', + build: () => AccountSettingsCubit( + feedRepository: feedRepository, + feed: 'at://did:plc:test/app.bsky.feed.generator/custom', + feedDisplayName: 'Custom', + ), + setUp: () { + when(() => feedRepository.getPreferences()).thenAnswer( + (_) async => PreferencesResult( + preferences: const [ + UPreferences.feedViewPref(data: FeedViewPref(feed: homeFeedPreferenceId, hideReplies: true)), + UPreferences.feedViewPref( + data: FeedViewPref( + feed: 'at://did:plc:test/app.bsky.feed.generator/custom', + hideReposts: true, + hideQuotePosts: true, + ), + ), + ], + ), + ); + }, + act: (cubit) => cubit.loadPreferences(), + expect: () => [ + isA().having((state) => state.status, 'status', AccountSettingsStatus.loading), + isA() + .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), + ], + ); + + blocTest( + 'setHideReposts replaces only the matching feed preference', + build: () => AccountSettingsCubit( + feedRepository: feedRepository, + feed: homeFeedPreferenceId, + feedDisplayName: 'Following', + ), + seed: () => const AccountSettingsState.initial(feed: homeFeedPreferenceId, feedDisplayName: 'Following').copyWith( + status: AccountSettingsStatus.loaded, + feedViewPref: const FeedViewPref(feed: homeFeedPreferenceId, hideReplies: true), + ), + setUp: () { + when(() => feedRepository.getPreferences()).thenAnswer( + (_) async => PreferencesResult( + preferences: const [ + UPreferences.feedViewPref(data: FeedViewPref(feed: homeFeedPreferenceId, hideReplies: false)), + UPreferences.feedViewPref( + data: FeedViewPref(feed: 'at://did:plc:test/app.bsky.feed.generator/custom', hideReposts: true), + ), + ], + ), + ); + when(() => feedRepository.putPreferences(preferences: any(named: 'preferences'))).thenAnswer((_) async {}); + }, + act: (cubit) => cubit.setHideReposts(true), + expect: () => [ + isA() + .having((state) => state.status, 'status', AccountSettingsStatus.saving) + .having((state) => state.feedViewPref?.hideReplies, 'hideReplies', true) + .having((state) => state.feedViewPref?.hideReposts, 'hideReposts', true), + isA() + .having((state) => state.status, 'status', AccountSettingsStatus.loaded) + .having((state) => state.feedViewPref?.hideReplies, 'hideReplies', true) + .having((state) => state.feedViewPref?.hideReposts, 'hideReposts', true), + ], + verify: (_) { + final captured = + verify(() => feedRepository.putPreferences(preferences: captureAny(named: 'preferences'))).captured.single + as List; + + final feedViewPrefs = captured.map((preference) => preference.feedViewPref).nonNulls.toList(); + expect(feedViewPrefs, hasLength(2)); + expect(feedViewPrefs.firstWhere((pref) => pref.feed == homeFeedPreferenceId).hideReplies, true); + expect(feedViewPrefs.firstWhere((pref) => pref.feed == homeFeedPreferenceId).hideReposts, true); + expect( + feedViewPrefs + .firstWhere((pref) => pref.feed == 'at://did:plc:test/app.bsky.feed.generator/custom') + .hideReposts, + true, + ); + }, + ); + }); +} diff --git a/lib/features/settings/presentation/widgets/account_feed_display_preferences.dart b/lib/features/settings/presentation/widgets/account_feed_display_preferences.dart new file mode 100644 --- /dev/null +++ b/lib/features/settings/presentation/widgets/account_feed_display_preferences.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +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'; + +class AccountFeedDisplayPreferences extends StatelessWidget { + const AccountFeedDisplayPreferences({ + super.key, + this.padding = const EdgeInsets.only(bottom: 24), + this.scrollController, + }); + + final EdgeInsetsGeometry padding; + final ScrollController? scrollController; + + @override + Widget build(BuildContext context) => BlocBuilder( + builder: (context, state) { + final cubit = context.read(); + final preference = state.feedViewPref; + final hideReplies = preference?.hideReplies ?? false; + final hideRepliesByUnfollowed = preference?.hideRepliesByUnfollowed ?? true; + final likeThreshold = preference?.hideRepliesByLikeCount; + final hideReposts = preference?.hideReposts ?? false; + final hideQuotePosts = preference?.hideQuotePosts ?? false; + + return ListView( + controller: scrollController, + padding: padding, + shrinkWrap: true, + children: [ + if (state.status == AccountSettingsStatus.loading) const LinearProgressIndicator(minHeight: 2), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: Text( + state.feedDisplayName, + style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + ), + ), + const SettingsSectionHeader('Feed display'), + SettingsGroup( + children: [ + SettingsTile( + icon: Icons.reply_outlined, + title: 'Hide replies', + subtitle: 'Only show top-level posts in this feed.', + trailing: Switch.adaptive(value: hideReplies, onChanged: state.isBusy ? null : cubit.setHideReplies), + ), + const Divider(height: 1), + SettingsTile( + icon: Icons.people_outline, + title: 'Hide replies from unfollowed accounts', + subtitle: 'Keep replies from people you follow or yourself.', + trailing: Switch.adaptive( + value: hideRepliesByUnfollowed, + onChanged: state.isBusy ? null : cubit.setHideRepliesByUnfollowed, + ), + ), + const Divider(height: 1), + _ReplyLikeThresholdTile( + value: likeThreshold, + enabled: !state.isBusy, + onChanged: cubit.setHideRepliesByLikeCount, + ), + const Divider(height: 1), + SettingsTile( + icon: Icons.repeat_outlined, + title: 'Hide reposts', + subtitle: 'Hide posts shown because someone reposted them.', + trailing: Switch.adaptive(value: hideReposts, onChanged: state.isBusy ? null : cubit.setHideReposts), + ), + const Divider(height: 1), + SettingsTile( + icon: Icons.format_quote_outlined, + title: 'Hide quote posts', + subtitle: 'Hide posts that quote another post.', + trailing: Switch.adaptive( + value: hideQuotePosts, + onChanged: state.isBusy ? null : cubit.setHideQuotePosts, + ), + ), + ], + ), + if (state.status == AccountSettingsStatus.saving) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Text('Saving...', style: context.textTheme.bodySmall), + ), + if (state.status == AccountSettingsStatus.error || state.status == AccountSettingsStatus.saveError) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Text( + 'Could not sync feed display preferences: ${state.message}', + style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.error), + ), + ), + ], + ); + }, + ); +} + +class _ReplyLikeThresholdTile extends StatelessWidget { + const _ReplyLikeThresholdTile({required this.value, required this.enabled, required this.onChanged}); + + static const int _off = -1; + static const List _options = [_off, 1, 2, 5, 10, 25, 50]; + + final int? value; + final bool enabled; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) => SettingsDropdownTile( + title: 'Hide replies below likes', + subtitle: 'Replies with fewer likes are hidden.', + value: _options.contains(value) ? value! : _off, + options: _options, + labelBuilder: (threshold) => threshold == _off ? 'Off' : threshold.toString(), + onChanged: enabled ? (threshold) => onChanged(threshold == _off ? null : threshold) : null, + ); +} diff --git a/lib/features/settings/presentation/widgets/settings_section.dart b/lib/features/settings/presentation/widgets/settings_section.dart new file mode 100644 --- /dev/null +++ b/lib/features/settings/presentation/widgets/settings_section.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; + +class SettingsSectionHeader extends StatelessWidget { + const SettingsSectionHeader(this.title, {super.key}); + + final String title; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text( + title.toUpperCase(), + style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), + ), + ); +} + +class SettingsGroup extends StatelessWidget { + const SettingsGroup({super.key, required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: theme.dividerColor), + bottom: BorderSide(color: theme.dividerColor), + ), + color: theme.cardColor, + ), + child: Column(children: children), + ); + } +} diff --git a/lib/features/settings/presentation/widgets/settings_tiles.dart b/lib/features/settings/presentation/widgets/settings_tiles.dart --- a/lib/features/settings/presentation/widgets/settings_tiles.dart +++ b/lib/features/settings/presentation/widgets/settings_tiles.dart @@ -63,7 +63,7 @@ final T value; final List options; final String Function(T value) labelBuilder; - final ValueChanged onChanged; + final ValueChanged? onChanged; final Widget Function(BuildContext context, T value)? optionBuilder; @override