diff --git a/lib/src/core/l10n/app_localizations.dart b/lib/src/core/l10n/app_localizations.dart index 4d33403d..21b86a35 100644 --- a/lib/src/core/l10n/app_localizations.dart +++ b/lib/src/core/l10n/app_localizations.dart @@ -376,6 +376,12 @@ abstract class AppLocalizations { /// **'We couldn’t save your profile. Check your connection and try again.'** String get onboardingProfileSaveFailed; + /// Error shown when feed setup fails after the onboarding profile is saved + /// + /// In en, this message translates to: + /// **'Your profile is saved, but we couldn’t prepare your feeds. Check your connection and try again.'** + String get onboardingFeedSetupFailed; + /// Small label on the onboarding avatar step /// /// In en, this message translates to: @@ -592,6 +598,54 @@ abstract class AppLocalizations { /// **'Error loading feed'** String get errorLoadingFeed; + /// Title shown when the Following feed has no posts + /// + /// In en, this message translates to: + /// **'Your Following feed is empty'** + String get emptyFollowingFeedTitle; + + /// Guidance shown when the Following feed has no posts + /// + /// In en, this message translates to: + /// **'Follow people to see their posts here.'** + String get emptyFollowingFeedDescription; + + /// Title shown when a non-Following feed has no posts + /// + /// In en, this message translates to: + /// **'Nothing here yet'** + String get emptyFeedTitle; + + /// Description shown when a non-Following feed has no posts + /// + /// In en, this message translates to: + /// **'This feed doesn’t have any posts right now.'** + String get emptyFeedDescription; + + /// Description shown after the user reaches the end of a feed + /// + /// In en, this message translates to: + /// **'Refresh to check for new posts.'** + String get caughtUpFeedDescription; + + /// Refresh feed button text + /// + /// In en, this message translates to: + /// **'Refresh'** + String get buttonRefresh; + + /// Button that opens people search from an empty feed + /// + /// In en, this message translates to: + /// **'Find people'** + String get buttonFindPeople; + + /// Button that switches from an empty feed to Discover + /// + /// In en, this message translates to: + /// **'Explore Discover'** + String get buttonExploreDiscover; + /// Error shown when the app cannot safely update saved feeds /// /// In en, this message translates to: diff --git a/lib/src/core/l10n/app_localizations_en.dart b/lib/src/core/l10n/app_localizations_en.dart index 63d0c12e..3911dda7 100644 --- a/lib/src/core/l10n/app_localizations_en.dart +++ b/lib/src/core/l10n/app_localizations_en.dart @@ -153,6 +153,10 @@ class AppLocalizationsEn extends AppLocalizations { String get onboardingProfileSaveFailed => 'We couldn’t save your profile. Check your connection and try again.'; + @override + String get onboardingFeedSetupFailed => + 'Your profile is saved, but we couldn’t prepare your feeds. Check your connection and try again.'; + @override String get onboardingAvatarEyebrow => 'Profile photo'; @@ -271,6 +275,32 @@ class AppLocalizationsEn extends AppLocalizations { @override String get errorLoadingFeed => 'Error loading feed'; + @override + String get emptyFollowingFeedTitle => 'Your Following feed is empty'; + + @override + String get emptyFollowingFeedDescription => + 'Follow people to see their posts here.'; + + @override + String get emptyFeedTitle => 'Nothing here yet'; + + @override + String get emptyFeedDescription => + 'This feed doesn’t have any posts right now.'; + + @override + String get caughtUpFeedDescription => 'Refresh to check for new posts.'; + + @override + String get buttonRefresh => 'Refresh'; + + @override + String get buttonFindPeople => 'Find people'; + + @override + String get buttonExploreDiscover => 'Explore Discover'; + @override String get errorUpdatingFeeds => 'Could not update feeds. Try again.'; diff --git a/lib/src/core/l10n/intl_en.arb b/lib/src/core/l10n/intl_en.arb index 7ab141e6..13cc1411 100644 --- a/lib/src/core/l10n/intl_en.arb +++ b/lib/src/core/l10n/intl_en.arb @@ -245,6 +245,11 @@ "description": "Error shown when onboarding profile creation fails" }, + "onboardingFeedSetupFailed": "Your profile is saved, but we couldn’t prepare your feeds. Check your connection and try again.", + "@onboardingFeedSetupFailed": { + "description": "Error shown when feed setup fails after the onboarding profile is saved" + }, + "onboardingAvatarEyebrow": "Profile photo", "@onboardingAvatarEyebrow": { "description": "Small label on the onboarding avatar step" @@ -425,6 +430,46 @@ "description": "Error loading feed message" }, + "emptyFollowingFeedTitle": "Your Following feed is empty", + "@emptyFollowingFeedTitle": { + "description": "Title shown when the Following feed has no posts" + }, + + "emptyFollowingFeedDescription": "Follow people to see their posts here.", + "@emptyFollowingFeedDescription": { + "description": "Guidance shown when the Following feed has no posts" + }, + + "emptyFeedTitle": "Nothing here yet", + "@emptyFeedTitle": { + "description": "Title shown when a non-Following feed has no posts" + }, + + "emptyFeedDescription": "This feed doesn’t have any posts right now.", + "@emptyFeedDescription": { + "description": "Description shown when a non-Following feed has no posts" + }, + + "caughtUpFeedDescription": "Refresh to check for new posts.", + "@caughtUpFeedDescription": { + "description": "Description shown after the user reaches the end of a feed" + }, + + "buttonRefresh": "Refresh", + "@buttonRefresh": { + "description": "Refresh feed button text" + }, + + "buttonFindPeople": "Find people", + "@buttonFindPeople": { + "description": "Button that opens people search from an empty feed" + }, + + "buttonExploreDiscover": "Explore Discover", + "@buttonExploreDiscover": { + "description": "Button that switches from an empty feed to Discover" + }, + "errorUpdatingFeeds": "Could not update feeds. Try again.", "@errorUpdatingFeeds": { "description": "Error shown when the app cannot safely update saved feeds" diff --git a/lib/src/core/storage/preferences/default_preferences.dart b/lib/src/core/storage/preferences/default_preferences.dart index bedb1912..15fb1658 100644 --- a/lib/src/core/storage/preferences/default_preferences.dart +++ b/lib/src/core/storage/preferences/default_preferences.dart @@ -4,6 +4,11 @@ import 'package:spark/src/core/network/atproto/data/models/pref_models.dart'; class DefaultPreferences { DefaultPreferences._(); + static const theVidsFeedUri = + 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/thevids'; + static const discoverFeedUri = + 'at://did:plc:cveom2iroj3mt747sd4qqnr2/so.sprk.feed.generator/discover'; + /// Get default preferences including default feeds and label preferences /// [modServiceDid] The DID of the mod service labeler (required) static Preferences defaultPreferences({required String modServiceDid}) { @@ -11,18 +16,8 @@ class DefaultPreferences { // Default feeds: timeline, forYou, latest final defaultFeeds = [ makeSavedFeed(type: 'timeline', value: 'following', pinned: true), - makeSavedFeed( - type: 'feed', - value: - 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/thevids', - pinned: true, - ), - makeSavedFeed( - type: 'feed', - value: - 'at://did:plc:cveom2iroj3mt747sd4qqnr2/so.sprk.feed.generator/discover', - pinned: true, - ), + makeSavedFeed(type: 'feed', value: theVidsFeedUri, pinned: true), + makeSavedFeed(type: 'feed', value: discoverFeedUri, pinned: true), ]; // Default labelers diff --git a/lib/src/features/auth/ui/onboarding/onboarding_sequence.dart b/lib/src/features/auth/ui/onboarding/onboarding_sequence.dart index 4b20e249..5f99c442 100644 --- a/lib/src/features/auth/ui/onboarding/onboarding_sequence.dart +++ b/lib/src/features/auth/ui/onboarding/onboarding_sequence.dart @@ -136,7 +136,9 @@ class _OnboardingSequenceState extends State { Expanded( child: AppButton( label: l10n.buttonBack, - onPressed: _goToPrevious, + onPressed: widget.isCompleteLoading + ? null + : _goToPrevious, variant: AppButtonVariant.neutral, size: AppButtonSize.medium, fullWidth: true, diff --git a/lib/src/features/auth/ui/pages/onboarding_page.dart b/lib/src/features/auth/ui/pages/onboarding_page.dart index 48ecf59c..1ce192cf 100644 --- a/lib/src/features/auth/ui/pages/onboarding_page.dart +++ b/lib/src/features/auth/ui/pages/onboarding_page.dart @@ -27,6 +27,13 @@ class _OnboardingPageState extends ConsumerState { final _displayNameStepKey = GlobalKey(); final _bioStepKey = GlobalKey(); bool _isCompleting = false; + bool _profileCreated = false; + + void _handleStepChanged(int _) { + if (!_profileCreated) return; + _profileCreated = false; + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + } Future _handleCompleteOnboarding() async { if (_isCompleting) return; @@ -52,15 +59,18 @@ class _OnboardingPageState extends ConsumerState { avatarToUse = currentState!.bskyProfileRecord!.avatar; } - await onboardingState.createCustomProfile( - displayName: displayNameState!.displayName.trim(), - description: bioState!.description.trim(), - avatar: avatarToUse, - ); + if (!_profileCreated) { + await onboardingState.createCustomProfile( + displayName: displayNameState!.displayName.trim(), + description: bioState!.description.trim(), + avatar: avatarToUse, + ); + _profileCreated = true; + } if (!mounted) return; - await ref.read(settingsProvider.notifier).syncPreferencesFromServer(); + await ref.read(settingsProvider.notifier).preparePostOnboardingFeed(); if (!mounted) return; @@ -73,7 +83,9 @@ class _OnboardingPageState extends ConsumerState { ..showSnackBar( SnackBar( content: Text( - AppLocalizations.of(context).onboardingProfileSaveFailed, + _profileCreated + ? AppLocalizations.of(context).onboardingFeedSetupFailed + : AppLocalizations.of(context).onboardingProfileSaveFailed, ), action: SnackBarAction( label: AppLocalizations.of(context).buttonRetry, @@ -176,12 +188,18 @@ class _OnboardingPageState extends ConsumerState { builder: (context) => OnboardingBioStep( key: _bioStepKey, initialDescription: state.description, - onDescriptionChanged: notifier.updateDescription, + onDescriptionChanged: (description) { + _profileCreated = false; + notifier.updateDescription(description); + }, onUndoDescription: (state.bskyProfileRecord?.description != null && state.description != (state.bskyProfileRecord?.description ?? '')) - ? notifier.resetDescription + ? () { + _profileCreated = false; + notifier.resetDescription(); + } : null, ), canProceed: () => _bioStepKey.currentState?.validate() ?? false, @@ -191,6 +209,7 @@ class _OnboardingPageState extends ConsumerState { return OnboardingSequence( steps: steps, isCompleteLoading: _isCompleting || profileSaveState.isLoading, + onIndexChanged: _handleStepChanged, onComplete: _handleCompleteOnboarding, ); }, diff --git a/lib/src/features/feed/ui/pages/feed_page.dart b/lib/src/features/feed/ui/pages/feed_page.dart index a2250036..122965dd 100644 --- a/lib/src/features/feed/ui/pages/feed_page.dart +++ b/lib/src/features/feed/ui/pages/feed_page.dart @@ -1,16 +1,23 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spark/src/core/design_system/tokens/colors.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; -import 'package:spark/src/core/design_system/tokens/colors.dart'; +import 'package:spark/src/core/storage/preferences/default_preferences.dart'; import 'package:spark/src/features/feed/providers/feed_action_controller.dart'; import 'package:spark/src/features/feed/providers/feed_provider.dart'; import 'package:spark/src/features/feed/providers/feed_refresh_trigger_provider.dart'; +import 'package:spark/src/features/feed/providers/visible_pinned_feeds_provider.dart'; import 'package:spark/src/features/feed/ui/widgets/feed/cacheable_page_view.dart'; import 'package:spark/src/features/feed/ui/widgets/feed/snappy_page_scroll_physics.dart'; import 'package:spark/src/features/feed/ui/widgets/post/feed_post_skeleton.dart'; import 'package:spark/src/features/feed/ui/widgets/post/feed_post_widget.dart'; -import 'package:spark/src/features/feed/ui/widgets/post/no_more_posts.dart'; +import 'package:spark/src/features/feed/ui/widgets/post/feed_terminal_state.dart'; +import 'package:spark/src/features/home/providers/navigation_provider.dart'; +import 'package:spark/src/features/settings/providers/settings_provider.dart'; class FeedPage extends ConsumerStatefulWidget { const FeedPage({required this.feed, required this.isActive, super.key}); @@ -84,6 +91,24 @@ class _FeedPageState extends ConsumerState notifier.removePostAtIndex(currentIndex); } + Feed? _discoverFeed(List visiblePinnedFeeds) { + for (final feed in visiblePinnedFeeds) { + if (feed.config.value == DefaultPreferences.discoverFeedUri) { + return feed; + } + } + return null; + } + + void _exploreDiscover(Feed feed) { + unawaited(ref.read(settingsProvider.notifier).setActiveFeed(feed)); + } + + void _findPeople() { + AutoTabsRouter.of(context).setActiveIndex(1); + ref.read(navigationProvider.notifier).updateIndex(1); + } + @override Widget build(BuildContext context) { super.build(context); // Required for AutomaticKeepAliveClientMixin @@ -146,85 +171,95 @@ class _FeedPageState extends ConsumerState } } + final visiblePinnedFeeds = ref.watch(visiblePinnedFeedsProvider).feeds; + final discoverFeed = _discoverFeed(visiblePinnedFeeds); + final VoidCallback? onExploreDiscover = + discoverFeed != null && discoverFeed.config.id != widget.feed.config.id + ? () => _exploreDiscover(discoverFeed) + : null; + final Widget content; + if (state.loadingFirstLoad) { + content = const FeedPostSkeleton(); + } else if (state.error) { + content = Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(l10n.errorLoadingFeed), + TextButton(onPressed: onRefresh, child: Text(l10n.buttonTryAgain)), + ], + ), + ); + } else if (state.length == 0 && state.isEndOfNetworkFeed) { + final isFollowing = + widget.feed.type == 'timeline' && + widget.feed.config.value == 'following'; + content = FeedTerminalState( + variant: isFollowing + ? FeedTerminalStateVariant.emptyFollowing + : FeedTerminalStateVariant.emptyFeed, + onRefresh: onRefresh, + onFindPeople: isFollowing ? _findPeople : null, + onExploreDiscover: onExploreDiscover, + ); + } else { + content = CacheablePageView.builder( + cachePageExtent: 1, + controller: pageController, + key: PageStorageKey(widget.feed.config.id), + itemCount: state.length + (state.isEndOfNetworkFeed ? 1 : 0), + scrollDirection: Axis.vertical, + restorationId: widget.feed.config.id, + physics: shouldBeActive + ? const SnappyPageScrollPhysics() + : const NeverScrollableScrollPhysics(), + allowImplicitScrolling: true, + onPageChanged: (index) { + if (shouldBeActive) { + if (index > state.index) { + notifier.scrollDown(); + } + notifier.setIndex(index); + } + }, + itemBuilder: (context, index) { + if (index == state.length) { + return FeedTerminalState( + variant: FeedTerminalStateVariant.caughtUp, + onRefresh: onRefresh, + onExploreDiscover: onExploreDiscover, + ); + } + if (index == state.length - 1 && + !state.isEndOfNetworkFeed && + shouldBeActive) { + return Stack( + children: [ + FeedPostWidget(index: index, feed: widget.feed), + const Positioned( + bottom: 10, + left: 10, + child: SizedBox( + width: 15, + height: 15, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.white, + ), + ), + ), + ], + ); + } + return FeedPostWidget(index: index, feed: widget.feed); + }, + ); + } + return RefreshIndicator( key: _refreshIndicatorKey, onRefresh: onRefresh, - child: state.loadingFirstLoad - ? const FeedPostSkeleton() - : state.error - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(l10n.errorLoadingFeed), - TextButton( - onPressed: onRefresh, - child: Text(l10n.buttonTryAgain), - ), - ], - ), - ) - : CacheablePageView.builder( - cachePageExtent: 1, - controller: pageController, - key: PageStorageKey(widget.feed.config.id), - itemCount: state.length + (state.isEndOfNetworkFeed ? 1 : 0), - scrollDirection: Axis.vertical, - restorationId: widget.feed.config.id, - physics: shouldBeActive - ? const SnappyPageScrollPhysics() - : const NeverScrollableScrollPhysics(), - allowImplicitScrolling: true, - onPageChanged: (index) { - // Only handle page changes when active - if (shouldBeActive) { - if (index > state.index) { - notifier.scrollDown(); - } - notifier.setIndex(index); - } - }, - itemBuilder: (context, index) { - // Handle end of feed - if (index == state.length) { - return const NoMorePosts(); - } - // Handle last item with loading indicator - else if (index == state.length - 1 && - !state.isEndOfNetworkFeed) { - if (shouldBeActive) { - return Stack( - children: [ - FeedPostWidget(index: index, feed: widget.feed), - const Positioned( - bottom: 10, - left: 10, - child: SizedBox( - width: 15, - height: 15, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.white, - ), - ), - ), - ], - ); - } - return FeedPostWidget(index: index, feed: widget.feed); - } - // Handle empty state - else if (state.length == 0 && !state.loadingFirstLoad) { - return shouldBeActive - ? const NoMorePosts() - : const DecoratedBox( - decoration: BoxDecoration(color: AppColors.black), - ); - } else { - return FeedPostWidget(index: index, feed: widget.feed); - } - }, - ), + child: content, ); } } diff --git a/lib/src/features/feed/ui/widgets/post/feed_terminal_state.dart b/lib/src/features/feed/ui/widgets/post/feed_terminal_state.dart new file mode 100644 index 00000000..e60511fa --- /dev/null +++ b/lib/src/features/feed/ui/widgets/post/feed_terminal_state.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/app_button.dart'; +import 'package:spark/src/core/design_system/tokens/colors.dart'; +import 'package:spark/src/core/design_system/tokens/typography.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; + +enum FeedTerminalStateVariant { emptyFollowing, emptyFeed, caughtUp } + +class FeedTerminalState extends StatelessWidget { + const FeedTerminalState({ + required this.variant, + required this.onRefresh, + super.key, + this.onExploreDiscover, + this.onFindPeople, + }); + + final FeedTerminalStateVariant variant; + final VoidCallback onRefresh; + final VoidCallback? onExploreDiscover; + final VoidCallback? onFindPeople; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final (title, description) = switch (variant) { + FeedTerminalStateVariant.emptyFollowing => ( + l10n.emptyFollowingFeedTitle, + l10n.emptyFollowingFeedDescription, + ), + FeedTerminalStateVariant.emptyFeed => ( + l10n.emptyFeedTitle, + l10n.emptyFeedDescription, + ), + FeedTerminalStateVariant.caughtUp => ( + l10n.messageAllCaughtUp, + l10n.caughtUpFeedDescription, + ), + }; + + Widget content = Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 96), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + textAlign: TextAlign.center, + style: AppTypography.headlineSmallBold.copyWith( + color: AppColors.white, + ), + ), + const SizedBox(height: 12), + Text( + description, + textAlign: TextAlign.center, + style: AppTypography.textMediumMedium.copyWith( + color: AppColors.grey300, + ), + ), + const SizedBox(height: 28), + ..._actions(l10n), + ], + ), + ), + ); + if (variant != FeedTerminalStateVariant.caughtUp) { + content = SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: content, + ); + } + + return DecoratedBox( + decoration: const BoxDecoration(color: AppColors.black), + child: SafeArea(child: Center(child: content)), + ); + } + + List _actions(AppLocalizations l10n) { + final actions = []; + if (variant == FeedTerminalStateVariant.emptyFollowing && + onFindPeople != null) { + actions.add( + AppButton( + label: l10n.buttonFindPeople, + onPressed: onFindPeople, + fullWidth: true, + ), + ); + } else { + actions.add( + AppButton( + label: l10n.buttonRefresh, + onPressed: onRefresh, + fullWidth: true, + ), + ); + } + + if (onExploreDiscover != null) { + actions + ..add(const SizedBox(height: 12)) + ..add( + AppButton( + label: l10n.buttonExploreDiscover, + onPressed: onExploreDiscover, + variant: AppButtonVariant.secondary, + fullWidth: true, + ), + ); + } + return actions; + } +} diff --git a/lib/src/features/feed/ui/widgets/post/no_more_posts.dart b/lib/src/features/feed/ui/widgets/post/no_more_posts.dart deleted file mode 100644 index a2964533..00000000 --- a/lib/src/features/feed/ui/widgets/post/no_more_posts.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:spark/src/core/design_system/tokens/colors.dart'; - -class NoMorePosts extends StatelessWidget { - const NoMorePosts({super.key}); - - @override - Widget build(BuildContext context) { - return const DecoratedBox( - decoration: BoxDecoration(color: AppColors.black), - child: Center( - child: Text( - 'No more posts in this feed.', - style: TextStyle(color: AppColors.white), - ), - ), - ); - } -} diff --git a/lib/src/features/settings/providers/settings_provider.dart b/lib/src/features/settings/providers/settings_provider.dart index 3ab8b6e7..c1a3e186 100644 --- a/lib/src/features/settings/providers/settings_provider.dart +++ b/lib/src/features/settings/providers/settings_provider.dart @@ -39,8 +39,9 @@ class Settings extends _$Settings { /// Tracks if settings have been loaded to prevent resetting state on rebuild bool _hasLoadedSettings = false; - /// Tracks if loadSettings currently in progress to prevent concurrent calls - bool _isLoadingSettings = false; + Future? _settingsOperation; + + bool _createdDefaultFeedsThisSession = false; SettingsState? _preservedState; @@ -61,6 +62,11 @@ class Settings extends _$Settings { return 'active_feed_$did'; } + String get _pendingInitialFeedStorageKey { + final did = sprkRepository.authRepository.did ?? 'anonymous'; + return 'pending_initial_feed_$did'; + } + String get _defaultModServiceDid { // Extract DID part from modDid (remove fragment if present) final modDid = sprkRepository.modDid; @@ -172,16 +178,45 @@ class Settings extends _$Settings { } /// Saves the active feed to local storage - Future _saveActiveFeedToStorage(Feed feed) async { + Future _saveActiveFeedToStorage( + Feed feed, { + bool rethrowErrors = false, + }) async { try { final storage = GetIt.instance().preferences; await storage.setObject(_activeFeedStorageKey, feed.toJson()); logger.d('Saved active feed to storage: ${feed.config.value}'); - } catch (e) { - logger.w('Error saving active feed: $e'); + } catch (e, st) { + logger.w('Error saving active feed', error: e, stackTrace: st); + if (rethrowErrors) rethrow; + } + } + + Future _markInitialFeedSelectionPending() async { + try { + final storage = GetIt.instance().preferences; + await storage.setBool(_pendingInitialFeedStorageKey, true); + } catch (e, st) { + logger.w( + 'Error marking initial feed selection as pending', + error: e, + stackTrace: st, + ); } } + Future _isInitialFeedSelectionPending() async { + if (_createdDefaultFeedsThisSession) return true; + final storage = GetIt.instance().preferences; + return await storage.getBool(_pendingInitialFeedStorageKey) ?? false; + } + + Future _clearInitialFeedSelectionPending() async { + final storage = GetIt.instance().preferences; + await storage.remove(_pendingInitialFeedStorageKey); + _createdDefaultFeedsThisSession = false; + } + @override SettingsState build() { // Note: We intentionally don't watch userPreferencesProvider here. @@ -201,7 +236,7 @@ class Settings extends _$Settings { // Load settings asynchronously but return a temporary state immediately // This prevents blocking the UI while loading - if (!_hasLoadedSettings && !_isLoadingSettings) { + if (!_hasLoadedSettings && _settingsOperation == null) { Future.microtask(loadSettings); } @@ -210,22 +245,15 @@ class Settings extends _$Settings { } /// Loads all settings from the preferences provider - Future loadSettings() async { - // Guard against concurrent calls - if (_isLoadingSettings) { - logger.d('loadSettings already in progress, skipping duplicate call'); - return; - } + Future loadSettings() => + _runSettingsOperation(() => _loadSettings(rethrowErrors: false)); - // Skip if already loaded - // (but allow explicit refresh via syncPreferencesFromServer) + Future _loadSettings({required bool rethrowErrors}) async { if (_hasLoadedSettings) { logger.d('Settings already loaded, skipping'); return; } - _isLoadingSettings = true; - try { logger.d('Loading settings from preferences...'); @@ -235,6 +263,9 @@ class Settings extends _$Settings { // Don't load settings if not authenticated - wait for login if (!authRepository.isAuthenticated) { + if (rethrowErrors) { + throw StateError('Cannot prepare feeds while unauthenticated'); + } return; } @@ -245,6 +276,7 @@ class Settings extends _$Settings { // If there are no feeds, set default preferences if (savedFeeds.isEmpty) { try { + var createdDefaultFeeds = false; final modServiceDid = _defaultModServiceDid; final defaultPrefs = DefaultPreferences.defaultPreferences( modServiceDid: modServiceDid, @@ -253,6 +285,7 @@ class Settings extends _$Settings { if (_getSavedFeedsFromPreferences(current).isNotEmpty) { return current; } + createdDefaultFeeds = true; return Preferences( preferences: [ ...current.preferences.where( @@ -278,10 +311,9 @@ class Settings extends _$Settings { final updatedFeeds = await _loadFeedsFromSavedFeeds( updatedSavedFeeds, ); - final updatedActiveFeed = _getActiveFeedFromFeeds( - updatedFeeds, - updatedSavedFeeds, - ); + final updatedActiveFeed = createdDefaultFeeds + ? _getPreferredInitialFeed(updatedFeeds) + : _getActiveFeedFromFeeds(updatedFeeds, updatedSavedFeeds); // Update liked feeds based on viewer state final likedFeeds = updatedFeeds @@ -295,11 +327,21 @@ class Settings extends _$Settings { ); _hasLoadedSettings = true; + if (createdDefaultFeeds) { + _createdDefaultFeedsThisSession = true; + await _markInitialFeedSelectionPending(); + } + // Save the default active feed to storage await _saveActiveFeedToStorage(updatedActiveFeed); return; - } catch (e) { - logger.e('Error setting default preferences: $e'); + } catch (e, st) { + logger.e( + 'Error setting default preferences', + error: e, + stackTrace: st, + ); + if (rethrowErrors) rethrow; // Continue with default feed if setting defaults fails } } @@ -344,10 +386,30 @@ class Settings extends _$Settings { _hasLoadedSettings = true; logger.d('Settings state updated successfully'); - } catch (e) { - logger.e('Error loading settings: $e'); + } catch (e, st) { + logger.e('Error loading settings', error: e, stackTrace: st); + if (rethrowErrors) rethrow; + } + } + + Future _runSettingsOperation(Future Function() operation) async { + while (_settingsOperation != null) { + try { + await _settingsOperation; + } catch (_) { + // The caller that started the operation owns its error. A queued + // operation should still get an opportunity to run. + } + } + + final future = operation(); + _settingsOperation = future; + try { + await future; } finally { - _isLoadingSettings = false; + if (identical(_settingsOperation, future)) { + _settingsOperation = null; + } } } @@ -420,20 +482,50 @@ class Settings extends _$Settings { /// - Manually from the settings UI if user wants to refresh preferences Future syncPreferencesFromServer() async { try { - // Reset all load state to force a fresh load from server - // Reset flags to handle race conditions (e.g., login while loading) + await _runSettingsOperation( + () => _syncPreferencesFromServer(rethrowErrors: false), + ); + logger.d('Preferences synced successfully'); + } catch (e, st) { + logger.e( + 'Error syncing preferences from server', + error: e, + stackTrace: st, + ); + } + } + + /// Prepares a deterministic first feed before onboarding enters the app. + /// + /// Unlike background preference synchronization, this method reports + /// failures to its caller. It selects Discover only when this installation + /// previously created the user's default feeds, preserving established + /// active-feed choices. + Future preparePostOnboardingFeed() => _runSettingsOperation(() async { + await _syncPreferencesFromServer(rethrowErrors: true); + if (!await _isInitialFeedSelectionPending()) return; + + final initialFeed = _getPreferredInitialFeed(state.feeds); + state = state.copyWith(activeFeed: initialFeed); + await _saveActiveFeedToStorage(initialFeed, rethrowErrors: true); + await _clearInitialFeedSelectionPending(); + }); + + Future _syncPreferencesFromServer({required bool rethrowErrors}) async { + try { _hasLoadedSettings = false; - _isLoadingSettings = false; ref.read(labelerSettingsControllerProvider).resetSessionCache(); - // Refresh preferences from server - use Future to avoid modifying - // provider during widget build phase await Future(() async { await ref.read(userPreferencesProvider.notifier).refresh(); }); - await loadSettings(); - logger.d('Preferences synced successfully'); - } catch (e) { - logger.e('Error syncing preferences from server: $e'); + await _loadSettings(rethrowErrors: rethrowErrors); + } catch (e, st) { + logger.e( + 'Error syncing preferences from server', + error: e, + stackTrace: st, + ); + if (rethrowErrors) rethrow; } } @@ -521,6 +613,15 @@ class Settings extends _$Settings { Future setActiveFeed(Feed feed) async { state = state.copyWith(activeFeed: feed); await _saveActiveFeedToStorage(feed); + try { + await _clearInitialFeedSelectionPending(); + } catch (e, st) { + logger.w( + 'Error clearing initial feed selection after user choice', + error: e, + stackTrace: st, + ); + } } // Helper methods for working with Preferences @@ -558,6 +659,22 @@ class Settings extends _$Settings { } } + Feed _getPreferredInitialFeed(List feeds) { + for (final feed in feeds) { + if (feed.config.pinned && + feed.config.value == DefaultPreferences.discoverFeedUri) { + return feed; + } + } + for (final feed in feeds) { + if (feed.config.pinned && feed.type != 'timeline') return feed; + } + for (final feed in feeds) { + if (feed.config.pinned) return feed; + } + return feeds.isEmpty ? defaultFeed : feeds.first; + } + Future getActiveFeed() async { final preferences = await _getPreferences(); final savedFeeds = _getSavedFeedsFromPreferences(preferences); diff --git a/test/src/features/auth/ui/pages/onboarding_page_test.dart b/test/src/features/auth/ui/pages/onboarding_page_test.dart index e6b350d8..7669eae5 100644 --- a/test/src/features/auth/ui/pages/onboarding_page_test.dart +++ b/test/src/features/auth/ui/pages/onboarding_page_test.dart @@ -87,11 +87,51 @@ void main() { ); }); - testWidgets('prevents duplicate submission while settings sync is pending', ( + testWidgets( + 'prevents duplicate submission while feed preparation is pending', + (tester) async { + final saveNotifier = _SuccessfulOnboardingState(); + final settingsNotifier = _BlockingSettings(); + final container = _container( + saveNotifier, + settingsNotifier: settingsNotifier, + ); + addTearDown(container.dispose); + + await _pumpPage(tester, container); + await _tapButton(tester, 'Continue'); + await _tapButton(tester, 'Continue'); + await _tapButton(tester, 'Continue'); + + final confirmButton = find.widgetWithText(AppButton, 'Confirm'); + await tester.tap(confirmButton); + await tester.pump(); + + expect(saveNotifier.calls, 1); + expect(settingsNotifier.prepareCalls, 1); + expect(tester.widget(confirmButton).onPressed, isNull); + expect( + tester + .widget(find.widgetWithText(AppButton, 'Back')) + .onPressed, + isNull, + ); + + await tester.tap(confirmButton); + await tester.pump(); + + expect(saveNotifier.calls, 1); + + settingsNotifier.completePreparation(); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('retries only feed setup after the profile is saved', ( tester, ) async { final saveNotifier = _SuccessfulOnboardingState(); - final settingsNotifier = _BlockingSettings(); + final settingsNotifier = _FailingSettings(); final container = _container( saveNotifier, settingsNotifier: settingsNotifier, @@ -102,22 +142,111 @@ void main() { await _tapButton(tester, 'Continue'); await _tapButton(tester, 'Continue'); await _tapButton(tester, 'Continue'); + await _tapButton(tester, 'Confirm'); - final confirmButton = find.widgetWithText(AppButton, 'Confirm'); - await tester.tap(confirmButton); - await tester.pump(); + expect(saveNotifier.calls, 1); + expect(settingsNotifier.prepareCalls, 1); + expect( + find.text( + 'Your profile is saved, but we couldn’t prepare your feeds. ' + 'Check your connection and try again.', + ), + findsOneWidget, + ); + + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); expect(saveNotifier.calls, 1); - expect(settingsNotifier.syncCalls, 1); - expect(tester.widget(confirmButton).onPressed, isNull); + expect(settingsNotifier.prepareCalls, 2); + }); + + testWidgets('saves profile edits made after feed setup fails', ( + tester, + ) async { + final saveNotifier = _SuccessfulOnboardingState(); + final settingsNotifier = _FailingSettings(); + final container = _container( + saveNotifier, + settingsNotifier: settingsNotifier, + ); + addTearDown(container.dispose); - await tester.tap(confirmButton); + await _pumpPage(tester, container); + await _tapButton(tester, 'Continue'); + await _tapButton(tester, 'Continue'); + await _tapButton(tester, 'Continue'); + await _tapButton(tester, 'Confirm'); + + ScaffoldMessenger.of( + tester.element(find.byType(OnboardingPage)), + ).hideCurrentSnackBar(); + await tester.pumpAndSettle(); + await _tapButton(tester, 'Back'); + await tester.enterText(find.byType(TextFormField).first, 'Updated name'); await tester.pump(); + await _tapButton(tester, 'Continue'); + await _tapButton(tester, 'Confirm'); - expect(saveNotifier.calls, 1); + expect(saveNotifier.calls, 2); + expect(saveNotifier.lastDisplayName, 'Updated name'); + expect(settingsNotifier.prepareCalls, 2); + }); - settingsNotifier.completeSync(); + testWidgets('saves bio edits made on the final step after feed setup fails', ( + tester, + ) async { + final saveNotifier = _SuccessfulOnboardingState(); + final settingsNotifier = _FailingSettings(); + final container = _container( + saveNotifier, + settingsNotifier: settingsNotifier, + ); + addTearDown(container.dispose); + + await _pumpPage(tester, container); + await _tapButton(tester, 'Continue'); + await _tapButton(tester, 'Continue'); + await _tapButton(tester, 'Continue'); + await _tapButton(tester, 'Confirm'); + + await tester.enterText(find.byType(TextFormField), 'Updated biography'); + await tester.pump(); + await tester.tap(find.text('Retry')); await tester.pumpAndSettle(); + + expect(saveNotifier.calls, 2); + expect(saveNotifier.lastDescription, 'Updated biography'); + expect(settingsNotifier.prepareCalls, 2); + }); + + testWidgets('saves a final-step bio revert after feed setup fails', ( + tester, + ) async { + final saveNotifier = _SuccessfulOnboardingState(); + final settingsNotifier = _FailingSettings(); + final container = _container( + saveNotifier, + settingsNotifier: settingsNotifier, + ); + addTearDown(container.dispose); + + await _pumpPage(tester, container); + await _tapButton(tester, 'Continue'); + await _tapButton(tester, 'Continue'); + await _tapButton(tester, 'Continue'); + await tester.enterText(find.byType(TextFormField), 'Custom biography'); + await tester.pump(); + await _tapButton(tester, 'Confirm'); + + await tester.tap(find.byTooltip('Revert')); + await tester.pump(); + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + + expect(saveNotifier.calls, 2); + expect(saveNotifier.lastDescription, 'Imported biography'); + expect(settingsNotifier.prepareCalls, 2); }); } @@ -198,6 +327,8 @@ class _FailingOnboardingState extends OnboardingState { class _SuccessfulOnboardingState extends OnboardingState { int calls = 0; + String? lastDisplayName; + String? lastDescription; @override Future build() async {} @@ -209,12 +340,14 @@ class _SuccessfulOnboardingState extends OnboardingState { dynamic avatar, }) async { calls++; + lastDisplayName = displayName; + lastDescription = description; } } class _BlockingSettings extends Settings { - final _syncCompleter = Completer(); - int syncCalls = 0; + final _preparationCompleter = Completer(); + int prepareCalls = 0; @override SettingsState build() => SettingsState( @@ -225,10 +358,28 @@ class _BlockingSettings extends Settings { ); @override - Future syncPreferencesFromServer() { - syncCalls++; - return _syncCompleter.future; + Future preparePostOnboardingFeed() { + prepareCalls++; + return _preparationCompleter.future; } - void completeSync() => _syncCompleter.complete(); + void completePreparation() => _preparationCompleter.complete(); +} + +class _FailingSettings extends Settings { + int prepareCalls = 0; + + @override + SettingsState build() => SettingsState( + activeFeed: Feed( + type: 'timeline', + config: makeSavedFeed(type: 'timeline', value: 'following', pinned: true), + ), + ); + + @override + Future preparePostOnboardingFeed() async { + prepareCalls++; + throw StateError('Feed preparation failed'); + } } diff --git a/test/src/features/feed/ui/pages/feed_page_test.dart b/test/src/features/feed/ui/pages/feed_page_test.dart new file mode 100644 index 00000000..58e34b7c --- /dev/null +++ b/test/src/features/feed/ui/pages/feed_page_test.dart @@ -0,0 +1,287 @@ +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/app_button.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; +import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; +import 'package:spark/src/core/network/atproto/data/models/pref_models.dart'; +import 'package:spark/src/core/storage/preferences/default_preferences.dart'; +import 'package:spark/src/features/feed/providers/feed_provider.dart'; +import 'package:spark/src/features/feed/providers/feed_state.dart'; +import 'package:spark/src/features/feed/providers/visible_pinned_feeds_provider.dart'; +import 'package:spark/src/features/feed/ui/pages/feed_page.dart'; +import 'package:spark/src/features/feed/ui/widgets/feed/cacheable_page_view.dart'; +import 'package:spark/src/features/feed/ui/widgets/post/feed_terminal_state.dart'; +import 'package:spark/src/features/settings/providers/settings_provider.dart'; +import 'package:spark/src/features/settings/providers/settings_state.dart'; + +void main() { + testWidgets('an empty Following feed explains how to populate it', ( + tester, + ) async { + final following = _followingFeed(); + final discover = _discoverFeed(); + final settings = _FakeSettings( + SettingsState(activeFeed: following, feeds: [following, discover]), + ); + final container = _container( + feedState: _emptyFeedState(), + settings: settings, + visibleFeeds: [following, discover], + ); + addTearDown(container.dispose); + + await _pumpFeedPage(tester, container, following); + + expect(find.text('Your Following feed is empty'), findsOneWidget); + expect(find.text('Follow people to see their posts here.'), findsOneWidget); + expect(find.text('Find people'), findsOneWidget); + expect(find.text('Explore Discover'), findsOneWidget); + expect(find.text("You're all caught up!"), findsNothing); + + await tester.tap(find.widgetWithText(AppButton, 'Explore Discover')); + await tester.pump(); + + expect(settings.selectedFeeds, [discover]); + await _disposeFeedPage(tester); + }); + + testWidgets('a concealed Discover feed is not offered', (tester) async { + final following = _followingFeed(); + final discover = _discoverFeed(); + final container = _container( + feedState: _emptyFeedState(), + settings: _FakeSettings( + SettingsState(activeFeed: following, feeds: [following, discover]), + ), + visibleFeeds: [following], + ); + addTearDown(container.dispose); + + await _pumpFeedPage(tester, container, following); + + expect(find.text('Explore Discover'), findsNothing); + await _disposeFeedPage(tester); + }); + + testWidgets('feed errors remain distinct from empty content', (tester) async { + final feed = _discoverFeed(); + final container = _container( + feedState: _emptyFeedState().copyWith(error: true), + settings: _FakeSettings(SettingsState(activeFeed: feed, feeds: [feed])), + visibleFeeds: [feed], + ); + addTearDown(container.dispose); + + await _pumpFeedPage(tester, container, feed); + + expect(find.text('Error loading feed'), findsOneWidget); + expect(find.text('Nothing here yet'), findsNothing); + await _disposeFeedPage(tester); + }); + + testWidgets('an empty custom feed offers a refresh without follow guidance', ( + tester, + ) async { + final feed = _customFeed(); + final container = _container( + feedState: _emptyFeedState(), + settings: _FakeSettings(SettingsState(activeFeed: feed, feeds: [feed])), + visibleFeeds: [feed], + ); + addTearDown(container.dispose); + + await _pumpFeedPage(tester, container, feed); + + expect(find.text('Nothing here yet'), findsOneWidget); + expect( + find.text('This feed doesn’t have any posts right now.'), + findsOneWidget, + ); + expect(find.text('Refresh'), findsOneWidget); + expect(find.text('Find people'), findsNothing); + + final notifier = + container.read(feedProvider(feed).notifier) as _FakeFeedNotifier; + await tester.drag(find.byType(SingleChildScrollView), const Offset(0, 400)); + await tester.pumpAndSettle(); + + expect(notifier.loadCalls, 1); + await _disposeFeedPage(tester); + }); + + testWidgets('caught-up state refreshes without claiming the feed is empty', ( + tester, + ) async { + var refreshCalls = 0; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: FeedTerminalState( + variant: FeedTerminalStateVariant.caughtUp, + onRefresh: () => refreshCalls++, + ), + ), + ); + + expect(find.text("You're all caught up!"), findsOneWidget); + expect(find.text('Refresh to check for new posts.'), findsOneWidget); + expect(find.text('Your Following feed is empty'), findsNothing); + + await tester.tap(find.widgetWithText(AppButton, 'Refresh')); + await tester.pump(); + + expect(refreshCalls, 1); + }); + + testWidgets('caught-up state yields vertical drags to the feed pager', ( + tester, + ) async { + final controller = PageController(initialPage: 1); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: CacheablePageView( + controller: controller, + scrollDirection: Axis.vertical, + children: [ + const ColoredBox(key: Key('previous-post'), color: Colors.black), + FeedTerminalState( + variant: FeedTerminalStateVariant.caughtUp, + onRefresh: () {}, + ), + ], + ), + ), + ); + + await tester.drag(find.text("You're all caught up!"), const Offset(0, 500)); + await tester.pumpAndSettle(); + + expect(controller.page, 0); + expect(find.byKey(const Key('previous-post')), findsOneWidget); + }); +} + +Future _disposeFeedPage(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 1)); +} + +ProviderContainer _container({ + required FeedState feedState, + required _FakeSettings settings, + required List visibleFeeds, +}) => ProviderContainer.test( + overrides: [ + settingsProvider.overrideWith(() => settings), + feedProvider.overrideWith2((feed) => _FakeFeedNotifier(feedState)), + visiblePinnedFeedsProvider.overrideWithValue(( + effectiveActiveFeed: settings.initialState.activeFeed, + feeds: visibleFeeds, + shouldPersistEffectiveActiveFeed: false, + )), + ], +); + +Future _pumpFeedPage( + WidgetTester tester, + ProviderContainer container, + Feed feed, +) async { + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: FeedPage(feed: feed, isActive: true), + ), + ), + ); + await tester.pump(); +} + +FeedState _emptyFeedState() => FeedState( + active: true, + loadedPosts: const [], + index: 0, + isEndOfNetworkFeed: true, + cursor: null, + loadingFirstLoad: false, + error: false, + extraInfo: LinkedHashMap(), +); + +Feed _followingFeed() => Feed( + type: 'timeline', + config: makeSavedFeed( + id: 'following', + type: 'timeline', + value: 'following', + pinned: true, + ), +); + +Feed _discoverFeed() => Feed( + type: 'feed', + config: makeSavedFeed( + id: 'discover', + type: 'feed', + value: DefaultPreferences.discoverFeedUri, + pinned: true, + ), +); + +Feed _customFeed() => Feed( + type: 'feed', + config: makeSavedFeed( + id: 'custom', + type: 'feed', + value: 'at://did:plc:feed/so.sprk.feed.generator/custom', + pinned: true, + ), +); + +class _FakeFeedNotifier extends FeedNotifier { + _FakeFeedNotifier(this.initialState); + + final FeedState initialState; + int loadCalls = 0; + + @override + FeedState build(Feed feed) => initialState; + + @override + Future loadAndUpdateFirstLoad() async { + loadCalls++; + } + + @override + Future setActive(bool active) async { + state = state.copyWith(active: active); + } +} + +class _FakeSettings extends Settings { + _FakeSettings(this.initialState); + + final SettingsState initialState; + final List selectedFeeds = []; + + @override + SettingsState build() => initialState; + + @override + Future setActiveFeed(Feed feed) async { + selectedFeeds.add(feed); + state = state.copyWith(activeFeed: feed); + } +} diff --git a/test/src/features/settings/providers/settings_provider_test.dart b/test/src/features/settings/providers/settings_provider_test.dart index f4099c2e..5f5717ca 100644 --- a/test/src/features/settings/providers/settings_provider_test.dart +++ b/test/src/features/settings/providers/settings_provider_test.dart @@ -13,6 +13,7 @@ import 'package:spark/src/core/network/atproto/data/models/pref_models.dart'; import 'package:spark/src/core/network/atproto/data/repositories/feed_repository.dart'; import 'package:spark/src/core/network/atproto/data/repositories/labeler_repository.dart'; import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; +import 'package:spark/src/core/storage/preferences/default_preferences.dart'; import 'package:spark/src/core/storage/preferences/storage_manager.dart'; import 'package:spark/src/core/utils/logging/log_service.dart'; import 'package:spark/src/core/providers/preferences_provider.dart'; @@ -110,12 +111,123 @@ void main() { expect(written.labelers?.map((labeler) => labeler.did), ['did:plc:mod']); final state = container.read(settingsProvider); expect(state.feeds, hasLength(3)); - expect(state.activeFeed.config.value, 'following'); + expect(state.activeFeed.config.value, DefaultPreferences.discoverFeedUri); final stored = await StorageManager.instance.preferences .getObject>('active_feed_did:plc:me'); - expect(Feed.fromJson(stored!).config.value, 'following'); + expect( + Feed.fromJson(stored!).config.value, + DefaultPreferences.discoverFeedUri, + ); + }); + + test( + 'post-onboarding preparation selects Discover after an app restart', + () async { + preferencesController.current = Preferences(preferences: []); + final firstContainer = createContainer(); + await loadSettings(firstContainer); + + expect( + firstContainer.read(settingsProvider).activeFeed.config.value, + DefaultPreferences.discoverFeedUri, + ); + expect( + await StorageManager.instance.preferences.getBool( + 'pending_initial_feed_did:plc:me', + ), + isTrue, + ); + final following = firstContainer + .read(settingsProvider) + .feeds + .firstWhere((feed) => feed.type == 'timeline'); + await StorageManager.instance.preferences.setObject( + 'active_feed_did:plc:me', + following.toJson(), + ); + firstContainer.dispose(); + + final secondContainer = createContainer(); + final secondNotifier = secondContainer.read(settingsProvider.notifier); + await secondNotifier.preparePostOnboardingFeed(); + + expect( + secondContainer.read(settingsProvider).activeFeed.config.value, + DefaultPreferences.discoverFeedUri, + ); + final stored = await StorageManager.instance.preferences + .getObject>('active_feed_did:plc:me'); + expect( + Feed.fromJson(stored!).config.value, + DefaultPreferences.discoverFeedUri, + ); + expect( + await StorageManager.instance.preferences.getBool( + 'pending_initial_feed_did:plc:me', + ), + isNull, + ); + secondContainer.dispose(); + }, + ); + + test( + 'post-onboarding preparation preserves an existing active feed', + () async { + final following = _savedFeed( + 'following', + type: 'timeline', + value: 'following', + ); + final existing = _savedFeed('existing'); + preferencesController.current = _feedPreferences([following, existing]); + await StorageManager.instance.preferences.setObject( + 'active_feed_did:plc:me', + Feed(type: 'feed', config: existing).toJson(), + ); + final container = createContainer(); + final notifier = await loadSettings(container); + + await notifier.preparePostOnboardingFeed(); + + expect( + container.read(settingsProvider).activeFeed.config.id, + 'existing', + ); + expect(preferencesController.writes, isEmpty); + }, + ); + + test('post-onboarding preparation reports refresh failures', () async { + preferencesController.current = _feedPreferences([_savedFeed('feed')]); + final container = createContainer(); + final notifier = await loadSettings(container); + final error = StateError('refresh failed'); + preferencesController.refreshError = error; + + await expectLater( + notifier.preparePostOnboardingFeed(), + throwsA(same(error)), + ); }); + test( + 'post-onboarding preparation falls back when Discover is unavailable', + () async { + preferencesController.current = Preferences(preferences: []); + feedRepository.omittedValues.add(DefaultPreferences.discoverFeedUri); + final container = createContainer(); + final notifier = await loadSettings(container); + + await notifier.preparePostOnboardingFeed(); + + expect( + container.read(settingsProvider).activeFeed.config.value, + DefaultPreferences.theVidsFeedUri, + ); + }, + ); + test('restores a still-pinned active feed from storage', () async { final following = _savedFeed( 'following', @@ -321,6 +433,10 @@ void main() { final container = createContainer(); final notifier = await loadSettings(container); final selected = Feed(type: 'feed', config: discover); + await StorageManager.instance.preferences.setBool( + 'pending_initial_feed_did:plc:me', + true, + ); await notifier.setActiveFeed(selected); @@ -328,6 +444,12 @@ void main() { final stored = await StorageManager.instance.preferences .getObject>('active_feed_did:plc:me'); expect(Feed.fromJson(stored!), selected); + expect( + await StorageManager.instance.preferences.getBool( + 'pending_initial_feed_did:plc:me', + ), + isNull, + ); }); group('labeler preferences', () { @@ -639,6 +761,7 @@ class _FakeUserPreferences extends UserPreferences { class _FakeFeedRepository implements FeedRepository { final List> loadCalls = []; + final Set omittedValues = {}; Object? loadError; @override @@ -647,6 +770,7 @@ class _FakeFeedRepository implements FeedRepository { final error = loadError; if (error != null) throw error; return savedFeeds + .where((savedFeed) => !omittedValues.contains(savedFeed.value)) .map((savedFeed) => Feed(type: savedFeed.typeValue, config: savedFeed)) .toList(); }