diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 495f65c..71e2773 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -51,6 +51,14 @@ jobs:
IGDB_CLIENT_SECRET=${{ secrets.IGDB_CLIENT_SECRET }}
EOF
+ - name: 🔥 Setup Firebase config files
+ env:
+ FIREBASE_OPTIONS_DART: ${{ secrets.FIREBASE_OPTIONS_DART }}
+ FIREBASE_OPTIONS_DART_BASE64: ${{ secrets.FIREBASE_OPTIONS_DART_BASE64 }}
+ run: |
+ chmod +x scripts/setup_firebase.sh
+ ./scripts/setup_firebase.sh --require dart
+
- name: 🔨 Generate code
run: |
chmod +x scripts/build_all.sh
@@ -93,6 +101,14 @@ jobs:
IGDB_CLIENT_SECRET=${{ secrets.IGDB_CLIENT_SECRET }}
EOF
+ - name: 🔥 Setup Firebase config files
+ env:
+ FIREBASE_OPTIONS_DART: ${{ secrets.FIREBASE_OPTIONS_DART }}
+ FIREBASE_OPTIONS_DART_BASE64: ${{ secrets.FIREBASE_OPTIONS_DART_BASE64 }}
+ run: |
+ chmod +x scripts/setup_firebase.sh
+ ./scripts/setup_firebase.sh --require dart
+
- name: 🔨 Generate code
run: |
chmod +x scripts/build_all.sh
diff --git a/.github/workflows/pr-checks.yaml b/.github/workflows/pr-checks.yaml
index 6ecfa8f..5e949ce 100644
--- a/.github/workflows/pr-checks.yaml
+++ b/.github/workflows/pr-checks.yaml
@@ -40,5 +40,5 @@ jobs:
- name: 🔍 Check for outdated dependencies
run: |
- cd apps/mobile
- flutter pub outdated
+ chmod +x scripts/check_dependencies.sh
+ ./scripts/check_dependencies.sh
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index f83b388..5539c75 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -48,6 +48,16 @@ jobs:
IGDB_CLIENT_SECRET=${{ secrets.IGDB_CLIENT_SECRET }}
EOF
+ - name: 🔥 Setup Firebase config files
+ env:
+ FIREBASE_OPTIONS_DART: ${{ secrets.FIREBASE_OPTIONS_DART }}
+ FIREBASE_OPTIONS_DART_BASE64: ${{ secrets.FIREBASE_OPTIONS_DART_BASE64 }}
+ FIREBASE_ANDROID_GOOGLE_SERVICES_JSON: ${{ secrets.FIREBASE_ANDROID_GOOGLE_SERVICES_JSON }}
+ FIREBASE_ANDROID_GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.FIREBASE_ANDROID_GOOGLE_SERVICES_JSON_BASE64 }}
+ run: |
+ chmod +x scripts/setup_firebase.sh
+ ./scripts/setup_firebase.sh --require dart --require android
+
- name: 🔨 Generate code
run: |
chmod +x scripts/build_all.sh
diff --git a/.vscode/launch.json b/.vscode/launch.json
index e94b835..c3f394c 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -1,7 +1,30 @@
{
"configurations": [
{
- "name": "Flutter",
+ "name": "Launch (Remote Config Flags)",
+ "type": "dart",
+ "request": "launch",
+ "program": "apps/mobile/lib/main.dart",
+ "args": [
+ "--dart-define=ENABLE_CRASHLYTICS_IN_DEBUG=true",
+ "--dart-define=BACKEND_API_BASE_URL=http://localhost:4000"
+ ]
+ },
+ {
+ "name": "Launch (Force Backend ON via ENV)",
+ "type": "dart",
+ "request": "launch",
+ "program": "apps/mobile/lib/main.dart",
+ "args": [
+ "--dart-define=ENABLE_CRASHLYTICS_IN_DEBUG=true",
+ "--dart-define=BACKEND_USE_ENV_FLAG_OVERRIDES=true",
+ "--dart-define=BACKEND_INTEGRATION_ENABLED=true",
+ "--dart-define=BACKEND_SYNC_ENABLED=true",
+ "--dart-define=BACKEND_API_BASE_URL=http://localhost:4000"
+ ]
+ },
+ {
+ "name": "Launch (Without ENV)",
"type": "dart",
"request": "launch",
"program": "apps/mobile/lib/main.dart"
diff --git a/QUICK_START.md b/QUICK_START.md
index 6d237f8..e4879e5 100644
--- a/QUICK_START.md
+++ b/QUICK_START.md
@@ -1,246 +1,63 @@
-# Collection Tracker - Quick Start Guide
+# Quick Start
-Get your Collection Tracker app up and running in minutes!
+For full documentation, start at [documentation/README.md](documentation/README.md).
-## ⚡ Quick Setup (5 minutes)
+## Minimal Local Run
-### 1. Prerequisites Check
+1. Install dependencies:
```bash
-# Verify Flutter is installed
-flutter doctor
-
-# You should see:
-# ✓ Flutter (Channel stable, 3.24.0 or higher)
-# ✓ Dart (3.2.0 or higher)
+dart pub get
```
-### 2. Install Dependencies
+2. Create env file:
```bash
-# From workspace root
-dart pub get
+cat > packages/common/env/.env <<'ENV'
+GOOGLE_BOOKS_API_KEY=...
+TMDB_API_KEY=...
+TMDB_READ_ACCESS_TOKEN=...
+IGDB_CLIENT_ID=...
+IGDB_CLIENT_SECRET=...
+ENV
```
-### 3. Generate Code
+3. Materialize Firebase config files:
```bash
-# Make scripts executable
-chmod +x scripts/*.sh
-
-# Run code generation
-./scripts/build_all.sh
+./scripts/setup_firebase.sh --require dart
```
-### 4. Run the App
+4. Generate code:
```bash
-cd apps/mobile
-flutter run
+./scripts/build_all.sh
```
-That's it! 🎉 Your app should now be running.
-
-## 📱 First Time Usage
-
-### Create Your First Collection
-
-1. Tap **"New Collection"** button
-2. Enter collection name (e.g., "My Books")
-3. Select collection type (Books, Games, Movies, etc.)
-4. Optionally add a description
-5. Tap **"Create Collection"**
-
-### Add Your First Item
-
-1. Tap on your collection
-2. Tap **"Add Item"**
-3. Enter item title (required)
-4. Optionally add:
- - Barcode (ISBN, UPC, etc.)
- - Description
- - More details coming soon!
-5. Tap **"Add Item"**
-
-### View and Manage Items
-
-1. From collection detail, tap **"View Items"**
-2. See all your items in a beautiful list
-3. Tap any item to view full details
-4. Use the menu to edit or delete items
-
-## 🛠️ Development Workflow
-
-### Daily Development
+5. Run app:
```bash
-# Terminal 1: Watch for code changes
cd apps/mobile
-flutter pub run build_runner watch --delete-conflicting-outputs
-
-# Terminal 2: Run the app
flutter run
-
-# Press 'r' for hot reload
-# Press 'R' for hot restart
```
-### Making Changes
-
-1. **Modify code** in your IDE
-2. **Hot reload** automatically (or press 'r')
-3. **If you modify models/providers**, code is auto-generated
-4. **Test your changes** immediately
-
-### Common Commands
+## Useful Commands
```bash
-# Using Makefile (recommended)
-make build # Generate code
-make test # Run tests
-make analyze # Check code quality
-make format # Format code
-make run # Run app
-
-# Or using scripts directly
-./scripts/build_all.sh
-./scripts/test_all.sh
./scripts/analyze_all.sh
+./scripts/test_all.sh
+dart format --set-exit-if-changed .
```
-## 🔧 Troubleshooting
-
-### "No such file or directory" for .g.dart files
-
-**Solution:**
-```bash
-./scripts/build_all.sh
-```
-
-### "The getter 'xxx' isn't defined"
+## Optional: Local Backend/Sync Debug Run
-**Solution:**
```bash
cd apps/mobile
-flutter pub run build_runner build --delete-conflicting-outputs
-```
-
-### Import errors
-
-**Solution:**
-```bash
-dart pub get
-flutter pub get
-```
-
-### App won't start
-
-**Solution:**
-```bash
-# Clean and rebuild
-./scripts/clean_all.sh
-dart pub get
-./scripts/build_all.sh
-flutter run
-```
-
-### Database errors
-
-**Solution:** Database is created automatically on first run. If you see errors:
-```bash
-# Uninstall app and reinstall
-flutter clean
-flutter run
-```
-
-## 📂 Project Structure
-
+flutter run \
+ --dart-define=BACKEND_USE_ENV_FLAG_OVERRIDES=true \
+ --dart-define=BACKEND_INTEGRATION_ENABLED=true \
+ --dart-define=BACKEND_SYNC_ENABLED=true \
+ --dart-define=BACKEND_API_BASE_URL=http://localhost:4000
```
-collection_tracker/
-├── apps/mobile/ # Your Flutter app
-│ └── lib/
-│ ├── main.dart # App entry point
-│ ├── features/ # Features (collections, items, etc.)
-│ └── core/ # Core app functionality
-│
-├── packages/ # Reusable packages
-│ ├── core/
-│ │ ├── domain/ # Business logic
-│ │ └── data/ # Data layer
-│ ├── common/
-│ │ ├── ui/ # Shared widgets
-│ │ └── utils/ # Utilities
-│ └── integrations/ # Third-party integrations
-│
-└── scripts/ # Build scripts
-```
-
-## 🎯 What's Next?
-
-### Explore Features
-
-- ✅ Create multiple collections
-- ✅ Add items with details
-- ✅ View statistics
-- ✅ Dark mode (automatic)
-- ✅ Beautiful Material 3 UI
-
-### Coming Soon
-
-- 📷 Barcode scanning
-- 🖼️ Photo upload
-- 🔍 Search functionality
-- ☁️ Cloud sync
-- 📊 Advanced statistics
-
-### Customize
-
-- **Change theme**: Edit `packages/common/ui/lib/theme/app_theme.dart`
-- **Add features**: Create new feature folders in `apps/mobile/lib/features/`
-- **Modify database**: Edit tables in `packages/integrations/database/lib/tables/`
-
-## 📚 Learn More
-
-### Documentation
-
-
-- [Architecture Guide](documentation/ARCHITECTURE.md) - Learn about the architecture
-- [Contributing Guide](CONTRIBUTING.md) - How to contribute
-
-### Resources
-
-- [Flutter Documentation](https://flutter.dev/docs)
-- [Riverpod Documentation](https://riverpod.dev)
-- [Drift Documentation](https://drift.simonbinder.eu)
-
-## 💡 Pro Tips
-
-1. **Use hot reload** - Press 'r' instead of restarting the app
-2. **Keep build_runner watching** - Saves time during development
-3. **Use the Makefile** - Easier than remembering script paths
-4. **Check analysis** - Run `make analyze` before committing
-5. **Format code** - Run `make format` to maintain consistency
-
-## 🆘 Need Help?
-
-- 📖 Check the [README.md](README.md)
-- 🐛 Report issues on GitHub
-- 💬 Ask questions in Discussions
-- 📧 Email: kyawzayartun.contact@gmail.com
-
-## ✅ Checklist
-
-Before you start coding:
-
-- [ ] Flutter doctor shows no issues
-- [ ] Dependencies installed (`dart pub get`)
-- [ ] Code generated (`./scripts/build_all.sh`)
-- [ ] App runs successfully (`flutter run`)
-- [ ] You can create a collection
-- [ ] You can add an item
-
-You're all set! Happy coding! 🚀
-
-
+See [documentation/FIREBASE_AND_FLAGS.md](documentation/FIREBASE_AND_FLAGS.md) for runtime flag details.
diff --git a/README.md b/README.md
index 4dff37a..33b71dc 100644
--- a/README.md
+++ b/README.md
@@ -1,268 +1,120 @@
# Collection Tracker
-A beautiful, feature-rich Flutter application for organizing and managing your collections (books, games, movies, comics, music, and more).
+Collection Tracker is an offline-first Flutter app for organizing personal collections (books, movies, games, and custom categories), with optional cloud sync and Firebase-powered observability.
-
-
-
-[](https://github.com/mixin27/collection_tracker/actions/workflows/ci.yaml)
+## Current State
-## 📱 Features
+This repository is actively developed and already includes:
-### ✨ Core Features
-- **Multiple Collection Types**: Books, Games, Movies, Comics, Music, Custom
-- **Item Management**: Add, view, edit, and delete items in your collections
-- **Rich Item Details**: Title, barcode, description, images, condition, quantity, location
-- **Beautiful UI**: Material 3 design with dark mode support
-- **Smooth Animations**: Delightful user experience with fluid transitions
-- **Offline First**: All data stored locally with Drift database
+- Collection and item management (list/grid views, create/edit/delete)
+- Tag system (assign tags, rename/merge/delete tags, bulk tag actions)
+- Favorites and wishlist flows
+- Item detail with price tracking and value history
+- Statistics dashboard with valuation and distribution insights
+- Import/export (JSON and CSV)
+- Barcode scanner flow
+- Localization support for 7 languages
+- Custom design system and glass bottom navigation
+- Firebase Crashlytics, Analytics (consent-based), Performance, Remote Config, App Check, FCM
+- Optional backend auth and sync (feature-flag gated)
-### 🎯 Coming Soon
-- 📷 Barcode scanning with camera
-- 🖼️ Image upload for items
-- 🔍 Advanced search and filtering
-- ⭐ Favorites and wish lists
-- ☁️ Cloud sync across devices
-- 📊 Statistics and insights
-- 📤 Backup and restore
-- 🌐 Multi-language support
+For a detailed status matrix, see [documentation/APP_PROGRESS.md](documentation/APP_PROGRESS.md).
-## 🏗️ Architecture
+## Tech Stack
-This project follows **Clean Architecture** principles with **MVVM** pattern:
+- Flutter + Dart (workspace/monorepo)
+- Riverpod (with code generation)
+- Drift (local database)
+- GoRouter (navigation)
+- Firebase (Core, Analytics, Crashlytics, Performance, Remote Config)
+- Dio (backend/sync transport)
-```
-┌─────────────────────────────────────────┐
-│ Presentation Layer │
-│ (UI, ViewModels, Widgets, State) │
-└──────────────┬──────────────────────────┘
- │
-┌──────────────▼──────────────────────────┐
-│ Domain Layer │
-│ (Entities, Use Cases, Repositories) │
-└──────────────┬──────────────────────────┘
- │
-┌──────────────▼──────────────────────────┐
-│ Data Layer │
-│ (Models, Repository Impl, DataSources) │
-└──────────────────────────────────────────┘
-```
-
-### 📦 Tech Stack
-
-- **Flutter**: Cross-platform UI framework
-- **Riverpod**: State management with code generation
-- **Drift**: Type-safe local database
-- **Go Router**: Declarative routing
-- **Freezed**: Immutable data classes
-- **fpdart**: Functional programming (Either type)
+## Workspace Layout
-### 🗂️ Project Structure
-
-```
-collection_tracker/
-├── apps/mobile/ # Flutter application
-├── packages/
-│ ├── core/
-│ │ ├── domain/ # Business logic
-│ │ └── data/ # Data layer
-│ ├── common/
-│ │ ├── ui/ # Shared widgets
-│ │ └── utils/ # Utilities
-│ └── integrations/
-│ ├── database/ # Drift database
-│ ├── barcode_scanner/ # Scanner integration
-│ └── metadata_api/ # API clients
-└── scripts/ # Build scripts
+```text
+apps/mobile/ Flutter app
+packages/core/domain/ Domain contracts/entities
+packages/core/data/ Repository implementations
+packages/common/ui/ Shared design system components
+packages/common/utils/ Shared utilities
+packages/common/env/ Compile-time env access (Envied)
+packages/integrations/* Database, analytics, auth session, backend API, sync API, etc.
+documentation/ Project documentation hub
```
-## 🚀 Getting Started
-
-### Prerequisites
-
-- Flutter SDK
-- Dart SDK
-- Android Studio / Xcode (for mobile development)
+## Quick Start
-### Installation
+1. Install dependencies:
-1. **Clone the repository**
-```bash
-git clone https://github.com/mixin27/collection_tracker.git
-cd collection_tracker
-```
-
-2. **Install dependencies**
```bash
dart pub get
```
-3. **Generate code**
-```bash
-./scripts/build_all.sh
-```
-
-Or manually:
-```bash
-cd apps/mobile
-flutter pub run build_runner build --delete-conflicting-outputs
-```
-
-4. **Run the app**
-```bash
-cd apps/mobile
-flutter run
-```
-
-## 🛠️ Development
-
-### Available Scripts
-
-```bash
-# Setup workspace
-./scripts/setup.sh
-
-# Run code generation
-./scripts/build_all.sh
-
-# Watch mode for code generation
-./scripts/build_watch.sh
-
-# Run tests
-./scripts/test_all.sh
-
-# Analyze code
-./scripts/analyze_all.sh
-
-# Format code
-./scripts/format_all.sh
-
-# Clean all packages
-./scripts/clean_all.sh
-```
-
-### Using Makefile
-
-```bash
-make setup # Initial setup
-make build # Generate code
-make test # Run tests
-make analyze # Analyze code
-make run # Run the app
-make clean # Clean everything
-```
-
-### Code Generation
-
-When you modify models, providers, or use Riverpod/Freezed annotations:
-
-```bash
-flutter pub run build_runner watch --delete-conflicting-outputs
-```
-
-## 🧪 Testing
+2. Create API env file used by metadata integrations:
-Run all tests:
```bash
-./scripts/test_all.sh
+cat > packages/common/env/.env <<'ENV'
+GOOGLE_BOOKS_API_KEY=...
+TMDB_API_KEY=...
+TMDB_READ_ACCESS_TOKEN=...
+IGDB_CLIENT_ID=...
+IGDB_CLIENT_SECRET=...
+ENV
```
-Run tests for specific package:
-```bash
-cd packages/core/domain
-dart test
-```
+3. Materialize Firebase files (recommended, especially for CI/local parity):
-Generate coverage:
```bash
-./scripts/coverage.sh
+./scripts/setup_firebase.sh --require dart
```
-
-
-## 📱 Building for Release
-
-### Android
-
-```bash
-cd apps/mobile
-flutter build apk --release
-# APK location: build/app/outputs/flutter-apk/app-release.apk
-
-# Or build App Bundle for Play Store
-flutter build appbundle --release
+./scripts/build_all.sh
```
-### iOS
+5. Run app:
```bash
cd apps/mobile
-flutter build ios --release
+flutter run
```
-## 🤝 Contributing
-
-Contributions are welcome! Please follow these steps:
-
-1. Fork the repository
-2. Create a feature branch (`git checkout -b feature/amazing-feature`)
-3. Commit your changes (`git commit -m 'feat: Add amazing feature'`)
-4. Push to the branch (`git push origin feature/amazing-feature`)
-5. Open a Pull Request
+Detailed setup instructions: [documentation/SETUP_AND_RUN.md](documentation/SETUP_AND_RUN.md)
-### Code Style
+## Cloud Sync Flags (Important)
-- Follow [Effective Dart](https://dart.dev/guides/language/effective-dart) guidelines
-- Use meaningful variable and function names
-- Add comments for complex logic
-- Write tests for new features
+Cloud Sync is enabled only when all required Remote Config flags are `true`:
-## 📄 License
+- `app_backend_integration_enabled`
+- `app_auth_feature_enabled`
+- `app_sync_feature_enabled`
-This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+If either is `false`, sync UI/actions are disabled. Full explanation: [documentation/FIREBASE_AND_FLAGS.md](documentation/FIREBASE_AND_FLAGS.md)
-## 🙏 Acknowledgments
+## Documentation
-- Flutter team for the amazing framework
-- Riverpod for excellent state management
-- Drift for type-safe database operations
-- All open-source contributors
+- [documentation/README.md](documentation/README.md) - docs index
+- [documentation/ARCHITECTURE.md](documentation/ARCHITECTURE.md) - architecture and runtime flow
+- [documentation/APP_PROGRESS.md](documentation/APP_PROGRESS.md) - implemented vs in-progress features
+- [documentation/SYNC_AND_AUTH.md](documentation/SYNC_AND_AUTH.md) - sync/auth integration details
+- [documentation/LOCALIZATION.md](documentation/LOCALIZATION.md) - i18n status and workflow
-## 📞 Support
+## CI/CD
-If you have any questions or issues:
+GitHub Actions currently runs:
-- 📧 Email: kyawzayartun.contact@gmail.com
-- 🐛 Issues: [GitHub Issues](https://github.com/mixin27/collection_tracker/issues)
+- Analyze (`.github/workflows/ci.yaml`)
+- Tests (`.github/workflows/ci.yaml`)
+- PR checks (`.github/workflows/pr-checks.yaml`)
+- Android release pipeline (`.github/workflows/release.yaml`)
-## 🗺️ Roadmap
+Firebase secrets are materialized during CI using `scripts/setup_firebase.sh`.
-- [x] Barcode scanning with camera
-- [x] Image upload and gallery
-- [x] Advanced search and filters
-- [ ] Cloud synchronization
-- [ ] Import/Export data (CSV, JSON)
-- [ ] Price tracking and statistics
-- [ ] Loan tracking (who borrowed what)
-- [ ] Multiple user profiles
-- [ ] Desktop app (Windows, macOS, Linux)
-- [ ] Web app
+- `CI analyze/test` requires: `FIREBASE_OPTIONS_DART` (or `_BASE64`)
+- `Release Android` requires: `FIREBASE_OPTIONS_DART` + `FIREBASE_ANDROID_GOOGLE_SERVICES_JSON` (or `_BASE64`)
----
+## License
-Made with ❤️ using Flutter
+MIT. See [LICENSE](LICENSE).
diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore
index 3820a95..31c175b 100644
--- a/apps/mobile/.gitignore
+++ b/apps/mobile/.gitignore
@@ -43,3 +43,17 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
+
+# Firebase local emulator exports
+firebase-exports
+
+# Firebase CLI config
+.firebaserc
+
+# Firebase
+lib/firebase_options.dart
+ios/Runner/GoogleService-Info.plist
+ios/firebase_app_id_file.json
+macos/Runner/GoogleService-Info.plist
+macos/firebase_app_id_file.json
+android/app/google-services.json
diff --git a/apps/mobile/README.md b/apps/mobile/README.md
index b26ae7e..4a5aaba 100644
--- a/apps/mobile/README.md
+++ b/apps/mobile/README.md
@@ -1,16 +1,17 @@
-# collection_tracker
+# Collection Tracker Mobile App
-A new Flutter project.
+This is the Flutter app module for the Collection Tracker workspace.
-## Getting Started
+## Main References
-This project is a starting point for a Flutter application.
+- Workspace root README: [README.md](../../README.md)
+- Setup guide: [documentation/SETUP_AND_RUN.md](../../documentation/SETUP_AND_RUN.md)
+- Architecture: [documentation/ARCHITECTURE.md](../../documentation/ARCHITECTURE.md)
+- Feature status: [documentation/APP_PROGRESS.md](../../documentation/APP_PROGRESS.md)
-A few resources to get you started if this is your first Flutter project:
+## Run
-- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
-- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
-
-For help getting started with Flutter development, view the
-[online documentation](https://docs.flutter.dev/), which offers tutorials,
-samples, guidance on mobile development, and a full API reference.
+```bash
+cd apps/mobile
+flutter run
+```
diff --git a/apps/mobile/android/app/build.gradle.kts b/apps/mobile/android/app/build.gradle.kts
index 41d569f..3fdac2a 100644
--- a/apps/mobile/android/app/build.gradle.kts
+++ b/apps/mobile/android/app/build.gradle.kts
@@ -3,6 +3,10 @@ import java.io.FileInputStream
plugins {
id("com.android.application")
+ // START: FlutterFire Configuration
+ id("com.google.gms.google-services")
+ id("com.google.firebase.crashlytics")
+ // END: FlutterFire Configuration
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
@@ -20,6 +24,7 @@ android {
ndkVersion = flutter.ndkVersion
compileOptions {
+ isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
@@ -57,6 +62,10 @@ android {
}
}
+dependencies {
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
+}
+
flutter {
source = "../.."
}
diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml
index 5248b98..0a19bcd 100644
--- a/apps/mobile/android/app/src/main/AndroidManifest.xml
+++ b/apps/mobile/android/app/src/main/AndroidManifest.xml
@@ -14,6 +14,7 @@
+
UIApplicationSupportsIndirectInputEvents
+ UIBackgroundModes
+
+ fetch
+ remote-notification
+
UILaunchStoryboardName
LaunchScreen
UIMainStoryboardFile
diff --git a/apps/mobile/ios/Runner/Runner.entitlements b/apps/mobile/ios/Runner/Runner.entitlements
new file mode 100644
index 0000000..903def2
--- /dev/null
+++ b/apps/mobile/ios/Runner/Runner.entitlements
@@ -0,0 +1,8 @@
+
+
+
+
+ aps-environment
+ development
+
+
diff --git a/apps/mobile/lib/app.dart b/apps/mobile/lib/app.dart
index 3babe6c..1d79e03 100644
--- a/apps/mobile/lib/app.dart
+++ b/apps/mobile/lib/app.dart
@@ -1,5 +1,8 @@
import 'package:collection_tracker/core/providers/providers.dart';
+import 'package:collection_tracker/core/firebase/firebase_runtime_config_auto_refresh.dart';
+import 'package:collection_tracker/core/notifications/push_notification_bridge.dart';
import 'package:collection_tracker/core/router/app_router.dart';
+import 'package:collection_tracker/core/sync/sync_auto_retry_on_resume.dart';
import 'package:collection_tracker/l10n/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -56,7 +59,13 @@ class CollectionTrackerApp extends ConsumerWidget {
return AnnotatedRegion(
value: overlay,
- child: child ?? const SizedBox.shrink(),
+ child: PushNotificationBridge(
+ child: SyncAutoRetryOnResume(
+ child: FirebaseRuntimeConfigAutoRefresh(
+ child: child ?? const SizedBox.shrink(),
+ ),
+ ),
+ ),
);
},
);
diff --git a/apps/mobile/lib/core/analytics/analytics_consent_dialog.dart b/apps/mobile/lib/core/analytics/analytics_consent_dialog.dart
new file mode 100644
index 0000000..812e848
--- /dev/null
+++ b/apps/mobile/lib/core/analytics/analytics_consent_dialog.dart
@@ -0,0 +1,33 @@
+import 'package:collection_tracker/l10n/l10n.dart';
+import 'package:flutter/material.dart';
+import 'package:ui/ui.dart';
+
+enum AnalyticsConsentDecision { allow, deny }
+
+Future showAnalyticsConsentDialog(
+ BuildContext context, {
+ bool barrierDismissible = false,
+}) async {
+ final l10n = context.l10n;
+ final result = await showAppDialog(
+ context: context,
+ barrierDismissible: barrierDismissible,
+ title: Text(l10n.analyticsConsentDialogTitle),
+ content: Text(l10n.analyticsConsentDialogMessage),
+ actions: [
+ AppButton(
+ label: l10n.analyticsConsentDeclineAction,
+ variant: AppButtonVariant.ghost,
+ onPressed: () => closeAppDialog(context, false),
+ ),
+ AppButton(
+ label: l10n.analyticsConsentAllowAction,
+ onPressed: () => closeAppDialog(context, true),
+ ),
+ ],
+ );
+
+ return result == true
+ ? AnalyticsConsentDecision.allow
+ : AnalyticsConsentDecision.deny;
+}
diff --git a/apps/mobile/lib/core/analytics/analytics_consent_gate.dart b/apps/mobile/lib/core/analytics/analytics_consent_gate.dart
new file mode 100644
index 0000000..b8999ac
--- /dev/null
+++ b/apps/mobile/lib/core/analytics/analytics_consent_gate.dart
@@ -0,0 +1,63 @@
+import 'package:collection_tracker/core/analytics/analytics_consent_dialog.dart';
+import 'package:collection_tracker/core/providers/providers.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:storage/storage.dart';
+
+class AnalyticsConsentGate extends ConsumerStatefulWidget {
+ const AnalyticsConsentGate({required this.child, super.key});
+
+ final Widget child;
+
+ @override
+ ConsumerState createState() =>
+ _AnalyticsConsentGateState();
+}
+
+class _AnalyticsConsentGateState extends ConsumerState {
+ bool _dialogInProgress = false;
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ _tryPromptConsent();
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ ref.listen(analyticsPreferencesProvider, (previous, next) {
+ _tryPromptConsent();
+ });
+
+ return widget.child;
+ }
+
+ Future _tryPromptConsent() async {
+ if (!mounted || _dialogInProgress) return;
+
+ final onboardingComplete =
+ PrefsStorageService.instance.readSync('onboarding_complete') ??
+ false;
+ if (!onboardingComplete) return;
+
+ final preferences = ref.read(analyticsPreferencesProvider);
+ if (!preferences.needsConsent) return;
+
+ _dialogInProgress = true;
+ try {
+ final decision = await showAnalyticsConsentDialog(context);
+ if (!mounted) return;
+
+ switch (decision) {
+ case AnalyticsConsentDecision.allow:
+ await ref.read(analyticsPreferencesProvider.notifier).grantConsent();
+ case AnalyticsConsentDecision.deny:
+ await ref.read(analyticsPreferencesProvider.notifier).denyConsent();
+ }
+ } finally {
+ _dialogInProgress = false;
+ }
+ }
+}
diff --git a/apps/mobile/lib/core/analytics/analytics_preferences.dart b/apps/mobile/lib/core/analytics/analytics_preferences.dart
new file mode 100644
index 0000000..c563b71
--- /dev/null
+++ b/apps/mobile/lib/core/analytics/analytics_preferences.dart
@@ -0,0 +1,45 @@
+enum AnalyticsConsentStatus { unknown, granted, denied }
+
+extension AnalyticsConsentStatusX on AnalyticsConsentStatus {
+ String get code => switch (this) {
+ AnalyticsConsentStatus.unknown => 'unknown',
+ AnalyticsConsentStatus.granted => 'granted',
+ AnalyticsConsentStatus.denied => 'denied',
+ };
+
+ static AnalyticsConsentStatus fromCode(String? code) {
+ return switch (code) {
+ 'granted' => AnalyticsConsentStatus.granted,
+ 'denied' => AnalyticsConsentStatus.denied,
+ _ => AnalyticsConsentStatus.unknown,
+ };
+ }
+}
+
+class AnalyticsPreferences {
+ const AnalyticsPreferences({
+ required this.enabled,
+ required this.consentStatus,
+ });
+
+ static const enabledPrefKey = 'analytics_enabled';
+ static const consentStatusPrefKey = 'analytics_consent_status';
+
+ final bool enabled;
+ final AnalyticsConsentStatus consentStatus;
+
+ bool get needsConsent =>
+ enabled && consentStatus == AnalyticsConsentStatus.unknown;
+ bool get canTrack =>
+ enabled && consentStatus == AnalyticsConsentStatus.granted;
+
+ AnalyticsPreferences copyWith({
+ bool? enabled,
+ AnalyticsConsentStatus? consentStatus,
+ }) {
+ return AnalyticsPreferences(
+ enabled: enabled ?? this.enabled,
+ consentStatus: consentStatus ?? this.consentStatus,
+ );
+ }
+}
diff --git a/apps/mobile/lib/core/auth/backend_auth_service.dart b/apps/mobile/lib/core/auth/backend_auth_service.dart
new file mode 100644
index 0000000..93e2193
--- /dev/null
+++ b/apps/mobile/lib/core/auth/backend_auth_service.dart
@@ -0,0 +1,146 @@
+import 'dart:io';
+
+import 'package:auth_session/auth_session.dart';
+import 'package:backend_api/backend_api.dart';
+
+class BackendAuthService {
+ BackendAuthService({
+ required BackendAuthClient client,
+ required AuthSessionStore sessionStore,
+ required Future Function() resolveDeviceId,
+ this.appVersion = '1.0.0',
+ }) : _client = client,
+ _sessionStore = sessionStore,
+ _resolveDeviceId = resolveDeviceId;
+
+ final BackendAuthClient _client;
+ final AuthSessionStore _sessionStore;
+ final Future Function() _resolveDeviceId;
+ final String appVersion;
+
+ Future signIn({
+ required String email,
+ required String password,
+ }) async {
+ final deviceId = await _resolveDeviceId();
+ final response = await _client.login(
+ BackendLoginRequest(
+ email: email,
+ password: password,
+ deviceId: deviceId,
+ deviceName: _deviceName(),
+ deviceOs: _deviceOs(),
+ appVersion: appVersion,
+ ),
+ );
+
+ return _persistAuthResponse(response);
+ }
+
+ Future register({
+ required String email,
+ required String password,
+ String? displayName,
+ }) async {
+ final deviceId = await _resolveDeviceId();
+ final response = await _client.register(
+ BackendRegisterRequest(
+ email: email,
+ password: password,
+ displayName: displayName,
+ deviceId: deviceId,
+ deviceName: _deviceName(),
+ deviceOs: _deviceOs(),
+ appVersion: appVersion,
+ ),
+ );
+
+ return _persistAuthResponse(response);
+ }
+
+ Future refreshSession() async {
+ final existing = await _sessionStore.readSession();
+ if (!existing.canRefresh || existing.refreshToken == null) {
+ return null;
+ }
+
+ try {
+ final tokens = await _client.refresh(
+ BackendRefreshTokenRequest(
+ refreshToken: existing.refreshToken!,
+ deviceId: existing.deviceId!,
+ ),
+ );
+
+ final updated = existing.copyWith(
+ status: AuthSessionStatus.signedIn,
+ accessToken: tokens.accessToken,
+ refreshToken: tokens.refreshToken,
+ updatedAt: DateTime.now().toUtc(),
+ );
+
+ await _sessionStore.saveSession(updated);
+ return updated;
+ } on BackendApiException catch (error) {
+ final statusCode = error.statusCode;
+ if (statusCode == 401 || statusCode == 403) {
+ await _sessionStore.clearSession();
+ return null;
+ }
+ rethrow;
+ }
+ }
+
+ Future signOut() async {
+ final existing = await _sessionStore.readSession();
+
+ if (existing.hasAccessToken && existing.accessToken != null) {
+ try {
+ await _client.logout(existing.accessToken!);
+ } on BackendApiException {
+ // Always clear local session even if remote logout fails.
+ }
+ }
+
+ await _sessionStore.clearSession();
+ }
+
+ Future fetchProfile() async {
+ final existing = await _sessionStore.readSession();
+ if (!existing.hasAccessToken || existing.accessToken == null) {
+ return null;
+ }
+
+ final response = await _client.me(existing.accessToken!);
+ return response.user;
+ }
+
+ Future _persistAuthResponse(BackendAuthResponse response) async {
+ final session = AuthSession(
+ status: AuthSessionStatus.signedIn,
+ accessToken: response.accessToken,
+ refreshToken: response.refreshToken,
+ deviceId: response.session.deviceId,
+ userId: response.user.id,
+ expiresAt: response.session.expiresAt,
+ updatedAt: DateTime.now().toUtc(),
+ );
+
+ await _sessionStore.saveSession(session);
+ return session;
+ }
+
+ String _deviceName() {
+ final host = Platform.localHostname.trim();
+ if (host.isNotEmpty) {
+ return host;
+ }
+ return 'Mobile Device';
+ }
+
+ String _deviceOs() {
+ final os = Platform.operatingSystem.trim();
+ final version = Platform.operatingSystemVersion.trim();
+ return '$os $version'.trim();
+ }
+}
diff --git a/apps/mobile/lib/core/bootstrap/app_bootstrap.dart b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart
new file mode 100644
index 0000000..acd0c86
--- /dev/null
+++ b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart
@@ -0,0 +1,112 @@
+import 'package:app_analytics/app_analytics.dart';
+import 'package:app_logger/app_logger.dart';
+import 'package:collection_tracker/core/analytics/analytics_preferences.dart';
+import 'package:collection_tracker/core/firebase/firebase_runtime_config.dart';
+import 'package:collection_tracker/core/observability/operational_telemetry.dart';
+import 'package:flutter/foundation.dart';
+import 'package:storage/storage.dart';
+
+import 'crashlytics_bootstrap.dart';
+import 'firebase_bootstrap.dart';
+import 'firebase_services_bootstrap.dart';
+
+class AppBootstrapData {
+ const AppBootstrapData({
+ required this.onboardingComplete,
+ required this.firebaseRuntimeConfig,
+ });
+
+ final bool onboardingComplete;
+ final FirebaseRuntimeConfig firebaseRuntimeConfig;
+}
+
+abstract final class AppBootstrap {
+ static Future initialize() async {
+ await _initializeLogger();
+ await PrefsStorageService.instance.init();
+ await FirebaseBootstrap.initialize();
+ final firebaseRuntimeConfig = await FirebaseServicesBootstrap.initialize();
+ await CrashlyticsBootstrap.initialize(
+ collectionEnabled: firebaseRuntimeConfig.crashlyticsCollectionEnabled,
+ );
+ await _initializeAnalytics(
+ analyticsCollectionEnabled:
+ firebaseRuntimeConfig.analyticsCollectionEnabled,
+ );
+ await OperationalTelemetry.trackRuntimeConfigApplied(
+ source: 'bootstrap',
+ analyticsEnabled: firebaseRuntimeConfig.analyticsCollectionEnabled,
+ crashlyticsEnabled: firebaseRuntimeConfig.crashlyticsCollectionEnabled,
+ performanceEnabled: firebaseRuntimeConfig.performanceCollectionEnabled,
+ appCheckEnabled: firebaseRuntimeConfig.appCheckEnabled,
+ fcmEnabled: firebaseRuntimeConfig.fcmEnabled,
+ backendEnabled: firebaseRuntimeConfig.backendIntegrationEnabled,
+ authEnabled: firebaseRuntimeConfig.authFeatureEnabled,
+ syncEnabled: firebaseRuntimeConfig.syncFeatureEnabled,
+ didActivateChanges: null,
+ );
+
+ final onboardingComplete =
+ await PrefsStorageService.instance.get('onboarding_complete') ??
+ false;
+
+ Logger.info('All services are initialized!');
+ return AppBootstrapData(
+ onboardingComplete: onboardingComplete,
+ firebaseRuntimeConfig: firebaseRuntimeConfig,
+ );
+ }
+
+ static Future _initializeLogger() async {
+ await Logger.initialize(
+ config: LoggerConfig(
+ enableConsoleLogging: true,
+ enableFileLogging: true,
+ enableRemoteLogging: false,
+ minLevel: kReleaseMode ? LogLevel.error : LogLevel.debug,
+ logFileNamePrefix: 'collection_tracker_log_',
+ ),
+ );
+ }
+
+ static Future _initializeAnalytics({
+ required bool analyticsCollectionEnabled,
+ }) async {
+ final enabled =
+ PrefsStorageService.instance.readSync(
+ AnalyticsPreferences.enabledPrefKey,
+ ) ??
+ true;
+ final consentCode = PrefsStorageService.instance.readSync(
+ AnalyticsPreferences.consentStatusPrefKey,
+ );
+ final consentStatus = AnalyticsConsentStatusX.fromCode(consentCode);
+
+ final config = AnalyticsConfig(
+ environment: kReleaseMode
+ ? AnalyticsEnvironment.production
+ : AnalyticsEnvironment.development,
+ enableLogging: !kReleaseMode,
+ providers: [
+ if (!kReleaseMode) ConsoleAnalyticsProvider(prettyPrint: true),
+ FirebaseAnalyticsProvider(),
+ ],
+ middleware: [
+ QueueMiddleware(),
+ PIIFilterMiddleware(),
+ ValidationMiddleware(),
+ EnrichmentMiddleware(),
+ ],
+ autoTrackScreenViews: true,
+ requireConsent: true,
+ );
+
+ await AnalyticsService.initialize(config);
+ await AnalyticsService.instance.setTrackingEnabled(
+ enabled && analyticsCollectionEnabled,
+ );
+ await AnalyticsService.instance.setConsentGranted(
+ consentStatus == AnalyticsConsentStatus.granted,
+ );
+ }
+}
diff --git a/apps/mobile/lib/core/bootstrap/crashlytics_bootstrap.dart b/apps/mobile/lib/core/bootstrap/crashlytics_bootstrap.dart
new file mode 100644
index 0000000..9d9c3c1
--- /dev/null
+++ b/apps/mobile/lib/core/bootstrap/crashlytics_bootstrap.dart
@@ -0,0 +1,96 @@
+import 'package:app_logger/app_logger.dart';
+import 'package:firebase_core/firebase_core.dart';
+import 'package:firebase_crashlytics/firebase_crashlytics.dart';
+import 'package:flutter/foundation.dart';
+
+abstract final class CrashlyticsBootstrap {
+ static bool _handlersRegistered = false;
+ static bool _collectionEnabled = false;
+
+ static const _enableInDebug = bool.fromEnvironment(
+ 'ENABLE_CRASHLYTICS_IN_DEBUG',
+ defaultValue: false,
+ );
+
+ static Future initialize({required bool collectionEnabled}) async {
+ if (!_isSupported) {
+ Logger.info(
+ 'Crashlytics skipped: unsupported platform or Firebase unavailable.',
+ );
+ return;
+ }
+
+ await setCollectionEnabled(collectionEnabled: collectionEnabled);
+
+ if (_handlersRegistered) {
+ Logger.info(
+ 'Crashlytics initialized (enabled: $_collectionEnabled, debug: $kDebugMode, debugOverride: $_enableInDebug).',
+ );
+ return;
+ }
+
+ final previousFlutterErrorHandler = FlutterError.onError;
+ FlutterError.onError = (details) {
+ previousFlutterErrorHandler?.call(details);
+ if (_collectionEnabled) {
+ FirebaseCrashlytics.instance.recordFlutterFatalError(details);
+ } else {
+ Logger.error(
+ 'Flutter framework error captured (Crashlytics disabled).',
+ details.exception,
+ details.stack,
+ );
+ }
+ };
+
+ final previousPlatformErrorHandler = PlatformDispatcher.instance.onError;
+ PlatformDispatcher.instance.onError = (error, stackTrace) {
+ if (_collectionEnabled) {
+ FirebaseCrashlytics.instance.recordError(
+ error,
+ stackTrace,
+ fatal: true,
+ reason: 'PlatformDispatcher uncaught error',
+ );
+ } else {
+ Logger.error(
+ 'Uncaught platform error captured (Crashlytics disabled).',
+ error,
+ stackTrace,
+ );
+ }
+ previousPlatformErrorHandler?.call(error, stackTrace);
+ return true;
+ };
+
+ _handlersRegistered = true;
+ Logger.info(
+ 'Crashlytics initialized (enabled: $_collectionEnabled, debug: $kDebugMode, debugOverride: $_enableInDebug).',
+ );
+ }
+
+ static Future setCollectionEnabled({
+ required bool collectionEnabled,
+ }) async {
+ if (!_isSupported) {
+ _collectionEnabled = false;
+ return;
+ }
+
+ _collectionEnabled = _resolveEnabled(collectionEnabled);
+ await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(
+ _collectionEnabled,
+ );
+
+ Logger.info(
+ 'Crashlytics collection updated: $_collectionEnabled '
+ '(remote flag: $collectionEnabled, debug: $kDebugMode, debugOverride: $_enableInDebug).',
+ );
+ }
+
+ static bool get _isSupported => !kIsWeb && Firebase.apps.isNotEmpty;
+
+ static bool _resolveEnabled(bool remoteCollectionEnabled) {
+ return remoteCollectionEnabled && (!kDebugMode || _enableInDebug);
+ }
+}
diff --git a/apps/mobile/lib/core/bootstrap/firebase_bootstrap.dart b/apps/mobile/lib/core/bootstrap/firebase_bootstrap.dart
new file mode 100644
index 0000000..4aa0936
--- /dev/null
+++ b/apps/mobile/lib/core/bootstrap/firebase_bootstrap.dart
@@ -0,0 +1,36 @@
+import 'package:app_logger/app_logger.dart';
+import 'package:collection_tracker/firebase_options.dart';
+import 'package:firebase_core/firebase_core.dart';
+
+abstract final class FirebaseBootstrap {
+ static Future initialize() async {
+ if (Firebase.apps.isNotEmpty) {
+ Logger.debug('Firebase already initialized.');
+ return;
+ }
+
+ try {
+ await Firebase.initializeApp(
+ options: DefaultFirebaseOptions.currentPlatform,
+ );
+ Logger.info('Firebase initialized.');
+ } on UnsupportedError catch (error, stackTrace) {
+ Logger.warning('Firebase initialization skipped: $error');
+ Logger.error('Firebase initialization unsupported.', error, stackTrace);
+ } on FirebaseException catch (error, stackTrace) {
+ Logger.error(
+ 'Firebase initialization failed (${error.code}).',
+ error,
+ stackTrace,
+ );
+ rethrow;
+ } catch (error, stackTrace) {
+ Logger.error(
+ 'Unexpected Firebase initialization failure.',
+ error,
+ stackTrace,
+ );
+ rethrow;
+ }
+ }
+}
diff --git a/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart
new file mode 100644
index 0000000..20ee3cc
--- /dev/null
+++ b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart
@@ -0,0 +1,177 @@
+import 'package:app_firebase/app_firebase.dart';
+import 'package:app_logger/app_logger.dart';
+import 'package:collection_tracker/core/firebase/firebase_runtime_config.dart';
+import 'package:collection_tracker/core/observability/operational_telemetry.dart';
+import 'package:flutter/foundation.dart';
+
+import 'crashlytics_bootstrap.dart';
+
+class FirebaseRuntimeConfigRefreshResult {
+ const FirebaseRuntimeConfigRefreshResult({
+ required this.runtimeConfig,
+ required this.status,
+ required this.didActivateChanges,
+ });
+
+ final FirebaseRuntimeConfig runtimeConfig;
+ final FirebaseRemoteConfigStatus status;
+ final bool didActivateChanges;
+}
+
+abstract final class FirebaseServicesBootstrap {
+ static const _analyticsCollectionEnabledKey =
+ 'app_analytics_collection_enabled';
+ static const _crashlyticsCollectionEnabledKey =
+ 'app_crashlytics_collection_enabled';
+ static const _performanceCollectionEnabledKey =
+ 'app_performance_collection_enabled';
+ static const _appCheckEnabledKey = 'app_app_check_enabled';
+ static const _fcmEnabledKey = 'app_fcm_enabled';
+ static const _backendIntegrationEnabledKey =
+ 'app_backend_integration_enabled';
+ static const _authFeatureEnabledKey = 'app_auth_feature_enabled';
+ static const _syncFeatureEnabledKey = 'app_sync_feature_enabled';
+
+ static Future initialize() async {
+ final remoteConfigService = FirebaseRemoteConfigService.instance;
+
+ await remoteConfigService.initialize(
+ defaults: {
+ _analyticsCollectionEnabledKey: true,
+ _crashlyticsCollectionEnabledKey: true,
+ _performanceCollectionEnabledKey: true,
+ _appCheckEnabledKey: false,
+ _fcmEnabledKey: false,
+ _backendIntegrationEnabledKey: false,
+ _authFeatureEnabledKey: true,
+ _syncFeatureEnabledKey: false,
+ },
+ minimumFetchInterval: kDebugMode
+ ? const Duration(minutes: 5)
+ : const Duration(hours: 12),
+ );
+
+ final runtimeConfig = _readRuntimeConfig(remoteConfigService);
+
+ await FirebaseAppCheckService.instance.initialize(
+ enabled: runtimeConfig.appCheckEnabled,
+ );
+ await FirebaseMessagingService.instance.initialize(
+ enabled: runtimeConfig.fcmEnabled,
+ );
+ await FirebasePerformanceService.instance.initialize(
+ enabled: runtimeConfig.performanceCollectionEnabled,
+ );
+
+ Logger.info(
+ 'Firebase services initialized (analytics: ${runtimeConfig.analyticsCollectionEnabled}, '
+ 'crashlytics: ${runtimeConfig.crashlyticsCollectionEnabled}, '
+ 'performance: ${runtimeConfig.performanceCollectionEnabled}, '
+ 'appCheck: ${runtimeConfig.appCheckEnabled}, '
+ 'fcm: ${runtimeConfig.fcmEnabled}, '
+ 'backend: ${runtimeConfig.backendIntegrationEnabled}, '
+ 'auth: ${runtimeConfig.authFeatureEnabled}, '
+ 'sync: ${runtimeConfig.syncFeatureEnabled}).',
+ );
+
+ return runtimeConfig;
+ }
+
+ static Future refreshRuntimeConfig({
+ bool forceFetch = false,
+ }) async {
+ final remoteConfigService = FirebaseRemoteConfigService.instance;
+ if (!remoteConfigService.isInitialized) {
+ final runtimeConfig = await initialize();
+ return FirebaseRuntimeConfigRefreshResult(
+ runtimeConfig: runtimeConfig,
+ status: remoteConfigService.status,
+ didActivateChanges: false,
+ );
+ }
+
+ final didActivateChanges = forceFetch
+ ? await remoteConfigService.refreshForced()
+ : await remoteConfigService.refresh();
+ final runtimeConfig = _readRuntimeConfig(remoteConfigService);
+
+ await FirebaseAppCheckService.instance.setEnabled(
+ runtimeConfig.appCheckEnabled,
+ );
+ await FirebaseMessagingService.instance.setEnabled(
+ runtimeConfig.fcmEnabled,
+ );
+ await FirebasePerformanceService.instance.setCollectionEnabled(
+ runtimeConfig.performanceCollectionEnabled,
+ );
+ await CrashlyticsBootstrap.setCollectionEnabled(
+ collectionEnabled: runtimeConfig.crashlyticsCollectionEnabled,
+ );
+
+ Logger.info(
+ 'Firebase runtime config refreshed (changed: $didActivateChanges, '
+ 'analytics: ${runtimeConfig.analyticsCollectionEnabled}, '
+ 'crashlytics: ${runtimeConfig.crashlyticsCollectionEnabled}, '
+ 'performance: ${runtimeConfig.performanceCollectionEnabled}, '
+ 'appCheck: ${runtimeConfig.appCheckEnabled}, '
+ 'fcm: ${runtimeConfig.fcmEnabled}, '
+ 'backend: ${runtimeConfig.backendIntegrationEnabled}, '
+ 'auth: ${runtimeConfig.authFeatureEnabled}, '
+ 'sync: ${runtimeConfig.syncFeatureEnabled}).',
+ );
+ await OperationalTelemetry.trackRuntimeConfigApplied(
+ source: forceFetch ? 'manual_refresh' : 'auto_refresh',
+ analyticsEnabled: runtimeConfig.analyticsCollectionEnabled,
+ crashlyticsEnabled: runtimeConfig.crashlyticsCollectionEnabled,
+ performanceEnabled: runtimeConfig.performanceCollectionEnabled,
+ appCheckEnabled: runtimeConfig.appCheckEnabled,
+ fcmEnabled: runtimeConfig.fcmEnabled,
+ backendEnabled: runtimeConfig.backendIntegrationEnabled,
+ authEnabled: runtimeConfig.authFeatureEnabled,
+ syncEnabled: runtimeConfig.syncFeatureEnabled,
+ didActivateChanges: didActivateChanges,
+ );
+
+ return FirebaseRuntimeConfigRefreshResult(
+ runtimeConfig: runtimeConfig,
+ status: remoteConfigService.status,
+ didActivateChanges: didActivateChanges,
+ );
+ }
+
+ static FirebaseRuntimeConfig _readRuntimeConfig(
+ FirebaseRemoteConfigService remoteConfigService,
+ ) {
+ return FirebaseRuntimeConfig(
+ analyticsCollectionEnabled: remoteConfigService.getBool(
+ _analyticsCollectionEnabledKey,
+ fallback: true,
+ ),
+ crashlyticsCollectionEnabled: remoteConfigService.getBool(
+ _crashlyticsCollectionEnabledKey,
+ fallback: true,
+ ),
+ performanceCollectionEnabled: remoteConfigService.getBool(
+ _performanceCollectionEnabledKey,
+ fallback: true,
+ ),
+ appCheckEnabled: remoteConfigService.getBool(
+ _appCheckEnabledKey,
+ fallback: false,
+ ),
+ fcmEnabled: remoteConfigService.getBool(_fcmEnabledKey, fallback: false),
+ backendIntegrationEnabled: remoteConfigService.getBool(
+ _backendIntegrationEnabledKey,
+ fallback: false,
+ ),
+ authFeatureEnabled: remoteConfigService.getBool(
+ _authFeatureEnabledKey,
+ fallback: true,
+ ),
+ syncFeatureEnabled: remoteConfigService.getBool(
+ _syncFeatureEnabledKey,
+ fallback: false,
+ ),
+ );
+ }
+}
diff --git a/apps/mobile/lib/core/firebase/firebase_runtime_config.dart b/apps/mobile/lib/core/firebase/firebase_runtime_config.dart
new file mode 100644
index 0000000..ba53a07
--- /dev/null
+++ b/apps/mobile/lib/core/firebase/firebase_runtime_config.dart
@@ -0,0 +1,32 @@
+class FirebaseRuntimeConfig {
+ const FirebaseRuntimeConfig({
+ required this.analyticsCollectionEnabled,
+ required this.crashlyticsCollectionEnabled,
+ required this.performanceCollectionEnabled,
+ required this.appCheckEnabled,
+ required this.fcmEnabled,
+ required this.backendIntegrationEnabled,
+ required this.authFeatureEnabled,
+ required this.syncFeatureEnabled,
+ });
+
+ static const defaults = FirebaseRuntimeConfig(
+ analyticsCollectionEnabled: true,
+ crashlyticsCollectionEnabled: true,
+ performanceCollectionEnabled: true,
+ appCheckEnabled: false,
+ fcmEnabled: false,
+ backendIntegrationEnabled: false,
+ authFeatureEnabled: true,
+ syncFeatureEnabled: false,
+ );
+
+ final bool analyticsCollectionEnabled;
+ final bool crashlyticsCollectionEnabled;
+ final bool performanceCollectionEnabled;
+ final bool appCheckEnabled;
+ final bool fcmEnabled;
+ final bool backendIntegrationEnabled;
+ final bool authFeatureEnabled;
+ final bool syncFeatureEnabled;
+}
diff --git a/apps/mobile/lib/core/firebase/firebase_runtime_config_auto_refresh.dart b/apps/mobile/lib/core/firebase/firebase_runtime_config_auto_refresh.dart
new file mode 100644
index 0000000..0e165c8
--- /dev/null
+++ b/apps/mobile/lib/core/firebase/firebase_runtime_config_auto_refresh.dart
@@ -0,0 +1,64 @@
+import 'package:app_logger/app_logger.dart';
+import 'package:collection_tracker/core/providers/providers.dart';
+import 'package:flutter/widgets.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+class FirebaseRuntimeConfigAutoRefresh extends ConsumerStatefulWidget {
+ const FirebaseRuntimeConfigAutoRefresh({required this.child, super.key});
+
+ final Widget child;
+
+ @override
+ ConsumerState createState() =>
+ _FirebaseRuntimeConfigAutoRefreshState();
+}
+
+class _FirebaseRuntimeConfigAutoRefreshState
+ extends ConsumerState
+ with WidgetsBindingObserver {
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addObserver(this);
+ }
+
+ @override
+ void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
+ super.dispose();
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ if (state == AppLifecycleState.resumed) {
+ _refreshRuntimeConfigIfDue();
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return widget.child;
+ }
+
+ Future _refreshRuntimeConfigIfDue() async {
+ try {
+ final result = await ref
+ .read(firebaseRuntimeConfigControllerProvider.notifier)
+ .refreshFromRemoteConfigIfDue();
+
+ if (result == null) {
+ return;
+ }
+
+ await ref
+ .read(analyticsPreferencesProvider.notifier)
+ .syncToAnalyticsService();
+ } catch (error, stackTrace) {
+ Logger.error(
+ 'Failed to auto-refresh Firebase runtime config on app resume.',
+ error,
+ stackTrace,
+ );
+ }
+ }
+}
diff --git a/apps/mobile/lib/core/notifications/local_notification_service.dart b/apps/mobile/lib/core/notifications/local_notification_service.dart
new file mode 100644
index 0000000..7208117
--- /dev/null
+++ b/apps/mobile/lib/core/notifications/local_notification_service.dart
@@ -0,0 +1,211 @@
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:app_firebase/app_firebase.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_local_notifications/flutter_local_notifications.dart';
+
+class LocalNotificationService {
+ LocalNotificationService._({FlutterLocalNotificationsPlugin? plugin})
+ : _plugin = plugin ?? FlutterLocalNotificationsPlugin();
+
+ static const _channelId = 'collection_tracker_general';
+ static const _channelName = 'Collection Tracker';
+ static const _channelDescription =
+ 'Sync updates, price alerts, reminders, and account notifications.';
+
+ static LocalNotificationService? _instance;
+
+ static LocalNotificationService get instance {
+ _instance ??= LocalNotificationService._();
+ return _instance!;
+ }
+
+ final FlutterLocalNotificationsPlugin _plugin;
+ final StreamController _routeTapController =
+ StreamController.broadcast();
+
+ bool _initialized = false;
+ String? _initialRouteFromLaunch;
+
+ Stream get onRouteTap => _routeTapController.stream;
+
+ String? takeInitialRoute() {
+ final route = _initialRouteFromLaunch;
+ _initialRouteFromLaunch = null;
+ return route;
+ }
+
+ Future initialize() async {
+ if (_initialized) {
+ return;
+ }
+
+ const initializationSettings = InitializationSettings(
+ android: AndroidInitializationSettings('launcher_icon'),
+ // Permission prompts are handled by FirebaseMessagingService to keep
+ // consent flow centralized (onboarding/settings).
+ iOS: DarwinInitializationSettings(
+ requestAlertPermission: false,
+ requestBadgePermission: false,
+ requestSoundPermission: false,
+ ),
+ macOS: DarwinInitializationSettings(
+ requestAlertPermission: false,
+ requestBadgePermission: false,
+ requestSoundPermission: false,
+ ),
+ );
+
+ await _plugin.initialize(
+ initializationSettings,
+ onDidReceiveNotificationResponse: _onNotificationResponse,
+ onDidReceiveBackgroundNotificationResponse: _onBackgroundResponse,
+ );
+
+ final launchDetails = await _plugin.getNotificationAppLaunchDetails();
+ if (launchDetails?.didNotificationLaunchApp ?? false) {
+ _initialRouteFromLaunch = _extractRouteFromPayload(
+ launchDetails?.notificationResponse?.payload,
+ );
+ }
+
+ await _plugin
+ .resolvePlatformSpecificImplementation<
+ AndroidFlutterLocalNotificationsPlugin
+ >()
+ ?.createNotificationChannel(
+ const AndroidNotificationChannel(
+ _channelId,
+ _channelName,
+ description: _channelDescription,
+ importance: Importance.high,
+ ),
+ );
+
+ _initialized = true;
+ }
+
+ Future showForegroundMessage({
+ required FirebaseMessagingMessage message,
+ required String notificationType,
+ String? route,
+ }) async {
+ if (!_initialized) {
+ await initialize();
+ }
+
+ final title = _resolveTitle(message, notificationType);
+ final body = _resolveBody(message, notificationType);
+ final notificationId = _notificationId(message);
+
+ final payload = jsonEncode({
+ 'route': ?route,
+ 'messageId': ?message.messageId,
+ 'notificationType': notificationType,
+ });
+
+ const details = NotificationDetails(
+ android: AndroidNotificationDetails(
+ _channelId,
+ _channelName,
+ channelDescription: _channelDescription,
+ importance: Importance.max,
+ priority: Priority.high,
+ ),
+ iOS: DarwinNotificationDetails(
+ presentAlert: true,
+ presentBadge: true,
+ presentSound: true,
+ ),
+ macOS: DarwinNotificationDetails(
+ presentAlert: true,
+ presentBadge: true,
+ presentSound: true,
+ ),
+ );
+
+ await _plugin.show(notificationId, title, body, details, payload: payload);
+ }
+
+ void dispose() {
+ _routeTapController.close();
+ }
+
+ Future _onNotificationResponse(NotificationResponse response) async {
+ final route = _extractRouteFromPayload(response.payload);
+ if (route == null || route.isEmpty) {
+ return;
+ }
+ _routeTapController.add(route);
+ }
+
+ @pragma('vm:entry-point')
+ static Future _onBackgroundResponse(
+ NotificationResponse response,
+ ) async {
+ // Route handling for terminated/background app is handled by FCM open events.
+ }
+
+ String _resolveTitle(FirebaseMessagingMessage message, String type) {
+ final title = message.title?.trim();
+ if (title != null && title.isNotEmpty) {
+ return title;
+ }
+
+ return switch (type) {
+ 'sync_needed' => 'Sync needed',
+ 'price_alert' => 'Price alert',
+ 'reminder' => 'Reminder',
+ 'account_security' => 'Account security',
+ _ => 'Collection Tracker',
+ };
+ }
+
+ String _resolveBody(FirebaseMessagingMessage message, String type) {
+ final body = message.body?.trim();
+ if (body != null && body.isNotEmpty) {
+ return body;
+ }
+
+ return switch (type) {
+ 'sync_needed' => 'Open the app to sync your latest changes.',
+ 'price_alert' => 'One of your tracked item prices changed.',
+ 'reminder' => 'You have a reminder from Collection Tracker.',
+ 'account_security' => 'Please review a recent account security event.',
+ _ => 'You have a new notification.',
+ };
+ }
+
+ int _notificationId(FirebaseMessagingMessage message) {
+ final messageId = message.messageId?.trim();
+ if (messageId != null && messageId.isNotEmpty) {
+ return messageId.hashCode & 0x7fffffff;
+ }
+
+ return DateTime.now().millisecondsSinceEpoch & 0x7fffffff;
+ }
+
+ String? _extractRouteFromPayload(String? payload) {
+ if (payload == null || payload.trim().isEmpty) {
+ return null;
+ }
+
+ try {
+ final decoded = jsonDecode(payload);
+ if (decoded is! Map) {
+ return null;
+ }
+ final route = decoded['route']?.toString().trim();
+ if (route == null || route.isEmpty) {
+ return null;
+ }
+ return route;
+ } catch (error) {
+ if (kDebugMode) {
+ debugPrint('Failed to parse local notification payload: $error');
+ }
+ return null;
+ }
+ }
+}
diff --git a/apps/mobile/lib/core/notifications/push_notification_bridge.dart b/apps/mobile/lib/core/notifications/push_notification_bridge.dart
new file mode 100644
index 0000000..a16947a
--- /dev/null
+++ b/apps/mobile/lib/core/notifications/push_notification_bridge.dart
@@ -0,0 +1,206 @@
+import 'dart:async';
+
+import 'package:app_analytics/app_analytics.dart';
+import 'package:app_firebase/app_firebase.dart';
+import 'package:collection_tracker/core/observability/operational_telemetry.dart';
+import 'package:collection_tracker/core/providers/push_notifications_provider.dart';
+import 'package:collection_tracker/core/router/routes.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import 'local_notification_service.dart';
+
+class PushNotificationBridge extends ConsumerStatefulWidget {
+ const PushNotificationBridge({required this.child, super.key});
+
+ final Widget child;
+
+ @override
+ ConsumerState createState() =>
+ _PushNotificationBridgeState();
+}
+
+class _PushNotificationBridgeState
+ extends ConsumerState {
+ StreamSubscription? _foregroundSubscription;
+ StreamSubscription? _openedSubscription;
+ StreamSubscription? _localNotificationTapSubscription;
+ bool _initialized = false;
+
+ @override
+ void initState() {
+ super.initState();
+ unawaited(_initializeMessagingListeners());
+ }
+
+ @override
+ void dispose() {
+ unawaited(_foregroundSubscription?.cancel());
+ unawaited(_openedSubscription?.cancel());
+ unawaited(_localNotificationTapSubscription?.cancel());
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ // Keep push preferences controller alive so runtime/permission/topic sync stays active.
+ ref.watch(pushNotificationPreferencesProvider);
+ return widget.child;
+ }
+
+ Future _initializeMessagingListeners() async {
+ if (_initialized) {
+ return;
+ }
+ _initialized = true;
+
+ final messaging = FirebaseMessagingService.instance;
+ final localNotifications = LocalNotificationService.instance;
+ await localNotifications.initialize();
+
+ _localNotificationTapSubscription = localNotifications.onRouteTap.listen((
+ route,
+ ) {
+ _navigateToRoute(route);
+ });
+
+ final initialLocalRoute = localNotifications.takeInitialRoute();
+ if (initialLocalRoute != null && initialLocalRoute.isNotEmpty) {
+ _navigateToRoute(initialLocalRoute);
+ }
+
+ _foregroundSubscription = messaging.onMessage.listen((message) {
+ unawaited(_handleForegroundMessage(message));
+ });
+ _openedSubscription = messaging.onMessageOpenedApp.listen((message) {
+ unawaited(_handleOpenedMessage(message, launchedFromTerminated: false));
+ });
+
+ final initialMessage = await messaging.getInitialMessage();
+ if (initialMessage != null && mounted) {
+ await _handleOpenedMessage(initialMessage, launchedFromTerminated: true);
+ }
+ }
+
+ Future _handleForegroundMessage(
+ FirebaseMessagingMessage message,
+ ) async {
+ final notificationType = _notificationType(message.data);
+ final route = _resolveRoute(message);
+
+ await AnalyticsService.instance.track(
+ NotificationEvents.notificationReceived(
+ notificationType: notificationType,
+ campaignId: _campaignId(message.data),
+ properties: {'route': ?route, 'message_id': ?message.messageId},
+ ),
+ );
+ await OperationalTelemetry.trackPushMessageReceived(
+ notificationType: notificationType,
+ hasRoute: route != null,
+ );
+
+ await LocalNotificationService.instance.showForegroundMessage(
+ message: message,
+ notificationType: notificationType,
+ route: route,
+ );
+ }
+
+ Future _handleOpenedMessage(
+ FirebaseMessagingMessage message, {
+ required bool launchedFromTerminated,
+ }) async {
+ final notificationType = _notificationType(message.data);
+ final route = _resolveRoute(message);
+
+ await AnalyticsService.instance.track(
+ NotificationEvents.notificationOpened(
+ notificationType: notificationType,
+ campaignId: _campaignId(message.data),
+ action: route == null ? 'no_route' : 'navigate',
+ properties: {'route': ?route, 'message_id': ?message.messageId},
+ ),
+ );
+ await OperationalTelemetry.trackPushMessageOpened(
+ notificationType: notificationType,
+ hasRoute: route != null,
+ launchedFromTerminated: launchedFromTerminated,
+ );
+
+ if (route == null || !mounted) {
+ return;
+ }
+
+ _navigateToRoute(route);
+ }
+
+ void _navigateToRoute(String route) {
+ if (!mounted) {
+ return;
+ }
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) {
+ return;
+ }
+ try {
+ GoRouter.of(context).go(route);
+ } catch (_) {
+ // Best-effort navigation for push intents.
+ }
+ });
+ }
+
+ String? _resolveRoute(FirebaseMessagingMessage message) {
+ final explicitRoute = message.data['route']?.trim();
+ if (explicitRoute != null && explicitRoute.startsWith('/')) {
+ return explicitRoute;
+ }
+
+ final itemId = (message.data['itemId'] ?? message.data['item_id'] ?? '')
+ .trim();
+ if (itemId.isNotEmpty) {
+ return '/items/$itemId';
+ }
+
+ final collectionId =
+ (message.data['collectionId'] ?? message.data['collection_id'] ?? '')
+ .trim();
+ if (collectionId.isNotEmpty) {
+ return '/collections/$collectionId';
+ }
+
+ return switch (_notificationType(message.data)) {
+ 'sync_needed' => Routes.settings,
+ 'price_alert' => Routes.statistics,
+ 'reminder' => Routes.collections,
+ 'account_security' => '${Routes.auth}?mode=signin',
+ _ => null,
+ };
+ }
+
+ String _notificationType(Map data) {
+ final candidates = [
+ data['notification_type'],
+ data['type'],
+ data['event'],
+ data['category'],
+ ];
+ for (final candidate in candidates) {
+ final normalized = (candidate ?? '').trim().toLowerCase();
+ if (normalized.isNotEmpty) {
+ return normalized;
+ }
+ }
+ return 'unknown';
+ }
+
+ String? _campaignId(Map data) {
+ final raw = (data['campaign_id'] ?? data['campaignId'] ?? '').trim();
+ if (raw.isEmpty) {
+ return null;
+ }
+ return raw;
+ }
+}
diff --git a/apps/mobile/lib/core/observability/operational_telemetry.dart b/apps/mobile/lib/core/observability/operational_telemetry.dart
new file mode 100644
index 0000000..dcbaa1c
--- /dev/null
+++ b/apps/mobile/lib/core/observability/operational_telemetry.dart
@@ -0,0 +1,408 @@
+import 'dart:convert';
+
+import 'package:app_analytics/app_analytics.dart';
+import 'package:firebase_core/firebase_core.dart';
+import 'package:firebase_crashlytics/firebase_crashlytics.dart';
+import 'package:flutter/foundation.dart';
+import 'package:storage/storage.dart';
+
+class OperationalTelemetry {
+ const OperationalTelemetry._();
+
+ static const String _historyKey = 'operational_telemetry_history_v1';
+ static const int _maxHistoryEntries = 80;
+
+ static Future trackSyncAttempt({
+ required String trigger,
+ required String readinessStatus,
+ required int pendingBefore,
+ }) {
+ return _trackEvent(
+ name: 'sync_attempted',
+ category: 'sync',
+ properties: {
+ 'trigger': trigger,
+ 'readiness_status': readinessStatus,
+ 'pending_before': pendingBefore,
+ },
+ crashlyticsLog: true,
+ );
+ }
+
+ static Future trackSyncSeed({
+ required int queuedOperations,
+ required bool skipped,
+ }) {
+ return _trackEvent(
+ name: 'sync_seed_prepared',
+ category: 'sync',
+ properties: {'queued_operations': queuedOperations, 'skipped': skipped},
+ crashlyticsLog: true,
+ );
+ }
+
+ static Future trackSyncResult({
+ required bool success,
+ required bool executed,
+ required bool partial,
+ required int pendingOperations,
+ required int processedOperations,
+ required int syncedCollections,
+ required int syncedItems,
+ required int syncedTags,
+ required int conflictCount,
+ required int appliedServerCollections,
+ required int appliedServerItems,
+ required int appliedServerTags,
+ required int skippedServerCollections,
+ required int skippedServerItems,
+ required int skippedServerTags,
+ String? message,
+ Object? error,
+ StackTrace? stackTrace,
+ }) {
+ return _trackEvent(
+ name: 'sync_completed',
+ category: 'sync',
+ properties: {
+ 'success': success,
+ 'executed': executed,
+ 'partial': partial,
+ 'pending_operations': pendingOperations,
+ 'processed_operations': processedOperations,
+ 'synced_collections': syncedCollections,
+ 'synced_items': syncedItems,
+ 'synced_tags': syncedTags,
+ 'conflicts': conflictCount,
+ 'applied_server_collections': appliedServerCollections,
+ 'applied_server_items': appliedServerItems,
+ 'applied_server_tags': appliedServerTags,
+ 'skipped_server_collections': skippedServerCollections,
+ 'skipped_server_items': skippedServerItems,
+ 'skipped_server_tags': skippedServerTags,
+ if (message != null && message.trim().isNotEmpty) 'message': message,
+ },
+ crashlyticsLog: true,
+ error: error,
+ stackTrace: stackTrace,
+ includeErrorInCrashlytics: !success && error != null,
+ errorReason: 'sync_completed_failed',
+ crashlyticsKeys: {
+ 'sync_success': success,
+ 'sync_partial': partial,
+ 'sync_executed': executed,
+ 'sync_pending_ops': pendingOperations,
+ 'sync_processed_ops': processedOperations,
+ 'sync_applied_server':
+ appliedServerCollections + appliedServerItems + appliedServerTags,
+ 'sync_skipped_server':
+ skippedServerCollections + skippedServerItems + skippedServerTags,
+ },
+ );
+ }
+
+ static Future trackSyncQueueRebuild({
+ required bool success,
+ required int queuedOperations,
+ Object? error,
+ StackTrace? stackTrace,
+ }) {
+ return _trackEvent(
+ name: 'sync_queue_rebuild',
+ category: 'sync',
+ properties: {'success': success, 'queued_operations': queuedOperations},
+ crashlyticsLog: true,
+ error: error,
+ stackTrace: stackTrace,
+ includeErrorInCrashlytics: !success && error != null,
+ errorReason: 'sync_queue_rebuild_failed',
+ );
+ }
+
+ static Future trackDataTransfer({
+ required String operation,
+ required bool success,
+ required int durationMs,
+ int? collectionCount,
+ int? itemCount,
+ Object? error,
+ StackTrace? stackTrace,
+ }) {
+ return _trackEvent(
+ name: 'data_transfer_completed',
+ category: 'settings_data',
+ properties: {
+ 'operation': operation,
+ 'success': success,
+ 'duration_ms': durationMs,
+ 'collection_count': ?collectionCount,
+ 'item_count': ?itemCount,
+ },
+ crashlyticsLog: true,
+ error: error,
+ stackTrace: stackTrace,
+ includeErrorInCrashlytics: !success && error != null,
+ errorReason: 'data_transfer_failed',
+ );
+ }
+
+ static Future trackRuntimeConfigApplied({
+ required String source,
+ required bool analyticsEnabled,
+ required bool crashlyticsEnabled,
+ required bool performanceEnabled,
+ required bool appCheckEnabled,
+ required bool fcmEnabled,
+ required bool backendEnabled,
+ required bool authEnabled,
+ required bool syncEnabled,
+ bool? didActivateChanges,
+ }) {
+ return _trackEvent(
+ name: 'runtime_config_applied',
+ category: 'operations',
+ properties: {
+ 'source': source,
+ 'analytics_enabled': analyticsEnabled,
+ 'crashlytics_enabled': crashlyticsEnabled,
+ 'performance_enabled': performanceEnabled,
+ 'app_check_enabled': appCheckEnabled,
+ 'fcm_enabled': fcmEnabled,
+ 'backend_enabled': backendEnabled,
+ 'auth_enabled': authEnabled,
+ 'sync_enabled': syncEnabled,
+ 'changed': ?didActivateChanges,
+ },
+ crashlyticsLog: true,
+ crashlyticsKeys: {
+ 'flag_analytics_enabled': analyticsEnabled,
+ 'flag_crashlytics_enabled': crashlyticsEnabled,
+ 'flag_performance_enabled': performanceEnabled,
+ 'flag_app_check_enabled': appCheckEnabled,
+ 'flag_fcm_enabled': fcmEnabled,
+ 'flag_backend_enabled': backendEnabled,
+ 'flag_auth_enabled': authEnabled,
+ 'flag_sync_enabled': syncEnabled,
+ },
+ );
+ }
+
+ static Future trackPushPermission({
+ required String status,
+ required bool preferenceEnabled,
+ required bool runtimeEnabled,
+ }) {
+ return _trackEvent(
+ name: 'push_permission_updated',
+ category: 'push',
+ properties: {
+ 'status': status,
+ 'preference_enabled': preferenceEnabled,
+ 'runtime_enabled': runtimeEnabled,
+ },
+ crashlyticsLog: true,
+ );
+ }
+
+ static Future trackPushTokenUpdate({
+ required bool hasToken,
+ required String source,
+ }) {
+ return _trackEvent(
+ name: 'push_token_updated',
+ category: 'push',
+ properties: {'has_token': hasToken, 'source': source},
+ crashlyticsLog: true,
+ );
+ }
+
+ static Future trackPushMessageReceived({
+ required String notificationType,
+ required bool hasRoute,
+ }) {
+ return _trackEvent(
+ name: 'push_message_received',
+ category: 'push',
+ properties: {
+ 'notification_type': notificationType,
+ 'has_route': hasRoute,
+ },
+ crashlyticsLog: true,
+ );
+ }
+
+ static Future trackPushMessageOpened({
+ required String notificationType,
+ required bool hasRoute,
+ required bool launchedFromTerminated,
+ }) {
+ return _trackEvent(
+ name: 'push_message_opened',
+ category: 'push',
+ properties: {
+ 'notification_type': notificationType,
+ 'has_route': hasRoute,
+ 'from_terminated': launchedFromTerminated,
+ },
+ crashlyticsLog: true,
+ );
+ }
+
+ static Future _trackEvent({
+ required String name,
+ required String category,
+ required Map properties,
+ required bool crashlyticsLog,
+ Map? crashlyticsKeys,
+ Object? error,
+ StackTrace? stackTrace,
+ bool includeErrorInCrashlytics = false,
+ String? errorReason,
+ }) async {
+ final cleanedProperties = _compact(properties);
+
+ try {
+ await AnalyticsService.instance.track(
+ AnalyticsEvent.custom(
+ name: name,
+ category: category,
+ properties: cleanedProperties,
+ ),
+ );
+ } catch (_) {
+ // Keep operational telemetry best-effort.
+ }
+
+ if (!_canUseCrashlytics) {
+ return;
+ }
+
+ try {
+ if (crashlyticsKeys != null) {
+ for (final entry in crashlyticsKeys.entries) {
+ final value = entry.value;
+ if (value is bool ||
+ value is int ||
+ value is double ||
+ value is String) {
+ await FirebaseCrashlytics.instance.setCustomKey(
+ entry.key,
+ value as Object,
+ );
+ } else if (value != null) {
+ await FirebaseCrashlytics.instance.setCustomKey(
+ entry.key,
+ value.toString(),
+ );
+ }
+ }
+ }
+
+ if (crashlyticsLog) {
+ await FirebaseCrashlytics.instance.log(
+ '$name ${jsonEncode(_stringifyValues(cleanedProperties))}',
+ );
+ }
+
+ if (includeErrorInCrashlytics && error != null) {
+ await FirebaseCrashlytics.instance.recordError(
+ error,
+ stackTrace,
+ fatal: false,
+ reason: errorReason ?? name,
+ information: [cleanedProperties],
+ );
+ }
+ } catch (_) {
+ // Keep operational telemetry best-effort.
+ }
+
+ await _persistEvent(
+ name: name,
+ category: category,
+ properties: cleanedProperties,
+ hasError: error != null,
+ );
+ }
+
+ static Future>> loadRecentHistory({
+ int limit = 40,
+ }) async {
+ try {
+ final raw = await PrefsStorageService.instance.get>(
+ _historyKey,
+ );
+ if (raw == null || raw.isEmpty) {
+ return const