diff --git a/SPEC.md b/SPEC.md index 98209b6..a8291c9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -50,6 +50,11 @@ - record (live audio recording) - just_audio (audio playback in chat bubbles) +## Observability + +- firebase_core (Firebase initialization) +- firebase_crashlytics (opt-in crash reporting) + ## Internationalization - flutter_localizations (ARB-based, 4 locales: en, fr, de, es) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c876d03..23e3a1c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,6 +6,8 @@ plugins { id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") + id("com.google.gms.google-services") + id("com.google.firebase.crashlytics") } val keystorePropertiesFile = rootProject.file("key.properties") diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000..49de7fe --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "691874426189", + "project_id": "cookmate-d8571", + "storage_bucket": "cookmate-d8571.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:691874426189:android:344aae686c918f34b49b04", + "android_client_info": { + "package_name": "com.cookmate.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyCrNYwZRbCxStw-UJ5ewvRka7r516T7MK0" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ca7fe06..a93e3f9 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -21,6 +21,8 @@ plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.11.1" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false + id("com.google.gms.google-services") version "4.4.2" apply false + id("com.google.firebase.crashlytics") version "3.0.4" apply false } include(":app") diff --git a/docs/superpowers/plans/2026-04-21-firebase-crashlytics.md b/docs/superpowers/plans/2026-04-21-firebase-crashlytics.md new file mode 100644 index 0000000..fc2ffa8 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-firebase-crashlytics.md @@ -0,0 +1,559 @@ +# Firebase Crashlytics Integration — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Firebase Crashlytics with an opt-in toggle in a new Observability settings section. + +**Architecture:** New `lib/features/observability/` module following the existing Riverpod + SharedPreferences pattern. Firebase initialized at app startup; crash collection controlled by user preference (default off). Settings UI adds an extensible Observability section between General and Actions. + +**Tech Stack:** firebase_core, firebase_crashlytics, flutter_riverpod, shared_preferences + +--- + +## Prerequisites (manual) + +The user must place the Firebase config files (these are gitignored and cannot be generated by code): +- `android/app/google-services.json` — download from Firebase Console +- `ios/Runner/GoogleService-Info.plist` — download from Firebase Console + +--- + +## File Map + +| Action | Path | Responsibility | +|--------|------|----------------| +| Create | `lib/features/observability/data/crashlytics_preference_storage.dart` | SharedPreferences read/write for crashlytics toggle | +| Create | `lib/features/observability/providers.dart` | Riverpod AsyncNotifier + storage provider | +| Create | `lib/features/observability/presentation/crashlytics_toggle_tile.dart` | SwitchListTile widget | +| Create | `lib/features/observability/presentation/observability_section.dart` | Section widget grouping observability tiles | +| Modify | `lib/features/settings/presentation/settings_page.dart` | Insert Observability section between General and Actions | +| Modify | `lib/main.dart` | Initialize Firebase + Crashlytics error handlers | +| Modify | `pubspec.yaml` | Add firebase_core and firebase_crashlytics dependencies | +| Modify | `android/settings.gradle.kts` | Add Google Services Gradle plugin | +| Modify | `android/app/build.gradle.kts` | Apply google-services and crashlytics plugins | +| Modify | `lib/l10n/app_en.arb` | Add Observability + Crashlytics ARB keys (EN) | +| Modify | `lib/l10n/app_fr.arb` | Add Observability + Crashlytics ARB keys (FR) | +| Modify | `lib/l10n/app_de.arb` | Add Observability + Crashlytics ARB keys (DE) | +| Modify | `lib/l10n/app_es.arb` | Add Observability + Crashlytics ARB keys (ES) | +| Modify | `SPEC.md` | Add Firebase Crashlytics to tech stack | + +--- + +### Task 1: Add Flutter dependencies + +**Files:** +- Modify: `pubspec.yaml:30-53` (dependencies block) + +- [ ] **Step 1: Add firebase_core and firebase_crashlytics to pubspec.yaml** + +In the `dependencies:` block, after `http: ^1.4.0`, add: + +```yaml + firebase_core: ^3.13.0 + firebase_crashlytics: ^4.3.5 +``` + +- [ ] **Step 2: Run flutter pub get** + +Run: `flutter pub get` +Expected: resolves without errors. + +- [ ] **Step 3: Commit** + +```bash +git add pubspec.yaml pubspec.lock +git commit -m "build(deps): add firebase_core and firebase_crashlytics" +``` + +--- + +### Task 2: Configure Android Gradle plugins + +**Files:** +- Modify: `android/settings.gradle.kts:21-26` (plugins block) +- Modify: `android/app/build.gradle.kts:4-9` (plugins block) + +- [ ] **Step 1: Add Google Services and Crashlytics Gradle plugins to settings.gradle.kts** + +In the `plugins` block of `android/settings.gradle.kts`, add after the existing entries: + +```kotlin +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false + id("com.google.gms.google-services") version "4.4.2" apply false + id("com.google.firebase.crashlytics") version "3.0.4" apply false +} +``` + +- [ ] **Step 2: Apply plugins in app/build.gradle.kts** + +In the `plugins` block of `android/app/build.gradle.kts`, add after the existing entries: + +```kotlin +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") + id("com.google.gms.google-services") + id("com.google.firebase.crashlytics") +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add android/settings.gradle.kts android/app/build.gradle.kts +git commit -m "build(android): add Firebase and Crashlytics Gradle plugins" +``` + +--- + +### Task 3: Add ARB localization keys + +**Files:** +- Modify: `lib/l10n/app_en.arb` +- Modify: `lib/l10n/app_fr.arb` +- Modify: `lib/l10n/app_de.arb` +- Modify: `lib/l10n/app_es.arb` + +- [ ] **Step 1: Add keys to app_en.arb** + +Before the closing `}`, add: + +```json + "settingsSectionObservability": "Observability", + "@settingsSectionObservability": { "description": "Section header for observability settings." }, + + "settingsCrashlyticsTitle": "Crash reporting", + "@settingsCrashlyticsTitle": { "description": "Title of the crash reporting toggle tile." }, + + "settingsCrashlyticsDescription": "Send anonymous crash reports to help improve the app", + "@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." } +``` + +- [ ] **Step 2: Add keys to app_fr.arb** + +Before the closing `}`, add: + +```json + "settingsSectionObservability": "Observabilité", + "@settingsSectionObservability": { "description": "Section header for observability settings." }, + + "settingsCrashlyticsTitle": "Rapport de plantage", + "@settingsCrashlyticsTitle": { "description": "Title of the crash reporting toggle tile." }, + + "settingsCrashlyticsDescription": "Envoyer des rapports de plantage anonymes pour améliorer l'application", + "@settingsCrashlyticsDescription": { "description": "Subtitle explaining what crash reporting does." }, + + "settingsCrashlyticsChangeFailureSnackbar": "Impossible de modifier le paramètre de rapport de plantage. Veuillez réessayer.", + "@settingsCrashlyticsChangeFailureSnackbar": { "description": "Shown when persisting the crash reporting toggle fails." } +``` + +- [ ] **Step 3: Add keys to app_de.arb** + +Before the closing `}`, add: + +```json + "settingsSectionObservability": "Beobachtbarkeit", + "@settingsSectionObservability": { "description": "Section header for observability settings." }, + + "settingsCrashlyticsTitle": "Absturzbericht", + "@settingsCrashlyticsTitle": { "description": "Title of the crash reporting toggle tile." }, + + "settingsCrashlyticsDescription": "Anonyme Absturzberichte senden, um die App zu verbessern", + "@settingsCrashlyticsDescription": { "description": "Subtitle explaining what crash reporting does." }, + + "settingsCrashlyticsChangeFailureSnackbar": "Absturzbericht-Einstellung konnte nicht geändert werden. Bitte versuchen Sie es erneut.", + "@settingsCrashlyticsChangeFailureSnackbar": { "description": "Shown when persisting the crash reporting toggle fails." } +``` + +- [ ] **Step 4: Add keys to app_es.arb** + +Before the closing `}`, add: + +```json + "settingsSectionObservability": "Observabilidad", + "@settingsSectionObservability": { "description": "Section header for observability settings." }, + + "settingsCrashlyticsTitle": "Informe de fallos", + "@settingsCrashlyticsTitle": { "description": "Title of the crash reporting toggle tile." }, + + "settingsCrashlyticsDescription": "Enviar informes de fallos anónimos para mejorar la aplicación", + "@settingsCrashlyticsDescription": { "description": "Subtitle explaining what crash reporting does." }, + + "settingsCrashlyticsChangeFailureSnackbar": "No se pudo cambiar la configuración de informe de fallos. Inténtelo de nuevo.", + "@settingsCrashlyticsChangeFailureSnackbar": { "description": "Shown when persisting the crash reporting toggle fails." } +``` + +- [ ] **Step 5: Run flutter gen-l10n to regenerate** + +Run: `flutter gen-l10n` +Expected: generates updated `lib/l10n/app_localizations*.dart` files without errors. + +- [ ] **Step 6: Commit** + +```bash +git add lib/l10n/ +git commit -m "feat(l10n): add observability and crashlytics localization keys" +``` + +--- + +### Task 4: Create crashlytics preference storage + +**Files:** +- Create: `lib/features/observability/data/crashlytics_preference_storage.dart` + +- [ ] **Step 1: Create the storage class** + +Create `lib/features/observability/data/crashlytics_preference_storage.dart`: + +```dart +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class CrashlyticsPreferenceStorage { + CrashlyticsPreferenceStorage(this._prefs); + + static const _key = 'observability_crashlytics_enabled'; + + final SharedPreferences _prefs; + + bool read() { + try { + return _prefs.getBool(_key) ?? false; + } catch (error, stack) { + debugPrint('Failed to read crashlytics preference: $error\n$stack'); + return false; + } + } + + Future write(bool enabled) async { + final didWrite = await _prefs.setBool(_key, enabled); + if (!didWrite) { + throw Exception('Failed to persist crashlytics preference.'); + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add lib/features/observability/data/crashlytics_preference_storage.dart +git commit -m "feat(observability): add crashlytics preference storage" +``` + +--- + +### Task 5: Create Riverpod providers + +**Files:** +- Create: `lib/features/observability/providers.dart` + +- [ ] **Step 1: Create the providers file** + +Create `lib/features/observability/providers.dart`: + +```dart +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/shared_preferences_provider.dart'; +import 'data/crashlytics_preference_storage.dart'; + +final crashlyticsPreferenceStorageProvider = + FutureProvider((ref) async { + final prefs = await ref.watch(sharedPreferencesProvider.future); + return CrashlyticsPreferenceStorage(prefs); +}); + +class CrashlyticsPreferenceNotifier extends AsyncNotifier { + @override + Future build() async { + final storage = + await ref.watch(crashlyticsPreferenceStorageProvider.future); + return storage.read(); + } + + Future setPreference(bool enabled) async { + final storage = + await ref.read(crashlyticsPreferenceStorageProvider.future); + state = const AsyncValue.loading().copyWithPrevious(state); + try { + await storage.write(enabled); + await FirebaseCrashlytics.instance + .setCrashlyticsCollectionEnabled(enabled); + state = AsyncValue.data(enabled); + } catch (error, stack) { + state = + AsyncValue.error(error, stack).copyWithPrevious(state); + rethrow; + } + } +} + +final crashlyticsPreferenceProvider = + AsyncNotifierProvider( + CrashlyticsPreferenceNotifier.new, +); +``` + +- [ ] **Step 2: Commit** + +```bash +git add lib/features/observability/providers.dart +git commit -m "feat(observability): add crashlytics preference provider" +``` + +--- + +### Task 6: Create the toggle tile widget + +**Files:** +- Create: `lib/features/observability/presentation/crashlytics_toggle_tile.dart` + +- [ ] **Step 1: Create the widget** + +Create `lib/features/observability/presentation/crashlytics_toggle_tile.dart`: + +```dart +import 'package:cookmate/l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers.dart'; + +class CrashlyticsToggleTile extends ConsumerWidget { + const CrashlyticsToggleTile({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final crashlyticsAsync = ref.watch(crashlyticsPreferenceProvider); + final enabled = crashlyticsAsync.valueOrNull ?? false; + + return SwitchListTile( + secondary: const Icon(Icons.bug_report_outlined), + title: Text(l10n.settingsCrashlyticsTitle), + subtitle: Text(l10n.settingsCrashlyticsDescription), + value: enabled, + onChanged: (value) async { + try { + await ref + .read(crashlyticsPreferenceProvider.notifier) + .setPreference(value); + } catch (error, stack) { + debugPrint('Failed to change crashlytics: $error\n$stack'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text(l10n.settingsCrashlyticsChangeFailureSnackbar), + ), + ); + } + } + }, + ); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add lib/features/observability/presentation/crashlytics_toggle_tile.dart +git commit -m "feat(observability): add crashlytics toggle tile widget" +``` + +--- + +### Task 7: Create the observability section widget + +**Files:** +- Create: `lib/features/observability/presentation/observability_section.dart` + +- [ ] **Step 1: Create the section widget** + +Create `lib/features/observability/presentation/observability_section.dart`: + +```dart +import 'package:flutter/material.dart'; + +import 'crashlytics_toggle_tile.dart'; + +class ObservabilitySection extends StatelessWidget { + const ObservabilitySection({super.key}); + + @override + Widget build(BuildContext context) { + return const Column( + mainAxisSize: MainAxisSize.min, + children: [ + CrashlyticsToggleTile(), + Divider(height: 1), + ], + ); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add lib/features/observability/presentation/observability_section.dart +git commit -m "feat(observability): add observability section widget" +``` + +--- + +### Task 8: Wire Observability section into settings page + +**Files:** +- Modify: `lib/features/settings/presentation/settings_page.dart:1-19` (imports), `78-81` (between General and Actions) + +- [ ] **Step 1: Add import** + +In `settings_page.dart`, add this import after the existing feature imports: + +```dart +import '../../observability/presentation/observability_section.dart'; +``` + +- [ ] **Step 2: Insert Observability section between General and Actions** + +In the `ListView.children` list, after the `ThemePickerTile()` and its `Divider`, and before the Actions section `Padding`, insert: + +```dart + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text(l10n.settingsSectionObservability, style: sectionStyle), + ), + const ObservabilitySection(), +``` + +The order should be: `ThemePickerTile` → `Divider` → **Observability header** → **ObservabilitySection** → Actions header. + +- [ ] **Step 3: Verify the app builds** + +Run: `flutter build apk --debug` +Expected: builds without errors (will fail if google-services.json is missing — that is expected in CI but should work locally if the user has placed the file). + +- [ ] **Step 4: Commit** + +```bash +git add lib/features/settings/presentation/settings_page.dart +git commit -m "feat(settings): add observability section between general and actions" +``` + +--- + +### Task 9: Initialize Firebase and Crashlytics in main.dart + +**Files:** +- Modify: `lib/main.dart` + +- [ ] **Step 1: Update main.dart with Firebase initialization** + +Replace the full contents of `lib/main.dart` with: + +```dart +import 'dart:async'; + +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_gemma/flutter_gemma.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'app.dart'; +import 'features/observability/data/crashlytics_preference_storage.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + + await Firebase.initializeApp(); + + // Read crashlytics preference before runApp so error handlers are set early. + final prefs = await SharedPreferences.getInstance(); + final crashlyticsEnabled = CrashlyticsPreferenceStorage(prefs).read(); + await FirebaseCrashlytics.instance + .setCrashlyticsCollectionEnabled(crashlyticsEnabled); + + // Forward Flutter framework errors to Crashlytics. + FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError; + + await FlutterGemma.initialize(); + + runZonedGuarded( + () => runApp(const ProviderScope(child: CookmateApp())), + (error, stack) => + FirebaseCrashlytics.instance.recordError(error, stack, fatal: true), + ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add lib/main.dart +git commit -m "feat(observability): initialize Firebase and Crashlytics at app startup" +``` + +--- + +### Task 10: Update SPEC.md + +**Files:** +- Modify: `SPEC.md` + +- [ ] **Step 1: Add Firebase Crashlytics to SPEC.md** + +After the `## Chat UI` section (or at a logical place), add: + +```markdown +## Observability + +- firebase_core (Firebase initialization) +- firebase_crashlytics (opt-in crash reporting) +``` + +- [ ] **Step 2: Commit** + +```bash +git add SPEC.md +git commit -m "docs: add Firebase Crashlytics to SPEC.md tech stack" +``` + +--- + +### Task 11: Verify end-to-end + +- [ ] **Step 1: Run flutter analyze** + +Run: `flutter analyze` +Expected: no errors. + +- [ ] **Step 2: Run flutter test** + +Run: `flutter test` +Expected: all existing tests pass. + +- [ ] **Step 3: Build and launch on device/emulator** + +Run: `flutter run` +Expected: app launches, navigate to Settings, Observability section visible between General and Actions, toggle defaults to off, toggling on/off works without errors. diff --git a/docs/superpowers/specs/2026-04-21-firebase-crashlytics-design.md b/docs/superpowers/specs/2026-04-21-firebase-crashlytics-design.md new file mode 100644 index 0000000..501391c --- /dev/null +++ b/docs/superpowers/specs/2026-04-21-firebase-crashlytics-design.md @@ -0,0 +1,86 @@ +# Firebase Crashlytics Integration + +## Summary + +Add Firebase Crashlytics to CookMate with a user-facing toggle in a new "Observability" settings section. Crash reporting is opt-in (disabled by default) and can be toggled at runtime. The Observability section is designed to be extensible for future additions (analytics, performance monitoring, etc.). + +## Context + +- Firebase is already configured at the native level (google-services.json / GoogleService-Info.plist present in the Firebase project) +- The `firebase_crashlytics` Flutter plugin needs to be added along with `firebase_core` +- The settings page currently has 6 sections: Recipe, Skills, Cookidoo, AI, General, Actions +- The new Observability section goes between General and Actions + +## Architecture + +### New feature module + +``` +lib/features/observability/ + ├── data/ + │ └── observability_storage.dart # SharedPreferences wrapper + ├── domain/ + │ └── crashlytics_preference.dart # Boolean preference type + ├── presentation/ + │ ├── observability_section.dart # Section widget for settings page + │ └── crashlytics_toggle_tile.dart # SwitchListTile for Crashlytics + └── providers.dart # Riverpod AsyncNotifier + provider +``` + +### Dependencies (pubspec.yaml) + +- `firebase_core` — Firebase initialization +- `firebase_crashlytics` — Crash reporting SDK + +### Android build changes + +- Add `com.google.firebase.crashlytics` Gradle plugin to `android/app/build.gradle` +- Add classpath dependency in `android/build.gradle` if not already present + +### SharedPreferences key + +- `observability_crashlytics_enabled` — `bool`, default `false` + +### Data flow + +1. App startup (`main.dart`): + - Initialize `Firebase.initializeApp()` + - Read crashlytics preference from SharedPreferences + - Call `FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(value)` + - Set `FlutterError.onError` to `FirebaseCrashlytics.instance.recordFlutterFatalError` + - Wrap runApp zone with `FirebaseCrashlytics.instance.recordError` for async errors +2. Settings toggle: + - User flips switch → notifier updates SharedPreferences + calls `setCrashlyticsCollectionEnabled(newValue)` immediately + +### Provider pattern + +Follows the existing Riverpod AsyncNotifier pattern: + +- `CrashlyticsPreferenceNotifier extends AsyncNotifier` — reads/writes preference and syncs with Crashlytics SDK +- `crashlyticsPreferenceProvider` — exposes the notifier +- Storage class `ObservabilityStorage` wraps SharedPreferences access + +### Settings page changes + +In `settings_page.dart`, insert the Observability section between the General and Actions sections. The section contains a single toggle tile for now but is structured identically to other sections for easy extension. + +### Internationalization (ARB keys) + +Add to all 4 locale ARB files (`app_en.arb`, `app_fr.arb`, `app_de.arb`, `app_es.arb`): + +| Key | EN | FR | DE | ES | +|-----|----|----|----|----| +| `settingsSectionObservability` | Observability | Observabilité | Beobachtbarkeit | Observabilidad | +| `settingsCrashlyticsTitle` | Crash reporting | Rapport de plantage | Absturzbericht | Informe de fallos | +| `settingsCrashlyticsDescription` | Send anonymous crash reports to help improve the app | Envoyer des rapports de plantage anonymes pour améliorer l'application | Anonyme Absturzberichte senden, um die App zu verbessern | Enviar informes de fallos anónimos para mejorar la aplicación | + +### SPEC.md update + +Add Firebase Crashlytics to the tech stack section. + +## Testing + +- Verify toggle default is off +- Verify toggling on/off persists across app restarts +- Verify `setCrashlyticsCollectionEnabled` is called with correct value on toggle and on startup +- Force a crash in debug mode to verify crash reports arrive in Firebase Console when enabled diff --git a/lib/features/observability/data/crashlytics_preference_storage.dart b/lib/features/observability/data/crashlytics_preference_storage.dart new file mode 100644 index 0000000..23e6ca3 --- /dev/null +++ b/lib/features/observability/data/crashlytics_preference_storage.dart @@ -0,0 +1,26 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class CrashlyticsPreferenceStorage { + CrashlyticsPreferenceStorage(this._prefs); + + static const _key = 'observability_crashlytics_enabled'; + + final SharedPreferences _prefs; + + bool read() { + try { + return _prefs.getBool(_key) ?? false; + } catch (error, stack) { + debugPrint('Failed to read crashlytics preference: $error\n$stack'); + return false; + } + } + + Future write(bool enabled) async { + final didWrite = await _prefs.setBool(_key, enabled); + if (!didWrite) { + throw Exception('Failed to persist crashlytics preference.'); + } + } +} diff --git a/lib/features/observability/presentation/crashlytics_toggle_tile.dart b/lib/features/observability/presentation/crashlytics_toggle_tile.dart new file mode 100644 index 0000000..1b669fe --- /dev/null +++ b/lib/features/observability/presentation/crashlytics_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 CrashlyticsToggleTile extends ConsumerWidget { + const CrashlyticsToggleTile({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final crashlyticsAsync = ref.watch(crashlyticsPreferenceProvider); + final enabled = crashlyticsAsync.valueOrNull ?? false; + + return SwitchListTile( + secondary: const Icon(Icons.bug_report_outlined), + title: Text(l10n.settingsCrashlyticsTitle), + subtitle: Text(l10n.settingsCrashlyticsDescription), + value: enabled, + onChanged: crashlyticsAsync.isLoading ? null : (value) async { + try { + await ref + .read(crashlyticsPreferenceProvider.notifier) + .setPreference(value); + } catch (error, stack) { + debugPrint('Failed to change crashlytics: $error\n$stack'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text(l10n.settingsCrashlyticsChangeFailureSnackbar), + ), + ); + } + } + }, + ); + } +} diff --git a/lib/features/observability/presentation/observability_section.dart b/lib/features/observability/presentation/observability_section.dart new file mode 100644 index 0000000..94b6027 --- /dev/null +++ b/lib/features/observability/presentation/observability_section.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +import 'crashlytics_toggle_tile.dart'; + +class ObservabilitySection extends StatelessWidget { + const ObservabilitySection({super.key}); + + @override + Widget build(BuildContext context) { + return const Column( + mainAxisSize: MainAxisSize.min, + children: [ + CrashlyticsToggleTile(), + Divider(height: 1), + ], + ); + } +} diff --git a/lib/features/observability/providers.dart b/lib/features/observability/providers.dart new file mode 100644 index 0000000..f3f3a5d --- /dev/null +++ b/lib/features/observability/providers.dart @@ -0,0 +1,44 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/shared_preferences_provider.dart'; +import 'data/crashlytics_preference_storage.dart'; + +final crashlyticsPreferenceStorageProvider = + FutureProvider((ref) async { + final prefs = await ref.watch(sharedPreferencesProvider.future); + return CrashlyticsPreferenceStorage(prefs); +}); + +class CrashlyticsPreferenceNotifier extends AsyncNotifier { + @override + Future build() async { + final storage = + await ref.watch(crashlyticsPreferenceStorageProvider.future); + return storage.read(); + } + + Future setPreference(bool enabled) async { + final storage = + await ref.read(crashlyticsPreferenceStorageProvider.future); + state = const AsyncValue.loading().copyWithPrevious(state); + try { + await storage.write(enabled); + if (Firebase.apps.isNotEmpty) { + await FirebaseCrashlytics.instance + .setCrashlyticsCollectionEnabled(enabled); + } + state = AsyncValue.data(enabled); + } catch (error, stack) { + state = + AsyncValue.error(error, stack).copyWithPrevious(state); + rethrow; + } + } +} + +final crashlyticsPreferenceProvider = + AsyncNotifierProvider( + CrashlyticsPreferenceNotifier.new, +); diff --git a/lib/features/settings/presentation/settings_page.dart b/lib/features/settings/presentation/settings_page.dart index 535558b..a77a667 100644 --- a/lib/features/settings/presentation/settings_page.dart +++ b/lib/features/settings/presentation/settings_page.dart @@ -15,6 +15,7 @@ import '../../recipe/presentation/level_picker_tile.dart'; import '../../recipe/presentation/portions_picker_tile.dart'; import '../../recipe/presentation/tm_version_picker_tile.dart'; import '../../recipe/presentation/unit_system_picker_tile.dart'; +import '../../observability/presentation/observability_section.dart'; import '../../theme/presentation/theme_picker_tile.dart'; class SettingsPage extends ConsumerWidget { @@ -76,6 +77,11 @@ class SettingsPage extends ConsumerWidget { const Divider(height: 1), const ThemePickerTile(), const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text(l10n.settingsSectionObservability, style: sectionStyle), + ), + const ObservabilitySection(), Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), child: Text(l10n.settingsSectionActions, style: sectionStyle), diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 4d9528e..6802a7a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -142,5 +142,10 @@ "settingsCookidooTestSuccess": "Verbindung erfolgreich!", "settingsCookidooTestFailure": "Verbindung fehlgeschlagen. Bitte Zugangsdaten prüfen.", "settingsCookidooNotConfigured": "Nicht konfiguriert", - "settingsCookidooChangeFailureSnackbar": "Cookidoo-Zugangsdaten konnten nicht gespeichert werden. Bitte versuche es erneut." + "settingsCookidooChangeFailureSnackbar": "Cookidoo-Zugangsdaten konnten nicht gespeichert werden. Bitte versuche es erneut.", + + "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." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b4aa31e..fbe6a24 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -440,5 +440,17 @@ "@settingsCookidooNotConfigured": { "description": "Subtitle shown when Cookidoo credentials are not set." }, "settingsCookidooChangeFailureSnackbar": "Couldn't save Cookidoo credentials. Please try again.", - "@settingsCookidooChangeFailureSnackbar": { "description": "Shown when persisting Cookidoo credentials fails." } + "@settingsCookidooChangeFailureSnackbar": { "description": "Shown when persisting Cookidoo credentials fails." }, + + "settingsSectionObservability": "Observability", + "@settingsSectionObservability": { "description": "Section header for observability settings." }, + + "settingsCrashlyticsTitle": "Crash reporting", + "@settingsCrashlyticsTitle": { "description": "Title of the crash reporting toggle tile." }, + + "settingsCrashlyticsDescription": "Send anonymous crash reports to help improve the app", + "@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." } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index eb3e291..b9156ac 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -142,5 +142,10 @@ "settingsCookidooTestSuccess": "¡Conexión exitosa!", "settingsCookidooTestFailure": "Conexión fallida. Verifica tus credenciales.", "settingsCookidooNotConfigured": "No configurado", - "settingsCookidooChangeFailureSnackbar": "No se pudieron guardar las credenciales de Cookidoo. Inténtalo de nuevo." + "settingsCookidooChangeFailureSnackbar": "No se pudieron guardar las credenciales de Cookidoo. Inténtalo de nuevo.", + + "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." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 63d61ad..09993e7 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -142,5 +142,10 @@ "settingsCookidooTestSuccess": "Connexion réussie !", "settingsCookidooTestFailure": "Échec de connexion. Vérifiez vos identifiants.", "settingsCookidooNotConfigured": "Non configuré", - "settingsCookidooChangeFailureSnackbar": "Impossible de sauvegarder les identifiants Cookidoo. Réessayez." + "settingsCookidooChangeFailureSnackbar": "Impossible de sauvegarder les identifiants Cookidoo. Réessayez.", + + "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." } diff --git a/lib/main.dart b/lib/main.dart index 5f6cb1f..028bfcf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,16 +1,46 @@ +import 'dart:async'; + +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gemma/flutter_gemma.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; +import 'features/observability/data/crashlytics_preference_storage.dart'; + +void main() { + runZonedGuarded( + () async { + WidgetsFlutterBinding.ensureInitialized(); + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + + try { + await Firebase.initializeApp(); + + final prefs = await SharedPreferences.getInstance(); + final crashlyticsEnabled = + CrashlyticsPreferenceStorage(prefs).read(); + await FirebaseCrashlytics.instance + .setCrashlyticsCollectionEnabled(crashlyticsEnabled); + + FlutterError.onError = + FirebaseCrashlytics.instance.recordFlutterFatalError; + } catch (e, stack) { + debugPrint('Firebase init skipped: $e\n$stack'); + } + + await FlutterGemma.initialize(); -void main() async { - WidgetsFlutterBinding.ensureInitialized(); - await SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - ]); - await FlutterGemma.initialize(); - runApp(const ProviderScope(child: CookmateApp())); + runApp(const ProviderScope(child: CookmateApp())); + }, + (error, stack) { + FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); + }, + ); } diff --git a/pubspec.lock b/pubspec.lock index 085faae..93c5a09 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11 + url: "https://pub.dev" + source: hosted + version: "1.3.59" archive: dependency: transitive description: @@ -217,6 +225,46 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.3+5" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5" + url: "https://pub.dev" + source: hosted + version: "3.15.2" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37" + url: "https://pub.dev" + source: hosted + version: "2.24.1" + firebase_crashlytics: + dependency: "direct main" + description: + name: firebase_crashlytics + sha256: "662ae6443da91bca1fb0be8aeeac026fa2975e8b7ddfca36e4d90ebafa35dde1" + url: "https://pub.dev" + source: hosted + version: "4.3.10" + firebase_crashlytics_platform_interface: + dependency: transitive + description: + name: firebase_crashlytics_platform_interface + sha256: "7222a8a40077c79f6b8b3f3439241c9f2b34e9ddfde8381ffc512f7b2e61f7eb" + url: "https://pub.dev" + source: hosted + version: "3.8.10" fixnum: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d0edaf2..9240d02 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,6 +51,8 @@ dependencies: share_plus: ^11.0.0 yaml: ^3.1.0 http: ^1.4.0 + firebase_core: ^3.13.0 + firebase_crashlytics: ^4.3.5 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 new file mode 100644 index 0000000..0e26efc --- /dev/null +++ b/test/features/observability/data/crashlytics_preference_storage_test.dart @@ -0,0 +1,39 @@ +import 'package:cookmate/features/observability/data/crashlytics_preference_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late CrashlyticsPreferenceStorage storage; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + storage = CrashlyticsPreferenceStorage(prefs); + }); + + test('read returns false when nothing is stored', () { + expect(storage.read(), false); + }); + + test('write then read returns the written value', () async { + await storage.write(true); + expect(storage.read(), true); + }); + + test('write overwrites a previous value', () async { + await storage.write(true); + await storage.write(false); + expect(storage.read(), false); + }); + + test('read returns false 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); + }); +} diff --git a/test/features/observability/presentation/crashlytics_toggle_tile_test.dart b/test/features/observability/presentation/crashlytics_toggle_tile_test.dart new file mode 100644 index 0000000..f8eef97 --- /dev/null +++ b/test/features/observability/presentation/crashlytics_toggle_tile_test.dart @@ -0,0 +1,114 @@ +import 'package:cookmate/features/observability/presentation/crashlytics_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 CrashlyticsPreferenceNotifier { + _FakeNotifier(this._initial); + final bool _initial; + + @override + Future build() async => _initial; + + @override + Future setPreference(bool enabled) async { + final storage = + await ref.read(crashlyticsPreferenceStorageProvider.future); + state = const AsyncValue.loading().copyWithPrevious(state); + await storage.write(enabled); + state = AsyncValue.data(enabled); + } +} + +Widget _wrap(Widget child, {bool initialValue = false}) { + return ProviderScope( + overrides: [ + crashlyticsPreferenceProvider.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 CrashlyticsToggleTile()), + ); + await tester.pumpAndSettle(); + + final l10n = _l10n(tester); + expect(find.text(l10n.settingsCrashlyticsTitle), findsOneWidget); + expect(find.text(l10n.settingsCrashlyticsDescription), findsOneWidget); + }); + + testWidgets('switch defaults to off', (tester) async { + SharedPreferences.setMockInitialValues({}); + + await tester.pumpWidget( + _wrap(const CrashlyticsToggleTile()), + ); + await tester.pumpAndSettle(); + + final switchWidget = tester.widget(find.byType(Switch)); + expect(switchWidget.value, false); + }); + + testWidgets('switch reflects stored true value', (tester) async { + SharedPreferences.setMockInitialValues({ + 'observability_crashlytics_enabled': true, + }); + + await tester.pumpWidget( + _wrap(const CrashlyticsToggleTile(), initialValue: true), + ); + await tester.pumpAndSettle(); + + final switchWidget = tester.widget(find.byType(Switch)); + expect(switchWidget.value, true); + }); + + testWidgets('toggling switch persists true', (tester) async { + SharedPreferences.setMockInitialValues({}); + + await tester.pumpWidget( + _wrap(const CrashlyticsToggleTile()), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('observability_crashlytics_enabled'), true); + }); + + testWidgets('shows bug report icon', (tester) async { + SharedPreferences.setMockInitialValues({}); + + await tester.pumpWidget( + _wrap(const CrashlyticsToggleTile()), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.bug_report_outlined), findsOneWidget); + }); +} diff --git a/test/features/observability/presentation/observability_section_test.dart b/test/features/observability/presentation/observability_section_test.dart new file mode 100644 index 0000000..ffd0b59 --- /dev/null +++ b/test/features/observability/presentation/observability_section_test.dart @@ -0,0 +1,54 @@ +import 'package:cookmate/features/observability/presentation/crashlytics_toggle_tile.dart'; +import 'package:cookmate/features/observability/presentation/observability_section.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 CrashlyticsPreferenceNotifier { + @override + Future build() async => false; + + @override + Future setPreference(bool enabled) async { + state = AsyncValue.data(enabled); + } +} + +Widget _wrap(Widget child) { + return ProviderScope( + overrides: [ + crashlyticsPreferenceProvider.overrideWith(() => _FakeNotifier()), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Scaffold(body: child), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('contains CrashlyticsToggleTile', (tester) async { + SharedPreferences.setMockInitialValues({}); + + await tester.pumpWidget(_wrap(const ObservabilitySection())); + await tester.pumpAndSettle(); + + expect(find.byType(CrashlyticsToggleTile), findsOneWidget); + }); + + testWidgets('contains a Divider', (tester) async { + SharedPreferences.setMockInitialValues({}); + + await tester.pumpWidget(_wrap(const ObservabilitySection())); + await tester.pumpAndSettle(); + + expect(find.byType(Divider), findsOneWidget); + }); +}