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: crashlytics (opt-in) integration

+952 -286
+1
CHANGELOG.md
··· 7 7 #### 2026-05-04 8 8 9 9 - Jump to top action in feed & profile screens. 10 + - Firebase Crashlytics integration for crash reporting and analytics. 10 11 11 12 ### Fixed 12 13
+1
android/app/build.gradle.kts
··· 4 4 // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. 5 5 id("dev.flutter.flutter-gradle-plugin") 6 6 id("com.google.gms.google-services") 7 + id("com.google.firebase.crashlytics") 7 8 } 8 9 9 10 android {
+3
android/app/src/main/AndroidManifest.xml
··· 8 8 android:label="Lazurite" 9 9 android:name="${applicationName}" 10 10 android:icon="@mipmap/ic_launcher"> 11 + <meta-data 12 + android:name="firebase_crashlytics_collection_enabled" 13 + android:value="false" /> 11 14 <activity 12 15 android:name=".MainActivity" 13 16 android:exported="true"
+1
android/settings.gradle.kts
··· 22 22 id("com.android.application") version "8.11.1" apply false 23 23 id("org.jetbrains.kotlin.android") version "2.2.20" apply false 24 24 id("com.google.gms.google-services") version "4.4.3" apply false 25 + id("com.google.firebase.crashlytics") version "3.0.6" apply false 25 26 } 26 27 27 28 include(":app")
+6 -4
ios/Runner/Info.plist
··· 41 41 <string>$(FLUTTER_BUILD_NAME)</string> 42 42 <key>CFBundleSignature</key> 43 43 <string>????</string> 44 - <key>CFBundleVersion</key> 45 - <string>$(FLUTTER_BUILD_NUMBER)</string> 46 - <key>LSRequiresIPhoneOS</key> 47 - <true/> 44 + <key>CFBundleVersion</key> 45 + <string>$(FLUTTER_BUILD_NUMBER)</string> 46 + <key>FirebaseCrashlyticsCollectionEnabled</key> 47 + <false/> 48 + <key>LSRequiresIPhoneOS</key> 49 + <true/> 48 50 <key>UIApplicationSceneManifest</key> 49 51 <dict> 50 52 <key>UIApplicationSupportsMultipleScenes</key>
+80
lib/core/crash_reporting/crash_reporting_service.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:firebase_crashlytics/firebase_crashlytics.dart'; 4 + import 'package:flutter/foundation.dart'; 5 + import 'package:lazurite/core/logging/app_logger.dart'; 6 + 7 + abstract class CrashReportingService { 8 + void recordFlutterFatalError(FlutterErrorDetails details); 9 + 10 + Future<void> recordError(Object error, StackTrace stackTrace, {bool fatal}); 11 + 12 + Future<void> setCollectionEnabled(bool enabled); 13 + 14 + Future<void> sendUnsentReports(); 15 + 16 + Future<void> deleteUnsentReports(); 17 + 18 + void crash(); 19 + } 20 + 21 + class FirebaseCrashReportingService implements CrashReportingService { 22 + FirebaseCrashReportingService({FirebaseCrashlytics? crashlytics}) 23 + : _crashlytics = crashlytics ?? FirebaseCrashlytics.instance; 24 + 25 + final FirebaseCrashlytics _crashlytics; 26 + 27 + @override 28 + void recordFlutterFatalError(FlutterErrorDetails details) { 29 + try { 30 + unawaited( 31 + _crashlytics.recordFlutterFatalError(details).catchError((Object error, StackTrace stackTrace) { 32 + log.w('Unable to record Flutter fatal error in Crashlytics', error: error, stackTrace: stackTrace); 33 + }), 34 + ); 35 + } catch (error, stackTrace) { 36 + log.w('Unable to record Flutter fatal error in Crashlytics', error: error, stackTrace: stackTrace); 37 + } 38 + } 39 + 40 + @override 41 + Future<void> recordError(Object error, StackTrace stackTrace, {bool fatal = false}) async { 42 + try { 43 + await _crashlytics.recordError(error, stackTrace, fatal: fatal); 44 + } catch (recordError, recordStackTrace) { 45 + log.w('Unable to record error in Crashlytics', error: recordError, stackTrace: recordStackTrace); 46 + } 47 + } 48 + 49 + @override 50 + Future<void> setCollectionEnabled(bool enabled) async { 51 + try { 52 + await _crashlytics.setCrashlyticsCollectionEnabled(enabled); 53 + } catch (error, stackTrace) { 54 + log.w('Unable to set Crashlytics collection state', error: error, stackTrace: stackTrace); 55 + } 56 + } 57 + 58 + @override 59 + Future<void> sendUnsentReports() async { 60 + try { 61 + await _crashlytics.sendUnsentReports(); 62 + } catch (error, stackTrace) { 63 + log.w('Unable to send unsent Crashlytics reports', error: error, stackTrace: stackTrace); 64 + } 65 + } 66 + 67 + @override 68 + Future<void> deleteUnsentReports() async { 69 + try { 70 + await _crashlytics.deleteUnsentReports(); 71 + } catch (error, stackTrace) { 72 + log.w('Unable to delete unsent Crashlytics reports', error: error, stackTrace: stackTrace); 73 + } 74 + } 75 + 76 + @override 77 + void crash() { 78 + _crashlytics.crash(); 79 + } 80 + }
+21 -14
lib/core/router/app_shell.dart
··· 5 5 import 'package:flutter_animate/flutter_animate.dart'; 6 6 import 'package:flutter_bloc/flutter_bloc.dart'; 7 7 import 'package:go_router/go_router.dart'; 8 + import 'package:lazurite/core/crash_reporting/crash_reporting_consent_gate.dart'; 9 + import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; 8 10 import 'package:lazurite/core/theme/animation_tokens.dart'; 9 11 import 'package:lazurite/core/theme/animation_utils.dart'; 10 12 import 'package:lazurite/core/theme/theme_extensions.dart'; ··· 92 94 @override 93 95 Widget build(BuildContext context) { 94 96 final theme = Theme.of(context); 97 + CrashReportingService? crashReportingService; 98 + try { 99 + crashReportingService = context.read<CrashReportingService>(); 100 + } catch (_) { 101 + crashReportingService = null; 102 + } 95 103 return AppShellScope( 96 104 openMenu: _openMenu, 97 105 child: PopScope( ··· 105 113 child: Scaffold( 106 114 key: AppShell.scaffoldKey, 107 115 drawer: _AppMenu(navigationShell: widget.navigationShell, rootContext: context), 108 - body: widget.navigationShell, 116 + body: crashReportingService == null 117 + ? widget.navigationShell 118 + : CrashReportingConsentGate(crashReportingService: crashReportingService, child: widget.navigationShell), 109 119 bottomNavigationBar: Container( 110 120 decoration: BoxDecoration( 111 121 color: theme.colorScheme.surface.withValues(alpha: 0.92), ··· 496 506 final String label; 497 507 498 508 @override 499 - Widget build(BuildContext context) { 500 - final theme = Theme.of(context); 501 - return Padding( 502 - padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), 503 - child: Text( 504 - label.toUpperCase(), 505 - style: theme.textTheme.labelSmall?.copyWith( 506 - color: theme.colorScheme.onSurfaceVariant, 507 - fontWeight: FontWeight.w700, 508 - letterSpacing: 0.8, 509 - ), 509 + Widget build(BuildContext context) => Padding( 510 + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), 511 + child: Text( 512 + label.toUpperCase(), 513 + style: Theme.of(context).textTheme.labelSmall?.copyWith( 514 + color: Theme.of(context).colorScheme.onSurfaceVariant, 515 + fontWeight: FontWeight.w700, 516 + letterSpacing: 0.8, 510 517 ), 511 - ); 512 - } 518 + ), 519 + ); 513 520 } 514 521 515 522 class _MenuProfileAvatar extends StatefulWidget {
+16
lib/features/settings/bloc/settings_cubit.dart
··· 56 56 static const String _keyAppViewProvider = 'appview_provider'; 57 57 static const String _keyCrossProviderFallbackEnabled = 'cross_provider_fallback_enabled'; 58 58 static const String _keySlingshotIdentityFallbackEnabled = 'slingshot_identity_fallback_enabled'; 59 + static const String _keyCrashReportingEnabled = 'crash_reporting_enabled'; 60 + static const String _keyCrashReportingConsentPrompted = 'crash_reporting_consent_prompted'; 59 61 60 62 Future<void> loadSettings() async { 61 63 final paletteStr = await database.getSetting(_keyThemePalette); ··· 74 76 final appViewProviderStr = await database.getSetting(_keyAppViewProvider); 75 77 final crossProviderFallbackEnabledStr = await database.getSetting(_keyCrossProviderFallbackEnabled); 76 78 final slingshotIdentityFallbackEnabledStr = await database.getSetting(_keySlingshotIdentityFallbackEnabled); 79 + final crashReportingEnabledStr = await database.getSetting(_keyCrashReportingEnabled); 80 + final crashReportingConsentPromptedStr = await database.getSetting(_keyCrashReportingConsentPrompted); 77 81 final resolvedTypeaheadProvider = _supportedTypeaheadProviders.contains(typeaheadProviderStr) 78 82 ? typeaheadProviderStr! 79 83 : _defaultTypeaheadProvider; ··· 96 100 appViewProvider: resolvedAppViewProvider, 97 101 crossProviderFallbackEnabled: crossProviderFallbackEnabledStr == 'true', 98 102 slingshotIdentityFallbackEnabled: slingshotIdentityFallbackEnabledStr == 'true', 103 + crashReportingEnabled: crashReportingEnabledStr == 'true', 104 + crashReportingConsentPrompted: crashReportingConsentPromptedStr == 'true', 99 105 ), 100 106 ); 101 107 } ··· 244 250 Future<void> setSlingshotIdentityFallbackEnabled(bool enabled) async { 245 251 await database.setSetting(_keySlingshotIdentityFallbackEnabled, enabled.toString()); 246 252 emit(state.copyWith(slingshotIdentityFallbackEnabled: enabled)); 253 + } 254 + 255 + Future<void> setCrashReportingEnabled(bool enabled) async { 256 + await database.setSetting(_keyCrashReportingEnabled, enabled.toString()); 257 + emit(state.copyWith(crashReportingEnabled: enabled)); 258 + } 259 + 260 + Future<void> setCrashReportingConsentPrompted(bool prompted) async { 261 + await database.setSetting(_keyCrashReportingConsentPrompted, prompted.toString()); 262 + emit(state.copyWith(crashReportingConsentPrompted: prompted)); 247 263 } 248 264 }
+14
lib/features/settings/bloc/settings_state.dart
··· 22 22 this.appViewProvider = 'bluesky', 23 23 this.crossProviderFallbackEnabled = false, 24 24 this.slingshotIdentityFallbackEnabled = false, 25 + this.crashReportingEnabled = false, 26 + this.crashReportingConsentPrompted = false, 25 27 this.routingEpoch = 0, 26 28 this.appViewHealthSummary, 27 29 this.appViewHealthCheckedAt, ··· 60 62 /// Enables Slingshot identity fallback for degraded handle resolution. 61 63 final bool slingshotIdentityFallbackEnabled; 62 64 65 + /// Whether crash/error reports can be sent to Crashlytics. 66 + final bool crashReportingEnabled; 67 + 68 + /// Whether the one-time crash reporting consent prompt has already been shown. 69 + final bool crashReportingConsentPrompted; 70 + 63 71 /// In-memory epoch incremented when routing state is soft-reset. 64 72 final int routingEpoch; 65 73 ··· 94 102 String? appViewProvider, 95 103 bool? crossProviderFallbackEnabled, 96 104 bool? slingshotIdentityFallbackEnabled, 105 + bool? crashReportingEnabled, 106 + bool? crashReportingConsentPrompted, 97 107 int? routingEpoch, 98 108 Object? appViewHealthSummary = _threadAutoCollapseDepthUnset, 99 109 Object? appViewHealthCheckedAt = _threadAutoCollapseDepthUnset, ··· 119 129 appViewProvider: appViewProvider ?? this.appViewProvider, 120 130 crossProviderFallbackEnabled: crossProviderFallbackEnabled ?? this.crossProviderFallbackEnabled, 121 131 slingshotIdentityFallbackEnabled: slingshotIdentityFallbackEnabled ?? this.slingshotIdentityFallbackEnabled, 132 + crashReportingEnabled: crashReportingEnabled ?? this.crashReportingEnabled, 133 + crashReportingConsentPrompted: crashReportingConsentPrompted ?? this.crashReportingConsentPrompted, 122 134 routingEpoch: routingEpoch ?? this.routingEpoch, 123 135 appViewHealthSummary: identical(appViewHealthSummary, _threadAutoCollapseDepthUnset) 124 136 ? this.appViewHealthSummary ··· 153 165 appViewProvider, 154 166 crossProviderFallbackEnabled, 155 167 slingshotIdentityFallbackEnabled, 168 + crashReportingEnabled, 169 + crashReportingConsentPrompted, 156 170 routingEpoch, 157 171 appViewHealthSummary, 158 172 appViewHealthCheckedAt,
+53 -6
lib/features/settings/presentation/settings_screen.dart
··· 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:flutter_bloc/flutter_bloc.dart'; 6 6 import 'package:go_router/go_router.dart'; 7 + import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; 7 8 import 'package:lazurite/core/network/app_view_provider.dart'; 8 9 import 'package:lazurite/core/network/atproto_host_resolver.dart'; 9 10 import 'package:lazurite/core/router/app_shell.dart'; ··· 112 113 _buildSectionHeader(context, 'Advanced'), 113 114 _buildAdvancedSettings(context), 114 115 const SizedBox(height: 24), 115 - if (!kReleaseMode) ...[ 116 + if (!kReleaseMode || kDebugMode) ...[ 116 117 _buildSectionHeader(context, 'Developer'), 117 118 _buildDeveloperSettings(context), 118 119 const SizedBox(height: 24), ··· 359 360 360 361 Widget _buildDeveloperSettings(BuildContext context) { 361 362 final settingsCubit = context.read<SettingsCubit>(); 363 + final crashReportingService = _readCrashReportingServiceOrNull(context); 362 364 363 365 return BlocBuilder<SettingsCubit, SettingsState>( 364 366 builder: (context, state) { ··· 371 373 ), 372 374 color: theme.cardColor, 373 375 ), 374 - child: _SettingsTile( 375 - icon: Icons.cloud_off_outlined, 376 - title: 'Go Offline', 377 - subtitle: 'Turn off online connectivity', 378 - trailing: Switch.adaptive(value: state.simulateOffline, onChanged: settingsCubit.setSimulateOffline), 376 + child: Column( 377 + children: [ 378 + _SettingsTile( 379 + icon: Icons.cloud_off_outlined, 380 + title: 'Go Offline', 381 + subtitle: 'Turn off online connectivity', 382 + trailing: Switch.adaptive(value: state.simulateOffline, onChanged: settingsCubit.setSimulateOffline), 383 + ), 384 + const Divider(height: 1), 385 + _SettingsTile( 386 + icon: Icons.bug_report_outlined, 387 + title: 'Crashlytics Test Crash', 388 + subtitle: 'Intentionally crash to validate Crashlytics reports', 389 + trailing: const Icon(Icons.warning_amber_rounded), 390 + onTap: crashReportingService?.crash, 391 + ), 392 + ], 379 393 ), 380 394 ); 381 395 }, 382 396 ); 383 397 } 384 398 399 + CrashReportingService? _readCrashReportingServiceOrNull(BuildContext context) { 400 + try { 401 + return context.read<CrashReportingService>(); 402 + } on ProviderNotFoundException { 403 + return null; 404 + } 405 + } 406 + 385 407 Widget _buildAdvancedSettings(BuildContext context) { 386 408 final settingsCubit = context.read<SettingsCubit>(); 387 409 final theme = Theme.of(context); ··· 446 468 ), 447 469 ), 448 470 const Divider(height: 1), 471 + _SettingsTile( 472 + icon: Icons.bug_report_outlined, 473 + title: 'Crash Reporting', 474 + subtitle: state.crashReportingEnabled 475 + ? 'Enabled. Crash and error reports are sent to improve stability.' 476 + : 'Disabled. Crash and error reports are not sent.', 477 + trailing: Switch.adaptive( 478 + value: state.crashReportingEnabled, 479 + onChanged: (enabled) => unawaited(_handleCrashReportingToggle(context, enabled)), 480 + ), 481 + ), 482 + const Divider(height: 1), 449 483 const _SettingsTile( 450 484 icon: Icons.monitor_heart_outlined, 451 485 title: 'Provider Diagnostics', ··· 521 555 } 522 556 523 557 await context.read<SettingsCubit>().setAppViewProvider(selectedProvider); 558 + } 559 + 560 + Future<void> _handleCrashReportingToggle(BuildContext context, bool enabled) async { 561 + final settingsCubit = context.read<SettingsCubit>(); 562 + final crashReportingService = context.read<CrashReportingService>(); 563 + await settingsCubit.setCrashReportingEnabled(enabled); 564 + await settingsCubit.setCrashReportingConsentPrompted(true); 565 + await crashReportingService.setCollectionEnabled(enabled); 566 + if (enabled) { 567 + await crashReportingService.sendUnsentReports(); 568 + return; 569 + } 570 + await crashReportingService.deleteUnsentReports(); 524 571 } 525 572 } 526 573
+287 -247
lib/main.dart
··· 1 1 import 'dart:async'; 2 + import 'dart:ui'; 2 3 3 4 import 'package:bluesky/bluesky.dart'; 4 5 import 'package:bluesky/bluesky_chat.dart'; 6 + import 'package:firebase_core/firebase_core.dart'; 5 7 import 'package:firebase_messaging/firebase_messaging.dart'; 6 8 import 'package:flutter/material.dart'; 7 9 import 'package:flutter_bloc/flutter_bloc.dart'; 8 10 import 'package:go_router/go_router.dart'; 9 11 import 'package:lazurite/core/bootstrap/auth_bootstrap.dart'; 10 12 import 'package:lazurite/core/cache/offline_cache_policy.dart'; 13 + import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; 11 14 import 'package:lazurite/core/database/app_database.dart'; 12 15 import 'package:lazurite/core/embedding/embedding_service.dart'; 13 16 import 'package:lazurite/core/logging/app_logger.dart'; ··· 73 76 imageCache.maximumSizeBytes = OfflineCachePolicy.imageMemoryByteLimit; 74 77 75 78 await log.initialize(); 79 + if (Firebase.apps.isEmpty) { 80 + await Firebase.initializeApp(); 81 + } 82 + 83 + final crashReportingService = FirebaseCrashReportingService(); 84 + final previousFlutterErrorHandler = FlutterError.onError; 85 + FlutterError.onError = (details) { 86 + previousFlutterErrorHandler?.call(details); 87 + crashReportingService.recordFlutterFatalError(details); 88 + }; 89 + PlatformDispatcher.instance.onError = (error, stackTrace) { 90 + unawaited(crashReportingService.recordError(error, stackTrace, fatal: true)); 91 + return true; 92 + }; 93 + 76 94 await PostScheduler.initialize(); 77 95 FirebaseMessaging.onBackgroundMessage(notificationFirebaseMessagingBackgroundHandler); 78 96 await NotificationBackgroundScheduler.ensureScheduled(); ··· 99 117 ); 100 118 final authRepository = authBootstrap.authRepository; 101 119 final restoredSession = authBootstrap.restoredSession; 120 + await crashReportingService.setCollectionEnabled(settingsCubit.state.crashReportingEnabled); 102 121 final authBloc = AuthBloc( 103 122 authRepository: authRepository, 104 123 initialState: restoredSession != null ··· 127 146 128 147 log.i('AppLogger: App started'); 129 148 130 - runApp( 131 - LazuriteApp.from( 132 - authBloc, 133 - database, 134 - appViewFallbackService, 135 - objectBoxStore, 136 - embeddingService, 137 - settingsCubit, 138 - connectivityCubit, 139 - accountSwitcherCubit, 140 - localNotificationAdapter, 141 - pushRegistrationService, 142 - ), 149 + runZonedGuarded( 150 + () { 151 + runApp( 152 + LazuriteApp.from( 153 + authBloc, 154 + database, 155 + appViewFallbackService, 156 + objectBoxStore, 157 + embeddingService, 158 + settingsCubit, 159 + connectivityCubit, 160 + accountSwitcherCubit, 161 + localNotificationAdapter, 162 + pushRegistrationService, 163 + crashReportingService, 164 + ), 165 + ); 166 + }, 167 + (error, stackTrace) { 168 + unawaited(crashReportingService.recordError(error, stackTrace, fatal: true)); 169 + }, 143 170 ); 144 171 } 145 172 ··· 156 183 required this.accountSwitcherCubit, 157 184 required this.localNotificationAdapter, 158 185 required this.pushRegistrationService, 186 + required this.crashReportingService, 159 187 }); 160 188 161 189 final AuthBloc authBloc; ··· 168 196 final AccountSwitcherCubit accountSwitcherCubit; 169 197 final LocalNotificationAdapter localNotificationAdapter; 170 198 final PushRegistrationService pushRegistrationService; 199 + final CrashReportingService crashReportingService; 171 200 172 201 /// factory constructor with positional params 173 202 static LazuriteApp from( ··· 181 210 AccountSwitcherCubit accountSwitcherCubit, 182 211 LocalNotificationAdapter localNotificationAdapter, 183 212 PushRegistrationService pushRegistrationService, 213 + CrashReportingService crashReportingService, 184 214 ) => LazuriteApp( 185 215 authBloc: authBloc, 186 216 database: database, ··· 192 222 accountSwitcherCubit: accountSwitcherCubit, 193 223 localNotificationAdapter: localNotificationAdapter, 194 224 pushRegistrationService: pushRegistrationService, 225 + crashReportingService: crashReportingService, 195 226 ); 196 227 197 228 @override ··· 340 371 341 372 @override 342 373 Widget build(BuildContext context) { 343 - return MultiBlocProvider( 344 - providers: [ 345 - BlocProvider.value(value: widget.authBloc), 346 - BlocProvider.value(value: widget.settingsCubit), 347 - BlocProvider.value(value: widget.connectivityCubit), 348 - BlocProvider.value(value: widget.accountSwitcherCubit), 349 - ], 350 - child: BlocBuilder<AuthBloc, AuthState>( 351 - builder: (context, authState) { 352 - final bluesky = _createBluesky(authState); 353 - final blueskyChat = _createBlueskyChat(authState); 354 - final appShell = BlocBuilder<SettingsCubit, SettingsState>( 355 - builder: (context, settingsState) { 356 - final themeMode = settingsState.useSystemTheme 357 - ? ThemeMode.system 358 - : (settingsState.themeVariant == AppThemeVariant.light ? ThemeMode.light : ThemeMode.dark); 374 + return RepositoryProvider<CrashReportingService>.value( 375 + value: widget.crashReportingService, 376 + child: MultiBlocProvider( 377 + providers: [ 378 + BlocProvider.value(value: widget.authBloc), 379 + BlocProvider.value(value: widget.settingsCubit), 380 + BlocProvider.value(value: widget.connectivityCubit), 381 + BlocProvider.value(value: widget.accountSwitcherCubit), 382 + ], 383 + child: BlocBuilder<AuthBloc, AuthState>( 384 + builder: (context, authState) { 385 + final bluesky = _createBluesky(authState); 386 + final blueskyChat = _createBlueskyChat(authState); 387 + final appShell = BlocBuilder<SettingsCubit, SettingsState>( 388 + builder: (context, settingsState) { 389 + final themeMode = settingsState.useSystemTheme 390 + ? ThemeMode.system 391 + : (settingsState.themeVariant == AppThemeVariant.light ? ThemeMode.light : ThemeMode.dark); 359 392 360 - final lightTheme = AppTheme.getTheme(settingsState.themePalette, AppThemeVariant.light); 361 - final darkTheme = AppTheme.getTheme(settingsState.themePalette, AppThemeVariant.dark); 393 + final lightTheme = AppTheme.getTheme(settingsState.themePalette, AppThemeVariant.light); 394 + final darkTheme = AppTheme.getTheme(settingsState.themePalette, AppThemeVariant.dark); 362 395 363 - return MaterialApp.router( 364 - key: ValueKey('router-$_routerSessionKey-$_routerGeneration'), 365 - title: 'Lazurite', 366 - debugShowCheckedModeBanner: false, 367 - theme: lightTheme, 368 - darkTheme: darkTheme, 369 - themeMode: themeMode, 370 - routerConfig: _router, 371 - builder: (context, child) => GlobalTapOutsideUnfocus( 372 - child: Stack( 373 - children: [ 374 - ConnectivityBannerHost(child: child ?? const SizedBox.shrink()), 375 - if (_isSoftRestarting) 376 - const ColoredBox( 377 - color: Color(0xC0000000), 378 - child: Center( 379 - child: Card( 380 - child: Padding( 381 - padding: EdgeInsets.symmetric(horizontal: 20, vertical: 16), 382 - child: Row( 383 - mainAxisSize: MainAxisSize.min, 384 - children: [ 385 - SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2.5)), 386 - SizedBox(width: 12), 387 - Text('Applying provider change...'), 388 - ], 396 + return MaterialApp.router( 397 + key: ValueKey('router-$_routerSessionKey-$_routerGeneration'), 398 + title: 'Lazurite', 399 + debugShowCheckedModeBanner: false, 400 + theme: lightTheme, 401 + darkTheme: darkTheme, 402 + themeMode: themeMode, 403 + routerConfig: _router, 404 + builder: (context, child) => GlobalTapOutsideUnfocus( 405 + child: Stack( 406 + children: [ 407 + ConnectivityBannerHost(child: child ?? const SizedBox.shrink()), 408 + if (_isSoftRestarting) 409 + const ColoredBox( 410 + color: Color(0xC0000000), 411 + child: Center( 412 + child: Card( 413 + child: Padding( 414 + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 16), 415 + child: Row( 416 + mainAxisSize: MainAxisSize.min, 417 + children: [ 418 + SizedBox( 419 + height: 20, 420 + width: 20, 421 + child: CircularProgressIndicator(strokeWidth: 2.5), 422 + ), 423 + SizedBox(width: 12), 424 + Text('Applying provider change...'), 425 + ], 426 + ), 389 427 ), 390 428 ), 391 429 ), 392 430 ), 393 - ), 394 - ], 431 + ], 432 + ), 395 433 ), 396 - ), 397 - ); 398 - }, 399 - ); 434 + ); 435 + }, 436 + ); 400 437 401 - if (bluesky == null || blueskyChat == null) { 402 - return appShell; 403 - } 438 + if (bluesky == null || blueskyChat == null) { 439 + return appShell; 440 + } 404 441 405 - final accountDid = authState.tokens?.did ?? ''; 442 + final accountDid = authState.tokens?.did ?? ''; 406 443 407 - return KeyedSubtree( 408 - key: ValueKey('account-$accountDid-routing-${context.read<SettingsCubit>().state.routingEpoch}'), 409 - child: MultiRepositoryProvider( 410 - providers: [ 411 - RepositoryProvider( 412 - create: (_) { 413 - final settingsCubit = context.read<SettingsCubit>(); 414 - final moderationService = ModerationService( 444 + return KeyedSubtree( 445 + key: ValueKey('account-$accountDid-routing-${context.read<SettingsCubit>().state.routingEpoch}'), 446 + child: MultiRepositoryProvider( 447 + providers: [ 448 + RepositoryProvider( 449 + create: (_) { 450 + final settingsCubit = context.read<SettingsCubit>(); 451 + final moderationService = ModerationService( 452 + bluesky: bluesky, 453 + database: widget.database, 454 + accountDid: accountDid, 455 + userDid: accountDid, 456 + appViewProviderResolver: () => settingsCubit.state.appViewProvider, 457 + ); 458 + unawaited(moderationService.ensureInitialized()); 459 + return moderationService; 460 + }, 461 + dispose: (moderationService) => moderationService.dispose(), 462 + ), 463 + RepositoryProvider( 464 + create: (context) => FeedRepository( 415 465 bluesky: bluesky, 416 466 database: widget.database, 417 467 accountDid: accountDid, 418 - userDid: accountDid, 419 - appViewProviderResolver: () => settingsCubit.state.appViewProvider, 420 - ); 421 - unawaited(moderationService.ensureInitialized()); 422 - return moderationService; 423 - }, 424 - dispose: (moderationService) => moderationService.dispose(), 425 - ), 426 - RepositoryProvider( 427 - create: (context) => FeedRepository( 428 - bluesky: bluesky, 429 - database: widget.database, 430 - accountDid: accountDid, 431 - moderationService: context.read<ModerationService>(), 432 - appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 433 - crossProviderFallbackEnabledResolver: () => 434 - context.read<SettingsCubit>().state.crossProviderFallbackEnabled, 435 - appViewFallbackService: widget.appViewFallbackService, 436 - routingEpoch: context.read<SettingsCubit>().state.routingEpoch, 437 - routingEpochResolver: () => context.read<SettingsCubit>().state.routingEpoch, 438 - ), 439 - ), 440 - RepositoryProvider( 441 - create: (context) { 442 - final settingsCubit = context.read<SettingsCubit>(); 443 - return SearchRepository( 444 - bluesky: bluesky, 445 468 moderationService: context.read<ModerationService>(), 446 - appViewProviderResolver: () => settingsCubit.state.appViewProvider, 447 - crossProviderFallbackEnabledResolver: () => settingsCubit.state.crossProviderFallbackEnabled, 448 - appViewFallbackService: widget.appViewFallbackService, 449 - routingEpoch: settingsCubit.state.routingEpoch, 450 - routingEpochResolver: () => settingsCubit.state.routingEpoch, 451 - ); 452 - }, 453 - ), 454 - RepositoryProvider( 455 - create: (context) { 456 - final settingsCubit = context.read<SettingsCubit>(); 457 - return TypeaheadRepository( 458 - bluesky: bluesky, 459 - providerResolver: () => settingsCubit.state.typeaheadProvider, 460 - appViewProviderResolver: () => settingsCubit.state.appViewProvider, 461 - moderationService: context.read<ModerationService>(), 462 - ); 463 - }, 464 - ), 465 - RepositoryProvider( 466 - create: (context) => ListRepository( 467 - bluesky: bluesky, 468 - moderationService: context.read<ModerationService>(), 469 - appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 470 - ), 471 - ), 472 - RepositoryProvider( 473 - create: (context) { 474 - final service = context.read<ModerationService>(); 475 - return ProfileRepository( 476 - database: widget.database, 477 - bluesky: bluesky, 478 - moderationService: service, 479 469 appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 480 - ); 481 - }, 482 - ), 483 - RepositoryProvider( 484 - create: (context) => NotificationRepository( 485 - bluesky: bluesky, 486 - moderationService: context.read<ModerationService>(), 487 - appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 470 + crossProviderFallbackEnabledResolver: () => 471 + context.read<SettingsCubit>().state.crossProviderFallbackEnabled, 472 + appViewFallbackService: widget.appViewFallbackService, 473 + routingEpoch: context.read<SettingsCubit>().state.routingEpoch, 474 + routingEpochResolver: () => context.read<SettingsCubit>().state.routingEpoch, 475 + ), 488 476 ), 489 - ), 490 - RepositoryProvider( 491 - create: (context) => NotificationDomainService( 492 - notificationRepository: context.read<NotificationRepository>(), 493 - database: widget.database, 494 - accountDid: accountDid, 495 - localNotificationAdapter: widget.localNotificationAdapter, 496 - shouldSuppressLocalNotifications: _isAlertsRouteActive, 477 + RepositoryProvider( 478 + create: (context) { 479 + final settingsCubit = context.read<SettingsCubit>(); 480 + return SearchRepository( 481 + bluesky: bluesky, 482 + moderationService: context.read<ModerationService>(), 483 + appViewProviderResolver: () => settingsCubit.state.appViewProvider, 484 + crossProviderFallbackEnabledResolver: () => settingsCubit.state.crossProviderFallbackEnabled, 485 + appViewFallbackService: widget.appViewFallbackService, 486 + routingEpoch: settingsCubit.state.routingEpoch, 487 + routingEpochResolver: () => settingsCubit.state.routingEpoch, 488 + ); 489 + }, 497 490 ), 498 - ), 499 - RepositoryProvider( 500 - create: (context) => PostThreadRepository( 501 - bluesky: bluesky, 502 - database: widget.database, 503 - accountDid: accountDid, 504 - moderationService: context.read<ModerationService>(), 505 - appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 491 + RepositoryProvider( 492 + create: (context) { 493 + final settingsCubit = context.read<SettingsCubit>(); 494 + return TypeaheadRepository( 495 + bluesky: bluesky, 496 + providerResolver: () => settingsCubit.state.typeaheadProvider, 497 + appViewProviderResolver: () => settingsCubit.state.appViewProvider, 498 + moderationService: context.read<ModerationService>(), 499 + ); 500 + }, 506 501 ), 507 - ), 508 - RepositoryProvider( 509 - create: (context) => StarterPackRepository( 510 - bluesky: bluesky, 511 - moderationService: context.read<ModerationService>(), 512 - appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 513 - ), 514 - ), 515 - RepositoryProvider( 516 - create: (context) => PostActionRepository( 517 - bluesky: bluesky, 518 - appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 519 - ), 520 - ), 521 - RepositoryProvider( 522 - create: (context) => ProfileActionRepository( 523 - bluesky: bluesky, 524 - appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 525 - ), 526 - ), 527 - RepositoryProvider(create: (_) => ConvoRepository(chat: blueskyChat)), 528 - RepositoryProvider(create: (_) => PostActionCache()), 529 - RepositoryProvider(create: (_) => VideoRepository(bluesky: bluesky)), 530 - RepositoryProvider.value(value: bluesky), 531 - RepositoryProvider.value(value: widget.database), 532 - RepositoryProvider.value(value: widget.objectBoxStore), 533 - RepositoryProvider.value(value: widget.embeddingService), 534 - RepositoryProvider(create: (context) => EmbeddingRepository(context.read<ObjectBoxStore>())), 535 - RepositoryProvider( 536 - create: (context) => SemanticIndexer( 537 - embeddingService: context.read<EmbeddingService>(), 538 - embeddingRepository: context.read<EmbeddingRepository>(), 539 - database: widget.database, 502 + RepositoryProvider( 503 + create: (context) => ListRepository( 504 + bluesky: bluesky, 505 + moderationService: context.read<ModerationService>(), 506 + appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 507 + ), 540 508 ), 541 - ), 542 - RepositoryProvider( 543 - create: (context) => LikedPostsRepository( 544 - bluesky: bluesky, 545 - database: widget.database, 546 - semanticIndexer: context.read<SemanticIndexer>(), 547 - appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 509 + RepositoryProvider( 510 + create: (context) { 511 + final service = context.read<ModerationService>(); 512 + return ProfileRepository( 513 + database: widget.database, 514 + bluesky: bluesky, 515 + moderationService: service, 516 + appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 517 + ); 518 + }, 548 519 ), 549 - ), 550 - RepositoryProvider( 551 - create: (context) => SemanticSearchRepository( 552 - embeddingService: context.read<EmbeddingService>(), 553 - embeddingRepository: context.read<EmbeddingRepository>(), 554 - database: widget.database, 520 + RepositoryProvider( 521 + create: (context) => NotificationRepository( 522 + bluesky: bluesky, 523 + moderationService: context.read<ModerationService>(), 524 + appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 525 + ), 555 526 ), 556 - ), 557 - RepositoryProvider.value(value: accountDid), 558 - ], 559 - child: MultiBlocProvider( 560 - providers: [ 561 - BlocProvider(create: (context) => ProfileBloc(profileRepository: context.read<ProfileRepository>())), 562 - BlocProvider(create: (context) => FeedBloc(feedRepository: context.read<FeedRepository>())), 563 - BlocProvider( 564 - create: (context) => FeedPreferencesCubit( 565 - feedRepository: context.read<FeedRepository>(), 527 + RepositoryProvider( 528 + create: (context) => NotificationDomainService( 529 + notificationRepository: context.read<NotificationRepository>(), 566 530 database: widget.database, 567 531 accountDid: accountDid, 568 - )..loadPreferences(), 532 + localNotificationAdapter: widget.localNotificationAdapter, 533 + shouldSuppressLocalNotifications: _isAlertsRouteActive, 534 + ), 569 535 ), 570 - BlocProvider(create: (_) => DevToolsCubit(atproto: bluesky.atproto)), 571 - BlocProvider( 572 - create: (context) => SearchBloc( 573 - searchRepository: context.read<SearchRepository>(), 574 - typeaheadRepository: context.read<TypeaheadRepository>(), 536 + RepositoryProvider( 537 + create: (context) => PostThreadRepository( 538 + bluesky: bluesky, 575 539 database: widget.database, 576 540 accountDid: accountDid, 541 + moderationService: context.read<ModerationService>(), 542 + appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 577 543 ), 578 544 ), 579 - BlocProvider( 580 - create: (context) => 581 - ConvoListBloc(convoRepository: context.read<ConvoRepository>()) 582 - ..add(const ConvosRequested(limit: 100)), 545 + RepositoryProvider( 546 + create: (context) => StarterPackRepository( 547 + bluesky: bluesky, 548 + moderationService: context.read<ModerationService>(), 549 + appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 550 + ), 551 + ), 552 + RepositoryProvider( 553 + create: (context) => PostActionRepository( 554 + bluesky: bluesky, 555 + appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 556 + ), 557 + ), 558 + RepositoryProvider( 559 + create: (context) => ProfileActionRepository( 560 + bluesky: bluesky, 561 + appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 562 + ), 563 + ), 564 + RepositoryProvider(create: (_) => ConvoRepository(chat: blueskyChat)), 565 + RepositoryProvider(create: (_) => PostActionCache()), 566 + RepositoryProvider(create: (_) => VideoRepository(bluesky: bluesky)), 567 + RepositoryProvider.value(value: bluesky), 568 + RepositoryProvider.value(value: widget.database), 569 + RepositoryProvider.value(value: widget.objectBoxStore), 570 + RepositoryProvider.value(value: widget.embeddingService), 571 + RepositoryProvider(create: (context) => EmbeddingRepository(context.read<ObjectBoxStore>())), 572 + RepositoryProvider( 573 + create: (context) => SemanticIndexer( 574 + embeddingService: context.read<EmbeddingService>(), 575 + embeddingRepository: context.read<EmbeddingRepository>(), 576 + database: widget.database, 577 + ), 583 578 ), 584 - BlocProvider( 585 - create: (context) => SavedPostsCubit( 579 + RepositoryProvider( 580 + create: (context) => LikedPostsRepository( 581 + bluesky: bluesky, 586 582 database: widget.database, 587 - accountDid: accountDid, 588 - postActionRepository: context.read<PostActionRepository>(), 589 583 semanticIndexer: context.read<SemanticIndexer>(), 584 + appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 590 585 ), 591 586 ), 592 - BlocProvider( 593 - create: (context) => SemanticSearchCubit( 594 - repository: context.read<SemanticSearchRepository>(), 587 + RepositoryProvider( 588 + create: (context) => SemanticSearchRepository( 595 589 embeddingService: context.read<EmbeddingService>(), 596 - accountDid: accountDid, 597 - ), 598 - ), 599 - BlocProvider( 600 - create: (context) => SemanticIndexCubit( 601 - indexer: context.read<SemanticIndexer>(), 602 590 embeddingRepository: context.read<EmbeddingRepository>(), 603 - accountDid: accountDid, 591 + database: widget.database, 604 592 ), 605 593 ), 606 - BlocProvider( 607 - create: (context) => 608 - LikedPostsSyncCubit(repository: context.read<LikedPostsRepository>(), accountDid: accountDid), 609 - ), 594 + RepositoryProvider.value(value: accountDid), 610 595 ], 611 - child: appShell, 596 + child: MultiBlocProvider( 597 + providers: [ 598 + BlocProvider( 599 + create: (context) => ProfileBloc(profileRepository: context.read<ProfileRepository>()), 600 + ), 601 + BlocProvider(create: (context) => FeedBloc(feedRepository: context.read<FeedRepository>())), 602 + BlocProvider( 603 + create: (context) => FeedPreferencesCubit( 604 + feedRepository: context.read<FeedRepository>(), 605 + database: widget.database, 606 + accountDid: accountDid, 607 + )..loadPreferences(), 608 + ), 609 + BlocProvider(create: (_) => DevToolsCubit(atproto: bluesky.atproto)), 610 + BlocProvider( 611 + create: (context) => SearchBloc( 612 + searchRepository: context.read<SearchRepository>(), 613 + typeaheadRepository: context.read<TypeaheadRepository>(), 614 + database: widget.database, 615 + accountDid: accountDid, 616 + ), 617 + ), 618 + BlocProvider( 619 + create: (context) => 620 + ConvoListBloc(convoRepository: context.read<ConvoRepository>()) 621 + ..add(const ConvosRequested(limit: 100)), 622 + ), 623 + BlocProvider( 624 + create: (context) => SavedPostsCubit( 625 + database: widget.database, 626 + accountDid: accountDid, 627 + postActionRepository: context.read<PostActionRepository>(), 628 + semanticIndexer: context.read<SemanticIndexer>(), 629 + ), 630 + ), 631 + BlocProvider( 632 + create: (context) => SemanticSearchCubit( 633 + repository: context.read<SemanticSearchRepository>(), 634 + embeddingService: context.read<EmbeddingService>(), 635 + accountDid: accountDid, 636 + ), 637 + ), 638 + BlocProvider( 639 + create: (context) => SemanticIndexCubit( 640 + indexer: context.read<SemanticIndexer>(), 641 + embeddingRepository: context.read<EmbeddingRepository>(), 642 + accountDid: accountDid, 643 + ), 644 + ), 645 + BlocProvider( 646 + create: (context) => 647 + LikedPostsSyncCubit(repository: context.read<LikedPostsRepository>(), accountDid: accountDid), 648 + ), 649 + ], 650 + child: appShell, 651 + ), 612 652 ), 613 - ), 614 - ); 615 - }, 653 + ); 654 + }, 655 + ), 616 656 ), 617 657 ); 618 658 }
+16
pubspec.lock
··· 505 505 url: "https://pub.dev" 506 506 source: hosted 507 507 version: "3.6.0" 508 + firebase_crashlytics: 509 + dependency: "direct main" 510 + description: 511 + name: firebase_crashlytics 512 + sha256: "43a311b280d9391389a690d10e1ac0d458b965154a57de5be2f0857225aa2016" 513 + url: "https://pub.dev" 514 + source: hosted 515 + version: "5.2.0" 516 + firebase_crashlytics_platform_interface: 517 + dependency: transitive 518 + description: 519 + name: firebase_crashlytics_platform_interface 520 + sha256: "1b6a921ad6f0d08203ecc1310437a88cec357bc3cad27e1138f1e2c16dd71db9" 521 + url: "https://pub.dev" 522 + source: hosted 523 + version: "3.8.20" 508 524 firebase_messaging: 509 525 dependency: "direct main" 510 526 description:
+1
pubspec.yaml
··· 56 56 flutter_local_notifications: ^19.4.2 57 57 firebase_core: ^4.0.0 58 58 firebase_messaging: ^16.0.0 59 + firebase_crashlytics: ^5.2.0 59 60 60 61 dev_dependencies: 61 62 flutter_test:
+57
test/core/crash_reporting/crash_reporting_service_test.dart
··· 1 + import 'package:firebase_crashlytics/firebase_crashlytics.dart'; 2 + import 'package:flutter/foundation.dart'; 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; 5 + import 'package:mocktail/mocktail.dart'; 6 + 7 + class MockFirebaseCrashlytics extends Mock implements FirebaseCrashlytics {} 8 + 9 + void main() { 10 + setUpAll(() { 11 + registerFallbackValue(FlutterErrorDetails(exception: Exception('fallback'), stack: StackTrace.empty)); 12 + }); 13 + 14 + late MockFirebaseCrashlytics crashlytics; 15 + late FirebaseCrashReportingService service; 16 + 17 + setUp(() { 18 + crashlytics = MockFirebaseCrashlytics(); 19 + service = FirebaseCrashReportingService(crashlytics: crashlytics); 20 + 21 + when(() => crashlytics.setCrashlyticsCollectionEnabled(any())).thenAnswer((_) async {}); 22 + when(() => crashlytics.recordError(any(), any(), fatal: any(named: 'fatal'))).thenAnswer((_) async {}); 23 + when(() => crashlytics.recordFlutterFatalError(any())).thenAnswer((_) async {}); 24 + when(() => crashlytics.sendUnsentReports()).thenAnswer((_) async {}); 25 + when(() => crashlytics.deleteUnsentReports()).thenAnswer((_) async {}); 26 + }); 27 + 28 + test('setCollectionEnabled delegates to Firebase Crashlytics', () async { 29 + await service.setCollectionEnabled(true); 30 + verify(() => crashlytics.setCrashlyticsCollectionEnabled(true)).called(1); 31 + }); 32 + 33 + test('setCollectionEnabled does not throw when plugin call fails', () async { 34 + when(() => crashlytics.setCrashlyticsCollectionEnabled(any())).thenThrow(Exception('boom')); 35 + await service.setCollectionEnabled(false); 36 + verify(() => crashlytics.setCrashlyticsCollectionEnabled(false)).called(1); 37 + }); 38 + 39 + test('recordError does not throw when plugin call fails', () async { 40 + when(() => crashlytics.recordError(any(), any(), fatal: any(named: 'fatal'))).thenThrow(Exception('boom')); 41 + await service.recordError(Exception('error'), StackTrace.current, fatal: true); 42 + verify(() => crashlytics.recordError(any(), any(), fatal: true)).called(1); 43 + }); 44 + 45 + test('recordFlutterFatalError does not throw when plugin call fails', () async { 46 + when(() => crashlytics.recordFlutterFatalError(any())).thenThrow(Exception('boom')); 47 + service.recordFlutterFatalError(FlutterErrorDetails(exception: Exception('fatal'), stack: StackTrace.current)); 48 + await Future<void>.delayed(const Duration(milliseconds: 1)); 49 + verify(() => crashlytics.recordFlutterFatalError(any())).called(1); 50 + }); 51 + 52 + test('crash delegates to Firebase Crashlytics', () { 53 + when(() => crashlytics.crash()).thenReturn(null); 54 + service.crash(); 55 + verify(() => crashlytics.crash()).called(1); 56 + }); 57 + }
+42 -1
test/features/settings/bloc/settings_cubit_test.dart
··· 33 33 expect(cubit.state.appViewProvider, 'bluesky'); 34 34 expect(cubit.state.crossProviderFallbackEnabled, isFalse); 35 35 expect(cubit.state.slingshotIdentityFallbackEnabled, isFalse); 36 + expect(cubit.state.crashReportingEnabled, isFalse); 37 + expect(cubit.state.crashReportingConsentPrompted, isFalse); 36 38 expect(cubit.state.routingEpoch, 0); 37 39 }); 38 40 ··· 97 99 .having((s) => s.typeaheadProvider, 'typeaheadProvider', 'bluesky') 98 100 .having((s) => s.appViewProvider, 'appViewProvider', 'bluesky') 99 101 .having((s) => s.crossProviderFallbackEnabled, 'crossProviderFallbackEnabled', false) 100 - .having((s) => s.slingshotIdentityFallbackEnabled, 'slingshotIdentityFallbackEnabled', false), 102 + .having((s) => s.slingshotIdentityFallbackEnabled, 'slingshotIdentityFallbackEnabled', false) 103 + .having((s) => s.crashReportingEnabled, 'crashReportingEnabled', false) 104 + .having((s) => s.crashReportingConsentPrompted, 'crashReportingConsentPrompted', false), 101 105 ], 102 106 ); 103 107 ··· 382 386 isA<SettingsState>() 383 387 .having((s) => s.crossProviderFallbackEnabled, 'crossProviderFallbackEnabled', true) 384 388 .having((s) => s.slingshotIdentityFallbackEnabled, 'slingshotIdentityFallbackEnabled', true), 389 + ], 390 + ); 391 + 392 + blocTest<SettingsCubit, SettingsState>( 393 + 'setCrashReportingEnabled updates state and persists to database', 394 + build: () => SettingsCubit(database: database), 395 + act: (cubit) => cubit.setCrashReportingEnabled(true), 396 + expect: () => [isA<SettingsState>().having((s) => s.crashReportingEnabled, 'crashReportingEnabled', true)], 397 + verify: (_) async { 398 + expect(await database.getSetting('crash_reporting_enabled'), 'true'); 399 + }, 400 + ); 401 + 402 + blocTest<SettingsCubit, SettingsState>( 403 + 'setCrashReportingConsentPrompted updates state and persists to database', 404 + build: () => SettingsCubit(database: database), 405 + act: (cubit) => cubit.setCrashReportingConsentPrompted(true), 406 + expect: () => [ 407 + isA<SettingsState>().having((s) => s.crashReportingConsentPrompted, 'crashReportingConsentPrompted', true), 408 + ], 409 + verify: (_) async { 410 + expect(await database.getSetting('crash_reporting_consent_prompted'), 'true'); 411 + }, 412 + ); 413 + 414 + blocTest<SettingsCubit, SettingsState>( 415 + 'loadSettings restores crash reporting settings', 416 + build: () => SettingsCubit(database: database), 417 + setUp: () async { 418 + await database.setSetting('crash_reporting_enabled', 'true'); 419 + await database.setSetting('crash_reporting_consent_prompted', 'true'); 420 + }, 421 + act: (cubit) => cubit.loadSettings(), 422 + expect: () => [ 423 + isA<SettingsState>() 424 + .having((s) => s.crashReportingEnabled, 'crashReportingEnabled', true) 425 + .having((s) => s.crashReportingConsentPrompted, 'crashReportingConsentPrompted', true), 385 426 ], 386 427 ); 387 428
+9
test/features/settings/bloc/settings_state_test.dart
··· 151 151 appViewProvider: 'blacksky', 152 152 crossProviderFallbackEnabled: true, 153 153 slingshotIdentityFallbackEnabled: true, 154 + crashReportingEnabled: true, 155 + crashReportingConsentPrompted: true, 154 156 ); 155 157 156 158 expect(updated.themePalette, AppThemePalette.nord); ··· 163 165 expect(updated.appViewProvider, 'blacksky'); 164 166 expect(updated.crossProviderFallbackEnabled, isTrue); 165 167 expect(updated.slingshotIdentityFallbackEnabled, isTrue); 168 + expect(updated.crashReportingEnabled, isTrue); 169 + expect(updated.crashReportingConsentPrompted, isTrue); 166 170 expect(original.themePalette, AppThemePalette.oxocarbon); 167 171 }); 168 172 ··· 189 193 expect(updated.appViewProvider, 'bluesky'); 190 194 expect(updated.crossProviderFallbackEnabled, isFalse); 191 195 expect(updated.slingshotIdentityFallbackEnabled, isFalse); 196 + expect(updated.crashReportingEnabled, isFalse); 197 + expect(updated.crashReportingConsentPrompted, isFalse); 192 198 }); 193 199 194 200 test('copyWith can clear threadAutoCollapseDepth', () { ··· 223 229 expect(state.props, contains(true)); 224 230 expect(state.props, contains(6)); 225 231 expect(state.props, contains('bluesky')); 232 + expect(state.props, contains(false)); 226 233 expect(state.props, contains(false)); 227 234 }); 228 235 ··· 279 286 ); 280 287 expect(state.crossProviderFallbackEnabled, isFalse); 281 288 expect(state.slingshotIdentityFallbackEnabled, isFalse); 289 + expect(state.crashReportingEnabled, isFalse); 290 + expect(state.crashReportingConsentPrompted, isFalse); 282 291 }); 283 292 }); 284 293 }
+81 -14
test/features/settings/presentation/settings_screen_test.dart
··· 5 5 import 'package:flutter_bloc/flutter_bloc.dart'; 6 6 import 'package:flutter_test/flutter_test.dart'; 7 7 import 'package:go_router/go_router.dart'; 8 + import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; 8 9 import 'package:lazurite/core/database/app_database.dart'; 9 10 import 'package:lazurite/core/network/app_view_provider.dart'; 10 11 import 'package:lazurite/core/theme/app_theme.dart'; ··· 23 24 24 25 class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 25 26 27 + class FakeCrashReportingService implements CrashReportingService { 28 + var crashCalls = 0; 29 + 30 + @override 31 + void crash() { 32 + crashCalls += 1; 33 + } 34 + 35 + @override 36 + Future<void> deleteUnsentReports() async {} 37 + 38 + @override 39 + Future<void> recordError(Object error, StackTrace stackTrace, {bool fatal = false}) async {} 40 + 41 + @override 42 + void recordFlutterFatalError(FlutterErrorDetails details) {} 43 + 44 + @override 45 + Future<void> sendUnsentReports() async {} 46 + 47 + @override 48 + Future<void> setCollectionEnabled(bool enabled) async {} 49 + } 50 + 26 51 void main() { 27 52 late MockAccountSwitcherCubit accountSwitcherCubit; 28 53 late MockAuthBloc authBloc; 29 54 late MockSettingsCubit settingsCubit; 55 + late FakeCrashReportingService crashReportingService; 30 56 31 57 setUp(() { 32 58 accountSwitcherCubit = MockAccountSwitcherCubit(); 33 59 authBloc = MockAuthBloc(); 34 60 settingsCubit = MockSettingsCubit(); 61 + crashReportingService = FakeCrashReportingService(); 35 62 36 63 when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); 37 64 whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.unauthenticated()); ··· 62 89 ); 63 90 when(() => settingsCubit.setAppViewProvider(any())).thenAnswer((_) async {}); 64 91 when(() => settingsCubit.refreshAppViewHealth()).thenAnswer((_) async {}); 92 + when(() => settingsCubit.setCrashReportingEnabled(any())).thenAnswer((_) async {}); 93 + when(() => settingsCubit.setCrashReportingConsentPrompted(any())).thenAnswer((_) async {}); 65 94 }); 66 95 67 96 Widget buildSubject() { 68 - return MultiBlocProvider( 69 - providers: [ 70 - BlocProvider<AuthBloc>.value(value: authBloc), 71 - BlocProvider<AccountSwitcherCubit>.value(value: accountSwitcherCubit), 72 - BlocProvider<SettingsCubit>.value(value: settingsCubit), 73 - ], 74 - child: const MaterialApp(home: SettingsScreen()), 97 + return RepositoryProvider<CrashReportingService>.value( 98 + value: crashReportingService, 99 + child: MultiBlocProvider( 100 + providers: [ 101 + BlocProvider<AuthBloc>.value(value: authBloc), 102 + BlocProvider<AccountSwitcherCubit>.value(value: accountSwitcherCubit), 103 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 104 + ], 105 + child: const MaterialApp(home: SettingsScreen()), 106 + ), 75 107 ); 76 108 } 77 109 ··· 80 112 routes: [ 81 113 GoRoute( 82 114 path: '/', 83 - builder: (context, state) => MultiBlocProvider( 84 - providers: [ 85 - BlocProvider<AuthBloc>.value(value: authBloc), 86 - BlocProvider<AccountSwitcherCubit>.value(value: accountSwitcherCubit), 87 - BlocProvider<SettingsCubit>.value(value: settingsCubit), 88 - ], 89 - child: const SettingsScreen(), 115 + builder: (context, state) => RepositoryProvider<CrashReportingService>.value( 116 + value: crashReportingService, 117 + child: MultiBlocProvider( 118 + providers: [ 119 + BlocProvider<AuthBloc>.value(value: authBloc), 120 + BlocProvider<AccountSwitcherCubit>.value(value: accountSwitcherCubit), 121 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 122 + ], 123 + child: const SettingsScreen(), 124 + ), 90 125 ), 91 126 ), 92 127 GoRoute( ··· 261 296 expect(find.text('AppView Provider'), findsOneWidget); 262 297 expect(find.text('Cross-Provider Fallback'), findsOneWidget); 263 298 expect(find.text('Slingshot Identity Fallback'), findsOneWidget); 299 + expect(find.text('Crash Reporting'), findsOneWidget); 264 300 expect(find.text('Provider Diagnostics'), findsOneWidget); 265 301 expect(find.text('Refresh Provider Health'), findsOneWidget); 266 302 expect(find.byIcon(Icons.edit_outlined), findsNothing); 303 + }); 304 + 305 + testWidgets('crash reporting toggle persists consent and reporting state', (tester) async { 306 + await tester.pumpWidget(buildSubject()); 307 + await tester.pumpAndSettle(); 308 + 309 + await tester.scrollUntilVisible(find.text('Crash Reporting'), 300); 310 + await tester.pumpAndSettle(); 311 + 312 + final crashTile = find.ancestor(of: find.text('Crash Reporting'), matching: find.byType(ListTile)); 313 + await tester.ensureVisible(crashTile); 314 + final crashSwitch = find.descendant(of: crashTile, matching: find.byType(Switch)); 315 + expect(crashSwitch, findsOneWidget); 316 + await tester.tap(crashSwitch); 317 + await tester.pumpAndSettle(); 318 + 319 + verify(() => settingsCubit.setCrashReportingEnabled(true)).called(1); 320 + verify(() => settingsCubit.setCrashReportingConsentPrompted(true)).called(1); 321 + }); 322 + 323 + testWidgets('developer crash row triggers crash reporting test crash', (tester) async { 324 + await tester.pumpWidget(buildSubject()); 325 + await tester.pumpAndSettle(); 326 + 327 + await tester.scrollUntilVisible(find.text('Crashlytics Test Crash'), 300); 328 + await tester.pumpAndSettle(); 329 + 330 + await tester.tap(find.text('Crashlytics Test Crash')); 331 + await tester.pumpAndSettle(); 332 + 333 + expect(crashReportingService.crashCalls, 1); 267 334 }); 268 335 269 336 testWidgets('provider change confirmation can be cancelled', (tester) async {