diff --git a/SPEC.md b/SPEC.md index a8291c9..ba4f972 100644 --- a/SPEC.md +++ b/SPEC.md @@ -53,7 +53,8 @@ ## Observability - firebase_core (Firebase initialization) -- firebase_crashlytics (opt-in crash reporting) +- firebase_crashlytics (crash reporting, enabled by default) +- firebase_performance (performance monitoring, enabled by default) ## Internationalization diff --git a/lib/features/observability/data/crashlytics_preference_storage.dart b/lib/features/observability/data/crashlytics_preference_storage.dart index 23e6ca3..6d13e25 100644 --- a/lib/features/observability/data/crashlytics_preference_storage.dart +++ b/lib/features/observability/data/crashlytics_preference_storage.dart @@ -10,10 +10,10 @@ class CrashlyticsPreferenceStorage { bool read() { try { - return _prefs.getBool(_key) ?? false; + return _prefs.getBool(_key) ?? true; } catch (error, stack) { debugPrint('Failed to read crashlytics preference: $error\n$stack'); - return false; + return true; } } diff --git a/lib/features/observability/data/performance_preference_storage.dart b/lib/features/observability/data/performance_preference_storage.dart new file mode 100644 index 0000000..68dc5a0 --- /dev/null +++ b/lib/features/observability/data/performance_preference_storage.dart @@ -0,0 +1,26 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class PerformancePreferenceStorage { + PerformancePreferenceStorage(this._prefs); + + static const _key = 'observability_performance_enabled'; + + final SharedPreferences _prefs; + + bool read() { + try { + return _prefs.getBool(_key) ?? true; + } catch (error, stack) { + debugPrint('Failed to read performance preference: $error\n$stack'); + return true; + } + } + + Future write(bool enabled) async { + final didWrite = await _prefs.setBool(_key, enabled); + if (!didWrite) { + throw Exception('Failed to persist performance preference.'); + } + } +} diff --git a/lib/features/observability/presentation/crashlytics_toggle_tile.dart b/lib/features/observability/presentation/crashlytics_toggle_tile.dart index 1b669fe..1ea61ba 100644 --- a/lib/features/observability/presentation/crashlytics_toggle_tile.dart +++ b/lib/features/observability/presentation/crashlytics_toggle_tile.dart @@ -11,7 +11,7 @@ class CrashlyticsToggleTile extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final crashlyticsAsync = ref.watch(crashlyticsPreferenceProvider); - final enabled = crashlyticsAsync.valueOrNull ?? false; + final enabled = crashlyticsAsync.valueOrNull ?? true; return SwitchListTile( secondary: const Icon(Icons.bug_report_outlined), diff --git a/lib/features/observability/presentation/observability_section.dart b/lib/features/observability/presentation/observability_section.dart index 94b6027..99803db 100644 --- a/lib/features/observability/presentation/observability_section.dart +++ b/lib/features/observability/presentation/observability_section.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'crashlytics_toggle_tile.dart'; +import 'performance_toggle_tile.dart'; class ObservabilitySection extends StatelessWidget { const ObservabilitySection({super.key}); @@ -12,6 +13,8 @@ class ObservabilitySection extends StatelessWidget { children: [ CrashlyticsToggleTile(), Divider(height: 1), + PerformanceToggleTile(), + Divider(height: 1), ], ); } diff --git a/lib/features/observability/presentation/performance_toggle_tile.dart b/lib/features/observability/presentation/performance_toggle_tile.dart new file mode 100644 index 0000000..9c1ac14 --- /dev/null +++ b/lib/features/observability/presentation/performance_toggle_tile.dart @@ -0,0 +1,40 @@ +import 'package:cookmate/l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers.dart'; + +class PerformanceToggleTile extends ConsumerWidget { + const PerformanceToggleTile({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final performanceAsync = ref.watch(performancePreferenceProvider); + final enabled = performanceAsync.valueOrNull ?? true; + + return SwitchListTile( + secondary: const Icon(Icons.speed), + title: Text(l10n.settingsPerformanceTitle), + subtitle: Text(l10n.settingsPerformanceDescription), + value: enabled, + onChanged: performanceAsync.isLoading ? null : (value) async { + try { + await ref + .read(performancePreferenceProvider.notifier) + .setPreference(value); + } catch (error, stack) { + debugPrint('Failed to change performance: $error\n$stack'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text(l10n.settingsPerformanceChangeFailureSnackbar), + ), + ); + } + } + }, + ); + } +} diff --git a/lib/features/observability/providers.dart b/lib/features/observability/providers.dart index f3f3a5d..b741b64 100644 --- a/lib/features/observability/providers.dart +++ b/lib/features/observability/providers.dart @@ -1,9 +1,11 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:firebase_performance/firebase_performance.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/shared_preferences_provider.dart'; import 'data/crashlytics_preference_storage.dart'; +import 'data/performance_preference_storage.dart'; final crashlyticsPreferenceStorageProvider = FutureProvider((ref) async { @@ -42,3 +44,41 @@ final crashlyticsPreferenceProvider = AsyncNotifierProvider( CrashlyticsPreferenceNotifier.new, ); + +final performancePreferenceStorageProvider = + FutureProvider((ref) async { + final prefs = await ref.watch(sharedPreferencesProvider.future); + return PerformancePreferenceStorage(prefs); +}); + +class PerformancePreferenceNotifier extends AsyncNotifier { + @override + Future build() async { + final storage = + await ref.watch(performancePreferenceStorageProvider.future); + return storage.read(); + } + + Future setPreference(bool enabled) async { + final storage = + await ref.read(performancePreferenceStorageProvider.future); + state = const AsyncValue.loading().copyWithPrevious(state); + try { + await storage.write(enabled); + if (Firebase.apps.isNotEmpty) { + await FirebasePerformance.instance + .setPerformanceCollectionEnabled(enabled); + } + state = AsyncValue.data(enabled); + } catch (error, stack) { + state = + AsyncValue.error(error, stack).copyWithPrevious(state); + rethrow; + } + } +} + +final performancePreferenceProvider = + AsyncNotifierProvider( + PerformancePreferenceNotifier.new, +); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 6802a7a..22effc6 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -147,5 +147,9 @@ "settingsSectionObservability": "Beobachtbarkeit", "settingsCrashlyticsTitle": "Absturzbericht", "settingsCrashlyticsDescription": "Anonyme Absturzberichte senden, um die App zu verbessern", - "settingsCrashlyticsChangeFailureSnackbar": "Absturzbericht-Einstellung konnte nicht geändert werden. Bitte versuchen Sie es erneut." + "settingsCrashlyticsChangeFailureSnackbar": "Absturzbericht-Einstellung konnte nicht geändert werden. Bitte versuchen Sie es erneut.", + + "settingsPerformanceTitle": "Leistungsüberwachung", + "settingsPerformanceDescription": "Anonyme Leistungsdaten sammeln, um die App zu verbessern", + "settingsPerformanceChangeFailureSnackbar": "Leistungsüberwachung-Einstellung konnte nicht geändert werden. Bitte versuchen Sie es erneut." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fbe6a24..e2299c1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -452,5 +452,14 @@ "@settingsCrashlyticsDescription": { "description": "Subtitle explaining what crash reporting does." }, "settingsCrashlyticsChangeFailureSnackbar": "Couldn't change crash reporting setting. Please try again.", - "@settingsCrashlyticsChangeFailureSnackbar": { "description": "Shown when persisting the crash reporting toggle fails." } + "@settingsCrashlyticsChangeFailureSnackbar": { "description": "Shown when persisting the crash reporting toggle fails." }, + + "settingsPerformanceTitle": "Performance monitoring", + "@settingsPerformanceTitle": { "description": "Title of the performance monitoring toggle tile." }, + + "settingsPerformanceDescription": "Collect anonymous performance data to help improve the app", + "@settingsPerformanceDescription": { "description": "Subtitle explaining what performance monitoring does." }, + + "settingsPerformanceChangeFailureSnackbar": "Couldn't change performance monitoring setting. Please try again.", + "@settingsPerformanceChangeFailureSnackbar": { "description": "Shown when persisting the performance monitoring toggle fails." } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index b9156ac..ac462ce 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -147,5 +147,9 @@ "settingsSectionObservability": "Observabilidad", "settingsCrashlyticsTitle": "Informe de fallos", "settingsCrashlyticsDescription": "Enviar informes de fallos anónimos para mejorar la aplicación", - "settingsCrashlyticsChangeFailureSnackbar": "No se pudo cambiar la configuración de informe de fallos. Inténtelo de nuevo." + "settingsCrashlyticsChangeFailureSnackbar": "No se pudo cambiar la configuración de informe de fallos. Inténtelo de nuevo.", + + "settingsPerformanceTitle": "Monitoreo de rendimiento", + "settingsPerformanceDescription": "Recopilar datos de rendimiento anónimos para mejorar la aplicación", + "settingsPerformanceChangeFailureSnackbar": "No se pudo cambiar la configuración de monitoreo de rendimiento. Inténtelo de nuevo." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 09993e7..ad2c708 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -147,5 +147,9 @@ "settingsSectionObservability": "Observabilité", "settingsCrashlyticsTitle": "Rapport de plantage", "settingsCrashlyticsDescription": "Envoyer des rapports de plantage anonymes pour améliorer l'application", - "settingsCrashlyticsChangeFailureSnackbar": "Impossible de modifier le paramètre de rapport de plantage. Veuillez réessayer." + "settingsCrashlyticsChangeFailureSnackbar": "Impossible de modifier le paramètre de rapport de plantage. Veuillez réessayer.", + + "settingsPerformanceTitle": "Rapport de performance", + "settingsPerformanceDescription": "Collecter des données de performance anonymes pour améliorer l'application", + "settingsPerformanceChangeFailureSnackbar": "Impossible de modifier le paramètre de performance. Veuillez réessayer." } diff --git a/lib/main.dart b/lib/main.dart index 028bfcf..c79d246 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:firebase_performance/firebase_performance.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gemma/flutter_gemma.dart'; @@ -10,6 +11,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; import 'features/observability/data/crashlytics_preference_storage.dart'; +import 'features/observability/data/performance_preference_storage.dart'; void main() { runZonedGuarded( @@ -31,6 +33,15 @@ void main() { FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError; + + try { + final performanceEnabled = + PerformancePreferenceStorage(prefs).read(); + await FirebasePerformance.instance + .setPerformanceCollectionEnabled(performanceEnabled); + } catch (e, stack) { + debugPrint('Firebase Performance init skipped: $e\n$stack'); + } } catch (e, stack) { debugPrint('Firebase init skipped: $e\n$stack'); } diff --git a/pubspec.lock b/pubspec.lock index 036f1cf..566331f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -265,6 +265,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.8.10" + firebase_performance: + dependency: "direct main" + description: + name: firebase_performance + sha256: b6948a02cb06d5da36e9f91f4a96b39745ab7565942a8134b7692ea6fc4ac595 + url: "https://pub.dev" + source: hosted + version: "0.10.1+10" + firebase_performance_platform_interface: + dependency: transitive + description: + name: firebase_performance_platform_interface + sha256: "836b219dc99c59ee5b854ba4f6432781f40ae75b31ea112f3b8b2ffce8a01cd4" + url: "https://pub.dev" + source: hosted + version: "0.1.5+10" + firebase_performance_web: + dependency: transitive + description: + name: firebase_performance_web + sha256: "7b8cdc99f8c7ceafd6c3f51a3a661aef96bc9c5948af78d35bc9660941ed5b2f" + url: "https://pub.dev" + source: hosted + version: "0.1.7+16" fixnum: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2ed7723..58d0419 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: http: ^1.4.0 firebase_core: ^3.13.0 firebase_crashlytics: ^4.3.5 + firebase_performance: ^0.10.0+12 dev_dependencies: flutter_test: diff --git a/test/features/observability/data/crashlytics_preference_storage_test.dart b/test/features/observability/data/crashlytics_preference_storage_test.dart index 0e26efc..2e3a7e4 100644 --- a/test/features/observability/data/crashlytics_preference_storage_test.dart +++ b/test/features/observability/data/crashlytics_preference_storage_test.dart @@ -13,8 +13,8 @@ void main() { storage = CrashlyticsPreferenceStorage(prefs); }); - test('read returns false when nothing is stored', () { - expect(storage.read(), false); + test('read returns true when nothing is stored', () { + expect(storage.read(), true); }); test('write then read returns the written value', () async { @@ -28,12 +28,12 @@ void main() { expect(storage.read(), false); }); - test('read returns false when stored value is corrupted', () async { + test('read returns true when stored value is corrupted', () async { SharedPreferences.setMockInitialValues({ 'observability_crashlytics_enabled': 'not_a_bool', }); final prefs = await SharedPreferences.getInstance(); final s = CrashlyticsPreferenceStorage(prefs); - expect(s.read(), false); + expect(s.read(), true); }); } diff --git a/test/features/observability/data/performance_preference_storage_test.dart b/test/features/observability/data/performance_preference_storage_test.dart new file mode 100644 index 0000000..b2cf9d5 --- /dev/null +++ b/test/features/observability/data/performance_preference_storage_test.dart @@ -0,0 +1,39 @@ +import 'package:cookmate/features/observability/data/performance_preference_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late PerformancePreferenceStorage storage; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + storage = PerformancePreferenceStorage(prefs); + }); + + test('read returns true when nothing is stored', () { + expect(storage.read(), true); + }); + + test('write then read returns the written value', () async { + await storage.write(false); + expect(storage.read(), false); + }); + + test('write overwrites a previous value', () async { + await storage.write(false); + await storage.write(true); + expect(storage.read(), true); + }); + + test('read returns true when stored value is corrupted', () async { + SharedPreferences.setMockInitialValues({ + 'observability_performance_enabled': 'not_a_bool', + }); + final prefs = await SharedPreferences.getInstance(); + final s = PerformancePreferenceStorage(prefs); + expect(s.read(), true); + }); +} diff --git a/test/features/observability/presentation/crashlytics_toggle_tile_test.dart b/test/features/observability/presentation/crashlytics_toggle_tile_test.dart index f8eef97..6d3b00f 100644 --- a/test/features/observability/presentation/crashlytics_toggle_tile_test.dart +++ b/test/features/observability/presentation/crashlytics_toggle_tile_test.dart @@ -23,7 +23,7 @@ class _FakeNotifier extends CrashlyticsPreferenceNotifier { } } -Widget _wrap(Widget child, {bool initialValue = false}) { +Widget _wrap(Widget child, {bool initialValue = true}) { return ProviderScope( overrides: [ crashlyticsPreferenceProvider.overrideWith( @@ -60,16 +60,16 @@ void main() { expect(find.text(l10n.settingsCrashlyticsDescription), findsOneWidget); }); - testWidgets('switch defaults to off', (tester) async { + testWidgets('switch reflects provided on value', (tester) async { SharedPreferences.setMockInitialValues({}); await tester.pumpWidget( - _wrap(const CrashlyticsToggleTile()), + _wrap(const CrashlyticsToggleTile(), initialValue: true), ); await tester.pumpAndSettle(); final switchWidget = tester.widget(find.byType(Switch)); - expect(switchWidget.value, false); + expect(switchWidget.value, true); }); testWidgets('switch reflects stored true value', (tester) async { @@ -86,7 +86,7 @@ void main() { expect(switchWidget.value, true); }); - testWidgets('toggling switch persists true', (tester) async { + testWidgets('toggling switch persists false', (tester) async { SharedPreferences.setMockInitialValues({}); await tester.pumpWidget( @@ -98,7 +98,7 @@ void main() { await tester.pumpAndSettle(); final prefs = await SharedPreferences.getInstance(); - expect(prefs.getBool('observability_crashlytics_enabled'), true); + expect(prefs.getBool('observability_crashlytics_enabled'), false); }); testWidgets('shows bug report icon', (tester) async { diff --git a/test/features/observability/presentation/observability_section_test.dart b/test/features/observability/presentation/observability_section_test.dart index ffd0b59..18d41a2 100644 --- a/test/features/observability/presentation/observability_section_test.dart +++ b/test/features/observability/presentation/observability_section_test.dart @@ -1,5 +1,6 @@ import 'package:cookmate/features/observability/presentation/crashlytics_toggle_tile.dart'; import 'package:cookmate/features/observability/presentation/observability_section.dart'; +import 'package:cookmate/features/observability/presentation/performance_toggle_tile.dart'; import 'package:cookmate/features/observability/providers.dart'; import 'package:cookmate/l10n/app_localizations.dart'; import 'package:flutter/material.dart'; @@ -17,10 +18,22 @@ class _FakeNotifier extends CrashlyticsPreferenceNotifier { } } +class _FakePerformanceNotifier extends PerformancePreferenceNotifier { + @override + Future build() async => true; + + @override + Future setPreference(bool enabled) async { + state = AsyncValue.data(enabled); + } +} + Widget _wrap(Widget child) { return ProviderScope( overrides: [ crashlyticsPreferenceProvider.overrideWith(() => _FakeNotifier()), + performancePreferenceProvider + .overrideWith(() => _FakePerformanceNotifier()), ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -43,12 +56,21 @@ void main() { expect(find.byType(CrashlyticsToggleTile), findsOneWidget); }); - testWidgets('contains a Divider', (tester) async { + testWidgets('contains PerformanceToggleTile', (tester) async { + SharedPreferences.setMockInitialValues({}); + + await tester.pumpWidget(_wrap(const ObservabilitySection())); + await tester.pumpAndSettle(); + + expect(find.byType(PerformanceToggleTile), findsOneWidget); + }); + + testWidgets('contains Dividers between tiles', (tester) async { SharedPreferences.setMockInitialValues({}); await tester.pumpWidget(_wrap(const ObservabilitySection())); await tester.pumpAndSettle(); - expect(find.byType(Divider), findsOneWidget); + expect(find.byType(Divider), findsNWidgets(2)); }); } diff --git a/test/features/observability/presentation/performance_toggle_tile_test.dart b/test/features/observability/presentation/performance_toggle_tile_test.dart new file mode 100644 index 0000000..9c57b15 --- /dev/null +++ b/test/features/observability/presentation/performance_toggle_tile_test.dart @@ -0,0 +1,114 @@ +import 'package:cookmate/features/observability/presentation/performance_toggle_tile.dart'; +import 'package:cookmate/features/observability/providers.dart'; +import 'package:cookmate/l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeNotifier extends PerformancePreferenceNotifier { + _FakeNotifier(this._initial); + final bool _initial; + + @override + Future build() async => _initial; + + @override + Future setPreference(bool enabled) async { + final storage = + await ref.read(performancePreferenceStorageProvider.future); + state = const AsyncValue.loading().copyWithPrevious(state); + await storage.write(enabled); + state = AsyncValue.data(enabled); + } +} + +Widget _wrap(Widget child, {bool initialValue = true}) { + return ProviderScope( + overrides: [ + performancePreferenceProvider.overrideWith( + () => _FakeNotifier(initialValue), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Scaffold(body: child), + ), + ); +} + +AppLocalizations _l10n(WidgetTester tester) { + final context = tester.element(find.byType(Scaffold)); + return AppLocalizations.of(context); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('shows title and description', (tester) async { + SharedPreferences.setMockInitialValues({}); + + await tester.pumpWidget( + _wrap(const PerformanceToggleTile()), + ); + await tester.pumpAndSettle(); + + final l10n = _l10n(tester); + expect(find.text(l10n.settingsPerformanceTitle), findsOneWidget); + expect(find.text(l10n.settingsPerformanceDescription), findsOneWidget); + }); + + testWidgets('switch defaults to on', (tester) async { + SharedPreferences.setMockInitialValues({}); + + await tester.pumpWidget( + _wrap(const PerformanceToggleTile(), initialValue: true), + ); + await tester.pumpAndSettle(); + + final switchWidget = tester.widget(find.byType(Switch)); + expect(switchWidget.value, true); + }); + + testWidgets('switch reflects provided false value', (tester) async { + SharedPreferences.setMockInitialValues({ + 'observability_performance_enabled': false, + }); + + await tester.pumpWidget( + _wrap(const PerformanceToggleTile(), initialValue: false), + ); + await tester.pumpAndSettle(); + + final switchWidget = tester.widget(find.byType(Switch)); + expect(switchWidget.value, false); + }); + + testWidgets('toggling switch persists false', (tester) async { + SharedPreferences.setMockInitialValues({}); + + await tester.pumpWidget( + _wrap(const PerformanceToggleTile()), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('observability_performance_enabled'), false); + }); + + testWidgets('shows speed icon', (tester) async { + SharedPreferences.setMockInitialValues({}); + + await tester.pumpWidget( + _wrap(const PerformanceToggleTile()), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.speed), findsOneWidget); + }); +}