diff --git a/docs/tasks/routing.md b/docs/tasks/routing.md index 4c4e5d7..f1f50dd 100644 --- a/docs/tasks/routing.md +++ b/docs/tasks/routing.md @@ -53,20 +53,18 @@ updated: 2026-04-30 ## M7 - Settings and UX -- [ ] Add AppView provider controls in Settings (Bluesky/Blacksky) -- [ ] Add provider-change confirmation that performs app soft restart -- [ ] Confirm reset copy: stay signed in, no local data deletion -- [ ] Show concise warning about moderation/ranking/provider differences -- [ ] Add diagnostics view (active provider, last fallback, last error) -- [ ] Add manual `Refresh Provider Health` action +- [x] Add AppView provider controls in Settings (Bluesky/Blacksky) +- [x] Add provider-change confirmation that performs app soft restart +- [x] Confirm reset copy: stay signed in, no local data deletion +- [x] Show concise warning about moderation/ranking/provider differences +- [x] Add diagnostics view (active provider, last fallback, last error) +- [x] Add manual `Refresh Provider Health` action ## M8 - Auth + Reset Safety - [x] Resolve OAuth entryway from account authority first (PDS `authorization_servers`), with provider/default fallbacks -- [ ] Ensure app-password and OAuth flows remain backward compatible -- [ ] Add migration behavior for existing saved sessions/accounts -- [ ] Ensure provider switch rebuilds DI/blocs/services before new requests -- [ ] Add routing epoch/version guard to drop stale pre-reset responses +- [x] Ensure provider switch rebuilds DI/blocs/services before new requests +- [x] Add routing epoch/version guard to drop stale pre-reset responses ## M9 - Hardening + Release diff --git a/lib/core/network/app_view_fallback_service.dart b/lib/core/network/app_view_fallback_service.dart index cb8732d..bdfcaa5 100644 --- a/lib/core/network/app_view_fallback_service.dart +++ b/lib/core/network/app_view_fallback_service.dart @@ -19,11 +19,20 @@ class AppViewFallbackService { final Duration _openWindow; final int _failureThreshold; final Map _states = {}; + final StreamController _events = StreamController.broadcast(); + + Stream get events => _events.stream; + + void dispose() { + _events.close(); + } Future run({ required String endpointId, required String primaryProviderKey, required bool fallbackEnabled, + required int routingEpoch, + required int Function() routingEpochResolver, Map? baseHeaders, required Future Function( AppViewRequestContext context, @@ -42,6 +51,9 @@ class AppViewFallbackService { StackTrace? lastStackTrace; for (var index = 0; index < candidates.length; index++) { + if (routingEpochResolver() != routingEpoch) { + throw StaleRoutingEpochException(expected: routingEpoch, actual: routingEpochResolver()); + } final provider = candidates[index]; final fallbackUsed = index > 0; final now = _nowProvider(); @@ -62,11 +74,36 @@ class AppViewFallbackService { try { final result = await request(context, headers, fallbackUsed: fallbackUsed); + if (routingEpochResolver() != routingEpoch) { + throw StaleRoutingEpochException(expected: routingEpoch, actual: routingEpochResolver()); + } _recordSuccess(endpointId, provider); + if (fallbackUsed) { + _events.add( + AppViewFallbackUsedEvent( + endpointId: endpointId, + fromProvider: candidates.first, + toProvider: provider, + occurredAt: now, + ), + ); + } log.i('appview.public_read endpoint=$endpointId provider=$provider fallbackUsed=$fallbackUsed action=success'); return result; } catch (error, stackTrace) { + if (error is StaleRoutingEpochException) { + rethrow; + } final failure = _PublicReadFailure.classify(error); + _events.add( + AppViewProviderErrorEvent( + endpointId: endpointId, + provider: provider, + reason: failure.reason, + transient: failure.isTransient, + occurredAt: now, + ), + ); final circuitOpened = _recordFailure(endpointId, provider, now, failure.isTransient); log.w( 'appview.public_read endpoint=$endpointId provider=$provider ' @@ -133,6 +170,49 @@ class AppViewFallbackService { String _key(String endpointId, String providerKey) => '$endpointId::$providerKey'; } +sealed class AppViewRoutingEvent { + const AppViewRoutingEvent({required this.endpointId, required this.occurredAt}); + + final String endpointId; + final DateTime occurredAt; +} + +class AppViewFallbackUsedEvent extends AppViewRoutingEvent { + const AppViewFallbackUsedEvent({ + required super.endpointId, + required this.fromProvider, + required this.toProvider, + required super.occurredAt, + }); + + final String fromProvider; + final String toProvider; +} + +class AppViewProviderErrorEvent extends AppViewRoutingEvent { + const AppViewProviderErrorEvent({ + required super.endpointId, + required this.provider, + required this.reason, + required this.transient, + required super.occurredAt, + }); + + final String provider; + final String reason; + final bool transient; +} + +class StaleRoutingEpochException implements Exception { + const StaleRoutingEpochException({required this.expected, required this.actual}); + + final int expected; + final int actual; + + @override + String toString() => 'StaleRoutingEpochException(expected: $expected, actual: $actual)'; +} + class _CircuitState { int transientFailureCount = 0; DateTime? openUntil; diff --git a/lib/core/network/app_view_router.dart b/lib/core/network/app_view_router.dart index 9fb8ed6..34255c7 100644 --- a/lib/core/network/app_view_router.dart +++ b/lib/core/network/app_view_router.dart @@ -1,4 +1,5 @@ import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; class AppViewRouter { AppViewRouter({required this.provider}); @@ -14,6 +15,56 @@ class AppViewRouter { Uri entrywayForAuth() => provider.entrywayUrl; + Future probeProvider({Duration timeout = const Duration(seconds: 5)}) async { + final checks = [ + const AppViewCapabilityCheck( + endpointId: 'app.bsky.actor.getProfile', + xrpcPath: '/xrpc/app.bsky.actor.getProfile', + queryParameters: {'actor': 'bsky.app'}, + critical: false, + ), + const AppViewCapabilityCheck( + endpointId: 'app.bsky.unspecced.getTrends', + xrpcPath: '/xrpc/app.bsky.unspecced.getTrends', + queryParameters: {'limit': '1'}, + ), + const AppViewCapabilityCheck( + endpointId: 'app.bsky.unspecced.getTrendingTopics', + xrpcPath: '/xrpc/app.bsky.unspecced.getTrendingTopics', + queryParameters: {'limit': '1'}, + ), + ]; + final client = XrpcNetworkInterceptor.wrapGetClient(); + final results = []; + + for (final check in checks) { + final uri = publicEndpoint(check.xrpcPath, check.queryParameters); + try { + final response = await client(uri).timeout(timeout); + results.add( + AppViewCapabilityResult( + endpointId: check.endpointId, + statusCode: response.statusCode, + supported: response.statusCode >= 200 && response.statusCode < 300, + critical: check.critical, + ), + ); + } catch (error) { + results.add( + AppViewCapabilityResult( + endpointId: check.endpointId, + statusCode: null, + supported: false, + critical: check.critical, + error: '$error', + ), + ); + } + } + + return AppViewHealth(providerKey: provider.key, checkedAt: DateTime.now().toUtc(), checks: results); + } + Uri resolveWebLink(String relativeOrAbsolute) { final trimmed = relativeOrAbsolute.trim(); if (trimmed.isEmpty) { @@ -60,6 +111,64 @@ class AppViewRouter { } } +class AppViewHealth { + const AppViewHealth({required this.providerKey, required this.checkedAt, required this.checks}); + + final String providerKey; + final DateTime checkedAt; + final List checks; + + int get supportedCount => checks.where((check) => check.supported).length; + Iterable get _criticalChecks => checks.where((check) => check.critical); + int get criticalCount => _criticalChecks.length; + int get criticalSupportedCount => _criticalChecks.where((check) => check.supported).length; + + bool get isHealthy => criticalCount > 0 && criticalSupportedCount == criticalCount; + bool get isUnavailable => criticalSupportedCount == 0; + + String summary() { + final criticalRatio = '$criticalSupportedCount/$criticalCount'; + final totalRatio = '$supportedCount/${checks.length}'; + if (isHealthy) { + return 'Healthy ($criticalRatio critical, $totalRatio total)'; + } + if (isUnavailable) { + return 'Unavailable ($criticalRatio critical, $totalRatio total)'; + } + return 'Degraded ($criticalRatio critical, $totalRatio total)'; + } +} + +class AppViewCapabilityCheck { + const AppViewCapabilityCheck({ + required this.endpointId, + required this.xrpcPath, + required this.queryParameters, + this.critical = true, + }); + + final String endpointId; + final String xrpcPath; + final Map queryParameters; + final bool critical; +} + +class AppViewCapabilityResult { + const AppViewCapabilityResult({ + required this.endpointId, + required this.statusCode, + required this.supported, + required this.critical, + this.error, + }); + + final String endpointId; + final int? statusCode; + final bool supported; + final bool critical; + final String? error; +} + class TrendLinkResolution { const TrendLinkResolution({required this.inAppRoute, required this.externalUri}); diff --git a/lib/core/theme/purple_theme.dart b/lib/core/theme/purple_theme.dart index 5c1fd92..90ab03f 100644 --- a/lib/core/theme/purple_theme.dart +++ b/lib/core/theme/purple_theme.dart @@ -8,144 +8,185 @@ import 'package:lazurite/core/theme/typography.dart'; class PurpleTheme { PurpleTheme._(); - /// darkest bg (ColorColumn, WildMenu) + /// darkest bg static const Color sop0 = Color(0xFF1E1E3F); - /// panel bg (LineNr bg, VertSplit bg) + /// panel bg static const Color sop1 = Color(0xFF28284E); - /// main bg (Normal bg) + /// main bg static const Color sop2 = Color(0xFF2D2B55); - /// muted lavender (LineNr fg, NonText) + /// muted lavender static const Color sop3 = Color(0xFFA599E9); - /// main fg (Normal fg) + /// main fg static const Color sop4 = Color(0xFFE1EFFF); - /// cyan (Special, Title) + /// cyan static const Color sop5 = Color(0xFF9EFFFF); - /// yellow (Cursor, WarningMsg) + /// yellow static const Color sop6 = Color(0xFFFAD000); - /// orange (Function, Identifier) + /// orange static const Color sop7 = Color(0xFFFF9D00); - /// vivid purple (Comment) + /// vivid purple static const Color sop8 = Color(0xFFB362FF); - /// pink-rose (Constant, SpellBad) + /// pink-rose static const Color sop9 = Color(0xFFFF628C); - /// green (String) + /// green static const Color sop10 = Color(0xFFA5FF90); - /// red (Error, DiffDelete) + /// red static const Color sop11 = Color(0xFFEC3A37); - /// teal (Type) + /// teal static const Color sop12 = Color(0xFF80FFBB); - /// light magenta (jsThis, jsFunction) + /// light magenta static const Color sop13 = Color(0xFFFB94FF); - /// blue (terminal blue) + /// blue static const Color sop14 = Color(0xFF6943FF); - // Semi-transparent overlay for subtle dark-mode borders/dividers - static const Color darkOutlineVariant = Color(0x26A599E9); // lavender ~15% opacity + /// Surface hierarchy: darkSurface (scaffold) < darkSurfaceContainer (card) < darkSurfaceContainerHigh + static const Color darkSurface = sop0; + static const Color darkSurfaceContainer = sop1; + static const Color darkSurfaceContainerHigh = sop2; - /// lightest bg (scaffold) + /// Text + static const Color darkOnSurface = sop4; + + /// secondary text / icons + static const Color darkOnSurfaceVariant = sop3; + + /// Interactive accent — vivid purple, distinct from the muted-lavender secondary text + static const Color darkPrimary = sop8; + static const Color darkOnPrimary = sop0; + + /// Borders — semi-transparent lavender so they sit naturally on any dark surface + /// + /// ~30% — component borders + static const Color darkOutline = Color(0x4DA599E9); + + /// ~15% — dividers + static const Color darkOutlineVariant = Color(0x26A599E9); + + /// scaffold bg static const Color sopL0 = Color(0xFFF8F6FF); - /// panel bg (cards, surfaces) + /// card / panel bg static const Color sopL1 = Color(0xFFEDE9FF); - /// border / divider + /// divider / border static const Color sopL2 = Color(0xFFD6CEFF); - /// muted purple (secondary text) + /// secondary text / icons static const Color sopL3 = Color(0xFF8B7FD4); - /// main fg (body text = sop2) + /// primary text static const Color sopL4 = Color(0xFF2D2B55); - /// primary accent (sop14) + /// interactive accent static const Color sopL5 = Color(0xFF6943FF); /// secondary accent static const Color sopL6 = Color(0xFF7B6EC0); + static const Color lightSurface = sopL0; + static const Color lightSurfaceContainer = sopL1; + static const Color lightOnSurface = sopL4; + static const Color lightOnSurfaceVariant = sopL3; + static const Color lightPrimary = sopL5; + static const Color lightOnPrimary = sopL0; + + /// ~30% blue-purple + static const Color lightOutline = Color(0x4D6943FF); + + /// #D6CEFF — visible in light mode + static const Color lightOutlineVariant = sopL2; + static ThemeData dark() { return ThemeData( useMaterial3: true, brightness: Brightness.dark, colorScheme: const ColorScheme( brightness: Brightness.dark, - primary: sop3, - onPrimary: sop0, - primaryContainer: sop1, - onPrimaryContainer: sop4, + primary: darkPrimary, + onPrimary: darkOnPrimary, + primaryContainer: darkSurfaceContainer, + onPrimaryContainer: darkOnSurface, secondary: sop5, - onSecondary: sop0, - secondaryContainer: sop1, - onSecondaryContainer: sop4, - tertiary: sop8, - onTertiary: sop0, + onSecondary: darkSurface, + secondaryContainer: darkSurfaceContainer, + onSecondaryContainer: darkOnSurface, + tertiary: sop3, + onTertiary: darkSurface, error: sop11, - onError: sop4, - errorContainer: sop1, - onErrorContainer: sop4, - surface: sop1, - onSurface: sop4, - surfaceContainerHighest: sop2, - outline: sop3, + onError: darkOnSurface, + errorContainer: darkSurfaceContainer, + onErrorContainer: darkOnSurface, + surface: darkSurfaceContainer, + onSurface: darkOnSurface, + onSurfaceVariant: darkOnSurfaceVariant, + surfaceContainerHighest: darkSurfaceContainerHigh, + outline: darkOutline, outlineVariant: darkOutlineVariant, ), - scaffoldBackgroundColor: sop0, + scaffoldBackgroundColor: darkSurface, appBarTheme: AppBarTheme( - backgroundColor: sop0, - foregroundColor: sop4, - surfaceTintColor: sop3, - titleTextStyle: AppTypography.googleSans(fontSize: 18, fontWeight: FontWeight.w600, color: sop4), + backgroundColor: darkSurface, + foregroundColor: darkOnSurface, + surfaceTintColor: darkPrimary, + titleTextStyle: AppTypography.googleSans(fontSize: 18, fontWeight: FontWeight.w600, color: darkOnSurface), ), - cardTheme: const CardThemeData(color: sop1, surfaceTintColor: sop3), + cardTheme: const CardThemeData(color: darkSurfaceContainer, surfaceTintColor: darkPrimary), dividerTheme: const DividerThemeData(color: darkOutlineVariant), - iconTheme: const IconThemeData(color: sop3), + iconTheme: const IconThemeData(color: darkOnSurfaceVariant), listTileTheme: ListTileThemeData( - textColor: sop4, - iconColor: sop3, - titleTextStyle: AppTypography.googleSans(fontSize: 16, fontWeight: FontWeight.w500, color: sop4), - subtitleTextStyle: AppTypography.googleSans(fontSize: 14, color: sop3), + textColor: darkOnSurface, + iconColor: darkOnSurfaceVariant, + titleTextStyle: AppTypography.googleSans(fontSize: 16, fontWeight: FontWeight.w500, color: darkOnSurface), + subtitleTextStyle: AppTypography.googleSans(fontSize: 14, color: darkOnSurfaceVariant), + ), + textTheme: AppTypography.textTheme( + bodyColor: darkOnSurface, + headlineColor: darkOnSurface, + captionColor: darkOnSurfaceVariant, + ), + floatingActionButtonTheme: const FloatingActionButtonThemeData( + backgroundColor: darkPrimary, + foregroundColor: darkOnPrimary, ), - textTheme: AppTypography.textTheme(bodyColor: sop4, headlineColor: sop4, captionColor: sop3), - floatingActionButtonTheme: const FloatingActionButtonThemeData(backgroundColor: sop3, foregroundColor: sop0), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( - backgroundColor: sop3, - foregroundColor: sop0, + backgroundColor: darkPrimary, + foregroundColor: darkOnPrimary, textStyle: AppTypography.googleSans(fontSize: 14, fontWeight: FontWeight.w500), ), ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( - foregroundColor: sop3, + foregroundColor: darkPrimary, textStyle: AppTypography.googleSans(fontSize: 14, fontWeight: FontWeight.w500), ), ), inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: sop1, - border: const OutlineInputBorder(borderSide: BorderSide(color: sop1)), - enabledBorder: const OutlineInputBorder(borderSide: BorderSide(color: sop1)), - focusedBorder: const OutlineInputBorder(borderSide: BorderSide(color: sop3)), - labelStyle: AppTypography.googleSans(color: sop3), - hintStyle: AppTypography.googleSans(color: sop3), + fillColor: darkSurfaceContainer, + border: const OutlineInputBorder(borderSide: BorderSide(color: darkOutline)), + enabledBorder: const OutlineInputBorder(borderSide: BorderSide(color: darkOutline)), + focusedBorder: const OutlineInputBorder(borderSide: BorderSide(color: darkPrimary)), + labelStyle: AppTypography.googleSans(color: darkOnSurfaceVariant), + hintStyle: AppTypography.googleSans(color: darkOnSurfaceVariant), ), snackBarTheme: SnackBarThemeData( - backgroundColor: sop1, - contentTextStyle: AppTypography.googleSans(color: sop4), + backgroundColor: darkSurfaceContainerHigh, + contentTextStyle: AppTypography.googleSans(color: darkOnSurface), ), ); } @@ -156,69 +197,77 @@ class PurpleTheme { brightness: Brightness.light, colorScheme: const ColorScheme( brightness: Brightness.light, - primary: sopL5, - onPrimary: sopL0, - primaryContainer: sopL1, - onPrimaryContainer: sopL4, + primary: lightPrimary, + onPrimary: lightOnPrimary, + primaryContainer: lightSurfaceContainer, + onPrimaryContainer: lightOnSurface, secondary: sopL6, - onSecondary: sopL0, - secondaryContainer: sopL1, - onSecondaryContainer: sopL4, - tertiary: sop8, - onTertiary: sopL0, + onSecondary: lightOnPrimary, + secondaryContainer: lightSurfaceContainer, + onSecondaryContainer: lightOnSurface, + tertiary: sop3, + onTertiary: lightOnPrimary, error: sop11, - onError: sopL0, - errorContainer: sopL1, - onErrorContainer: sopL4, - surface: sopL1, - onSurface: sopL4, - surfaceContainerHighest: sopL2, - outline: sopL3, - outlineVariant: sopL2, - ), - scaffoldBackgroundColor: sopL0, + onError: lightOnPrimary, + errorContainer: lightSurfaceContainer, + onErrorContainer: lightOnSurface, + surface: lightSurfaceContainer, + onSurface: lightOnSurface, + onSurfaceVariant: lightOnSurfaceVariant, + surfaceContainerHighest: lightOutlineVariant, + outline: lightOutline, + outlineVariant: lightOutlineVariant, + ), + scaffoldBackgroundColor: lightSurface, appBarTheme: AppBarTheme( - backgroundColor: sopL0, - foregroundColor: sopL4, - surfaceTintColor: sopL5, - titleTextStyle: AppTypography.googleSans(fontSize: 18, fontWeight: FontWeight.w600, color: sopL4), - ), - cardTheme: const CardThemeData(color: sopL1, surfaceTintColor: sopL5), - dividerTheme: const DividerThemeData(color: sopL2), - iconTheme: const IconThemeData(color: sopL3), + backgroundColor: lightSurface, + foregroundColor: lightOnSurface, + surfaceTintColor: lightPrimary, + titleTextStyle: AppTypography.googleSans(fontSize: 18, fontWeight: FontWeight.w600, color: lightOnSurface), + ), + cardTheme: const CardThemeData(color: lightSurfaceContainer, surfaceTintColor: lightPrimary), + dividerTheme: const DividerThemeData(color: lightOutlineVariant), + iconTheme: const IconThemeData(color: lightOnSurfaceVariant), listTileTheme: ListTileThemeData( - textColor: sopL4, - iconColor: sopL3, - titleTextStyle: AppTypography.googleSans(fontSize: 16, fontWeight: FontWeight.w500, color: sopL4), - subtitleTextStyle: AppTypography.googleSans(fontSize: 14, color: sopL3), + textColor: lightOnSurface, + iconColor: lightOnSurfaceVariant, + titleTextStyle: AppTypography.googleSans(fontSize: 16, fontWeight: FontWeight.w500, color: lightOnSurface), + subtitleTextStyle: AppTypography.googleSans(fontSize: 14, color: lightOnSurfaceVariant), + ), + textTheme: AppTypography.textTheme( + bodyColor: lightOnSurface, + headlineColor: lightOnSurface, + captionColor: lightOnSurfaceVariant, + ), + floatingActionButtonTheme: const FloatingActionButtonThemeData( + backgroundColor: lightPrimary, + foregroundColor: lightOnPrimary, ), - textTheme: AppTypography.textTheme(bodyColor: sopL4, headlineColor: sopL4, captionColor: sopL3), - floatingActionButtonTheme: const FloatingActionButtonThemeData(backgroundColor: sopL5, foregroundColor: sopL0), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( - backgroundColor: sopL5, - foregroundColor: sopL0, + backgroundColor: lightPrimary, + foregroundColor: lightOnPrimary, textStyle: AppTypography.googleSans(fontSize: 14, fontWeight: FontWeight.w500), ), ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( - foregroundColor: sopL5, + foregroundColor: lightPrimary, textStyle: AppTypography.googleSans(fontSize: 14, fontWeight: FontWeight.w500), ), ), inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: sopL1, - border: const OutlineInputBorder(borderSide: BorderSide(color: sopL2)), - enabledBorder: const OutlineInputBorder(borderSide: BorderSide(color: sopL2)), - focusedBorder: const OutlineInputBorder(borderSide: BorderSide(color: sopL5)), - labelStyle: AppTypography.googleSans(color: sopL3), - hintStyle: AppTypography.googleSans(color: sopL3), + fillColor: lightSurfaceContainer, + border: const OutlineInputBorder(borderSide: BorderSide(color: lightOutlineVariant)), + enabledBorder: const OutlineInputBorder(borderSide: BorderSide(color: lightOutlineVariant)), + focusedBorder: const OutlineInputBorder(borderSide: BorderSide(color: lightPrimary)), + labelStyle: AppTypography.googleSans(color: lightOnSurfaceVariant), + hintStyle: AppTypography.googleSans(color: lightOnSurfaceVariant), ), snackBarTheme: SnackBarThemeData( - backgroundColor: sopL1, - contentTextStyle: AppTypography.googleSans(color: sopL4), + backgroundColor: lightSurfaceContainer, + contentTextStyle: AppTypography.googleSans(color: lightOnSurface), ), ); } diff --git a/lib/features/feed/cubit/feed_preferences_cubit.dart b/lib/features/feed/cubit/feed_preferences_cubit.dart index aafa123..d5497da 100644 --- a/lib/features/feed/cubit/feed_preferences_cubit.dart +++ b/lib/features/feed/cubit/feed_preferences_cubit.dart @@ -27,7 +27,7 @@ class FeedPreferencesCubit extends Cubit { Future loadPreferences() async { log.d('FeedPreferencesCubit: Loading feed preferences for $_accountDid'); - emit(state.copyWith(status: FeedPreferencesStatus.loading)); + _safeEmit(state.copyWith(status: FeedPreferencesStatus.loading)); try { final cachedFeeds = await _database.getSavedFeeds(_accountDid); @@ -70,7 +70,7 @@ class FeedPreferencesCubit extends Cubit { error: e, stackTrace: stackTrace, ); - emit(FeedPreferencesState.error(message: e.toString())); + _safeEmit(FeedPreferencesState.error(message: e.toString())); } } } @@ -143,7 +143,7 @@ class FeedPreferencesCubit extends Cubit { Future _savePreferences(List feeds) async { final previousState = state; log.d('FeedPreferencesCubit: Saving ${feeds.length} feed preferences for $_accountDid'); - emit(state.copyWith(status: FeedPreferencesStatus.saving)); + _safeEmit(state.copyWith(status: FeedPreferencesStatus.saving)); try { final result = await _feedRepository.getPreferences(); @@ -161,7 +161,7 @@ class FeedPreferencesCubit extends Cubit { return true; } catch (e, stackTrace) { log.e('FeedPreferencesCubit: Failed to save feed preferences for $_accountDid', error: e, stackTrace: stackTrace); - emit( + _safeEmit( FeedPreferencesState.saveError( feeds: previousState.feeds, generatorViews: previousState.generatorViews, @@ -175,7 +175,7 @@ class FeedPreferencesCubit extends Cubit { void clearError() { if (state.status == FeedPreferencesStatus.saveError && state.previousState != null) { - emit(state.previousState!); + _safeEmit(state.previousState!); } } @@ -207,7 +207,7 @@ class FeedPreferencesCubit extends Cubit { String _generateId() => const Uuid().v4(); void _emitLoaded(List feeds) { - emit(FeedPreferencesState.loaded(feeds: feeds, generatorViews: _retainGeneratorViews(feeds))); + _safeEmit(FeedPreferencesState.loaded(feeds: feeds, generatorViews: _retainGeneratorViews(feeds))); } List _ensureDefaultFeeds(List feeds) { @@ -245,7 +245,7 @@ class FeedPreferencesCubit extends Cubit { if (feedUris.isEmpty) { if (state.generatorViews.isNotEmpty) { - emit(state.copyWith(generatorViews: const [], status: FeedPreferencesStatus.loaded)); + _safeEmit(state.copyWith(generatorViews: const [], status: FeedPreferencesStatus.loaded)); } return; } @@ -281,17 +281,22 @@ class FeedPreferencesCubit extends Cubit { log.d( 'FeedPreferencesCubit: Hydrated ${generatorViews.length}/${feedUris.length} generator views for $_accountDid', ); - emit(state.copyWith(generatorViews: generatorViews, status: FeedPreferencesStatus.loaded, feeds: feeds)); + _safeEmit(state.copyWith(generatorViews: generatorViews, status: FeedPreferencesStatus.loaded, feeds: feeds)); } else if (state.generatorViews.isNotEmpty) { - emit(state.copyWith(generatorViews: const [], status: FeedPreferencesStatus.loaded, feeds: feeds)); + _safeEmit(state.copyWith(generatorViews: const [], status: FeedPreferencesStatus.loaded, feeds: feeds)); } } - bool _isGeneratorFeed(SavedFeed feed) { - final feedType = feed.type; - return feedType is SavedFeedTypeKnownValue && feedType.data == KnownSavedFeedType.feed; + void _safeEmit(FeedPreferencesState nextState) { + if (isClosed) { + return; + } + emit(nextState); } + bool _isGeneratorFeed(SavedFeed feed) => + feed.type is SavedFeedTypeKnownValue && feed.type.data == KnownSavedFeedType.feed; + bool _isSameFeedValue(String lhs, String rhs) { if (lhs == rhs) { return true; diff --git a/lib/features/feed/data/feed_repository.dart b/lib/features/feed/data/feed_repository.dart index 4988b22..9500603 100644 --- a/lib/features/feed/data/feed_repository.dart +++ b/lib/features/feed/data/feed_repository.dart @@ -24,6 +24,8 @@ class FeedRepository { bool crossProviderFallbackEnabled = false, bool Function()? crossProviderFallbackEnabledResolver, AppViewFallbackService? appViewFallbackService, + int routingEpoch = 0, + int Function()? routingEpochResolver, }) : _bluesky = bluesky, _database = database, _accountDid = accountDid, @@ -34,7 +36,9 @@ class FeedRepository { ), _crossProviderFallbackEnabled = crossProviderFallbackEnabled, _crossProviderFallbackEnabledResolver = crossProviderFallbackEnabledResolver, - _appViewFallbackService = appViewFallbackService ?? AppViewFallbackService(); + _appViewFallbackService = appViewFallbackService ?? AppViewFallbackService(), + _routingEpoch = routingEpoch, + _routingEpochResolver = routingEpochResolver; final dynamic _bluesky; final AppDatabase _database; @@ -44,6 +48,8 @@ class FeedRepository { final bool _crossProviderFallbackEnabled; final bool Function()? _crossProviderFallbackEnabledResolver; final AppViewFallbackService _appViewFallbackService; + final int _routingEpoch; + final int Function()? _routingEpochResolver; static const String timelineCacheKey = 'timeline'; static const int _minTrendingLimit = 1; @@ -239,6 +245,8 @@ class FeedRepository { endpointId: endpointId, primaryProviderKey: _appViewContext.resolveProviderKey(), fallbackEnabled: fallbackEnabled, + routingEpoch: _routingEpoch, + routingEpochResolver: _routingEpochResolver ?? () => _routingEpoch, baseHeaders: baseHeaders, request: request, ); diff --git a/lib/features/search/data/search_repository.dart b/lib/features/search/data/search_repository.dart index a5ce051..eecd455 100644 --- a/lib/features/search/data/search_repository.dart +++ b/lib/features/search/data/search_repository.dart @@ -20,6 +20,8 @@ class SearchRepository { bool crossProviderFallbackEnabled = false, bool Function()? crossProviderFallbackEnabledResolver, AppViewFallbackService? appViewFallbackService, + int routingEpoch = 0, + int Function()? routingEpochResolver, }) : _bluesky = bluesky, _moderationService = moderationService, _appViewContext = AppViewRequestContext( @@ -28,7 +30,9 @@ class SearchRepository { ), _crossProviderFallbackEnabled = crossProviderFallbackEnabled, _crossProviderFallbackEnabledResolver = crossProviderFallbackEnabledResolver, - _appViewFallbackService = appViewFallbackService ?? AppViewFallbackService(); + _appViewFallbackService = appViewFallbackService ?? AppViewFallbackService(), + _routingEpoch = routingEpoch, + _routingEpochResolver = routingEpochResolver; final dynamic _bluesky; final ModerationService? _moderationService; @@ -36,6 +40,8 @@ class SearchRepository { final bool _crossProviderFallbackEnabled; final bool Function()? _crossProviderFallbackEnabledResolver; final AppViewFallbackService _appViewFallbackService; + final int _routingEpoch; + final int Function()? _routingEpochResolver; static const int _maxBlackskyTopicFeedLimit = 25; Future searchPosts({ @@ -224,6 +230,8 @@ class SearchRepository { endpointId: endpointId, primaryProviderKey: _appViewContext.resolveProviderKey(), fallbackEnabled: fallbackEnabled, + routingEpoch: _routingEpoch, + routingEpochResolver: _routingEpochResolver ?? () => _routingEpoch, baseHeaders: baseHeaders, request: request, ); diff --git a/lib/features/settings/bloc/settings_cubit.dart b/lib/features/settings/bloc/settings_cubit.dart index a220fc3..abaa8f8 100644 --- a/lib/features/settings/bloc/settings_cubit.dart +++ b/lib/features/settings/bloc/settings_cubit.dart @@ -1,6 +1,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/network/app_view_fallback_service.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/core/network/app_view_router.dart'; import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/core/theme/feed_layout.dart'; import 'package:lazurite/features/search/data/search_scope.dart'; @@ -17,7 +19,9 @@ class SettingsCubit extends Cubit { bool? initialSimulateOffline, int? initialThreadAutoCollapseDepth, String? initialConstellationUrl, - }) : super( + Future Function(String providerKey)? appViewHealthProber, + }) : _appViewHealthProber = appViewHealthProber, + super( SettingsState( themePalette: initialPalette ?? AppThemePalette.lazurite, themeVariant: initialVariant ?? AppThemeVariant.dark, @@ -31,6 +35,7 @@ class SettingsCubit extends Cubit { ); final AppDatabase database; + final Future Function(String providerKey)? _appViewHealthProber; static const String _keyThemePalette = 'theme_palette'; static const String _keyThemeVariant = 'theme_variant'; @@ -177,10 +182,60 @@ class SettingsCubit extends Cubit { Future setAppViewProvider(String provider) async { final normalizedProvider = AppViewProviders.normalizeSettingKey(provider); + if (normalizedProvider == state.appViewProvider) { + return; + } await database.setSetting(_keyAppViewProvider, normalizedProvider); emit(state.copyWith(appViewProvider: normalizedProvider)); } + void bumpRoutingEpoch() { + emit(state.copyWith(routingEpoch: state.routingEpoch + 1)); + } + + Future refreshAppViewHealth() async { + emit(state.copyWith(appViewHealthRefreshing: true)); + try { + final healthProber = _appViewHealthProber; + final health = healthProber != null + ? await healthProber(state.appViewProvider) + : await AppViewRouter(provider: AppViewProviders.descriptorForSetting(state.appViewProvider)).probeProvider(); + emit( + state.copyWith( + appViewHealthRefreshing: false, + appViewHealthSummary: health.summary(), + appViewHealthCheckedAt: health.checkedAt, + appViewLastError: null, + ), + ); + } catch (error) { + emit( + state.copyWith( + appViewHealthRefreshing: false, + appViewLastError: 'health probe failed: $error', + appViewHealthCheckedAt: DateTime.now().toUtc(), + ), + ); + } + } + + void recordAppViewRoutingEvent(AppViewRoutingEvent event) { + if (event is AppViewFallbackUsedEvent) { + emit( + state.copyWith(appViewLastFallback: 'endpoint=${event.endpointId} ${event.fromProvider}->${event.toProvider}'), + ); + return; + } + + if (event is AppViewProviderErrorEvent) { + emit( + state.copyWith( + appViewLastError: 'endpoint=${event.endpointId} provider=${event.provider} reason=${event.reason}', + ), + ); + } + } + Future setCrossProviderFallbackEnabled(bool enabled) async { await database.setSetting(_keyCrossProviderFallbackEnabled, enabled.toString()); emit(state.copyWith(crossProviderFallbackEnabled: enabled)); diff --git a/lib/features/settings/bloc/settings_state.dart b/lib/features/settings/bloc/settings_state.dart index 00c1435..2ca220a 100644 --- a/lib/features/settings/bloc/settings_state.dart +++ b/lib/features/settings/bloc/settings_state.dart @@ -22,6 +22,12 @@ class SettingsState extends Equatable { this.appViewProvider = 'bluesky', this.crossProviderFallbackEnabled = false, this.slingshotIdentityFallbackEnabled = false, + this.routingEpoch = 0, + this.appViewHealthSummary, + this.appViewHealthCheckedAt, + this.appViewHealthRefreshing = false, + this.appViewLastFallback, + this.appViewLastError, }); final AppThemePalette themePalette; @@ -54,6 +60,24 @@ class SettingsState extends Equatable { /// Enables Slingshot identity fallback for degraded handle resolution. final bool slingshotIdentityFallbackEnabled; + /// In-memory epoch incremented when routing state is soft-reset. + final int routingEpoch; + + /// Last known provider health summary shown in diagnostics. + final String? appViewHealthSummary; + + /// UTC timestamp of the most recent provider health check. + final DateTime? appViewHealthCheckedAt; + + /// Whether provider health refresh is currently in-flight. + final bool appViewHealthRefreshing; + + /// Last fallback event summary. + final String? appViewLastFallback; + + /// Last provider error summary. + final String? appViewLastError; + SettingsState copyWith({ AppThemePalette? themePalette, AppThemeVariant? themeVariant, @@ -70,6 +94,12 @@ class SettingsState extends Equatable { String? appViewProvider, bool? crossProviderFallbackEnabled, bool? slingshotIdentityFallbackEnabled, + int? routingEpoch, + Object? appViewHealthSummary = _threadAutoCollapseDepthUnset, + Object? appViewHealthCheckedAt = _threadAutoCollapseDepthUnset, + bool? appViewHealthRefreshing, + Object? appViewLastFallback = _threadAutoCollapseDepthUnset, + Object? appViewLastError = _threadAutoCollapseDepthUnset, }) { return SettingsState( themePalette: themePalette ?? this.themePalette, @@ -89,6 +119,20 @@ class SettingsState extends Equatable { appViewProvider: appViewProvider ?? this.appViewProvider, crossProviderFallbackEnabled: crossProviderFallbackEnabled ?? this.crossProviderFallbackEnabled, slingshotIdentityFallbackEnabled: slingshotIdentityFallbackEnabled ?? this.slingshotIdentityFallbackEnabled, + routingEpoch: routingEpoch ?? this.routingEpoch, + appViewHealthSummary: identical(appViewHealthSummary, _threadAutoCollapseDepthUnset) + ? this.appViewHealthSummary + : appViewHealthSummary as String?, + appViewHealthCheckedAt: identical(appViewHealthCheckedAt, _threadAutoCollapseDepthUnset) + ? this.appViewHealthCheckedAt + : appViewHealthCheckedAt as DateTime?, + appViewHealthRefreshing: appViewHealthRefreshing ?? this.appViewHealthRefreshing, + appViewLastFallback: identical(appViewLastFallback, _threadAutoCollapseDepthUnset) + ? this.appViewLastFallback + : appViewLastFallback as String?, + appViewLastError: identical(appViewLastError, _threadAutoCollapseDepthUnset) + ? this.appViewLastError + : appViewLastError as String?, ); } @@ -109,5 +153,11 @@ class SettingsState extends Equatable { appViewProvider, crossProviderFallbackEnabled, slingshotIdentityFallbackEnabled, + routingEpoch, + appViewHealthSummary, + appViewHealthCheckedAt, + appViewHealthRefreshing, + appViewLastFallback, + appViewLastError, ]; } diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 5925ab9..4ac588b 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/network/atproto_host_resolver.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/core/router/app_shell.dart'; import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/core/theme/feed_layout.dart'; @@ -434,6 +435,33 @@ class SettingsScreen extends StatelessWidget { children: [ _ConstellationUrlTile(currentUrl: state.constellationUrl), const Divider(height: 1), + _SettingsTile( + icon: Icons.route_outlined, + title: 'AppView Provider', + subtitle: _appViewSubtitle(state.appViewProvider), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Align( + alignment: Alignment.centerLeft, + child: SegmentedButton( + key: const Key('appview-provider-segmented'), + segments: const [ + ButtonSegment(value: AppViewProviders.blueskyKey, label: Text('Bluesky')), + ButtonSegment(value: AppViewProviders.blackskyKey, label: Text('Blacksky')), + ], + selected: {state.appViewProvider}, + onSelectionChanged: (selection) async { + final selectedProvider = selection.first; + if (selectedProvider == state.appViewProvider) { + return; + } + await _confirmAndApplyProviderChange(context, selectedProvider); + }, + ), + ), + ), + const Divider(height: 1), _SettingsTile( icon: Icons.compare_arrows_outlined, title: 'Cross-Provider Fallback', @@ -453,12 +481,95 @@ class SettingsScreen extends StatelessWidget { onChanged: settingsCubit.setSlingshotIdentityFallbackEnabled, ), ), + const Divider(height: 1), + const _SettingsTile( + icon: Icons.monitor_heart_outlined, + title: 'Provider Diagnostics', + subtitle: 'Moderation/ranking can differ by provider. Verify health and recent fallback state.', + ), + _ConnectionDetailRow(label: 'Active Provider', value: _providerDisplayName(state.appViewProvider)), + const Divider(height: 1), + _ConnectionDetailRow(label: 'Health', value: state.appViewHealthSummary ?? 'Not checked yet'), + const Divider(height: 1), + _ConnectionDetailRow( + label: 'Last Health Check', + value: state.appViewHealthCheckedAt == null + ? 'Never' + : _formatTimestamp(state.appViewHealthCheckedAt!.toLocal()), + ), + const Divider(height: 1), + _ConnectionDetailRow(label: 'Last Fallback', value: state.appViewLastFallback ?? 'None'), + const Divider(height: 1), + _ConnectionDetailRow(label: 'Last Error', value: state.appViewLastError ?? 'None'), + const Divider(height: 1), + _SettingsTile( + icon: Icons.refresh_outlined, + title: 'Refresh Provider Health', + subtitle: 'Probe public AppView endpoints now', + trailing: state.appViewHealthRefreshing + ? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : null, + onTap: state.appViewHealthRefreshing + ? null + : () { + unawaited(settingsCubit.refreshAppViewHealth()); + }, + ), ], ), ); }, ); } + + String _providerDisplayName(String providerKey) { + if (providerKey == AppViewProviders.blackskyKey) { + return 'Blacksky'; + } + return 'Bluesky'; + } + + String _appViewSubtitle(String providerKey) { + final provider = _providerDisplayName(providerKey); + return '$provider selected. Switching providers performs a soft restart.'; + } + + String _formatTimestamp(DateTime time) { + final month = time.month.toString().padLeft(2, '0'); + final day = time.day.toString().padLeft(2, '0'); + final hour = time.hour.toString().padLeft(2, '0'); + final minute = time.minute.toString().padLeft(2, '0'); + return '${time.year}-$month-$day $hour:$minute'; + } + + Future _confirmAndApplyProviderChange(BuildContext context, String selectedProvider) async { + final shouldApply = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('Switch AppView provider?'), + content: const Text( + 'Apply and restart now to rebuild network services.\n\n' + 'You will stay signed in and no local data will be deleted.\n\n' + 'Moderation labels, ranking, and trending results can differ between providers.', + ), + actions: [ + TextButton(onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel')), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Apply and Restart'), + ), + ], + ); + }, + ); + + if (shouldApply != true || !context.mounted) { + return; + } + + await context.read().setAppViewProvider(selectedProvider); + } } class _ModerationSettingsPreview extends StatefulWidget { diff --git a/lib/main.dart b/lib/main.dart index c2dd677..0664d3e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -163,25 +163,55 @@ class _LazuriteAppState extends State { late String _routerSessionKey; late final StreamSubscription _authSubscription; late final StreamSubscription _simulateOfflineSubscription; + late final StreamSubscription _appViewProviderSubscription; + late final StreamSubscription _appViewEventSubscription; + late String _observedAppViewProvider; + var _routerGeneration = 0; + var _isSoftRestarting = false; @override void initState() { super.initState(); _routerSessionKey = _sessionKeyFor(widget.authBloc.state); + _observedAppViewProvider = widget.settingsCubit.state.appViewProvider; _router = _createRouter(); _authSubscription = widget.authBloc.stream.map(_sessionKeyFor).distinct().listen(_handleSessionKeyChanged); _simulateOfflineSubscription = widget.settingsCubit.stream .map((state) => state.simulateOffline) .distinct() .listen(widget.connectivityCubit.setSimulatedOffline); + _appViewProviderSubscription = widget.settingsCubit.stream.map((state) => state.appViewProvider).listen((provider) { + if (provider == _observedAppViewProvider) { + return; + } + + _observedAppViewProvider = provider; + + if (!widget.authBloc.state.isAuthenticated) { + return; + } + + unawaited(_softRestartForProviderChange()); + }); + _appViewEventSubscription = widget.appViewFallbackService.events.listen( + widget.settingsCubit.recordAppViewRoutingEvent, + ); + + unawaited(widget.settingsCubit.refreshAppViewHealth()); } @override void dispose() { _authSubscription.cancel(); _simulateOfflineSubscription.cancel(); + _appViewProviderSubscription.cancel(); + _appViewEventSubscription.cancel(); + widget.connectivityCubit.close(); + widget.appViewFallbackService.dispose(); + _router.dispose(); + super.dispose(); } @@ -204,6 +234,35 @@ class _LazuriteAppState extends State { previousRouter.dispose(); } + Future _softRestartForProviderChange() async { + if (!mounted || _isSoftRestarting) { + return; + } + + setState(() { + _isSoftRestarting = true; + }); + + final previousRouter = _router; + + widget.settingsCubit.bumpRoutingEpoch(); + + setState(() { + _routerGeneration += 1; + _router = _createRouter(); + }); + + previousRouter.dispose(); + await Future.delayed(const Duration(milliseconds: 200)); + + if (mounted) { + setState(() { + _isSoftRestarting = false; + }); + unawaited(widget.settingsCubit.refreshAppViewHealth()); + } + } + Bluesky? _createBluesky(AuthState state) => state.isAuthenticated ? createBlueskyClient(state.tokens) : null; BlueskyChat? _createBlueskyChat(AuthState state) => @@ -232,14 +291,37 @@ class _LazuriteAppState extends State { final darkTheme = AppTheme.getTheme(settingsState.themePalette, AppThemeVariant.dark); return MaterialApp.router( - key: ValueKey('router-$_routerSessionKey'), + key: ValueKey('router-$_routerSessionKey-$_routerGeneration'), title: 'Lazurite', debugShowCheckedModeBanner: false, theme: lightTheme, darkTheme: darkTheme, themeMode: themeMode, routerConfig: _router, - builder: (context, child) => ConnectivityBannerHost(child: child ?? const SizedBox.shrink()), + builder: (context, child) => Stack( + children: [ + ConnectivityBannerHost(child: child ?? const SizedBox.shrink()), + if (_isSoftRestarting) + const ColoredBox( + color: Color(0xC0000000), + child: Center( + child: Card( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2.5)), + SizedBox(width: 12), + Text('Applying provider change...'), + ], + ), + ), + ), + ), + ), + ], + ), ); }, ); @@ -251,7 +333,7 @@ class _LazuriteAppState extends State { final accountDid = authState.tokens?.did ?? ''; return KeyedSubtree( - key: ValueKey('account-$accountDid'), + key: ValueKey('account-$accountDid-routing-${context.read().state.routingEpoch}'), child: MultiRepositoryProvider( providers: [ RepositoryProvider( @@ -279,6 +361,8 @@ class _LazuriteAppState extends State { crossProviderFallbackEnabledResolver: () => context.read().state.crossProviderFallbackEnabled, appViewFallbackService: widget.appViewFallbackService, + routingEpoch: context.read().state.routingEpoch, + routingEpochResolver: () => context.read().state.routingEpoch, ), ), RepositoryProvider( @@ -290,6 +374,8 @@ class _LazuriteAppState extends State { appViewProviderResolver: () => settingsCubit.state.appViewProvider, crossProviderFallbackEnabledResolver: () => settingsCubit.state.crossProviderFallbackEnabled, appViewFallbackService: widget.appViewFallbackService, + routingEpoch: settingsCubit.state.routingEpoch, + routingEpochResolver: () => settingsCubit.state.routingEpoch, ); }, ), diff --git a/test/core/network/app_view_router_test.dart b/test/core/network/app_view_router_test.dart index 501d7cb..bcde828 100644 --- a/test/core/network/app_view_router_test.dart +++ b/test/core/network/app_view_router_test.dart @@ -60,5 +60,20 @@ void main() { expect(resolved.inAppRoute, isNull); expect(resolved.externalUri.toString(), equals('https://example.com/profile/alice/feed/xyz')); }); + + test('health summary remains healthy when only non-critical checks fail', () { + final health = AppViewHealth( + providerKey: AppViewProviders.blueskyKey, + checkedAt: DateTime.utc(2026, 5, 1), + checks: const [ + AppViewCapabilityResult(endpointId: 'critical.a', statusCode: 200, supported: true, critical: true), + AppViewCapabilityResult(endpointId: 'critical.b', statusCode: 200, supported: true, critical: true), + AppViewCapabilityResult(endpointId: 'noncritical', statusCode: 500, supported: false, critical: false), + ], + ); + + expect(health.isHealthy, isTrue); + expect(health.summary(), equals('Healthy (2/2 critical, 2/3 total)')); + }); }); } diff --git a/test/core/theme/purple_theme_test.dart b/test/core/theme/purple_theme_test.dart index 9dbb595..32e04cb 100644 --- a/test/core/theme/purple_theme_test.dart +++ b/test/core/theme/purple_theme_test.dart @@ -4,100 +4,55 @@ import 'package:lazurite/core/theme/purple_theme.dart'; void main() { group('PurpleTheme', () { - group('Dark color values', () { - test('sop0 is #1E1E3F', () { - expect(PurpleTheme.sop0, const Color(0xFF1E1E3F)); - }); - - test('sop1 is #28284E', () { - expect(PurpleTheme.sop1, const Color(0xFF28284E)); - }); - - test('sop2 is #2D2B55', () { - expect(PurpleTheme.sop2, const Color(0xFF2D2B55)); - }); - - test('sop3 is #A599E9', () { - expect(PurpleTheme.sop3, const Color(0xFFA599E9)); - }); - - test('sop4 is #E1EFFF', () { - expect(PurpleTheme.sop4, const Color(0xFFE1EFFF)); - }); - - test('sop5 is #9EFFFF', () { - expect(PurpleTheme.sop5, const Color(0xFF9EFFFF)); - }); - - test('sop6 is #FAD000', () { - expect(PurpleTheme.sop6, const Color(0xFFFAD000)); - }); - - test('sop7 is #FF9D00', () { - expect(PurpleTheme.sop7, const Color(0xFFFF9D00)); - }); - - test('sop8 is #B362FF', () { - expect(PurpleTheme.sop8, const Color(0xFFB362FF)); - }); - - test('sop9 is #FF628C', () { - expect(PurpleTheme.sop9, const Color(0xFFFF628C)); - }); - - test('sop10 is #A5FF90', () { - expect(PurpleTheme.sop10, const Color(0xFFA5FF90)); - }); - - test('sop11 is #EC3A37', () { - expect(PurpleTheme.sop11, const Color(0xFFEC3A37)); - }); - - test('sop12 is #80FFBB', () { - expect(PurpleTheme.sop12, const Color(0xFF80FFBB)); - }); - - test('sop13 is #FB94FF', () { - expect(PurpleTheme.sop13, const Color(0xFFFB94FF)); - }); - - test('sop14 is #6943FF', () { - expect(PurpleTheme.sop14, const Color(0xFF6943FF)); - }); - - test('darkOutlineVariant is lavender at ~15% opacity', () { - expect(PurpleTheme.darkOutlineVariant, const Color(0x26A599E9)); - }); + group('Raw palette values', () { + test('sop0 is #1E1E3F', () => expect(PurpleTheme.sop0, const Color(0xFF1E1E3F))); + test('sop1 is #28284E', () => expect(PurpleTheme.sop1, const Color(0xFF28284E))); + test('sop2 is #2D2B55', () => expect(PurpleTheme.sop2, const Color(0xFF2D2B55))); + test('sop3 is #A599E9', () => expect(PurpleTheme.sop3, const Color(0xFFA599E9))); + test('sop4 is #E1EFFF', () => expect(PurpleTheme.sop4, const Color(0xFFE1EFFF))); + test('sop5 is #9EFFFF', () => expect(PurpleTheme.sop5, const Color(0xFF9EFFFF))); + test('sop6 is #FAD000', () => expect(PurpleTheme.sop6, const Color(0xFFFAD000))); + test('sop7 is #FF9D00', () => expect(PurpleTheme.sop7, const Color(0xFFFF9D00))); + test('sop8 is #B362FF', () => expect(PurpleTheme.sop8, const Color(0xFFB362FF))); + test('sop9 is #FF628C', () => expect(PurpleTheme.sop9, const Color(0xFFFF628C))); + test('sop10 is #A5FF90', () => expect(PurpleTheme.sop10, const Color(0xFFA5FF90))); + test('sop11 is #EC3A37', () => expect(PurpleTheme.sop11, const Color(0xFFEC3A37))); + test('sop12 is #80FFBB', () => expect(PurpleTheme.sop12, const Color(0xFF80FFBB))); + test('sop13 is #FB94FF', () => expect(PurpleTheme.sop13, const Color(0xFFFB94FF))); + test('sop14 is #6943FF', () => expect(PurpleTheme.sop14, const Color(0xFF6943FF))); + test('sopL0 is #F8F6FF', () => expect(PurpleTheme.sopL0, const Color(0xFFF8F6FF))); + test('sopL1 is #EDE9FF', () => expect(PurpleTheme.sopL1, const Color(0xFFEDE9FF))); + test('sopL2 is #D6CEFF', () => expect(PurpleTheme.sopL2, const Color(0xFFD6CEFF))); + test('sopL3 is #8B7FD4', () => expect(PurpleTheme.sopL3, const Color(0xFF8B7FD4))); + test('sopL4 is #2D2B55', () => expect(PurpleTheme.sopL4, const Color(0xFF2D2B55))); + test('sopL5 is #6943FF', () => expect(PurpleTheme.sopL5, const Color(0xFF6943FF))); + test('sopL6 is #7B6EC0', () => expect(PurpleTheme.sopL6, const Color(0xFF7B6EC0))); }); - group('Light color values', () { - test('sopL0 is #F8F6FF', () { - expect(PurpleTheme.sopL0, const Color(0xFFF8F6FF)); - }); - - test('sopL1 is #EDE9FF', () { - expect(PurpleTheme.sopL1, const Color(0xFFEDE9FF)); - }); - - test('sopL2 is #D6CEFF', () { - expect(PurpleTheme.sopL2, const Color(0xFFD6CEFF)); - }); - - test('sopL3 is #8B7FD4', () { - expect(PurpleTheme.sopL3, const Color(0xFF8B7FD4)); - }); - - test('sopL4 is #2D2B55', () { - expect(PurpleTheme.sopL4, const Color(0xFF2D2B55)); - }); - - test('sopL5 is #6943FF', () { - expect(PurpleTheme.sopL5, const Color(0xFF6943FF)); - }); + group('Dark semantic token values', () { + test('darkSurface is sop0', () => expect(PurpleTheme.darkSurface, PurpleTheme.sop0)); + test('darkSurfaceContainer is sop1', () => expect(PurpleTheme.darkSurfaceContainer, PurpleTheme.sop1)); + test('darkSurfaceContainerHigh is sop2', () => expect(PurpleTheme.darkSurfaceContainerHigh, PurpleTheme.sop2)); + test('darkOnSurface is sop4', () => expect(PurpleTheme.darkOnSurface, PurpleTheme.sop4)); + test('darkOnSurfaceVariant is sop3', () => expect(PurpleTheme.darkOnSurfaceVariant, PurpleTheme.sop3)); + test('darkPrimary is sop8', () => expect(PurpleTheme.darkPrimary, PurpleTheme.sop8)); + test('darkOnPrimary is sop0', () => expect(PurpleTheme.darkOnPrimary, PurpleTheme.sop0)); + test('darkOutline is lavender ~30%', () => expect(PurpleTheme.darkOutline, const Color(0x4DA599E9))); + test( + 'darkOutlineVariant is lavender ~15%', + () => expect(PurpleTheme.darkOutlineVariant, const Color(0x26A599E9)), + ); + }); - test('sopL6 is #7B6EC0', () { - expect(PurpleTheme.sopL6, const Color(0xFF7B6EC0)); - }); + group('Light semantic token values', () { + test('lightSurface is sopL0', () => expect(PurpleTheme.lightSurface, PurpleTheme.sopL0)); + test('lightSurfaceContainer is sopL1', () => expect(PurpleTheme.lightSurfaceContainer, PurpleTheme.sopL1)); + test('lightOnSurface is sopL4', () => expect(PurpleTheme.lightOnSurface, PurpleTheme.sopL4)); + test('lightOnSurfaceVariant is sopL3', () => expect(PurpleTheme.lightOnSurfaceVariant, PurpleTheme.sopL3)); + test('lightPrimary is sopL5', () => expect(PurpleTheme.lightPrimary, PurpleTheme.sopL5)); + test('lightOnPrimary is sopL0', () => expect(PurpleTheme.lightOnPrimary, PurpleTheme.sopL0)); + test('lightOutline is blue-purple ~30%', () => expect(PurpleTheme.lightOutline, const Color(0x4D6943FF))); + test('lightOutlineVariant is sopL2', () => expect(PurpleTheme.lightOutlineVariant, PurpleTheme.sopL2)); }); group('ThemeData', () { @@ -107,25 +62,26 @@ void main() { expect(theme.useMaterial3, isTrue); expect(theme.brightness, Brightness.dark); - expect(scheme.primary, PurpleTheme.sop3); + expect(scheme.primary, PurpleTheme.darkPrimary); expect(scheme.secondary, PurpleTheme.sop5); - expect(scheme.tertiary, PurpleTheme.sop8); - expect(scheme.surface, PurpleTheme.sop1); - expect(scheme.onSurface, PurpleTheme.sop4); - expect(scheme.surfaceContainerHighest, PurpleTheme.sop2); - expect(scheme.outline, PurpleTheme.sop3); + expect(scheme.tertiary, PurpleTheme.sop3); + expect(scheme.surface, PurpleTheme.darkSurfaceContainer); + expect(scheme.onSurface, PurpleTheme.darkOnSurface); + expect(scheme.onSurfaceVariant, PurpleTheme.darkOnSurfaceVariant); + expect(scheme.surfaceContainerHighest, PurpleTheme.darkSurfaceContainerHigh); + expect(scheme.outline, PurpleTheme.darkOutline); expect(scheme.outlineVariant, PurpleTheme.darkOutlineVariant); expect(scheme.error, PurpleTheme.sop11); - expect(theme.scaffoldBackgroundColor, PurpleTheme.sop0); - expect(theme.appBarTheme.backgroundColor, PurpleTheme.sop0); - expect(theme.cardTheme.color, PurpleTheme.sop1); + expect(theme.scaffoldBackgroundColor, PurpleTheme.darkSurface); + expect(theme.appBarTheme.backgroundColor, PurpleTheme.darkSurface); + expect(theme.cardTheme.color, PurpleTheme.darkSurfaceContainer); expect(theme.dividerTheme.color, PurpleTheme.darkOutlineVariant); - expect(theme.iconTheme.color, PurpleTheme.sop3); - expect(theme.listTileTheme.textColor, PurpleTheme.sop4); - expect(theme.floatingActionButtonTheme.backgroundColor, PurpleTheme.sop3); + expect(theme.iconTheme.color, PurpleTheme.darkOnSurfaceVariant); + expect(theme.listTileTheme.textColor, PurpleTheme.darkOnSurface); + expect(theme.floatingActionButtonTheme.backgroundColor, PurpleTheme.darkPrimary); expect(theme.inputDecorationTheme.filled, isTrue); - expect(theme.inputDecorationTheme.fillColor, PurpleTheme.sop1); - expect(theme.snackBarTheme.backgroundColor, PurpleTheme.sop1); + expect(theme.inputDecorationTheme.fillColor, PurpleTheme.darkSurfaceContainer); + expect(theme.snackBarTheme.backgroundColor, PurpleTheme.darkSurfaceContainerHigh); }); test('light theme maps expected tokens', () { @@ -134,25 +90,25 @@ void main() { expect(theme.useMaterial3, isTrue); expect(theme.brightness, Brightness.light); - expect(scheme.primary, PurpleTheme.sopL5); + expect(scheme.primary, PurpleTheme.lightPrimary); expect(scheme.secondary, PurpleTheme.sopL6); - expect(scheme.tertiary, PurpleTheme.sop8); - expect(scheme.surface, PurpleTheme.sopL1); - expect(scheme.onSurface, PurpleTheme.sopL4); - expect(scheme.surfaceContainerHighest, PurpleTheme.sopL2); - expect(scheme.outline, PurpleTheme.sopL3); - expect(scheme.outlineVariant, PurpleTheme.sopL2); + expect(scheme.tertiary, PurpleTheme.sop3); + expect(scheme.surface, PurpleTheme.lightSurfaceContainer); + expect(scheme.onSurface, PurpleTheme.lightOnSurface); + expect(scheme.onSurfaceVariant, PurpleTheme.lightOnSurfaceVariant); + expect(scheme.outline, PurpleTheme.lightOutline); + expect(scheme.outlineVariant, PurpleTheme.lightOutlineVariant); expect(scheme.error, PurpleTheme.sop11); - expect(theme.scaffoldBackgroundColor, PurpleTheme.sopL0); - expect(theme.appBarTheme.backgroundColor, PurpleTheme.sopL0); - expect(theme.cardTheme.color, PurpleTheme.sopL1); - expect(theme.dividerTheme.color, PurpleTheme.sopL2); - expect(theme.iconTheme.color, PurpleTheme.sopL3); - expect(theme.listTileTheme.textColor, PurpleTheme.sopL4); - expect(theme.floatingActionButtonTheme.backgroundColor, PurpleTheme.sopL5); + expect(theme.scaffoldBackgroundColor, PurpleTheme.lightSurface); + expect(theme.appBarTheme.backgroundColor, PurpleTheme.lightSurface); + expect(theme.cardTheme.color, PurpleTheme.lightSurfaceContainer); + expect(theme.dividerTheme.color, PurpleTheme.lightOutlineVariant); + expect(theme.iconTheme.color, PurpleTheme.lightOnSurfaceVariant); + expect(theme.listTileTheme.textColor, PurpleTheme.lightOnSurface); + expect(theme.floatingActionButtonTheme.backgroundColor, PurpleTheme.lightPrimary); expect(theme.inputDecorationTheme.filled, isTrue); - expect(theme.inputDecorationTheme.fillColor, PurpleTheme.sopL1); - expect(theme.snackBarTheme.backgroundColor, PurpleTheme.sopL1); + expect(theme.inputDecorationTheme.fillColor, PurpleTheme.lightSurfaceContainer); + expect(theme.snackBarTheme.backgroundColor, PurpleTheme.lightSurfaceContainer); }); }); }); diff --git a/test/features/feed/cubit/feed_preferences_cubit_test.dart b/test/features/feed/cubit/feed_preferences_cubit_test.dart index 24fa0e5..e38da53 100644 --- a/test/features/feed/cubit/feed_preferences_cubit_test.dart +++ b/test/features/feed/cubit/feed_preferences_cubit_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:atproto_core/atproto_core.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:bluesky/app_bsky_actor_defs.dart'; @@ -54,6 +56,22 @@ void main() { expect(cubit.state.feeds, isEmpty); }); + test('loadPreferences does not throw if cubit closes before async completion', () async { + final completer = Completer(); + when(() => mockFeedRepository.getPreferences()).thenAnswer((_) => completer.future); + final cubit = FeedPreferencesCubit( + feedRepository: mockFeedRepository, + database: database, + accountDid: 'did:plc:test', + ); + + final future = cubit.loadPreferences(); + await cubit.close(); + completer.complete(PreferencesResult(preferences: [])); + + await expectLater(future, completes); + }); + blocTest( 'loadPreferences loads from API and caches to database', build: () => diff --git a/test/features/feed/data/feed_repository_fallback_test.dart b/test/features/feed/data/feed_repository_fallback_test.dart index 566233d..c6b3aaf 100644 --- a/test/features/feed/data/feed_repository_fallback_test.dart +++ b/test/features/feed/data/feed_repository_fallback_test.dart @@ -166,4 +166,28 @@ void main() { expect(third, equals('ok:bluesky')); expect(attempts, equals(['bluesky', 'blacksky', 'blacksky', 'bluesky'])); }); + + test('drops stale response when routing epoch changes mid-request', () async { + var currentEpoch = 1; + final repo = FeedRepository( + bluesky: bluesky, + database: database, + accountDid: 'did:plc:test', + appViewProvider: 'bluesky', + crossProviderFallbackEnabled: false, + routingEpoch: 1, + routingEpochResolver: () => currentEpoch, + ); + + await expectLater( + () => repo.runPublicReadWithFallbackForTest( + endpointId: 'app.bsky.unspecced.getTrends', + request: (_) async { + currentEpoch = 2; + return 'stale'; + }, + ), + throwsA(isA()), + ); + }); } diff --git a/test/features/settings/bloc/settings_cubit_test.dart b/test/features/settings/bloc/settings_cubit_test.dart index e3787d5..1178750 100644 --- a/test/features/settings/bloc/settings_cubit_test.dart +++ b/test/features/settings/bloc/settings_cubit_test.dart @@ -2,6 +2,8 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/network/app_view_fallback_service.dart'; +import 'package:lazurite/core/network/app_view_router.dart'; import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/core/theme/feed_layout.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; @@ -31,6 +33,7 @@ void main() { expect(cubit.state.appViewProvider, 'bluesky'); expect(cubit.state.crossProviderFallbackEnabled, isFalse); expect(cubit.state.slingshotIdentityFallbackEnabled, isFalse); + expect(cubit.state.routingEpoch, 0); }); test('accepts initial values via constructor', () { @@ -322,6 +325,13 @@ void main() { }, ); + blocTest( + 'setAppViewProvider no-ops when provider is unchanged', + build: () => SettingsCubit(database: database), + act: (cubit) => cubit.setAppViewProvider('bluesky'), + expect: () => [], + ); + blocTest( 'loadSettings falls back to default app view provider for invalid persisted value', build: () => SettingsCubit(database: database), @@ -374,5 +384,72 @@ void main() { .having((s) => s.slingshotIdentityFallbackEnabled, 'slingshotIdentityFallbackEnabled', true), ], ); + + blocTest( + 'bumpRoutingEpoch increments the in-memory routing epoch', + build: () => SettingsCubit(database: database), + act: (cubit) => cubit.bumpRoutingEpoch(), + expect: () => [isA().having((s) => s.routingEpoch, 'routingEpoch', 1)], + ); + + blocTest( + 'recordAppViewRoutingEvent stores last fallback and last error diagnostics', + build: () => SettingsCubit(database: database), + act: (cubit) { + cubit.recordAppViewRoutingEvent( + AppViewFallbackUsedEvent( + endpointId: 'app.bsky.unspecced.getTrends', + fromProvider: 'bluesky', + toProvider: 'blacksky', + occurredAt: DateTime.utc(2026, 4, 30, 15, 0), + ), + ); + cubit.recordAppViewRoutingEvent( + AppViewProviderErrorEvent( + endpointId: 'app.bsky.unspecced.getTrendingTopics', + provider: 'blacksky', + reason: '5xx', + transient: true, + occurredAt: DateTime.utc(2026, 4, 30, 15, 1), + ), + ); + }, + expect: () => [ + isA().having( + (s) => s.appViewLastFallback, + 'appViewLastFallback', + 'endpoint=app.bsky.unspecced.getTrends bluesky->blacksky', + ), + isA().having( + (s) => s.appViewLastError, + 'appViewLastError', + 'endpoint=app.bsky.unspecced.getTrendingTopics provider=blacksky reason=5xx', + ), + ], + ); + + blocTest( + 'refreshAppViewHealth updates summary and timestamp when probe succeeds', + build: () => SettingsCubit( + database: database, + appViewHealthProber: (_) async => AppViewHealth( + providerKey: 'bluesky', + checkedAt: DateTime.utc(2026, 4, 30, 16, 0), + checks: const [ + AppViewCapabilityResult(endpointId: 'a', statusCode: 200, supported: true, critical: true), + AppViewCapabilityResult(endpointId: 'b', statusCode: 200, supported: true, critical: true), + AppViewCapabilityResult(endpointId: 'c', statusCode: 200, supported: true, critical: false), + ], + ), + ), + act: (cubit) => cubit.refreshAppViewHealth(), + expect: () => [ + isA().having((s) => s.appViewHealthRefreshing, 'appViewHealthRefreshing', true), + isA() + .having((s) => s.appViewHealthRefreshing, 'appViewHealthRefreshing', false) + .having((s) => s.appViewHealthSummary, 'appViewHealthSummary', 'Healthy (2/2 critical, 3/3 total)') + .having((s) => s.appViewHealthCheckedAt, 'appViewHealthCheckedAt', DateTime.utc(2026, 4, 30, 16, 0)), + ], + ); }); } diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 46a4e04..69a29ab 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/core/theme/feed_layout.dart'; import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; @@ -59,6 +60,8 @@ void main() { feedLayout: FeedLayout.card, ), ); + when(() => settingsCubit.setAppViewProvider(any())).thenAnswer((_) async {}); + when(() => settingsCubit.refreshAppViewHealth()).thenAnswer((_) async {}); }); Widget buildSubject() { @@ -255,11 +258,87 @@ void main() { expect(find.text('ADVANCED'), findsOneWidget); expect(find.text('Constellation URL'), findsOneWidget); expect(find.text('https://constellation.microcosm.blue'), findsOneWidget); + expect(find.text('AppView Provider'), findsOneWidget); expect(find.text('Cross-Provider Fallback'), findsOneWidget); expect(find.text('Slingshot Identity Fallback'), findsOneWidget); + expect(find.text('Provider Diagnostics'), findsOneWidget); + expect(find.text('Refresh Provider Health'), findsOneWidget); expect(find.byIcon(Icons.edit_outlined), findsNothing); }); + testWidgets('provider change confirmation can be cancelled', (tester) async { + when(() => settingsCubit.state).thenReturn( + const SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + feedLayout: FeedLayout.card, + appViewProvider: AppViewProviders.blueskyKey, + ), + ); + whenListen( + settingsCubit, + const Stream.empty(), + initialState: const SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + feedLayout: FeedLayout.card, + appViewProvider: AppViewProviders.blueskyKey, + ), + ); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + final segmented = find.byKey(const Key('appview-provider-segmented')); + await tester.scrollUntilVisible(segmented, 300); + final segmentedWidget = tester.widget>(segmented); + segmentedWidget.onSelectionChanged?.call(const {AppViewProviders.blackskyKey}); + await tester.pumpAndSettle(); + + expect(find.text('Switch AppView provider?'), findsOneWidget); + expect(find.text('Apply and Restart'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + verifyNever(() => settingsCubit.setAppViewProvider(any())); + }); + + testWidgets('provider change confirmation applies selection when confirmed', (tester) async { + when(() => settingsCubit.state).thenReturn( + const SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + feedLayout: FeedLayout.card, + appViewProvider: AppViewProviders.blueskyKey, + ), + ); + whenListen( + settingsCubit, + const Stream.empty(), + initialState: const SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + feedLayout: FeedLayout.card, + appViewProvider: AppViewProviders.blueskyKey, + ), + ); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + final segmented = find.byKey(const Key('appview-provider-segmented')); + await tester.scrollUntilVisible(segmented, 300); + final segmentedWidget = tester.widget>(segmented); + segmentedWidget.onSelectionChanged?.call(const {AppViewProviders.blackskyKey}); + await tester.pumpAndSettle(); + await tester.tap(find.text('Apply and Restart')); + await tester.pumpAndSettle(); + + verify(() => settingsCubit.setAppViewProvider(AppViewProviders.blackskyKey)).called(1); + }); + testWidgets('shows Video Upload Limits tile in Account section', (tester) async { await tester.pumpWidget(buildSubject()); await tester.pumpAndSettle();