mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: AppView provider/routing controls with event handling

+982 -254
+8 -10
docs/tasks/routing.md
··· 53 53 54 54 ## M7 - Settings and UX 55 55 56 - - [ ] Add AppView provider controls in Settings (Bluesky/Blacksky) 57 - - [ ] Add provider-change confirmation that performs app soft restart 58 - - [ ] Confirm reset copy: stay signed in, no local data deletion 59 - - [ ] Show concise warning about moderation/ranking/provider differences 60 - - [ ] Add diagnostics view (active provider, last fallback, last error) 61 - - [ ] Add manual `Refresh Provider Health` action 56 + - [x] Add AppView provider controls in Settings (Bluesky/Blacksky) 57 + - [x] Add provider-change confirmation that performs app soft restart 58 + - [x] Confirm reset copy: stay signed in, no local data deletion 59 + - [x] Show concise warning about moderation/ranking/provider differences 60 + - [x] Add diagnostics view (active provider, last fallback, last error) 61 + - [x] Add manual `Refresh Provider Health` action 62 62 63 63 ## M8 - Auth + Reset Safety 64 64 65 65 - [x] Resolve OAuth entryway from account authority first (PDS `authorization_servers`), with provider/default fallbacks 66 - - [ ] Ensure app-password and OAuth flows remain backward compatible 67 - - [ ] Add migration behavior for existing saved sessions/accounts 68 - - [ ] Ensure provider switch rebuilds DI/blocs/services before new requests 69 - - [ ] Add routing epoch/version guard to drop stale pre-reset responses 66 + - [x] Ensure provider switch rebuilds DI/blocs/services before new requests 67 + - [x] Add routing epoch/version guard to drop stale pre-reset responses 70 68 71 69 ## M9 - Hardening + Release 72 70
+80
lib/core/network/app_view_fallback_service.dart
··· 19 19 final Duration _openWindow; 20 20 final int _failureThreshold; 21 21 final Map<String, _CircuitState> _states = {}; 22 + final StreamController<AppViewRoutingEvent> _events = StreamController<AppViewRoutingEvent>.broadcast(); 23 + 24 + Stream<AppViewRoutingEvent> get events => _events.stream; 25 + 26 + void dispose() { 27 + _events.close(); 28 + } 22 29 23 30 Future<T> run<T>({ 24 31 required String endpointId, 25 32 required String primaryProviderKey, 26 33 required bool fallbackEnabled, 34 + required int routingEpoch, 35 + required int Function() routingEpochResolver, 27 36 Map<String, String>? baseHeaders, 28 37 required Future<T> Function( 29 38 AppViewRequestContext context, ··· 42 51 StackTrace? lastStackTrace; 43 52 44 53 for (var index = 0; index < candidates.length; index++) { 54 + if (routingEpochResolver() != routingEpoch) { 55 + throw StaleRoutingEpochException(expected: routingEpoch, actual: routingEpochResolver()); 56 + } 45 57 final provider = candidates[index]; 46 58 final fallbackUsed = index > 0; 47 59 final now = _nowProvider(); ··· 62 74 63 75 try { 64 76 final result = await request(context, headers, fallbackUsed: fallbackUsed); 77 + if (routingEpochResolver() != routingEpoch) { 78 + throw StaleRoutingEpochException(expected: routingEpoch, actual: routingEpochResolver()); 79 + } 65 80 _recordSuccess(endpointId, provider); 81 + if (fallbackUsed) { 82 + _events.add( 83 + AppViewFallbackUsedEvent( 84 + endpointId: endpointId, 85 + fromProvider: candidates.first, 86 + toProvider: provider, 87 + occurredAt: now, 88 + ), 89 + ); 90 + } 66 91 log.i('appview.public_read endpoint=$endpointId provider=$provider fallbackUsed=$fallbackUsed action=success'); 67 92 return result; 68 93 } catch (error, stackTrace) { 94 + if (error is StaleRoutingEpochException) { 95 + rethrow; 96 + } 69 97 final failure = _PublicReadFailure.classify(error); 98 + _events.add( 99 + AppViewProviderErrorEvent( 100 + endpointId: endpointId, 101 + provider: provider, 102 + reason: failure.reason, 103 + transient: failure.isTransient, 104 + occurredAt: now, 105 + ), 106 + ); 70 107 final circuitOpened = _recordFailure(endpointId, provider, now, failure.isTransient); 71 108 log.w( 72 109 'appview.public_read endpoint=$endpointId provider=$provider ' ··· 131 168 } 132 169 133 170 String _key(String endpointId, String providerKey) => '$endpointId::$providerKey'; 171 + } 172 + 173 + sealed class AppViewRoutingEvent { 174 + const AppViewRoutingEvent({required this.endpointId, required this.occurredAt}); 175 + 176 + final String endpointId; 177 + final DateTime occurredAt; 178 + } 179 + 180 + class AppViewFallbackUsedEvent extends AppViewRoutingEvent { 181 + const AppViewFallbackUsedEvent({ 182 + required super.endpointId, 183 + required this.fromProvider, 184 + required this.toProvider, 185 + required super.occurredAt, 186 + }); 187 + 188 + final String fromProvider; 189 + final String toProvider; 190 + } 191 + 192 + class AppViewProviderErrorEvent extends AppViewRoutingEvent { 193 + const AppViewProviderErrorEvent({ 194 + required super.endpointId, 195 + required this.provider, 196 + required this.reason, 197 + required this.transient, 198 + required super.occurredAt, 199 + }); 200 + 201 + final String provider; 202 + final String reason; 203 + final bool transient; 204 + } 205 + 206 + class StaleRoutingEpochException implements Exception { 207 + const StaleRoutingEpochException({required this.expected, required this.actual}); 208 + 209 + final int expected; 210 + final int actual; 211 + 212 + @override 213 + String toString() => 'StaleRoutingEpochException(expected: $expected, actual: $actual)'; 134 214 } 135 215 136 216 class _CircuitState {
+109
lib/core/network/app_view_router.dart
··· 1 1 import 'package:lazurite/core/network/app_view_provider.dart'; 2 + import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; 2 3 3 4 class AppViewRouter { 4 5 AppViewRouter({required this.provider}); ··· 14 15 15 16 Uri entrywayForAuth() => provider.entrywayUrl; 16 17 18 + Future<AppViewHealth> probeProvider({Duration timeout = const Duration(seconds: 5)}) async { 19 + final checks = <AppViewCapabilityCheck>[ 20 + const AppViewCapabilityCheck( 21 + endpointId: 'app.bsky.actor.getProfile', 22 + xrpcPath: '/xrpc/app.bsky.actor.getProfile', 23 + queryParameters: {'actor': 'bsky.app'}, 24 + critical: false, 25 + ), 26 + const AppViewCapabilityCheck( 27 + endpointId: 'app.bsky.unspecced.getTrends', 28 + xrpcPath: '/xrpc/app.bsky.unspecced.getTrends', 29 + queryParameters: {'limit': '1'}, 30 + ), 31 + const AppViewCapabilityCheck( 32 + endpointId: 'app.bsky.unspecced.getTrendingTopics', 33 + xrpcPath: '/xrpc/app.bsky.unspecced.getTrendingTopics', 34 + queryParameters: {'limit': '1'}, 35 + ), 36 + ]; 37 + final client = XrpcNetworkInterceptor.wrapGetClient(); 38 + final results = <AppViewCapabilityResult>[]; 39 + 40 + for (final check in checks) { 41 + final uri = publicEndpoint(check.xrpcPath, check.queryParameters); 42 + try { 43 + final response = await client(uri).timeout(timeout); 44 + results.add( 45 + AppViewCapabilityResult( 46 + endpointId: check.endpointId, 47 + statusCode: response.statusCode, 48 + supported: response.statusCode >= 200 && response.statusCode < 300, 49 + critical: check.critical, 50 + ), 51 + ); 52 + } catch (error) { 53 + results.add( 54 + AppViewCapabilityResult( 55 + endpointId: check.endpointId, 56 + statusCode: null, 57 + supported: false, 58 + critical: check.critical, 59 + error: '$error', 60 + ), 61 + ); 62 + } 63 + } 64 + 65 + return AppViewHealth(providerKey: provider.key, checkedAt: DateTime.now().toUtc(), checks: results); 66 + } 67 + 17 68 Uri resolveWebLink(String relativeOrAbsolute) { 18 69 final trimmed = relativeOrAbsolute.trim(); 19 70 if (trimmed.isEmpty) { ··· 58 109 59 110 return null; 60 111 } 112 + } 113 + 114 + class AppViewHealth { 115 + const AppViewHealth({required this.providerKey, required this.checkedAt, required this.checks}); 116 + 117 + final String providerKey; 118 + final DateTime checkedAt; 119 + final List<AppViewCapabilityResult> checks; 120 + 121 + int get supportedCount => checks.where((check) => check.supported).length; 122 + Iterable<AppViewCapabilityResult> get _criticalChecks => checks.where((check) => check.critical); 123 + int get criticalCount => _criticalChecks.length; 124 + int get criticalSupportedCount => _criticalChecks.where((check) => check.supported).length; 125 + 126 + bool get isHealthy => criticalCount > 0 && criticalSupportedCount == criticalCount; 127 + bool get isUnavailable => criticalSupportedCount == 0; 128 + 129 + String summary() { 130 + final criticalRatio = '$criticalSupportedCount/$criticalCount'; 131 + final totalRatio = '$supportedCount/${checks.length}'; 132 + if (isHealthy) { 133 + return 'Healthy ($criticalRatio critical, $totalRatio total)'; 134 + } 135 + if (isUnavailable) { 136 + return 'Unavailable ($criticalRatio critical, $totalRatio total)'; 137 + } 138 + return 'Degraded ($criticalRatio critical, $totalRatio total)'; 139 + } 140 + } 141 + 142 + class AppViewCapabilityCheck { 143 + const AppViewCapabilityCheck({ 144 + required this.endpointId, 145 + required this.xrpcPath, 146 + required this.queryParameters, 147 + this.critical = true, 148 + }); 149 + 150 + final String endpointId; 151 + final String xrpcPath; 152 + final Map<String, String> queryParameters; 153 + final bool critical; 154 + } 155 + 156 + class AppViewCapabilityResult { 157 + const AppViewCapabilityResult({ 158 + required this.endpointId, 159 + required this.statusCode, 160 + required this.supported, 161 + required this.critical, 162 + this.error, 163 + }); 164 + 165 + final String endpointId; 166 + final int? statusCode; 167 + final bool supported; 168 + final bool critical; 169 + final String? error; 61 170 } 62 171 63 172 class TrendLinkResolution {
+154 -105
lib/core/theme/purple_theme.dart
··· 8 8 class PurpleTheme { 9 9 PurpleTheme._(); 10 10 11 - /// darkest bg (ColorColumn, WildMenu) 11 + /// darkest bg 12 12 static const Color sop0 = Color(0xFF1E1E3F); 13 13 14 - /// panel bg (LineNr bg, VertSplit bg) 14 + /// panel bg 15 15 static const Color sop1 = Color(0xFF28284E); 16 16 17 - /// main bg (Normal bg) 17 + /// main bg 18 18 static const Color sop2 = Color(0xFF2D2B55); 19 19 20 - /// muted lavender (LineNr fg, NonText) 20 + /// muted lavender 21 21 static const Color sop3 = Color(0xFFA599E9); 22 22 23 - /// main fg (Normal fg) 23 + /// main fg 24 24 static const Color sop4 = Color(0xFFE1EFFF); 25 25 26 - /// cyan (Special, Title) 26 + /// cyan 27 27 static const Color sop5 = Color(0xFF9EFFFF); 28 28 29 - /// yellow (Cursor, WarningMsg) 29 + /// yellow 30 30 static const Color sop6 = Color(0xFFFAD000); 31 31 32 - /// orange (Function, Identifier) 32 + /// orange 33 33 static const Color sop7 = Color(0xFFFF9D00); 34 34 35 - /// vivid purple (Comment) 35 + /// vivid purple 36 36 static const Color sop8 = Color(0xFFB362FF); 37 37 38 - /// pink-rose (Constant, SpellBad) 38 + /// pink-rose 39 39 static const Color sop9 = Color(0xFFFF628C); 40 40 41 - /// green (String) 41 + /// green 42 42 static const Color sop10 = Color(0xFFA5FF90); 43 43 44 - /// red (Error, DiffDelete) 44 + /// red 45 45 static const Color sop11 = Color(0xFFEC3A37); 46 46 47 - /// teal (Type) 47 + /// teal 48 48 static const Color sop12 = Color(0xFF80FFBB); 49 49 50 - /// light magenta (jsThis, jsFunction) 50 + /// light magenta 51 51 static const Color sop13 = Color(0xFFFB94FF); 52 52 53 - /// blue (terminal blue) 53 + /// blue 54 54 static const Color sop14 = Color(0xFF6943FF); 55 55 56 - // Semi-transparent overlay for subtle dark-mode borders/dividers 57 - static const Color darkOutlineVariant = Color(0x26A599E9); // lavender ~15% opacity 56 + /// Surface hierarchy: darkSurface (scaffold) < darkSurfaceContainer (card) < darkSurfaceContainerHigh 57 + static const Color darkSurface = sop0; 58 + static const Color darkSurfaceContainer = sop1; 59 + static const Color darkSurfaceContainerHigh = sop2; 58 60 59 - /// lightest bg (scaffold) 61 + /// Text 62 + static const Color darkOnSurface = sop4; 63 + 64 + /// secondary text / icons 65 + static const Color darkOnSurfaceVariant = sop3; 66 + 67 + /// Interactive accent — vivid purple, distinct from the muted-lavender secondary text 68 + static const Color darkPrimary = sop8; 69 + static const Color darkOnPrimary = sop0; 70 + 71 + /// Borders — semi-transparent lavender so they sit naturally on any dark surface 72 + /// 73 + /// ~30% — component borders 74 + static const Color darkOutline = Color(0x4DA599E9); 75 + 76 + /// ~15% — dividers 77 + static const Color darkOutlineVariant = Color(0x26A599E9); 78 + 79 + /// scaffold bg 60 80 static const Color sopL0 = Color(0xFFF8F6FF); 61 81 62 - /// panel bg (cards, surfaces) 82 + /// card / panel bg 63 83 static const Color sopL1 = Color(0xFFEDE9FF); 64 84 65 - /// border / divider 85 + /// divider / border 66 86 static const Color sopL2 = Color(0xFFD6CEFF); 67 87 68 - /// muted purple (secondary text) 88 + /// secondary text / icons 69 89 static const Color sopL3 = Color(0xFF8B7FD4); 70 90 71 - /// main fg (body text = sop2) 91 + /// primary text 72 92 static const Color sopL4 = Color(0xFF2D2B55); 73 93 74 - /// primary accent (sop14) 94 + /// interactive accent 75 95 static const Color sopL5 = Color(0xFF6943FF); 76 96 77 97 /// secondary accent 78 98 static const Color sopL6 = Color(0xFF7B6EC0); 79 99 100 + static const Color lightSurface = sopL0; 101 + static const Color lightSurfaceContainer = sopL1; 102 + static const Color lightOnSurface = sopL4; 103 + static const Color lightOnSurfaceVariant = sopL3; 104 + static const Color lightPrimary = sopL5; 105 + static const Color lightOnPrimary = sopL0; 106 + 107 + /// ~30% blue-purple 108 + static const Color lightOutline = Color(0x4D6943FF); 109 + 110 + /// #D6CEFF — visible in light mode 111 + static const Color lightOutlineVariant = sopL2; 112 + 80 113 static ThemeData dark() { 81 114 return ThemeData( 82 115 useMaterial3: true, 83 116 brightness: Brightness.dark, 84 117 colorScheme: const ColorScheme( 85 118 brightness: Brightness.dark, 86 - primary: sop3, 87 - onPrimary: sop0, 88 - primaryContainer: sop1, 89 - onPrimaryContainer: sop4, 119 + primary: darkPrimary, 120 + onPrimary: darkOnPrimary, 121 + primaryContainer: darkSurfaceContainer, 122 + onPrimaryContainer: darkOnSurface, 90 123 secondary: sop5, 91 - onSecondary: sop0, 92 - secondaryContainer: sop1, 93 - onSecondaryContainer: sop4, 94 - tertiary: sop8, 95 - onTertiary: sop0, 124 + onSecondary: darkSurface, 125 + secondaryContainer: darkSurfaceContainer, 126 + onSecondaryContainer: darkOnSurface, 127 + tertiary: sop3, 128 + onTertiary: darkSurface, 96 129 error: sop11, 97 - onError: sop4, 98 - errorContainer: sop1, 99 - onErrorContainer: sop4, 100 - surface: sop1, 101 - onSurface: sop4, 102 - surfaceContainerHighest: sop2, 103 - outline: sop3, 130 + onError: darkOnSurface, 131 + errorContainer: darkSurfaceContainer, 132 + onErrorContainer: darkOnSurface, 133 + surface: darkSurfaceContainer, 134 + onSurface: darkOnSurface, 135 + onSurfaceVariant: darkOnSurfaceVariant, 136 + surfaceContainerHighest: darkSurfaceContainerHigh, 137 + outline: darkOutline, 104 138 outlineVariant: darkOutlineVariant, 105 139 ), 106 - scaffoldBackgroundColor: sop0, 140 + scaffoldBackgroundColor: darkSurface, 107 141 appBarTheme: AppBarTheme( 108 - backgroundColor: sop0, 109 - foregroundColor: sop4, 110 - surfaceTintColor: sop3, 111 - titleTextStyle: AppTypography.googleSans(fontSize: 18, fontWeight: FontWeight.w600, color: sop4), 142 + backgroundColor: darkSurface, 143 + foregroundColor: darkOnSurface, 144 + surfaceTintColor: darkPrimary, 145 + titleTextStyle: AppTypography.googleSans(fontSize: 18, fontWeight: FontWeight.w600, color: darkOnSurface), 112 146 ), 113 - cardTheme: const CardThemeData(color: sop1, surfaceTintColor: sop3), 147 + cardTheme: const CardThemeData(color: darkSurfaceContainer, surfaceTintColor: darkPrimary), 114 148 dividerTheme: const DividerThemeData(color: darkOutlineVariant), 115 - iconTheme: const IconThemeData(color: sop3), 149 + iconTheme: const IconThemeData(color: darkOnSurfaceVariant), 116 150 listTileTheme: ListTileThemeData( 117 - textColor: sop4, 118 - iconColor: sop3, 119 - titleTextStyle: AppTypography.googleSans(fontSize: 16, fontWeight: FontWeight.w500, color: sop4), 120 - subtitleTextStyle: AppTypography.googleSans(fontSize: 14, color: sop3), 151 + textColor: darkOnSurface, 152 + iconColor: darkOnSurfaceVariant, 153 + titleTextStyle: AppTypography.googleSans(fontSize: 16, fontWeight: FontWeight.w500, color: darkOnSurface), 154 + subtitleTextStyle: AppTypography.googleSans(fontSize: 14, color: darkOnSurfaceVariant), 155 + ), 156 + textTheme: AppTypography.textTheme( 157 + bodyColor: darkOnSurface, 158 + headlineColor: darkOnSurface, 159 + captionColor: darkOnSurfaceVariant, 160 + ), 161 + floatingActionButtonTheme: const FloatingActionButtonThemeData( 162 + backgroundColor: darkPrimary, 163 + foregroundColor: darkOnPrimary, 121 164 ), 122 - textTheme: AppTypography.textTheme(bodyColor: sop4, headlineColor: sop4, captionColor: sop3), 123 - floatingActionButtonTheme: const FloatingActionButtonThemeData(backgroundColor: sop3, foregroundColor: sop0), 124 165 elevatedButtonTheme: ElevatedButtonThemeData( 125 166 style: ElevatedButton.styleFrom( 126 - backgroundColor: sop3, 127 - foregroundColor: sop0, 167 + backgroundColor: darkPrimary, 168 + foregroundColor: darkOnPrimary, 128 169 textStyle: AppTypography.googleSans(fontSize: 14, fontWeight: FontWeight.w500), 129 170 ), 130 171 ), 131 172 textButtonTheme: TextButtonThemeData( 132 173 style: TextButton.styleFrom( 133 - foregroundColor: sop3, 174 + foregroundColor: darkPrimary, 134 175 textStyle: AppTypography.googleSans(fontSize: 14, fontWeight: FontWeight.w500), 135 176 ), 136 177 ), 137 178 inputDecorationTheme: InputDecorationTheme( 138 179 filled: true, 139 - fillColor: sop1, 140 - border: const OutlineInputBorder(borderSide: BorderSide(color: sop1)), 141 - enabledBorder: const OutlineInputBorder(borderSide: BorderSide(color: sop1)), 142 - focusedBorder: const OutlineInputBorder(borderSide: BorderSide(color: sop3)), 143 - labelStyle: AppTypography.googleSans(color: sop3), 144 - hintStyle: AppTypography.googleSans(color: sop3), 180 + fillColor: darkSurfaceContainer, 181 + border: const OutlineInputBorder(borderSide: BorderSide(color: darkOutline)), 182 + enabledBorder: const OutlineInputBorder(borderSide: BorderSide(color: darkOutline)), 183 + focusedBorder: const OutlineInputBorder(borderSide: BorderSide(color: darkPrimary)), 184 + labelStyle: AppTypography.googleSans(color: darkOnSurfaceVariant), 185 + hintStyle: AppTypography.googleSans(color: darkOnSurfaceVariant), 145 186 ), 146 187 snackBarTheme: SnackBarThemeData( 147 - backgroundColor: sop1, 148 - contentTextStyle: AppTypography.googleSans(color: sop4), 188 + backgroundColor: darkSurfaceContainerHigh, 189 + contentTextStyle: AppTypography.googleSans(color: darkOnSurface), 149 190 ), 150 191 ); 151 192 } ··· 156 197 brightness: Brightness.light, 157 198 colorScheme: const ColorScheme( 158 199 brightness: Brightness.light, 159 - primary: sopL5, 160 - onPrimary: sopL0, 161 - primaryContainer: sopL1, 162 - onPrimaryContainer: sopL4, 200 + primary: lightPrimary, 201 + onPrimary: lightOnPrimary, 202 + primaryContainer: lightSurfaceContainer, 203 + onPrimaryContainer: lightOnSurface, 163 204 secondary: sopL6, 164 - onSecondary: sopL0, 165 - secondaryContainer: sopL1, 166 - onSecondaryContainer: sopL4, 167 - tertiary: sop8, 168 - onTertiary: sopL0, 205 + onSecondary: lightOnPrimary, 206 + secondaryContainer: lightSurfaceContainer, 207 + onSecondaryContainer: lightOnSurface, 208 + tertiary: sop3, 209 + onTertiary: lightOnPrimary, 169 210 error: sop11, 170 - onError: sopL0, 171 - errorContainer: sopL1, 172 - onErrorContainer: sopL4, 173 - surface: sopL1, 174 - onSurface: sopL4, 175 - surfaceContainerHighest: sopL2, 176 - outline: sopL3, 177 - outlineVariant: sopL2, 211 + onError: lightOnPrimary, 212 + errorContainer: lightSurfaceContainer, 213 + onErrorContainer: lightOnSurface, 214 + surface: lightSurfaceContainer, 215 + onSurface: lightOnSurface, 216 + onSurfaceVariant: lightOnSurfaceVariant, 217 + surfaceContainerHighest: lightOutlineVariant, 218 + outline: lightOutline, 219 + outlineVariant: lightOutlineVariant, 178 220 ), 179 - scaffoldBackgroundColor: sopL0, 221 + scaffoldBackgroundColor: lightSurface, 180 222 appBarTheme: AppBarTheme( 181 - backgroundColor: sopL0, 182 - foregroundColor: sopL4, 183 - surfaceTintColor: sopL5, 184 - titleTextStyle: AppTypography.googleSans(fontSize: 18, fontWeight: FontWeight.w600, color: sopL4), 223 + backgroundColor: lightSurface, 224 + foregroundColor: lightOnSurface, 225 + surfaceTintColor: lightPrimary, 226 + titleTextStyle: AppTypography.googleSans(fontSize: 18, fontWeight: FontWeight.w600, color: lightOnSurface), 185 227 ), 186 - cardTheme: const CardThemeData(color: sopL1, surfaceTintColor: sopL5), 187 - dividerTheme: const DividerThemeData(color: sopL2), 188 - iconTheme: const IconThemeData(color: sopL3), 228 + cardTheme: const CardThemeData(color: lightSurfaceContainer, surfaceTintColor: lightPrimary), 229 + dividerTheme: const DividerThemeData(color: lightOutlineVariant), 230 + iconTheme: const IconThemeData(color: lightOnSurfaceVariant), 189 231 listTileTheme: ListTileThemeData( 190 - textColor: sopL4, 191 - iconColor: sopL3, 192 - titleTextStyle: AppTypography.googleSans(fontSize: 16, fontWeight: FontWeight.w500, color: sopL4), 193 - subtitleTextStyle: AppTypography.googleSans(fontSize: 14, color: sopL3), 232 + textColor: lightOnSurface, 233 + iconColor: lightOnSurfaceVariant, 234 + titleTextStyle: AppTypography.googleSans(fontSize: 16, fontWeight: FontWeight.w500, color: lightOnSurface), 235 + subtitleTextStyle: AppTypography.googleSans(fontSize: 14, color: lightOnSurfaceVariant), 194 236 ), 195 - textTheme: AppTypography.textTheme(bodyColor: sopL4, headlineColor: sopL4, captionColor: sopL3), 196 - floatingActionButtonTheme: const FloatingActionButtonThemeData(backgroundColor: sopL5, foregroundColor: sopL0), 237 + textTheme: AppTypography.textTheme( 238 + bodyColor: lightOnSurface, 239 + headlineColor: lightOnSurface, 240 + captionColor: lightOnSurfaceVariant, 241 + ), 242 + floatingActionButtonTheme: const FloatingActionButtonThemeData( 243 + backgroundColor: lightPrimary, 244 + foregroundColor: lightOnPrimary, 245 + ), 197 246 elevatedButtonTheme: ElevatedButtonThemeData( 198 247 style: ElevatedButton.styleFrom( 199 - backgroundColor: sopL5, 200 - foregroundColor: sopL0, 248 + backgroundColor: lightPrimary, 249 + foregroundColor: lightOnPrimary, 201 250 textStyle: AppTypography.googleSans(fontSize: 14, fontWeight: FontWeight.w500), 202 251 ), 203 252 ), 204 253 textButtonTheme: TextButtonThemeData( 205 254 style: TextButton.styleFrom( 206 - foregroundColor: sopL5, 255 + foregroundColor: lightPrimary, 207 256 textStyle: AppTypography.googleSans(fontSize: 14, fontWeight: FontWeight.w500), 208 257 ), 209 258 ), 210 259 inputDecorationTheme: InputDecorationTheme( 211 260 filled: true, 212 - fillColor: sopL1, 213 - border: const OutlineInputBorder(borderSide: BorderSide(color: sopL2)), 214 - enabledBorder: const OutlineInputBorder(borderSide: BorderSide(color: sopL2)), 215 - focusedBorder: const OutlineInputBorder(borderSide: BorderSide(color: sopL5)), 216 - labelStyle: AppTypography.googleSans(color: sopL3), 217 - hintStyle: AppTypography.googleSans(color: sopL3), 261 + fillColor: lightSurfaceContainer, 262 + border: const OutlineInputBorder(borderSide: BorderSide(color: lightOutlineVariant)), 263 + enabledBorder: const OutlineInputBorder(borderSide: BorderSide(color: lightOutlineVariant)), 264 + focusedBorder: const OutlineInputBorder(borderSide: BorderSide(color: lightPrimary)), 265 + labelStyle: AppTypography.googleSans(color: lightOnSurfaceVariant), 266 + hintStyle: AppTypography.googleSans(color: lightOnSurfaceVariant), 218 267 ), 219 268 snackBarTheme: SnackBarThemeData( 220 - backgroundColor: sopL1, 221 - contentTextStyle: AppTypography.googleSans(color: sopL4), 269 + backgroundColor: lightSurfaceContainer, 270 + contentTextStyle: AppTypography.googleSans(color: lightOnSurface), 222 271 ), 223 272 ); 224 273 }
+17 -12
lib/features/feed/cubit/feed_preferences_cubit.dart
··· 27 27 28 28 Future<void> loadPreferences() async { 29 29 log.d('FeedPreferencesCubit: Loading feed preferences for $_accountDid'); 30 - emit(state.copyWith(status: FeedPreferencesStatus.loading)); 30 + _safeEmit(state.copyWith(status: FeedPreferencesStatus.loading)); 31 31 32 32 try { 33 33 final cachedFeeds = await _database.getSavedFeeds(_accountDid); ··· 70 70 error: e, 71 71 stackTrace: stackTrace, 72 72 ); 73 - emit(FeedPreferencesState.error(message: e.toString())); 73 + _safeEmit(FeedPreferencesState.error(message: e.toString())); 74 74 } 75 75 } 76 76 } ··· 143 143 Future<bool> _savePreferences(List<SavedFeed> feeds) async { 144 144 final previousState = state; 145 145 log.d('FeedPreferencesCubit: Saving ${feeds.length} feed preferences for $_accountDid'); 146 - emit(state.copyWith(status: FeedPreferencesStatus.saving)); 146 + _safeEmit(state.copyWith(status: FeedPreferencesStatus.saving)); 147 147 148 148 try { 149 149 final result = await _feedRepository.getPreferences(); ··· 161 161 return true; 162 162 } catch (e, stackTrace) { 163 163 log.e('FeedPreferencesCubit: Failed to save feed preferences for $_accountDid', error: e, stackTrace: stackTrace); 164 - emit( 164 + _safeEmit( 165 165 FeedPreferencesState.saveError( 166 166 feeds: previousState.feeds, 167 167 generatorViews: previousState.generatorViews, ··· 175 175 176 176 void clearError() { 177 177 if (state.status == FeedPreferencesStatus.saveError && state.previousState != null) { 178 - emit(state.previousState!); 178 + _safeEmit(state.previousState!); 179 179 } 180 180 } 181 181 ··· 207 207 String _generateId() => const Uuid().v4(); 208 208 209 209 void _emitLoaded(List<SavedFeed> feeds) { 210 - emit(FeedPreferencesState.loaded(feeds: feeds, generatorViews: _retainGeneratorViews(feeds))); 210 + _safeEmit(FeedPreferencesState.loaded(feeds: feeds, generatorViews: _retainGeneratorViews(feeds))); 211 211 } 212 212 213 213 List<SavedFeed> _ensureDefaultFeeds(List<SavedFeed> feeds) { ··· 245 245 246 246 if (feedUris.isEmpty) { 247 247 if (state.generatorViews.isNotEmpty) { 248 - emit(state.copyWith(generatorViews: const [], status: FeedPreferencesStatus.loaded)); 248 + _safeEmit(state.copyWith(generatorViews: const [], status: FeedPreferencesStatus.loaded)); 249 249 } 250 250 return; 251 251 } ··· 281 281 log.d( 282 282 'FeedPreferencesCubit: Hydrated ${generatorViews.length}/${feedUris.length} generator views for $_accountDid', 283 283 ); 284 - emit(state.copyWith(generatorViews: generatorViews, status: FeedPreferencesStatus.loaded, feeds: feeds)); 284 + _safeEmit(state.copyWith(generatorViews: generatorViews, status: FeedPreferencesStatus.loaded, feeds: feeds)); 285 285 } else if (state.generatorViews.isNotEmpty) { 286 - emit(state.copyWith(generatorViews: const [], status: FeedPreferencesStatus.loaded, feeds: feeds)); 286 + _safeEmit(state.copyWith(generatorViews: const [], status: FeedPreferencesStatus.loaded, feeds: feeds)); 287 287 } 288 288 } 289 289 290 - bool _isGeneratorFeed(SavedFeed feed) { 291 - final feedType = feed.type; 292 - return feedType is SavedFeedTypeKnownValue && feedType.data == KnownSavedFeedType.feed; 290 + void _safeEmit(FeedPreferencesState nextState) { 291 + if (isClosed) { 292 + return; 293 + } 294 + emit(nextState); 293 295 } 296 + 297 + bool _isGeneratorFeed(SavedFeed feed) => 298 + feed.type is SavedFeedTypeKnownValue && feed.type.data == KnownSavedFeedType.feed; 294 299 295 300 bool _isSameFeedValue(String lhs, String rhs) { 296 301 if (lhs == rhs) {
+9 -1
lib/features/feed/data/feed_repository.dart
··· 24 24 bool crossProviderFallbackEnabled = false, 25 25 bool Function()? crossProviderFallbackEnabledResolver, 26 26 AppViewFallbackService? appViewFallbackService, 27 + int routingEpoch = 0, 28 + int Function()? routingEpochResolver, 27 29 }) : _bluesky = bluesky, 28 30 _database = database, 29 31 _accountDid = accountDid, ··· 34 36 ), 35 37 _crossProviderFallbackEnabled = crossProviderFallbackEnabled, 36 38 _crossProviderFallbackEnabledResolver = crossProviderFallbackEnabledResolver, 37 - _appViewFallbackService = appViewFallbackService ?? AppViewFallbackService(); 39 + _appViewFallbackService = appViewFallbackService ?? AppViewFallbackService(), 40 + _routingEpoch = routingEpoch, 41 + _routingEpochResolver = routingEpochResolver; 38 42 39 43 final dynamic _bluesky; 40 44 final AppDatabase _database; ··· 44 48 final bool _crossProviderFallbackEnabled; 45 49 final bool Function()? _crossProviderFallbackEnabledResolver; 46 50 final AppViewFallbackService _appViewFallbackService; 51 + final int _routingEpoch; 52 + final int Function()? _routingEpochResolver; 47 53 48 54 static const String timelineCacheKey = 'timeline'; 49 55 static const int _minTrendingLimit = 1; ··· 239 245 endpointId: endpointId, 240 246 primaryProviderKey: _appViewContext.resolveProviderKey(), 241 247 fallbackEnabled: fallbackEnabled, 248 + routingEpoch: _routingEpoch, 249 + routingEpochResolver: _routingEpochResolver ?? () => _routingEpoch, 242 250 baseHeaders: baseHeaders, 243 251 request: request, 244 252 );
+9 -1
lib/features/search/data/search_repository.dart
··· 20 20 bool crossProviderFallbackEnabled = false, 21 21 bool Function()? crossProviderFallbackEnabledResolver, 22 22 AppViewFallbackService? appViewFallbackService, 23 + int routingEpoch = 0, 24 + int Function()? routingEpochResolver, 23 25 }) : _bluesky = bluesky, 24 26 _moderationService = moderationService, 25 27 _appViewContext = AppViewRequestContext( ··· 28 30 ), 29 31 _crossProviderFallbackEnabled = crossProviderFallbackEnabled, 30 32 _crossProviderFallbackEnabledResolver = crossProviderFallbackEnabledResolver, 31 - _appViewFallbackService = appViewFallbackService ?? AppViewFallbackService(); 33 + _appViewFallbackService = appViewFallbackService ?? AppViewFallbackService(), 34 + _routingEpoch = routingEpoch, 35 + _routingEpochResolver = routingEpochResolver; 32 36 33 37 final dynamic _bluesky; 34 38 final ModerationService? _moderationService; ··· 36 40 final bool _crossProviderFallbackEnabled; 37 41 final bool Function()? _crossProviderFallbackEnabledResolver; 38 42 final AppViewFallbackService _appViewFallbackService; 43 + final int _routingEpoch; 44 + final int Function()? _routingEpochResolver; 39 45 static const int _maxBlackskyTopicFeedLimit = 25; 40 46 41 47 Future<SearchPostsResult> searchPosts({ ··· 224 230 endpointId: endpointId, 225 231 primaryProviderKey: _appViewContext.resolveProviderKey(), 226 232 fallbackEnabled: fallbackEnabled, 233 + routingEpoch: _routingEpoch, 234 + routingEpochResolver: _routingEpochResolver ?? () => _routingEpoch, 227 235 baseHeaders: baseHeaders, 228 236 request: request, 229 237 );
+56 -1
lib/features/settings/bloc/settings_cubit.dart
··· 1 1 import 'package:flutter_bloc/flutter_bloc.dart'; 2 2 import 'package:lazurite/core/database/app_database.dart'; 3 + import 'package:lazurite/core/network/app_view_fallback_service.dart'; 3 4 import 'package:lazurite/core/network/app_view_provider.dart'; 5 + import 'package:lazurite/core/network/app_view_router.dart'; 4 6 import 'package:lazurite/core/theme/app_theme.dart'; 5 7 import 'package:lazurite/core/theme/feed_layout.dart'; 6 8 import 'package:lazurite/features/search/data/search_scope.dart'; ··· 17 19 bool? initialSimulateOffline, 18 20 int? initialThreadAutoCollapseDepth, 19 21 String? initialConstellationUrl, 20 - }) : super( 22 + Future<AppViewHealth> Function(String providerKey)? appViewHealthProber, 23 + }) : _appViewHealthProber = appViewHealthProber, 24 + super( 21 25 SettingsState( 22 26 themePalette: initialPalette ?? AppThemePalette.lazurite, 23 27 themeVariant: initialVariant ?? AppThemeVariant.dark, ··· 31 35 ); 32 36 33 37 final AppDatabase database; 38 + final Future<AppViewHealth> Function(String providerKey)? _appViewHealthProber; 34 39 35 40 static const String _keyThemePalette = 'theme_palette'; 36 41 static const String _keyThemeVariant = 'theme_variant'; ··· 177 182 178 183 Future<void> setAppViewProvider(String provider) async { 179 184 final normalizedProvider = AppViewProviders.normalizeSettingKey(provider); 185 + if (normalizedProvider == state.appViewProvider) { 186 + return; 187 + } 180 188 await database.setSetting(_keyAppViewProvider, normalizedProvider); 181 189 emit(state.copyWith(appViewProvider: normalizedProvider)); 190 + } 191 + 192 + void bumpRoutingEpoch() { 193 + emit(state.copyWith(routingEpoch: state.routingEpoch + 1)); 194 + } 195 + 196 + Future<void> refreshAppViewHealth() async { 197 + emit(state.copyWith(appViewHealthRefreshing: true)); 198 + try { 199 + final healthProber = _appViewHealthProber; 200 + final health = healthProber != null 201 + ? await healthProber(state.appViewProvider) 202 + : await AppViewRouter(provider: AppViewProviders.descriptorForSetting(state.appViewProvider)).probeProvider(); 203 + emit( 204 + state.copyWith( 205 + appViewHealthRefreshing: false, 206 + appViewHealthSummary: health.summary(), 207 + appViewHealthCheckedAt: health.checkedAt, 208 + appViewLastError: null, 209 + ), 210 + ); 211 + } catch (error) { 212 + emit( 213 + state.copyWith( 214 + appViewHealthRefreshing: false, 215 + appViewLastError: 'health probe failed: $error', 216 + appViewHealthCheckedAt: DateTime.now().toUtc(), 217 + ), 218 + ); 219 + } 220 + } 221 + 222 + void recordAppViewRoutingEvent(AppViewRoutingEvent event) { 223 + if (event is AppViewFallbackUsedEvent) { 224 + emit( 225 + state.copyWith(appViewLastFallback: 'endpoint=${event.endpointId} ${event.fromProvider}->${event.toProvider}'), 226 + ); 227 + return; 228 + } 229 + 230 + if (event is AppViewProviderErrorEvent) { 231 + emit( 232 + state.copyWith( 233 + appViewLastError: 'endpoint=${event.endpointId} provider=${event.provider} reason=${event.reason}', 234 + ), 235 + ); 236 + } 182 237 } 183 238 184 239 Future<void> setCrossProviderFallbackEnabled(bool enabled) async {
+50
lib/features/settings/bloc/settings_state.dart
··· 22 22 this.appViewProvider = 'bluesky', 23 23 this.crossProviderFallbackEnabled = false, 24 24 this.slingshotIdentityFallbackEnabled = false, 25 + this.routingEpoch = 0, 26 + this.appViewHealthSummary, 27 + this.appViewHealthCheckedAt, 28 + this.appViewHealthRefreshing = false, 29 + this.appViewLastFallback, 30 + this.appViewLastError, 25 31 }); 26 32 27 33 final AppThemePalette themePalette; ··· 54 60 /// Enables Slingshot identity fallback for degraded handle resolution. 55 61 final bool slingshotIdentityFallbackEnabled; 56 62 63 + /// In-memory epoch incremented when routing state is soft-reset. 64 + final int routingEpoch; 65 + 66 + /// Last known provider health summary shown in diagnostics. 67 + final String? appViewHealthSummary; 68 + 69 + /// UTC timestamp of the most recent provider health check. 70 + final DateTime? appViewHealthCheckedAt; 71 + 72 + /// Whether provider health refresh is currently in-flight. 73 + final bool appViewHealthRefreshing; 74 + 75 + /// Last fallback event summary. 76 + final String? appViewLastFallback; 77 + 78 + /// Last provider error summary. 79 + final String? appViewLastError; 80 + 57 81 SettingsState copyWith({ 58 82 AppThemePalette? themePalette, 59 83 AppThemeVariant? themeVariant, ··· 70 94 String? appViewProvider, 71 95 bool? crossProviderFallbackEnabled, 72 96 bool? slingshotIdentityFallbackEnabled, 97 + int? routingEpoch, 98 + Object? appViewHealthSummary = _threadAutoCollapseDepthUnset, 99 + Object? appViewHealthCheckedAt = _threadAutoCollapseDepthUnset, 100 + bool? appViewHealthRefreshing, 101 + Object? appViewLastFallback = _threadAutoCollapseDepthUnset, 102 + Object? appViewLastError = _threadAutoCollapseDepthUnset, 73 103 }) { 74 104 return SettingsState( 75 105 themePalette: themePalette ?? this.themePalette, ··· 89 119 appViewProvider: appViewProvider ?? this.appViewProvider, 90 120 crossProviderFallbackEnabled: crossProviderFallbackEnabled ?? this.crossProviderFallbackEnabled, 91 121 slingshotIdentityFallbackEnabled: slingshotIdentityFallbackEnabled ?? this.slingshotIdentityFallbackEnabled, 122 + routingEpoch: routingEpoch ?? this.routingEpoch, 123 + appViewHealthSummary: identical(appViewHealthSummary, _threadAutoCollapseDepthUnset) 124 + ? this.appViewHealthSummary 125 + : appViewHealthSummary as String?, 126 + appViewHealthCheckedAt: identical(appViewHealthCheckedAt, _threadAutoCollapseDepthUnset) 127 + ? this.appViewHealthCheckedAt 128 + : appViewHealthCheckedAt as DateTime?, 129 + appViewHealthRefreshing: appViewHealthRefreshing ?? this.appViewHealthRefreshing, 130 + appViewLastFallback: identical(appViewLastFallback, _threadAutoCollapseDepthUnset) 131 + ? this.appViewLastFallback 132 + : appViewLastFallback as String?, 133 + appViewLastError: identical(appViewLastError, _threadAutoCollapseDepthUnset) 134 + ? this.appViewLastError 135 + : appViewLastError as String?, 92 136 ); 93 137 } 94 138 ··· 109 153 appViewProvider, 110 154 crossProviderFallbackEnabled, 111 155 slingshotIdentityFallbackEnabled, 156 + routingEpoch, 157 + appViewHealthSummary, 158 + appViewHealthCheckedAt, 159 + appViewHealthRefreshing, 160 + appViewLastFallback, 161 + appViewLastError, 112 162 ]; 113 163 }
+111
lib/features/settings/presentation/settings_screen.dart
··· 6 6 import 'package:flutter_bloc/flutter_bloc.dart'; 7 7 import 'package:go_router/go_router.dart'; 8 8 import 'package:lazurite/core/network/atproto_host_resolver.dart'; 9 + import 'package:lazurite/core/network/app_view_provider.dart'; 9 10 import 'package:lazurite/core/router/app_shell.dart'; 10 11 import 'package:lazurite/core/theme/app_theme.dart'; 11 12 import 'package:lazurite/core/theme/feed_layout.dart'; ··· 435 436 _ConstellationUrlTile(currentUrl: state.constellationUrl), 436 437 const Divider(height: 1), 437 438 _SettingsTile( 439 + icon: Icons.route_outlined, 440 + title: 'AppView Provider', 441 + subtitle: _appViewSubtitle(state.appViewProvider), 442 + ), 443 + Padding( 444 + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), 445 + child: Align( 446 + alignment: Alignment.centerLeft, 447 + child: SegmentedButton<String>( 448 + key: const Key('appview-provider-segmented'), 449 + segments: const [ 450 + ButtonSegment<String>(value: AppViewProviders.blueskyKey, label: Text('Bluesky')), 451 + ButtonSegment<String>(value: AppViewProviders.blackskyKey, label: Text('Blacksky')), 452 + ], 453 + selected: {state.appViewProvider}, 454 + onSelectionChanged: (selection) async { 455 + final selectedProvider = selection.first; 456 + if (selectedProvider == state.appViewProvider) { 457 + return; 458 + } 459 + await _confirmAndApplyProviderChange(context, selectedProvider); 460 + }, 461 + ), 462 + ), 463 + ), 464 + const Divider(height: 1), 465 + _SettingsTile( 438 466 icon: Icons.compare_arrows_outlined, 439 467 title: 'Cross-Provider Fallback', 440 468 subtitle: 'Retry public reads on the alternate AppView when transient errors occur', ··· 453 481 onChanged: settingsCubit.setSlingshotIdentityFallbackEnabled, 454 482 ), 455 483 ), 484 + const Divider(height: 1), 485 + const _SettingsTile( 486 + icon: Icons.monitor_heart_outlined, 487 + title: 'Provider Diagnostics', 488 + subtitle: 'Moderation/ranking can differ by provider. Verify health and recent fallback state.', 489 + ), 490 + _ConnectionDetailRow(label: 'Active Provider', value: _providerDisplayName(state.appViewProvider)), 491 + const Divider(height: 1), 492 + _ConnectionDetailRow(label: 'Health', value: state.appViewHealthSummary ?? 'Not checked yet'), 493 + const Divider(height: 1), 494 + _ConnectionDetailRow( 495 + label: 'Last Health Check', 496 + value: state.appViewHealthCheckedAt == null 497 + ? 'Never' 498 + : _formatTimestamp(state.appViewHealthCheckedAt!.toLocal()), 499 + ), 500 + const Divider(height: 1), 501 + _ConnectionDetailRow(label: 'Last Fallback', value: state.appViewLastFallback ?? 'None'), 502 + const Divider(height: 1), 503 + _ConnectionDetailRow(label: 'Last Error', value: state.appViewLastError ?? 'None'), 504 + const Divider(height: 1), 505 + _SettingsTile( 506 + icon: Icons.refresh_outlined, 507 + title: 'Refresh Provider Health', 508 + subtitle: 'Probe public AppView endpoints now', 509 + trailing: state.appViewHealthRefreshing 510 + ? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2)) 511 + : null, 512 + onTap: state.appViewHealthRefreshing 513 + ? null 514 + : () { 515 + unawaited(settingsCubit.refreshAppViewHealth()); 516 + }, 517 + ), 456 518 ], 457 519 ), 458 520 ); 459 521 }, 460 522 ); 523 + } 524 + 525 + String _providerDisplayName(String providerKey) { 526 + if (providerKey == AppViewProviders.blackskyKey) { 527 + return 'Blacksky'; 528 + } 529 + return 'Bluesky'; 530 + } 531 + 532 + String _appViewSubtitle(String providerKey) { 533 + final provider = _providerDisplayName(providerKey); 534 + return '$provider selected. Switching providers performs a soft restart.'; 535 + } 536 + 537 + String _formatTimestamp(DateTime time) { 538 + final month = time.month.toString().padLeft(2, '0'); 539 + final day = time.day.toString().padLeft(2, '0'); 540 + final hour = time.hour.toString().padLeft(2, '0'); 541 + final minute = time.minute.toString().padLeft(2, '0'); 542 + return '${time.year}-$month-$day $hour:$minute'; 543 + } 544 + 545 + Future<void> _confirmAndApplyProviderChange(BuildContext context, String selectedProvider) async { 546 + final shouldApply = await showDialog<bool>( 547 + context: context, 548 + builder: (dialogContext) { 549 + return AlertDialog( 550 + title: const Text('Switch AppView provider?'), 551 + content: const Text( 552 + 'Apply and restart now to rebuild network services.\n\n' 553 + 'You will stay signed in and no local data will be deleted.\n\n' 554 + 'Moderation labels, ranking, and trending results can differ between providers.', 555 + ), 556 + actions: [ 557 + TextButton(onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel')), 558 + FilledButton( 559 + onPressed: () => Navigator.of(dialogContext).pop(true), 560 + child: const Text('Apply and Restart'), 561 + ), 562 + ], 563 + ); 564 + }, 565 + ); 566 + 567 + if (shouldApply != true || !context.mounted) { 568 + return; 569 + } 570 + 571 + await context.read<SettingsCubit>().setAppViewProvider(selectedProvider); 461 572 } 462 573 } 463 574
+89 -3
lib/main.dart
··· 163 163 late String _routerSessionKey; 164 164 late final StreamSubscription<String> _authSubscription; 165 165 late final StreamSubscription<bool> _simulateOfflineSubscription; 166 + late final StreamSubscription<String> _appViewProviderSubscription; 167 + late final StreamSubscription<AppViewRoutingEvent> _appViewEventSubscription; 168 + late String _observedAppViewProvider; 169 + var _routerGeneration = 0; 170 + var _isSoftRestarting = false; 166 171 167 172 @override 168 173 void initState() { 169 174 super.initState(); 170 175 _routerSessionKey = _sessionKeyFor(widget.authBloc.state); 176 + _observedAppViewProvider = widget.settingsCubit.state.appViewProvider; 171 177 _router = _createRouter(); 172 178 _authSubscription = widget.authBloc.stream.map(_sessionKeyFor).distinct().listen(_handleSessionKeyChanged); 173 179 _simulateOfflineSubscription = widget.settingsCubit.stream 174 180 .map((state) => state.simulateOffline) 175 181 .distinct() 176 182 .listen(widget.connectivityCubit.setSimulatedOffline); 183 + _appViewProviderSubscription = widget.settingsCubit.stream.map((state) => state.appViewProvider).listen((provider) { 184 + if (provider == _observedAppViewProvider) { 185 + return; 186 + } 187 + 188 + _observedAppViewProvider = provider; 189 + 190 + if (!widget.authBloc.state.isAuthenticated) { 191 + return; 192 + } 193 + 194 + unawaited(_softRestartForProviderChange()); 195 + }); 196 + _appViewEventSubscription = widget.appViewFallbackService.events.listen( 197 + widget.settingsCubit.recordAppViewRoutingEvent, 198 + ); 199 + 200 + unawaited(widget.settingsCubit.refreshAppViewHealth()); 177 201 } 178 202 179 203 @override 180 204 void dispose() { 181 205 _authSubscription.cancel(); 182 206 _simulateOfflineSubscription.cancel(); 207 + _appViewProviderSubscription.cancel(); 208 + _appViewEventSubscription.cancel(); 209 + 183 210 widget.connectivityCubit.close(); 211 + widget.appViewFallbackService.dispose(); 212 + 184 213 _router.dispose(); 214 + 185 215 super.dispose(); 186 216 } 187 217 ··· 204 234 previousRouter.dispose(); 205 235 } 206 236 237 + Future<void> _softRestartForProviderChange() async { 238 + if (!mounted || _isSoftRestarting) { 239 + return; 240 + } 241 + 242 + setState(() { 243 + _isSoftRestarting = true; 244 + }); 245 + 246 + final previousRouter = _router; 247 + 248 + widget.settingsCubit.bumpRoutingEpoch(); 249 + 250 + setState(() { 251 + _routerGeneration += 1; 252 + _router = _createRouter(); 253 + }); 254 + 255 + previousRouter.dispose(); 256 + await Future<void>.delayed(const Duration(milliseconds: 200)); 257 + 258 + if (mounted) { 259 + setState(() { 260 + _isSoftRestarting = false; 261 + }); 262 + unawaited(widget.settingsCubit.refreshAppViewHealth()); 263 + } 264 + } 265 + 207 266 Bluesky? _createBluesky(AuthState state) => state.isAuthenticated ? createBlueskyClient(state.tokens) : null; 208 267 209 268 BlueskyChat? _createBlueskyChat(AuthState state) => ··· 232 291 final darkTheme = AppTheme.getTheme(settingsState.themePalette, AppThemeVariant.dark); 233 292 234 293 return MaterialApp.router( 235 - key: ValueKey('router-$_routerSessionKey'), 294 + key: ValueKey('router-$_routerSessionKey-$_routerGeneration'), 236 295 title: 'Lazurite', 237 296 debugShowCheckedModeBanner: false, 238 297 theme: lightTheme, 239 298 darkTheme: darkTheme, 240 299 themeMode: themeMode, 241 300 routerConfig: _router, 242 - builder: (context, child) => ConnectivityBannerHost(child: child ?? const SizedBox.shrink()), 301 + builder: (context, child) => Stack( 302 + children: [ 303 + ConnectivityBannerHost(child: child ?? const SizedBox.shrink()), 304 + if (_isSoftRestarting) 305 + const ColoredBox( 306 + color: Color(0xC0000000), 307 + child: Center( 308 + child: Card( 309 + child: Padding( 310 + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 16), 311 + child: Row( 312 + mainAxisSize: MainAxisSize.min, 313 + children: [ 314 + SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2.5)), 315 + SizedBox(width: 12), 316 + Text('Applying provider change...'), 317 + ], 318 + ), 319 + ), 320 + ), 321 + ), 322 + ), 323 + ], 324 + ), 243 325 ); 244 326 }, 245 327 ); ··· 251 333 final accountDid = authState.tokens?.did ?? ''; 252 334 253 335 return KeyedSubtree( 254 - key: ValueKey('account-$accountDid'), 336 + key: ValueKey('account-$accountDid-routing-${context.read<SettingsCubit>().state.routingEpoch}'), 255 337 child: MultiRepositoryProvider( 256 338 providers: [ 257 339 RepositoryProvider( ··· 279 361 crossProviderFallbackEnabledResolver: () => 280 362 context.read<SettingsCubit>().state.crossProviderFallbackEnabled, 281 363 appViewFallbackService: widget.appViewFallbackService, 364 + routingEpoch: context.read<SettingsCubit>().state.routingEpoch, 365 + routingEpochResolver: () => context.read<SettingsCubit>().state.routingEpoch, 282 366 ), 283 367 ), 284 368 RepositoryProvider( ··· 290 374 appViewProviderResolver: () => settingsCubit.state.appViewProvider, 291 375 crossProviderFallbackEnabledResolver: () => settingsCubit.state.crossProviderFallbackEnabled, 292 376 appViewFallbackService: widget.appViewFallbackService, 377 + routingEpoch: settingsCubit.state.routingEpoch, 378 + routingEpochResolver: () => settingsCubit.state.routingEpoch, 293 379 ); 294 380 }, 295 381 ),
+15
test/core/network/app_view_router_test.dart
··· 60 60 expect(resolved.inAppRoute, isNull); 61 61 expect(resolved.externalUri.toString(), equals('https://example.com/profile/alice/feed/xyz')); 62 62 }); 63 + 64 + test('health summary remains healthy when only non-critical checks fail', () { 65 + final health = AppViewHealth( 66 + providerKey: AppViewProviders.blueskyKey, 67 + checkedAt: DateTime.utc(2026, 5, 1), 68 + checks: const [ 69 + AppViewCapabilityResult(endpointId: 'critical.a', statusCode: 200, supported: true, critical: true), 70 + AppViewCapabilityResult(endpointId: 'critical.b', statusCode: 200, supported: true, critical: true), 71 + AppViewCapabilityResult(endpointId: 'noncritical', statusCode: 500, supported: false, critical: false), 72 + ], 73 + ); 74 + 75 + expect(health.isHealthy, isTrue); 76 + expect(health.summary(), equals('Healthy (2/2 critical, 2/3 total)')); 77 + }); 63 78 }); 64 79 }
+77 -121
test/core/theme/purple_theme_test.dart
··· 4 4 5 5 void main() { 6 6 group('PurpleTheme', () { 7 - group('Dark color values', () { 8 - test('sop0 is #1E1E3F', () { 9 - expect(PurpleTheme.sop0, const Color(0xFF1E1E3F)); 10 - }); 11 - 12 - test('sop1 is #28284E', () { 13 - expect(PurpleTheme.sop1, const Color(0xFF28284E)); 14 - }); 15 - 16 - test('sop2 is #2D2B55', () { 17 - expect(PurpleTheme.sop2, const Color(0xFF2D2B55)); 18 - }); 19 - 20 - test('sop3 is #A599E9', () { 21 - expect(PurpleTheme.sop3, const Color(0xFFA599E9)); 22 - }); 23 - 24 - test('sop4 is #E1EFFF', () { 25 - expect(PurpleTheme.sop4, const Color(0xFFE1EFFF)); 26 - }); 27 - 28 - test('sop5 is #9EFFFF', () { 29 - expect(PurpleTheme.sop5, const Color(0xFF9EFFFF)); 30 - }); 31 - 32 - test('sop6 is #FAD000', () { 33 - expect(PurpleTheme.sop6, const Color(0xFFFAD000)); 34 - }); 35 - 36 - test('sop7 is #FF9D00', () { 37 - expect(PurpleTheme.sop7, const Color(0xFFFF9D00)); 38 - }); 39 - 40 - test('sop8 is #B362FF', () { 41 - expect(PurpleTheme.sop8, const Color(0xFFB362FF)); 42 - }); 43 - 44 - test('sop9 is #FF628C', () { 45 - expect(PurpleTheme.sop9, const Color(0xFFFF628C)); 46 - }); 47 - 48 - test('sop10 is #A5FF90', () { 49 - expect(PurpleTheme.sop10, const Color(0xFFA5FF90)); 50 - }); 51 - 52 - test('sop11 is #EC3A37', () { 53 - expect(PurpleTheme.sop11, const Color(0xFFEC3A37)); 54 - }); 55 - 56 - test('sop12 is #80FFBB', () { 57 - expect(PurpleTheme.sop12, const Color(0xFF80FFBB)); 58 - }); 59 - 60 - test('sop13 is #FB94FF', () { 61 - expect(PurpleTheme.sop13, const Color(0xFFFB94FF)); 62 - }); 63 - 64 - test('sop14 is #6943FF', () { 65 - expect(PurpleTheme.sop14, const Color(0xFF6943FF)); 66 - }); 67 - 68 - test('darkOutlineVariant is lavender at ~15% opacity', () { 69 - expect(PurpleTheme.darkOutlineVariant, const Color(0x26A599E9)); 70 - }); 7 + group('Raw palette values', () { 8 + test('sop0 is #1E1E3F', () => expect(PurpleTheme.sop0, const Color(0xFF1E1E3F))); 9 + test('sop1 is #28284E', () => expect(PurpleTheme.sop1, const Color(0xFF28284E))); 10 + test('sop2 is #2D2B55', () => expect(PurpleTheme.sop2, const Color(0xFF2D2B55))); 11 + test('sop3 is #A599E9', () => expect(PurpleTheme.sop3, const Color(0xFFA599E9))); 12 + test('sop4 is #E1EFFF', () => expect(PurpleTheme.sop4, const Color(0xFFE1EFFF))); 13 + test('sop5 is #9EFFFF', () => expect(PurpleTheme.sop5, const Color(0xFF9EFFFF))); 14 + test('sop6 is #FAD000', () => expect(PurpleTheme.sop6, const Color(0xFFFAD000))); 15 + test('sop7 is #FF9D00', () => expect(PurpleTheme.sop7, const Color(0xFFFF9D00))); 16 + test('sop8 is #B362FF', () => expect(PurpleTheme.sop8, const Color(0xFFB362FF))); 17 + test('sop9 is #FF628C', () => expect(PurpleTheme.sop9, const Color(0xFFFF628C))); 18 + test('sop10 is #A5FF90', () => expect(PurpleTheme.sop10, const Color(0xFFA5FF90))); 19 + test('sop11 is #EC3A37', () => expect(PurpleTheme.sop11, const Color(0xFFEC3A37))); 20 + test('sop12 is #80FFBB', () => expect(PurpleTheme.sop12, const Color(0xFF80FFBB))); 21 + test('sop13 is #FB94FF', () => expect(PurpleTheme.sop13, const Color(0xFFFB94FF))); 22 + test('sop14 is #6943FF', () => expect(PurpleTheme.sop14, const Color(0xFF6943FF))); 23 + test('sopL0 is #F8F6FF', () => expect(PurpleTheme.sopL0, const Color(0xFFF8F6FF))); 24 + test('sopL1 is #EDE9FF', () => expect(PurpleTheme.sopL1, const Color(0xFFEDE9FF))); 25 + test('sopL2 is #D6CEFF', () => expect(PurpleTheme.sopL2, const Color(0xFFD6CEFF))); 26 + test('sopL3 is #8B7FD4', () => expect(PurpleTheme.sopL3, const Color(0xFF8B7FD4))); 27 + test('sopL4 is #2D2B55', () => expect(PurpleTheme.sopL4, const Color(0xFF2D2B55))); 28 + test('sopL5 is #6943FF', () => expect(PurpleTheme.sopL5, const Color(0xFF6943FF))); 29 + test('sopL6 is #7B6EC0', () => expect(PurpleTheme.sopL6, const Color(0xFF7B6EC0))); 71 30 }); 72 31 73 - group('Light color values', () { 74 - test('sopL0 is #F8F6FF', () { 75 - expect(PurpleTheme.sopL0, const Color(0xFFF8F6FF)); 76 - }); 77 - 78 - test('sopL1 is #EDE9FF', () { 79 - expect(PurpleTheme.sopL1, const Color(0xFFEDE9FF)); 80 - }); 81 - 82 - test('sopL2 is #D6CEFF', () { 83 - expect(PurpleTheme.sopL2, const Color(0xFFD6CEFF)); 84 - }); 85 - 86 - test('sopL3 is #8B7FD4', () { 87 - expect(PurpleTheme.sopL3, const Color(0xFF8B7FD4)); 88 - }); 89 - 90 - test('sopL4 is #2D2B55', () { 91 - expect(PurpleTheme.sopL4, const Color(0xFF2D2B55)); 92 - }); 93 - 94 - test('sopL5 is #6943FF', () { 95 - expect(PurpleTheme.sopL5, const Color(0xFF6943FF)); 96 - }); 32 + group('Dark semantic token values', () { 33 + test('darkSurface is sop0', () => expect(PurpleTheme.darkSurface, PurpleTheme.sop0)); 34 + test('darkSurfaceContainer is sop1', () => expect(PurpleTheme.darkSurfaceContainer, PurpleTheme.sop1)); 35 + test('darkSurfaceContainerHigh is sop2', () => expect(PurpleTheme.darkSurfaceContainerHigh, PurpleTheme.sop2)); 36 + test('darkOnSurface is sop4', () => expect(PurpleTheme.darkOnSurface, PurpleTheme.sop4)); 37 + test('darkOnSurfaceVariant is sop3', () => expect(PurpleTheme.darkOnSurfaceVariant, PurpleTheme.sop3)); 38 + test('darkPrimary is sop8', () => expect(PurpleTheme.darkPrimary, PurpleTheme.sop8)); 39 + test('darkOnPrimary is sop0', () => expect(PurpleTheme.darkOnPrimary, PurpleTheme.sop0)); 40 + test('darkOutline is lavender ~30%', () => expect(PurpleTheme.darkOutline, const Color(0x4DA599E9))); 41 + test( 42 + 'darkOutlineVariant is lavender ~15%', 43 + () => expect(PurpleTheme.darkOutlineVariant, const Color(0x26A599E9)), 44 + ); 45 + }); 97 46 98 - test('sopL6 is #7B6EC0', () { 99 - expect(PurpleTheme.sopL6, const Color(0xFF7B6EC0)); 100 - }); 47 + group('Light semantic token values', () { 48 + test('lightSurface is sopL0', () => expect(PurpleTheme.lightSurface, PurpleTheme.sopL0)); 49 + test('lightSurfaceContainer is sopL1', () => expect(PurpleTheme.lightSurfaceContainer, PurpleTheme.sopL1)); 50 + test('lightOnSurface is sopL4', () => expect(PurpleTheme.lightOnSurface, PurpleTheme.sopL4)); 51 + test('lightOnSurfaceVariant is sopL3', () => expect(PurpleTheme.lightOnSurfaceVariant, PurpleTheme.sopL3)); 52 + test('lightPrimary is sopL5', () => expect(PurpleTheme.lightPrimary, PurpleTheme.sopL5)); 53 + test('lightOnPrimary is sopL0', () => expect(PurpleTheme.lightOnPrimary, PurpleTheme.sopL0)); 54 + test('lightOutline is blue-purple ~30%', () => expect(PurpleTheme.lightOutline, const Color(0x4D6943FF))); 55 + test('lightOutlineVariant is sopL2', () => expect(PurpleTheme.lightOutlineVariant, PurpleTheme.sopL2)); 101 56 }); 102 57 103 58 group('ThemeData', () { ··· 107 62 108 63 expect(theme.useMaterial3, isTrue); 109 64 expect(theme.brightness, Brightness.dark); 110 - expect(scheme.primary, PurpleTheme.sop3); 65 + expect(scheme.primary, PurpleTheme.darkPrimary); 111 66 expect(scheme.secondary, PurpleTheme.sop5); 112 - expect(scheme.tertiary, PurpleTheme.sop8); 113 - expect(scheme.surface, PurpleTheme.sop1); 114 - expect(scheme.onSurface, PurpleTheme.sop4); 115 - expect(scheme.surfaceContainerHighest, PurpleTheme.sop2); 116 - expect(scheme.outline, PurpleTheme.sop3); 67 + expect(scheme.tertiary, PurpleTheme.sop3); 68 + expect(scheme.surface, PurpleTheme.darkSurfaceContainer); 69 + expect(scheme.onSurface, PurpleTheme.darkOnSurface); 70 + expect(scheme.onSurfaceVariant, PurpleTheme.darkOnSurfaceVariant); 71 + expect(scheme.surfaceContainerHighest, PurpleTheme.darkSurfaceContainerHigh); 72 + expect(scheme.outline, PurpleTheme.darkOutline); 117 73 expect(scheme.outlineVariant, PurpleTheme.darkOutlineVariant); 118 74 expect(scheme.error, PurpleTheme.sop11); 119 - expect(theme.scaffoldBackgroundColor, PurpleTheme.sop0); 120 - expect(theme.appBarTheme.backgroundColor, PurpleTheme.sop0); 121 - expect(theme.cardTheme.color, PurpleTheme.sop1); 75 + expect(theme.scaffoldBackgroundColor, PurpleTheme.darkSurface); 76 + expect(theme.appBarTheme.backgroundColor, PurpleTheme.darkSurface); 77 + expect(theme.cardTheme.color, PurpleTheme.darkSurfaceContainer); 122 78 expect(theme.dividerTheme.color, PurpleTheme.darkOutlineVariant); 123 - expect(theme.iconTheme.color, PurpleTheme.sop3); 124 - expect(theme.listTileTheme.textColor, PurpleTheme.sop4); 125 - expect(theme.floatingActionButtonTheme.backgroundColor, PurpleTheme.sop3); 79 + expect(theme.iconTheme.color, PurpleTheme.darkOnSurfaceVariant); 80 + expect(theme.listTileTheme.textColor, PurpleTheme.darkOnSurface); 81 + expect(theme.floatingActionButtonTheme.backgroundColor, PurpleTheme.darkPrimary); 126 82 expect(theme.inputDecorationTheme.filled, isTrue); 127 - expect(theme.inputDecorationTheme.fillColor, PurpleTheme.sop1); 128 - expect(theme.snackBarTheme.backgroundColor, PurpleTheme.sop1); 83 + expect(theme.inputDecorationTheme.fillColor, PurpleTheme.darkSurfaceContainer); 84 + expect(theme.snackBarTheme.backgroundColor, PurpleTheme.darkSurfaceContainerHigh); 129 85 }); 130 86 131 87 test('light theme maps expected tokens', () { ··· 134 90 135 91 expect(theme.useMaterial3, isTrue); 136 92 expect(theme.brightness, Brightness.light); 137 - expect(scheme.primary, PurpleTheme.sopL5); 93 + expect(scheme.primary, PurpleTheme.lightPrimary); 138 94 expect(scheme.secondary, PurpleTheme.sopL6); 139 - expect(scheme.tertiary, PurpleTheme.sop8); 140 - expect(scheme.surface, PurpleTheme.sopL1); 141 - expect(scheme.onSurface, PurpleTheme.sopL4); 142 - expect(scheme.surfaceContainerHighest, PurpleTheme.sopL2); 143 - expect(scheme.outline, PurpleTheme.sopL3); 144 - expect(scheme.outlineVariant, PurpleTheme.sopL2); 95 + expect(scheme.tertiary, PurpleTheme.sop3); 96 + expect(scheme.surface, PurpleTheme.lightSurfaceContainer); 97 + expect(scheme.onSurface, PurpleTheme.lightOnSurface); 98 + expect(scheme.onSurfaceVariant, PurpleTheme.lightOnSurfaceVariant); 99 + expect(scheme.outline, PurpleTheme.lightOutline); 100 + expect(scheme.outlineVariant, PurpleTheme.lightOutlineVariant); 145 101 expect(scheme.error, PurpleTheme.sop11); 146 - expect(theme.scaffoldBackgroundColor, PurpleTheme.sopL0); 147 - expect(theme.appBarTheme.backgroundColor, PurpleTheme.sopL0); 148 - expect(theme.cardTheme.color, PurpleTheme.sopL1); 149 - expect(theme.dividerTheme.color, PurpleTheme.sopL2); 150 - expect(theme.iconTheme.color, PurpleTheme.sopL3); 151 - expect(theme.listTileTheme.textColor, PurpleTheme.sopL4); 152 - expect(theme.floatingActionButtonTheme.backgroundColor, PurpleTheme.sopL5); 102 + expect(theme.scaffoldBackgroundColor, PurpleTheme.lightSurface); 103 + expect(theme.appBarTheme.backgroundColor, PurpleTheme.lightSurface); 104 + expect(theme.cardTheme.color, PurpleTheme.lightSurfaceContainer); 105 + expect(theme.dividerTheme.color, PurpleTheme.lightOutlineVariant); 106 + expect(theme.iconTheme.color, PurpleTheme.lightOnSurfaceVariant); 107 + expect(theme.listTileTheme.textColor, PurpleTheme.lightOnSurface); 108 + expect(theme.floatingActionButtonTheme.backgroundColor, PurpleTheme.lightPrimary); 153 109 expect(theme.inputDecorationTheme.filled, isTrue); 154 - expect(theme.inputDecorationTheme.fillColor, PurpleTheme.sopL1); 155 - expect(theme.snackBarTheme.backgroundColor, PurpleTheme.sopL1); 110 + expect(theme.inputDecorationTheme.fillColor, PurpleTheme.lightSurfaceContainer); 111 + expect(theme.snackBarTheme.backgroundColor, PurpleTheme.lightSurfaceContainer); 156 112 }); 157 113 }); 158 114 });
+18
test/features/feed/cubit/feed_preferences_cubit_test.dart
··· 1 + import 'dart:async'; 2 + 1 3 import 'package:atproto_core/atproto_core.dart'; 2 4 import 'package:bloc_test/bloc_test.dart'; 3 5 import 'package:bluesky/app_bsky_actor_defs.dart'; ··· 52 54 ); 53 55 expect(cubit.state.status, FeedPreferencesStatus.initial); 54 56 expect(cubit.state.feeds, isEmpty); 57 + }); 58 + 59 + test('loadPreferences does not throw if cubit closes before async completion', () async { 60 + final completer = Completer<PreferencesResult>(); 61 + when(() => mockFeedRepository.getPreferences()).thenAnswer((_) => completer.future); 62 + final cubit = FeedPreferencesCubit( 63 + feedRepository: mockFeedRepository, 64 + database: database, 65 + accountDid: 'did:plc:test', 66 + ); 67 + 68 + final future = cubit.loadPreferences(); 69 + await cubit.close(); 70 + completer.complete(PreferencesResult(preferences: [])); 71 + 72 + await expectLater(future, completes); 55 73 }); 56 74 57 75 blocTest<FeedPreferencesCubit, FeedPreferencesState>(
+24
test/features/feed/data/feed_repository_fallback_test.dart
··· 166 166 expect(third, equals('ok:bluesky')); 167 167 expect(attempts, equals(['bluesky', 'blacksky', 'blacksky', 'bluesky'])); 168 168 }); 169 + 170 + test('drops stale response when routing epoch changes mid-request', () async { 171 + var currentEpoch = 1; 172 + final repo = FeedRepository( 173 + bluesky: bluesky, 174 + database: database, 175 + accountDid: 'did:plc:test', 176 + appViewProvider: 'bluesky', 177 + crossProviderFallbackEnabled: false, 178 + routingEpoch: 1, 179 + routingEpochResolver: () => currentEpoch, 180 + ); 181 + 182 + await expectLater( 183 + () => repo.runPublicReadWithFallbackForTest<String>( 184 + endpointId: 'app.bsky.unspecced.getTrends', 185 + request: (_) async { 186 + currentEpoch = 2; 187 + return 'stale'; 188 + }, 189 + ), 190 + throwsA(isA<StaleRoutingEpochException>()), 191 + ); 192 + }); 169 193 }
+77
test/features/settings/bloc/settings_cubit_test.dart
··· 2 2 import 'package:drift/native.dart'; 3 3 import 'package:flutter_test/flutter_test.dart'; 4 4 import 'package:lazurite/core/database/app_database.dart'; 5 + import 'package:lazurite/core/network/app_view_fallback_service.dart'; 6 + import 'package:lazurite/core/network/app_view_router.dart'; 5 7 import 'package:lazurite/core/theme/app_theme.dart'; 6 8 import 'package:lazurite/core/theme/feed_layout.dart'; 7 9 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; ··· 31 33 expect(cubit.state.appViewProvider, 'bluesky'); 32 34 expect(cubit.state.crossProviderFallbackEnabled, isFalse); 33 35 expect(cubit.state.slingshotIdentityFallbackEnabled, isFalse); 36 + expect(cubit.state.routingEpoch, 0); 34 37 }); 35 38 36 39 test('accepts initial values via constructor', () { ··· 323 326 ); 324 327 325 328 blocTest<SettingsCubit, SettingsState>( 329 + 'setAppViewProvider no-ops when provider is unchanged', 330 + build: () => SettingsCubit(database: database), 331 + act: (cubit) => cubit.setAppViewProvider('bluesky'), 332 + expect: () => <SettingsState>[], 333 + ); 334 + 335 + blocTest<SettingsCubit, SettingsState>( 326 336 'loadSettings falls back to default app view provider for invalid persisted value', 327 337 build: () => SettingsCubit(database: database), 328 338 setUp: () async { ··· 372 382 isA<SettingsState>() 373 383 .having((s) => s.crossProviderFallbackEnabled, 'crossProviderFallbackEnabled', true) 374 384 .having((s) => s.slingshotIdentityFallbackEnabled, 'slingshotIdentityFallbackEnabled', true), 385 + ], 386 + ); 387 + 388 + blocTest<SettingsCubit, SettingsState>( 389 + 'bumpRoutingEpoch increments the in-memory routing epoch', 390 + build: () => SettingsCubit(database: database), 391 + act: (cubit) => cubit.bumpRoutingEpoch(), 392 + expect: () => [isA<SettingsState>().having((s) => s.routingEpoch, 'routingEpoch', 1)], 393 + ); 394 + 395 + blocTest<SettingsCubit, SettingsState>( 396 + 'recordAppViewRoutingEvent stores last fallback and last error diagnostics', 397 + build: () => SettingsCubit(database: database), 398 + act: (cubit) { 399 + cubit.recordAppViewRoutingEvent( 400 + AppViewFallbackUsedEvent( 401 + endpointId: 'app.bsky.unspecced.getTrends', 402 + fromProvider: 'bluesky', 403 + toProvider: 'blacksky', 404 + occurredAt: DateTime.utc(2026, 4, 30, 15, 0), 405 + ), 406 + ); 407 + cubit.recordAppViewRoutingEvent( 408 + AppViewProviderErrorEvent( 409 + endpointId: 'app.bsky.unspecced.getTrendingTopics', 410 + provider: 'blacksky', 411 + reason: '5xx', 412 + transient: true, 413 + occurredAt: DateTime.utc(2026, 4, 30, 15, 1), 414 + ), 415 + ); 416 + }, 417 + expect: () => [ 418 + isA<SettingsState>().having( 419 + (s) => s.appViewLastFallback, 420 + 'appViewLastFallback', 421 + 'endpoint=app.bsky.unspecced.getTrends bluesky->blacksky', 422 + ), 423 + isA<SettingsState>().having( 424 + (s) => s.appViewLastError, 425 + 'appViewLastError', 426 + 'endpoint=app.bsky.unspecced.getTrendingTopics provider=blacksky reason=5xx', 427 + ), 428 + ], 429 + ); 430 + 431 + blocTest<SettingsCubit, SettingsState>( 432 + 'refreshAppViewHealth updates summary and timestamp when probe succeeds', 433 + build: () => SettingsCubit( 434 + database: database, 435 + appViewHealthProber: (_) async => AppViewHealth( 436 + providerKey: 'bluesky', 437 + checkedAt: DateTime.utc(2026, 4, 30, 16, 0), 438 + checks: const [ 439 + AppViewCapabilityResult(endpointId: 'a', statusCode: 200, supported: true, critical: true), 440 + AppViewCapabilityResult(endpointId: 'b', statusCode: 200, supported: true, critical: true), 441 + AppViewCapabilityResult(endpointId: 'c', statusCode: 200, supported: true, critical: false), 442 + ], 443 + ), 444 + ), 445 + act: (cubit) => cubit.refreshAppViewHealth(), 446 + expect: () => [ 447 + isA<SettingsState>().having((s) => s.appViewHealthRefreshing, 'appViewHealthRefreshing', true), 448 + isA<SettingsState>() 449 + .having((s) => s.appViewHealthRefreshing, 'appViewHealthRefreshing', false) 450 + .having((s) => s.appViewHealthSummary, 'appViewHealthSummary', 'Healthy (2/2 critical, 3/3 total)') 451 + .having((s) => s.appViewHealthCheckedAt, 'appViewHealthCheckedAt', DateTime.utc(2026, 4, 30, 16, 0)), 375 452 ], 376 453 ); 377 454 });
+79
test/features/settings/presentation/settings_screen_test.dart
··· 6 6 import 'package:flutter_test/flutter_test.dart'; 7 7 import 'package:go_router/go_router.dart'; 8 8 import 'package:lazurite/core/database/app_database.dart'; 9 + import 'package:lazurite/core/network/app_view_provider.dart'; 9 10 import 'package:lazurite/core/theme/app_theme.dart'; 10 11 import 'package:lazurite/core/theme/feed_layout.dart'; 11 12 import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; ··· 59 60 feedLayout: FeedLayout.card, 60 61 ), 61 62 ); 63 + when(() => settingsCubit.setAppViewProvider(any())).thenAnswer((_) async {}); 64 + when(() => settingsCubit.refreshAppViewHealth()).thenAnswer((_) async {}); 62 65 }); 63 66 64 67 Widget buildSubject() { ··· 255 258 expect(find.text('ADVANCED'), findsOneWidget); 256 259 expect(find.text('Constellation URL'), findsOneWidget); 257 260 expect(find.text('https://constellation.microcosm.blue'), findsOneWidget); 261 + expect(find.text('AppView Provider'), findsOneWidget); 258 262 expect(find.text('Cross-Provider Fallback'), findsOneWidget); 259 263 expect(find.text('Slingshot Identity Fallback'), findsOneWidget); 264 + expect(find.text('Provider Diagnostics'), findsOneWidget); 265 + expect(find.text('Refresh Provider Health'), findsOneWidget); 260 266 expect(find.byIcon(Icons.edit_outlined), findsNothing); 267 + }); 268 + 269 + testWidgets('provider change confirmation can be cancelled', (tester) async { 270 + when(() => settingsCubit.state).thenReturn( 271 + const SettingsState( 272 + themePalette: AppThemePalette.oxocarbon, 273 + themeVariant: AppThemeVariant.dark, 274 + useSystemTheme: false, 275 + feedLayout: FeedLayout.card, 276 + appViewProvider: AppViewProviders.blueskyKey, 277 + ), 278 + ); 279 + whenListen( 280 + settingsCubit, 281 + const Stream<SettingsState>.empty(), 282 + initialState: const SettingsState( 283 + themePalette: AppThemePalette.oxocarbon, 284 + themeVariant: AppThemeVariant.dark, 285 + useSystemTheme: false, 286 + feedLayout: FeedLayout.card, 287 + appViewProvider: AppViewProviders.blueskyKey, 288 + ), 289 + ); 290 + 291 + await tester.pumpWidget(buildSubject()); 292 + await tester.pumpAndSettle(); 293 + final segmented = find.byKey(const Key('appview-provider-segmented')); 294 + await tester.scrollUntilVisible(segmented, 300); 295 + final segmentedWidget = tester.widget<SegmentedButton<String>>(segmented); 296 + segmentedWidget.onSelectionChanged?.call(const {AppViewProviders.blackskyKey}); 297 + await tester.pumpAndSettle(); 298 + 299 + expect(find.text('Switch AppView provider?'), findsOneWidget); 300 + expect(find.text('Apply and Restart'), findsOneWidget); 301 + await tester.tap(find.text('Cancel')); 302 + await tester.pumpAndSettle(); 303 + 304 + verifyNever(() => settingsCubit.setAppViewProvider(any())); 305 + }); 306 + 307 + testWidgets('provider change confirmation applies selection when confirmed', (tester) async { 308 + when(() => settingsCubit.state).thenReturn( 309 + const SettingsState( 310 + themePalette: AppThemePalette.oxocarbon, 311 + themeVariant: AppThemeVariant.dark, 312 + useSystemTheme: false, 313 + feedLayout: FeedLayout.card, 314 + appViewProvider: AppViewProviders.blueskyKey, 315 + ), 316 + ); 317 + whenListen( 318 + settingsCubit, 319 + const Stream<SettingsState>.empty(), 320 + initialState: const SettingsState( 321 + themePalette: AppThemePalette.oxocarbon, 322 + themeVariant: AppThemeVariant.dark, 323 + useSystemTheme: false, 324 + feedLayout: FeedLayout.card, 325 + appViewProvider: AppViewProviders.blueskyKey, 326 + ), 327 + ); 328 + 329 + await tester.pumpWidget(buildSubject()); 330 + await tester.pumpAndSettle(); 331 + final segmented = find.byKey(const Key('appview-provider-segmented')); 332 + await tester.scrollUntilVisible(segmented, 300); 333 + final segmentedWidget = tester.widget<SegmentedButton<String>>(segmented); 334 + segmentedWidget.onSelectionChanged?.call(const {AppViewProviders.blackskyKey}); 335 + await tester.pumpAndSettle(); 336 + await tester.tap(find.text('Apply and Restart')); 337 + await tester.pumpAndSettle(); 338 + 339 + verify(() => settingsCubit.setAppViewProvider(AppViewProviders.blackskyKey)).called(1); 261 340 }); 262 341 263 342 testWidgets('shows Video Upload Limits tile in Account section', (tester) async {