From ed6e9843702546c2c5fed3d53449eb85bd7891f6 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sat, 21 Feb 2026 23:50:11 +0630 Subject: [PATCH 01/31] feat: Integrate Firebase and Crashlytics with automated configuration setup and app bootstrapping. --- .github/workflows/ci.yaml | 28 ++++ .github/workflows/release.yaml | 14 ++ apps/mobile/.gitignore | 14 ++ apps/mobile/android/app/build.gradle.kts | 3 + apps/mobile/android/settings.gradle.kts | 3 + apps/mobile/firebase.json | 1 + .../ios/Runner.xcodeproj/project.pbxproj | 4 + .../lib/core/bootstrap/app_bootstrap.dart | 60 +++++++ .../core/bootstrap/crashlytics_bootstrap.dart | 65 ++++++++ .../core/bootstrap/firebase_bootstrap.dart | 36 +++++ apps/mobile/lib/main.dart | 56 +------ .../Flutter/GeneratedPluginRegistrant.swift | 2 + .../macos/Runner.xcodeproj/project.pbxproj | 10 +- apps/mobile/pubspec.yaml | 5 + scripts/setup_firebase.sh | 153 ++++++++++++++++++ 15 files changed, 400 insertions(+), 54 deletions(-) create mode 100644 apps/mobile/firebase.json create mode 100644 apps/mobile/lib/core/bootstrap/app_bootstrap.dart create mode 100644 apps/mobile/lib/core/bootstrap/crashlytics_bootstrap.dart create mode 100644 apps/mobile/lib/core/bootstrap/firebase_bootstrap.dart create mode 100755 scripts/setup_firebase.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 495f65c..fc70e64 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -51,6 +51,20 @@ 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 }} + FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST: ${{ secrets.FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST }} + FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64: ${{ secrets.FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64 }} + FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST: ${{ secrets.FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST }} + FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST_BASE64: ${{ secrets.FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST_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 +107,20 @@ 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 }} + FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST: ${{ secrets.FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST }} + FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64: ${{ secrets.FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64 }} + FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST: ${{ secrets.FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST }} + FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST_BASE64: ${{ secrets.FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST_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/release.yaml b/.github/workflows/release.yaml index f83b388..03a4aa9 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -48,6 +48,20 @@ 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 }} + FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST: ${{ secrets.FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST }} + FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64: ${{ secrets.FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64 }} + FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST: ${{ secrets.FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST }} + FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST_BASE64: ${{ secrets.FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST_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/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/android/app/build.gradle.kts b/apps/mobile/android/app/build.gradle.kts index 41d569f..5ca21fe 100644 --- a/apps/mobile/android/app/build.gradle.kts +++ b/apps/mobile/android/app/build.gradle.kts @@ -3,6 +3,9 @@ import java.io.FileInputStream plugins { id("com.android.application") + // START: FlutterFire Configuration + id("com.google.gms.google-services") + // 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") diff --git a/apps/mobile/android/settings.gradle.kts b/apps/mobile/android/settings.gradle.kts index ca7fe06..174f408 100644 --- a/apps/mobile/android/settings.gradle.kts +++ b/apps/mobile/android/settings.gradle.kts @@ -20,6 +20,9 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.11.1" apply false + // START: FlutterFire Configuration + id("com.google.gms.google-services") version("4.3.15") apply false + // END: FlutterFire Configuration id("org.jetbrains.kotlin.android") version "2.2.20" apply false } diff --git a/apps/mobile/firebase.json b/apps/mobile/firebase.json new file mode 100644 index 0000000..8af5087 --- /dev/null +++ b/apps/mobile/firebase.json @@ -0,0 +1 @@ +{"flutter":{"platforms":{"android":{"default":{"projectId":"collection-tracker-39c1f","appId":"1:423715760259:android:32fca335f5a736c66ed3b6","fileOutput":"android/app/google-services.json"}},"ios":{"default":{"projectId":"collection-tracker-39c1f","appId":"1:423715760259:ios:f146491e89b7b7c26ed3b6","uploadDebugSymbols":false,"fileOutput":"ios/Runner/GoogleService-Info.plist"}},"macos":{"default":{"projectId":"collection-tracker-39c1f","appId":"1:423715760259:ios:f146491e89b7b7c26ed3b6","uploadDebugSymbols":false,"fileOutput":"macos/Runner/GoogleService-Info.plist"}},"dart":{"lib/firebase_options.dart":{"projectId":"collection-tracker-39c1f","configurations":{"android":"1:423715760259:android:32fca335f5a736c66ed3b6","ios":"1:423715760259:ios:f146491e89b7b7c26ed3b6","macos":"1:423715760259:ios:f146491e89b7b7c26ed3b6","web":"1:423715760259:web:13c2bdee2346fd596ed3b6"}}}}}} \ No newline at end of file diff --git a/apps/mobile/ios/Runner.xcodeproj/project.pbxproj b/apps/mobile/ios/Runner.xcodeproj/project.pbxproj index c286a91..e46ed64 100644 --- a/apps/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 4F061851CC8FE7EC8E55A42F /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4765A5CAA0BA16CF3EE49F99 /* GoogleService-Info.plist */; }; 5AA453720C1EDB409B6F19B4 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9FC8F4CF8423C5DD73D07B9E /* Pods_Runner.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; @@ -51,6 +52,7 @@ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 4765A5CAA0BA16CF3EE49F99 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; 59C00BE55F07081017B5A5D3 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 63C20FCC630A89C239C10AC7 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 6E4E524434F226C6F73555E3 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; @@ -130,6 +132,7 @@ 331C8082294A63A400263BE5 /* RunnerTests */, F72CB1BE9D16018D71B9D6D8 /* Pods */, 409215A34ECF8AF2471E3EE8 /* Frameworks */, + 4765A5CAA0BA16CF3EE49F99 /* GoogleService-Info.plist */, ); sourceTree = ""; }; @@ -277,6 +280,7 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + 4F061851CC8FE7EC8E55A42F /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; 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..a5dc608 --- /dev/null +++ b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart @@ -0,0 +1,60 @@ +import 'package:app_analytics/app_analytics.dart'; +import 'package:app_logger/app_logger.dart'; +import 'package:flutter/foundation.dart'; +import 'package:storage/storage.dart'; + +import 'crashlytics_bootstrap.dart'; +import 'firebase_bootstrap.dart'; + +class AppBootstrapData { + const AppBootstrapData({required this.onboardingComplete}); + + final bool onboardingComplete; +} + +abstract final class AppBootstrap { + static Future initialize() async { + await _initializeLogger(); + await PrefsStorageService.instance.init(); + await FirebaseBootstrap.initialize(); + await CrashlyticsBootstrap.initialize(); + await _initializeAnalytics(); + + final onboardingComplete = + await PrefsStorageService.instance.get('onboarding_complete') ?? + false; + + Logger.info('All services are initialized!'); + return AppBootstrapData(onboardingComplete: onboardingComplete); + } + + 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() async { + final config = AnalyticsConfig( + environment: AnalyticsEnvironment.development, + enableLogging: true, + providers: [ConsoleAnalyticsProvider(prettyPrint: true)], + middleware: [ + QueueMiddleware(), + PIIFilterMiddleware(), + ValidationMiddleware(), + EnrichmentMiddleware(), + ], + autoTrackScreenViews: true, + requireConsent: false, + ); + + await AnalyticsService.initialize(config); + } +} 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..d0a493e --- /dev/null +++ b/apps/mobile/lib/core/bootstrap/crashlytics_bootstrap.dart @@ -0,0 +1,65 @@ +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 Future initialize() async { + const enableInDebug = bool.fromEnvironment( + 'ENABLE_CRASHLYTICS_IN_DEBUG', + defaultValue: false, + ); + final crashlyticsSupported = !kIsWeb && Firebase.apps.isNotEmpty; + final crashlyticsEnabled = + crashlyticsSupported && (!kDebugMode || enableInDebug); + + if (!crashlyticsSupported) { + Logger.info( + 'Crashlytics skipped: unsupported platform or Firebase unavailable.', + ); + return; + } + + await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled( + crashlyticsEnabled, + ); + + final previousFlutterErrorHandler = FlutterError.onError; + FlutterError.onError = (details) { + previousFlutterErrorHandler?.call(details); + if (crashlyticsEnabled) { + 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 (crashlyticsEnabled) { + 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; + }; + + Logger.info( + 'Crashlytics initialized (enabled: $crashlyticsEnabled, debug: $kDebugMode, debugOverride: $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/main.dart b/apps/mobile/lib/main.dart index 3494e60..f0e50d5 100644 --- a/apps/mobile/lib/main.dart +++ b/apps/mobile/lib/main.dart @@ -1,68 +1,22 @@ -import 'package:app_analytics/app_analytics.dart'; -import 'package:app_logger/app_logger.dart'; import 'package:collection_tracker/app.dart'; -import 'package:flutter/foundation.dart'; +import 'package:collection_tracker/core/bootstrap/app_bootstrap.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -export 'package:storage/storage.dart'; import 'package:collection_tracker/core/observers/riverpod_logger.dart'; -import 'package:storage/storage.dart'; import 'core/providers/providers.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - // Initialize with configuration - await Logger.initialize( - config: LoggerConfig( - enableConsoleLogging: true, - enableFileLogging: true, - enableRemoteLogging: false, - minLevel: kReleaseMode ? LogLevel.error : LogLevel.debug, - logFileNamePrefix: 'collection_tracker_log_', - ), - ); - - // Initialize PrefsStorageService - await PrefsStorageService.instance.init(); - - // Check onboarding status - final prefs = PrefsStorageService.instance; - final onboardingComplete = - await prefs.get('onboarding_complete') ?? false; - - // Configure analytics - final config = AnalyticsConfig( - environment: AnalyticsEnvironment.development, - enableLogging: true, - providers: [ - ConsoleAnalyticsProvider(prettyPrint: true), - // GoogleAnalytics4Provider( - // measurementId: 'G-XXXXXXXXXX', // Replace with valid ID - // apiSecret: 'YOUR_API_SECRET', // Optional, for Measurement Protocol - // ), - ], - middleware: [ - // ConsentMiddleware(), // Check consent first - QueueMiddleware(), // Queue offline events - PIIFilterMiddleware(), // Remove PII - ValidationMiddleware(), // Validate events - EnrichmentMiddleware(), // Add common properties - ], - autoTrackScreenViews: true, - requireConsent: false, - ); - - // Initialize - await AnalyticsService.initialize(config); - - Logger.info("All services are initialized!"); + final bootstrapData = await AppBootstrap.initialize(); runApp( ProviderScope( overrides: [ - onboardingCompleteProvider.overrideWith((ref) => onboardingComplete), + onboardingCompleteProvider.overrideWith( + (ref) => bootstrapData.onboardingComplete, + ), ], observers: [RiverpodLogger()], child: const CollectionTrackerApp(), diff --git a/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift index 72466ee..f3ca0da 100644 --- a/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,6 +11,7 @@ import file_picker import file_selector_macos import firebase_analytics import firebase_core +import firebase_crashlytics import flutter_secure_storage_darwin import mobile_scanner import package_info_plus @@ -27,6 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseCrashlyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCrashlyticsPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) diff --git a/apps/mobile/macos/Runner.xcodeproj/project.pbxproj b/apps/mobile/macos/Runner.xcodeproj/project.pbxproj index e5a4d02..563e734 100644 --- a/apps/mobile/macos/Runner.xcodeproj/project.pbxproj +++ b/apps/mobile/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 5BB041B1B47954DA44ED6D37 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 10880302CE76EC404DE5F247 /* GoogleService-Info.plist */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ @@ -61,11 +62,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 10880302CE76EC404DE5F247 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* collection_tracker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "collection_tracker.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* collection_tracker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = collection_tracker.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -128,6 +130,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + 10880302CE76EC404DE5F247 /* GoogleService-Info.plist */, ); sourceTree = ""; }; @@ -268,7 +271,7 @@ ); mainGroup = 33CC10E42044A3C60003C045; packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; projectDirPath = ""; @@ -295,6 +298,7 @@ files = ( 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + 5BB041B1B47954DA44ED6D37 /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -712,7 +716,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { isa = XCLocalSwiftPackageReference; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; }; diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index 5936d3c..f1b1b7e 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -31,6 +31,11 @@ dependencies: intl: ^0.20.2 uuid: ^4.5.2 + # Firebase + firebase_core: ^4.4.0 + firebase_analytics: ^12.1.2 + firebase_crashlytics: ^5.0.7 + # Local workspace packages domain: path: ../../packages/core/domain diff --git a/scripts/setup_firebase.sh b/scripts/setup_firebase.sh new file mode 100755 index 0000000..cdec65b --- /dev/null +++ b/scripts/setup_firebase.sh @@ -0,0 +1,153 @@ +#!/bin/bash +set -euo pipefail + +# Materialize Firebase config files from environment variables. +# Supports both raw and base64 values for each target file. +# +# Supported environment variables: +# - FIREBASE_OPTIONS_DART or FIREBASE_OPTIONS_DART_BASE64 +# - FIREBASE_ANDROID_GOOGLE_SERVICES_JSON or FIREBASE_ANDROID_GOOGLE_SERVICES_JSON_BASE64 +# - FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST or FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64 +# - FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST or FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST_BASE64 +# +# Optional flags: +# - --require where target in: dart, android, ios, macos + +WORKSPACE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP_DIR="$WORKSPACE_ROOT/apps/mobile" + +declare -a REQUIRED_TARGETS=() + +usage() { + cat <&2 + usage + exit 1 + fi + REQUIRED_TARGETS+=("$2") + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +for target in "${REQUIRED_TARGETS[@]-}"; do + if [[ -z "$target" ]]; then + continue + fi + case "$target" in + dart|android|ios|macos) + ;; + *) + echo "Unknown required target: $target" >&2 + usage + exit 1 + ;; + esac +done + +is_required() { + local target="$1" + for required in "${REQUIRED_TARGETS[@]-}"; do + if [[ -z "$required" ]]; then + continue + fi + if [[ "$required" == "$target" ]]; then + return 0 + fi + done + return 1 +} + +decode_base64_to_file() { + local encoded="$1" + local out_file="$2" + + if printf '%s' "$encoded" | base64 --decode > "$out_file" 2>/dev/null; then + return 0 + fi + + printf '%s' "$encoded" | base64 -D > "$out_file" +} + +write_secret_file() { + local target="$1" + local out_file="$2" + local raw_var_name="$3" + local b64_var_name="$4" + + local raw_value="${!raw_var_name:-}" + local b64_value="${!b64_var_name:-}" + + mkdir -p "$(dirname "$out_file")" + + if [[ -n "$b64_value" ]]; then + decode_base64_to_file "$b64_value" "$out_file" + echo "[ok] Wrote $target config: $out_file" + return 0 + fi + + if [[ -n "$raw_value" ]]; then + printf '%s' "$raw_value" > "$out_file" + echo "[ok] Wrote $target config: $out_file" + return 0 + fi + + if is_required "$target"; then + echo "[error] Missing required Firebase config for '$target'." >&2 + echo " Expected $raw_var_name or $b64_var_name." >&2 + return 1 + fi + + echo "[skip] No Firebase config provided for '$target', skipping." + return 0 +} + +FAILURES=0 + +write_secret_file \ + "dart" \ + "$APP_DIR/lib/firebase_options.dart" \ + "FIREBASE_OPTIONS_DART" \ + "FIREBASE_OPTIONS_DART_BASE64" || FAILURES=$((FAILURES + 1)) + +write_secret_file \ + "android" \ + "$APP_DIR/android/app/google-services.json" \ + "FIREBASE_ANDROID_GOOGLE_SERVICES_JSON" \ + "FIREBASE_ANDROID_GOOGLE_SERVICES_JSON_BASE64" || FAILURES=$((FAILURES + 1)) + +write_secret_file \ + "ios" \ + "$APP_DIR/ios/Runner/GoogleService-Info.plist" \ + "FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST" \ + "FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64" || FAILURES=$((FAILURES + 1)) + +write_secret_file \ + "macos" \ + "$APP_DIR/macos/Runner/GoogleService-Info.plist" \ + "FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST" \ + "FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST_BASE64" || FAILURES=$((FAILURES + 1)) + +if [[ $FAILURES -gt 0 ]]; then + echo "Firebase setup failed with $FAILURES missing required target(s)." >&2 + exit 1 +fi + +echo "Firebase setup completed." From 88e19c88953777c44cf889990e28707dbcc3e04d Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 00:01:02 +0630 Subject: [PATCH 02/31] feat: Add developer settings section with Crashlytics test functionality and update localizations. --- .vscode/launch.json | 5 +- .../lib/core/bootstrap/app_bootstrap.dart | 5 +- .../presentation/views/settings_screen.dart | 73 +++++++++++++++++++ apps/mobile/lib/l10n/arb/app_en.arb | 22 ++++++ apps/mobile/lib/l10n/arb/app_es.arb | 22 ++++++ apps/mobile/lib/l10n/arb/app_id.arb | 22 ++++++ apps/mobile/lib/l10n/arb/app_ja.arb | 22 ++++++ apps/mobile/lib/l10n/arb/app_ko.arb | 22 ++++++ apps/mobile/lib/l10n/arb/app_my.arb | 22 ++++++ apps/mobile/lib/l10n/arb/app_zh.arb | 22 ++++++ .../lib/l10n/gen/app_localizations.dart | 48 ++++++++++++ .../lib/l10n/gen/app_localizations_en.dart | 26 +++++++ .../lib/l10n/gen/app_localizations_es.dart | 26 +++++++ .../lib/l10n/gen/app_localizations_id.dart | 26 +++++++ .../lib/l10n/gen/app_localizations_ja.dart | 26 +++++++ .../lib/l10n/gen/app_localizations_ko.dart | 26 +++++++ .../lib/l10n/gen/app_localizations_my.dart | 26 +++++++ .../lib/l10n/gen/app_localizations_zh.dart | 26 +++++++ .../common/ui/lib/src/widgets/app_dialog.dart | 1 + 19 files changed, 466 insertions(+), 2 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index e94b835..f6880c8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,7 +4,10 @@ "name": "Flutter", "type": "dart", "request": "launch", - "program": "apps/mobile/lib/main.dart" + "program": "apps/mobile/lib/main.dart", + "args": [ + "--dart-define=ENABLE_CRASHLYTICS_IN_DEBUG=true" + ] } ] } diff --git a/apps/mobile/lib/core/bootstrap/app_bootstrap.dart b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart index a5dc608..add7149 100644 --- a/apps/mobile/lib/core/bootstrap/app_bootstrap.dart +++ b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart @@ -44,7 +44,10 @@ abstract final class AppBootstrap { final config = AnalyticsConfig( environment: AnalyticsEnvironment.development, enableLogging: true, - providers: [ConsoleAnalyticsProvider(prettyPrint: true)], + providers: [ + ConsoleAnalyticsProvider(prettyPrint: true), + FirebaseAnalyticsProvider(), + ], middleware: [ QueueMiddleware(), PIIFilterMiddleware(), diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart index f18da65..88bd020 100644 --- a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart +++ b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart @@ -1,5 +1,8 @@ +import 'package:app_logger/app_logger.dart'; import 'package:collection_tracker/core/providers/providers.dart'; import 'package:collection_tracker/l10n/l10n.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -112,6 +115,23 @@ class SettingsScreen extends ConsumerWidget { ], ), ), + if (kDebugMode) ...[ + const SizedBox(height: AppSpacing.lg), + AppReveal( + delay: AppMotion.stagger * 3, + child: _SettingsSection( + title: l10n.settingsSectionDeveloper, + children: [ + _SettingsTile( + icon: Icons.bug_report_outlined, + title: l10n.settingsCrashlyticsTestTitle, + subtitle: l10n.settingsCrashlyticsTestSubtitle, + onTap: () => _handleCrashlyticsTest(context), + ), + ], + ), + ), + ], ], ), ); @@ -339,6 +359,59 @@ class SettingsScreen extends ConsumerWidget { ); } + Future _handleCrashlyticsTest(BuildContext context) async { + final l10n = context.l10n; + final shouldCrash = await showAppDialog( + context: context, + title: Text(l10n.settingsCrashlyticsTestConfirmTitle), + content: Text(l10n.settingsCrashlyticsTestConfirmMessage), + actions: [ + AppButton( + label: l10n.actionCancel, + variant: AppButtonVariant.ghost, + onPressed: () => Navigator.pop(context, false), + ), + AppButton( + label: l10n.settingsCrashlyticsTestConfirmAction, + variant: AppButtonVariant.danger, + onPressed: () => Navigator.pop(context, true), + ), + ], + ); + + if (shouldCrash != true || !context.mounted) return; + + try { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.settingsCrashlyticsTestTriggered)), + ); + + await FirebaseCrashlytics.instance.log( + 'Manual Crashlytics test from debug settings action.', + ); + await FirebaseCrashlytics.instance.setCustomKey( + 'debug_action', + 'settings_crashlytics_test', + ); + + await Future.delayed(const Duration(milliseconds: 350)); + FirebaseCrashlytics.instance.crash(); + } catch (error, stackTrace) { + Logger.error( + 'Failed to trigger Crashlytics test crash.', + error, + stackTrace, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.settingsCrashlyticsTestFailed('$error')), + backgroundColor: Colors.red, + ), + ); + } + } + Future _showLanguageSelector( BuildContext context, WidgetRef ref, diff --git a/apps/mobile/lib/l10n/arb/app_en.arb b/apps/mobile/lib/l10n/arb/app_en.arb index dbf95af..2453307 100644 --- a/apps/mobile/lib/l10n/arb/app_en.arb +++ b/apps/mobile/lib/l10n/arb/app_en.arb @@ -42,6 +42,8 @@ "@settingsSectionData": {}, "settingsSectionAbout": "About", "@settingsSectionAbout": {}, + "settingsSectionDeveloper": "Developer", + "@settingsSectionDeveloper": {}, "settingsThemeTitle": "Theme", "@settingsThemeTitle": {}, "settingsLanguageTitle": "Language", @@ -72,6 +74,26 @@ "@settingsPrivacyPolicyTitle": {}, "settingsTermsTitle": "Terms of Service", "@settingsTermsTitle": {}, + "settingsCrashlyticsTestTitle": "Test Crashlytics", + "@settingsCrashlyticsTestTitle": {}, + "settingsCrashlyticsTestSubtitle": "Intentionally crash the app to verify crash reporting", + "@settingsCrashlyticsTestSubtitle": {}, + "settingsCrashlyticsTestConfirmTitle": "Trigger test crash?", + "@settingsCrashlyticsTestConfirmTitle": {}, + "settingsCrashlyticsTestConfirmMessage": "The app will crash immediately. Re-open the app to verify the crash in Firebase Crashlytics.", + "@settingsCrashlyticsTestConfirmMessage": {}, + "settingsCrashlyticsTestConfirmAction": "Crash Now", + "@settingsCrashlyticsTestConfirmAction": {}, + "settingsCrashlyticsTestTriggered": "Triggering test crash...", + "@settingsCrashlyticsTestTriggered": {}, + "settingsCrashlyticsTestFailed": "Failed to trigger crash test: {error}", + "@settingsCrashlyticsTestFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "settingsExportingData": "Exporting data...", "@settingsExportingData": {}, "settingsDataExportSuccess": "Data exported successfully!", diff --git a/apps/mobile/lib/l10n/arb/app_es.arb b/apps/mobile/lib/l10n/arb/app_es.arb index 6b93eed..ffcd2c8 100644 --- a/apps/mobile/lib/l10n/arb/app_es.arb +++ b/apps/mobile/lib/l10n/arb/app_es.arb @@ -42,6 +42,8 @@ "@settingsSectionData": {}, "settingsSectionAbout": "Acerca de", "@settingsSectionAbout": {}, + "settingsSectionDeveloper": "Desarrollador", + "@settingsSectionDeveloper": {}, "settingsThemeTitle": "Tema", "@settingsThemeTitle": {}, "settingsLanguageTitle": "Idioma", @@ -72,6 +74,26 @@ "@settingsPrivacyPolicyTitle": {}, "settingsTermsTitle": "Términos de servicio", "@settingsTermsTitle": {}, + "settingsCrashlyticsTestTitle": "Probar Crashlytics", + "@settingsCrashlyticsTestTitle": {}, + "settingsCrashlyticsTestSubtitle": "Bloquea la app intencionalmente para verificar los reportes de fallos", + "@settingsCrashlyticsTestSubtitle": {}, + "settingsCrashlyticsTestConfirmTitle": "¿Activar bloqueo de prueba?", + "@settingsCrashlyticsTestConfirmTitle": {}, + "settingsCrashlyticsTestConfirmMessage": "La aplicación se cerrará inmediatamente. Ábrela de nuevo para verificar el fallo en Firebase Crashlytics.", + "@settingsCrashlyticsTestConfirmMessage": {}, + "settingsCrashlyticsTestConfirmAction": "Bloquear ahora", + "@settingsCrashlyticsTestConfirmAction": {}, + "settingsCrashlyticsTestTriggered": "Iniciando bloqueo de prueba...", + "@settingsCrashlyticsTestTriggered": {}, + "settingsCrashlyticsTestFailed": "No se pudo iniciar la prueba de bloqueo: {error}", + "@settingsCrashlyticsTestFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "settingsExportingData": "Exportando datos...", "@settingsExportingData": {}, "settingsDataExportSuccess": "¡Datos exportados correctamente!", diff --git a/apps/mobile/lib/l10n/arb/app_id.arb b/apps/mobile/lib/l10n/arb/app_id.arb index 16f6b09..435a313 100644 --- a/apps/mobile/lib/l10n/arb/app_id.arb +++ b/apps/mobile/lib/l10n/arb/app_id.arb @@ -42,6 +42,8 @@ "@settingsSectionData": {}, "settingsSectionAbout": "Tentang", "@settingsSectionAbout": {}, + "settingsSectionDeveloper": "Pengembang", + "@settingsSectionDeveloper": {}, "settingsThemeTitle": "Tema", "@settingsThemeTitle": {}, "settingsLanguageTitle": "Bahasa", @@ -72,6 +74,26 @@ "@settingsPrivacyPolicyTitle": {}, "settingsTermsTitle": "Ketentuan Layanan", "@settingsTermsTitle": {}, + "settingsCrashlyticsTestTitle": "Uji Crashlytics", + "@settingsCrashlyticsTestTitle": {}, + "settingsCrashlyticsTestSubtitle": "Sengaja membuat aplikasi crash untuk memverifikasi pelaporan crash", + "@settingsCrashlyticsTestSubtitle": {}, + "settingsCrashlyticsTestConfirmTitle": "Picu crash uji coba?", + "@settingsCrashlyticsTestConfirmTitle": {}, + "settingsCrashlyticsTestConfirmMessage": "Aplikasi akan langsung crash. Buka kembali aplikasi untuk memverifikasi crash di Firebase Crashlytics.", + "@settingsCrashlyticsTestConfirmMessage": {}, + "settingsCrashlyticsTestConfirmAction": "Crash Sekarang", + "@settingsCrashlyticsTestConfirmAction": {}, + "settingsCrashlyticsTestTriggered": "Memicu crash uji coba...", + "@settingsCrashlyticsTestTriggered": {}, + "settingsCrashlyticsTestFailed": "Gagal memicu uji crash: {error}", + "@settingsCrashlyticsTestFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "settingsExportingData": "Mengekspor data...", "@settingsExportingData": {}, "settingsDataExportSuccess": "Data berhasil diekspor!", diff --git a/apps/mobile/lib/l10n/arb/app_ja.arb b/apps/mobile/lib/l10n/arb/app_ja.arb index 957af35..d636e9b 100644 --- a/apps/mobile/lib/l10n/arb/app_ja.arb +++ b/apps/mobile/lib/l10n/arb/app_ja.arb @@ -42,6 +42,8 @@ "@settingsSectionData": {}, "settingsSectionAbout": "情報", "@settingsSectionAbout": {}, + "settingsSectionDeveloper": "開発者", + "@settingsSectionDeveloper": {}, "settingsThemeTitle": "テーマ", "@settingsThemeTitle": {}, "settingsLanguageTitle": "言語", @@ -72,6 +74,26 @@ "@settingsPrivacyPolicyTitle": {}, "settingsTermsTitle": "利用規約", "@settingsTermsTitle": {}, + "settingsCrashlyticsTestTitle": "Crashlytics をテスト", + "@settingsCrashlyticsTestTitle": {}, + "settingsCrashlyticsTestSubtitle": "クラッシュレポートを確認するため、意図的にアプリをクラッシュさせます", + "@settingsCrashlyticsTestSubtitle": {}, + "settingsCrashlyticsTestConfirmTitle": "テストクラッシュを実行しますか?", + "@settingsCrashlyticsTestConfirmTitle": {}, + "settingsCrashlyticsTestConfirmMessage": "アプリはすぐにクラッシュします。再起動して Firebase Crashlytics でクラッシュを確認してください。", + "@settingsCrashlyticsTestConfirmMessage": {}, + "settingsCrashlyticsTestConfirmAction": "今すぐクラッシュ", + "@settingsCrashlyticsTestConfirmAction": {}, + "settingsCrashlyticsTestTriggered": "テストクラッシュを実行しています...", + "@settingsCrashlyticsTestTriggered": {}, + "settingsCrashlyticsTestFailed": "テストクラッシュの実行に失敗しました: {error}", + "@settingsCrashlyticsTestFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "settingsExportingData": "データをエクスポート中...", "@settingsExportingData": {}, "settingsDataExportSuccess": "データのエクスポートに成功しました!", diff --git a/apps/mobile/lib/l10n/arb/app_ko.arb b/apps/mobile/lib/l10n/arb/app_ko.arb index 32110b2..541e421 100644 --- a/apps/mobile/lib/l10n/arb/app_ko.arb +++ b/apps/mobile/lib/l10n/arb/app_ko.arb @@ -42,6 +42,8 @@ "@settingsSectionData": {}, "settingsSectionAbout": "정보", "@settingsSectionAbout": {}, + "settingsSectionDeveloper": "개발자", + "@settingsSectionDeveloper": {}, "settingsThemeTitle": "테마", "@settingsThemeTitle": {}, "settingsLanguageTitle": "언어", @@ -72,6 +74,26 @@ "@settingsPrivacyPolicyTitle": {}, "settingsTermsTitle": "이용 약관", "@settingsTermsTitle": {}, + "settingsCrashlyticsTestTitle": "Crashlytics 테스트", + "@settingsCrashlyticsTestTitle": {}, + "settingsCrashlyticsTestSubtitle": "크래시 보고 확인을 위해 앱을 의도적으로 종료합니다", + "@settingsCrashlyticsTestSubtitle": {}, + "settingsCrashlyticsTestConfirmTitle": "테스트 크래시를 실행할까요?", + "@settingsCrashlyticsTestConfirmTitle": {}, + "settingsCrashlyticsTestConfirmMessage": "앱이 즉시 종료됩니다. 앱을 다시 열어 Firebase Crashlytics에서 크래시를 확인하세요.", + "@settingsCrashlyticsTestConfirmMessage": {}, + "settingsCrashlyticsTestConfirmAction": "지금 크래시", + "@settingsCrashlyticsTestConfirmAction": {}, + "settingsCrashlyticsTestTriggered": "테스트 크래시를 실행하는 중...", + "@settingsCrashlyticsTestTriggered": {}, + "settingsCrashlyticsTestFailed": "테스트 크래시 실행 실패: {error}", + "@settingsCrashlyticsTestFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "settingsExportingData": "데이터 내보내는 중...", "@settingsExportingData": {}, "settingsDataExportSuccess": "데이터 내보내기 완료!", diff --git a/apps/mobile/lib/l10n/arb/app_my.arb b/apps/mobile/lib/l10n/arb/app_my.arb index 4f66507..943d41d 100644 --- a/apps/mobile/lib/l10n/arb/app_my.arb +++ b/apps/mobile/lib/l10n/arb/app_my.arb @@ -42,6 +42,8 @@ "@settingsSectionData": {}, "settingsSectionAbout": "အကြောင်းအရာ", "@settingsSectionAbout": {}, + "settingsSectionDeveloper": "ဆော့ဖ်ဝဲရေးသားသူ", + "@settingsSectionDeveloper": {}, "settingsThemeTitle": "အပြင်အဆင်", "@settingsThemeTitle": {}, "settingsLanguageTitle": "ဘာသာစကား", @@ -72,6 +74,26 @@ "@settingsPrivacyPolicyTitle": {}, "settingsTermsTitle": "အသုံးပြုမှု စည်းမျဉ်းများ", "@settingsTermsTitle": {}, + "settingsCrashlyticsTestTitle": "Crashlytics စမ်းသပ်ရန်", + "@settingsCrashlyticsTestTitle": {}, + "settingsCrashlyticsTestSubtitle": "Crash report ပို့မှုကို စစ်ဆေးရန် အက်ပ်ကို ရည်ရွယ်ချက်ရှိစွာ crash လုပ်မည်", + "@settingsCrashlyticsTestSubtitle": {}, + "settingsCrashlyticsTestConfirmTitle": "စမ်းသပ် crash ကို စတင်မလား?", + "@settingsCrashlyticsTestConfirmTitle": {}, + "settingsCrashlyticsTestConfirmMessage": "အက်ပ်သည် ချက်ချင်းပိတ်သွားမည်။ Firebase Crashlytics တွင် crash ကို စစ်ဆေးရန် အက်ပ်ကို ပြန်ဖွင့်ပါ။", + "@settingsCrashlyticsTestConfirmMessage": {}, + "settingsCrashlyticsTestConfirmAction": "ယခုပဲ Crash လုပ်မည်", + "@settingsCrashlyticsTestConfirmAction": {}, + "settingsCrashlyticsTestTriggered": "စမ်းသပ် crash စတင်နေသည်...", + "@settingsCrashlyticsTestTriggered": {}, + "settingsCrashlyticsTestFailed": "စမ်းသပ် crash စတင်မရပါ: {error}", + "@settingsCrashlyticsTestFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "settingsExportingData": "ဒေတာ ထုတ်ယူနေသည်...", "@settingsExportingData": {}, "settingsDataExportSuccess": "ဒေတာ ထုတ်ယူမှု အောင်မြင်သည်!", diff --git a/apps/mobile/lib/l10n/arb/app_zh.arb b/apps/mobile/lib/l10n/arb/app_zh.arb index c4ee4e1..d487a5e 100644 --- a/apps/mobile/lib/l10n/arb/app_zh.arb +++ b/apps/mobile/lib/l10n/arb/app_zh.arb @@ -42,6 +42,8 @@ "@settingsSectionData": {}, "settingsSectionAbout": "关于", "@settingsSectionAbout": {}, + "settingsSectionDeveloper": "开发者", + "@settingsSectionDeveloper": {}, "settingsThemeTitle": "主题", "@settingsThemeTitle": {}, "settingsLanguageTitle": "语言", @@ -72,6 +74,26 @@ "@settingsPrivacyPolicyTitle": {}, "settingsTermsTitle": "服务条款", "@settingsTermsTitle": {}, + "settingsCrashlyticsTestTitle": "测试 Crashlytics", + "@settingsCrashlyticsTestTitle": {}, + "settingsCrashlyticsTestSubtitle": "故意让应用崩溃以验证崩溃上报", + "@settingsCrashlyticsTestSubtitle": {}, + "settingsCrashlyticsTestConfirmTitle": "触发测试崩溃?", + "@settingsCrashlyticsTestConfirmTitle": {}, + "settingsCrashlyticsTestConfirmMessage": "应用将立即崩溃。请重新打开应用,并在 Firebase Crashlytics 中确认该崩溃。", + "@settingsCrashlyticsTestConfirmMessage": {}, + "settingsCrashlyticsTestConfirmAction": "立即崩溃", + "@settingsCrashlyticsTestConfirmAction": {}, + "settingsCrashlyticsTestTriggered": "正在触发测试崩溃...", + "@settingsCrashlyticsTestTriggered": {}, + "settingsCrashlyticsTestFailed": "触发测试崩溃失败:{error}", + "@settingsCrashlyticsTestFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "settingsExportingData": "正在导出数据...", "@settingsExportingData": {}, "settingsDataExportSuccess": "数据导出成功!", diff --git a/apps/mobile/lib/l10n/gen/app_localizations.dart b/apps/mobile/lib/l10n/gen/app_localizations.dart index 8a0643b..313cb0a 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations.dart @@ -233,6 +233,12 @@ abstract class AppLocalizations { /// **'About'** String get settingsSectionAbout; + /// No description provided for @settingsSectionDeveloper. + /// + /// In en, this message translates to: + /// **'Developer'** + String get settingsSectionDeveloper; + /// No description provided for @settingsThemeTitle. /// /// In en, this message translates to: @@ -323,6 +329,48 @@ abstract class AppLocalizations { /// **'Terms of Service'** String get settingsTermsTitle; + /// No description provided for @settingsCrashlyticsTestTitle. + /// + /// In en, this message translates to: + /// **'Test Crashlytics'** + String get settingsCrashlyticsTestTitle; + + /// No description provided for @settingsCrashlyticsTestSubtitle. + /// + /// In en, this message translates to: + /// **'Intentionally crash the app to verify crash reporting'** + String get settingsCrashlyticsTestSubtitle; + + /// No description provided for @settingsCrashlyticsTestConfirmTitle. + /// + /// In en, this message translates to: + /// **'Trigger test crash?'** + String get settingsCrashlyticsTestConfirmTitle; + + /// No description provided for @settingsCrashlyticsTestConfirmMessage. + /// + /// In en, this message translates to: + /// **'The app will crash immediately. Re-open the app to verify the crash in Firebase Crashlytics.'** + String get settingsCrashlyticsTestConfirmMessage; + + /// No description provided for @settingsCrashlyticsTestConfirmAction. + /// + /// In en, this message translates to: + /// **'Crash Now'** + String get settingsCrashlyticsTestConfirmAction; + + /// No description provided for @settingsCrashlyticsTestTriggered. + /// + /// In en, this message translates to: + /// **'Triggering test crash...'** + String get settingsCrashlyticsTestTriggered; + + /// No description provided for @settingsCrashlyticsTestFailed. + /// + /// In en, this message translates to: + /// **'Failed to trigger crash test: {error}'** + String settingsCrashlyticsTestFailed(String error); + /// No description provided for @settingsExportingData. /// /// In en, this message translates to: diff --git a/apps/mobile/lib/l10n/gen/app_localizations_en.dart b/apps/mobile/lib/l10n/gen/app_localizations_en.dart index 1cb0820..988d81f 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_en.dart @@ -74,6 +74,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsSectionAbout => 'About'; + @override + String get settingsSectionDeveloper => 'Developer'; + @override String get settingsThemeTitle => 'Theme'; @@ -119,6 +122,29 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsTermsTitle => 'Terms of Service'; + @override + String get settingsCrashlyticsTestTitle => 'Test Crashlytics'; + + @override + String get settingsCrashlyticsTestSubtitle => 'Intentionally crash the app to verify crash reporting'; + + @override + String get settingsCrashlyticsTestConfirmTitle => 'Trigger test crash?'; + + @override + String get settingsCrashlyticsTestConfirmMessage => 'The app will crash immediately. Re-open the app to verify the crash in Firebase Crashlytics.'; + + @override + String get settingsCrashlyticsTestConfirmAction => 'Crash Now'; + + @override + String get settingsCrashlyticsTestTriggered => 'Triggering test crash...'; + + @override + String settingsCrashlyticsTestFailed(String error) { + return 'Failed to trigger crash test: $error'; + } + @override String get settingsExportingData => 'Exporting data...'; diff --git a/apps/mobile/lib/l10n/gen/app_localizations_es.dart b/apps/mobile/lib/l10n/gen/app_localizations_es.dart index f1de996..77cd45b 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_es.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_es.dart @@ -74,6 +74,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settingsSectionAbout => 'Acerca de'; + @override + String get settingsSectionDeveloper => 'Desarrollador'; + @override String get settingsThemeTitle => 'Tema'; @@ -119,6 +122,29 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settingsTermsTitle => 'Términos de servicio'; + @override + String get settingsCrashlyticsTestTitle => 'Probar Crashlytics'; + + @override + String get settingsCrashlyticsTestSubtitle => 'Bloquea la app intencionalmente para verificar los reportes de fallos'; + + @override + String get settingsCrashlyticsTestConfirmTitle => '¿Activar bloqueo de prueba?'; + + @override + String get settingsCrashlyticsTestConfirmMessage => 'La aplicación se cerrará inmediatamente. Ábrela de nuevo para verificar el fallo en Firebase Crashlytics.'; + + @override + String get settingsCrashlyticsTestConfirmAction => 'Bloquear ahora'; + + @override + String get settingsCrashlyticsTestTriggered => 'Iniciando bloqueo de prueba...'; + + @override + String settingsCrashlyticsTestFailed(String error) { + return 'No se pudo iniciar la prueba de bloqueo: $error'; + } + @override String get settingsExportingData => 'Exportando datos...'; diff --git a/apps/mobile/lib/l10n/gen/app_localizations_id.dart b/apps/mobile/lib/l10n/gen/app_localizations_id.dart index 98bf488..6d37d24 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_id.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_id.dart @@ -74,6 +74,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get settingsSectionAbout => 'Tentang'; + @override + String get settingsSectionDeveloper => 'Pengembang'; + @override String get settingsThemeTitle => 'Tema'; @@ -119,6 +122,29 @@ class AppLocalizationsId extends AppLocalizations { @override String get settingsTermsTitle => 'Ketentuan Layanan'; + @override + String get settingsCrashlyticsTestTitle => 'Uji Crashlytics'; + + @override + String get settingsCrashlyticsTestSubtitle => 'Sengaja membuat aplikasi crash untuk memverifikasi pelaporan crash'; + + @override + String get settingsCrashlyticsTestConfirmTitle => 'Picu crash uji coba?'; + + @override + String get settingsCrashlyticsTestConfirmMessage => 'Aplikasi akan langsung crash. Buka kembali aplikasi untuk memverifikasi crash di Firebase Crashlytics.'; + + @override + String get settingsCrashlyticsTestConfirmAction => 'Crash Sekarang'; + + @override + String get settingsCrashlyticsTestTriggered => 'Memicu crash uji coba...'; + + @override + String settingsCrashlyticsTestFailed(String error) { + return 'Gagal memicu uji crash: $error'; + } + @override String get settingsExportingData => 'Mengekspor data...'; diff --git a/apps/mobile/lib/l10n/gen/app_localizations_ja.dart b/apps/mobile/lib/l10n/gen/app_localizations_ja.dart index 003218a..5f61d8b 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_ja.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_ja.dart @@ -74,6 +74,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get settingsSectionAbout => '情報'; + @override + String get settingsSectionDeveloper => '開発者'; + @override String get settingsThemeTitle => 'テーマ'; @@ -119,6 +122,29 @@ class AppLocalizationsJa extends AppLocalizations { @override String get settingsTermsTitle => '利用規約'; + @override + String get settingsCrashlyticsTestTitle => 'Crashlytics をテスト'; + + @override + String get settingsCrashlyticsTestSubtitle => 'クラッシュレポートを確認するため、意図的にアプリをクラッシュさせます'; + + @override + String get settingsCrashlyticsTestConfirmTitle => 'テストクラッシュを実行しますか?'; + + @override + String get settingsCrashlyticsTestConfirmMessage => 'アプリはすぐにクラッシュします。再起動して Firebase Crashlytics でクラッシュを確認してください。'; + + @override + String get settingsCrashlyticsTestConfirmAction => '今すぐクラッシュ'; + + @override + String get settingsCrashlyticsTestTriggered => 'テストクラッシュを実行しています...'; + + @override + String settingsCrashlyticsTestFailed(String error) { + return 'テストクラッシュの実行に失敗しました: $error'; + } + @override String get settingsExportingData => 'データをエクスポート中...'; diff --git a/apps/mobile/lib/l10n/gen/app_localizations_ko.dart b/apps/mobile/lib/l10n/gen/app_localizations_ko.dart index b948f7e..2cd193c 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_ko.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_ko.dart @@ -74,6 +74,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get settingsSectionAbout => '정보'; + @override + String get settingsSectionDeveloper => '개발자'; + @override String get settingsThemeTitle => '테마'; @@ -119,6 +122,29 @@ class AppLocalizationsKo extends AppLocalizations { @override String get settingsTermsTitle => '이용 약관'; + @override + String get settingsCrashlyticsTestTitle => 'Crashlytics 테스트'; + + @override + String get settingsCrashlyticsTestSubtitle => '크래시 보고 확인을 위해 앱을 의도적으로 종료합니다'; + + @override + String get settingsCrashlyticsTestConfirmTitle => '테스트 크래시를 실행할까요?'; + + @override + String get settingsCrashlyticsTestConfirmMessage => '앱이 즉시 종료됩니다. 앱을 다시 열어 Firebase Crashlytics에서 크래시를 확인하세요.'; + + @override + String get settingsCrashlyticsTestConfirmAction => '지금 크래시'; + + @override + String get settingsCrashlyticsTestTriggered => '테스트 크래시를 실행하는 중...'; + + @override + String settingsCrashlyticsTestFailed(String error) { + return '테스트 크래시 실행 실패: $error'; + } + @override String get settingsExportingData => '데이터 내보내는 중...'; diff --git a/apps/mobile/lib/l10n/gen/app_localizations_my.dart b/apps/mobile/lib/l10n/gen/app_localizations_my.dart index f80b4a2..88a6e9e 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_my.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_my.dart @@ -74,6 +74,9 @@ class AppLocalizationsMy extends AppLocalizations { @override String get settingsSectionAbout => 'အကြောင်းအရာ'; + @override + String get settingsSectionDeveloper => 'ဆော့ဖ်ဝဲရေးသားသူ'; + @override String get settingsThemeTitle => 'အပြင်အဆင်'; @@ -119,6 +122,29 @@ class AppLocalizationsMy extends AppLocalizations { @override String get settingsTermsTitle => 'အသုံးပြုမှု စည်းမျဉ်းများ'; + @override + String get settingsCrashlyticsTestTitle => 'Crashlytics စမ်းသပ်ရန်'; + + @override + String get settingsCrashlyticsTestSubtitle => 'Crash report ပို့မှုကို စစ်ဆေးရန် အက်ပ်ကို ရည်ရွယ်ချက်ရှိစွာ crash လုပ်မည်'; + + @override + String get settingsCrashlyticsTestConfirmTitle => 'စမ်းသပ် crash ကို စတင်မလား?'; + + @override + String get settingsCrashlyticsTestConfirmMessage => 'အက်ပ်သည် ချက်ချင်းပိတ်သွားမည်။ Firebase Crashlytics တွင် crash ကို စစ်ဆေးရန် အက်ပ်ကို ပြန်ဖွင့်ပါ။'; + + @override + String get settingsCrashlyticsTestConfirmAction => 'ယခုပဲ Crash လုပ်မည်'; + + @override + String get settingsCrashlyticsTestTriggered => 'စမ်းသပ် crash စတင်နေသည်...'; + + @override + String settingsCrashlyticsTestFailed(String error) { + return 'စမ်းသပ် crash စတင်မရပါ: $error'; + } + @override String get settingsExportingData => 'ဒေတာ ထုတ်ယူနေသည်...'; diff --git a/apps/mobile/lib/l10n/gen/app_localizations_zh.dart b/apps/mobile/lib/l10n/gen/app_localizations_zh.dart index 4135257..7ce3f2f 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_zh.dart @@ -74,6 +74,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settingsSectionAbout => '关于'; + @override + String get settingsSectionDeveloper => '开发者'; + @override String get settingsThemeTitle => '主题'; @@ -119,6 +122,29 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settingsTermsTitle => '服务条款'; + @override + String get settingsCrashlyticsTestTitle => '测试 Crashlytics'; + + @override + String get settingsCrashlyticsTestSubtitle => '故意让应用崩溃以验证崩溃上报'; + + @override + String get settingsCrashlyticsTestConfirmTitle => '触发测试崩溃?'; + + @override + String get settingsCrashlyticsTestConfirmMessage => '应用将立即崩溃。请重新打开应用,并在 Firebase Crashlytics 中确认该崩溃。'; + + @override + String get settingsCrashlyticsTestConfirmAction => '立即崩溃'; + + @override + String get settingsCrashlyticsTestTriggered => '正在触发测试崩溃...'; + + @override + String settingsCrashlyticsTestFailed(String error) { + return '触发测试崩溃失败:$error'; + } + @override String get settingsExportingData => '正在导出数据...'; diff --git a/packages/common/ui/lib/src/widgets/app_dialog.dart b/packages/common/ui/lib/src/widgets/app_dialog.dart index 3e1df85..e0d4d1b 100644 --- a/packages/common/ui/lib/src/widgets/app_dialog.dart +++ b/packages/common/ui/lib/src/widgets/app_dialog.dart @@ -12,6 +12,7 @@ Future showAppDialog({ }) { return showDialog( context: context, + useRootNavigator: false, barrierDismissible: barrierDismissible, builder: (context) => AppDialog(title: title, content: content, actions: actions), From 87559f4a285b0d38ecfa01f8d2a9f8a93e62cfb5 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 00:05:13 +0630 Subject: [PATCH 03/31] refactor: Centralize dialog dismissal logic by introducing and utilizing a new `closeAppDialog` helper function across various screens. --- .../presentation/views/collections_screen.dart | 4 ++-- .../presentation/views/item_detail_screen.dart | 4 ++-- .../items/presentation/views/items_screen.dart | 4 ++-- .../presentation/views/tag_items_screen.dart | 4 ++-- .../views/tag_management_screen.dart | 16 ++++++++-------- .../presentation/views/settings_screen.dart | 8 ++++---- .../common/ui/lib/src/widgets/app_dialog.dart | 6 ++++++ 7 files changed, 26 insertions(+), 20 deletions(-) diff --git a/apps/mobile/lib/features/collections/presentation/views/collections_screen.dart b/apps/mobile/lib/features/collections/presentation/views/collections_screen.dart index aa81611..386a257 100644 --- a/apps/mobile/lib/features/collections/presentation/views/collections_screen.dart +++ b/apps/mobile/lib/features/collections/presentation/views/collections_screen.dart @@ -233,12 +233,12 @@ class _CollectionsScreenState extends ConsumerState { AppButton( label: context.l10n.actionCancel, variant: AppButtonVariant.ghost, - onPressed: () => Navigator.pop(context, false), + onPressed: () => closeAppDialog(context, false), ), AppButton( label: context.l10n.actionDelete, variant: AppButtonVariant.danger, - onPressed: () => Navigator.pop(context, true), + onPressed: () => closeAppDialog(context, true), ), ], ); diff --git a/apps/mobile/lib/features/items/presentation/views/item_detail_screen.dart b/apps/mobile/lib/features/items/presentation/views/item_detail_screen.dart index b83156b..e9b53fb 100644 --- a/apps/mobile/lib/features/items/presentation/views/item_detail_screen.dart +++ b/apps/mobile/lib/features/items/presentation/views/item_detail_screen.dart @@ -474,14 +474,14 @@ class ItemDetailScreen extends ConsumerWidget { AppButton( label: l10n.actionCancel, variant: AppButtonVariant.ghost, - onPressed: () => Navigator.pop(context), + onPressed: () => closeAppDialog(context), ), AppButton( label: l10n.actionSave, onPressed: () { final parsed = double.tryParse(draftValue.trim()); if (parsed == null || parsed < 0) return; - Navigator.pop(context, parsed); + closeAppDialog(context, parsed); }, ), ], diff --git a/apps/mobile/lib/features/items/presentation/views/items_screen.dart b/apps/mobile/lib/features/items/presentation/views/items_screen.dart index f9457c9..0b07ad7 100644 --- a/apps/mobile/lib/features/items/presentation/views/items_screen.dart +++ b/apps/mobile/lib/features/items/presentation/views/items_screen.dart @@ -368,12 +368,12 @@ class _ItemsScreenState extends ConsumerState { AppButton( label: l10n.actionCancel, variant: AppButtonVariant.ghost, - onPressed: () => Navigator.pop(context, false), + onPressed: () => closeAppDialog(context, false), ), AppButton( label: l10n.actionDelete, variant: AppButtonVariant.danger, - onPressed: () => Navigator.pop(context, true), + onPressed: () => closeAppDialog(context, true), ), ], ); diff --git a/apps/mobile/lib/features/items/presentation/views/tag_items_screen.dart b/apps/mobile/lib/features/items/presentation/views/tag_items_screen.dart index 9542202..9537ca2 100644 --- a/apps/mobile/lib/features/items/presentation/views/tag_items_screen.dart +++ b/apps/mobile/lib/features/items/presentation/views/tag_items_screen.dart @@ -216,12 +216,12 @@ class _TagItemsScreenState extends ConsumerState { AppButton( label: context.l10n.actionCancel, variant: AppButtonVariant.ghost, - onPressed: () => Navigator.pop(context, false), + onPressed: () => closeAppDialog(context, false), ), AppButton( label: context.l10n.actionDelete, variant: AppButtonVariant.danger, - onPressed: () => Navigator.pop(context, true), + onPressed: () => closeAppDialog(context, true), ), ], ); diff --git a/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart b/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart index d418c57..8de04c9 100644 --- a/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart +++ b/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart @@ -404,11 +404,11 @@ class _TagManagementScreenState extends ConsumerState { AppButton( label: context.l10n.actionCancel, variant: AppButtonVariant.ghost, - onPressed: () => Navigator.pop(context), + onPressed: () => closeAppDialog(context), ), AppButton( label: context.l10n.tagManagementRenameAction, - onPressed: () => Navigator.pop(context, draftName), + onPressed: () => closeAppDialog(context, draftName), ), ], ); @@ -507,11 +507,11 @@ class _TagManagementScreenState extends ConsumerState { AppButton( label: context.l10n.actionCancel, variant: AppButtonVariant.ghost, - onPressed: () => Navigator.pop(context, false), + onPressed: () => closeAppDialog(context, false), ), AppButton( label: context.l10n.tagManagementMergeAction, - onPressed: () => Navigator.pop(context, true), + onPressed: () => closeAppDialog(context, true), ), ], ); @@ -554,12 +554,12 @@ class _TagManagementScreenState extends ConsumerState { AppButton( label: context.l10n.actionCancel, variant: AppButtonVariant.ghost, - onPressed: () => Navigator.pop(context, false), + onPressed: () => closeAppDialog(context, false), ), AppButton( label: context.l10n.actionDelete, variant: AppButtonVariant.danger, - onPressed: () => Navigator.pop(context, true), + onPressed: () => closeAppDialog(context, true), ), ], ); @@ -636,12 +636,12 @@ class _TagManagementScreenState extends ConsumerState { AppButton( label: context.l10n.actionCancel, variant: AppButtonVariant.ghost, - onPressed: () => Navigator.pop(context, false), + onPressed: () => closeAppDialog(context, false), ), AppButton( label: context.l10n.actionDelete, variant: AppButtonVariant.danger, - onPressed: () => Navigator.pop(context, true), + onPressed: () => closeAppDialog(context, true), ), ], ); diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart index 88bd020..b24eba6 100644 --- a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart +++ b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart @@ -217,11 +217,11 @@ class SettingsScreen extends ConsumerWidget { AppButton( label: l10n.actionCancel, variant: AppButtonVariant.ghost, - onPressed: () => Navigator.pop(context, false), + onPressed: () => closeAppDialog(context, false), ), AppButton( label: l10n.actionImport, - onPressed: () => Navigator.pop(context, true), + onPressed: () => closeAppDialog(context, true), ), ], ); @@ -369,12 +369,12 @@ class SettingsScreen extends ConsumerWidget { AppButton( label: l10n.actionCancel, variant: AppButtonVariant.ghost, - onPressed: () => Navigator.pop(context, false), + onPressed: () => closeAppDialog(context, false), ), AppButton( label: l10n.settingsCrashlyticsTestConfirmAction, variant: AppButtonVariant.danger, - onPressed: () => Navigator.pop(context, true), + onPressed: () => closeAppDialog(context, true), ), ], ); diff --git a/packages/common/ui/lib/src/widgets/app_dialog.dart b/packages/common/ui/lib/src/widgets/app_dialog.dart index e0d4d1b..86aab18 100644 --- a/packages/common/ui/lib/src/widgets/app_dialog.dart +++ b/packages/common/ui/lib/src/widgets/app_dialog.dart @@ -19,6 +19,12 @@ Future showAppDialog({ ); } +Future closeAppDialog(BuildContext context, [T? result]) async { + final navigator = Navigator.maybeOf(context); + if (navigator == null) return; + await navigator.maybePop(result); +} + class AppDialog extends StatelessWidget { final Widget? title; final Widget content; From 6c75ec35123c5da801c7aa2ae368360cffc2483b Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 00:27:19 +0630 Subject: [PATCH 04/31] feat: Add user-facing analytics consent and preferences management with corresponding UI and service integration. --- .../analytics/analytics_consent_dialog.dart | 33 ++++ .../analytics/analytics_consent_gate.dart | 63 ++++++++ .../core/analytics/analytics_preferences.dart | 45 ++++++ .../lib/core/bootstrap/app_bootstrap.dart | 17 +- .../analytics_preferences_provider.dart | 57 +++++++ apps/mobile/lib/core/providers/providers.dart | 1 + apps/mobile/lib/core/router/app_router.dart | 23 ++- .../presentation/views/settings_screen.dart | 149 ++++++++++++++++++ apps/mobile/lib/l10n/arb/app_en.arb | 42 +++++ apps/mobile/lib/l10n/arb/app_es.arb | 42 +++++ apps/mobile/lib/l10n/arb/app_id.arb | 42 +++++ apps/mobile/lib/l10n/arb/app_ja.arb | 42 +++++ apps/mobile/lib/l10n/arb/app_ko.arb | 42 +++++ apps/mobile/lib/l10n/arb/app_my.arb | 42 +++++ apps/mobile/lib/l10n/arb/app_zh.arb | 42 +++++ .../lib/l10n/gen/app_localizations.dart | 126 +++++++++++++++ .../lib/l10n/gen/app_localizations_en.dart | 63 ++++++++ .../lib/l10n/gen/app_localizations_es.dart | 63 ++++++++ .../lib/l10n/gen/app_localizations_id.dart | 63 ++++++++ .../lib/l10n/gen/app_localizations_ja.dart | 63 ++++++++ .../lib/l10n/gen/app_localizations_ko.dart | 63 ++++++++ .../lib/l10n/gen/app_localizations_my.dart | 63 ++++++++ .../lib/l10n/gen/app_localizations_zh.dart | 63 ++++++++ .../lib/src/core/analytics_service.dart | 43 +++++ 24 files changed, 1285 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/lib/core/analytics/analytics_consent_dialog.dart create mode 100644 apps/mobile/lib/core/analytics/analytics_consent_gate.dart create mode 100644 apps/mobile/lib/core/analytics/analytics_preferences.dart create mode 100644 apps/mobile/lib/core/providers/analytics_preferences_provider.dart 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/bootstrap/app_bootstrap.dart b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart index add7149..522f045 100644 --- a/apps/mobile/lib/core/bootstrap/app_bootstrap.dart +++ b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart @@ -1,5 +1,6 @@ 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:flutter/foundation.dart'; import 'package:storage/storage.dart'; @@ -41,6 +42,16 @@ abstract final class AppBootstrap { } static Future _initializeAnalytics() 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: AnalyticsEnvironment.development, enableLogging: true, @@ -55,9 +66,13 @@ abstract final class AppBootstrap { EnrichmentMiddleware(), ], autoTrackScreenViews: true, - requireConsent: false, + requireConsent: true, ); await AnalyticsService.initialize(config); + await AnalyticsService.instance.setTrackingEnabled(enabled); + await AnalyticsService.instance.setConsentGranted( + consentStatus == AnalyticsConsentStatus.granted, + ); } } diff --git a/apps/mobile/lib/core/providers/analytics_preferences_provider.dart b/apps/mobile/lib/core/providers/analytics_preferences_provider.dart new file mode 100644 index 0000000..a6ab003 --- /dev/null +++ b/apps/mobile/lib/core/providers/analytics_preferences_provider.dart @@ -0,0 +1,57 @@ +import 'package:app_analytics/app_analytics.dart'; +import 'package:collection_tracker/core/analytics/analytics_preferences.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:storage/storage.dart'; + +part 'analytics_preferences_provider.g.dart'; + +@riverpod +class AnalyticsPreferencesNotifier extends _$AnalyticsPreferencesNotifier { + late final PrefsStorageService _prefs; + + @override + AnalyticsPreferences build() { + _prefs = PrefsStorageService.instance; + final enabled = + _prefs.readSync(AnalyticsPreferences.enabledPrefKey) ?? true; + final consentCode = _prefs.readSync( + AnalyticsPreferences.consentStatusPrefKey, + ); + final consentStatus = AnalyticsConsentStatusX.fromCode(consentCode); + + return AnalyticsPreferences(enabled: enabled, consentStatus: consentStatus); + } + + Future setEnabled(bool enabled) async { + await _prefs.save(AnalyticsPreferences.enabledPrefKey, enabled); + state = state.copyWith(enabled: enabled); + await _applyToAnalyticsService(); + } + + Future grantConsent() => + setConsentStatus(AnalyticsConsentStatus.granted); + + Future denyConsent() => setConsentStatus(AnalyticsConsentStatus.denied); + + Future resetConsent() => + setConsentStatus(AnalyticsConsentStatus.unknown); + + Future setConsentStatus(AnalyticsConsentStatus status) async { + await _prefs.save( + AnalyticsPreferences.consentStatusPrefKey, + status.code, + ); + state = state.copyWith(consentStatus: status); + await _applyToAnalyticsService(); + } + + Future _applyToAnalyticsService() async { + final analytics = AnalyticsService.instance; + if (!analytics.isInitialized) return; + + await analytics.setTrackingEnabled(state.enabled); + await analytics.setConsentGranted( + state.consentStatus == AnalyticsConsentStatus.granted, + ); + } +} diff --git a/apps/mobile/lib/core/providers/providers.dart b/apps/mobile/lib/core/providers/providers.dart index 877602c..bc5f5b7 100644 --- a/apps/mobile/lib/core/providers/providers.dart +++ b/apps/mobile/lib/core/providers/providers.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; export 'data_providers.dart'; export 'database_providers.dart'; +export 'analytics_preferences_provider.dart'; export 'theme_provider.dart'; export 'items_view_mode_provider.dart'; export 'collections_view_mode_provider.dart'; diff --git a/apps/mobile/lib/core/router/app_router.dart b/apps/mobile/lib/core/router/app_router.dart index dd53faf..b514610 100644 --- a/apps/mobile/lib/core/router/app_router.dart +++ b/apps/mobile/lib/core/router/app_router.dart @@ -1,4 +1,5 @@ import 'package:collection_tracker/core/providers/providers.dart'; +import 'package:collection_tracker/core/analytics/analytics_consent_gate.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -29,6 +30,8 @@ final _rootNavigatorKey = GlobalKey(); @riverpod GoRouter appRouter(Ref ref) { final onboardingComplete = ref.watch(onboardingCompleteProvider); + Widget withAnalyticsConsent(Widget child) => + AnalyticsConsentGate(child: child); return GoRouter( navigatorKey: _rootNavigatorKey, @@ -40,11 +43,14 @@ GoRouter appRouter(Ref ref) { GoRoute( path: Routes.onboarding, name: 'onboarding', - builder: (context, state) => const OnboardingScreen(), + builder: (context, state) => + withAnalyticsConsent(const OnboardingScreen()), ), StatefulShellRoute.indexedStack( builder: (context, state, navigationShell) { - return AppShell(navigationShell: navigationShell); + return withAnalyticsConsent( + AppShell(navigationShell: navigationShell), + ); }, branches: [ StatefulShellBranch( @@ -147,7 +153,9 @@ GoRouter appRouter(Ref ref) { builder: (context, state) { final id = state.pathParameters['id']!; final heroTag = state.uri.queryParameters['heroTag']; - return ItemDetailScreen(itemId: id, heroTag: heroTag); + return withAnalyticsConsent( + ItemDetailScreen(itemId: id, heroTag: heroTag), + ); }, routes: [ GoRoute( @@ -165,7 +173,7 @@ GoRouter appRouter(Ref ref) { name: 'tag-items', builder: (context, state) { final tag = state.uri.queryParameters['tag'] ?? ''; - return TagItemsScreen(tagName: tag); + return withAnalyticsConsent(TagItemsScreen(tagName: tag)); }, ), GoRoute( @@ -173,13 +181,16 @@ GoRouter appRouter(Ref ref) { name: 'scanner', builder: (context, state) { final collectionId = state.uri.queryParameters['collectionId']; - return ScannerScreen(collectionId: collectionId); + return withAnalyticsConsent( + ScannerScreen(collectionId: collectionId), + ); }, ), GoRoute( path: Routes.statistics, name: 'statistics', - builder: (context, state) => const StatisticsScreen(), + builder: (context, state) => + withAnalyticsConsent(const StatisticsScreen()), ), ], // Error handling diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart index b24eba6..2f2b034 100644 --- a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart +++ b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart @@ -1,4 +1,6 @@ import 'package:app_logger/app_logger.dart'; +import 'package:collection_tracker/core/analytics/analytics_consent_dialog.dart'; +import 'package:collection_tracker/core/analytics/analytics_preferences.dart'; import 'package:collection_tracker/core/providers/providers.dart'; import 'package:collection_tracker/l10n/l10n.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; @@ -19,9 +21,11 @@ class SettingsScreen extends ConsumerWidget { final l10n = context.l10n; final themeSettings = ref.watch(themeSettingsProvider); final currentLanguage = ref.watch(localeSettingsProvider); + final analyticsPreferences = ref.watch(analyticsPreferencesProvider); final themeSummary = '${_themeModeLabel(context, themeSettings.mode)} - ${themeSettings.variant.label}'; final languageSummary = _languageLabel(context, currentLanguage); + final analyticsSummary = _analyticsSummary(context, analyticsPreferences); return Scaffold( appBar: AppBar(title: Text(l10n.settingsTitle)), @@ -49,6 +53,12 @@ class SettingsScreen extends ConsumerWidget { subtitle: languageSummary, onTap: () => _showLanguageSelector(context, ref), ), + _SettingsTile( + icon: Icons.insights_outlined, + title: l10n.settingsAnalyticsTitle, + subtitle: analyticsSummary, + onTap: () => _showAnalyticsSettings(context, ref), + ), ], ), ), @@ -359,6 +369,129 @@ class SettingsScreen extends ConsumerWidget { ); } + Future _showAnalyticsSettings( + BuildContext context, + WidgetRef ref, + ) async { + await showAppSheet( + context: context, + builder: (context) { + return Consumer( + builder: (context, ref, _) { + final l10n = context.l10n; + final preferences = ref.watch(analyticsPreferencesProvider); + final notifier = ref.read(analyticsPreferencesProvider.notifier); + final consentLabel = switch (preferences.consentStatus) { + AnalyticsConsentStatus.granted => + l10n.settingsAnalyticsConsentStatusGranted, + AnalyticsConsentStatus.denied => + l10n.settingsAnalyticsConsentStatusDenied, + AnalyticsConsentStatus.unknown => + l10n.settingsAnalyticsConsentStatusPending, + }; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.settingsAnalyticsSheetTitle, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + l10n.settingsAnalyticsDescription, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.md), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.settingsAnalyticsToggleTitle), + subtitle: Text(l10n.settingsAnalyticsToggleSubtitle), + value: preferences.enabled, + onChanged: (value) { + notifier.setEnabled(value); + }, + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.privacy_tip_outlined), + title: Text(l10n.settingsAnalyticsConsentStatusTitle), + subtitle: Text(consentLabel), + ), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + if (preferences.enabled && + preferences.consentStatus != + AnalyticsConsentStatus.granted) + AppButton( + label: l10n.settingsAnalyticsReviewConsentAction, + onPressed: () async { + final decision = await showAnalyticsConsentDialog( + context, + barrierDismissible: true, + ); + if (!context.mounted) return; + if (decision == AnalyticsConsentDecision.allow) { + await notifier.grantConsent(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + l10n.settingsAnalyticsConsentAccepted, + ), + ), + ); + } + } else { + await notifier.denyConsent(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + l10n.settingsAnalyticsConsentDeclined, + ), + ), + ); + } + } + }, + ), + if (preferences.consentStatus == + AnalyticsConsentStatus.granted) + AppButton( + label: l10n.settingsAnalyticsRevokeConsentAction, + variant: AppButtonVariant.ghost, + onPressed: () async { + await notifier.denyConsent(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + l10n.settingsAnalyticsConsentDeclined, + ), + ), + ); + } + }, + ), + ], + ), + ], + ); + }, + ); + }, + ); + } + Future _handleCrashlyticsTest(BuildContext context) async { final l10n = context.l10n; final shouldCrash = await showAppDialog( @@ -482,6 +615,22 @@ class SettingsScreen extends ConsumerWidget { AppLanguage.burmese => l10n.languageBurmese, }; } + + String _analyticsSummary( + BuildContext context, + AnalyticsPreferences preferences, + ) { + final l10n = context.l10n; + if (!preferences.enabled) { + return l10n.settingsAnalyticsSummaryDisabled; + } + + return switch (preferences.consentStatus) { + AnalyticsConsentStatus.granted => l10n.settingsAnalyticsSummaryEnabled, + AnalyticsConsentStatus.denied => l10n.settingsAnalyticsSummaryDenied, + AnalyticsConsentStatus.unknown => l10n.settingsAnalyticsSummaryPending, + }; + } } class _SettingsSection extends StatelessWidget { diff --git a/apps/mobile/lib/l10n/arb/app_en.arb b/apps/mobile/lib/l10n/arb/app_en.arb index 2453307..262f2f9 100644 --- a/apps/mobile/lib/l10n/arb/app_en.arb +++ b/apps/mobile/lib/l10n/arb/app_en.arb @@ -74,6 +74,48 @@ "@settingsPrivacyPolicyTitle": {}, "settingsTermsTitle": "Terms of Service", "@settingsTermsTitle": {}, + "settingsAnalyticsTitle": "Analytics", + "@settingsAnalyticsTitle": {}, + "settingsAnalyticsSummaryEnabled": "Enabled", + "@settingsAnalyticsSummaryEnabled": {}, + "settingsAnalyticsSummaryDisabled": "Disabled", + "@settingsAnalyticsSummaryDisabled": {}, + "settingsAnalyticsSummaryPending": "Consent required", + "@settingsAnalyticsSummaryPending": {}, + "settingsAnalyticsSummaryDenied": "Consent declined", + "@settingsAnalyticsSummaryDenied": {}, + "settingsAnalyticsSheetTitle": "Analytics Preferences", + "@settingsAnalyticsSheetTitle": {}, + "settingsAnalyticsDescription": "Control anonymous usage analytics and data sharing preferences.", + "@settingsAnalyticsDescription": {}, + "settingsAnalyticsToggleTitle": "Enable analytics", + "@settingsAnalyticsToggleTitle": {}, + "settingsAnalyticsToggleSubtitle": "Allow anonymous app usage events to be collected.", + "@settingsAnalyticsToggleSubtitle": {}, + "settingsAnalyticsConsentStatusTitle": "Consent status", + "@settingsAnalyticsConsentStatusTitle": {}, + "settingsAnalyticsConsentStatusGranted": "Granted", + "@settingsAnalyticsConsentStatusGranted": {}, + "settingsAnalyticsConsentStatusDenied": "Declined", + "@settingsAnalyticsConsentStatusDenied": {}, + "settingsAnalyticsConsentStatusPending": "Pending", + "@settingsAnalyticsConsentStatusPending": {}, + "settingsAnalyticsReviewConsentAction": "Review Consent", + "@settingsAnalyticsReviewConsentAction": {}, + "settingsAnalyticsRevokeConsentAction": "Revoke Consent", + "@settingsAnalyticsRevokeConsentAction": {}, + "settingsAnalyticsConsentAccepted": "Analytics consent accepted.", + "@settingsAnalyticsConsentAccepted": {}, + "settingsAnalyticsConsentDeclined": "Analytics consent declined.", + "@settingsAnalyticsConsentDeclined": {}, + "analyticsConsentDialogTitle": "Help Improve Collection Tracker", + "@analyticsConsentDialogTitle": {}, + "analyticsConsentDialogMessage": "Can we collect anonymous usage analytics to improve app quality and features? You can change this anytime in Settings.", + "@analyticsConsentDialogMessage": {}, + "analyticsConsentAllowAction": "Allow", + "@analyticsConsentAllowAction": {}, + "analyticsConsentDeclineAction": "Not now", + "@analyticsConsentDeclineAction": {}, "settingsCrashlyticsTestTitle": "Test Crashlytics", "@settingsCrashlyticsTestTitle": {}, "settingsCrashlyticsTestSubtitle": "Intentionally crash the app to verify crash reporting", diff --git a/apps/mobile/lib/l10n/arb/app_es.arb b/apps/mobile/lib/l10n/arb/app_es.arb index ffcd2c8..6f4b731 100644 --- a/apps/mobile/lib/l10n/arb/app_es.arb +++ b/apps/mobile/lib/l10n/arb/app_es.arb @@ -74,6 +74,48 @@ "@settingsPrivacyPolicyTitle": {}, "settingsTermsTitle": "Términos de servicio", "@settingsTermsTitle": {}, + "settingsAnalyticsTitle": "Analíticas", + "@settingsAnalyticsTitle": {}, + "settingsAnalyticsSummaryEnabled": "Activadas", + "@settingsAnalyticsSummaryEnabled": {}, + "settingsAnalyticsSummaryDisabled": "Desactivadas", + "@settingsAnalyticsSummaryDisabled": {}, + "settingsAnalyticsSummaryPending": "Se requiere consentimiento", + "@settingsAnalyticsSummaryPending": {}, + "settingsAnalyticsSummaryDenied": "Consentimiento rechazado", + "@settingsAnalyticsSummaryDenied": {}, + "settingsAnalyticsSheetTitle": "Preferencias de analíticas", + "@settingsAnalyticsSheetTitle": {}, + "settingsAnalyticsDescription": "Controla las analíticas de uso anónimas y las preferencias de compartición de datos.", + "@settingsAnalyticsDescription": {}, + "settingsAnalyticsToggleTitle": "Activar analíticas", + "@settingsAnalyticsToggleTitle": {}, + "settingsAnalyticsToggleSubtitle": "Permite recopilar eventos anónimos de uso de la aplicación.", + "@settingsAnalyticsToggleSubtitle": {}, + "settingsAnalyticsConsentStatusTitle": "Estado del consentimiento", + "@settingsAnalyticsConsentStatusTitle": {}, + "settingsAnalyticsConsentStatusGranted": "Concedido", + "@settingsAnalyticsConsentStatusGranted": {}, + "settingsAnalyticsConsentStatusDenied": "Rechazado", + "@settingsAnalyticsConsentStatusDenied": {}, + "settingsAnalyticsConsentStatusPending": "Pendiente", + "@settingsAnalyticsConsentStatusPending": {}, + "settingsAnalyticsReviewConsentAction": "Revisar consentimiento", + "@settingsAnalyticsReviewConsentAction": {}, + "settingsAnalyticsRevokeConsentAction": "Revocar consentimiento", + "@settingsAnalyticsRevokeConsentAction": {}, + "settingsAnalyticsConsentAccepted": "Consentimiento de analíticas aceptado.", + "@settingsAnalyticsConsentAccepted": {}, + "settingsAnalyticsConsentDeclined": "Consentimiento de analíticas rechazado.", + "@settingsAnalyticsConsentDeclined": {}, + "analyticsConsentDialogTitle": "Ayúdanos a mejorar Collection Tracker", + "@analyticsConsentDialogTitle": {}, + "analyticsConsentDialogMessage": "¿Podemos recopilar analíticas de uso anónimas para mejorar la calidad y las funciones de la app? Puedes cambiarlo en Configuración en cualquier momento.", + "@analyticsConsentDialogMessage": {}, + "analyticsConsentAllowAction": "Permitir", + "@analyticsConsentAllowAction": {}, + "analyticsConsentDeclineAction": "Ahora no", + "@analyticsConsentDeclineAction": {}, "settingsCrashlyticsTestTitle": "Probar Crashlytics", "@settingsCrashlyticsTestTitle": {}, "settingsCrashlyticsTestSubtitle": "Bloquea la app intencionalmente para verificar los reportes de fallos", diff --git a/apps/mobile/lib/l10n/arb/app_id.arb b/apps/mobile/lib/l10n/arb/app_id.arb index 435a313..09f76f4 100644 --- a/apps/mobile/lib/l10n/arb/app_id.arb +++ b/apps/mobile/lib/l10n/arb/app_id.arb @@ -74,6 +74,48 @@ "@settingsPrivacyPolicyTitle": {}, "settingsTermsTitle": "Ketentuan Layanan", "@settingsTermsTitle": {}, + "settingsAnalyticsTitle": "Analitik", + "@settingsAnalyticsTitle": {}, + "settingsAnalyticsSummaryEnabled": "Aktif", + "@settingsAnalyticsSummaryEnabled": {}, + "settingsAnalyticsSummaryDisabled": "Nonaktif", + "@settingsAnalyticsSummaryDisabled": {}, + "settingsAnalyticsSummaryPending": "Perlu persetujuan", + "@settingsAnalyticsSummaryPending": {}, + "settingsAnalyticsSummaryDenied": "Persetujuan ditolak", + "@settingsAnalyticsSummaryDenied": {}, + "settingsAnalyticsSheetTitle": "Preferensi Analitik", + "@settingsAnalyticsSheetTitle": {}, + "settingsAnalyticsDescription": "Atur analitik penggunaan anonim dan preferensi berbagi data.", + "@settingsAnalyticsDescription": {}, + "settingsAnalyticsToggleTitle": "Aktifkan analitik", + "@settingsAnalyticsToggleTitle": {}, + "settingsAnalyticsToggleSubtitle": "Izinkan pengumpulan event penggunaan aplikasi secara anonim.", + "@settingsAnalyticsToggleSubtitle": {}, + "settingsAnalyticsConsentStatusTitle": "Status persetujuan", + "@settingsAnalyticsConsentStatusTitle": {}, + "settingsAnalyticsConsentStatusGranted": "Disetujui", + "@settingsAnalyticsConsentStatusGranted": {}, + "settingsAnalyticsConsentStatusDenied": "Ditolak", + "@settingsAnalyticsConsentStatusDenied": {}, + "settingsAnalyticsConsentStatusPending": "Menunggu", + "@settingsAnalyticsConsentStatusPending": {}, + "settingsAnalyticsReviewConsentAction": "Tinjau Persetujuan", + "@settingsAnalyticsReviewConsentAction": {}, + "settingsAnalyticsRevokeConsentAction": "Cabut Persetujuan", + "@settingsAnalyticsRevokeConsentAction": {}, + "settingsAnalyticsConsentAccepted": "Persetujuan analitik diterima.", + "@settingsAnalyticsConsentAccepted": {}, + "settingsAnalyticsConsentDeclined": "Persetujuan analitik ditolak.", + "@settingsAnalyticsConsentDeclined": {}, + "analyticsConsentDialogTitle": "Bantu Tingkatkan Collection Tracker", + "@analyticsConsentDialogTitle": {}, + "analyticsConsentDialogMessage": "Bolehkah kami mengumpulkan analitik penggunaan anonim untuk meningkatkan kualitas dan fitur aplikasi? Anda dapat mengubahnya kapan saja di Pengaturan.", + "@analyticsConsentDialogMessage": {}, + "analyticsConsentAllowAction": "Izinkan", + "@analyticsConsentAllowAction": {}, + "analyticsConsentDeclineAction": "Nanti saja", + "@analyticsConsentDeclineAction": {}, "settingsCrashlyticsTestTitle": "Uji Crashlytics", "@settingsCrashlyticsTestTitle": {}, "settingsCrashlyticsTestSubtitle": "Sengaja membuat aplikasi crash untuk memverifikasi pelaporan crash", diff --git a/apps/mobile/lib/l10n/arb/app_ja.arb b/apps/mobile/lib/l10n/arb/app_ja.arb index d636e9b..9525f19 100644 --- a/apps/mobile/lib/l10n/arb/app_ja.arb +++ b/apps/mobile/lib/l10n/arb/app_ja.arb @@ -74,6 +74,48 @@ "@settingsPrivacyPolicyTitle": {}, "settingsTermsTitle": "利用規約", "@settingsTermsTitle": {}, + "settingsAnalyticsTitle": "アナリティクス", + "@settingsAnalyticsTitle": {}, + "settingsAnalyticsSummaryEnabled": "有効", + "@settingsAnalyticsSummaryEnabled": {}, + "settingsAnalyticsSummaryDisabled": "無効", + "@settingsAnalyticsSummaryDisabled": {}, + "settingsAnalyticsSummaryPending": "同意が必要", + "@settingsAnalyticsSummaryPending": {}, + "settingsAnalyticsSummaryDenied": "同意しない", + "@settingsAnalyticsSummaryDenied": {}, + "settingsAnalyticsSheetTitle": "アナリティクス設定", + "@settingsAnalyticsSheetTitle": {}, + "settingsAnalyticsDescription": "匿名の利用状況分析とデータ共有設定を管理します。", + "@settingsAnalyticsDescription": {}, + "settingsAnalyticsToggleTitle": "アナリティクスを有効化", + "@settingsAnalyticsToggleTitle": {}, + "settingsAnalyticsToggleSubtitle": "匿名のアプリ利用イベントの収集を許可します。", + "@settingsAnalyticsToggleSubtitle": {}, + "settingsAnalyticsConsentStatusTitle": "同意ステータス", + "@settingsAnalyticsConsentStatusTitle": {}, + "settingsAnalyticsConsentStatusGranted": "同意済み", + "@settingsAnalyticsConsentStatusGranted": {}, + "settingsAnalyticsConsentStatusDenied": "拒否", + "@settingsAnalyticsConsentStatusDenied": {}, + "settingsAnalyticsConsentStatusPending": "保留", + "@settingsAnalyticsConsentStatusPending": {}, + "settingsAnalyticsReviewConsentAction": "同意内容を確認", + "@settingsAnalyticsReviewConsentAction": {}, + "settingsAnalyticsRevokeConsentAction": "同意を取り消す", + "@settingsAnalyticsRevokeConsentAction": {}, + "settingsAnalyticsConsentAccepted": "アナリティクスへの同意を受け付けました。", + "@settingsAnalyticsConsentAccepted": {}, + "settingsAnalyticsConsentDeclined": "アナリティクスへの同意を拒否しました。", + "@settingsAnalyticsConsentDeclined": {}, + "analyticsConsentDialogTitle": "Collection Tracker の改善にご協力ください", + "@analyticsConsentDialogTitle": {}, + "analyticsConsentDialogMessage": "アプリ品質と機能改善のため、匿名の利用データ収集にご協力いただけますか?この設定はいつでも設定画面で変更できます。", + "@analyticsConsentDialogMessage": {}, + "analyticsConsentAllowAction": "許可", + "@analyticsConsentAllowAction": {}, + "analyticsConsentDeclineAction": "今はしない", + "@analyticsConsentDeclineAction": {}, "settingsCrashlyticsTestTitle": "Crashlytics をテスト", "@settingsCrashlyticsTestTitle": {}, "settingsCrashlyticsTestSubtitle": "クラッシュレポートを確認するため、意図的にアプリをクラッシュさせます", diff --git a/apps/mobile/lib/l10n/arb/app_ko.arb b/apps/mobile/lib/l10n/arb/app_ko.arb index 541e421..f11e913 100644 --- a/apps/mobile/lib/l10n/arb/app_ko.arb +++ b/apps/mobile/lib/l10n/arb/app_ko.arb @@ -74,6 +74,48 @@ "@settingsPrivacyPolicyTitle": {}, "settingsTermsTitle": "이용 약관", "@settingsTermsTitle": {}, + "settingsAnalyticsTitle": "분석", + "@settingsAnalyticsTitle": {}, + "settingsAnalyticsSummaryEnabled": "활성화됨", + "@settingsAnalyticsSummaryEnabled": {}, + "settingsAnalyticsSummaryDisabled": "비활성화됨", + "@settingsAnalyticsSummaryDisabled": {}, + "settingsAnalyticsSummaryPending": "동의 필요", + "@settingsAnalyticsSummaryPending": {}, + "settingsAnalyticsSummaryDenied": "동의 거부", + "@settingsAnalyticsSummaryDenied": {}, + "settingsAnalyticsSheetTitle": "분석 설정", + "@settingsAnalyticsSheetTitle": {}, + "settingsAnalyticsDescription": "익명 사용 분석 및 데이터 공유 설정을 관리합니다.", + "@settingsAnalyticsDescription": {}, + "settingsAnalyticsToggleTitle": "분석 활성화", + "@settingsAnalyticsToggleTitle": {}, + "settingsAnalyticsToggleSubtitle": "익명 앱 사용 이벤트 수집을 허용합니다.", + "@settingsAnalyticsToggleSubtitle": {}, + "settingsAnalyticsConsentStatusTitle": "동의 상태", + "@settingsAnalyticsConsentStatusTitle": {}, + "settingsAnalyticsConsentStatusGranted": "동의함", + "@settingsAnalyticsConsentStatusGranted": {}, + "settingsAnalyticsConsentStatusDenied": "거부됨", + "@settingsAnalyticsConsentStatusDenied": {}, + "settingsAnalyticsConsentStatusPending": "대기 중", + "@settingsAnalyticsConsentStatusPending": {}, + "settingsAnalyticsReviewConsentAction": "동의 다시 보기", + "@settingsAnalyticsReviewConsentAction": {}, + "settingsAnalyticsRevokeConsentAction": "동의 철회", + "@settingsAnalyticsRevokeConsentAction": {}, + "settingsAnalyticsConsentAccepted": "분석 동의가 수락되었습니다.", + "@settingsAnalyticsConsentAccepted": {}, + "settingsAnalyticsConsentDeclined": "분석 동의가 거부되었습니다.", + "@settingsAnalyticsConsentDeclined": {}, + "analyticsConsentDialogTitle": "Collection Tracker 개선에 도움을 주세요", + "@analyticsConsentDialogTitle": {}, + "analyticsConsentDialogMessage": "앱 품질과 기능 개선을 위해 익명 사용 분석을 수집해도 될까요? 이 설정은 언제든지 설정에서 변경할 수 있습니다.", + "@analyticsConsentDialogMessage": {}, + "analyticsConsentAllowAction": "허용", + "@analyticsConsentAllowAction": {}, + "analyticsConsentDeclineAction": "나중에", + "@analyticsConsentDeclineAction": {}, "settingsCrashlyticsTestTitle": "Crashlytics 테스트", "@settingsCrashlyticsTestTitle": {}, "settingsCrashlyticsTestSubtitle": "크래시 보고 확인을 위해 앱을 의도적으로 종료합니다", diff --git a/apps/mobile/lib/l10n/arb/app_my.arb b/apps/mobile/lib/l10n/arb/app_my.arb index 943d41d..4245576 100644 --- a/apps/mobile/lib/l10n/arb/app_my.arb +++ b/apps/mobile/lib/l10n/arb/app_my.arb @@ -74,6 +74,48 @@ "@settingsPrivacyPolicyTitle": {}, "settingsTermsTitle": "အသုံးပြုမှု စည်းမျဉ်းများ", "@settingsTermsTitle": {}, + "settingsAnalyticsTitle": "Analytics", + "@settingsAnalyticsTitle": {}, + "settingsAnalyticsSummaryEnabled": "ဖွင့်ထားသည်", + "@settingsAnalyticsSummaryEnabled": {}, + "settingsAnalyticsSummaryDisabled": "ပိတ်ထားသည်", + "@settingsAnalyticsSummaryDisabled": {}, + "settingsAnalyticsSummaryPending": "သဘောတူညီချက်လိုအပ်သည်", + "@settingsAnalyticsSummaryPending": {}, + "settingsAnalyticsSummaryDenied": "သဘောတူညီချက် မပေးထားပါ", + "@settingsAnalyticsSummaryDenied": {}, + "settingsAnalyticsSheetTitle": "Analytics စိတ်ကြိုက်", + "@settingsAnalyticsSheetTitle": {}, + "settingsAnalyticsDescription": "အမည်မဖော်ဘဲ အသုံးပြုမှု analytics နှင့် ဒေတာမျှဝေမှုဆိုင်ရာ စိတ်ကြိုက်များကို စီမံပါ။", + "@settingsAnalyticsDescription": {}, + "settingsAnalyticsToggleTitle": "Analytics ဖွင့်ရန်", + "@settingsAnalyticsToggleTitle": {}, + "settingsAnalyticsToggleSubtitle": "အက်ပ်အသုံးပြုမှု event များကို အမည်မဖော်ဘဲ စုဆောင်းခွင့်ပြုမည်။", + "@settingsAnalyticsToggleSubtitle": {}, + "settingsAnalyticsConsentStatusTitle": "သဘောတူညီချက်အခြေအနေ", + "@settingsAnalyticsConsentStatusTitle": {}, + "settingsAnalyticsConsentStatusGranted": "သဘောတူသည်", + "@settingsAnalyticsConsentStatusGranted": {}, + "settingsAnalyticsConsentStatusDenied": "ငြင်းဆိုထားသည်", + "@settingsAnalyticsConsentStatusDenied": {}, + "settingsAnalyticsConsentStatusPending": "စောင့်ဆိုင်းနေသည်", + "@settingsAnalyticsConsentStatusPending": {}, + "settingsAnalyticsReviewConsentAction": "သဘောတူညီချက် ပြန်ကြည့်မည်", + "@settingsAnalyticsReviewConsentAction": {}, + "settingsAnalyticsRevokeConsentAction": "သဘောတူညီချက် ရုပ်သိမ်းမည်", + "@settingsAnalyticsRevokeConsentAction": {}, + "settingsAnalyticsConsentAccepted": "Analytics သဘောတူညီချက် လက်ခံပြီးပါပြီ။", + "@settingsAnalyticsConsentAccepted": {}, + "settingsAnalyticsConsentDeclined": "Analytics သဘောတူညီချက် ငြင်းဆိုထားသည်။", + "@settingsAnalyticsConsentDeclined": {}, + "analyticsConsentDialogTitle": "Collection Tracker ကို တိုးတက်စေရန် ကူညီပါ", + "@analyticsConsentDialogTitle": {}, + "analyticsConsentDialogMessage": "အက်ပ်အရည်အသွေးနှင့် အင်္ဂါရပ်များ တိုးတက်စေရန် အမည်မဖော်ထားသော အသုံးပြုမှု analytics ကို စုဆောင်းခွင့်ပြုမလား? ဤဆက်တင်ကို Settings တွင် အချိန်မရွေး ပြောင်းလဲနိုင်သည်။", + "@analyticsConsentDialogMessage": {}, + "analyticsConsentAllowAction": "ခွင့်ပြုမည်", + "@analyticsConsentAllowAction": {}, + "analyticsConsentDeclineAction": "ယခုမဟုတ်သေး", + "@analyticsConsentDeclineAction": {}, "settingsCrashlyticsTestTitle": "Crashlytics စမ်းသပ်ရန်", "@settingsCrashlyticsTestTitle": {}, "settingsCrashlyticsTestSubtitle": "Crash report ပို့မှုကို စစ်ဆေးရန် အက်ပ်ကို ရည်ရွယ်ချက်ရှိစွာ crash လုပ်မည်", diff --git a/apps/mobile/lib/l10n/arb/app_zh.arb b/apps/mobile/lib/l10n/arb/app_zh.arb index d487a5e..f7854a8 100644 --- a/apps/mobile/lib/l10n/arb/app_zh.arb +++ b/apps/mobile/lib/l10n/arb/app_zh.arb @@ -74,6 +74,48 @@ "@settingsPrivacyPolicyTitle": {}, "settingsTermsTitle": "服务条款", "@settingsTermsTitle": {}, + "settingsAnalyticsTitle": "分析", + "@settingsAnalyticsTitle": {}, + "settingsAnalyticsSummaryEnabled": "已启用", + "@settingsAnalyticsSummaryEnabled": {}, + "settingsAnalyticsSummaryDisabled": "已禁用", + "@settingsAnalyticsSummaryDisabled": {}, + "settingsAnalyticsSummaryPending": "需要同意", + "@settingsAnalyticsSummaryPending": {}, + "settingsAnalyticsSummaryDenied": "已拒绝同意", + "@settingsAnalyticsSummaryDenied": {}, + "settingsAnalyticsSheetTitle": "分析偏好设置", + "@settingsAnalyticsSheetTitle": {}, + "settingsAnalyticsDescription": "管理匿名使用分析和数据共享偏好。", + "@settingsAnalyticsDescription": {}, + "settingsAnalyticsToggleTitle": "启用分析", + "@settingsAnalyticsToggleTitle": {}, + "settingsAnalyticsToggleSubtitle": "允许收集匿名的应用使用事件。", + "@settingsAnalyticsToggleSubtitle": {}, + "settingsAnalyticsConsentStatusTitle": "同意状态", + "@settingsAnalyticsConsentStatusTitle": {}, + "settingsAnalyticsConsentStatusGranted": "已同意", + "@settingsAnalyticsConsentStatusGranted": {}, + "settingsAnalyticsConsentStatusDenied": "已拒绝", + "@settingsAnalyticsConsentStatusDenied": {}, + "settingsAnalyticsConsentStatusPending": "待确认", + "@settingsAnalyticsConsentStatusPending": {}, + "settingsAnalyticsReviewConsentAction": "查看同意内容", + "@settingsAnalyticsReviewConsentAction": {}, + "settingsAnalyticsRevokeConsentAction": "撤销同意", + "@settingsAnalyticsRevokeConsentAction": {}, + "settingsAnalyticsConsentAccepted": "已同意分析收集。", + "@settingsAnalyticsConsentAccepted": {}, + "settingsAnalyticsConsentDeclined": "已拒绝分析收集。", + "@settingsAnalyticsConsentDeclined": {}, + "analyticsConsentDialogTitle": "帮助改进 Collection Tracker", + "@analyticsConsentDialogTitle": {}, + "analyticsConsentDialogMessage": "我们可以收集匿名使用分析以改进应用质量和功能吗?你可以随时在设置中更改。", + "@analyticsConsentDialogMessage": {}, + "analyticsConsentAllowAction": "允许", + "@analyticsConsentAllowAction": {}, + "analyticsConsentDeclineAction": "暂不", + "@analyticsConsentDeclineAction": {}, "settingsCrashlyticsTestTitle": "测试 Crashlytics", "@settingsCrashlyticsTestTitle": {}, "settingsCrashlyticsTestSubtitle": "故意让应用崩溃以验证崩溃上报", diff --git a/apps/mobile/lib/l10n/gen/app_localizations.dart b/apps/mobile/lib/l10n/gen/app_localizations.dart index 313cb0a..766a440 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations.dart @@ -329,6 +329,132 @@ abstract class AppLocalizations { /// **'Terms of Service'** String get settingsTermsTitle; + /// No description provided for @settingsAnalyticsTitle. + /// + /// In en, this message translates to: + /// **'Analytics'** + String get settingsAnalyticsTitle; + + /// No description provided for @settingsAnalyticsSummaryEnabled. + /// + /// In en, this message translates to: + /// **'Enabled'** + String get settingsAnalyticsSummaryEnabled; + + /// No description provided for @settingsAnalyticsSummaryDisabled. + /// + /// In en, this message translates to: + /// **'Disabled'** + String get settingsAnalyticsSummaryDisabled; + + /// No description provided for @settingsAnalyticsSummaryPending. + /// + /// In en, this message translates to: + /// **'Consent required'** + String get settingsAnalyticsSummaryPending; + + /// No description provided for @settingsAnalyticsSummaryDenied. + /// + /// In en, this message translates to: + /// **'Consent declined'** + String get settingsAnalyticsSummaryDenied; + + /// No description provided for @settingsAnalyticsSheetTitle. + /// + /// In en, this message translates to: + /// **'Analytics Preferences'** + String get settingsAnalyticsSheetTitle; + + /// No description provided for @settingsAnalyticsDescription. + /// + /// In en, this message translates to: + /// **'Control anonymous usage analytics and data sharing preferences.'** + String get settingsAnalyticsDescription; + + /// No description provided for @settingsAnalyticsToggleTitle. + /// + /// In en, this message translates to: + /// **'Enable analytics'** + String get settingsAnalyticsToggleTitle; + + /// No description provided for @settingsAnalyticsToggleSubtitle. + /// + /// In en, this message translates to: + /// **'Allow anonymous app usage events to be collected.'** + String get settingsAnalyticsToggleSubtitle; + + /// No description provided for @settingsAnalyticsConsentStatusTitle. + /// + /// In en, this message translates to: + /// **'Consent status'** + String get settingsAnalyticsConsentStatusTitle; + + /// No description provided for @settingsAnalyticsConsentStatusGranted. + /// + /// In en, this message translates to: + /// **'Granted'** + String get settingsAnalyticsConsentStatusGranted; + + /// No description provided for @settingsAnalyticsConsentStatusDenied. + /// + /// In en, this message translates to: + /// **'Declined'** + String get settingsAnalyticsConsentStatusDenied; + + /// No description provided for @settingsAnalyticsConsentStatusPending. + /// + /// In en, this message translates to: + /// **'Pending'** + String get settingsAnalyticsConsentStatusPending; + + /// No description provided for @settingsAnalyticsReviewConsentAction. + /// + /// In en, this message translates to: + /// **'Review Consent'** + String get settingsAnalyticsReviewConsentAction; + + /// No description provided for @settingsAnalyticsRevokeConsentAction. + /// + /// In en, this message translates to: + /// **'Revoke Consent'** + String get settingsAnalyticsRevokeConsentAction; + + /// No description provided for @settingsAnalyticsConsentAccepted. + /// + /// In en, this message translates to: + /// **'Analytics consent accepted.'** + String get settingsAnalyticsConsentAccepted; + + /// No description provided for @settingsAnalyticsConsentDeclined. + /// + /// In en, this message translates to: + /// **'Analytics consent declined.'** + String get settingsAnalyticsConsentDeclined; + + /// No description provided for @analyticsConsentDialogTitle. + /// + /// In en, this message translates to: + /// **'Help Improve Collection Tracker'** + String get analyticsConsentDialogTitle; + + /// No description provided for @analyticsConsentDialogMessage. + /// + /// In en, this message translates to: + /// **'Can we collect anonymous usage analytics to improve app quality and features? You can change this anytime in Settings.'** + String get analyticsConsentDialogMessage; + + /// No description provided for @analyticsConsentAllowAction. + /// + /// In en, this message translates to: + /// **'Allow'** + String get analyticsConsentAllowAction; + + /// No description provided for @analyticsConsentDeclineAction. + /// + /// In en, this message translates to: + /// **'Not now'** + String get analyticsConsentDeclineAction; + /// No description provided for @settingsCrashlyticsTestTitle. /// /// In en, this message translates to: diff --git a/apps/mobile/lib/l10n/gen/app_localizations_en.dart b/apps/mobile/lib/l10n/gen/app_localizations_en.dart index 988d81f..983c245 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_en.dart @@ -122,6 +122,69 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsTermsTitle => 'Terms of Service'; + @override + String get settingsAnalyticsTitle => 'Analytics'; + + @override + String get settingsAnalyticsSummaryEnabled => 'Enabled'; + + @override + String get settingsAnalyticsSummaryDisabled => 'Disabled'; + + @override + String get settingsAnalyticsSummaryPending => 'Consent required'; + + @override + String get settingsAnalyticsSummaryDenied => 'Consent declined'; + + @override + String get settingsAnalyticsSheetTitle => 'Analytics Preferences'; + + @override + String get settingsAnalyticsDescription => 'Control anonymous usage analytics and data sharing preferences.'; + + @override + String get settingsAnalyticsToggleTitle => 'Enable analytics'; + + @override + String get settingsAnalyticsToggleSubtitle => 'Allow anonymous app usage events to be collected.'; + + @override + String get settingsAnalyticsConsentStatusTitle => 'Consent status'; + + @override + String get settingsAnalyticsConsentStatusGranted => 'Granted'; + + @override + String get settingsAnalyticsConsentStatusDenied => 'Declined'; + + @override + String get settingsAnalyticsConsentStatusPending => 'Pending'; + + @override + String get settingsAnalyticsReviewConsentAction => 'Review Consent'; + + @override + String get settingsAnalyticsRevokeConsentAction => 'Revoke Consent'; + + @override + String get settingsAnalyticsConsentAccepted => 'Analytics consent accepted.'; + + @override + String get settingsAnalyticsConsentDeclined => 'Analytics consent declined.'; + + @override + String get analyticsConsentDialogTitle => 'Help Improve Collection Tracker'; + + @override + String get analyticsConsentDialogMessage => 'Can we collect anonymous usage analytics to improve app quality and features? You can change this anytime in Settings.'; + + @override + String get analyticsConsentAllowAction => 'Allow'; + + @override + String get analyticsConsentDeclineAction => 'Not now'; + @override String get settingsCrashlyticsTestTitle => 'Test Crashlytics'; diff --git a/apps/mobile/lib/l10n/gen/app_localizations_es.dart b/apps/mobile/lib/l10n/gen/app_localizations_es.dart index 77cd45b..4549da2 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_es.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_es.dart @@ -122,6 +122,69 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settingsTermsTitle => 'Términos de servicio'; + @override + String get settingsAnalyticsTitle => 'Analíticas'; + + @override + String get settingsAnalyticsSummaryEnabled => 'Activadas'; + + @override + String get settingsAnalyticsSummaryDisabled => 'Desactivadas'; + + @override + String get settingsAnalyticsSummaryPending => 'Se requiere consentimiento'; + + @override + String get settingsAnalyticsSummaryDenied => 'Consentimiento rechazado'; + + @override + String get settingsAnalyticsSheetTitle => 'Preferencias de analíticas'; + + @override + String get settingsAnalyticsDescription => 'Controla las analíticas de uso anónimas y las preferencias de compartición de datos.'; + + @override + String get settingsAnalyticsToggleTitle => 'Activar analíticas'; + + @override + String get settingsAnalyticsToggleSubtitle => 'Permite recopilar eventos anónimos de uso de la aplicación.'; + + @override + String get settingsAnalyticsConsentStatusTitle => 'Estado del consentimiento'; + + @override + String get settingsAnalyticsConsentStatusGranted => 'Concedido'; + + @override + String get settingsAnalyticsConsentStatusDenied => 'Rechazado'; + + @override + String get settingsAnalyticsConsentStatusPending => 'Pendiente'; + + @override + String get settingsAnalyticsReviewConsentAction => 'Revisar consentimiento'; + + @override + String get settingsAnalyticsRevokeConsentAction => 'Revocar consentimiento'; + + @override + String get settingsAnalyticsConsentAccepted => 'Consentimiento de analíticas aceptado.'; + + @override + String get settingsAnalyticsConsentDeclined => 'Consentimiento de analíticas rechazado.'; + + @override + String get analyticsConsentDialogTitle => 'Ayúdanos a mejorar Collection Tracker'; + + @override + String get analyticsConsentDialogMessage => '¿Podemos recopilar analíticas de uso anónimas para mejorar la calidad y las funciones de la app? Puedes cambiarlo en Configuración en cualquier momento.'; + + @override + String get analyticsConsentAllowAction => 'Permitir'; + + @override + String get analyticsConsentDeclineAction => 'Ahora no'; + @override String get settingsCrashlyticsTestTitle => 'Probar Crashlytics'; diff --git a/apps/mobile/lib/l10n/gen/app_localizations_id.dart b/apps/mobile/lib/l10n/gen/app_localizations_id.dart index 6d37d24..932b701 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_id.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_id.dart @@ -122,6 +122,69 @@ class AppLocalizationsId extends AppLocalizations { @override String get settingsTermsTitle => 'Ketentuan Layanan'; + @override + String get settingsAnalyticsTitle => 'Analitik'; + + @override + String get settingsAnalyticsSummaryEnabled => 'Aktif'; + + @override + String get settingsAnalyticsSummaryDisabled => 'Nonaktif'; + + @override + String get settingsAnalyticsSummaryPending => 'Perlu persetujuan'; + + @override + String get settingsAnalyticsSummaryDenied => 'Persetujuan ditolak'; + + @override + String get settingsAnalyticsSheetTitle => 'Preferensi Analitik'; + + @override + String get settingsAnalyticsDescription => 'Atur analitik penggunaan anonim dan preferensi berbagi data.'; + + @override + String get settingsAnalyticsToggleTitle => 'Aktifkan analitik'; + + @override + String get settingsAnalyticsToggleSubtitle => 'Izinkan pengumpulan event penggunaan aplikasi secara anonim.'; + + @override + String get settingsAnalyticsConsentStatusTitle => 'Status persetujuan'; + + @override + String get settingsAnalyticsConsentStatusGranted => 'Disetujui'; + + @override + String get settingsAnalyticsConsentStatusDenied => 'Ditolak'; + + @override + String get settingsAnalyticsConsentStatusPending => 'Menunggu'; + + @override + String get settingsAnalyticsReviewConsentAction => 'Tinjau Persetujuan'; + + @override + String get settingsAnalyticsRevokeConsentAction => 'Cabut Persetujuan'; + + @override + String get settingsAnalyticsConsentAccepted => 'Persetujuan analitik diterima.'; + + @override + String get settingsAnalyticsConsentDeclined => 'Persetujuan analitik ditolak.'; + + @override + String get analyticsConsentDialogTitle => 'Bantu Tingkatkan Collection Tracker'; + + @override + String get analyticsConsentDialogMessage => 'Bolehkah kami mengumpulkan analitik penggunaan anonim untuk meningkatkan kualitas dan fitur aplikasi? Anda dapat mengubahnya kapan saja di Pengaturan.'; + + @override + String get analyticsConsentAllowAction => 'Izinkan'; + + @override + String get analyticsConsentDeclineAction => 'Nanti saja'; + @override String get settingsCrashlyticsTestTitle => 'Uji Crashlytics'; diff --git a/apps/mobile/lib/l10n/gen/app_localizations_ja.dart b/apps/mobile/lib/l10n/gen/app_localizations_ja.dart index 5f61d8b..894afb5 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_ja.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_ja.dart @@ -122,6 +122,69 @@ class AppLocalizationsJa extends AppLocalizations { @override String get settingsTermsTitle => '利用規約'; + @override + String get settingsAnalyticsTitle => 'アナリティクス'; + + @override + String get settingsAnalyticsSummaryEnabled => '有効'; + + @override + String get settingsAnalyticsSummaryDisabled => '無効'; + + @override + String get settingsAnalyticsSummaryPending => '同意が必要'; + + @override + String get settingsAnalyticsSummaryDenied => '同意しない'; + + @override + String get settingsAnalyticsSheetTitle => 'アナリティクス設定'; + + @override + String get settingsAnalyticsDescription => '匿名の利用状況分析とデータ共有設定を管理します。'; + + @override + String get settingsAnalyticsToggleTitle => 'アナリティクスを有効化'; + + @override + String get settingsAnalyticsToggleSubtitle => '匿名のアプリ利用イベントの収集を許可します。'; + + @override + String get settingsAnalyticsConsentStatusTitle => '同意ステータス'; + + @override + String get settingsAnalyticsConsentStatusGranted => '同意済み'; + + @override + String get settingsAnalyticsConsentStatusDenied => '拒否'; + + @override + String get settingsAnalyticsConsentStatusPending => '保留'; + + @override + String get settingsAnalyticsReviewConsentAction => '同意内容を確認'; + + @override + String get settingsAnalyticsRevokeConsentAction => '同意を取り消す'; + + @override + String get settingsAnalyticsConsentAccepted => 'アナリティクスへの同意を受け付けました。'; + + @override + String get settingsAnalyticsConsentDeclined => 'アナリティクスへの同意を拒否しました。'; + + @override + String get analyticsConsentDialogTitle => 'Collection Tracker の改善にご協力ください'; + + @override + String get analyticsConsentDialogMessage => 'アプリ品質と機能改善のため、匿名の利用データ収集にご協力いただけますか?この設定はいつでも設定画面で変更できます。'; + + @override + String get analyticsConsentAllowAction => '許可'; + + @override + String get analyticsConsentDeclineAction => '今はしない'; + @override String get settingsCrashlyticsTestTitle => 'Crashlytics をテスト'; diff --git a/apps/mobile/lib/l10n/gen/app_localizations_ko.dart b/apps/mobile/lib/l10n/gen/app_localizations_ko.dart index 2cd193c..2cdc228 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_ko.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_ko.dart @@ -122,6 +122,69 @@ class AppLocalizationsKo extends AppLocalizations { @override String get settingsTermsTitle => '이용 약관'; + @override + String get settingsAnalyticsTitle => '분석'; + + @override + String get settingsAnalyticsSummaryEnabled => '활성화됨'; + + @override + String get settingsAnalyticsSummaryDisabled => '비활성화됨'; + + @override + String get settingsAnalyticsSummaryPending => '동의 필요'; + + @override + String get settingsAnalyticsSummaryDenied => '동의 거부'; + + @override + String get settingsAnalyticsSheetTitle => '분석 설정'; + + @override + String get settingsAnalyticsDescription => '익명 사용 분석 및 데이터 공유 설정을 관리합니다.'; + + @override + String get settingsAnalyticsToggleTitle => '분석 활성화'; + + @override + String get settingsAnalyticsToggleSubtitle => '익명 앱 사용 이벤트 수집을 허용합니다.'; + + @override + String get settingsAnalyticsConsentStatusTitle => '동의 상태'; + + @override + String get settingsAnalyticsConsentStatusGranted => '동의함'; + + @override + String get settingsAnalyticsConsentStatusDenied => '거부됨'; + + @override + String get settingsAnalyticsConsentStatusPending => '대기 중'; + + @override + String get settingsAnalyticsReviewConsentAction => '동의 다시 보기'; + + @override + String get settingsAnalyticsRevokeConsentAction => '동의 철회'; + + @override + String get settingsAnalyticsConsentAccepted => '분석 동의가 수락되었습니다.'; + + @override + String get settingsAnalyticsConsentDeclined => '분석 동의가 거부되었습니다.'; + + @override + String get analyticsConsentDialogTitle => 'Collection Tracker 개선에 도움을 주세요'; + + @override + String get analyticsConsentDialogMessage => '앱 품질과 기능 개선을 위해 익명 사용 분석을 수집해도 될까요? 이 설정은 언제든지 설정에서 변경할 수 있습니다.'; + + @override + String get analyticsConsentAllowAction => '허용'; + + @override + String get analyticsConsentDeclineAction => '나중에'; + @override String get settingsCrashlyticsTestTitle => 'Crashlytics 테스트'; diff --git a/apps/mobile/lib/l10n/gen/app_localizations_my.dart b/apps/mobile/lib/l10n/gen/app_localizations_my.dart index 88a6e9e..87624e7 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_my.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_my.dart @@ -122,6 +122,69 @@ class AppLocalizationsMy extends AppLocalizations { @override String get settingsTermsTitle => 'အသုံးပြုမှု စည်းမျဉ်းများ'; + @override + String get settingsAnalyticsTitle => 'Analytics'; + + @override + String get settingsAnalyticsSummaryEnabled => 'ဖွင့်ထားသည်'; + + @override + String get settingsAnalyticsSummaryDisabled => 'ပိတ်ထားသည်'; + + @override + String get settingsAnalyticsSummaryPending => 'သဘောတူညီချက်လိုအပ်သည်'; + + @override + String get settingsAnalyticsSummaryDenied => 'သဘောတူညီချက် မပေးထားပါ'; + + @override + String get settingsAnalyticsSheetTitle => 'Analytics စိတ်ကြိုက်'; + + @override + String get settingsAnalyticsDescription => 'အမည်မဖော်ဘဲ အသုံးပြုမှု analytics နှင့် ဒေတာမျှဝေမှုဆိုင်ရာ စိတ်ကြိုက်များကို စီမံပါ။'; + + @override + String get settingsAnalyticsToggleTitle => 'Analytics ဖွင့်ရန်'; + + @override + String get settingsAnalyticsToggleSubtitle => 'အက်ပ်အသုံးပြုမှု event များကို အမည်မဖော်ဘဲ စုဆောင်းခွင့်ပြုမည်။'; + + @override + String get settingsAnalyticsConsentStatusTitle => 'သဘောတူညီချက်အခြေအနေ'; + + @override + String get settingsAnalyticsConsentStatusGranted => 'သဘောတူသည်'; + + @override + String get settingsAnalyticsConsentStatusDenied => 'ငြင်းဆိုထားသည်'; + + @override + String get settingsAnalyticsConsentStatusPending => 'စောင့်ဆိုင်းနေသည်'; + + @override + String get settingsAnalyticsReviewConsentAction => 'သဘောတူညီချက် ပြန်ကြည့်မည်'; + + @override + String get settingsAnalyticsRevokeConsentAction => 'သဘောတူညီချက် ရုပ်သိမ်းမည်'; + + @override + String get settingsAnalyticsConsentAccepted => 'Analytics သဘောတူညီချက် လက်ခံပြီးပါပြီ။'; + + @override + String get settingsAnalyticsConsentDeclined => 'Analytics သဘောတူညီချက် ငြင်းဆိုထားသည်။'; + + @override + String get analyticsConsentDialogTitle => 'Collection Tracker ကို တိုးတက်စေရန် ကူညီပါ'; + + @override + String get analyticsConsentDialogMessage => 'အက်ပ်အရည်အသွေးနှင့် အင်္ဂါရပ်များ တိုးတက်စေရန် အမည်မဖော်ထားသော အသုံးပြုမှု analytics ကို စုဆောင်းခွင့်ပြုမလား? ဤဆက်တင်ကို Settings တွင် အချိန်မရွေး ပြောင်းလဲနိုင်သည်။'; + + @override + String get analyticsConsentAllowAction => 'ခွင့်ပြုမည်'; + + @override + String get analyticsConsentDeclineAction => 'ယခုမဟုတ်သေး'; + @override String get settingsCrashlyticsTestTitle => 'Crashlytics စမ်းသပ်ရန်'; diff --git a/apps/mobile/lib/l10n/gen/app_localizations_zh.dart b/apps/mobile/lib/l10n/gen/app_localizations_zh.dart index 7ce3f2f..f6a8e8a 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_zh.dart @@ -122,6 +122,69 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settingsTermsTitle => '服务条款'; + @override + String get settingsAnalyticsTitle => '分析'; + + @override + String get settingsAnalyticsSummaryEnabled => '已启用'; + + @override + String get settingsAnalyticsSummaryDisabled => '已禁用'; + + @override + String get settingsAnalyticsSummaryPending => '需要同意'; + + @override + String get settingsAnalyticsSummaryDenied => '已拒绝同意'; + + @override + String get settingsAnalyticsSheetTitle => '分析偏好设置'; + + @override + String get settingsAnalyticsDescription => '管理匿名使用分析和数据共享偏好。'; + + @override + String get settingsAnalyticsToggleTitle => '启用分析'; + + @override + String get settingsAnalyticsToggleSubtitle => '允许收集匿名的应用使用事件。'; + + @override + String get settingsAnalyticsConsentStatusTitle => '同意状态'; + + @override + String get settingsAnalyticsConsentStatusGranted => '已同意'; + + @override + String get settingsAnalyticsConsentStatusDenied => '已拒绝'; + + @override + String get settingsAnalyticsConsentStatusPending => '待确认'; + + @override + String get settingsAnalyticsReviewConsentAction => '查看同意内容'; + + @override + String get settingsAnalyticsRevokeConsentAction => '撤销同意'; + + @override + String get settingsAnalyticsConsentAccepted => '已同意分析收集。'; + + @override + String get settingsAnalyticsConsentDeclined => '已拒绝分析收集。'; + + @override + String get analyticsConsentDialogTitle => '帮助改进 Collection Tracker'; + + @override + String get analyticsConsentDialogMessage => '我们可以收集匿名使用分析以改进应用质量和功能吗?你可以随时在设置中更改。'; + + @override + String get analyticsConsentAllowAction => '允许'; + + @override + String get analyticsConsentDeclineAction => '暂不'; + @override String get settingsCrashlyticsTestTitle => '测试 Crashlytics'; diff --git a/packages/integrations/analytics/lib/src/core/analytics_service.dart b/packages/integrations/analytics/lib/src/core/analytics_service.dart index d3a74f0..beb0a8d 100644 --- a/packages/integrations/analytics/lib/src/core/analytics_service.dart +++ b/packages/integrations/analytics/lib/src/core/analytics_service.dart @@ -3,6 +3,7 @@ import 'analytics_event.dart'; import 'analytics_middleware.dart'; import 'analytics_provider.dart'; import 'analytics_user.dart'; +import '../providers/base_analytics_provider.dart'; /// Main analytics service - Singleton class AnalyticsService { @@ -18,6 +19,7 @@ class AnalyticsService { bool _initialized = false; bool _consentGranted = false; + bool _trackingEnabled = true; AnalyticsService._(); @@ -30,7 +32,22 @@ class AnalyticsService { /// Initialize analytics service static Future initialize(AnalyticsConfig config) async { final service = instance; + if (service._initialized) { + for (final provider in service._providers) { + try { + await provider.dispose(); + } catch (_) { + // Ignore dispose failures while reconfiguring + } + } + service._providers.clear(); + service._middleware.clear(); + service._initialized = false; + } + service._config = config; + service._consentGranted = false; + service._trackingEnabled = true; // Initialize providers for (final provider in config.providers) { @@ -65,9 +82,27 @@ class AnalyticsService { /// Check if initialized bool get isInitialized => _initialized; + /// Check if analytics event tracking is enabled + bool get isTrackingEnabled => _trackingEnabled; + /// Check if consent is granted bool get hasConsent => _consentGranted; + /// Enable/disable analytics tracking at runtime. + Future setTrackingEnabled(bool enabled) async { + _trackingEnabled = enabled; + + for (final provider in _providers) { + if (provider is BaseAnalyticsProvider) { + provider.enabled = enabled; + } + } + + if (enabled) { + await flush(); + } + } + /// Set user consent Future setConsentGranted(bool granted) async { _consentGranted = granted; @@ -87,6 +122,13 @@ class AnalyticsService { return; } + if (!_trackingEnabled) { + if (_config?.enableLogging ?? false) { + print('Event blocked: analytics disabled'); + } + return; + } + // Check consent if required if (_config?.requireConsent ?? false) { if (!_consentGranted) { @@ -229,6 +271,7 @@ class AnalyticsService { _providers.clear(); _middleware.clear(); _initialized = false; + _trackingEnabled = true; _instance = null; } From 8eecf555a3db233fd2c4303bf7acc40e40b56475 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 06:34:18 +0630 Subject: [PATCH 05/31] feat: Implement offline event queuing, app lifecycle tracking, and enhanced consent management for the analytics service. --- .../core/observers/analytics_observer.dart | 9 +- .../lib/src/core/analytics_service.dart | 368 ++++++++++++------ .../src/middleware/consent_middleware.dart | 26 +- .../src/middleware/enrichment_middleware.dart | 41 +- .../lib/src/middleware/queue_middleware.dart | 89 +---- .../lib/src/storage/analytics_storage.dart | 51 ++- .../test/core/analytics_service_test.dart | 45 ++- 7 files changed, 408 insertions(+), 221 deletions(-) diff --git a/apps/mobile/lib/core/observers/analytics_observer.dart b/apps/mobile/lib/core/observers/analytics_observer.dart index 205a819..af43caa 100644 --- a/apps/mobile/lib/core/observers/analytics_observer.dart +++ b/apps/mobile/lib/core/observers/analytics_observer.dart @@ -26,9 +26,14 @@ class AnalyticsObserver extends NavigatorObserver { } void _trackScreenView(Route route) { + final analytics = AnalyticsService.instance; + if (!analytics.isInitialized || !analytics.shouldAutoTrackScreenViews) { + return; + } + final screenName = route.settings.name; - if (screenName != null) { - AnalyticsService.instance.trackScreen(screenName); + if (screenName != null && screenName.isNotEmpty) { + analytics.trackScreen(screenName); } } } diff --git a/packages/integrations/analytics/lib/src/core/analytics_service.dart b/packages/integrations/analytics/lib/src/core/analytics_service.dart index beb0a8d..88701c7 100644 --- a/packages/integrations/analytics/lib/src/core/analytics_service.dart +++ b/packages/integrations/analytics/lib/src/core/analytics_service.dart @@ -1,9 +1,16 @@ +import 'dart:async'; + +import 'package:app_analytics/src/events/app_events.dart'; +import 'package:app_analytics/src/providers/base_analytics_provider.dart'; +import 'package:app_analytics/src/storage/analytics_storage.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/widgets.dart'; + import 'analytics_config.dart'; import 'analytics_event.dart'; import 'analytics_middleware.dart'; import 'analytics_provider.dart'; import 'analytics_user.dart'; -import '../providers/base_analytics_provider.dart'; /// Main analytics service - Singleton class AnalyticsService { @@ -16,10 +23,16 @@ class AnalyticsService { final List _providers = []; final List _middleware = []; + final Connectivity _connectivity = Connectivity(); + + AnalyticsStorage _queueStorage = AnalyticsStorage(); + Timer? _flushTimer; + _AnalyticsLifecycleObserver? _lifecycleObserver; bool _initialized = false; bool _consentGranted = false; bool _trackingEnabled = true; + bool _isFlushingQueuedEvents = false; AnalyticsService._(); @@ -33,21 +46,17 @@ class AnalyticsService { static Future initialize(AnalyticsConfig config) async { final service = instance; if (service._initialized) { - for (final provider in service._providers) { - try { - await provider.dispose(); - } catch (_) { - // Ignore dispose failures while reconfiguring - } - } - service._providers.clear(); - service._middleware.clear(); - service._initialized = false; + await service._teardownProvidersAndObservers(); } service._config = config; - service._consentGranted = false; + service._queueStorage = AnalyticsStorage(maxQueueSize: config.maxQueueSize); + service._consentGranted = !config.requireConsent; service._trackingEnabled = true; + service._currentUser = null; + service._currentSessionId = null; + service._sessionStartTime = null; + service._isFlushingQueuedEvents = false; // Initialize providers for (final provider in config.providers) { @@ -55,28 +64,32 @@ class AnalyticsService { await provider.initialize(); service._providers.add(provider); } catch (e) { - if (config.enableLogging) { - print('Failed to initialize provider ${provider.name}: $e'); - } + service._log('Failed to initialize provider ${provider.name}: $e'); } } - // Add middleware (sorted by priority) + // Add middleware sorted by priority (higher runs first) final sortedMiddleware = List.from(config.middleware) ..sort((a, b) => b.priority.compareTo(a.priority)); - service._middleware.addAll(sortedMiddleware); - // Start new session service._startNewSession(); - service._initialized = true; - if (config.enableLogging) { - print( - 'Analytics initialized with ${service._providers.length} providers', - ); + service._attachLifecycleObserverIfNeeded(); + service._startFlushTimer(); + + if (config.autoTrackAppLifecycle) { + unawaited(service.track(AppEvents.appOpened())); } + + if (!config.requireConsent && config.enableOfflineQueue) { + unawaited(service.flushQueuedEvents()); + } + + service._log( + 'Analytics initialized with ${service._providers.length} providers', + ); } /// Check if initialized @@ -88,6 +101,12 @@ class AnalyticsService { /// Check if consent is granted bool get hasConsent => _consentGranted; + /// Whether configured to auto-track screen views. + bool get shouldAutoTrackScreenViews => _config?.autoTrackScreenViews ?? true; + + /// Whether offline queueing is enabled by config. + bool get isOfflineQueueEnabled => _config?.enableOfflineQueue ?? false; + /// Enable/disable analytics tracking at runtime. Future setTrackingEnabled(bool enabled) async { _trackingEnabled = enabled; @@ -103,61 +122,45 @@ class AnalyticsService { } } - /// Set user consent + /// Set user consent. Future setConsentGranted(bool granted) async { _consentGranted = granted; if (granted) { - // Flush any queued events await flush(); } } - /// Track an event + /// Track an event. Future track(AnalyticsEvent event) async { if (!_initialized) { - if (_config?.enableLogging ?? false) { - print('Analytics not initialized'); - } + _log('Analytics not initialized'); return; } - if (!_trackingEnabled) { - if (_config?.enableLogging ?? false) { - print('Event blocked: analytics disabled'); - } + if (!_canTrackEvents(logBlocked: true)) { return; } - // Check consent if required - if (_config?.requireConsent ?? false) { - if (!_consentGranted) { - if (_config?.enableLogging ?? false) { - print('Event blocked: consent not granted'); - } - return; - } - } - - // Enrich event with common data - AnalyticsEvent? enrichedEvent = _enrichEvent(event); - - // Process through middleware - enrichedEvent = await _processMiddleware(enrichedEvent); - if (enrichedEvent == null) { - return; // Event was dropped + final enrichedEvent = _enrichEvent(event); + final processedEvent = await _processMiddleware(enrichedEvent); + if (processedEvent == null) { + return; } - // Send to all providers - await _sendToProviders(enrichedEvent); + await _sendOrQueue(processedEvent); } - /// Track screen view + /// Track screen view. Future trackScreen( String screenName, { String? screenClass, Map? properties, }) async { + if (!shouldAutoTrackScreenViews) { + return; + } + await track( AnalyticsEvent.screenView( screenName: screenName, @@ -167,7 +170,7 @@ class AnalyticsService { ); } - /// Identify user + /// Identify user. Future identifyUser({ required String userId, Map? properties, @@ -180,21 +183,18 @@ class AnalyticsService { createdAt: DateTime.now(), ); - // Send to all providers for (final provider in _providers) { if (!provider.isEnabled) continue; try { await provider.identifyUser(_currentUser!); } catch (e) { - if (_config?.enableLogging ?? false) { - print('Error identifying user in ${provider.name}: $e'); - } + _log('Error identifying user in ${provider.name}: $e'); } } } - /// Set user properties + /// Set user properties. Future setUserProperties(Map properties) async { if (!_initialized) return; @@ -204,78 +204,141 @@ class AnalyticsService { ); } - // Send to all providers for (final provider in _providers) { if (!provider.isEnabled) continue; try { await provider.setUserProperties(properties); } catch (e) { - if (_config?.enableLogging ?? false) { - print('Error setting user properties in ${provider.name}: $e'); - } + _log('Error setting user properties in ${provider.name}: $e'); } } } - /// Reset analytics (logout) + /// Reset analytics (logout). Future reset() async { if (!_initialized) return; _currentUser = null; _startNewSession(); - // Reset all providers for (final provider in _providers) { if (!provider.isEnabled) continue; try { await provider.reset(); } catch (e) { - if (_config?.enableLogging ?? false) { - print('Error resetting ${provider.name}: $e'); - } + _log('Error resetting ${provider.name}: $e'); } } } - /// Flush pending events + /// Flush pending events and queued offline events. Future flush() async { if (!_initialized) return; + await flushQueuedEvents(); + for (final provider in _providers) { if (!provider.isEnabled) continue; try { await provider.flush(); } catch (e) { - if (_config?.enableLogging ?? false) { - print('Error flushing ${provider.name}: $e'); + _log('Error flushing ${provider.name}: $e'); + } + } + } + + /// Check current connectivity status. + Future isOnline() async { + try { + final connectivityResult = await _connectivity.checkConnectivity(); + return !connectivityResult.contains(ConnectivityResult.none); + } catch (e) { + // Fail open so analytics can still attempt provider delivery. + _log('Connectivity check failed: $e'); + return true; + } + } + + /// Queue an already-processed event for later delivery. + Future queueEvent( + AnalyticsEvent event, { + AnalyticsStorage? storage, + }) async { + if (!isOfflineQueueEnabled) return; + + final targetStorage = storage ?? _queueStorage; + await targetStorage.addToQueue(event); + _log('Queued event: ${event.name}'); + } + + /// Flush queued events from offline storage. + Future flushQueuedEvents({AnalyticsStorage? storage}) async { + if (!_initialized || !isOfflineQueueEnabled) return; + if (!_canTrackEvents(logBlocked: false)) return; + if (_isFlushingQueuedEvents) return; + if (!await isOnline()) return; + + _isFlushingQueuedEvents = true; + final targetStorage = storage ?? _queueStorage; + + try { + final queue = await targetStorage.getQueue(); + if (queue.isEmpty) return; + + final failedEvents = []; + for (final queuedEvent in queue) { + final delivered = await _sendToProviders(queuedEvent); + if (!delivered) { + failedEvents.add(queuedEvent); } } + + if (failedEvents.isEmpty) { + await targetStorage.clearQueue(); + } else { + await targetStorage.saveQueue(failedEvents); + } + + _log( + 'Flushed queued events: ${queue.length - failedEvents.length}/${queue.length}', + ); + } finally { + _isFlushingQueuedEvents = false; } } - /// Dispose service + /// Dispose service. Future dispose() async { + await _teardownProvidersAndObservers(); + _initialized = false; + _trackingEnabled = true; + _consentGranted = false; + _currentUser = null; + _currentSessionId = null; + _sessionStartTime = null; + _isFlushingQueuedEvents = false; + _instance = null; + } + + Future _teardownProvidersAndObservers() async { + _stopFlushTimer(); + _detachLifecycleObserver(); + for (final provider in _providers) { try { await provider.dispose(); } catch (e) { - if (_config?.enableLogging ?? false) { - print('Error disposing ${provider.name}: $e'); - } + _log('Error disposing ${provider.name}: $e'); } } _providers.clear(); _middleware.clear(); - _initialized = false; - _trackingEnabled = true; - _instance = null; } - // Private methods void _startNewSession() { _currentSessionId = DateTime.now().millisecondsSinceEpoch.toString(); _sessionStartTime = DateTime.now(); @@ -289,12 +352,10 @@ class AnalyticsService { } AnalyticsEvent _enrichEvent(AnalyticsEvent event) { - // Check session timeout if (_isSessionExpired()) { _startNewSession(); } - // Add common properties final enrichedProperties = { ...(_config?.commonProperties ?? {}), ...event.properties, @@ -311,58 +372,141 @@ class AnalyticsService { var currentEvent = event; for (final middleware in _middleware) { - bool shouldContinue = false; - final result = await middleware.process( currentEvent, next: (processedEvent) { currentEvent = processedEvent; - shouldContinue = true; return true; }, ); - switch (result) { - case MiddlewareResult.continueProcessing: - if (!shouldContinue) { - // If next wasn't called but return was continue, keep original event - // or should we assume next() must be called? - // Logic in middleware usually is: return next(modifiedEvent) -> which returns Future - // Wait, the signature of process is Future process(AnalyticsEvent, next). - // Middleware usually does: return next(modifiedEvent) which is not possible here because next returns bool. - - // The middleware contract is: - // Future process(AnalyticsEvent event, {required bool Function(AnalyticsEvent) next}) - - // If middleware implementation is: - // next(modifiedEvent); return MiddlewareResult.continueProcessing; - // Then we are good with the `shouldContinue` flag or just relying on `currentEvent` update. - } - continue; - case MiddlewareResult.track: - return currentEvent; - case MiddlewareResult.drop: - if (_config?.enableLogging ?? false) { - print('Event dropped by ${middleware.runtimeType}'); - } - return null; + if (result == MiddlewareResult.drop) { + _log('Event dropped by ${middleware.runtimeType}'); + return null; + } + + if (result == MiddlewareResult.track) { + break; } } return currentEvent; } - Future _sendToProviders(AnalyticsEvent event) async { + Future _sendOrQueue(AnalyticsEvent event) async { + if (isOfflineQueueEnabled && !await isOnline()) { + await queueEvent(event); + return; + } + + final delivered = await _sendToProviders(event); + if (!delivered && isOfflineQueueEnabled) { + await queueEvent(event); + } + } + + Future _sendToProviders(AnalyticsEvent event) async { + if (_providers.isEmpty) { + _log('No analytics providers configured'); + return false; + } + + var delivered = false; + for (final provider in _providers) { if (!provider.isEnabled) continue; try { await provider.trackEvent(event); + delivered = true; } catch (e) { - if (_config?.enableLogging ?? false) { - print('Error sending event to ${provider.name}: $e'); - } + _log('Error sending event to ${provider.name}: $e'); } } + + return delivered; + } + + bool _canTrackEvents({required bool logBlocked}) { + if (!_trackingEnabled) { + if (logBlocked) { + _log('Event blocked: analytics disabled'); + } + return false; + } + + if ((_config?.requireConsent ?? false) && !_consentGranted) { + if (logBlocked) { + _log('Event blocked: consent not granted'); + } + return false; + } + + return true; + } + + void _startFlushTimer() { + _stopFlushTimer(); + + final intervalSeconds = _config?.flushInterval ?? 0; + if (intervalSeconds <= 0) return; + + _flushTimer = Timer.periodic(Duration(seconds: intervalSeconds), (_) { + unawaited(flush()); + }); + } + + void _stopFlushTimer() { + _flushTimer?.cancel(); + _flushTimer = null; + } + + void _attachLifecycleObserverIfNeeded() { + _detachLifecycleObserver(); + + if (!(_config?.autoTrackAppLifecycle ?? false)) { + return; + } + + final binding = WidgetsBinding.instance; + _lifecycleObserver = _AnalyticsLifecycleObserver(this); + binding.addObserver(_lifecycleObserver!); + } + + void _detachLifecycleObserver() { + final observer = _lifecycleObserver; + if (observer == null) return; + + final binding = WidgetsBinding.instance; + binding.removeObserver(observer); + _lifecycleObserver = null; + } + + void _log(String message) { + if (_config?.enableLogging ?? false) { + print(message); + } + } +} + +class _AnalyticsLifecycleObserver with WidgetsBindingObserver { + _AnalyticsLifecycleObserver(this._service); + + final AnalyticsService _service; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + unawaited(_service.track(AppEvents.appResumed())); + break; + case AppLifecycleState.paused: + unawaited(_service.track(AppEvents.appBackgrounded())); + break; + case AppLifecycleState.detached: + case AppLifecycleState.inactive: + case AppLifecycleState.hidden: + break; + } } } diff --git a/packages/integrations/analytics/lib/src/middleware/consent_middleware.dart b/packages/integrations/analytics/lib/src/middleware/consent_middleware.dart index 4cf05e5..0b6d655 100644 --- a/packages/integrations/analytics/lib/src/middleware/consent_middleware.dart +++ b/packages/integrations/analytics/lib/src/middleware/consent_middleware.dart @@ -1,13 +1,20 @@ import 'package:app_analytics/src/core/analytics_event.dart'; import 'package:app_analytics/src/core/analytics_middleware.dart'; +import 'package:app_analytics/src/core/analytics_service.dart'; import 'package:app_analytics/src/storage/consent_storage.dart'; +typedef ConsentResolver = Future Function(); + /// Middleware to check user consent before tracking class ConsentMiddleware implements AnalyticsMiddleware { final ConsentStorage _storage; + final ConsentResolver? _consentResolver; + final bool _preferServiceState; - ConsentMiddleware({ConsentStorage? storage}) - : _storage = storage ?? ConsentStorage(); + ConsentMiddleware({ConsentStorage? storage, ConsentResolver? consentResolver}) + : _storage = storage ?? ConsentStorage(), + _consentResolver = consentResolver, + _preferServiceState = storage == null && consentResolver == null; @override int get priority => 100; // Run first @@ -17,7 +24,7 @@ class ConsentMiddleware implements AnalyticsMiddleware { AnalyticsEvent event, { required bool Function(AnalyticsEvent) next, }) async { - final hasConsent = await _storage.hasConsent(); + final hasConsent = await _resolveConsent(); if (!hasConsent) { // Drop event if no consent @@ -26,4 +33,17 @@ class ConsentMiddleware implements AnalyticsMiddleware { return MiddlewareResult.continueProcessing; } + + Future _resolveConsent() async { + final consentResolver = _consentResolver; + if (consentResolver != null) { + return consentResolver(); + } + + if (_preferServiceState && AnalyticsService.instance.isInitialized) { + return AnalyticsService.instance.hasConsent; + } + + return _storage.hasConsent(); + } } diff --git a/packages/integrations/analytics/lib/src/middleware/enrichment_middleware.dart b/packages/integrations/analytics/lib/src/middleware/enrichment_middleware.dart index d5a3e7d..bd05e01 100644 --- a/packages/integrations/analytics/lib/src/middleware/enrichment_middleware.dart +++ b/packages/integrations/analytics/lib/src/middleware/enrichment_middleware.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:app_analytics/src/core/analytics_event.dart'; import 'package:app_analytics/src/core/analytics_middleware.dart'; import 'package:flutter/foundation.dart'; @@ -8,9 +6,10 @@ import 'package:package_info_plus/package_info_plus.dart'; /// Middleware to enrich events with common properties class EnrichmentMiddleware implements AnalyticsMiddleware { final Map _commonProperties = {}; + late final Future _initialization; EnrichmentMiddleware() { - _initializeCommonProperties(); + _initialization = _initializeCommonProperties(); } @override @@ -21,6 +20,8 @@ class EnrichmentMiddleware implements AnalyticsMiddleware { AnalyticsEvent event, { required bool Function(AnalyticsEvent) next, }) async { + await _initialization; + // Add common properties to event final enrichedEvent = event.withProperties(_commonProperties); @@ -33,24 +34,30 @@ class EnrichmentMiddleware implements AnalyticsMiddleware { // Platform if (kIsWeb) { _commonProperties['platform'] = 'web'; - } else if (Platform.isAndroid) { - _commonProperties['platform'] = 'android'; - } else if (Platform.isIOS) { - _commonProperties['platform'] = 'ios'; - } else if (Platform.isMacOS) { - _commonProperties['platform'] = 'macos'; - } else if (Platform.isWindows) { - _commonProperties['platform'] = 'windows'; - } else if (Platform.isLinux) { - _commonProperties['platform'] = 'linux'; + } else { + _commonProperties['platform'] = switch (defaultTargetPlatform) { + TargetPlatform.android => 'android', + TargetPlatform.iOS => 'ios', + TargetPlatform.macOS => 'macos', + TargetPlatform.windows => 'windows', + TargetPlatform.linux => 'linux', + TargetPlatform.fuchsia => 'fuchsia', + }; } - // App version - PackageInfo packageInfo = await PackageInfo.fromPlatform(); - _commonProperties['app_version'] = packageInfo.version; + // App version may be unavailable in tests or unsupported environments. + try { + final packageInfo = await PackageInfo.fromPlatform(); + _commonProperties['app_version'] = packageInfo.version; + _commonProperties['app_build_number'] = packageInfo.buildNumber; + } catch (_) { + // Ignore package info failures to keep middleware non-blocking. + } // Build mode - _commonProperties['build_mode'] = kDebugMode ? 'debug' : 'release'; + _commonProperties['build_mode'] = kDebugMode + ? 'debug' + : (kProfileMode ? 'profile' : 'release'); } /// Add or update a common property diff --git a/packages/integrations/analytics/lib/src/middleware/queue_middleware.dart b/packages/integrations/analytics/lib/src/middleware/queue_middleware.dart index 2724bbf..d4c913a 100644 --- a/packages/integrations/analytics/lib/src/middleware/queue_middleware.dart +++ b/packages/integrations/analytics/lib/src/middleware/queue_middleware.dart @@ -1,4 +1,3 @@ -import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:app_analytics/src/core/analytics_event.dart'; import 'package:app_analytics/src/core/analytics_middleware.dart'; import 'package:app_analytics/src/core/analytics_service.dart'; @@ -6,12 +5,14 @@ import 'package:app_analytics/src/storage/analytics_storage.dart'; /// Middleware to queue events when offline class QueueMiddleware implements AnalyticsMiddleware { - final AnalyticsStorage _storage; - final int _maxQueueSize; + final AnalyticsStorage? _storageOverride; QueueMiddleware({AnalyticsStorage? storage, int maxQueueSize = 100}) - : _storage = storage ?? AnalyticsStorage(), - _maxQueueSize = maxQueueSize; + : _storageOverride = + storage ?? + (maxQueueSize == 100 + ? null + : AnalyticsStorage(maxQueueSize: maxQueueSize)); @override int get priority => 60; @@ -21,80 +22,20 @@ class QueueMiddleware implements AnalyticsMiddleware { AnalyticsEvent event, { required bool Function(AnalyticsEvent) next, }) async { - // Check if offline - final isOnline = await _checkConnectivity(); - - if (!isOnline) { - // Queue event - await _queueEvent(event); - return MiddlewareResult.drop; // Don't send now + final service = AnalyticsService.instance; + if (!service.isOfflineQueueEnabled) { + return MiddlewareResult.continueProcessing; } - // If online, check if there are queued events - await _flushQueue(); - - return MiddlewareResult.continueProcessing; - } - - Future _checkConnectivity() async { - final connectivityResult = await Connectivity().checkConnectivity(); - return !connectivityResult.contains(ConnectivityResult.none); - } - - Future _queueEvent(AnalyticsEvent event) async { - final queue = await _storage.getQueue(); + final isOnline = await service.isOnline(); - // Check queue size - if (queue.length >= _maxQueueSize) { - // Remove oldest event - queue.removeAt(0); + if (!isOnline) { + await service.queueEvent(event, storage: _storageOverride); + return MiddlewareResult.drop; } - queue.add(event); - await _storage.saveQueue(queue); - } + await service.flushQueuedEvents(storage: _storageOverride); - Future _flushQueue() async { - final queue = await _storage.getQueue(); - - if (queue.isEmpty) return; - - // Send queued events - // We need to use the service to track them, but avoid infinite loops - // Ideally, the service should have a method to direct send to providers - // or we can just call track() and let other middleware handle it (might be redundant but safe) - // However, if we are here, we are online. - - // Better approach: Get the service and send to providers directly or re-process? - // Re-processing runs all middleware again (enrichment, pii, etc). - // If they were already processed before queuing (priority 60), iterating might duplicate work or be fine. - // Given priority is 60, Enrichment(80), PII(85) already ran. - // So we should probably send directly to providers if we can, or just re-track. - // Since AnalyticsService access to providers is private, we'll re-track. - // BUT we must ensure we don't queue again if it fails immediately? - // Actually, re-tracking is dangerous if we don't have a way to skip queue middleware. - - // Changing approach: Use AnalyticsService.instance.track() but we need to avoid the QueueMiddleware this time? - // Use a custom property or check if it's a replayed event? - // For simplicity given the scope, I will assume re-tracking is fine as long as we are online. - // If we go offline during flush, they will be re-queued. - - // Clear queue first to prevent loops if we crash/fail partially? - // Or remove one by one? - - // Let's clear queue then re-track. If re-track fails and queues again, so be it. - await _storage.clearQueue(); - - for (final event in queue) { - // Re-track event - // We need to access AnalyticsService. - // Since it's a singleton, we can use it. - // Note: This relies on AnalyticsService being initialized. - try { - await AnalyticsService.instance.track(event); - } catch (e) { - print('Error re-tracking queued event: $e'); - } - } + return MiddlewareResult.continueProcessing; } } diff --git a/packages/integrations/analytics/lib/src/storage/analytics_storage.dart b/packages/integrations/analytics/lib/src/storage/analytics_storage.dart index a6b2c74..aebce17 100644 --- a/packages/integrations/analytics/lib/src/storage/analytics_storage.dart +++ b/packages/integrations/analytics/lib/src/storage/analytics_storage.dart @@ -5,22 +5,48 @@ import 'package:shared_preferences/shared_preferences.dart'; /// Storage for analytics event queue class AnalyticsStorage { - static const _queueKey = 'analytics_event_queue'; - static const _maxQueueSize = 100; + static const _defaultQueueKey = 'analytics_event_queue'; + + AnalyticsStorage({ + int maxQueueSize = 100, + String queueKey = _defaultQueueKey, + SharedPreferences? sharedPreferences, + }) : _maxQueueSize = maxQueueSize, + _queueKey = queueKey, + _sharedPreferences = sharedPreferences; + + final int _maxQueueSize; + final String _queueKey; + final SharedPreferences? _sharedPreferences; /// Get queued events Future> getQueue() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _getPrefs(); final jsonList = prefs.getStringList(_queueKey) ?? []; - return jsonList - .map((json) => AnalyticsEvent.fromJson(jsonDecode(json))) - .toList(); + final events = []; + + for (final jsonValue in jsonList) { + try { + final decoded = jsonDecode(jsonValue); + if (decoded is Map) { + events.add(AnalyticsEvent.fromJson(decoded)); + } else if (decoded is Map) { + events.add( + AnalyticsEvent.fromJson(Map.from(decoded)), + ); + } + } catch (_) { + // Skip malformed entries to keep queue usable. + } + } + + return events; } /// Save event queue Future saveQueue(List events) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _getPrefs(); // Limit queue size final limitedEvents = events.length > _maxQueueSize @@ -36,7 +62,7 @@ class AnalyticsStorage { /// Clear event queue Future clearQueue() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _getPrefs(); await prefs.remove(_queueKey); } @@ -46,4 +72,13 @@ class AnalyticsStorage { queue.add(event); await saveQueue(queue); } + + Future _getPrefs() async { + final prefs = _sharedPreferences; + if (prefs != null) { + return prefs; + } + + return SharedPreferences.getInstance(); + } } diff --git a/packages/integrations/analytics/test/core/analytics_service_test.dart b/packages/integrations/analytics/test/core/analytics_service_test.dart index 6aa5240..401214b 100644 --- a/packages/integrations/analytics/test/core/analytics_service_test.dart +++ b/packages/integrations/analytics/test/core/analytics_service_test.dart @@ -11,18 +11,36 @@ void main() { late MockAnalyticsProvider mockProvider; late AnalyticsConfig config; - setUp(() { + setUp(() async { mockProvider = MockAnalyticsProvider(); when(mockProvider.name).thenReturn('MockProvider'); when(mockProvider.isEnabled).thenReturn(true); when(mockProvider.initialize()).thenAnswer((_) async => {}); + when(mockProvider.trackEvent(any)).thenAnswer((_) async => {}); + when(mockProvider.identifyUser(any)).thenAnswer((_) async => {}); + when( + mockProvider.trackScreen(any, properties: anyNamed('properties')), + ).thenAnswer((_) async => {}); + when(mockProvider.setUserProperties(any)).thenAnswer((_) async => {}); + when(mockProvider.reset()).thenAnswer((_) async => {}); + when(mockProvider.flush()).thenAnswer((_) async => {}); + when(mockProvider.dispose()).thenAnswer((_) async => {}); config = AnalyticsConfig( environment: AnalyticsEnvironment.development, providers: [mockProvider], enableLogging: true, requireConsent: false, + autoTrackAppLifecycle: false, + enableOfflineQueue: false, + flushInterval: 0, ); + + await AnalyticsService.instance.dispose(); + }); + + tearDown(() async { + await AnalyticsService.instance.dispose(); }); test('initializes providers correctly', () async { @@ -33,8 +51,6 @@ void main() { }); test('tracks events when initialized', () async { - when(mockProvider.trackEvent(any)).thenAnswer((_) async => {}); - await AnalyticsService.initialize(config); final event = AnalyticsEvent.custom( @@ -48,8 +64,6 @@ void main() { }); test('identifies user correctly', () async { - when(mockProvider.identifyUser(any)).thenAnswer((_) async => {}); - await AnalyticsService.initialize(config); await AnalyticsService.instance.identifyUser( @@ -77,5 +91,26 @@ void main() { verify(mockProvider.trackEvent(any)).called(1); }); + + test('blocks events when tracking is disabled', () async { + await AnalyticsService.initialize(config); + await AnalyticsService.instance.setTrackingEnabled(false); + + await AnalyticsService.instance.track( + AnalyticsEvent.custom(name: 'test'), + ); + + verifyNever(mockProvider.trackEvent(any)); + }); + + test('trackScreen respects autoTrackScreenViews config', () async { + await AnalyticsService.initialize( + config.copyWith(autoTrackScreenViews: false), + ); + + await AnalyticsService.instance.trackScreen('Home'); + + verifyNever(mockProvider.trackEvent(any)); + }); }); } From 0ecc02925be01bea435166d8169ae85fea2491ad Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 14:32:08 +0630 Subject: [PATCH 06/31] feat: Add Firebase services package with Remote Config and Performance, enabling dynamic control over analytics and Crashlytics collection. --- .../lib/core/bootstrap/app_bootstrap.dart | 37 +- .../core/bootstrap/crashlytics_bootstrap.dart | 6 +- .../firebase_services_bootstrap.dart | 55 +++ .../firebase/firebase_runtime_config.dart | 17 + .../analytics_preferences_provider.dart | 6 +- .../firebase_runtime_config_provider.dart | 6 + apps/mobile/lib/core/providers/providers.dart | 1 + .../view_models/export_import_view_model.dart | 334 +++++++++--------- apps/mobile/lib/main.dart | 3 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + apps/mobile/pubspec.yaml | 2 + .../analytics/lib/app_analytics.dart | 1 + .../core/analytics_collection_control.dart | 4 + .../lib/src/core/analytics_service.dart | 10 + .../firebase_analytics_provider.dart | 9 +- .../integrations/firebase_services/.gitignore | 3 + .../firebase_services/analysis_options.yaml | 1 + .../firebase_services/lib/app_firebase.dart | 5 + .../models/firebase_remote_config_status.dart | 13 + .../firebase_performance_service.dart | 141 ++++++++ .../firebase_remote_config_service.dart | 143 ++++++++ .../firebase_services/pubspec.yaml | 21 ++ pubspec.yaml | 1 + 23 files changed, 649 insertions(+), 172 deletions(-) create mode 100644 apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart create mode 100644 apps/mobile/lib/core/firebase/firebase_runtime_config.dart create mode 100644 apps/mobile/lib/core/providers/firebase_runtime_config_provider.dart create mode 100644 packages/integrations/analytics/lib/src/core/analytics_collection_control.dart create mode 100644 packages/integrations/firebase_services/.gitignore create mode 100644 packages/integrations/firebase_services/analysis_options.yaml create mode 100644 packages/integrations/firebase_services/lib/app_firebase.dart create mode 100644 packages/integrations/firebase_services/lib/src/models/firebase_remote_config_status.dart create mode 100644 packages/integrations/firebase_services/lib/src/services/firebase_performance_service.dart create mode 100644 packages/integrations/firebase_services/lib/src/services/firebase_remote_config_service.dart create mode 100644 packages/integrations/firebase_services/pubspec.yaml diff --git a/apps/mobile/lib/core/bootstrap/app_bootstrap.dart b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart index 522f045..13d2d22 100644 --- a/apps/mobile/lib/core/bootstrap/app_bootstrap.dart +++ b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart @@ -1,16 +1,22 @@ 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: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}); + const AppBootstrapData({ + required this.onboardingComplete, + required this.firebaseRuntimeConfig, + }); final bool onboardingComplete; + final FirebaseRuntimeConfig firebaseRuntimeConfig; } abstract final class AppBootstrap { @@ -18,15 +24,24 @@ abstract final class AppBootstrap { await _initializeLogger(); await PrefsStorageService.instance.init(); await FirebaseBootstrap.initialize(); - await CrashlyticsBootstrap.initialize(); - await _initializeAnalytics(); + final firebaseRuntimeConfig = await FirebaseServicesBootstrap.initialize(); + await CrashlyticsBootstrap.initialize( + collectionEnabled: firebaseRuntimeConfig.crashlyticsCollectionEnabled, + ); + await _initializeAnalytics( + analyticsCollectionEnabled: + firebaseRuntimeConfig.analyticsCollectionEnabled, + ); final onboardingComplete = await PrefsStorageService.instance.get('onboarding_complete') ?? false; Logger.info('All services are initialized!'); - return AppBootstrapData(onboardingComplete: onboardingComplete); + return AppBootstrapData( + onboardingComplete: onboardingComplete, + firebaseRuntimeConfig: firebaseRuntimeConfig, + ); } static Future _initializeLogger() async { @@ -41,7 +56,9 @@ abstract final class AppBootstrap { ); } - static Future _initializeAnalytics() async { + static Future _initializeAnalytics({ + required bool analyticsCollectionEnabled, + }) async { final enabled = PrefsStorageService.instance.readSync( AnalyticsPreferences.enabledPrefKey, @@ -53,8 +70,10 @@ abstract final class AppBootstrap { final consentStatus = AnalyticsConsentStatusX.fromCode(consentCode); final config = AnalyticsConfig( - environment: AnalyticsEnvironment.development, - enableLogging: true, + environment: kReleaseMode + ? AnalyticsEnvironment.production + : AnalyticsEnvironment.development, + enableLogging: !kReleaseMode, providers: [ ConsoleAnalyticsProvider(prettyPrint: true), FirebaseAnalyticsProvider(), @@ -70,7 +89,9 @@ abstract final class AppBootstrap { ); await AnalyticsService.initialize(config); - await AnalyticsService.instance.setTrackingEnabled(enabled); + 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 index d0a493e..0fa466b 100644 --- a/apps/mobile/lib/core/bootstrap/crashlytics_bootstrap.dart +++ b/apps/mobile/lib/core/bootstrap/crashlytics_bootstrap.dart @@ -4,14 +4,16 @@ import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; abstract final class CrashlyticsBootstrap { - static Future initialize() async { + static Future initialize({required bool collectionEnabled}) async { const enableInDebug = bool.fromEnvironment( 'ENABLE_CRASHLYTICS_IN_DEBUG', defaultValue: false, ); final crashlyticsSupported = !kIsWeb && Firebase.apps.isNotEmpty; final crashlyticsEnabled = - crashlyticsSupported && (!kDebugMode || enableInDebug); + crashlyticsSupported && + collectionEnabled && + (!kDebugMode || enableInDebug); if (!crashlyticsSupported) { Logger.info( 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..9013a69 --- /dev/null +++ b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart @@ -0,0 +1,55 @@ +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:flutter/foundation.dart'; + +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 Future initialize() async { + final remoteConfigService = FirebaseRemoteConfigService.instance; + + await remoteConfigService.initialize( + defaults: { + _analyticsCollectionEnabledKey: true, + _crashlyticsCollectionEnabledKey: true, + _performanceCollectionEnabledKey: true, + }, + minimumFetchInterval: kDebugMode + ? const Duration(minutes: 5) + : const Duration(hours: 12), + ); + + final runtimeConfig = FirebaseRuntimeConfig( + analyticsCollectionEnabled: remoteConfigService.getBool( + _analyticsCollectionEnabledKey, + fallback: true, + ), + crashlyticsCollectionEnabled: remoteConfigService.getBool( + _crashlyticsCollectionEnabledKey, + fallback: true, + ), + performanceCollectionEnabled: remoteConfigService.getBool( + _performanceCollectionEnabledKey, + fallback: true, + ), + ); + + await FirebasePerformanceService.instance.initialize( + enabled: runtimeConfig.performanceCollectionEnabled, + ); + + Logger.info( + 'Firebase services initialized (analytics: ${runtimeConfig.analyticsCollectionEnabled}, ' + 'crashlytics: ${runtimeConfig.crashlyticsCollectionEnabled}, ' + 'performance: ${runtimeConfig.performanceCollectionEnabled}).', + ); + + return runtimeConfig; + } +} 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..48c7260 --- /dev/null +++ b/apps/mobile/lib/core/firebase/firebase_runtime_config.dart @@ -0,0 +1,17 @@ +class FirebaseRuntimeConfig { + const FirebaseRuntimeConfig({ + required this.analyticsCollectionEnabled, + required this.crashlyticsCollectionEnabled, + required this.performanceCollectionEnabled, + }); + + static const defaults = FirebaseRuntimeConfig( + analyticsCollectionEnabled: true, + crashlyticsCollectionEnabled: true, + performanceCollectionEnabled: true, + ); + + final bool analyticsCollectionEnabled; + final bool crashlyticsCollectionEnabled; + final bool performanceCollectionEnabled; +} diff --git a/apps/mobile/lib/core/providers/analytics_preferences_provider.dart b/apps/mobile/lib/core/providers/analytics_preferences_provider.dart index a6ab003..3182bd8 100644 --- a/apps/mobile/lib/core/providers/analytics_preferences_provider.dart +++ b/apps/mobile/lib/core/providers/analytics_preferences_provider.dart @@ -1,5 +1,6 @@ import 'package:app_analytics/app_analytics.dart'; import 'package:collection_tracker/core/analytics/analytics_preferences.dart'; +import 'package:collection_tracker/core/providers/firebase_runtime_config_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:storage/storage.dart'; @@ -48,8 +49,11 @@ class AnalyticsPreferencesNotifier extends _$AnalyticsPreferencesNotifier { Future _applyToAnalyticsService() async { final analytics = AnalyticsService.instance; if (!analytics.isInitialized) return; + final firebaseRuntimeConfig = ref.read(firebaseRuntimeConfigProvider); - await analytics.setTrackingEnabled(state.enabled); + await analytics.setTrackingEnabled( + state.enabled && firebaseRuntimeConfig.analyticsCollectionEnabled, + ); await analytics.setConsentGranted( state.consentStatus == AnalyticsConsentStatus.granted, ); diff --git a/apps/mobile/lib/core/providers/firebase_runtime_config_provider.dart b/apps/mobile/lib/core/providers/firebase_runtime_config_provider.dart new file mode 100644 index 0000000..0d54d05 --- /dev/null +++ b/apps/mobile/lib/core/providers/firebase_runtime_config_provider.dart @@ -0,0 +1,6 @@ +import 'package:collection_tracker/core/firebase/firebase_runtime_config.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final firebaseRuntimeConfigProvider = Provider( + (ref) => FirebaseRuntimeConfig.defaults, +); diff --git a/apps/mobile/lib/core/providers/providers.dart b/apps/mobile/lib/core/providers/providers.dart index bc5f5b7..b6b14aa 100644 --- a/apps/mobile/lib/core/providers/providers.dart +++ b/apps/mobile/lib/core/providers/providers.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; export 'data_providers.dart'; export 'database_providers.dart'; export 'analytics_preferences_provider.dart'; +export 'firebase_runtime_config_provider.dart'; export 'theme_provider.dart'; export 'items_view_mode_provider.dart'; export 'collections_view_mode_provider.dart'; diff --git a/apps/mobile/lib/features/settings/presentation/view_models/export_import_view_model.dart b/apps/mobile/lib/features/settings/presentation/view_models/export_import_view_model.dart index 3470397..e280df3 100644 --- a/apps/mobile/lib/features/settings/presentation/view_models/export_import_view_model.dart +++ b/apps/mobile/lib/features/settings/presentation/view_models/export_import_view_model.dart @@ -1,3 +1,4 @@ +import 'package:app_firebase/app_firebase.dart'; import 'package:collection_tracker/core/providers/providers.dart'; import 'package:database/database.dart'; import 'package:domain/domain.dart'; @@ -10,77 +11,80 @@ part 'export_import_view_model.g.dart'; class ExportImportViewModel extends _$ExportImportViewModel { @override FutureOr build() { - // No initial state needed + // No initial state needed. } Future exportAllDataToJson() async { state = const AsyncValue.loading(); + final performanceService = FirebasePerformanceService.instance; + + final result = await AsyncValue.guard( + () => performanceService.traceAsync( + 'settings_export_all_data_json', + () async { + final collectionRepo = ref.read(collectionRepositoryProvider); + final itemRepo = ref.read(itemRepositoryProvider); + final exportService = ExportImportService(); + + final collectionsResult = await collectionRepo.getCollections(); + final collections = collectionsResult.fold( + (exception) => throw exception, + (data) => data, + ); + + final allItems = []; + for (final collection in collections) { + final itemsResult = await itemRepo.getItems( + collectionId: collection.id, + ); + itemsResult.fold( + (exception) => null, + (items) => allItems.addAll(items), + ); + } - final result = await AsyncValue.guard(() async { - final collectionRepo = ref.read(collectionRepositoryProvider); - final itemRepo = ref.read(itemRepositoryProvider); - final exportService = ExportImportService(); - - // Get all collections - final collectionsResult = await collectionRepo.getCollections(); - final collections = collectionsResult.fold( - (exception) => throw exception, - (data) => data, - ); - - // Get all items - final allItems = []; - for (final collection in collections) { - final itemsResult = await itemRepo.getItems( - collectionId: collection.id, - ); - itemsResult.fold( - (exception) => null, - (items) => allItems.addAll(items), - ); - } - - // Create export data - final exportData = { - 'version': '1.0.0', - 'exportDate': DateTime.now().toIso8601String(), - 'collections': collections - .map( - (c) => { - 'id': c.id, - 'name': c.name, - 'type': c.type.name, - 'description': c.description, - 'itemCount': c.itemCount, - 'createdAt': c.createdAt.toIso8601String(), - 'updatedAt': c.updatedAt.toIso8601String(), - }, - ) - .toList(), - 'items': allItems - .map( - (i) => { - 'id': i.id, - 'collectionId': i.collectionId, - 'title': i.title, - 'barcode': i.barcode, - 'description': i.description, - 'condition': i.condition?.name, - 'quantity': i.quantity, - 'location': i.location, - 'notes': i.notes, - 'isFavorite': i.isFavorite, - 'purchasePrice': i.purchasePrice, - 'purchaseDate': i.purchaseDate?.toIso8601String(), - 'createdAt': i.createdAt.toIso8601String(), - 'updatedAt': i.updatedAt.toIso8601String(), - }, - ) - .toList(), - }; - - return await exportService.exportToJson(exportData); - }); + final exportData = { + 'version': '1.0.0', + 'exportDate': DateTime.now().toIso8601String(), + 'collections': collections + .map( + (c) => { + 'id': c.id, + 'name': c.name, + 'type': c.type.name, + 'description': c.description, + 'itemCount': c.itemCount, + 'createdAt': c.createdAt.toIso8601String(), + 'updatedAt': c.updatedAt.toIso8601String(), + }, + ) + .toList(), + 'items': allItems + .map( + (i) => { + 'id': i.id, + 'collectionId': i.collectionId, + 'title': i.title, + 'barcode': i.barcode, + 'description': i.description, + 'condition': i.condition?.name, + 'quantity': i.quantity, + 'location': i.location, + 'notes': i.notes, + 'isFavorite': i.isFavorite, + 'purchasePrice': i.purchasePrice, + 'purchaseDate': i.purchaseDate?.toIso8601String(), + 'createdAt': i.createdAt.toIso8601String(), + 'updatedAt': i.updatedAt.toIso8601String(), + }, + ) + .toList(), + }; + + return exportService.exportToJson(exportData); + }, + ), + ); if (ref.mounted) { state = result; @@ -89,49 +93,54 @@ class ExportImportViewModel extends _$ExportImportViewModel { if (result.hasError) { throw result.error!; } + return result.value!; } Future exportItemsToCsv() async { state = const AsyncValue.loading(); - - final result = await AsyncValue.guard(() async { - final collectionRepo = ref.read(collectionRepositoryProvider); - final itemRepo = ref.read(itemRepositoryProvider); - final exportService = ExportImportService(); - - final collectionsResult = await collectionRepo.getCollections(); - final collections = collectionsResult.fold( - (exception) => throw exception, - (data) => data, - ); - - final allItems = >[]; - for (final collection in collections) { - final itemsResult = await itemRepo.getItems( - collectionId: collection.id, - ); - itemsResult.fold((exception) => null, (items) { - for (final item in items) { - allItems.add({ - 'Collection': collection.name, - 'Title': item.title, - 'Barcode': item.barcode ?? '', - 'Description': item.description ?? '', - 'Condition': item.condition?.name ?? '', - 'Quantity': item.quantity.toString(), - 'Location': item.location ?? '', - 'Notes': item.notes ?? '', - 'Favorite': item.isFavorite ? 'Yes' : 'No', - 'Purchase Price': item.purchasePrice?.toString() ?? '', - 'Created': item.createdAt.toString(), - }); - } - }); - } - - return await exportService.exportToCsv(allItems); - }); + final performanceService = FirebasePerformanceService.instance; + + final result = await AsyncValue.guard( + () => + performanceService.traceAsync('settings_export_items_csv', () async { + final collectionRepo = ref.read(collectionRepositoryProvider); + final itemRepo = ref.read(itemRepositoryProvider); + final exportService = ExportImportService(); + + final collectionsResult = await collectionRepo.getCollections(); + final collections = collectionsResult.fold( + (exception) => throw exception, + (data) => data, + ); + + final allItems = >[]; + for (final collection in collections) { + final itemsResult = await itemRepo.getItems( + collectionId: collection.id, + ); + itemsResult.fold((exception) => null, (items) { + for (final item in items) { + allItems.add({ + 'Collection': collection.name, + 'Title': item.title, + 'Barcode': item.barcode ?? '', + 'Description': item.description ?? '', + 'Condition': item.condition?.name ?? '', + 'Quantity': item.quantity.toString(), + 'Location': item.location ?? '', + 'Notes': item.notes ?? '', + 'Favorite': item.isFavorite ? 'Yes' : 'No', + 'Purchase Price': item.purchasePrice?.toString() ?? '', + 'Created': item.createdAt.toString(), + }); + } + }); + } + + return exportService.exportToCsv(allItems); + }), + ); if (ref.mounted) { state = result; @@ -140,68 +149,73 @@ class ExportImportViewModel extends _$ExportImportViewModel { if (result.hasError) { throw result.error!; } + return result.value!; } Future importFromJson() async { state = const AsyncValue.loading(); + final performanceService = FirebasePerformanceService.instance; + + final result = await AsyncValue.guard( + () => performanceService.traceAsync( + 'settings_import_data_json', + () async { + final collectionDao = ref.read(collectionDaoProvider); + final itemDao = ref.read(itemDaoProvider); + final exportService = ExportImportService(); + + final data = await exportService.importFromJson(); + + final collections = data['collections'] as List; + for (final collectionData in collections) { + final companion = CollectionsCompanion( + id: Value(collectionData['id'] as String), + name: Value(collectionData['name'] as String), + type: Value(collectionData['type'] as String), + description: Value(collectionData['description'] as String?), + itemCount: Value(collectionData['itemCount'] as int), + createdAt: Value( + DateTime.parse(collectionData['createdAt'] as String), + ), + updatedAt: Value( + DateTime.parse(collectionData['updatedAt'] as String), + ), + ); + await collectionDao.insertCollection(companion); + } - final result = await AsyncValue.guard(() async { - final collectionDao = ref.read(collectionDaoProvider); - final itemDao = ref.read(itemDaoProvider); - final exportService = ExportImportService(); - - final data = await exportService.importFromJson(); - - // Import collections - final collections = data['collections'] as List; - for (final collectionData in collections) { - final companion = CollectionsCompanion( - id: Value(collectionData['id'] as String), - name: Value(collectionData['name'] as String), - type: Value(collectionData['type'] as String), - description: Value(collectionData['description'] as String?), - itemCount: Value(collectionData['itemCount'] as int), - createdAt: Value( - DateTime.parse(collectionData['createdAt'] as String), - ), - updatedAt: Value( - DateTime.parse(collectionData['updatedAt'] as String), - ), - ); - await collectionDao.insertCollection(companion); - } - - // Import items - final items = data['items'] as List; - for (final itemData in items) { - final companion = ItemsCompanion( - id: Value(itemData['id'] as String), - collectionId: Value(itemData['collectionId'] as String), - title: Value(itemData['title'] as String), - barcode: Value(itemData['barcode'] as String?), - description: Value(itemData['description'] as String?), - condition: Value(itemData['condition'] as String?), - quantity: Value(itemData['quantity'] as int), - location: Value(itemData['location'] as String?), - notes: Value(itemData['notes'] as String?), - isFavorite: Value(itemData['isFavorite'] as bool), - purchasePrice: Value( - itemData['purchasePrice'] != null - ? (itemData['purchasePrice'] as num).toDouble() - : null, - ), - purchaseDate: Value( - itemData['purchaseDate'] != null - ? DateTime.parse(itemData['purchaseDate'] as String) - : null, - ), - createdAt: Value(DateTime.parse(itemData['createdAt'] as String)), - updatedAt: Value(DateTime.parse(itemData['updatedAt'] as String)), - ); - await itemDao.insertItem(companion); - } - }); + final items = data['items'] as List; + for (final itemData in items) { + final companion = ItemsCompanion( + id: Value(itemData['id'] as String), + collectionId: Value(itemData['collectionId'] as String), + title: Value(itemData['title'] as String), + barcode: Value(itemData['barcode'] as String?), + description: Value(itemData['description'] as String?), + condition: Value(itemData['condition'] as String?), + quantity: Value(itemData['quantity'] as int), + location: Value(itemData['location'] as String?), + notes: Value(itemData['notes'] as String?), + isFavorite: Value(itemData['isFavorite'] as bool), + purchasePrice: Value( + itemData['purchasePrice'] != null + ? (itemData['purchasePrice'] as num).toDouble() + : null, + ), + purchaseDate: Value( + itemData['purchaseDate'] != null + ? DateTime.parse(itemData['purchaseDate'] as String) + : null, + ), + createdAt: Value(DateTime.parse(itemData['createdAt'] as String)), + updatedAt: Value(DateTime.parse(itemData['updatedAt'] as String)), + ); + await itemDao.insertItem(companion); + } + }, + ), + ); if (ref.mounted) { state = result; diff --git a/apps/mobile/lib/main.dart b/apps/mobile/lib/main.dart index f0e50d5..c960300 100644 --- a/apps/mobile/lib/main.dart +++ b/apps/mobile/lib/main.dart @@ -17,6 +17,9 @@ void main() async { onboardingCompleteProvider.overrideWith( (ref) => bootstrapData.onboardingComplete, ), + firebaseRuntimeConfigProvider.overrideWith( + (ref) => bootstrapData.firebaseRuntimeConfig, + ), ], observers: [RiverpodLogger()], child: const CollectionTrackerApp(), diff --git a/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift index f3ca0da..b5c3fb9 100644 --- a/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift @@ -12,6 +12,7 @@ import file_selector_macos import firebase_analytics import firebase_core import firebase_crashlytics +import firebase_remote_config import flutter_secure_storage_darwin import mobile_scanner import package_info_plus @@ -29,6 +30,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCrashlyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCrashlyticsPlugin")) + FirebaseRemoteConfigPlugin.register(with: registry.registrar(forPlugin: "FirebaseRemoteConfigPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index f1b1b7e..8f8b836 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -59,6 +59,8 @@ dependencies: path: ../../packages/integrations/logger app_analytics: path: ../../packages/integrations/analytics + app_firebase: + path: ../../packages/integrations/firebase_services dev_dependencies: flutter_test: diff --git a/packages/integrations/analytics/lib/app_analytics.dart b/packages/integrations/analytics/lib/app_analytics.dart index 34e7b4c..7d4b438 100644 --- a/packages/integrations/analytics/lib/app_analytics.dart +++ b/packages/integrations/analytics/lib/app_analytics.dart @@ -5,6 +5,7 @@ export 'src/core/analytics_provider.dart'; export 'src/core/analytics_event.dart'; export 'src/core/analytics_user.dart'; export 'src/core/analytics_middleware.dart'; +export 'src/core/analytics_collection_control.dart'; // Providers export 'src/providers/base_analytics_provider.dart'; diff --git a/packages/integrations/analytics/lib/src/core/analytics_collection_control.dart b/packages/integrations/analytics/lib/src/core/analytics_collection_control.dart new file mode 100644 index 0000000..3dbad38 --- /dev/null +++ b/packages/integrations/analytics/lib/src/core/analytics_collection_control.dart @@ -0,0 +1,4 @@ +/// Optional capability for providers that can toggle SDK-side collection. +abstract interface class AnalyticsCollectionControl { + Future setCollectionEnabled(bool enabled); +} diff --git a/packages/integrations/analytics/lib/src/core/analytics_service.dart b/packages/integrations/analytics/lib/src/core/analytics_service.dart index 88701c7..a272fcb 100644 --- a/packages/integrations/analytics/lib/src/core/analytics_service.dart +++ b/packages/integrations/analytics/lib/src/core/analytics_service.dart @@ -6,6 +6,7 @@ import 'package:app_analytics/src/storage/analytics_storage.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/widgets.dart'; +import 'analytics_collection_control.dart'; import 'analytics_config.dart'; import 'analytics_event.dart'; import 'analytics_middleware.dart'; @@ -115,6 +116,15 @@ class AnalyticsService { if (provider is BaseAnalyticsProvider) { provider.enabled = enabled; } + if (provider is AnalyticsCollectionControl) { + final collectionControlProvider = + provider as AnalyticsCollectionControl; + try { + await collectionControlProvider.setCollectionEnabled(enabled); + } catch (e) { + _log('Unable to set collection state on ${provider.runtimeType}: $e'); + } + } } if (enabled) { diff --git a/packages/integrations/analytics/lib/src/providers/firebase_analytics_provider.dart b/packages/integrations/analytics/lib/src/providers/firebase_analytics_provider.dart index 05f987a..a2c48d6 100644 --- a/packages/integrations/analytics/lib/src/providers/firebase_analytics_provider.dart +++ b/packages/integrations/analytics/lib/src/providers/firebase_analytics_provider.dart @@ -1,10 +1,12 @@ import 'package:app_analytics/src/core/analytics_event.dart'; +import 'package:app_analytics/src/core/analytics_collection_control.dart'; import 'package:app_analytics/src/core/analytics_user.dart'; import 'package:app_analytics/src/providers/base_analytics_provider.dart'; import 'package:firebase_analytics/firebase_analytics.dart'; /// Firebase Analytics provider -class FirebaseAnalyticsProvider extends BaseAnalyticsProvider { +class FirebaseAnalyticsProvider extends BaseAnalyticsProvider + implements AnalyticsCollectionControl { late FirebaseAnalytics _analytics; late FirebaseAnalyticsObserver _observer; @@ -67,6 +69,11 @@ class FirebaseAnalyticsProvider extends BaseAnalyticsProvider { await _analytics.setUserId(id: null); } + @override + Future setCollectionEnabled(bool enabled) async { + await _analytics.setAnalyticsCollectionEnabled(enabled); + } + // Firebase has specific rules for event names and parameters String _sanitizeEventName(String name) { // Convert to lowercase, replace spaces with underscores diff --git a/packages/integrations/firebase_services/.gitignore b/packages/integrations/firebase_services/.gitignore new file mode 100644 index 0000000..877ebfb --- /dev/null +++ b/packages/integrations/firebase_services/.gitignore @@ -0,0 +1,3 @@ +.dart_tool/ +.flutter-plugins-dependencies +build/ diff --git a/packages/integrations/firebase_services/analysis_options.yaml b/packages/integrations/firebase_services/analysis_options.yaml new file mode 100644 index 0000000..e2badd7 --- /dev/null +++ b/packages/integrations/firebase_services/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../../analysis_options.yaml diff --git a/packages/integrations/firebase_services/lib/app_firebase.dart b/packages/integrations/firebase_services/lib/app_firebase.dart new file mode 100644 index 0000000..a328d03 --- /dev/null +++ b/packages/integrations/firebase_services/lib/app_firebase.dart @@ -0,0 +1,5 @@ +library; + +export 'src/models/firebase_remote_config_status.dart'; +export 'src/services/firebase_performance_service.dart'; +export 'src/services/firebase_remote_config_service.dart'; diff --git a/packages/integrations/firebase_services/lib/src/models/firebase_remote_config_status.dart b/packages/integrations/firebase_services/lib/src/models/firebase_remote_config_status.dart new file mode 100644 index 0000000..b696e10 --- /dev/null +++ b/packages/integrations/firebase_services/lib/src/models/firebase_remote_config_status.dart @@ -0,0 +1,13 @@ +import 'package:firebase_remote_config/firebase_remote_config.dart'; + +class FirebaseRemoteConfigStatus { + const FirebaseRemoteConfigStatus({ + required this.isInitialized, + required this.lastFetchTime, + required this.lastFetchStatus, + }); + + final bool isInitialized; + final DateTime? lastFetchTime; + final RemoteConfigFetchStatus? lastFetchStatus; +} diff --git a/packages/integrations/firebase_services/lib/src/services/firebase_performance_service.dart b/packages/integrations/firebase_services/lib/src/services/firebase_performance_service.dart new file mode 100644 index 0000000..defb384 --- /dev/null +++ b/packages/integrations/firebase_services/lib/src/services/firebase_performance_service.dart @@ -0,0 +1,141 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_performance/firebase_performance.dart'; +import 'package:flutter/foundation.dart'; + +class FirebasePerformanceService { + FirebasePerformanceService._({FirebasePerformance? performance}) + : _performance = performance ?? FirebasePerformance.instance; + + static FirebasePerformanceService? _instance; + + static FirebasePerformanceService get instance { + _instance ??= FirebasePerformanceService._(); + return _instance!; + } + + final FirebasePerformance _performance; + + bool _initialized = false; + bool _collectionEnabled = false; + + bool get isInitialized => _initialized; + bool get isCollectionEnabled => _collectionEnabled; + + Future initialize({required bool enabled}) async { + if (Firebase.apps.isEmpty) { + return; + } + + try { + await _performance.setPerformanceCollectionEnabled(enabled); + _collectionEnabled = enabled; + _initialized = true; + } catch (error) { + _collectionEnabled = false; + _initialized = false; + if (kDebugMode) { + debugPrint('FirebasePerformance initialize failed: $error'); + } + } + } + + Future setCollectionEnabled(bool enabled) async { + if (!_initialized || Firebase.apps.isEmpty) { + _collectionEnabled = enabled; + return; + } + + try { + await _performance.setPerformanceCollectionEnabled(enabled); + _collectionEnabled = enabled; + } catch (error) { + if (kDebugMode) { + debugPrint('FirebasePerformance setCollectionEnabled failed: $error'); + } + } + } + + Future startTrace( + String traceName, { + Map? attributes, + }) async { + if (!_isReady) { + return null; + } + + try { + final trace = _performance.newTrace(traceName); + if (attributes != null) { + for (final entry in attributes.entries) { + trace.putAttribute(entry.key, entry.value); + } + } + await trace.start(); + return trace; + } catch (error) { + if (kDebugMode) { + debugPrint('Unable to start Firebase trace $traceName: $error'); + } + return null; + } + } + + Future stopTrace(Trace? trace, {Map? metrics}) async { + if (trace == null) { + return; + } + + if (metrics != null) { + for (final entry in metrics.entries) { + trace.setMetric(entry.key, entry.value); + } + } + + try { + await trace.stop(); + } catch (error) { + if (kDebugMode) { + debugPrint('Unable to stop Firebase trace: $error'); + } + } + } + + Future traceAsync( + String traceName, + Future Function() operation, { + Map? attributes, + bool trackErrorMetric = true, + }) async { + final trace = await startTrace(traceName, attributes: attributes); + var hasError = false; + + try { + return await operation(); + } catch (error) { + hasError = true; + rethrow; + } finally { + if (trackErrorMetric && trace != null) { + trace.setMetric('failed', hasError ? 1 : 0); + } + await stopTrace(trace); + } + } + + HttpMetric? newHttpMetric(String url, HttpMethod method) { + if (!_isReady) { + return null; + } + + try { + return _performance.newHttpMetric(url, method); + } catch (error) { + if (kDebugMode) { + debugPrint('Unable to create Firebase HttpMetric for $url: $error'); + } + return null; + } + } + + bool get _isReady => _initialized && _collectionEnabled; +} diff --git a/packages/integrations/firebase_services/lib/src/services/firebase_remote_config_service.dart b/packages/integrations/firebase_services/lib/src/services/firebase_remote_config_service.dart new file mode 100644 index 0000000..f8827e0 --- /dev/null +++ b/packages/integrations/firebase_services/lib/src/services/firebase_remote_config_service.dart @@ -0,0 +1,143 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_remote_config/firebase_remote_config.dart'; +import 'package:flutter/foundation.dart'; + +import '../models/firebase_remote_config_status.dart'; + +class FirebaseRemoteConfigService { + FirebaseRemoteConfigService._({FirebaseRemoteConfig? remoteConfig}) + : _remoteConfig = remoteConfig ?? FirebaseRemoteConfig.instance; + + static FirebaseRemoteConfigService? _instance; + + static FirebaseRemoteConfigService get instance { + _instance ??= FirebaseRemoteConfigService._(); + return _instance!; + } + + final FirebaseRemoteConfig _remoteConfig; + + bool _initialized = false; + + bool get isInitialized => _initialized; + + FirebaseRemoteConfigStatus get status => FirebaseRemoteConfigStatus( + isInitialized: _initialized, + lastFetchTime: _initialized ? _remoteConfig.lastFetchTime : null, + lastFetchStatus: _initialized ? _remoteConfig.lastFetchStatus : null, + ); + + Future initialize({ + Map defaults = const {}, + Duration fetchTimeout = const Duration(seconds: 10), + Duration minimumFetchInterval = const Duration(hours: 12), + bool fetchAndActivate = true, + }) async { + if (_initialized || Firebase.apps.isEmpty) { + return; + } + + try { + await _remoteConfig.setConfigSettings( + RemoteConfigSettings( + fetchTimeout: fetchTimeout, + minimumFetchInterval: minimumFetchInterval, + ), + ); + + if (defaults.isNotEmpty) { + await _remoteConfig.setDefaults(defaults); + } + + if (fetchAndActivate) { + try { + await _remoteConfig.fetchAndActivate(); + } catch (error) { + if (kDebugMode) { + debugPrint( + 'FirebaseRemoteConfig fetchAndActivate failed during initialize: $error', + ); + } + } + } + + _initialized = true; + } catch (error) { + _initialized = false; + if (kDebugMode) { + debugPrint('FirebaseRemoteConfig initialize failed: $error'); + } + } + } + + Future refresh() async { + if (!_initialized) { + return false; + } + + try { + return await _remoteConfig.fetchAndActivate(); + } catch (error) { + if (kDebugMode) { + debugPrint('FirebaseRemoteConfig refresh failed: $error'); + } + return false; + } + } + + bool getBool(String key, {bool fallback = false}) { + if (!_initialized) { + return fallback; + } + + try { + return _remoteConfig.getBool(key); + } catch (_) { + return fallback; + } + } + + int getInt(String key, {int fallback = 0}) { + if (!_initialized) { + return fallback; + } + + try { + return _remoteConfig.getInt(key); + } catch (_) { + return fallback; + } + } + + double getDouble(String key, {double fallback = 0}) { + if (!_initialized) { + return fallback; + } + + try { + return _remoteConfig.getDouble(key); + } catch (_) { + return fallback; + } + } + + String getString(String key, {String fallback = ''}) { + if (!_initialized) { + return fallback; + } + + try { + return _remoteConfig.getString(key); + } catch (_) { + return fallback; + } + } + + Map getAll() { + if (!_initialized) { + return {}; + } + + return _remoteConfig.getAll(); + } +} diff --git a/packages/integrations/firebase_services/pubspec.yaml b/packages/integrations/firebase_services/pubspec.yaml new file mode 100644 index 0000000..dce4783 --- /dev/null +++ b/packages/integrations/firebase_services/pubspec.yaml @@ -0,0 +1,21 @@ +name: app_firebase +description: "Firebase integration services for Remote Config and Performance." +version: 1.0.0 +publish_to: none +resolution: workspace + +environment: + sdk: ^3.11.0 + flutter: ">=1.17.0" + +dependencies: + firebase_core: ^4.4.0 + firebase_performance: ^0.11.0+9 + firebase_remote_config: ^6.1.1 + flutter: + sdk: flutter + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 04f1585..22d9efe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,3 +27,4 @@ workspace: - packages/integrations/metadata_api - packages/integrations/payment - packages/integrations/storage + - packages/integrations/firebase_services From f8e4997ebd46faee5f568846ddde355d7916eb52 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 14:50:41 +0630 Subject: [PATCH 07/31] feat: Implement Firebase Runtime Config UI with refresh functionality and enhanced state management. --- .../lib/core/bootstrap/app_bootstrap.dart | 2 +- .../core/bootstrap/crashlytics_bootstrap.dart | 63 +++- .../firebase_services_bootstrap.dart | 82 ++++- .../analytics_preferences_provider.dart | 14 + .../firebase_runtime_config_provider.dart | 88 ++++- .../presentation/views/settings_screen.dart | 339 +++++++++++++++++- apps/mobile/lib/l10n/arb/app_en.arb | 54 +++ apps/mobile/lib/l10n/arb/app_es.arb | 54 +++ apps/mobile/lib/l10n/arb/app_id.arb | 54 +++ apps/mobile/lib/l10n/arb/app_ja.arb | 54 +++ apps/mobile/lib/l10n/arb/app_ko.arb | 54 +++ apps/mobile/lib/l10n/arb/app_my.arb | 54 +++ apps/mobile/lib/l10n/arb/app_zh.arb | 54 +++ .../lib/l10n/gen/app_localizations.dart | 126 +++++++ .../lib/l10n/gen/app_localizations_en.dart | 67 ++++ .../lib/l10n/gen/app_localizations_es.dart | 67 ++++ .../lib/l10n/gen/app_localizations_id.dart | 67 ++++ .../lib/l10n/gen/app_localizations_ja.dart | 67 ++++ .../lib/l10n/gen/app_localizations_ko.dart | 67 ++++ .../lib/l10n/gen/app_localizations_my.dart | 67 ++++ .../lib/l10n/gen/app_localizations_zh.dart | 67 ++++ apps/mobile/lib/main.dart | 2 +- 22 files changed, 1527 insertions(+), 36 deletions(-) diff --git a/apps/mobile/lib/core/bootstrap/app_bootstrap.dart b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart index 13d2d22..68954de 100644 --- a/apps/mobile/lib/core/bootstrap/app_bootstrap.dart +++ b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart @@ -75,7 +75,7 @@ abstract final class AppBootstrap { : AnalyticsEnvironment.development, enableLogging: !kReleaseMode, providers: [ - ConsoleAnalyticsProvider(prettyPrint: true), + if (!kReleaseMode) ConsoleAnalyticsProvider(prettyPrint: true), FirebaseAnalyticsProvider(), ], middleware: [ diff --git a/apps/mobile/lib/core/bootstrap/crashlytics_bootstrap.dart b/apps/mobile/lib/core/bootstrap/crashlytics_bootstrap.dart index 0fa466b..9d9c3c1 100644 --- a/apps/mobile/lib/core/bootstrap/crashlytics_bootstrap.dart +++ b/apps/mobile/lib/core/bootstrap/crashlytics_bootstrap.dart @@ -4,32 +4,35 @@ import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; abstract final class CrashlyticsBootstrap { - static Future initialize({required bool collectionEnabled}) async { - const enableInDebug = bool.fromEnvironment( - 'ENABLE_CRASHLYTICS_IN_DEBUG', - defaultValue: false, - ); - final crashlyticsSupported = !kIsWeb && Firebase.apps.isNotEmpty; - final crashlyticsEnabled = - crashlyticsSupported && - collectionEnabled && - (!kDebugMode || enableInDebug); + static bool _handlersRegistered = false; + static bool _collectionEnabled = false; + + static const _enableInDebug = bool.fromEnvironment( + 'ENABLE_CRASHLYTICS_IN_DEBUG', + defaultValue: false, + ); - if (!crashlyticsSupported) { + static Future initialize({required bool collectionEnabled}) async { + if (!_isSupported) { Logger.info( 'Crashlytics skipped: unsupported platform or Firebase unavailable.', ); return; } - await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled( - crashlyticsEnabled, - ); + 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 (crashlyticsEnabled) { + if (_collectionEnabled) { FirebaseCrashlytics.instance.recordFlutterFatalError(details); } else { Logger.error( @@ -42,7 +45,7 @@ abstract final class CrashlyticsBootstrap { final previousPlatformErrorHandler = PlatformDispatcher.instance.onError; PlatformDispatcher.instance.onError = (error, stackTrace) { - if (crashlyticsEnabled) { + if (_collectionEnabled) { FirebaseCrashlytics.instance.recordError( error, stackTrace, @@ -60,8 +63,34 @@ abstract final class CrashlyticsBootstrap { 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 initialized (enabled: $crashlyticsEnabled, debug: $kDebugMode, debugOverride: $enableInDebug).', + '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_services_bootstrap.dart b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart index 9013a69..80371b4 100644 --- a/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart +++ b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart @@ -3,6 +3,20 @@ import 'package:app_logger/app_logger.dart'; import 'package:collection_tracker/core/firebase/firebase_runtime_config.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'; @@ -25,7 +39,61 @@ abstract final class FirebaseServicesBootstrap { : const Duration(hours: 12), ); - final runtimeConfig = FirebaseRuntimeConfig( + final runtimeConfig = _readRuntimeConfig(remoteConfigService); + + await FirebasePerformanceService.instance.initialize( + enabled: runtimeConfig.performanceCollectionEnabled, + ); + + Logger.info( + 'Firebase services initialized (analytics: ${runtimeConfig.analyticsCollectionEnabled}, ' + 'crashlytics: ${runtimeConfig.crashlyticsCollectionEnabled}, ' + 'performance: ${runtimeConfig.performanceCollectionEnabled}).', + ); + + return runtimeConfig; + } + + static Future + refreshRuntimeConfig() async { + final remoteConfigService = FirebaseRemoteConfigService.instance; + if (!remoteConfigService.isInitialized) { + final runtimeConfig = await initialize(); + return FirebaseRuntimeConfigRefreshResult( + runtimeConfig: runtimeConfig, + status: remoteConfigService.status, + didActivateChanges: false, + ); + } + + final didActivateChanges = await remoteConfigService.refresh(); + final runtimeConfig = _readRuntimeConfig(remoteConfigService); + + 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}).', + ); + + return FirebaseRuntimeConfigRefreshResult( + runtimeConfig: runtimeConfig, + status: remoteConfigService.status, + didActivateChanges: didActivateChanges, + ); + } + + static FirebaseRuntimeConfig _readRuntimeConfig( + FirebaseRemoteConfigService remoteConfigService, + ) { + return FirebaseRuntimeConfig( analyticsCollectionEnabled: remoteConfigService.getBool( _analyticsCollectionEnabledKey, fallback: true, @@ -39,17 +107,5 @@ abstract final class FirebaseServicesBootstrap { fallback: true, ), ); - - await FirebasePerformanceService.instance.initialize( - enabled: runtimeConfig.performanceCollectionEnabled, - ); - - Logger.info( - 'Firebase services initialized (analytics: ${runtimeConfig.analyticsCollectionEnabled}, ' - 'crashlytics: ${runtimeConfig.crashlyticsCollectionEnabled}, ' - 'performance: ${runtimeConfig.performanceCollectionEnabled}).', - ); - - return runtimeConfig; } } diff --git a/apps/mobile/lib/core/providers/analytics_preferences_provider.dart b/apps/mobile/lib/core/providers/analytics_preferences_provider.dart index 3182bd8..1200b46 100644 --- a/apps/mobile/lib/core/providers/analytics_preferences_provider.dart +++ b/apps/mobile/lib/core/providers/analytics_preferences_provider.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:app_analytics/app_analytics.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/providers/firebase_runtime_config_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:storage/storage.dart'; @@ -12,6 +15,13 @@ class AnalyticsPreferencesNotifier extends _$AnalyticsPreferencesNotifier { @override AnalyticsPreferences build() { + ref.listen(firebaseRuntimeConfigProvider, ( + previous, + next, + ) { + unawaited(_applyToAnalyticsService()); + }); + _prefs = PrefsStorageService.instance; final enabled = _prefs.readSync(AnalyticsPreferences.enabledPrefKey) ?? true; @@ -46,6 +56,10 @@ class AnalyticsPreferencesNotifier extends _$AnalyticsPreferencesNotifier { await _applyToAnalyticsService(); } + Future syncToAnalyticsService() async { + await _applyToAnalyticsService(); + } + Future _applyToAnalyticsService() async { final analytics = AnalyticsService.instance; if (!analytics.isInitialized) return; diff --git a/apps/mobile/lib/core/providers/firebase_runtime_config_provider.dart b/apps/mobile/lib/core/providers/firebase_runtime_config_provider.dart index 0d54d05..04293cf 100644 --- a/apps/mobile/lib/core/providers/firebase_runtime_config_provider.dart +++ b/apps/mobile/lib/core/providers/firebase_runtime_config_provider.dart @@ -1,6 +1,92 @@ +import 'package:app_firebase/app_firebase.dart'; +import 'package:collection_tracker/core/bootstrap/firebase_services_bootstrap.dart'; import 'package:collection_tracker/core/firebase/firebase_runtime_config.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -final firebaseRuntimeConfigProvider = Provider( +class FirebaseRuntimeConfigState { + const FirebaseRuntimeConfigState({ + required this.config, + required this.remoteConfigStatus, + required this.isRefreshing, + }); + + final FirebaseRuntimeConfig config; + final FirebaseRemoteConfigStatus remoteConfigStatus; + final bool isRefreshing; + + FirebaseRuntimeConfigState copyWith({ + FirebaseRuntimeConfig? config, + FirebaseRemoteConfigStatus? remoteConfigStatus, + bool? isRefreshing, + }) { + return FirebaseRuntimeConfigState( + config: config ?? this.config, + remoteConfigStatus: remoteConfigStatus ?? this.remoteConfigStatus, + isRefreshing: isRefreshing ?? this.isRefreshing, + ); + } +} + +final initialFirebaseRuntimeConfigProvider = Provider( (ref) => FirebaseRuntimeConfig.defaults, ); + +final firebaseRuntimeConfigControllerProvider = + NotifierProvider< + FirebaseRuntimeConfigController, + FirebaseRuntimeConfigState + >(FirebaseRuntimeConfigController.new); + +class FirebaseRuntimeConfigController + extends Notifier { + @override + FirebaseRuntimeConfigState build() { + final initialConfig = ref.watch(initialFirebaseRuntimeConfigProvider); + return FirebaseRuntimeConfigState( + config: initialConfig, + remoteConfigStatus: FirebaseRemoteConfigService.instance.status, + isRefreshing: false, + ); + } + + Future refreshFromRemoteConfig() async { + if (state.isRefreshing) { + return FirebaseRuntimeConfigRefreshResult( + runtimeConfig: state.config, + status: state.remoteConfigStatus, + didActivateChanges: false, + ); + } + + state = state.copyWith(isRefreshing: true); + + try { + final result = await FirebaseServicesBootstrap.refreshRuntimeConfig(); + state = state.copyWith( + config: result.runtimeConfig, + remoteConfigStatus: result.status, + isRefreshing: false, + ); + return result; + } catch (_) { + state = state.copyWith( + remoteConfigStatus: FirebaseRemoteConfigService.instance.status, + isRefreshing: false, + ); + rethrow; + } + } +} + +final firebaseRuntimeConfigProvider = Provider( + (ref) => ref.watch(firebaseRuntimeConfigControllerProvider).config, +); + +final firebaseRemoteConfigStatusProvider = Provider( + (ref) => + ref.watch(firebaseRuntimeConfigControllerProvider).remoteConfigStatus, +); + +final firebaseRuntimeConfigRefreshInProgressProvider = Provider( + (ref) => ref.watch(firebaseRuntimeConfigControllerProvider).isRefreshing, +); diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart index 2f2b034..9c222a0 100644 --- a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart +++ b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart @@ -1,7 +1,9 @@ import 'package:app_logger/app_logger.dart'; import 'package:collection_tracker/core/analytics/analytics_consent_dialog.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/providers/providers.dart'; +import 'package:intl/intl.dart'; import 'package:collection_tracker/l10n/l10n.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; @@ -22,10 +24,15 @@ class SettingsScreen extends ConsumerWidget { final themeSettings = ref.watch(themeSettingsProvider); final currentLanguage = ref.watch(localeSettingsProvider); final analyticsPreferences = ref.watch(analyticsPreferencesProvider); + final firebaseRuntimeConfig = ref.watch(firebaseRuntimeConfigProvider); final themeSummary = '${_themeModeLabel(context, themeSettings.mode)} - ${themeSettings.variant.label}'; final languageSummary = _languageLabel(context, currentLanguage); final analyticsSummary = _analyticsSummary(context, analyticsPreferences); + final firebaseRuntimeSummary = _firebaseRuntimeSummary( + context, + firebaseRuntimeConfig, + ); return Scaffold( appBar: AppBar(title: Text(l10n.settingsTitle)), @@ -62,9 +69,14 @@ class SettingsScreen extends ConsumerWidget { ], ), ), - const SizedBox(height: AppSpacing.lg), + const SizedBox(height: AppSpacing.md), AppReveal( delay: AppMotion.stagger, + child: _FirebaseRuntimeHealthCard(config: firebaseRuntimeConfig), + ), + const SizedBox(height: AppSpacing.lg), + AppReveal( + delay: AppMotion.stagger * 2, child: _SettingsSection( title: l10n.settingsSectionData, children: [ @@ -103,7 +115,7 @@ class SettingsScreen extends ConsumerWidget { ), const SizedBox(height: AppSpacing.lg), AppReveal( - delay: AppMotion.stagger * 2, + delay: AppMotion.stagger * 3, child: _SettingsSection( title: l10n.settingsSectionAbout, children: [ @@ -128,10 +140,16 @@ class SettingsScreen extends ConsumerWidget { if (kDebugMode) ...[ const SizedBox(height: AppSpacing.lg), AppReveal( - delay: AppMotion.stagger * 3, + delay: AppMotion.stagger * 4, child: _SettingsSection( title: l10n.settingsSectionDeveloper, children: [ + _SettingsTile( + icon: Icons.settings_remote_outlined, + title: l10n.settingsFirebaseRuntimeConfigTitle, + subtitle: firebaseRuntimeSummary, + onTap: () => _showFirebaseRuntimeConfigSheet(context, ref), + ), _SettingsTile( icon: Icons.bug_report_outlined, title: l10n.settingsCrashlyticsTestTitle, @@ -492,6 +510,175 @@ class SettingsScreen extends ConsumerWidget { ); } + Future _showFirebaseRuntimeConfigSheet( + BuildContext context, + WidgetRef ref, + ) async { + await showAppSheet( + context: context, + builder: (context) { + return Consumer( + builder: (context, ref, _) { + final l10n = context.l10n; + final runtimeConfig = ref.watch(firebaseRuntimeConfigProvider); + final remoteConfigStatus = ref.watch( + firebaseRemoteConfigStatusProvider, + ); + final isRefreshing = ref.watch( + firebaseRuntimeConfigRefreshInProgressProvider, + ); + final localeTag = Localizations.localeOf(context).toLanguageTag(); + final lastFetchTimeText = remoteConfigStatus.lastFetchTime == null + ? l10n.settingsFirebaseRuntimeConfigFetchStatusNoFetch + : DateFormat.yMd(localeTag).add_Hms().format( + remoteConfigStatus.lastFetchTime!.toLocal(), + ); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.settingsFirebaseRuntimeConfigSheetTitle, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + l10n.settingsFirebaseRuntimeConfigDescription, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.md), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.insights_outlined), + title: Text(l10n.settingsFirebaseRuntimeConfigAnalyticsLabel), + subtitle: const Text('app_analytics_collection_enabled'), + trailing: Text( + _enabledDisabledLabel( + context, + runtimeConfig.analyticsCollectionEnabled, + ), + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.bug_report_outlined), + title: Text( + l10n.settingsFirebaseRuntimeConfigCrashlyticsLabel, + ), + subtitle: const Text('app_crashlytics_collection_enabled'), + trailing: Text( + _enabledDisabledLabel( + context, + runtimeConfig.crashlyticsCollectionEnabled, + ), + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.speed_outlined), + title: Text( + l10n.settingsFirebaseRuntimeConfigPerformanceLabel, + ), + subtitle: const Text('app_performance_collection_enabled'), + trailing: Text( + _enabledDisabledLabel( + context, + runtimeConfig.performanceCollectionEnabled, + ), + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.sync_alt_outlined), + title: Text( + l10n.settingsFirebaseRuntimeConfigFetchStatusTitle, + ), + subtitle: Text( + _remoteConfigFetchStatusLabel( + context, + remoteConfigStatus.lastFetchStatus, + ), + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.schedule_outlined), + title: Text(l10n.settingsFirebaseRuntimeConfigLastFetchTitle), + subtitle: Text(lastFetchTimeText), + ), + const SizedBox(height: AppSpacing.md), + AppButton( + label: isRefreshing + ? l10n.settingsFirebaseRuntimeConfigRefreshingAction + : l10n.settingsFirebaseRuntimeConfigRefreshAction, + onPressed: isRefreshing + ? null + : () => _refreshFirebaseRuntimeConfig(context, ref), + ), + ], + ); + }, + ); + }, + ); + } + + Future _refreshFirebaseRuntimeConfig( + BuildContext context, + WidgetRef ref, + ) async { + final isRefreshing = ref.read( + firebaseRuntimeConfigRefreshInProgressProvider, + ); + if (isRefreshing) { + return; + } + + final l10n = context.l10n; + + try { + final result = await ref + .read(firebaseRuntimeConfigControllerProvider.notifier) + .refreshFromRemoteConfig(); + await ref + .read(analyticsPreferencesProvider.notifier) + .syncToAnalyticsService(); + + if (!context.mounted) { + return; + } + + final message = result.didActivateChanges + ? l10n.settingsFirebaseRuntimeConfigRefreshSuccess + : l10n.settingsFirebaseRuntimeConfigRefreshNoChanges; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } catch (error, stackTrace) { + Logger.error( + 'Failed to refresh Firebase runtime config.', + error, + stackTrace, + ); + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + l10n.settingsFirebaseRuntimeConfigRefreshFailed('$error'), + ), + backgroundColor: Colors.red, + ), + ); + } + } + Future _handleCrashlyticsTest(BuildContext context) async { final l10n = context.l10n; final shouldCrash = await showAppDialog( @@ -631,6 +818,40 @@ class SettingsScreen extends ConsumerWidget { AnalyticsConsentStatus.unknown => l10n.settingsAnalyticsSummaryPending, }; } + + String _firebaseRuntimeSummary( + BuildContext context, + FirebaseRuntimeConfig config, + ) { + final enabledCount = [ + config.analyticsCollectionEnabled, + config.crashlyticsCollectionEnabled, + config.performanceCollectionEnabled, + ].where((value) => value).length; + return context.l10n.settingsFirebaseRuntimeConfigSummary(enabledCount); + } + + String _enabledDisabledLabel(BuildContext context, bool enabled) { + final l10n = context.l10n; + return enabled + ? l10n.settingsFirebaseRuntimeConfigValueEnabled + : l10n.settingsFirebaseRuntimeConfigValueDisabled; + } + + String _remoteConfigFetchStatusLabel( + BuildContext context, + dynamic lastFetchStatus, + ) { + final l10n = context.l10n; + final statusName = lastFetchStatus?.name; + + return switch (statusName) { + 'success' => l10n.settingsFirebaseRuntimeConfigFetchStatusSuccess, + 'failure' => l10n.settingsFirebaseRuntimeConfigFetchStatusFailure, + 'throttle' => l10n.settingsFirebaseRuntimeConfigFetchStatusThrottled, + _ => l10n.settingsFirebaseRuntimeConfigFetchStatusNoFetch, + }; + } } class _SettingsSection extends StatelessWidget { @@ -681,6 +902,118 @@ class _SettingsSection extends StatelessWidget { } } +class _FirebaseRuntimeHealthCard extends StatelessWidget { + const _FirebaseRuntimeHealthCard({required this.config}); + + final FirebaseRuntimeConfig config; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final enabledCount = [ + config.analyticsCollectionEnabled, + config.crashlyticsCollectionEnabled, + config.performanceCollectionEnabled, + ].where((value) => value).length; + + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.settingsFirebaseRuntimeConfigTitle, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: AppSpacing.xs), + Text( + l10n.settingsFirebaseRuntimeConfigSummary(enabledCount), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.sm), + _FirebaseFlagStatusRow( + icon: Icons.insights_outlined, + label: l10n.settingsFirebaseRuntimeConfigAnalyticsLabel, + enabled: config.analyticsCollectionEnabled, + ), + const SizedBox(height: AppSpacing.xs), + _FirebaseFlagStatusRow( + icon: Icons.bug_report_outlined, + label: l10n.settingsFirebaseRuntimeConfigCrashlyticsLabel, + enabled: config.crashlyticsCollectionEnabled, + ), + const SizedBox(height: AppSpacing.xs), + _FirebaseFlagStatusRow( + icon: Icons.speed_outlined, + label: l10n.settingsFirebaseRuntimeConfigPerformanceLabel, + enabled: config.performanceCollectionEnabled, + ), + ], + ), + ); + } +} + +class _FirebaseFlagStatusRow extends StatelessWidget { + const _FirebaseFlagStatusRow({ + required this.icon, + required this.label, + required this.enabled, + }); + + final IconData icon; + final String label; + final bool enabled; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final labelText = enabled + ? l10n.settingsFirebaseRuntimeConfigValueEnabled + : l10n.settingsFirebaseRuntimeConfigValueDisabled; + final badgeColor = enabled + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outline; + final badgeForeground = enabled + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurfaceVariant; + + return Row( + children: [ + Icon( + icon, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text(label, style: Theme.of(context).textTheme.bodyMedium), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: badgeColor, + borderRadius: BorderRadius.circular(AppRadii.pill), + ), + child: Text( + labelText, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: badgeForeground, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ); + } +} + class _SettingsTile extends StatelessWidget { final IconData icon; final String title; diff --git a/apps/mobile/lib/l10n/arb/app_en.arb b/apps/mobile/lib/l10n/arb/app_en.arb index 262f2f9..96b0378 100644 --- a/apps/mobile/lib/l10n/arb/app_en.arb +++ b/apps/mobile/lib/l10n/arb/app_en.arb @@ -136,6 +136,60 @@ } } }, + "settingsFirebaseRuntimeConfigTitle": "Firebase Runtime Config", + "@settingsFirebaseRuntimeConfigTitle": {}, + "settingsFirebaseRuntimeConfigSubtitle": "Inspect and refresh runtime feature flags", + "@settingsFirebaseRuntimeConfigSubtitle": {}, + "settingsFirebaseRuntimeConfigSheetTitle": "Firebase Runtime Config", + "@settingsFirebaseRuntimeConfigSheetTitle": {}, + "settingsFirebaseRuntimeConfigDescription": "Values are fetched from Firebase Remote Config and applied at runtime.", + "@settingsFirebaseRuntimeConfigDescription": {}, + "settingsFirebaseRuntimeConfigSummary": "{enabledCount} of 3 signals enabled", + "@settingsFirebaseRuntimeConfigSummary": { + "placeholders": { + "enabledCount": { + "type": "int" + } + } + }, + "settingsFirebaseRuntimeConfigAnalyticsLabel": "Analytics collection", + "@settingsFirebaseRuntimeConfigAnalyticsLabel": {}, + "settingsFirebaseRuntimeConfigCrashlyticsLabel": "Crashlytics collection", + "@settingsFirebaseRuntimeConfigCrashlyticsLabel": {}, + "settingsFirebaseRuntimeConfigPerformanceLabel": "Performance collection", + "@settingsFirebaseRuntimeConfigPerformanceLabel": {}, + "settingsFirebaseRuntimeConfigFetchStatusTitle": "Last fetch status", + "@settingsFirebaseRuntimeConfigFetchStatusTitle": {}, + "settingsFirebaseRuntimeConfigLastFetchTitle": "Last fetch time", + "@settingsFirebaseRuntimeConfigLastFetchTitle": {}, + "settingsFirebaseRuntimeConfigValueEnabled": "Enabled", + "@settingsFirebaseRuntimeConfigValueEnabled": {}, + "settingsFirebaseRuntimeConfigValueDisabled": "Disabled", + "@settingsFirebaseRuntimeConfigValueDisabled": {}, + "settingsFirebaseRuntimeConfigFetchStatusSuccess": "Success", + "@settingsFirebaseRuntimeConfigFetchStatusSuccess": {}, + "settingsFirebaseRuntimeConfigFetchStatusFailure": "Failure", + "@settingsFirebaseRuntimeConfigFetchStatusFailure": {}, + "settingsFirebaseRuntimeConfigFetchStatusThrottled": "Throttled", + "@settingsFirebaseRuntimeConfigFetchStatusThrottled": {}, + "settingsFirebaseRuntimeConfigFetchStatusNoFetch": "No fetch yet", + "@settingsFirebaseRuntimeConfigFetchStatusNoFetch": {}, + "settingsFirebaseRuntimeConfigRefreshAction": "Refresh config", + "@settingsFirebaseRuntimeConfigRefreshAction": {}, + "settingsFirebaseRuntimeConfigRefreshingAction": "Refreshing...", + "@settingsFirebaseRuntimeConfigRefreshingAction": {}, + "settingsFirebaseRuntimeConfigRefreshSuccess": "Firebase runtime config refreshed.", + "@settingsFirebaseRuntimeConfigRefreshSuccess": {}, + "settingsFirebaseRuntimeConfigRefreshNoChanges": "Firebase runtime config is already up to date.", + "@settingsFirebaseRuntimeConfigRefreshNoChanges": {}, + "settingsFirebaseRuntimeConfigRefreshFailed": "Failed to refresh config: {error}", + "@settingsFirebaseRuntimeConfigRefreshFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "settingsExportingData": "Exporting data...", "@settingsExportingData": {}, "settingsDataExportSuccess": "Data exported successfully!", diff --git a/apps/mobile/lib/l10n/arb/app_es.arb b/apps/mobile/lib/l10n/arb/app_es.arb index 6f4b731..b08f0c6 100644 --- a/apps/mobile/lib/l10n/arb/app_es.arb +++ b/apps/mobile/lib/l10n/arb/app_es.arb @@ -136,6 +136,60 @@ } } }, + "settingsFirebaseRuntimeConfigTitle": "Configuración de ejecución de Firebase", + "@settingsFirebaseRuntimeConfigTitle": {}, + "settingsFirebaseRuntimeConfigSubtitle": "Inspecciona y actualiza las banderas de ejecución", + "@settingsFirebaseRuntimeConfigSubtitle": {}, + "settingsFirebaseRuntimeConfigSheetTitle": "Configuración de ejecución de Firebase", + "@settingsFirebaseRuntimeConfigSheetTitle": {}, + "settingsFirebaseRuntimeConfigDescription": "Los valores se obtienen de Firebase Remote Config y se aplican en tiempo de ejecución.", + "@settingsFirebaseRuntimeConfigDescription": {}, + "settingsFirebaseRuntimeConfigSummary": "{enabledCount} de 3 señales activas", + "@settingsFirebaseRuntimeConfigSummary": { + "placeholders": { + "enabledCount": { + "type": "int" + } + } + }, + "settingsFirebaseRuntimeConfigAnalyticsLabel": "Recopilación de analíticas", + "@settingsFirebaseRuntimeConfigAnalyticsLabel": {}, + "settingsFirebaseRuntimeConfigCrashlyticsLabel": "Recopilación de Crashlytics", + "@settingsFirebaseRuntimeConfigCrashlyticsLabel": {}, + "settingsFirebaseRuntimeConfigPerformanceLabel": "Recopilación de rendimiento", + "@settingsFirebaseRuntimeConfigPerformanceLabel": {}, + "settingsFirebaseRuntimeConfigFetchStatusTitle": "Estado de la última obtención", + "@settingsFirebaseRuntimeConfigFetchStatusTitle": {}, + "settingsFirebaseRuntimeConfigLastFetchTitle": "Hora de la última obtención", + "@settingsFirebaseRuntimeConfigLastFetchTitle": {}, + "settingsFirebaseRuntimeConfigValueEnabled": "Activado", + "@settingsFirebaseRuntimeConfigValueEnabled": {}, + "settingsFirebaseRuntimeConfigValueDisabled": "Desactivado", + "@settingsFirebaseRuntimeConfigValueDisabled": {}, + "settingsFirebaseRuntimeConfigFetchStatusSuccess": "Correcto", + "@settingsFirebaseRuntimeConfigFetchStatusSuccess": {}, + "settingsFirebaseRuntimeConfigFetchStatusFailure": "Fallido", + "@settingsFirebaseRuntimeConfigFetchStatusFailure": {}, + "settingsFirebaseRuntimeConfigFetchStatusThrottled": "Limitado", + "@settingsFirebaseRuntimeConfigFetchStatusThrottled": {}, + "settingsFirebaseRuntimeConfigFetchStatusNoFetch": "Aún sin obtención", + "@settingsFirebaseRuntimeConfigFetchStatusNoFetch": {}, + "settingsFirebaseRuntimeConfigRefreshAction": "Actualizar configuración", + "@settingsFirebaseRuntimeConfigRefreshAction": {}, + "settingsFirebaseRuntimeConfigRefreshingAction": "Actualizando...", + "@settingsFirebaseRuntimeConfigRefreshingAction": {}, + "settingsFirebaseRuntimeConfigRefreshSuccess": "La configuración de ejecución de Firebase se actualizó.", + "@settingsFirebaseRuntimeConfigRefreshSuccess": {}, + "settingsFirebaseRuntimeConfigRefreshNoChanges": "La configuración de ejecución de Firebase ya está actualizada.", + "@settingsFirebaseRuntimeConfigRefreshNoChanges": {}, + "settingsFirebaseRuntimeConfigRefreshFailed": "No se pudo actualizar la configuración: {error}", + "@settingsFirebaseRuntimeConfigRefreshFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "settingsExportingData": "Exportando datos...", "@settingsExportingData": {}, "settingsDataExportSuccess": "¡Datos exportados correctamente!", diff --git a/apps/mobile/lib/l10n/arb/app_id.arb b/apps/mobile/lib/l10n/arb/app_id.arb index 09f76f4..f69c021 100644 --- a/apps/mobile/lib/l10n/arb/app_id.arb +++ b/apps/mobile/lib/l10n/arb/app_id.arb @@ -136,6 +136,60 @@ } } }, + "settingsFirebaseRuntimeConfigTitle": "Konfigurasi Runtime Firebase", + "@settingsFirebaseRuntimeConfigTitle": {}, + "settingsFirebaseRuntimeConfigSubtitle": "Periksa dan segarkan flag fitur runtime", + "@settingsFirebaseRuntimeConfigSubtitle": {}, + "settingsFirebaseRuntimeConfigSheetTitle": "Konfigurasi Runtime Firebase", + "@settingsFirebaseRuntimeConfigSheetTitle": {}, + "settingsFirebaseRuntimeConfigDescription": "Nilai diambil dari Firebase Remote Config dan diterapkan saat runtime.", + "@settingsFirebaseRuntimeConfigDescription": {}, + "settingsFirebaseRuntimeConfigSummary": "{enabledCount} dari 3 sinyal aktif", + "@settingsFirebaseRuntimeConfigSummary": { + "placeholders": { + "enabledCount": { + "type": "int" + } + } + }, + "settingsFirebaseRuntimeConfigAnalyticsLabel": "Pengumpulan analitik", + "@settingsFirebaseRuntimeConfigAnalyticsLabel": {}, + "settingsFirebaseRuntimeConfigCrashlyticsLabel": "Pengumpulan Crashlytics", + "@settingsFirebaseRuntimeConfigCrashlyticsLabel": {}, + "settingsFirebaseRuntimeConfigPerformanceLabel": "Pengumpulan performa", + "@settingsFirebaseRuntimeConfigPerformanceLabel": {}, + "settingsFirebaseRuntimeConfigFetchStatusTitle": "Status pengambilan terakhir", + "@settingsFirebaseRuntimeConfigFetchStatusTitle": {}, + "settingsFirebaseRuntimeConfigLastFetchTitle": "Waktu pengambilan terakhir", + "@settingsFirebaseRuntimeConfigLastFetchTitle": {}, + "settingsFirebaseRuntimeConfigValueEnabled": "Aktif", + "@settingsFirebaseRuntimeConfigValueEnabled": {}, + "settingsFirebaseRuntimeConfigValueDisabled": "Nonaktif", + "@settingsFirebaseRuntimeConfigValueDisabled": {}, + "settingsFirebaseRuntimeConfigFetchStatusSuccess": "Berhasil", + "@settingsFirebaseRuntimeConfigFetchStatusSuccess": {}, + "settingsFirebaseRuntimeConfigFetchStatusFailure": "Gagal", + "@settingsFirebaseRuntimeConfigFetchStatusFailure": {}, + "settingsFirebaseRuntimeConfigFetchStatusThrottled": "Dibatasi", + "@settingsFirebaseRuntimeConfigFetchStatusThrottled": {}, + "settingsFirebaseRuntimeConfigFetchStatusNoFetch": "Belum pernah diambil", + "@settingsFirebaseRuntimeConfigFetchStatusNoFetch": {}, + "settingsFirebaseRuntimeConfigRefreshAction": "Segarkan konfigurasi", + "@settingsFirebaseRuntimeConfigRefreshAction": {}, + "settingsFirebaseRuntimeConfigRefreshingAction": "Menyegarkan...", + "@settingsFirebaseRuntimeConfigRefreshingAction": {}, + "settingsFirebaseRuntimeConfigRefreshSuccess": "Konfigurasi runtime Firebase diperbarui.", + "@settingsFirebaseRuntimeConfigRefreshSuccess": {}, + "settingsFirebaseRuntimeConfigRefreshNoChanges": "Konfigurasi runtime Firebase sudah terbaru.", + "@settingsFirebaseRuntimeConfigRefreshNoChanges": {}, + "settingsFirebaseRuntimeConfigRefreshFailed": "Gagal menyegarkan konfigurasi: {error}", + "@settingsFirebaseRuntimeConfigRefreshFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "settingsExportingData": "Mengekspor data...", "@settingsExportingData": {}, "settingsDataExportSuccess": "Data berhasil diekspor!", diff --git a/apps/mobile/lib/l10n/arb/app_ja.arb b/apps/mobile/lib/l10n/arb/app_ja.arb index 9525f19..de08977 100644 --- a/apps/mobile/lib/l10n/arb/app_ja.arb +++ b/apps/mobile/lib/l10n/arb/app_ja.arb @@ -136,6 +136,60 @@ } } }, + "settingsFirebaseRuntimeConfigTitle": "Firebase ランタイム設定", + "@settingsFirebaseRuntimeConfigTitle": {}, + "settingsFirebaseRuntimeConfigSubtitle": "ランタイム機能フラグを確認して更新", + "@settingsFirebaseRuntimeConfigSubtitle": {}, + "settingsFirebaseRuntimeConfigSheetTitle": "Firebase ランタイム設定", + "@settingsFirebaseRuntimeConfigSheetTitle": {}, + "settingsFirebaseRuntimeConfigDescription": "値は Firebase Remote Config から取得され、実行時に適用されます。", + "@settingsFirebaseRuntimeConfigDescription": {}, + "settingsFirebaseRuntimeConfigSummary": "3 件中 {enabledCount} 件が有効", + "@settingsFirebaseRuntimeConfigSummary": { + "placeholders": { + "enabledCount": { + "type": "int" + } + } + }, + "settingsFirebaseRuntimeConfigAnalyticsLabel": "アナリティクス収集", + "@settingsFirebaseRuntimeConfigAnalyticsLabel": {}, + "settingsFirebaseRuntimeConfigCrashlyticsLabel": "Crashlytics 収集", + "@settingsFirebaseRuntimeConfigCrashlyticsLabel": {}, + "settingsFirebaseRuntimeConfigPerformanceLabel": "パフォーマンス収集", + "@settingsFirebaseRuntimeConfigPerformanceLabel": {}, + "settingsFirebaseRuntimeConfigFetchStatusTitle": "最終取得ステータス", + "@settingsFirebaseRuntimeConfigFetchStatusTitle": {}, + "settingsFirebaseRuntimeConfigLastFetchTitle": "最終取得時刻", + "@settingsFirebaseRuntimeConfigLastFetchTitle": {}, + "settingsFirebaseRuntimeConfigValueEnabled": "有効", + "@settingsFirebaseRuntimeConfigValueEnabled": {}, + "settingsFirebaseRuntimeConfigValueDisabled": "無効", + "@settingsFirebaseRuntimeConfigValueDisabled": {}, + "settingsFirebaseRuntimeConfigFetchStatusSuccess": "成功", + "@settingsFirebaseRuntimeConfigFetchStatusSuccess": {}, + "settingsFirebaseRuntimeConfigFetchStatusFailure": "失敗", + "@settingsFirebaseRuntimeConfigFetchStatusFailure": {}, + "settingsFirebaseRuntimeConfigFetchStatusThrottled": "制限中", + "@settingsFirebaseRuntimeConfigFetchStatusThrottled": {}, + "settingsFirebaseRuntimeConfigFetchStatusNoFetch": "未取得", + "@settingsFirebaseRuntimeConfigFetchStatusNoFetch": {}, + "settingsFirebaseRuntimeConfigRefreshAction": "設定を更新", + "@settingsFirebaseRuntimeConfigRefreshAction": {}, + "settingsFirebaseRuntimeConfigRefreshingAction": "更新中...", + "@settingsFirebaseRuntimeConfigRefreshingAction": {}, + "settingsFirebaseRuntimeConfigRefreshSuccess": "Firebase ランタイム設定を更新しました。", + "@settingsFirebaseRuntimeConfigRefreshSuccess": {}, + "settingsFirebaseRuntimeConfigRefreshNoChanges": "Firebase ランタイム設定は最新です。", + "@settingsFirebaseRuntimeConfigRefreshNoChanges": {}, + "settingsFirebaseRuntimeConfigRefreshFailed": "設定の更新に失敗しました: {error}", + "@settingsFirebaseRuntimeConfigRefreshFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "settingsExportingData": "データをエクスポート中...", "@settingsExportingData": {}, "settingsDataExportSuccess": "データのエクスポートに成功しました!", diff --git a/apps/mobile/lib/l10n/arb/app_ko.arb b/apps/mobile/lib/l10n/arb/app_ko.arb index f11e913..7543352 100644 --- a/apps/mobile/lib/l10n/arb/app_ko.arb +++ b/apps/mobile/lib/l10n/arb/app_ko.arb @@ -136,6 +136,60 @@ } } }, + "settingsFirebaseRuntimeConfigTitle": "Firebase 런타임 구성", + "@settingsFirebaseRuntimeConfigTitle": {}, + "settingsFirebaseRuntimeConfigSubtitle": "런타임 기능 플래그 확인 및 새로고침", + "@settingsFirebaseRuntimeConfigSubtitle": {}, + "settingsFirebaseRuntimeConfigSheetTitle": "Firebase 런타임 구성", + "@settingsFirebaseRuntimeConfigSheetTitle": {}, + "settingsFirebaseRuntimeConfigDescription": "값은 Firebase Remote Config에서 가져와 런타임에 적용됩니다.", + "@settingsFirebaseRuntimeConfigDescription": {}, + "settingsFirebaseRuntimeConfigSummary": "3개 신호 중 {enabledCount}개 활성화", + "@settingsFirebaseRuntimeConfigSummary": { + "placeholders": { + "enabledCount": { + "type": "int" + } + } + }, + "settingsFirebaseRuntimeConfigAnalyticsLabel": "애널리틱스 수집", + "@settingsFirebaseRuntimeConfigAnalyticsLabel": {}, + "settingsFirebaseRuntimeConfigCrashlyticsLabel": "Crashlytics 수집", + "@settingsFirebaseRuntimeConfigCrashlyticsLabel": {}, + "settingsFirebaseRuntimeConfigPerformanceLabel": "성능 수집", + "@settingsFirebaseRuntimeConfigPerformanceLabel": {}, + "settingsFirebaseRuntimeConfigFetchStatusTitle": "마지막 가져오기 상태", + "@settingsFirebaseRuntimeConfigFetchStatusTitle": {}, + "settingsFirebaseRuntimeConfigLastFetchTitle": "마지막 가져오기 시간", + "@settingsFirebaseRuntimeConfigLastFetchTitle": {}, + "settingsFirebaseRuntimeConfigValueEnabled": "사용", + "@settingsFirebaseRuntimeConfigValueEnabled": {}, + "settingsFirebaseRuntimeConfigValueDisabled": "사용 안 함", + "@settingsFirebaseRuntimeConfigValueDisabled": {}, + "settingsFirebaseRuntimeConfigFetchStatusSuccess": "성공", + "@settingsFirebaseRuntimeConfigFetchStatusSuccess": {}, + "settingsFirebaseRuntimeConfigFetchStatusFailure": "실패", + "@settingsFirebaseRuntimeConfigFetchStatusFailure": {}, + "settingsFirebaseRuntimeConfigFetchStatusThrottled": "제한됨", + "@settingsFirebaseRuntimeConfigFetchStatusThrottled": {}, + "settingsFirebaseRuntimeConfigFetchStatusNoFetch": "가져온 기록 없음", + "@settingsFirebaseRuntimeConfigFetchStatusNoFetch": {}, + "settingsFirebaseRuntimeConfigRefreshAction": "구성 새로고침", + "@settingsFirebaseRuntimeConfigRefreshAction": {}, + "settingsFirebaseRuntimeConfigRefreshingAction": "새로고침 중...", + "@settingsFirebaseRuntimeConfigRefreshingAction": {}, + "settingsFirebaseRuntimeConfigRefreshSuccess": "Firebase 런타임 구성을 새로고침했습니다.", + "@settingsFirebaseRuntimeConfigRefreshSuccess": {}, + "settingsFirebaseRuntimeConfigRefreshNoChanges": "Firebase 런타임 구성이 이미 최신입니다.", + "@settingsFirebaseRuntimeConfigRefreshNoChanges": {}, + "settingsFirebaseRuntimeConfigRefreshFailed": "구성 새로고침 실패: {error}", + "@settingsFirebaseRuntimeConfigRefreshFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "settingsExportingData": "데이터 내보내는 중...", "@settingsExportingData": {}, "settingsDataExportSuccess": "데이터 내보내기 완료!", diff --git a/apps/mobile/lib/l10n/arb/app_my.arb b/apps/mobile/lib/l10n/arb/app_my.arb index 4245576..ced5bf2 100644 --- a/apps/mobile/lib/l10n/arb/app_my.arb +++ b/apps/mobile/lib/l10n/arb/app_my.arb @@ -136,6 +136,60 @@ } } }, + "settingsFirebaseRuntimeConfigTitle": "Firebase Runtime Config", + "@settingsFirebaseRuntimeConfigTitle": {}, + "settingsFirebaseRuntimeConfigSubtitle": "runtime feature flags ကို စစ်ဆေးပြီး refresh လုပ်ပါ", + "@settingsFirebaseRuntimeConfigSubtitle": {}, + "settingsFirebaseRuntimeConfigSheetTitle": "Firebase Runtime Config", + "@settingsFirebaseRuntimeConfigSheetTitle": {}, + "settingsFirebaseRuntimeConfigDescription": "values တွေကို Firebase Remote Config မှ ရယူပြီး runtime တွင် apply လုပ်ပါသည်။", + "@settingsFirebaseRuntimeConfigDescription": {}, + "settingsFirebaseRuntimeConfigSummary": "signal 3 ခုထဲမှ {enabledCount} ခု ဖွင့်ထားသည်", + "@settingsFirebaseRuntimeConfigSummary": { + "placeholders": { + "enabledCount": { + "type": "int" + } + } + }, + "settingsFirebaseRuntimeConfigAnalyticsLabel": "Analytics collection", + "@settingsFirebaseRuntimeConfigAnalyticsLabel": {}, + "settingsFirebaseRuntimeConfigCrashlyticsLabel": "Crashlytics collection", + "@settingsFirebaseRuntimeConfigCrashlyticsLabel": {}, + "settingsFirebaseRuntimeConfigPerformanceLabel": "Performance collection", + "@settingsFirebaseRuntimeConfigPerformanceLabel": {}, + "settingsFirebaseRuntimeConfigFetchStatusTitle": "နောက်ဆုံး fetch status", + "@settingsFirebaseRuntimeConfigFetchStatusTitle": {}, + "settingsFirebaseRuntimeConfigLastFetchTitle": "နောက်ဆုံး fetch အချိန်", + "@settingsFirebaseRuntimeConfigLastFetchTitle": {}, + "settingsFirebaseRuntimeConfigValueEnabled": "ဖွင့်ထားသည်", + "@settingsFirebaseRuntimeConfigValueEnabled": {}, + "settingsFirebaseRuntimeConfigValueDisabled": "ပိတ်ထားသည်", + "@settingsFirebaseRuntimeConfigValueDisabled": {}, + "settingsFirebaseRuntimeConfigFetchStatusSuccess": "အောင်မြင်သည်", + "@settingsFirebaseRuntimeConfigFetchStatusSuccess": {}, + "settingsFirebaseRuntimeConfigFetchStatusFailure": "မအောင်မြင်ပါ", + "@settingsFirebaseRuntimeConfigFetchStatusFailure": {}, + "settingsFirebaseRuntimeConfigFetchStatusThrottled": "ကန့်သတ်ထားသည်", + "@settingsFirebaseRuntimeConfigFetchStatusThrottled": {}, + "settingsFirebaseRuntimeConfigFetchStatusNoFetch": "မရယူရသေးပါ", + "@settingsFirebaseRuntimeConfigFetchStatusNoFetch": {}, + "settingsFirebaseRuntimeConfigRefreshAction": "config refresh", + "@settingsFirebaseRuntimeConfigRefreshAction": {}, + "settingsFirebaseRuntimeConfigRefreshingAction": "refresh လုပ်နေသည်...", + "@settingsFirebaseRuntimeConfigRefreshingAction": {}, + "settingsFirebaseRuntimeConfigRefreshSuccess": "Firebase runtime config ကို refresh လုပ်ပြီးပါပြီ။", + "@settingsFirebaseRuntimeConfigRefreshSuccess": {}, + "settingsFirebaseRuntimeConfigRefreshNoChanges": "Firebase runtime config သည် နောက်ဆုံး update ဖြစ်နေပါသည်။", + "@settingsFirebaseRuntimeConfigRefreshNoChanges": {}, + "settingsFirebaseRuntimeConfigRefreshFailed": "config refresh မအောင်မြင်ပါ: {error}", + "@settingsFirebaseRuntimeConfigRefreshFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "settingsExportingData": "ဒေတာ ထုတ်ယူနေသည်...", "@settingsExportingData": {}, "settingsDataExportSuccess": "ဒေတာ ထုတ်ယူမှု အောင်မြင်သည်!", diff --git a/apps/mobile/lib/l10n/arb/app_zh.arb b/apps/mobile/lib/l10n/arb/app_zh.arb index f7854a8..41eb06c 100644 --- a/apps/mobile/lib/l10n/arb/app_zh.arb +++ b/apps/mobile/lib/l10n/arb/app_zh.arb @@ -136,6 +136,60 @@ } } }, + "settingsFirebaseRuntimeConfigTitle": "Firebase 运行时配置", + "@settingsFirebaseRuntimeConfigTitle": {}, + "settingsFirebaseRuntimeConfigSubtitle": "查看并刷新运行时功能开关", + "@settingsFirebaseRuntimeConfigSubtitle": {}, + "settingsFirebaseRuntimeConfigSheetTitle": "Firebase 运行时配置", + "@settingsFirebaseRuntimeConfigSheetTitle": {}, + "settingsFirebaseRuntimeConfigDescription": "这些值来自 Firebase Remote Config,并在运行时生效。", + "@settingsFirebaseRuntimeConfigDescription": {}, + "settingsFirebaseRuntimeConfigSummary": "3 项信号中已启用 {enabledCount} 项", + "@settingsFirebaseRuntimeConfigSummary": { + "placeholders": { + "enabledCount": { + "type": "int" + } + } + }, + "settingsFirebaseRuntimeConfigAnalyticsLabel": "分析收集", + "@settingsFirebaseRuntimeConfigAnalyticsLabel": {}, + "settingsFirebaseRuntimeConfigCrashlyticsLabel": "Crashlytics 收集", + "@settingsFirebaseRuntimeConfigCrashlyticsLabel": {}, + "settingsFirebaseRuntimeConfigPerformanceLabel": "性能收集", + "@settingsFirebaseRuntimeConfigPerformanceLabel": {}, + "settingsFirebaseRuntimeConfigFetchStatusTitle": "最近拉取状态", + "@settingsFirebaseRuntimeConfigFetchStatusTitle": {}, + "settingsFirebaseRuntimeConfigLastFetchTitle": "最近拉取时间", + "@settingsFirebaseRuntimeConfigLastFetchTitle": {}, + "settingsFirebaseRuntimeConfigValueEnabled": "已启用", + "@settingsFirebaseRuntimeConfigValueEnabled": {}, + "settingsFirebaseRuntimeConfigValueDisabled": "已禁用", + "@settingsFirebaseRuntimeConfigValueDisabled": {}, + "settingsFirebaseRuntimeConfigFetchStatusSuccess": "成功", + "@settingsFirebaseRuntimeConfigFetchStatusSuccess": {}, + "settingsFirebaseRuntimeConfigFetchStatusFailure": "失败", + "@settingsFirebaseRuntimeConfigFetchStatusFailure": {}, + "settingsFirebaseRuntimeConfigFetchStatusThrottled": "受限", + "@settingsFirebaseRuntimeConfigFetchStatusThrottled": {}, + "settingsFirebaseRuntimeConfigFetchStatusNoFetch": "尚未拉取", + "@settingsFirebaseRuntimeConfigFetchStatusNoFetch": {}, + "settingsFirebaseRuntimeConfigRefreshAction": "刷新配置", + "@settingsFirebaseRuntimeConfigRefreshAction": {}, + "settingsFirebaseRuntimeConfigRefreshingAction": "正在刷新...", + "@settingsFirebaseRuntimeConfigRefreshingAction": {}, + "settingsFirebaseRuntimeConfigRefreshSuccess": "Firebase 运行时配置已刷新。", + "@settingsFirebaseRuntimeConfigRefreshSuccess": {}, + "settingsFirebaseRuntimeConfigRefreshNoChanges": "Firebase 运行时配置已是最新。", + "@settingsFirebaseRuntimeConfigRefreshNoChanges": {}, + "settingsFirebaseRuntimeConfigRefreshFailed": "刷新配置失败:{error}", + "@settingsFirebaseRuntimeConfigRefreshFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "settingsExportingData": "正在导出数据...", "@settingsExportingData": {}, "settingsDataExportSuccess": "数据导出成功!", diff --git a/apps/mobile/lib/l10n/gen/app_localizations.dart b/apps/mobile/lib/l10n/gen/app_localizations.dart index 766a440..d4e4491 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations.dart @@ -497,6 +497,132 @@ abstract class AppLocalizations { /// **'Failed to trigger crash test: {error}'** String settingsCrashlyticsTestFailed(String error); + /// No description provided for @settingsFirebaseRuntimeConfigTitle. + /// + /// In en, this message translates to: + /// **'Firebase Runtime Config'** + String get settingsFirebaseRuntimeConfigTitle; + + /// No description provided for @settingsFirebaseRuntimeConfigSubtitle. + /// + /// In en, this message translates to: + /// **'Inspect and refresh runtime feature flags'** + String get settingsFirebaseRuntimeConfigSubtitle; + + /// No description provided for @settingsFirebaseRuntimeConfigSheetTitle. + /// + /// In en, this message translates to: + /// **'Firebase Runtime Config'** + String get settingsFirebaseRuntimeConfigSheetTitle; + + /// No description provided for @settingsFirebaseRuntimeConfigDescription. + /// + /// In en, this message translates to: + /// **'Values are fetched from Firebase Remote Config and applied at runtime.'** + String get settingsFirebaseRuntimeConfigDescription; + + /// No description provided for @settingsFirebaseRuntimeConfigSummary. + /// + /// In en, this message translates to: + /// **'{enabledCount} of 3 signals enabled'** + String settingsFirebaseRuntimeConfigSummary(int enabledCount); + + /// No description provided for @settingsFirebaseRuntimeConfigAnalyticsLabel. + /// + /// In en, this message translates to: + /// **'Analytics collection'** + String get settingsFirebaseRuntimeConfigAnalyticsLabel; + + /// No description provided for @settingsFirebaseRuntimeConfigCrashlyticsLabel. + /// + /// In en, this message translates to: + /// **'Crashlytics collection'** + String get settingsFirebaseRuntimeConfigCrashlyticsLabel; + + /// No description provided for @settingsFirebaseRuntimeConfigPerformanceLabel. + /// + /// In en, this message translates to: + /// **'Performance collection'** + String get settingsFirebaseRuntimeConfigPerformanceLabel; + + /// No description provided for @settingsFirebaseRuntimeConfigFetchStatusTitle. + /// + /// In en, this message translates to: + /// **'Last fetch status'** + String get settingsFirebaseRuntimeConfigFetchStatusTitle; + + /// No description provided for @settingsFirebaseRuntimeConfigLastFetchTitle. + /// + /// In en, this message translates to: + /// **'Last fetch time'** + String get settingsFirebaseRuntimeConfigLastFetchTitle; + + /// No description provided for @settingsFirebaseRuntimeConfigValueEnabled. + /// + /// In en, this message translates to: + /// **'Enabled'** + String get settingsFirebaseRuntimeConfigValueEnabled; + + /// No description provided for @settingsFirebaseRuntimeConfigValueDisabled. + /// + /// In en, this message translates to: + /// **'Disabled'** + String get settingsFirebaseRuntimeConfigValueDisabled; + + /// No description provided for @settingsFirebaseRuntimeConfigFetchStatusSuccess. + /// + /// In en, this message translates to: + /// **'Success'** + String get settingsFirebaseRuntimeConfigFetchStatusSuccess; + + /// No description provided for @settingsFirebaseRuntimeConfigFetchStatusFailure. + /// + /// In en, this message translates to: + /// **'Failure'** + String get settingsFirebaseRuntimeConfigFetchStatusFailure; + + /// No description provided for @settingsFirebaseRuntimeConfigFetchStatusThrottled. + /// + /// In en, this message translates to: + /// **'Throttled'** + String get settingsFirebaseRuntimeConfigFetchStatusThrottled; + + /// No description provided for @settingsFirebaseRuntimeConfigFetchStatusNoFetch. + /// + /// In en, this message translates to: + /// **'No fetch yet'** + String get settingsFirebaseRuntimeConfigFetchStatusNoFetch; + + /// No description provided for @settingsFirebaseRuntimeConfigRefreshAction. + /// + /// In en, this message translates to: + /// **'Refresh config'** + String get settingsFirebaseRuntimeConfigRefreshAction; + + /// No description provided for @settingsFirebaseRuntimeConfigRefreshingAction. + /// + /// In en, this message translates to: + /// **'Refreshing...'** + String get settingsFirebaseRuntimeConfigRefreshingAction; + + /// No description provided for @settingsFirebaseRuntimeConfigRefreshSuccess. + /// + /// In en, this message translates to: + /// **'Firebase runtime config refreshed.'** + String get settingsFirebaseRuntimeConfigRefreshSuccess; + + /// No description provided for @settingsFirebaseRuntimeConfigRefreshNoChanges. + /// + /// In en, this message translates to: + /// **'Firebase runtime config is already up to date.'** + String get settingsFirebaseRuntimeConfigRefreshNoChanges; + + /// No description provided for @settingsFirebaseRuntimeConfigRefreshFailed. + /// + /// In en, this message translates to: + /// **'Failed to refresh config: {error}'** + String settingsFirebaseRuntimeConfigRefreshFailed(String error); + /// No description provided for @settingsExportingData. /// /// In en, this message translates to: diff --git a/apps/mobile/lib/l10n/gen/app_localizations_en.dart b/apps/mobile/lib/l10n/gen/app_localizations_en.dart index 983c245..c8b4487 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_en.dart @@ -208,6 +208,73 @@ class AppLocalizationsEn extends AppLocalizations { return 'Failed to trigger crash test: $error'; } + @override + String get settingsFirebaseRuntimeConfigTitle => 'Firebase Runtime Config'; + + @override + String get settingsFirebaseRuntimeConfigSubtitle => 'Inspect and refresh runtime feature flags'; + + @override + String get settingsFirebaseRuntimeConfigSheetTitle => 'Firebase Runtime Config'; + + @override + String get settingsFirebaseRuntimeConfigDescription => 'Values are fetched from Firebase Remote Config and applied at runtime.'; + + @override + String settingsFirebaseRuntimeConfigSummary(int enabledCount) { + return '$enabledCount of 3 signals enabled'; + } + + @override + String get settingsFirebaseRuntimeConfigAnalyticsLabel => 'Analytics collection'; + + @override + String get settingsFirebaseRuntimeConfigCrashlyticsLabel => 'Crashlytics collection'; + + @override + String get settingsFirebaseRuntimeConfigPerformanceLabel => 'Performance collection'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusTitle => 'Last fetch status'; + + @override + String get settingsFirebaseRuntimeConfigLastFetchTitle => 'Last fetch time'; + + @override + String get settingsFirebaseRuntimeConfigValueEnabled => 'Enabled'; + + @override + String get settingsFirebaseRuntimeConfigValueDisabled => 'Disabled'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusSuccess => 'Success'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusFailure => 'Failure'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusThrottled => 'Throttled'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusNoFetch => 'No fetch yet'; + + @override + String get settingsFirebaseRuntimeConfigRefreshAction => 'Refresh config'; + + @override + String get settingsFirebaseRuntimeConfigRefreshingAction => 'Refreshing...'; + + @override + String get settingsFirebaseRuntimeConfigRefreshSuccess => 'Firebase runtime config refreshed.'; + + @override + String get settingsFirebaseRuntimeConfigRefreshNoChanges => 'Firebase runtime config is already up to date.'; + + @override + String settingsFirebaseRuntimeConfigRefreshFailed(String error) { + return 'Failed to refresh config: $error'; + } + @override String get settingsExportingData => 'Exporting data...'; diff --git a/apps/mobile/lib/l10n/gen/app_localizations_es.dart b/apps/mobile/lib/l10n/gen/app_localizations_es.dart index 4549da2..7fb797f 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_es.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_es.dart @@ -208,6 +208,73 @@ class AppLocalizationsEs extends AppLocalizations { return 'No se pudo iniciar la prueba de bloqueo: $error'; } + @override + String get settingsFirebaseRuntimeConfigTitle => 'Configuración de ejecución de Firebase'; + + @override + String get settingsFirebaseRuntimeConfigSubtitle => 'Inspecciona y actualiza las banderas de ejecución'; + + @override + String get settingsFirebaseRuntimeConfigSheetTitle => 'Configuración de ejecución de Firebase'; + + @override + String get settingsFirebaseRuntimeConfigDescription => 'Los valores se obtienen de Firebase Remote Config y se aplican en tiempo de ejecución.'; + + @override + String settingsFirebaseRuntimeConfigSummary(int enabledCount) { + return '$enabledCount de 3 señales activas'; + } + + @override + String get settingsFirebaseRuntimeConfigAnalyticsLabel => 'Recopilación de analíticas'; + + @override + String get settingsFirebaseRuntimeConfigCrashlyticsLabel => 'Recopilación de Crashlytics'; + + @override + String get settingsFirebaseRuntimeConfigPerformanceLabel => 'Recopilación de rendimiento'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusTitle => 'Estado de la última obtención'; + + @override + String get settingsFirebaseRuntimeConfigLastFetchTitle => 'Hora de la última obtención'; + + @override + String get settingsFirebaseRuntimeConfigValueEnabled => 'Activado'; + + @override + String get settingsFirebaseRuntimeConfigValueDisabled => 'Desactivado'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusSuccess => 'Correcto'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusFailure => 'Fallido'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusThrottled => 'Limitado'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusNoFetch => 'Aún sin obtención'; + + @override + String get settingsFirebaseRuntimeConfigRefreshAction => 'Actualizar configuración'; + + @override + String get settingsFirebaseRuntimeConfigRefreshingAction => 'Actualizando...'; + + @override + String get settingsFirebaseRuntimeConfigRefreshSuccess => 'La configuración de ejecución de Firebase se actualizó.'; + + @override + String get settingsFirebaseRuntimeConfigRefreshNoChanges => 'La configuración de ejecución de Firebase ya está actualizada.'; + + @override + String settingsFirebaseRuntimeConfigRefreshFailed(String error) { + return 'No se pudo actualizar la configuración: $error'; + } + @override String get settingsExportingData => 'Exportando datos...'; diff --git a/apps/mobile/lib/l10n/gen/app_localizations_id.dart b/apps/mobile/lib/l10n/gen/app_localizations_id.dart index 932b701..fc22d03 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_id.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_id.dart @@ -208,6 +208,73 @@ class AppLocalizationsId extends AppLocalizations { return 'Gagal memicu uji crash: $error'; } + @override + String get settingsFirebaseRuntimeConfigTitle => 'Konfigurasi Runtime Firebase'; + + @override + String get settingsFirebaseRuntimeConfigSubtitle => 'Periksa dan segarkan flag fitur runtime'; + + @override + String get settingsFirebaseRuntimeConfigSheetTitle => 'Konfigurasi Runtime Firebase'; + + @override + String get settingsFirebaseRuntimeConfigDescription => 'Nilai diambil dari Firebase Remote Config dan diterapkan saat runtime.'; + + @override + String settingsFirebaseRuntimeConfigSummary(int enabledCount) { + return '$enabledCount dari 3 sinyal aktif'; + } + + @override + String get settingsFirebaseRuntimeConfigAnalyticsLabel => 'Pengumpulan analitik'; + + @override + String get settingsFirebaseRuntimeConfigCrashlyticsLabel => 'Pengumpulan Crashlytics'; + + @override + String get settingsFirebaseRuntimeConfigPerformanceLabel => 'Pengumpulan performa'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusTitle => 'Status pengambilan terakhir'; + + @override + String get settingsFirebaseRuntimeConfigLastFetchTitle => 'Waktu pengambilan terakhir'; + + @override + String get settingsFirebaseRuntimeConfigValueEnabled => 'Aktif'; + + @override + String get settingsFirebaseRuntimeConfigValueDisabled => 'Nonaktif'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusSuccess => 'Berhasil'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusFailure => 'Gagal'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusThrottled => 'Dibatasi'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusNoFetch => 'Belum pernah diambil'; + + @override + String get settingsFirebaseRuntimeConfigRefreshAction => 'Segarkan konfigurasi'; + + @override + String get settingsFirebaseRuntimeConfigRefreshingAction => 'Menyegarkan...'; + + @override + String get settingsFirebaseRuntimeConfigRefreshSuccess => 'Konfigurasi runtime Firebase diperbarui.'; + + @override + String get settingsFirebaseRuntimeConfigRefreshNoChanges => 'Konfigurasi runtime Firebase sudah terbaru.'; + + @override + String settingsFirebaseRuntimeConfigRefreshFailed(String error) { + return 'Gagal menyegarkan konfigurasi: $error'; + } + @override String get settingsExportingData => 'Mengekspor data...'; diff --git a/apps/mobile/lib/l10n/gen/app_localizations_ja.dart b/apps/mobile/lib/l10n/gen/app_localizations_ja.dart index 894afb5..e5d70dc 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_ja.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_ja.dart @@ -208,6 +208,73 @@ class AppLocalizationsJa extends AppLocalizations { return 'テストクラッシュの実行に失敗しました: $error'; } + @override + String get settingsFirebaseRuntimeConfigTitle => 'Firebase ランタイム設定'; + + @override + String get settingsFirebaseRuntimeConfigSubtitle => 'ランタイム機能フラグを確認して更新'; + + @override + String get settingsFirebaseRuntimeConfigSheetTitle => 'Firebase ランタイム設定'; + + @override + String get settingsFirebaseRuntimeConfigDescription => '値は Firebase Remote Config から取得され、実行時に適用されます。'; + + @override + String settingsFirebaseRuntimeConfigSummary(int enabledCount) { + return '3 件中 $enabledCount 件が有効'; + } + + @override + String get settingsFirebaseRuntimeConfigAnalyticsLabel => 'アナリティクス収集'; + + @override + String get settingsFirebaseRuntimeConfigCrashlyticsLabel => 'Crashlytics 収集'; + + @override + String get settingsFirebaseRuntimeConfigPerformanceLabel => 'パフォーマンス収集'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusTitle => '最終取得ステータス'; + + @override + String get settingsFirebaseRuntimeConfigLastFetchTitle => '最終取得時刻'; + + @override + String get settingsFirebaseRuntimeConfigValueEnabled => '有効'; + + @override + String get settingsFirebaseRuntimeConfigValueDisabled => '無効'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusSuccess => '成功'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusFailure => '失敗'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusThrottled => '制限中'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusNoFetch => '未取得'; + + @override + String get settingsFirebaseRuntimeConfigRefreshAction => '設定を更新'; + + @override + String get settingsFirebaseRuntimeConfigRefreshingAction => '更新中...'; + + @override + String get settingsFirebaseRuntimeConfigRefreshSuccess => 'Firebase ランタイム設定を更新しました。'; + + @override + String get settingsFirebaseRuntimeConfigRefreshNoChanges => 'Firebase ランタイム設定は最新です。'; + + @override + String settingsFirebaseRuntimeConfigRefreshFailed(String error) { + return '設定の更新に失敗しました: $error'; + } + @override String get settingsExportingData => 'データをエクスポート中...'; diff --git a/apps/mobile/lib/l10n/gen/app_localizations_ko.dart b/apps/mobile/lib/l10n/gen/app_localizations_ko.dart index 2cdc228..e48c373 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_ko.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_ko.dart @@ -208,6 +208,73 @@ class AppLocalizationsKo extends AppLocalizations { return '테스트 크래시 실행 실패: $error'; } + @override + String get settingsFirebaseRuntimeConfigTitle => 'Firebase 런타임 구성'; + + @override + String get settingsFirebaseRuntimeConfigSubtitle => '런타임 기능 플래그 확인 및 새로고침'; + + @override + String get settingsFirebaseRuntimeConfigSheetTitle => 'Firebase 런타임 구성'; + + @override + String get settingsFirebaseRuntimeConfigDescription => '값은 Firebase Remote Config에서 가져와 런타임에 적용됩니다.'; + + @override + String settingsFirebaseRuntimeConfigSummary(int enabledCount) { + return '3개 신호 중 $enabledCount개 활성화'; + } + + @override + String get settingsFirebaseRuntimeConfigAnalyticsLabel => '애널리틱스 수집'; + + @override + String get settingsFirebaseRuntimeConfigCrashlyticsLabel => 'Crashlytics 수집'; + + @override + String get settingsFirebaseRuntimeConfigPerformanceLabel => '성능 수집'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusTitle => '마지막 가져오기 상태'; + + @override + String get settingsFirebaseRuntimeConfigLastFetchTitle => '마지막 가져오기 시간'; + + @override + String get settingsFirebaseRuntimeConfigValueEnabled => '사용'; + + @override + String get settingsFirebaseRuntimeConfigValueDisabled => '사용 안 함'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusSuccess => '성공'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusFailure => '실패'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusThrottled => '제한됨'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusNoFetch => '가져온 기록 없음'; + + @override + String get settingsFirebaseRuntimeConfigRefreshAction => '구성 새로고침'; + + @override + String get settingsFirebaseRuntimeConfigRefreshingAction => '새로고침 중...'; + + @override + String get settingsFirebaseRuntimeConfigRefreshSuccess => 'Firebase 런타임 구성을 새로고침했습니다.'; + + @override + String get settingsFirebaseRuntimeConfigRefreshNoChanges => 'Firebase 런타임 구성이 이미 최신입니다.'; + + @override + String settingsFirebaseRuntimeConfigRefreshFailed(String error) { + return '구성 새로고침 실패: $error'; + } + @override String get settingsExportingData => '데이터 내보내는 중...'; diff --git a/apps/mobile/lib/l10n/gen/app_localizations_my.dart b/apps/mobile/lib/l10n/gen/app_localizations_my.dart index 87624e7..728fdac 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_my.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_my.dart @@ -208,6 +208,73 @@ class AppLocalizationsMy extends AppLocalizations { return 'စမ်းသပ် crash စတင်မရပါ: $error'; } + @override + String get settingsFirebaseRuntimeConfigTitle => 'Firebase Runtime Config'; + + @override + String get settingsFirebaseRuntimeConfigSubtitle => 'runtime feature flags ကို စစ်ဆေးပြီး refresh လုပ်ပါ'; + + @override + String get settingsFirebaseRuntimeConfigSheetTitle => 'Firebase Runtime Config'; + + @override + String get settingsFirebaseRuntimeConfigDescription => 'values တွေကို Firebase Remote Config မှ ရယူပြီး runtime တွင် apply လုပ်ပါသည်။'; + + @override + String settingsFirebaseRuntimeConfigSummary(int enabledCount) { + return 'signal 3 ခုထဲမှ $enabledCount ခု ဖွင့်ထားသည်'; + } + + @override + String get settingsFirebaseRuntimeConfigAnalyticsLabel => 'Analytics collection'; + + @override + String get settingsFirebaseRuntimeConfigCrashlyticsLabel => 'Crashlytics collection'; + + @override + String get settingsFirebaseRuntimeConfigPerformanceLabel => 'Performance collection'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusTitle => 'နောက်ဆုံး fetch status'; + + @override + String get settingsFirebaseRuntimeConfigLastFetchTitle => 'နောက်ဆုံး fetch အချိန်'; + + @override + String get settingsFirebaseRuntimeConfigValueEnabled => 'ဖွင့်ထားသည်'; + + @override + String get settingsFirebaseRuntimeConfigValueDisabled => 'ပိတ်ထားသည်'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusSuccess => 'အောင်မြင်သည်'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusFailure => 'မအောင်မြင်ပါ'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusThrottled => 'ကန့်သတ်ထားသည်'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusNoFetch => 'မရယူရသေးပါ'; + + @override + String get settingsFirebaseRuntimeConfigRefreshAction => 'config refresh'; + + @override + String get settingsFirebaseRuntimeConfigRefreshingAction => 'refresh လုပ်နေသည်...'; + + @override + String get settingsFirebaseRuntimeConfigRefreshSuccess => 'Firebase runtime config ကို refresh လုပ်ပြီးပါပြီ။'; + + @override + String get settingsFirebaseRuntimeConfigRefreshNoChanges => 'Firebase runtime config သည် နောက်ဆုံး update ဖြစ်နေပါသည်။'; + + @override + String settingsFirebaseRuntimeConfigRefreshFailed(String error) { + return 'config refresh မအောင်မြင်ပါ: $error'; + } + @override String get settingsExportingData => 'ဒေတာ ထုတ်ယူနေသည်...'; diff --git a/apps/mobile/lib/l10n/gen/app_localizations_zh.dart b/apps/mobile/lib/l10n/gen/app_localizations_zh.dart index f6a8e8a..76701d8 100644 --- a/apps/mobile/lib/l10n/gen/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/gen/app_localizations_zh.dart @@ -208,6 +208,73 @@ class AppLocalizationsZh extends AppLocalizations { return '触发测试崩溃失败:$error'; } + @override + String get settingsFirebaseRuntimeConfigTitle => 'Firebase 运行时配置'; + + @override + String get settingsFirebaseRuntimeConfigSubtitle => '查看并刷新运行时功能开关'; + + @override + String get settingsFirebaseRuntimeConfigSheetTitle => 'Firebase 运行时配置'; + + @override + String get settingsFirebaseRuntimeConfigDescription => '这些值来自 Firebase Remote Config,并在运行时生效。'; + + @override + String settingsFirebaseRuntimeConfigSummary(int enabledCount) { + return '3 项信号中已启用 $enabledCount 项'; + } + + @override + String get settingsFirebaseRuntimeConfigAnalyticsLabel => '分析收集'; + + @override + String get settingsFirebaseRuntimeConfigCrashlyticsLabel => 'Crashlytics 收集'; + + @override + String get settingsFirebaseRuntimeConfigPerformanceLabel => '性能收集'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusTitle => '最近拉取状态'; + + @override + String get settingsFirebaseRuntimeConfigLastFetchTitle => '最近拉取时间'; + + @override + String get settingsFirebaseRuntimeConfigValueEnabled => '已启用'; + + @override + String get settingsFirebaseRuntimeConfigValueDisabled => '已禁用'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusSuccess => '成功'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusFailure => '失败'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusThrottled => '受限'; + + @override + String get settingsFirebaseRuntimeConfigFetchStatusNoFetch => '尚未拉取'; + + @override + String get settingsFirebaseRuntimeConfigRefreshAction => '刷新配置'; + + @override + String get settingsFirebaseRuntimeConfigRefreshingAction => '正在刷新...'; + + @override + String get settingsFirebaseRuntimeConfigRefreshSuccess => 'Firebase 运行时配置已刷新。'; + + @override + String get settingsFirebaseRuntimeConfigRefreshNoChanges => 'Firebase 运行时配置已是最新。'; + + @override + String settingsFirebaseRuntimeConfigRefreshFailed(String error) { + return '刷新配置失败:$error'; + } + @override String get settingsExportingData => '正在导出数据...'; diff --git a/apps/mobile/lib/main.dart b/apps/mobile/lib/main.dart index c960300..be4b173 100644 --- a/apps/mobile/lib/main.dart +++ b/apps/mobile/lib/main.dart @@ -17,7 +17,7 @@ void main() async { onboardingCompleteProvider.overrideWith( (ref) => bootstrapData.onboardingComplete, ), - firebaseRuntimeConfigProvider.overrideWith( + initialFirebaseRuntimeConfigProvider.overrideWith( (ref) => bootstrapData.firebaseRuntimeConfig, ), ], From ef1a4b66448e3e2590af5e7932f6900f5b59066d Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 15:19:34 +0630 Subject: [PATCH 08/31] feat: Implement automatic Firebase Remote Config refreshing on app resume with throttling and forced refresh capabilities. --- apps/mobile/lib/app.dart | 5 +- .../firebase_services_bootstrap.dart | 9 ++- .../firebase_runtime_config_auto_refresh.dart | 64 +++++++++++++++++++ .../firebase_runtime_config_provider.dart | 32 +++++++++- .../view_models/export_import_view_model.dart | 18 +++--- .../presentation/views/settings_screen.dart | 50 ++++++++++----- .../firebase_performance_service.dart | 12 ++++ .../firebase_remote_config_service.dart | 38 +++++++++++ 8 files changed, 198 insertions(+), 30 deletions(-) create mode 100644 apps/mobile/lib/core/firebase/firebase_runtime_config_auto_refresh.dart diff --git a/apps/mobile/lib/app.dart b/apps/mobile/lib/app.dart index 3babe6c..181e0b3 100644 --- a/apps/mobile/lib/app.dart +++ b/apps/mobile/lib/app.dart @@ -1,4 +1,5 @@ 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/router/app_router.dart'; import 'package:collection_tracker/l10n/l10n.dart'; import 'package:flutter/material.dart'; @@ -56,7 +57,9 @@ class CollectionTrackerApp extends ConsumerWidget { return AnnotatedRegion( value: overlay, - child: child ?? const SizedBox.shrink(), + child: FirebaseRuntimeConfigAutoRefresh( + child: child ?? const SizedBox.shrink(), + ), ); }, ); diff --git a/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart index 80371b4..4a6f468 100644 --- a/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart +++ b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart @@ -54,8 +54,9 @@ abstract final class FirebaseServicesBootstrap { return runtimeConfig; } - static Future - refreshRuntimeConfig() async { + static Future refreshRuntimeConfig({ + bool forceFetch = false, + }) async { final remoteConfigService = FirebaseRemoteConfigService.instance; if (!remoteConfigService.isInitialized) { final runtimeConfig = await initialize(); @@ -66,7 +67,9 @@ abstract final class FirebaseServicesBootstrap { ); } - final didActivateChanges = await remoteConfigService.refresh(); + final didActivateChanges = forceFetch + ? await remoteConfigService.refreshForced() + : await remoteConfigService.refresh(); final runtimeConfig = _readRuntimeConfig(remoteConfigService); await FirebasePerformanceService.instance.setCollectionEnabled( 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/providers/firebase_runtime_config_provider.dart b/apps/mobile/lib/core/providers/firebase_runtime_config_provider.dart index 04293cf..aff2eb6 100644 --- a/apps/mobile/lib/core/providers/firebase_runtime_config_provider.dart +++ b/apps/mobile/lib/core/providers/firebase_runtime_config_provider.dart @@ -1,6 +1,7 @@ import 'package:app_firebase/app_firebase.dart'; import 'package:collection_tracker/core/bootstrap/firebase_services_bootstrap.dart'; import 'package:collection_tracker/core/firebase/firebase_runtime_config.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class FirebaseRuntimeConfigState { @@ -39,6 +40,12 @@ final firebaseRuntimeConfigControllerProvider = class FirebaseRuntimeConfigController extends Notifier { + static final Duration _defaultResumeRefreshThrottle = kDebugMode + ? const Duration(minutes: 1) + : const Duration(minutes: 15); + + DateTime? _lastAutoRefreshAttempt; + @override FirebaseRuntimeConfigState build() { final initialConfig = ref.watch(initialFirebaseRuntimeConfigProvider); @@ -49,7 +56,9 @@ class FirebaseRuntimeConfigController ); } - Future refreshFromRemoteConfig() async { + Future refreshFromRemoteConfig({ + bool forceFetch = false, + }) async { if (state.isRefreshing) { return FirebaseRuntimeConfigRefreshResult( runtimeConfig: state.config, @@ -61,7 +70,9 @@ class FirebaseRuntimeConfigController state = state.copyWith(isRefreshing: true); try { - final result = await FirebaseServicesBootstrap.refreshRuntimeConfig(); + final result = await FirebaseServicesBootstrap.refreshRuntimeConfig( + forceFetch: forceFetch, + ); state = state.copyWith( config: result.runtimeConfig, remoteConfigStatus: result.status, @@ -76,6 +87,23 @@ class FirebaseRuntimeConfigController rethrow; } } + + Future refreshFromRemoteConfigIfDue({ + Duration? minimumInterval, + DateTime? now, + }) async { + final effectiveNow = now ?? DateTime.now(); + final throttle = minimumInterval ?? _defaultResumeRefreshThrottle; + final previousAttempt = _lastAutoRefreshAttempt; + + if (previousAttempt != null && + effectiveNow.difference(previousAttempt) < throttle) { + return null; + } + + _lastAutoRefreshAttempt = effectiveNow; + return refreshFromRemoteConfig(); + } } final firebaseRuntimeConfigProvider = Provider( diff --git a/apps/mobile/lib/features/settings/presentation/view_models/export_import_view_model.dart b/apps/mobile/lib/features/settings/presentation/view_models/export_import_view_model.dart index e280df3..f8f663a 100644 --- a/apps/mobile/lib/features/settings/presentation/view_models/export_import_view_model.dart +++ b/apps/mobile/lib/features/settings/presentation/view_models/export_import_view_model.dart @@ -86,9 +86,7 @@ class ExportImportViewModel extends _$ExportImportViewModel { ), ); - if (ref.mounted) { - state = result; - } + _setStateSafely(result); if (result.hasError) { throw result.error!; @@ -142,9 +140,7 @@ class ExportImportViewModel extends _$ExportImportViewModel { }), ); - if (ref.mounted) { - state = result; - } + _setStateSafely(result); if (result.hasError) { throw result.error!; @@ -217,8 +213,14 @@ class ExportImportViewModel extends _$ExportImportViewModel { ), ); - if (ref.mounted) { - state = result; + _setStateSafely(result); + } + + void _setStateSafely(AsyncValue value) { + try { + state = value; + } catch (_) { + // Ignore if provider was auto-disposed while async work was running. } } } diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart index 9c222a0..9cafcc8 100644 --- a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart +++ b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart @@ -3,7 +3,6 @@ import 'package:collection_tracker/core/analytics/analytics_consent_dialog.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/providers/providers.dart'; -import 'package:intl/intl.dart'; import 'package:collection_tracker/l10n/l10n.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; @@ -527,12 +526,10 @@ class SettingsScreen extends ConsumerWidget { final isRefreshing = ref.watch( firebaseRuntimeConfigRefreshInProgressProvider, ); - final localeTag = Localizations.localeOf(context).toLanguageTag(); - final lastFetchTimeText = remoteConfigStatus.lastFetchTime == null - ? l10n.settingsFirebaseRuntimeConfigFetchStatusNoFetch - : DateFormat.yMd(localeTag).add_Hms().format( - remoteConfigStatus.lastFetchTime!.toLocal(), - ); + final lastFetchTimeText = _lastFetchTimeLabel( + context, + remoteConfigStatus.lastFetchTime, + ); return Column( mainAxisSize: MainAxisSize.min, @@ -644,7 +641,7 @@ class SettingsScreen extends ConsumerWidget { try { final result = await ref .read(firebaseRuntimeConfigControllerProvider.notifier) - .refreshFromRemoteConfig(); + .refreshFromRemoteConfig(forceFetch: true); await ref .read(analyticsPreferencesProvider.notifier) .syncToAnalyticsService(); @@ -840,17 +837,38 @@ class SettingsScreen extends ConsumerWidget { String _remoteConfigFetchStatusLabel( BuildContext context, - dynamic lastFetchStatus, + Object? lastFetchStatus, ) { final l10n = context.l10n; - final statusName = lastFetchStatus?.name; + final statusText = lastFetchStatus?.toString() ?? ''; - return switch (statusName) { - 'success' => l10n.settingsFirebaseRuntimeConfigFetchStatusSuccess, - 'failure' => l10n.settingsFirebaseRuntimeConfigFetchStatusFailure, - 'throttle' => l10n.settingsFirebaseRuntimeConfigFetchStatusThrottled, - _ => l10n.settingsFirebaseRuntimeConfigFetchStatusNoFetch, - }; + if (statusText.contains('success')) { + return l10n.settingsFirebaseRuntimeConfigFetchStatusSuccess; + } + if (statusText.contains('failure')) { + return l10n.settingsFirebaseRuntimeConfigFetchStatusFailure; + } + if (statusText.contains('throttle')) { + return l10n.settingsFirebaseRuntimeConfigFetchStatusThrottled; + } + + return l10n.settingsFirebaseRuntimeConfigFetchStatusNoFetch; + } + + String _lastFetchTimeLabel(BuildContext context, DateTime? lastFetchTime) { + if (lastFetchTime == null) { + return context.l10n.settingsFirebaseRuntimeConfigFetchStatusNoFetch; + } + + final materialLocalizations = MaterialLocalizations.of(context); + final localTime = lastFetchTime.toLocal(); + final dateLabel = materialLocalizations.formatShortDate(localTime); + final timeLabel = materialLocalizations.formatTimeOfDay( + TimeOfDay.fromDateTime(localTime), + alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), + ); + + return '$dateLabel $timeLabel'; } } diff --git a/packages/integrations/firebase_services/lib/src/services/firebase_performance_service.dart b/packages/integrations/firebase_services/lib/src/services/firebase_performance_service.dart index defb384..e73aa3f 100644 --- a/packages/integrations/firebase_services/lib/src/services/firebase_performance_service.dart +++ b/packages/integrations/firebase_services/lib/src/services/firebase_performance_service.dart @@ -60,6 +60,12 @@ class FirebasePerformanceService { Map? attributes, }) async { if (!_isReady) { + if (kDebugMode) { + debugPrint( + 'Skipping Firebase trace $traceName: initialized=$_initialized, ' + 'collectionEnabled=$_collectionEnabled', + ); + } return null; } @@ -124,6 +130,12 @@ class FirebasePerformanceService { HttpMetric? newHttpMetric(String url, HttpMethod method) { if (!_isReady) { + if (kDebugMode) { + debugPrint( + 'Skipping Firebase HttpMetric for $url: initialized=$_initialized, ' + 'collectionEnabled=$_collectionEnabled', + ); + } return null; } diff --git a/packages/integrations/firebase_services/lib/src/services/firebase_remote_config_service.dart b/packages/integrations/firebase_services/lib/src/services/firebase_remote_config_service.dart index f8827e0..8be946f 100644 --- a/packages/integrations/firebase_services/lib/src/services/firebase_remote_config_service.dart +++ b/packages/integrations/firebase_services/lib/src/services/firebase_remote_config_service.dart @@ -18,6 +18,8 @@ class FirebaseRemoteConfigService { final FirebaseRemoteConfig _remoteConfig; bool _initialized = false; + Duration _fetchTimeout = const Duration(seconds: 10); + Duration _minimumFetchInterval = const Duration(hours: 12); bool get isInitialized => _initialized; @@ -38,6 +40,9 @@ class FirebaseRemoteConfigService { } try { + _fetchTimeout = fetchTimeout; + _minimumFetchInterval = minimumFetchInterval; + await _remoteConfig.setConfigSettings( RemoteConfigSettings( fetchTimeout: fetchTimeout, @@ -85,6 +90,39 @@ class FirebaseRemoteConfigService { } } + Future refreshForced() async { + if (!_initialized) { + return false; + } + + try { + await _remoteConfig.setConfigSettings( + RemoteConfigSettings( + fetchTimeout: _fetchTimeout, + minimumFetchInterval: Duration.zero, + ), + ); + + return await _remoteConfig.fetchAndActivate(); + } catch (error) { + if (kDebugMode) { + debugPrint('FirebaseRemoteConfig forced refresh failed: $error'); + } + return false; + } finally { + try { + await _remoteConfig.setConfigSettings( + RemoteConfigSettings( + fetchTimeout: _fetchTimeout, + minimumFetchInterval: _minimumFetchInterval, + ), + ); + } catch (_) { + // Ignore reset failure. + } + } + } + bool getBool(String key, {bool fallback = false}) { if (!_initialized) { return fallback; From 701fe62916ba14b5a7cffbb1e33209b869bc5d11 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 20:06:47 +0630 Subject: [PATCH 09/31] feat: Implement client-side synchronization infrastructure including outbox, sync state, DAO, and orchestrator with new database tables and schema migration. --- apps/mobile/lib/core/providers/providers.dart | 1 + .../lib/core/providers/sync_providers.dart | 25 +++ .../lib/core/sync/sync_backend_client.dart | 24 +++ apps/mobile/lib/core/sync/sync_contract.dart | 133 +++++++++++++ .../lib/core/sync/sync_orchestrator.dart | 181 ++++++++++++++++++ .../database/lib/src/app_database.dart | 33 +++- .../database/lib/src/daos/daos.dart | 1 + .../database/lib/src/daos/sync_dao.dart | 117 +++++++++++ .../lib/src/tables/sync_outbox_table.dart | 18 ++ .../lib/src/tables/sync_state_table.dart | 16 ++ .../database/lib/src/tables/tables.dart | 2 + 11 files changed, 548 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/lib/core/providers/sync_providers.dart create mode 100644 apps/mobile/lib/core/sync/sync_backend_client.dart create mode 100644 apps/mobile/lib/core/sync/sync_contract.dart create mode 100644 apps/mobile/lib/core/sync/sync_orchestrator.dart create mode 100644 packages/integrations/database/lib/src/daos/sync_dao.dart create mode 100644 packages/integrations/database/lib/src/tables/sync_outbox_table.dart create mode 100644 packages/integrations/database/lib/src/tables/sync_state_table.dart diff --git a/apps/mobile/lib/core/providers/providers.dart b/apps/mobile/lib/core/providers/providers.dart index b6b14aa..8230fa9 100644 --- a/apps/mobile/lib/core/providers/providers.dart +++ b/apps/mobile/lib/core/providers/providers.dart @@ -8,5 +8,6 @@ export 'theme_provider.dart'; export 'items_view_mode_provider.dart'; export 'collections_view_mode_provider.dart'; export 'locale_provider.dart'; +export 'sync_providers.dart'; final onboardingCompleteProvider = Provider((ref) => false); diff --git a/apps/mobile/lib/core/providers/sync_providers.dart b/apps/mobile/lib/core/providers/sync_providers.dart new file mode 100644 index 0000000..05b8520 --- /dev/null +++ b/apps/mobile/lib/core/providers/sync_providers.dart @@ -0,0 +1,25 @@ +import 'package:collection_tracker/core/providers/database_providers.dart'; +import 'package:collection_tracker/core/sync/sync_backend_client.dart'; +import 'package:collection_tracker/core/sync/sync_orchestrator.dart'; +import 'package:database/database.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final syncDaoProvider = Provider((ref) { + final database = ref.watch(appDatabaseProvider); + return database.syncDao; +}); + +final syncBackendClientProvider = Provider((ref) { + return const NoopSyncBackendClient(); +}); + +final syncOrchestratorProvider = Provider((ref) { + final dao = ref.watch(syncDaoProvider); + final backendClient = ref.watch(syncBackendClientProvider); + return SyncOrchestrator(syncDao: dao, backendClient: backendClient); +}); + +final syncOutboxCountProvider = StreamProvider((ref) { + final dao = ref.watch(syncDaoProvider); + return dao.watchPendingOperationCount(); +}); diff --git a/apps/mobile/lib/core/sync/sync_backend_client.dart b/apps/mobile/lib/core/sync/sync_backend_client.dart new file mode 100644 index 0000000..3da1fe4 --- /dev/null +++ b/apps/mobile/lib/core/sync/sync_backend_client.dart @@ -0,0 +1,24 @@ +import 'sync_contract.dart'; + +abstract class SyncBackendClient { + Future getCapabilities(); + Future sync(SyncRequestPayload request); +} + +class NoopSyncBackendClient implements SyncBackendClient { + const NoopSyncBackendClient(); + + @override + Future getCapabilities() async { + throw UnsupportedError( + 'Backend sync is not configured yet. Provide a concrete SyncBackendClient.', + ); + } + + @override + Future sync(SyncRequestPayload request) async { + throw UnsupportedError( + 'Backend sync is not configured yet. Provide a concrete SyncBackendClient.', + ); + } +} diff --git a/apps/mobile/lib/core/sync/sync_contract.dart b/apps/mobile/lib/core/sync/sync_contract.dart new file mode 100644 index 0000000..4a6dfad --- /dev/null +++ b/apps/mobile/lib/core/sync/sync_contract.dart @@ -0,0 +1,133 @@ +class SyncChangesPayload { + const SyncChangesPayload({ + this.collections = const [], + this.items = const [], + this.tags = const [], + }); + + final List> collections; + final List> items; + final List> tags; + + bool get isEmpty => collections.isEmpty && items.isEmpty && tags.isEmpty; + + int get totalCount => collections.length + items.length + tags.length; + + Map toJson() { + return {'collections': collections, 'items': items, 'tags': tags}; + } +} + +class SyncRequestPayload { + const SyncRequestPayload({ + required this.deviceId, + this.schemaVersion = 'v1', + this.clientRequestId, + this.lastSyncAt, + this.changes, + }); + + final String schemaVersion; + final String? clientRequestId; + final String deviceId; + final DateTime? lastSyncAt; + final SyncChangesPayload? changes; + + Map toJson() { + final map = { + 'schemaVersion': schemaVersion, + 'deviceId': deviceId, + }; + if (clientRequestId != null) { + map['clientRequestId'] = clientRequestId; + } + if (lastSyncAt != null) { + map['lastSyncAt'] = lastSyncAt!.toUtc().toIso8601String(); + } + if (changes != null && !changes!.isEmpty) { + map['changes'] = changes!.toJson(); + } + return map; + } +} + +class SyncCapabilities { + const SyncCapabilities({ + required this.apiVersion, + required this.supportedModes, + required this.maxBatchSize, + required this.conflictStrategy, + required this.acceptedSchemaVersions, + }); + + final String apiVersion; + final List supportedModes; + final int maxBatchSize; + final String conflictStrategy; + final List acceptedSchemaVersions; + + factory SyncCapabilities.fromJson(Map json) { + return SyncCapabilities( + apiVersion: json['apiVersion'] as String? ?? 'v1', + supportedModes: _toStringList(json['supportedModes']), + maxBatchSize: (json['maxBatchSize'] as num?)?.toInt() ?? 1000, + conflictStrategy: + json['conflictStrategy'] as String? ?? 'last_write_wins', + acceptedSchemaVersions: _toStringList(json['acceptedSchemaVersions']), + ); + } + + static List _toStringList(Object? raw) { + if (raw is! List) return const []; + return raw.whereType().toList(growable: false); + } +} + +class SyncResponsePayload { + const SyncResponsePayload({ + required this.lastSyncAt, + required this.serverChanges, + required this.conflicts, + required this.syncedCollections, + required this.syncedItems, + required this.syncedTags, + required this.conflictsResolved, + }); + + final DateTime lastSyncAt; + final SyncChangesPayload serverChanges; + final List> conflicts; + final int syncedCollections; + final int syncedItems; + final int syncedTags; + final int conflictsResolved; + + factory SyncResponsePayload.fromJson(Map json) { + final serverChangesJson = + (json['serverChanges'] as Map?)?.cast() ?? + const {}; + return SyncResponsePayload( + lastSyncAt: + DateTime.tryParse(json['lastSyncAt'] as String? ?? '') ?? + DateTime.now().toUtc(), + serverChanges: SyncChangesPayload( + collections: _toJsonMapList(serverChangesJson['collections']), + items: _toJsonMapList(serverChangesJson['items']), + tags: _toJsonMapList(serverChangesJson['tags']), + ), + conflicts: _toJsonMapList(json['conflicts']), + syncedCollections: (json['syncedCollections'] as num?)?.toInt() ?? 0, + syncedItems: (json['syncedItems'] as num?)?.toInt() ?? 0, + syncedTags: (json['syncedTags'] as num?)?.toInt() ?? 0, + conflictsResolved: (json['conflictsResolved'] as num?)?.toInt() ?? 0, + ); + } + + static List> _toJsonMapList(Object? raw) { + if (raw is! List) return const []; + return raw + .whereType() + .map((entry) => entry.cast()) + .toList(growable: false); + } +} diff --git a/apps/mobile/lib/core/sync/sync_orchestrator.dart b/apps/mobile/lib/core/sync/sync_orchestrator.dart new file mode 100644 index 0000000..ae8a860 --- /dev/null +++ b/apps/mobile/lib/core/sync/sync_orchestrator.dart @@ -0,0 +1,181 @@ +import 'dart:convert'; + +import 'package:database/database.dart'; +import 'package:uuid/uuid.dart'; + +import 'sync_backend_client.dart'; +import 'sync_contract.dart'; + +enum SyncEntityType { collection, item, tag } + +enum SyncOperationType { upsert, delete } + +class SyncAttemptResult { + const SyncAttemptResult({ + required this.executed, + required this.success, + required this.message, + this.error, + }); + + final bool executed; + final bool success; + final String message; + final Object? error; +} + +class SyncOrchestrator { + SyncOrchestrator({ + required SyncDao syncDao, + required SyncBackendClient backendClient, + Uuid? uuid, + int maxBatchSize = 1000, + }) : _syncDao = syncDao, + _backendClient = backendClient, + _uuid = uuid ?? const Uuid(), + _maxBatchSize = maxBatchSize; + + final SyncDao _syncDao; + final SyncBackendClient _backendClient; + final Uuid _uuid; + final int _maxBatchSize; + + Stream watchPendingOperationCount() { + return _syncDao.watchPendingOperationCount(); + } + + Future getPendingOperationCount() async { + final pending = await _syncDao.getPendingOperations(limit: _maxBatchSize); + return pending.length; + } + + Future enqueueOperation({ + required SyncEntityType entityType, + required String entityId, + required SyncOperationType operationType, + required Map payload, + String? operationId, + }) async { + final id = operationId ?? _uuid.v4(); + final now = DateTime.now().toUtc().toIso8601String(); + + final normalizedPayload = { + ...payload, + 'id': payload['id'] ?? entityId, + 'updatedAt': payload['updatedAt'] ?? now, + 'isDeleted': operationType == SyncOperationType.delete, + }; + + await _syncDao.enqueueOperation( + id: id, + entityType: entityType.name, + entityId: entityId, + operationType: operationType.name, + payload: jsonEncode(normalizedPayload), + ); + + return id; + } + + Future syncNow({ + required String deviceId, + bool forceFullSync = false, + }) async { + if (_backendClient is NoopSyncBackendClient) { + return const SyncAttemptResult( + executed: false, + success: false, + message: + 'Sync backend is not configured yet. Attach a concrete SyncBackendClient first.', + ); + } + + final state = await _syncDao.getSyncState(); + final pending = await _syncDao.getPendingOperations(limit: _maxBatchSize); + + await _syncDao.upsertSyncState(lastAttemptedSyncAt: DateTime.now()); + + if (pending.isEmpty && !forceFullSync) { + await _syncDao.upsertSyncState(consecutiveFailures: 0); + return const SyncAttemptResult( + executed: false, + success: true, + message: 'No pending operations to sync.', + ); + } + + final changes = _buildChangesPayload(pending); + + try { + final response = await _backendClient.sync( + SyncRequestPayload( + deviceId: deviceId, + clientRequestId: _uuid.v4(), + lastSyncAt: forceFullSync ? null : state?.lastSuccessfulSyncAt, + changes: changes.isEmpty ? null : changes, + ), + ); + + for (final op in pending) { + await _syncDao.markOperationSynced(op.id); + } + + await _syncDao.upsertSyncState( + lastSuccessfulSyncAt: response.lastSyncAt.toUtc(), + consecutiveFailures: 0, + ); + + return SyncAttemptResult( + executed: true, + success: true, + message: + 'Sync completed: ${response.syncedCollections} collections, ' + '${response.syncedItems} items, ${response.syncedTags} tags.', + ); + } catch (error) { + final errorText = '$error'; + for (final op in pending) { + await _syncDao.markOperationFailed(op.id, errorText); + } + + await _syncDao.upsertSyncState( + consecutiveFailures: (state?.consecutiveFailures ?? 0) + 1, + ); + + return SyncAttemptResult( + executed: true, + success: false, + message: 'Sync failed.', + error: error, + ); + } + } + + SyncChangesPayload _buildChangesPayload(List pending) { + final collections = >[]; + final items = >[]; + final tags = >[]; + + for (final op in pending) { + final decoded = jsonDecode(op.payload); + if (decoded is! Map) { + continue; + } + + final payload = decoded.cast(); + if (op.entityType == SyncEntityType.collection.name) { + collections.add(payload); + } else if (op.entityType == SyncEntityType.item.name) { + items.add(payload); + } else if (op.entityType == SyncEntityType.tag.name) { + tags.add(payload); + } + } + + return SyncChangesPayload( + collections: collections, + items: items, + tags: tags, + ); + } +} diff --git a/packages/integrations/database/lib/src/app_database.dart b/packages/integrations/database/lib/src/app_database.dart index 21f1eaa..78c5777 100644 --- a/packages/integrations/database/lib/src/app_database.dart +++ b/packages/integrations/database/lib/src/app_database.dart @@ -7,14 +7,22 @@ import 'package:path_provider/path_provider.dart'; part 'app_database.g.dart'; @DriftDatabase( - tables: [Collections, Items, Tags, ItemTags, ItemPriceHistory], - daos: [CollectionDao, ItemDao], + tables: [ + Collections, + Items, + Tags, + ItemTags, + ItemPriceHistory, + SyncOutbox, + SyncState, + ], + daos: [CollectionDao, ItemDao, SyncDao], ) class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 5; + int get schemaVersion => 6; @override MigrationStrategy get migration { @@ -31,6 +39,13 @@ class AppDatabase extends _$AppDatabase { 'CREATE INDEX idx_item_price_history_item_time ' 'ON item_price_history(item_id, recorded_at DESC);', ); + await customStatement( + 'CREATE INDEX idx_sync_outbox_created_at ON sync_outbox(created_at);', + ); + await customStatement( + 'CREATE INDEX idx_sync_outbox_entity ' + 'ON sync_outbox(entity_type, entity_id);', + ); }, onUpgrade: (Migrator m, int from, int to) async { if (from < 2) { @@ -53,6 +68,18 @@ class AppDatabase extends _$AppDatabase { 'ON item_price_history(item_id, recorded_at DESC);', ); } + + if (from < 6) { + await m.createTable(syncOutbox); + await m.createTable(syncState); + await customStatement( + 'CREATE INDEX idx_sync_outbox_created_at ON sync_outbox(created_at);', + ); + await customStatement( + 'CREATE INDEX idx_sync_outbox_entity ' + 'ON sync_outbox(entity_type, entity_id);', + ); + } }, beforeOpen: (details) async { await customStatement('PRAGMA foreign_keys = ON'); diff --git a/packages/integrations/database/lib/src/daos/daos.dart b/packages/integrations/database/lib/src/daos/daos.dart index 4bf6e96..57b697c 100644 --- a/packages/integrations/database/lib/src/daos/daos.dart +++ b/packages/integrations/database/lib/src/daos/daos.dart @@ -1,2 +1,3 @@ export 'collection_dao.dart'; export 'item_dao.dart'; +export 'sync_dao.dart'; diff --git a/packages/integrations/database/lib/src/daos/sync_dao.dart b/packages/integrations/database/lib/src/daos/sync_dao.dart new file mode 100644 index 0000000..628453f --- /dev/null +++ b/packages/integrations/database/lib/src/daos/sync_dao.dart @@ -0,0 +1,117 @@ +import 'package:database/src/app_database.dart'; +import 'package:database/src/tables/tables.dart'; +import 'package:drift/drift.dart'; + +part 'sync_dao.g.dart'; + +@DriftAccessor(tables: [SyncOutbox, SyncState]) +class SyncDao extends DatabaseAccessor with _$SyncDaoMixin { + SyncDao(super.db); + + static const String _primaryStateId = 'primary'; + + Future getSyncState() { + return (select( + syncState, + )..where((tbl) => tbl.id.equals(_primaryStateId))).getSingleOrNull(); + } + + Stream watchSyncState() { + return (select( + syncState, + )..where((tbl) => tbl.id.equals(_primaryStateId))).watchSingleOrNull(); + } + + Future upsertSyncState({ + DateTime? lastSuccessfulSyncAt, + DateTime? lastAttemptedSyncAt, + String? lastRemoteCursor, + int? consecutiveFailures, + }) async { + final current = await getSyncState(); + final now = DateTime.now(); + + await into(syncState).insert( + SyncStateCompanion( + id: const Value(_primaryStateId), + lastSuccessfulSyncAt: Value( + lastSuccessfulSyncAt ?? current?.lastSuccessfulSyncAt, + ), + lastAttemptedSyncAt: Value( + lastAttemptedSyncAt ?? current?.lastAttemptedSyncAt, + ), + lastRemoteCursor: Value(lastRemoteCursor ?? current?.lastRemoteCursor), + consecutiveFailures: Value( + consecutiveFailures ?? current?.consecutiveFailures ?? 0, + ), + updatedAt: Value(now), + ), + mode: InsertMode.insertOrReplace, + ); + } + + Future enqueueOperation({ + required String id, + required String entityType, + required String entityId, + required String operationType, + required String payload, + }) async { + final now = DateTime.now(); + await into(syncOutbox).insert( + SyncOutboxCompanion( + id: Value(id), + entityType: Value(entityType), + entityId: Value(entityId), + operationType: Value(operationType), + payload: Value(payload), + attempts: const Value(0), + lastAttemptAt: const Value.absent(), + lastError: const Value.absent(), + createdAt: Value(now), + updatedAt: Value(now), + ), + mode: InsertMode.insertOrReplace, + ); + } + + Future> getPendingOperations({int limit = 100}) { + return (select(syncOutbox) + ..orderBy([(tbl) => OrderingTerm.asc(tbl.createdAt)]) + ..limit(limit)) + .get(); + } + + Stream watchPendingOperationCount() { + final countExpression = syncOutbox.id.count(); + final query = selectOnly(syncOutbox)..addColumns([countExpression]); + return query.watchSingle().map((row) => row.read(countExpression) ?? 0); + } + + Future markOperationSynced(String id) { + return (delete(syncOutbox)..where((tbl) => tbl.id.equals(id))).go(); + } + + Future markOperationFailed(String id, String error) async { + final existing = await (select( + syncOutbox, + )..where((tbl) => tbl.id.equals(id))).getSingleOrNull(); + + if (existing == null) { + return; + } + + await (update(syncOutbox)..where((tbl) => tbl.id.equals(id))).write( + SyncOutboxCompanion( + attempts: Value(existing.attempts + 1), + lastAttemptAt: Value(DateTime.now()), + lastError: Value(error), + updatedAt: Value(DateTime.now()), + ), + ); + } + + Future clearOutbox() async { + await delete(syncOutbox).go(); + } +} diff --git a/packages/integrations/database/lib/src/tables/sync_outbox_table.dart b/packages/integrations/database/lib/src/tables/sync_outbox_table.dart new file mode 100644 index 0000000..41a36dc --- /dev/null +++ b/packages/integrations/database/lib/src/tables/sync_outbox_table.dart @@ -0,0 +1,18 @@ +import 'package:drift/drift.dart'; + +@DataClassName('SyncOutboxData') +class SyncOutbox extends Table { + TextColumn get id => text()(); // Client-generated operation id (UUID) + TextColumn get entityType => text().withLength(min: 1, max: 32)(); + TextColumn get entityId => text()(); + TextColumn get operationType => text().withLength(min: 1, max: 32)(); + TextColumn get payload => text()(); // JSON payload + IntColumn get attempts => integer().withDefault(const Constant(0))(); + DateTimeColumn get lastAttemptAt => dateTime().nullable()(); + TextColumn get lastError => text().nullable()(); + DateTimeColumn get createdAt => dateTime()(); + DateTimeColumn get updatedAt => dateTime()(); + + @override + Set get primaryKey => {id}; +} diff --git a/packages/integrations/database/lib/src/tables/sync_state_table.dart b/packages/integrations/database/lib/src/tables/sync_state_table.dart new file mode 100644 index 0000000..2f692b6 --- /dev/null +++ b/packages/integrations/database/lib/src/tables/sync_state_table.dart @@ -0,0 +1,16 @@ +import 'package:drift/drift.dart'; + +@DataClassName('SyncStateData') +class SyncState extends Table { + TextColumn get id => + text().withDefault(const Constant('primary'))(); // single-row table + DateTimeColumn get lastSuccessfulSyncAt => dateTime().nullable()(); + DateTimeColumn get lastAttemptedSyncAt => dateTime().nullable()(); + TextColumn get lastRemoteCursor => text().nullable()(); + IntColumn get consecutiveFailures => + integer().withDefault(const Constant(0))(); + DateTimeColumn get updatedAt => dateTime()(); + + @override + Set get primaryKey => {id}; +} diff --git a/packages/integrations/database/lib/src/tables/tables.dart b/packages/integrations/database/lib/src/tables/tables.dart index 669dff1..e158a85 100644 --- a/packages/integrations/database/lib/src/tables/tables.dart +++ b/packages/integrations/database/lib/src/tables/tables.dart @@ -3,3 +3,5 @@ export 'items_table.dart'; export 'tags_table.dart'; export 'item_tags_table.dart'; export 'item_price_history_table.dart'; +export 'sync_outbox_table.dart'; +export 'sync_state_table.dart'; From cc7a3050cc3f81783a86c7a02afe237dcff964ee Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 20:21:52 +0630 Subject: [PATCH 10/31] feat: Implement initial synchronization outbox for items and tags, including authentication token provisioning. --- .../lib/core/providers/data_providers.dart | 7 +- .../lib/core/providers/sync_providers.dart | 97 ++++++++- .../core/sync/dio_sync_backend_client.dart | 101 +++++++++ .../sync/nest_sync_auth_token_provider.dart | 95 +++++++++ .../core/sync/sync_auth_token_provider.dart | 50 +++++ .../collection_repository_impl.dart | 76 ++++++- .../repositories/item_repository_impl.dart | 195 +++++++++++++++++- .../data/lib/src/sync/outbox_sync_writer.dart | 68 ++++++ .../database/lib/src/daos/item_dao.dart | 20 +- packages/integrations/database/pubspec.yaml | 1 + 10 files changed, 703 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/lib/core/sync/dio_sync_backend_client.dart create mode 100644 apps/mobile/lib/core/sync/nest_sync_auth_token_provider.dart create mode 100644 apps/mobile/lib/core/sync/sync_auth_token_provider.dart create mode 100644 packages/core/data/lib/src/sync/outbox_sync_writer.dart diff --git a/apps/mobile/lib/core/providers/data_providers.dart b/apps/mobile/lib/core/providers/data_providers.dart index f1ee81a..106061e 100644 --- a/apps/mobile/lib/core/providers/data_providers.dart +++ b/apps/mobile/lib/core/providers/data_providers.dart @@ -3,17 +3,20 @@ import 'package:domain/domain.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'database_providers.dart'; +import 'sync_providers.dart'; part 'data_providers.g.dart'; @riverpod CollectionRepository collectionRepository(Ref ref) { final dao = ref.watch(collectionDaoProvider); - return CollectionRepositoryImpl(dao); + final syncDao = ref.watch(syncDaoProvider); + return CollectionRepositoryImpl(dao, syncDao: syncDao); } @riverpod ItemRepository itemRepository(Ref ref) { final dao = ref.watch(itemDaoProvider); - return ItemRepositoryImpl(dao); + final syncDao = ref.watch(syncDaoProvider); + return ItemRepositoryImpl(dao, syncDao: syncDao); } diff --git a/apps/mobile/lib/core/providers/sync_providers.dart b/apps/mobile/lib/core/providers/sync_providers.dart index 05b8520..24c01dd 100644 --- a/apps/mobile/lib/core/providers/sync_providers.dart +++ b/apps/mobile/lib/core/providers/sync_providers.dart @@ -1,16 +1,99 @@ import 'package:collection_tracker/core/providers/database_providers.dart'; +import 'package:collection_tracker/core/sync/dio_sync_backend_client.dart'; +import 'package:collection_tracker/core/sync/nest_sync_auth_token_provider.dart'; +import 'package:collection_tracker/core/sync/sync_auth_token_provider.dart'; import 'package:collection_tracker/core/sync/sync_backend_client.dart'; import 'package:collection_tracker/core/sync/sync_orchestrator.dart'; +import 'package:dio/dio.dart'; import 'package:database/database.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:storage/storage.dart'; final syncDaoProvider = Provider((ref) { final database = ref.watch(appDatabaseProvider); return database.syncDao; }); +final syncApiBaseUrlProvider = Provider((ref) { + return const String.fromEnvironment('SYNC_API_BASE_URL', defaultValue: ''); +}); + +final syncApiPrefixProvider = Provider((ref) { + return const String.fromEnvironment( + 'SYNC_API_PREFIX', + defaultValue: '/api/v1', + ); +}); + +final syncDioProvider = Provider((ref) { + final dio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 20), + headers: const {'Accept': 'application/json'}, + ), + ); + + if (kDebugMode) { + dio.interceptors.add( + LogInterceptor( + requestBody: true, + responseBody: true, + error: true, + logPrint: (obj) => debugPrint('[SyncApi] $obj'), + ), + ); + } + + return dio; +}); + +final syncAuthDioProvider = Provider((ref) { + return Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 20), + headers: const {'Accept': 'application/json'}, + ), + ); +}); + +final syncAuthTokenAdapterProvider = Provider((ref) { + final baseUrl = ref.watch(syncApiBaseUrlProvider); + if (baseUrl.trim().isEmpty) { + return const NoopSyncAuthTokenProvider(); + } + + final apiPrefix = ref.watch(syncApiPrefixProvider); + final apiBaseUrl = _joinUrl(baseUrl, apiPrefix); + final authDio = ref.watch(syncAuthDioProvider); + final storage = SecureStorageService.instance; + + return NestSyncAuthTokenProvider( + dio: authDio, + apiBaseUrl: apiBaseUrl, + storage: storage, + ); +}); + final syncBackendClientProvider = Provider((ref) { - return const NoopSyncBackendClient(); + final baseUrl = ref.watch(syncApiBaseUrlProvider); + if (baseUrl.trim().isEmpty) { + return const NoopSyncBackendClient(); + } + + final dio = ref.watch(syncDioProvider); + final apiPrefix = ref.watch(syncApiPrefixProvider); + final tokenProvider = ref.watch(syncAuthTokenAdapterProvider); + + final normalizedBaseUrl = _joinUrl(baseUrl, apiPrefix); + + return DioSyncBackendClient( + dio: dio, + baseUrl: normalizedBaseUrl, + authTokenProvider: tokenProvider, + ); }); final syncOrchestratorProvider = Provider((ref) { @@ -23,3 +106,15 @@ final syncOutboxCountProvider = StreamProvider((ref) { final dao = ref.watch(syncDaoProvider); return dao.watchPendingOperationCount(); }); + +String _joinUrl(String baseUrl, String prefix) { + final base = baseUrl.trim().replaceAll(RegExp(r'/+$'), ''); + final path = prefix.trim(); + + if (path.isEmpty || path == '/') { + return base; + } + + final normalizedPath = path.startsWith('/') ? path : '/$path'; + return '$base$normalizedPath'; +} diff --git a/apps/mobile/lib/core/sync/dio_sync_backend_client.dart b/apps/mobile/lib/core/sync/dio_sync_backend_client.dart new file mode 100644 index 0000000..c29b6ee --- /dev/null +++ b/apps/mobile/lib/core/sync/dio_sync_backend_client.dart @@ -0,0 +1,101 @@ +import 'package:dio/dio.dart'; + +import 'sync_auth_token_provider.dart'; +import 'sync_backend_client.dart'; +import 'sync_contract.dart'; + +class DioSyncBackendClient implements SyncBackendClient { + DioSyncBackendClient({ + required Dio dio, + required String baseUrl, + required SyncAuthTokenProvider authTokenProvider, + String capabilitiesPath = '/sync/capabilities', + String syncPath = '/sync', + }) : _dio = dio, + _baseUrl = _normalizeBaseUrl(baseUrl), + _authTokenProvider = authTokenProvider, + _capabilitiesPath = capabilitiesPath, + _syncPath = syncPath; + + final Dio _dio; + final String _baseUrl; + final SyncAuthTokenProvider _authTokenProvider; + final String _capabilitiesPath; + final String _syncPath; + + @override + Future getCapabilities() async { + final response = await _requestWithAuthRetry( + () => _dio.get>('$_baseUrl$_capabilitiesPath'), + ); + final data = _asJsonMap(response.data); + return SyncCapabilities.fromJson(data); + } + + @override + Future sync(SyncRequestPayload request) async { + final response = await _requestWithAuthRetry( + () => _dio.post>( + '$_baseUrl$_syncPath', + data: request.toJson(), + ), + ); + final data = _asJsonMap(response.data); + return SyncResponsePayload.fromJson(data); + } + + Future> _requestWithAuthRetry( + Future> Function() runRequest, + ) async { + await _applyAccessToken(); + + try { + return await runRequest(); + } on DioException catch (error) { + if (!_isUnauthorized(error)) { + rethrow; + } + + final refreshedToken = await _authTokenProvider.refreshAccessToken(); + if (refreshedToken == null || refreshedToken.isEmpty) { + rethrow; + } + + _dio.options.headers['Authorization'] = 'Bearer $refreshedToken'; + return runRequest(); + } + } + + Future _applyAccessToken() async { + final token = await _authTokenProvider.readAccessToken(); + if (token == null || token.isEmpty) { + _dio.options.headers.remove('Authorization'); + return; + } + _dio.options.headers['Authorization'] = 'Bearer $token'; + } + + bool _isUnauthorized(DioException error) { + return error.response?.statusCode == 401; + } + + static Map _asJsonMap(Object? raw) { + if (raw is Map) { + return raw; + } + if (raw is Map) { + return raw.cast(); + } + return const {}; + } + + static String _normalizeBaseUrl(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return ''; + } + return trimmed.endsWith('/') + ? trimmed.substring(0, trimmed.length - 1) + : trimmed; + } +} diff --git a/apps/mobile/lib/core/sync/nest_sync_auth_token_provider.dart b/apps/mobile/lib/core/sync/nest_sync_auth_token_provider.dart new file mode 100644 index 0000000..c185e59 --- /dev/null +++ b/apps/mobile/lib/core/sync/nest_sync_auth_token_provider.dart @@ -0,0 +1,95 @@ +import 'package:dio/dio.dart'; +import 'package:storage/storage.dart'; + +import 'sync_auth_token_provider.dart'; + +class NestSyncAuthTokenProvider implements SyncAuthTokenProvider { + NestSyncAuthTokenProvider({ + required Dio dio, + required String apiBaseUrl, + required SecureStorageService storage, + this.refreshPath = '/auth/refresh', + this.accessTokenKey = 'sync_access_token', + this.refreshTokenKey = 'sync_refresh_token', + this.deviceIdKey = 'sync_device_id', + }) : _dio = dio, + _apiBaseUrl = _normalizeBaseUrl(apiBaseUrl), + _storage = storage; + + final Dio _dio; + final String _apiBaseUrl; + final SecureStorageService _storage; + + final String refreshPath; + final String accessTokenKey; + final String refreshTokenKey; + final String deviceIdKey; + + @override + Future readAccessToken() { + return _storage.get(accessTokenKey); + } + + @override + Future refreshAccessToken() async { + final refreshToken = await _storage.get(refreshTokenKey); + final deviceId = await _storage.get(deviceIdKey); + + if (refreshToken == null || + refreshToken.isEmpty || + deviceId == null || + deviceId.isEmpty || + _apiBaseUrl.isEmpty) { + return null; + } + + final response = await _dio.post>( + '$_apiBaseUrl$refreshPath', + data: { + 'refreshToken': refreshToken, + 'deviceId': deviceId, + }, + ); + + final data = _asJsonMap(response.data); + final newAccessToken = data['accessToken'] as String?; + final newRefreshToken = data['refreshToken'] as String?; + + if (newAccessToken == null || newAccessToken.isEmpty) { + return null; + } + + await _storage.save(accessTokenKey, newAccessToken); + if (newRefreshToken != null && newRefreshToken.isNotEmpty) { + await _storage.save(refreshTokenKey, newRefreshToken); + } + + return newAccessToken; + } + + @override + Future clearTokens() async { + await _storage.delete(accessTokenKey); + await _storage.delete(refreshTokenKey); + } + + static String _normalizeBaseUrl(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return ''; + } + return trimmed.endsWith('/') + ? trimmed.substring(0, trimmed.length - 1) + : trimmed; + } + + static Map _asJsonMap(Object? raw) { + if (raw is Map) { + return raw; + } + if (raw is Map) { + return raw.cast(); + } + return const {}; + } +} diff --git a/apps/mobile/lib/core/sync/sync_auth_token_provider.dart b/apps/mobile/lib/core/sync/sync_auth_token_provider.dart new file mode 100644 index 0000000..50a340d --- /dev/null +++ b/apps/mobile/lib/core/sync/sync_auth_token_provider.dart @@ -0,0 +1,50 @@ +import 'package:storage/storage.dart'; + +abstract class SyncAuthTokenProvider { + Future readAccessToken(); + Future refreshAccessToken(); + Future clearTokens(); +} + +class NoopSyncAuthTokenProvider implements SyncAuthTokenProvider { + const NoopSyncAuthTokenProvider(); + + @override + Future clearTokens() async {} + + @override + Future readAccessToken() async => null; + + @override + Future refreshAccessToken() async => null; +} + +class SecureStorageSyncAuthTokenProvider implements SyncAuthTokenProvider { + SecureStorageSyncAuthTokenProvider({ + required SecureStorageService storage, + this.accessTokenKey = 'sync_access_token', + this.refreshTokenKey = 'sync_refresh_token', + }) : _storage = storage; + + final SecureStorageService _storage; + final String accessTokenKey; + final String refreshTokenKey; + + @override + Future readAccessToken() { + return _storage.get(accessTokenKey); + } + + @override + Future refreshAccessToken() async { + // Placeholder for future auth exchange flow. + // For now we return currently stored access token (if any). + return _storage.get(accessTokenKey); + } + + @override + Future clearTokens() async { + await _storage.delete(accessTokenKey); + await _storage.delete(refreshTokenKey); + } +} diff --git a/packages/core/data/lib/src/repositories/collection_repository_impl.dart b/packages/core/data/lib/src/repositories/collection_repository_impl.dart index dfa7063..8704240 100644 --- a/packages/core/data/lib/src/repositories/collection_repository_impl.dart +++ b/packages/core/data/lib/src/repositories/collection_repository_impl.dart @@ -2,10 +2,14 @@ import 'package:database/database.dart'; import 'package:domain/domain.dart'; import 'package:fpdart/fpdart.dart'; +import '../sync/outbox_sync_writer.dart'; + class CollectionRepositoryImpl implements CollectionRepository { final CollectionDao _dao; + final SyncOutboxWriter? _syncOutboxWriter; - CollectionRepositoryImpl(this._dao); + CollectionRepositoryImpl(this._dao, {SyncDao? syncDao}) + : _syncOutboxWriter = syncDao != null ? SyncOutboxWriter(syncDao) : null; @override Future>> getCollections() async { @@ -53,6 +57,7 @@ class CollectionRepositoryImpl implements CollectionRepository { try { final companion = _mapToCompanion(collection); await _dao.insertCollection(companion); + await _queueCollectionUpsert(collection); return Right(collection); } catch (e, stack) { return Left( @@ -79,6 +84,7 @@ class CollectionRepositoryImpl implements CollectionRepository { ), ); } + await _queueCollectionUpsert(collection); return Right(collection); } catch (e, stack) { return Left( @@ -93,7 +99,11 @@ class CollectionRepositoryImpl implements CollectionRepository { @override Future> deleteCollection(String id) async { try { + final existing = await _dao.getCollectionById(id); await _dao.deleteCollection(id); + if (existing != null) { + await _queueCollectionDelete(_mapToEntity(existing)); + } return const Right(null); } catch (e, stack) { return Left( @@ -147,4 +157,68 @@ class CollectionRepositoryImpl implements CollectionRepository { updatedAt: Value(entity.updatedAt), ); } + + Future _queueCollectionUpsert(Collection collection) async { + final writer = _syncOutboxWriter; + if (writer == null) { + return; + } + + final payload = _collectionSyncPayload(collection: collection); + try { + await writer.queueUpsert( + entityType: 'collection', + entityId: collection.id, + payload: payload, + ); + } catch (_) { + // Keep local write successful even if sync queue persistence fails. + } + } + + Future _queueCollectionDelete(Collection collection) async { + final writer = _syncOutboxWriter; + if (writer == null) { + return; + } + + final deletedAt = DateTime.now(); + final payload = _collectionSyncPayload( + collection: collection, + isDeleted: true, + deletedAt: deletedAt, + updatedAt: deletedAt, + ); + try { + await writer.queueDelete( + entityType: 'collection', + entityId: collection.id, + payload: payload, + ); + } catch (_) { + // Keep local write successful even if sync queue persistence fails. + } + } + + Map _collectionSyncPayload({ + required Collection collection, + bool isDeleted = false, + DateTime? deletedAt, + DateTime? updatedAt, + }) { + final effectiveUpdatedAt = updatedAt ?? collection.updatedAt; + return { + 'id': collection.id, + 'name': collection.name, + 'type': collection.type.name, + 'description': collection.description, + 'coverImagePath': collection.coverImagePath, + 'itemCount': collection.itemCount, + 'version': 1, + 'isDeleted': isDeleted, + if (deletedAt != null) 'deletedAt': deletedAt.toUtc().toIso8601String(), + 'createdAt': collection.createdAt.toUtc().toIso8601String(), + 'updatedAt': effectiveUpdatedAt.toUtc().toIso8601String(), + }; + } } diff --git a/packages/core/data/lib/src/repositories/item_repository_impl.dart b/packages/core/data/lib/src/repositories/item_repository_impl.dart index e9b649e..36d7e2d 100644 --- a/packages/core/data/lib/src/repositories/item_repository_impl.dart +++ b/packages/core/data/lib/src/repositories/item_repository_impl.dart @@ -4,10 +4,14 @@ import 'package:database/database.dart'; import 'package:domain/domain.dart'; import 'package:fpdart/fpdart.dart'; +import '../sync/outbox_sync_writer.dart'; + class ItemRepositoryImpl implements ItemRepository { final ItemDao _dao; + final SyncOutboxWriter? _syncOutboxWriter; - ItemRepositoryImpl(this._dao); + ItemRepositoryImpl(this._dao, {SyncDao? syncDao}) + : _syncOutboxWriter = syncDao != null ? SyncOutboxWriter(syncDao) : null; @override Future>> getItems({ @@ -70,6 +74,7 @@ class ItemRepositoryImpl implements ItemRepository { try { final companion = _mapToCompanion(item); await _dao.insertItem(companion, tags: item.tags); + await _queueItemUpsert(item); return Right(item); } catch (e, stack) { return Left( @@ -94,6 +99,7 @@ class ItemRepositoryImpl implements ItemRepository { ), ); } + await _queueItemUpsert(item); return Right(item); } catch (e, stack) { return Left( @@ -108,7 +114,11 @@ class ItemRepositoryImpl implements ItemRepository { @override Future> deleteItem(String id) async { try { + final existing = await _dao.getItemWithTags(id); await _dao.deleteItem(id); + if (existing != null) { + await _queueItemDelete(_mapToEntity(existing.$1, existing.$2)); + } return const Right(null); } catch (e, stack) { return Left( @@ -236,7 +246,26 @@ class ItemRepositoryImpl implements ItemRepository { required String newName, }) async { try { + final sourceTagBefore = await _dao.getTagByName(oldName.trim()); + final targetTagBefore = await _dao.getTagByName(newName.trim()); + await _dao.renameTag(oldName: oldName, newName: newName); + + if (sourceTagBefore != null) { + if (targetTagBefore == null) { + final renamedTag = await _dao.getTagByName(newName.trim()); + if (renamedTag != null) { + await _queueTagUpsert(renamedTag); + } + } else { + final mergedTarget = await _dao.getTagByName(newName.trim()); + if (mergedTarget != null) { + await _queueTagUpsert(mergedTarget); + } + await _queueTagDelete(sourceTagBefore); + } + } + return const Right(null); } catch (e, stack) { return Left( @@ -254,7 +283,21 @@ class ItemRepositoryImpl implements ItemRepository { required String targetName, }) async { try { + final sourceTag = await _dao.getTagByName(sourceName.trim()); + final targetTag = await _dao.getTagByName(targetName.trim()); + await _dao.mergeTags(sourceName: sourceName, targetName: targetName); + + if (targetTag != null) { + final mergedTarget = await _dao.getTagByName(targetName.trim()); + if (mergedTarget != null) { + await _queueTagUpsert(mergedTarget); + } + } + if (sourceTag != null) { + await _queueTagDelete(sourceTag); + } + return const Right(null); } catch (e, stack) { return Left( @@ -269,7 +312,11 @@ class ItemRepositoryImpl implements ItemRepository { @override Future> deleteTag(String tagName) async { try { + final existingTag = await _dao.getTagByName(tagName.trim()); await _dao.deleteTagByName(tagName); + if (existingTag != null) { + await _queueTagDelete(existingTag); + } return const Right(null); } catch (e, stack) { return Left( @@ -340,4 +387,150 @@ class ItemRepositoryImpl implements ItemRepository { updatedAt: Value(entity.updatedAt), ); } + + Future _queueItemUpsert(Item item) async { + final writer = _syncOutboxWriter; + if (writer == null) { + return; + } + + try { + final tagRecords = await _dao.getTagsByNames(item.tags); + for (final tag in tagRecords) { + await _queueTagUpsert(tag); + } + + final payload = _itemSyncPayload( + item: item, + tagIds: tagRecords.map((tag) => tag.id).toList(), + ); + await writer.queueUpsert( + entityType: 'item', + entityId: item.id, + payload: payload, + ); + } catch (_) { + // Keep local write successful even if sync queue persistence fails. + } + } + + Future _queueItemDelete(Item item) async { + final writer = _syncOutboxWriter; + if (writer == null) { + return; + } + + try { + final deletedAt = DateTime.now(); + final payload = _itemSyncPayload( + item: item, + tagIds: const [], + isDeleted: true, + deletedAt: deletedAt, + updatedAt: deletedAt, + ); + await writer.queueDelete( + entityType: 'item', + entityId: item.id, + payload: payload, + ); + } catch (_) { + // Keep local write successful even if sync queue persistence fails. + } + } + + Future _queueTagUpsert(TagData tag) async { + final writer = _syncOutboxWriter; + if (writer == null) { + return; + } + + try { + await writer.queueUpsert( + entityType: 'tag', + entityId: tag.id, + payload: _tagSyncPayload(tag: tag), + ); + } catch (_) { + // Keep local write successful even if sync queue persistence fails. + } + } + + Future _queueTagDelete(TagData tag) async { + final writer = _syncOutboxWriter; + if (writer == null) { + return; + } + + try { + final deletedAt = DateTime.now(); + await writer.queueDelete( + entityType: 'tag', + entityId: tag.id, + payload: _tagSyncPayload( + tag: tag, + isDeleted: true, + deletedAt: deletedAt, + updatedAt: deletedAt, + ), + ); + } catch (_) { + // Keep local write successful even if sync queue persistence fails. + } + } + + Map _itemSyncPayload({ + required Item item, + required List tagIds, + bool isDeleted = false, + DateTime? deletedAt, + DateTime? updatedAt, + }) { + final effectiveUpdatedAt = updatedAt ?? item.updatedAt; + return { + 'id': item.id, + 'collectionId': item.collectionId, + 'title': item.title, + 'barcode': item.barcode, + 'coverImageUrl': item.coverImageUrl, + 'coverImagePath': item.coverImagePath, + 'description': item.description, + 'notes': item.notes, + 'metadata': item.metadata != null ? jsonEncode(item.metadata) : null, + 'condition': item.condition?.name, + 'purchasePrice': item.purchasePrice, + 'purchaseDate': item.purchaseDate?.toUtc().toIso8601String(), + 'currentValue': item.currentValue, + 'location': item.location, + 'isFavorite': item.isFavorite, + 'isWishlist': item.isWishlist, + 'sortOrder': item.sortOrder, + 'quantity': item.quantity, + 'version': 1, + 'isDeleted': isDeleted, + if (deletedAt != null) 'deletedAt': deletedAt.toUtc().toIso8601String(), + 'createdAt': item.createdAt.toUtc().toIso8601String(), + 'updatedAt': effectiveUpdatedAt.toUtc().toIso8601String(), + 'tagIds': tagIds, + }; + } + + Map _tagSyncPayload({ + required TagData tag, + bool isDeleted = false, + DateTime? deletedAt, + DateTime? updatedAt, + }) { + final effectiveUpdatedAt = updatedAt ?? tag.updatedAt; + return { + 'id': tag.id, + 'name': tag.name, + 'color': tag.color, + 'version': 1, + 'isDeleted': isDeleted, + if (deletedAt != null) 'deletedAt': deletedAt.toUtc().toIso8601String(), + 'createdAt': tag.createdAt.toUtc().toIso8601String(), + 'updatedAt': effectiveUpdatedAt.toUtc().toIso8601String(), + }; + } } diff --git a/packages/core/data/lib/src/sync/outbox_sync_writer.dart b/packages/core/data/lib/src/sync/outbox_sync_writer.dart new file mode 100644 index 0000000..22c8a46 --- /dev/null +++ b/packages/core/data/lib/src/sync/outbox_sync_writer.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; + +import 'package:database/database.dart'; + +class SyncOutboxWriter { + SyncOutboxWriter(this._syncDao); + + final SyncDao _syncDao; + + Future queueUpsert({ + required String entityType, + required String entityId, + required Map payload, + }) { + return _queue( + entityType: entityType, + entityId: entityId, + operationType: _upsertOp, + payload: payload, + ); + } + + Future queueDelete({ + required String entityType, + required String entityId, + required Map payload, + }) { + return _queue( + entityType: entityType, + entityId: entityId, + operationType: _deleteOp, + payload: payload, + ); + } + + Future _queue({ + required String entityType, + required String entityId, + required String operationType, + required Map payload, + }) async { + final oppositeOperation = operationType == _deleteOp + ? _upsertOp + : _deleteOp; + + await _syncDao.markOperationSynced( + _operationId(entityType, entityId, oppositeOperation), + ); + await _syncDao.enqueueOperation( + id: _operationId(entityType, entityId, operationType), + entityType: entityType, + entityId: entityId, + operationType: operationType, + payload: jsonEncode(payload), + ); + } + + String _operationId( + String entityType, + String entityId, + String operationType, + ) { + return '$entityType:$entityId:$operationType'; + } + + static const String _upsertOp = 'upsert'; + static const String _deleteOp = 'delete'; +} diff --git a/packages/integrations/database/lib/src/daos/item_dao.dart b/packages/integrations/database/lib/src/daos/item_dao.dart index 11aca3e..5068819 100644 --- a/packages/integrations/database/lib/src/daos/item_dao.dart +++ b/packages/integrations/database/lib/src/daos/item_dao.dart @@ -1,6 +1,7 @@ import 'package:database/src/app_database.dart'; import 'package:database/src/tables/tables.dart'; import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; part 'item_dao.g.dart'; @@ -8,6 +9,8 @@ part 'item_dao.g.dart'; class ItemDao extends DatabaseAccessor with _$ItemDaoMixin { ItemDao(super.db); + static const Uuid _uuid = Uuid(); + // Get all tags with usage count Future> getTagsWithUsage() async { final rows = await customSelect( @@ -211,6 +214,20 @@ class ItemDao extends DatabaseAccessor with _$ItemDaoMixin { return result; } + Future getTagByName(String tagName) { + return (select( + tags, + )..where((tbl) => tbl.name.equals(tagName))).getSingleOrNull(); + } + + Future> getTagsByNames(List tagNames) async { + if (tagNames.isEmpty) { + return const []; + } + + return (select(tags)..where((tbl) => tbl.name.isIn(tagNames))).get(); + } + // Watch tags for an item Stream> watchTagsForItem(String itemId) { final query = select(itemTags).join([ @@ -516,8 +533,7 @@ class ItemDao extends DatabaseAccessor with _$ItemDaoMixin { if (existingTag != null) { tagIds.add(existingTag.id); } else { - final newTagId = DateTime.now().microsecondsSinceEpoch - .toString(); // Simple ID generation + final newTagId = _uuid.v4(); await into(tags).insert( TagsCompanion.insert( id: newTagId, diff --git a/packages/integrations/database/pubspec.yaml b/packages/integrations/database/pubspec.yaml index 66f069d..cca08b9 100644 --- a/packages/integrations/database/pubspec.yaml +++ b/packages/integrations/database/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: drift: ^2.30.1 drift_flutter: ^0.2.8 path_provider: ^2.1.5 + uuid: ^4.5.2 dev_dependencies: flutter_test: From bc55cb05603c2ffc68655d07e7d9163629b2e20c Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 20:48:16 +0630 Subject: [PATCH 11/31] feat: Extract sync backend client into a new `sync_api` package and introduce a Firebase Remote Config feature flag for sync. --- .../firebase_services_bootstrap.dart | 12 +- .../firebase/firebase_runtime_config.dart | 3 + .../lib/core/providers/data_providers.dart | 8 +- .../lib/core/providers/sync_providers.dart | 129 +++++++++++++++--- .../lib/core/sync/sync_backend_client.dart | 24 ---- .../lib/core/sync/sync_orchestrator.dart | 17 ++- .../presentation/views/settings_screen.dart | 12 ++ apps/mobile/pubspec.yaml | 2 + .../sync_api/.flutter-plugins-dependencies | 1 + packages/integrations/sync_api/README.md | 8 ++ .../sync_api/analysis_options.yaml | 1 + .../auth}/nest_sync_auth_token_provider.dart | 15 ++ .../src/auth}/sync_auth_token_provider.dart | 15 ++ .../src/client}/dio_sync_backend_client.dart | 34 ++++- .../lib/src/client/sync_backend_client.dart | 47 +++++++ .../src/exceptions/sync_api_exceptions.dart | 15 ++ .../lib/src/models}/sync_contract.dart | 0 .../integrations/sync_api/lib/sync_api.dart | 6 + packages/integrations/sync_api/pubspec.yaml | 21 +++ pubspec.yaml | 1 + 20 files changed, 313 insertions(+), 58 deletions(-) delete mode 100644 apps/mobile/lib/core/sync/sync_backend_client.dart create mode 100644 packages/integrations/sync_api/.flutter-plugins-dependencies create mode 100644 packages/integrations/sync_api/README.md create mode 100644 packages/integrations/sync_api/analysis_options.yaml rename {apps/mobile/lib/core/sync => packages/integrations/sync_api/lib/src/auth}/nest_sync_auth_token_provider.dart (84%) rename {apps/mobile/lib/core/sync => packages/integrations/sync_api/lib/src/auth}/sync_auth_token_provider.dart (76%) rename {apps/mobile/lib/core/sync => packages/integrations/sync_api/lib/src/client}/dio_sync_backend_client.dart (74%) create mode 100644 packages/integrations/sync_api/lib/src/client/sync_backend_client.dart create mode 100644 packages/integrations/sync_api/lib/src/exceptions/sync_api_exceptions.dart rename {apps/mobile/lib/core/sync => packages/integrations/sync_api/lib/src/models}/sync_contract.dart (100%) create mode 100644 packages/integrations/sync_api/lib/sync_api.dart create mode 100644 packages/integrations/sync_api/pubspec.yaml diff --git a/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart index 4a6f468..adcca0c 100644 --- a/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart +++ b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart @@ -24,6 +24,7 @@ abstract final class FirebaseServicesBootstrap { 'app_crashlytics_collection_enabled'; static const _performanceCollectionEnabledKey = 'app_performance_collection_enabled'; + static const _syncFeatureEnabledKey = 'app_sync_feature_enabled'; static Future initialize() async { final remoteConfigService = FirebaseRemoteConfigService.instance; @@ -33,6 +34,7 @@ abstract final class FirebaseServicesBootstrap { _analyticsCollectionEnabledKey: true, _crashlyticsCollectionEnabledKey: true, _performanceCollectionEnabledKey: true, + _syncFeatureEnabledKey: false, }, minimumFetchInterval: kDebugMode ? const Duration(minutes: 5) @@ -48,7 +50,8 @@ abstract final class FirebaseServicesBootstrap { Logger.info( 'Firebase services initialized (analytics: ${runtimeConfig.analyticsCollectionEnabled}, ' 'crashlytics: ${runtimeConfig.crashlyticsCollectionEnabled}, ' - 'performance: ${runtimeConfig.performanceCollectionEnabled}).', + 'performance: ${runtimeConfig.performanceCollectionEnabled}, ' + 'sync: ${runtimeConfig.syncFeatureEnabled}).', ); return runtimeConfig; @@ -83,7 +86,8 @@ abstract final class FirebaseServicesBootstrap { 'Firebase runtime config refreshed (changed: $didActivateChanges, ' 'analytics: ${runtimeConfig.analyticsCollectionEnabled}, ' 'crashlytics: ${runtimeConfig.crashlyticsCollectionEnabled}, ' - 'performance: ${runtimeConfig.performanceCollectionEnabled}).', + 'performance: ${runtimeConfig.performanceCollectionEnabled}, ' + 'sync: ${runtimeConfig.syncFeatureEnabled}).', ); return FirebaseRuntimeConfigRefreshResult( @@ -109,6 +113,10 @@ abstract final class FirebaseServicesBootstrap { _performanceCollectionEnabledKey, 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 index 48c7260..7b0716e 100644 --- a/apps/mobile/lib/core/firebase/firebase_runtime_config.dart +++ b/apps/mobile/lib/core/firebase/firebase_runtime_config.dart @@ -3,15 +3,18 @@ class FirebaseRuntimeConfig { required this.analyticsCollectionEnabled, required this.crashlyticsCollectionEnabled, required this.performanceCollectionEnabled, + required this.syncFeatureEnabled, }); static const defaults = FirebaseRuntimeConfig( analyticsCollectionEnabled: true, crashlyticsCollectionEnabled: true, performanceCollectionEnabled: true, + syncFeatureEnabled: false, ); final bool analyticsCollectionEnabled; final bool crashlyticsCollectionEnabled; final bool performanceCollectionEnabled; + final bool syncFeatureEnabled; } diff --git a/apps/mobile/lib/core/providers/data_providers.dart b/apps/mobile/lib/core/providers/data_providers.dart index 106061e..1573c1f 100644 --- a/apps/mobile/lib/core/providers/data_providers.dart +++ b/apps/mobile/lib/core/providers/data_providers.dart @@ -10,13 +10,17 @@ part 'data_providers.g.dart'; @riverpod CollectionRepository collectionRepository(Ref ref) { final dao = ref.watch(collectionDaoProvider); - final syncDao = ref.watch(syncDaoProvider); + final syncDao = ref.watch(syncOutboxWritesEnabledProvider) + ? ref.watch(syncDaoProvider) + : null; return CollectionRepositoryImpl(dao, syncDao: syncDao); } @riverpod ItemRepository itemRepository(Ref ref) { final dao = ref.watch(itemDaoProvider); - final syncDao = ref.watch(syncDaoProvider); + final syncDao = ref.watch(syncOutboxWritesEnabledProvider) + ? ref.watch(syncDaoProvider) + : null; return ItemRepositoryImpl(dao, syncDao: syncDao); } diff --git a/apps/mobile/lib/core/providers/sync_providers.dart b/apps/mobile/lib/core/providers/sync_providers.dart index 24c01dd..0a98e8c 100644 --- a/apps/mobile/lib/core/providers/sync_providers.dart +++ b/apps/mobile/lib/core/providers/sync_providers.dart @@ -1,14 +1,44 @@ import 'package:collection_tracker/core/providers/database_providers.dart'; -import 'package:collection_tracker/core/sync/dio_sync_backend_client.dart'; -import 'package:collection_tracker/core/sync/nest_sync_auth_token_provider.dart'; -import 'package:collection_tracker/core/sync/sync_auth_token_provider.dart'; -import 'package:collection_tracker/core/sync/sync_backend_client.dart'; +import 'package:collection_tracker/core/providers/firebase_runtime_config_provider.dart'; import 'package:collection_tracker/core/sync/sync_orchestrator.dart'; -import 'package:dio/dio.dart'; import 'package:database/database.dart'; +import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:storage/storage.dart'; +import 'package:sync_api/sync_api.dart'; + +class SyncTransportConfig { + const SyncTransportConfig({ + required this.featureFlagEnabled, + required this.baseUrl, + required this.apiPrefix, + }); + + final bool featureFlagEnabled; + final String baseUrl; + final String apiPrefix; + + bool get isApiBaseUrlConfigured => baseUrl.trim().isNotEmpty; + + String get normalizedApiBaseUrl => _joinUrl(baseUrl, apiPrefix); +} + +enum SyncReadinessStatus { + ready, + disabledByFeatureFlag, + missingApiConfiguration, + authenticationRequired, +} + +class SyncReadinessState { + const SyncReadinessState({required this.status, required this.message}); + + final SyncReadinessStatus status; + final String message; + + bool get isReady => status == SyncReadinessStatus.ready; +} final syncDaoProvider = Provider((ref) { final database = ref.watch(appDatabaseProvider); @@ -26,6 +56,27 @@ final syncApiPrefixProvider = Provider((ref) { ); }); +final syncFeatureFlagEnabledProvider = Provider((ref) { + final runtimeConfig = ref.watch(firebaseRuntimeConfigProvider); + return runtimeConfig.syncFeatureEnabled; +}); + +final syncTransportConfigProvider = Provider((ref) { + final featureFlagEnabled = ref.watch(syncFeatureFlagEnabledProvider); + final baseUrl = ref.watch(syncApiBaseUrlProvider); + final apiPrefix = ref.watch(syncApiPrefixProvider); + + return SyncTransportConfig( + featureFlagEnabled: featureFlagEnabled, + baseUrl: baseUrl, + apiPrefix: apiPrefix, + ); +}); + +final syncOutboxWritesEnabledProvider = Provider((ref) { + return ref.watch(syncFeatureFlagEnabledProvider); +}); + final syncDioProvider = Provider((ref) { final dio = Dio( BaseOptions( @@ -60,42 +111,86 @@ final syncAuthDioProvider = Provider((ref) { }); final syncAuthTokenAdapterProvider = Provider((ref) { - final baseUrl = ref.watch(syncApiBaseUrlProvider); - if (baseUrl.trim().isEmpty) { + final transportConfig = ref.watch(syncTransportConfigProvider); + if (!transportConfig.isApiBaseUrlConfigured) { return const NoopSyncAuthTokenProvider(); } - final apiPrefix = ref.watch(syncApiPrefixProvider); - final apiBaseUrl = _joinUrl(baseUrl, apiPrefix); final authDio = ref.watch(syncAuthDioProvider); final storage = SecureStorageService.instance; return NestSyncAuthTokenProvider( dio: authDio, - apiBaseUrl: apiBaseUrl, + apiBaseUrl: transportConfig.normalizedApiBaseUrl, storage: storage, ); }); final syncBackendClientProvider = Provider((ref) { - final baseUrl = ref.watch(syncApiBaseUrlProvider); - if (baseUrl.trim().isEmpty) { - return const NoopSyncBackendClient(); + final transportConfig = ref.watch(syncTransportConfigProvider); + + if (!transportConfig.featureFlagEnabled) { + return const NoopSyncBackendClient( + reason: SyncBackendUnavailableReason.featureFlagDisabled, + ); + } + + if (!transportConfig.isApiBaseUrlConfigured) { + return const NoopSyncBackendClient( + reason: SyncBackendUnavailableReason.notConfigured, + message: + 'Sync backend URL is not configured. Set --dart-define=SYNC_API_BASE_URL.', + ); } final dio = ref.watch(syncDioProvider); - final apiPrefix = ref.watch(syncApiPrefixProvider); final tokenProvider = ref.watch(syncAuthTokenAdapterProvider); - final normalizedBaseUrl = _joinUrl(baseUrl, apiPrefix); - return DioSyncBackendClient( dio: dio, - baseUrl: normalizedBaseUrl, + baseUrl: transportConfig.normalizedApiBaseUrl, authTokenProvider: tokenProvider, ); }); +final syncAuthSessionProvider = FutureProvider((ref) async { + final tokenProvider = ref.watch(syncAuthTokenAdapterProvider); + return tokenProvider.hasSession(); +}); + +final syncReadinessProvider = FutureProvider((ref) async { + final transportConfig = ref.watch(syncTransportConfigProvider); + + if (!transportConfig.featureFlagEnabled) { + return const SyncReadinessState( + status: SyncReadinessStatus.disabledByFeatureFlag, + message: 'Sync is disabled by remote config.', + ); + } + + if (!transportConfig.isApiBaseUrlConfigured) { + return const SyncReadinessState( + status: SyncReadinessStatus.missingApiConfiguration, + message: + 'Sync API base URL is missing. Configure SYNC_API_BASE_URL to enable sync.', + ); + } + + final hasSession = await ref.watch(syncAuthSessionProvider.future); + if (!hasSession) { + return const SyncReadinessState( + status: SyncReadinessStatus.authenticationRequired, + message: + 'Authentication is optional for the app, but required for sync features.', + ); + } + + return const SyncReadinessState( + status: SyncReadinessStatus.ready, + message: 'Sync is ready.', + ); +}); + final syncOrchestratorProvider = Provider((ref) { final dao = ref.watch(syncDaoProvider); final backendClient = ref.watch(syncBackendClientProvider); diff --git a/apps/mobile/lib/core/sync/sync_backend_client.dart b/apps/mobile/lib/core/sync/sync_backend_client.dart deleted file mode 100644 index 3da1fe4..0000000 --- a/apps/mobile/lib/core/sync/sync_backend_client.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'sync_contract.dart'; - -abstract class SyncBackendClient { - Future getCapabilities(); - Future sync(SyncRequestPayload request); -} - -class NoopSyncBackendClient implements SyncBackendClient { - const NoopSyncBackendClient(); - - @override - Future getCapabilities() async { - throw UnsupportedError( - 'Backend sync is not configured yet. Provide a concrete SyncBackendClient.', - ); - } - - @override - Future sync(SyncRequestPayload request) async { - throw UnsupportedError( - 'Backend sync is not configured yet. Provide a concrete SyncBackendClient.', - ); - } -} diff --git a/apps/mobile/lib/core/sync/sync_orchestrator.dart b/apps/mobile/lib/core/sync/sync_orchestrator.dart index ae8a860..5931366 100644 --- a/apps/mobile/lib/core/sync/sync_orchestrator.dart +++ b/apps/mobile/lib/core/sync/sync_orchestrator.dart @@ -1,11 +1,9 @@ import 'dart:convert'; import 'package:database/database.dart'; +import 'package:sync_api/sync_api.dart'; import 'package:uuid/uuid.dart'; -import 'sync_backend_client.dart'; -import 'sync_contract.dart'; - enum SyncEntityType { collection, item, tag } enum SyncOperationType { upsert, delete } @@ -82,11 +80,11 @@ class SyncOrchestrator { bool forceFullSync = false, }) async { if (_backendClient is NoopSyncBackendClient) { - return const SyncAttemptResult( + final client = _backendClient; + return SyncAttemptResult( executed: false, success: false, - message: - 'Sync backend is not configured yet. Attach a concrete SyncBackendClient first.', + message: client.message, ); } @@ -132,6 +130,13 @@ class SyncOrchestrator { 'Sync completed: ${response.syncedCollections} collections, ' '${response.syncedItems} items, ${response.syncedTags} tags.', ); + } on SyncAuthRequiredException catch (error) { + return SyncAttemptResult( + executed: false, + success: false, + message: error.message, + error: error, + ); } catch (error) { final errorText = '$error'; for (final op in pending) { diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart index 9cafcc8..0d6f8c7 100644 --- a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart +++ b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart @@ -589,6 +589,18 @@ class SettingsScreen extends ConsumerWidget { ), ), ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.cloud_sync_outlined), + title: Text(l10n.settingsCloudSyncTitle), + subtitle: const Text('app_sync_feature_enabled'), + trailing: Text( + _enabledDisabledLabel( + context, + runtimeConfig.syncFeatureEnabled, + ), + ), + ), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.sync_alt_outlined), diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index 8f8b836..41c017b 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -61,6 +61,8 @@ dependencies: path: ../../packages/integrations/analytics app_firebase: path: ../../packages/integrations/firebase_services + sync_api: + path: ../../packages/integrations/sync_api dev_dependencies: flutter_test: diff --git a/packages/integrations/sync_api/.flutter-plugins-dependencies b/packages/integrations/sync_api/.flutter-plugins-dependencies new file mode 100644 index 0000000..b8d8851 --- /dev/null +++ b/packages/integrations/sync_api/.flutter-plugins-dependencies @@ -0,0 +1 @@ +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"file_picker","path":"/Users/mixin/.pub-cache/hosted/pub.dev/file_picker-10.3.10/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_darwin","path":"/Users/mixin/.pub-cache/hosted/pub.dev/flutter_secure_storage_darwin-0.2.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_ios","path":"/Users/mixin/.pub-cache/hosted/pub.dev/image_picker_ios-0.8.13+6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/Users/mixin/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_apple","path":"/Users/mixin/.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.7/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"share_plus","path":"/Users/mixin/.pub-cache/hosted/pub.dev/share_plus-12.0.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/Users/mixin/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"file_picker","path":"/Users/mixin/.pub-cache/hosted/pub.dev/file_picker-10.3.10/","native_build":true,"dependencies":["flutter_plugin_android_lifecycle"],"dev_dependency":false},{"name":"flutter_plugin_android_lifecycle","path":"/Users/mixin/.pub-cache/hosted/pub.dev/flutter_plugin_android_lifecycle-2.0.33/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/Users/mixin/.pub-cache/hosted/pub.dev/flutter_secure_storage-10.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_android","path":"/Users/mixin/.pub-cache/hosted/pub.dev/image_picker_android-0.8.13+13/","native_build":true,"dependencies":["flutter_plugin_android_lifecycle"],"dev_dependency":false},{"name":"path_provider_android","path":"/Users/mixin/.pub-cache/hosted/pub.dev/path_provider_android-2.2.22/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_android","path":"/Users/mixin/.pub-cache/hosted/pub.dev/permission_handler_android-13.0.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"share_plus","path":"/Users/mixin/.pub-cache/hosted/pub.dev/share_plus-12.0.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_android","path":"/Users/mixin/.pub-cache/hosted/pub.dev/shared_preferences_android-2.4.20/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"file_picker","path":"/Users/mixin/.pub-cache/hosted/pub.dev/file_picker-10.3.10/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_macos","path":"/Users/mixin/.pub-cache/hosted/pub.dev/file_selector_macos-0.9.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_darwin","path":"/Users/mixin/.pub-cache/hosted/pub.dev/flutter_secure_storage_darwin-0.2.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_macos","path":"/Users/mixin/.pub-cache/hosted/pub.dev/image_picker_macos-0.2.2+1/","native_build":false,"dependencies":["file_selector_macos"],"dev_dependency":false},{"name":"path_provider_foundation","path":"/Users/mixin/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"share_plus","path":"/Users/mixin/.pub-cache/hosted/pub.dev/share_plus-12.0.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/Users/mixin/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"file_picker","path":"/Users/mixin/.pub-cache/hosted/pub.dev/file_picker-10.3.10/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_selector_linux","path":"/Users/mixin/.pub-cache/hosted/pub.dev/file_selector_linux-0.9.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_linux","path":"/Users/mixin/.pub-cache/hosted/pub.dev/flutter_secure_storage_linux-3.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_linux","path":"/Users/mixin/.pub-cache/hosted/pub.dev/image_picker_linux-0.2.2/","native_build":false,"dependencies":["file_selector_linux"],"dev_dependency":false},{"name":"path_provider_linux","path":"/Users/mixin/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"share_plus","path":"/Users/mixin/.pub-cache/hosted/pub.dev/share_plus-12.0.1/","native_build":false,"dependencies":["url_launcher_linux"],"dev_dependency":false},{"name":"shared_preferences_linux","path":"/Users/mixin/.pub-cache/hosted/pub.dev/shared_preferences_linux-2.4.1/","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false},{"name":"url_launcher_linux","path":"/Users/mixin/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.2/","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"file_picker","path":"/Users/mixin/.pub-cache/hosted/pub.dev/file_picker-10.3.10/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_selector_windows","path":"/Users/mixin/.pub-cache/hosted/pub.dev/file_selector_windows-0.9.3+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_windows","path":"/Users/mixin/.pub-cache/hosted/pub.dev/flutter_secure_storage_windows-4.1.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_windows","path":"/Users/mixin/.pub-cache/hosted/pub.dev/image_picker_windows-0.2.2/","native_build":false,"dependencies":["file_selector_windows"],"dev_dependency":false},{"name":"path_provider_windows","path":"/Users/mixin/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_windows","path":"/Users/mixin/.pub-cache/hosted/pub.dev/permission_handler_windows-0.2.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"share_plus","path":"/Users/mixin/.pub-cache/hosted/pub.dev/share_plus-12.0.1/","native_build":true,"dependencies":["url_launcher_windows"],"dev_dependency":false},{"name":"shared_preferences_windows","path":"/Users/mixin/.pub-cache/hosted/pub.dev/shared_preferences_windows-2.4.1/","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false},{"name":"url_launcher_windows","path":"/Users/mixin/.pub-cache/hosted/pub.dev/url_launcher_windows-3.1.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"file_picker","path":"/Users/mixin/.pub-cache/hosted/pub.dev/file_picker-10.3.10/","dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_web","path":"/Users/mixin/.pub-cache/hosted/pub.dev/flutter_secure_storage_web-2.1.0/","dependencies":[],"dev_dependency":false},{"name":"image_picker_for_web","path":"/Users/mixin/.pub-cache/hosted/pub.dev/image_picker_for_web-3.1.1/","dependencies":[],"dev_dependency":false},{"name":"permission_handler_html","path":"/Users/mixin/.pub-cache/hosted/pub.dev/permission_handler_html-0.1.3+5/","dependencies":[],"dev_dependency":false},{"name":"share_plus","path":"/Users/mixin/.pub-cache/hosted/pub.dev/share_plus-12.0.1/","dependencies":["url_launcher_web"],"dev_dependency":false},{"name":"shared_preferences_web","path":"/Users/mixin/.pub-cache/hosted/pub.dev/shared_preferences_web-2.4.3/","dependencies":[],"dev_dependency":false},{"name":"url_launcher_web","path":"/Users/mixin/.pub-cache/hosted/pub.dev/url_launcher_web-2.4.2/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"file_picker","dependencies":["flutter_plugin_android_lifecycle"]},{"name":"file_selector_linux","dependencies":[]},{"name":"file_selector_macos","dependencies":[]},{"name":"file_selector_windows","dependencies":[]},{"name":"flutter_plugin_android_lifecycle","dependencies":[]},{"name":"flutter_secure_storage","dependencies":["flutter_secure_storage_darwin","flutter_secure_storage_linux","flutter_secure_storage_web","flutter_secure_storage_windows"]},{"name":"flutter_secure_storage_darwin","dependencies":[]},{"name":"flutter_secure_storage_linux","dependencies":[]},{"name":"flutter_secure_storage_web","dependencies":[]},{"name":"flutter_secure_storage_windows","dependencies":["path_provider"]},{"name":"image_picker","dependencies":["image_picker_android","image_picker_for_web","image_picker_ios","image_picker_linux","image_picker_macos","image_picker_windows"]},{"name":"image_picker_android","dependencies":["flutter_plugin_android_lifecycle"]},{"name":"image_picker_for_web","dependencies":[]},{"name":"image_picker_ios","dependencies":[]},{"name":"image_picker_linux","dependencies":["file_selector_linux"]},{"name":"image_picker_macos","dependencies":["file_selector_macos"]},{"name":"image_picker_windows","dependencies":["file_selector_windows"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"permission_handler","dependencies":["permission_handler_android","permission_handler_apple","permission_handler_html","permission_handler_windows"]},{"name":"permission_handler_android","dependencies":[]},{"name":"permission_handler_apple","dependencies":[]},{"name":"permission_handler_html","dependencies":[]},{"name":"permission_handler_windows","dependencies":[]},{"name":"share_plus","dependencies":["url_launcher_web","url_launcher_windows","url_launcher_linux"]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]}],"date_created":"2026-02-22 20:36:57.940942","version":"3.41.1","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/packages/integrations/sync_api/README.md b/packages/integrations/sync_api/README.md new file mode 100644 index 0000000..9b4f6b4 --- /dev/null +++ b/packages/integrations/sync_api/README.md @@ -0,0 +1,8 @@ +# sync_api + +Sync API integration package for Collection Tracker. + +Provides: +- Sync API request/response contracts +- Dio-based sync backend client +- Token provider abstractions and NestJS refresh-token adapter diff --git a/packages/integrations/sync_api/analysis_options.yaml b/packages/integrations/sync_api/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/packages/integrations/sync_api/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/apps/mobile/lib/core/sync/nest_sync_auth_token_provider.dart b/packages/integrations/sync_api/lib/src/auth/nest_sync_auth_token_provider.dart similarity index 84% rename from apps/mobile/lib/core/sync/nest_sync_auth_token_provider.dart rename to packages/integrations/sync_api/lib/src/auth/nest_sync_auth_token_provider.dart index c185e59..7bf244d 100644 --- a/apps/mobile/lib/core/sync/nest_sync_auth_token_provider.dart +++ b/packages/integrations/sync_api/lib/src/auth/nest_sync_auth_token_provider.dart @@ -73,6 +73,21 @@ class NestSyncAuthTokenProvider implements SyncAuthTokenProvider { await _storage.delete(refreshTokenKey); } + @override + Future hasSession() async { + final accessToken = await _storage.get(accessTokenKey); + if (accessToken != null && accessToken.isNotEmpty) { + return true; + } + + final refreshToken = await _storage.get(refreshTokenKey); + final deviceId = await _storage.get(deviceIdKey); + return refreshToken != null && + refreshToken.isNotEmpty && + deviceId != null && + deviceId.isNotEmpty; + } + static String _normalizeBaseUrl(String value) { final trimmed = value.trim(); if (trimmed.isEmpty) { diff --git a/apps/mobile/lib/core/sync/sync_auth_token_provider.dart b/packages/integrations/sync_api/lib/src/auth/sync_auth_token_provider.dart similarity index 76% rename from apps/mobile/lib/core/sync/sync_auth_token_provider.dart rename to packages/integrations/sync_api/lib/src/auth/sync_auth_token_provider.dart index 50a340d..02be0af 100644 --- a/apps/mobile/lib/core/sync/sync_auth_token_provider.dart +++ b/packages/integrations/sync_api/lib/src/auth/sync_auth_token_provider.dart @@ -1,6 +1,7 @@ import 'package:storage/storage.dart'; abstract class SyncAuthTokenProvider { + Future hasSession(); Future readAccessToken(); Future refreshAccessToken(); Future clearTokens(); @@ -12,6 +13,9 @@ class NoopSyncAuthTokenProvider implements SyncAuthTokenProvider { @override Future clearTokens() async {} + @override + Future hasSession() async => false; + @override Future readAccessToken() async => null; @@ -47,4 +51,15 @@ class SecureStorageSyncAuthTokenProvider implements SyncAuthTokenProvider { await _storage.delete(accessTokenKey); await _storage.delete(refreshTokenKey); } + + @override + Future hasSession() async { + final accessToken = await _storage.get(accessTokenKey); + if (accessToken != null && accessToken.isNotEmpty) { + return true; + } + + final refreshToken = await _storage.get(refreshTokenKey); + return refreshToken != null && refreshToken.isNotEmpty; + } } diff --git a/apps/mobile/lib/core/sync/dio_sync_backend_client.dart b/packages/integrations/sync_api/lib/src/client/dio_sync_backend_client.dart similarity index 74% rename from apps/mobile/lib/core/sync/dio_sync_backend_client.dart rename to packages/integrations/sync_api/lib/src/client/dio_sync_backend_client.dart index c29b6ee..4d4548f 100644 --- a/apps/mobile/lib/core/sync/dio_sync_backend_client.dart +++ b/packages/integrations/sync_api/lib/src/client/dio_sync_backend_client.dart @@ -1,8 +1,9 @@ import 'package:dio/dio.dart'; -import 'sync_auth_token_provider.dart'; +import '../auth/sync_auth_token_provider.dart'; +import '../exceptions/sync_api_exceptions.dart'; +import '../models/sync_contract.dart'; import 'sync_backend_client.dart'; -import 'sync_contract.dart'; class DioSyncBackendClient implements SyncBackendClient { DioSyncBackendClient({ @@ -27,6 +28,7 @@ class DioSyncBackendClient implements SyncBackendClient { Future getCapabilities() async { final response = await _requestWithAuthRetry( () => _dio.get>('$_baseUrl$_capabilitiesPath'), + requireAuth: false, ); final data = _asJsonMap(response.data); return SyncCapabilities.fromJson(data); @@ -39,15 +41,29 @@ class DioSyncBackendClient implements SyncBackendClient { '$_baseUrl$_syncPath', data: request.toJson(), ), + requireAuth: true, ); final data = _asJsonMap(response.data); return SyncResponsePayload.fromJson(data); } Future> _requestWithAuthRetry( - Future> Function() runRequest, - ) async { - await _applyAccessToken(); + Future> Function() runRequest, { + required bool requireAuth, + }) async { + var tokenApplied = await _applyAccessToken(); + + if (!tokenApplied) { + final refreshedToken = await _authTokenProvider.refreshAccessToken(); + if (refreshedToken != null && refreshedToken.isNotEmpty) { + _dio.options.headers['Authorization'] = 'Bearer $refreshedToken'; + tokenApplied = true; + } + } + + if (requireAuth && !tokenApplied) { + throw const SyncAuthRequiredException(); + } try { return await runRequest(); @@ -58,6 +74,9 @@ class DioSyncBackendClient implements SyncBackendClient { final refreshedToken = await _authTokenProvider.refreshAccessToken(); if (refreshedToken == null || refreshedToken.isEmpty) { + if (requireAuth) { + throw const SyncAuthRequiredException(); + } rethrow; } @@ -66,13 +85,14 @@ class DioSyncBackendClient implements SyncBackendClient { } } - Future _applyAccessToken() async { + Future _applyAccessToken() async { final token = await _authTokenProvider.readAccessToken(); if (token == null || token.isEmpty) { _dio.options.headers.remove('Authorization'); - return; + return false; } _dio.options.headers['Authorization'] = 'Bearer $token'; + return true; } bool _isUnauthorized(DioException error) { diff --git a/packages/integrations/sync_api/lib/src/client/sync_backend_client.dart b/packages/integrations/sync_api/lib/src/client/sync_backend_client.dart new file mode 100644 index 0000000..9132c40 --- /dev/null +++ b/packages/integrations/sync_api/lib/src/client/sync_backend_client.dart @@ -0,0 +1,47 @@ +import '../models/sync_contract.dart'; + +enum SyncBackendUnavailableReason { + notConfigured, + featureFlagDisabled, + unknown, +} + +abstract class SyncBackendClient { + Future getCapabilities(); + Future sync(SyncRequestPayload request); +} + +class NoopSyncBackendClient implements SyncBackendClient { + const NoopSyncBackendClient({ + this.reason = SyncBackendUnavailableReason.notConfigured, + String? message, + }) : _message = message; + + final SyncBackendUnavailableReason reason; + final String? _message; + + String get message { + if (_message != null && _message.trim().isNotEmpty) { + return _message; + } + + return switch (reason) { + SyncBackendUnavailableReason.featureFlagDisabled => + 'Sync is disabled by runtime feature flag.', + SyncBackendUnavailableReason.notConfigured => + 'Sync backend is not configured yet. Provide a concrete SyncBackendClient.', + SyncBackendUnavailableReason.unknown => + 'Sync backend is currently unavailable.', + }; + } + + @override + Future getCapabilities() async { + throw UnsupportedError(message); + } + + @override + Future sync(SyncRequestPayload request) async { + throw UnsupportedError(message); + } +} diff --git a/packages/integrations/sync_api/lib/src/exceptions/sync_api_exceptions.dart b/packages/integrations/sync_api/lib/src/exceptions/sync_api_exceptions.dart new file mode 100644 index 0000000..7d42e90 --- /dev/null +++ b/packages/integrations/sync_api/lib/src/exceptions/sync_api_exceptions.dart @@ -0,0 +1,15 @@ +class SyncApiException implements Exception { + const SyncApiException(this.message); + + final String message; + + @override + String toString() => 'SyncApiException: $message'; +} + +class SyncAuthRequiredException extends SyncApiException { + const SyncAuthRequiredException({ + String message = + 'Authentication is required for sync. Sign in to use sync features.', + }) : super(message); +} diff --git a/apps/mobile/lib/core/sync/sync_contract.dart b/packages/integrations/sync_api/lib/src/models/sync_contract.dart similarity index 100% rename from apps/mobile/lib/core/sync/sync_contract.dart rename to packages/integrations/sync_api/lib/src/models/sync_contract.dart diff --git a/packages/integrations/sync_api/lib/sync_api.dart b/packages/integrations/sync_api/lib/sync_api.dart new file mode 100644 index 0000000..b0133aa --- /dev/null +++ b/packages/integrations/sync_api/lib/sync_api.dart @@ -0,0 +1,6 @@ +export 'src/auth/nest_sync_auth_token_provider.dart'; +export 'src/auth/sync_auth_token_provider.dart'; +export 'src/client/dio_sync_backend_client.dart'; +export 'src/client/sync_backend_client.dart'; +export 'src/exceptions/sync_api_exceptions.dart'; +export 'src/models/sync_contract.dart'; diff --git a/packages/integrations/sync_api/pubspec.yaml b/packages/integrations/sync_api/pubspec.yaml new file mode 100644 index 0000000..5684027 --- /dev/null +++ b/packages/integrations/sync_api/pubspec.yaml @@ -0,0 +1,21 @@ +name: sync_api +description: "Sync API integration for backend synchronization." +version: 1.0.0 +publish_to: none +resolution: workspace + +environment: + sdk: ^3.11.0 + flutter: ">=1.17.0" + +dependencies: + dio: ^5.9.0 + flutter: + sdk: flutter + storage: + path: ../storage + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 22d9efe..e730e6d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,3 +28,4 @@ workspace: - packages/integrations/payment - packages/integrations/storage - packages/integrations/firebase_services + - packages/integrations/sync_api From bf466f0ceafd923e5ec12e0202205d75408a3e5e Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 21:25:20 +0630 Subject: [PATCH 12/31] feat: Introduce `auth_session` package for centralized authentication state management and integrate it with the sync API. --- .../providers/auth_session_providers.dart | 17 + apps/mobile/lib/core/providers/providers.dart | 1 + .../lib/core/providers/sync_providers.dart | 26 +- .../presentation/views/settings_screen.dart | 390 +++++++++++++++++- apps/mobile/pubspec.yaml | 2 + packages/integrations/auth_session/.gitignore | 31 ++ packages/integrations/auth_session/README.md | 8 + .../auth_session/analysis_options.yaml | 1 + .../auth_session/lib/auth_session.dart | 3 + .../lib/src/models/auth_session_model.dart | 113 +++++ .../lib/src/stores/auth_session_store.dart | 8 + .../secure_storage_auth_session_store.dart | 49 +++ .../integrations/auth_session/pubspec.yaml | 20 + .../auth/nest_sync_auth_token_provider.dart | 60 ++- .../src/auth/sync_auth_token_provider.dart | 39 +- packages/integrations/sync_api/pubspec.yaml | 4 +- pubspec.yaml | 1 + 17 files changed, 701 insertions(+), 72 deletions(-) create mode 100644 apps/mobile/lib/core/providers/auth_session_providers.dart create mode 100644 packages/integrations/auth_session/.gitignore create mode 100644 packages/integrations/auth_session/README.md create mode 100644 packages/integrations/auth_session/analysis_options.yaml create mode 100644 packages/integrations/auth_session/lib/auth_session.dart create mode 100644 packages/integrations/auth_session/lib/src/models/auth_session_model.dart create mode 100644 packages/integrations/auth_session/lib/src/stores/auth_session_store.dart create mode 100644 packages/integrations/auth_session/lib/src/stores/secure_storage_auth_session_store.dart create mode 100644 packages/integrations/auth_session/pubspec.yaml diff --git a/apps/mobile/lib/core/providers/auth_session_providers.dart b/apps/mobile/lib/core/providers/auth_session_providers.dart new file mode 100644 index 0000000..984998e --- /dev/null +++ b/apps/mobile/lib/core/providers/auth_session_providers.dart @@ -0,0 +1,17 @@ +import 'package:auth_session/auth_session.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:storage/storage.dart'; + +final authSessionStoreProvider = Provider((ref) { + return SecureStorageAuthSessionStore(storage: SecureStorageService.instance); +}); + +final authSessionProvider = StreamProvider((ref) { + final store = ref.watch(authSessionStoreProvider); + return store.watchSession(); +}); + +final authSessionIsAuthenticatedProvider = Provider((ref) { + final session = ref.watch(authSessionProvider).value; + return session?.isAuthenticated ?? false; +}); diff --git a/apps/mobile/lib/core/providers/providers.dart b/apps/mobile/lib/core/providers/providers.dart index 8230fa9..5a0f1cc 100644 --- a/apps/mobile/lib/core/providers/providers.dart +++ b/apps/mobile/lib/core/providers/providers.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; export 'data_providers.dart'; export 'database_providers.dart'; export 'analytics_preferences_provider.dart'; +export 'auth_session_providers.dart'; export 'firebase_runtime_config_provider.dart'; export 'theme_provider.dart'; export 'items_view_mode_provider.dart'; diff --git a/apps/mobile/lib/core/providers/sync_providers.dart b/apps/mobile/lib/core/providers/sync_providers.dart index 0a98e8c..1d80470 100644 --- a/apps/mobile/lib/core/providers/sync_providers.dart +++ b/apps/mobile/lib/core/providers/sync_providers.dart @@ -1,11 +1,11 @@ import 'package:collection_tracker/core/providers/database_providers.dart'; import 'package:collection_tracker/core/providers/firebase_runtime_config_provider.dart'; +import 'package:collection_tracker/core/providers/auth_session_providers.dart'; import 'package:collection_tracker/core/sync/sync_orchestrator.dart'; import 'package:database/database.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:storage/storage.dart'; import 'package:sync_api/sync_api.dart'; class SyncTransportConfig { @@ -28,6 +28,7 @@ enum SyncReadinessStatus { ready, disabledByFeatureFlag, missingApiConfiguration, + checkingAuthentication, authenticationRequired, } @@ -117,12 +118,12 @@ final syncAuthTokenAdapterProvider = Provider((ref) { } final authDio = ref.watch(syncAuthDioProvider); - final storage = SecureStorageService.instance; + final sessionStore = ref.watch(authSessionStoreProvider); return NestSyncAuthTokenProvider( dio: authDio, apiBaseUrl: transportConfig.normalizedApiBaseUrl, - storage: storage, + sessionStore: sessionStore, ); }); @@ -153,12 +154,7 @@ final syncBackendClientProvider = Provider((ref) { ); }); -final syncAuthSessionProvider = FutureProvider((ref) async { - final tokenProvider = ref.watch(syncAuthTokenAdapterProvider); - return tokenProvider.hasSession(); -}); - -final syncReadinessProvider = FutureProvider((ref) async { +final syncReadinessProvider = Provider((ref) { final transportConfig = ref.watch(syncTransportConfigProvider); if (!transportConfig.featureFlagEnabled) { @@ -176,8 +172,16 @@ final syncReadinessProvider = FutureProvider((ref) async { ); } - final hasSession = await ref.watch(syncAuthSessionProvider.future); - if (!hasSession) { + final sessionAsync = ref.watch(authSessionProvider); + if (sessionAsync.isLoading) { + return const SyncReadinessState( + status: SyncReadinessStatus.checkingAuthentication, + message: 'Checking authentication session...', + ); + } + + final session = sessionAsync.value; + if (session == null || !session.isAuthenticated) { return const SyncReadinessState( status: SyncReadinessStatus.authenticationRequired, message: diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart index 0d6f8c7..df50b82 100644 --- a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart +++ b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart @@ -1,4 +1,5 @@ import 'package:app_logger/app_logger.dart'; +import 'package:auth_session/auth_session.dart'; import 'package:collection_tracker/core/analytics/analytics_consent_dialog.dart'; import 'package:collection_tracker/core/analytics/analytics_preferences.dart'; import 'package:collection_tracker/core/firebase/firebase_runtime_config.dart'; @@ -24,10 +25,16 @@ class SettingsScreen extends ConsumerWidget { final currentLanguage = ref.watch(localeSettingsProvider); final analyticsPreferences = ref.watch(analyticsPreferencesProvider); final firebaseRuntimeConfig = ref.watch(firebaseRuntimeConfigProvider); + final syncReadiness = ref.watch(syncReadinessProvider); + final pendingSyncCount = ref.watch(syncOutboxCountProvider).value ?? 0; final themeSummary = '${_themeModeLabel(context, themeSettings.mode)} - ${themeSettings.variant.label}'; final languageSummary = _languageLabel(context, currentLanguage); final analyticsSummary = _analyticsSummary(context, analyticsPreferences); + final cloudSyncSummary = _cloudSyncSummary( + syncReadiness, + pendingSyncCount: pendingSyncCount, + ); final firebaseRuntimeSummary = _firebaseRuntimeSummary( context, firebaseRuntimeConfig, @@ -100,8 +107,8 @@ class SettingsScreen extends ConsumerWidget { _SettingsTile( icon: Icons.cloud_upload, title: l10n.settingsCloudSyncTitle, - subtitle: l10n.settingsCloudSyncSubtitle, - onTap: () {}, + subtitle: cloudSyncSummary, + onTap: () => _showCloudSyncStatusSheet(context, ref), ), _SettingsTile( icon: Icons.sell_outlined, @@ -283,6 +290,352 @@ class SettingsScreen extends ConsumerWidget { } } + String _cloudSyncSummary( + SyncReadinessState readiness, { + required int pendingSyncCount, + }) { + return switch (readiness.status) { + SyncReadinessStatus.ready => + 'Ready • $pendingSyncCount pending change(s)', + SyncReadinessStatus.disabledByFeatureFlag => + 'Feature disabled by runtime config', + SyncReadinessStatus.missingApiConfiguration => + 'Sync API is not configured', + SyncReadinessStatus.checkingAuthentication => + 'Checking authentication session...', + SyncReadinessStatus.authenticationRequired => + 'Sign in required for sync features', + }; + } + + String _cloudSyncPrimaryCta(SyncReadinessStatus status) { + return switch (status) { + SyncReadinessStatus.ready => 'Sync now', + SyncReadinessStatus.authenticationRequired => 'Sign in', + SyncReadinessStatus.missingApiConfiguration => 'Configure API', + SyncReadinessStatus.disabledByFeatureFlag => 'Feature disabled', + SyncReadinessStatus.checkingAuthentication => 'Checking session', + }; + } + + Future _showCloudSyncStatusSheet( + BuildContext context, + WidgetRef ref, + ) async { + await showAppSheet( + context: context, + builder: (sheetContext) { + return Consumer( + builder: (sheetContext, ref, _) { + final readiness = ref.watch(syncReadinessProvider); + final pendingCount = + ref.watch(syncOutboxCountProvider).asData?.value ?? 0; + final transportConfig = ref.watch(syncTransportConfigProvider); + final isBusy = + readiness.status == SyncReadinessStatus.checkingAuthentication; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + sheetContext.l10n.settingsCloudSyncTitle, + style: Theme.of(sheetContext).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + _cloudSyncSummary(readiness, pendingSyncCount: pendingCount), + style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( + color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.md), + _CloudSyncStateRow( + label: 'Feature flag', + value: transportConfig.featureFlagEnabled + ? 'Enabled' + : 'Disabled', + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'API base URL', + value: transportConfig.baseUrl.trim().isEmpty + ? 'Not set' + : transportConfig.normalizedApiBaseUrl, + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Outbox pending', + value: '$pendingCount', + ), + const SizedBox(height: AppSpacing.lg), + AppButton( + label: _cloudSyncPrimaryCta(readiness.status), + onPressed: isBusy + ? null + : () => _handleCloudSyncPrimaryAction( + context: context, + sheetContext: sheetContext, + ref: ref, + readiness: readiness, + ), + ), + const SizedBox(height: AppSpacing.sm), + AppButton( + label: sheetContext.l10n.actionDismiss, + variant: AppButtonVariant.ghost, + onPressed: () => Navigator.of(sheetContext).pop(), + ), + ], + ); + }, + ); + }, + ); + } + + Future _handleCloudSyncPrimaryAction({ + required BuildContext context, + required BuildContext sheetContext, + required WidgetRef ref, + required SyncReadinessState readiness, + }) async { + switch (readiness.status) { + case SyncReadinessStatus.ready: + await _triggerSyncNow(context, ref); + return; + case SyncReadinessStatus.authenticationRequired: + Navigator.of(sheetContext).pop(); + await _showSyncSignInSheet(context, ref); + return; + case SyncReadinessStatus.missingApiConfiguration: + Navigator.of(sheetContext).pop(); + await _showSyncApiConfigurationHelp(context, ref); + return; + case SyncReadinessStatus.disabledByFeatureFlag: + Navigator.of(sheetContext).pop(); + await _showFeatureDisabledHelp(context); + return; + case SyncReadinessStatus.checkingAuthentication: + return; + } + } + + Future _triggerSyncNow(BuildContext context, WidgetRef ref) async { + final session = ref.read(authSessionProvider).asData?.value; + final deviceId = session?.deviceId; + if (deviceId == null || deviceId.trim().isEmpty) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Missing device id. Sign in again to enable sync.'), + ), + ); + return; + } + + final result = await ref + .read(syncOrchestratorProvider) + .syncNow(deviceId: deviceId); + + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result.message), + backgroundColor: result.success ? Colors.green : Colors.orange, + ), + ); + } + + Future _showSyncSignInSheet(BuildContext context, WidgetRef ref) async { + final existingSession = ref.read(authSessionProvider).asData?.value; + final accessTokenController = TextEditingController( + text: existingSession?.accessToken ?? '', + ); + final refreshTokenController = TextEditingController( + text: existingSession?.refreshToken ?? '', + ); + final deviceIdController = TextEditingController( + text: existingSession?.deviceId ?? '', + ); + + try { + await showAppSheet( + context: context, + builder: (sheetContext) { + var isSaving = false; + return StatefulBuilder( + builder: (context, setState) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sign in for Sync', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Auth is optional for the app. Add your sync session tokens only if you want cloud sync.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.md), + AppInput( + controller: accessTokenController, + labelText: 'Access token (optional if refresh provided)', + ), + const SizedBox(height: AppSpacing.sm), + AppInput( + controller: refreshTokenController, + labelText: 'Refresh token', + ), + const SizedBox(height: AppSpacing.sm), + AppInput( + controller: deviceIdController, + labelText: 'Device id', + ), + const SizedBox(height: AppSpacing.lg), + AppButton( + label: isSaving ? 'Saving...' : 'Save session', + onPressed: isSaving + ? null + : () async { + final accessToken = accessTokenController.text + .trim(); + final refreshToken = refreshTokenController.text + .trim(); + final deviceId = deviceIdController.text.trim(); + + final hasAccess = accessToken.isNotEmpty; + final hasRefreshFlow = + refreshToken.isNotEmpty && deviceId.isNotEmpty; + + if (!hasAccess && !hasRefreshFlow) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Provide an access token, or provide both refresh token and device id.', + ), + ), + ); + return; + } + + setState(() => isSaving = true); + await ref + .read(authSessionStoreProvider) + .saveSession( + AuthSession( + status: AuthSessionStatus.signedIn, + accessToken: hasAccess ? accessToken : null, + refreshToken: refreshToken.isEmpty + ? null + : refreshToken, + deviceId: deviceId.isEmpty + ? null + : deviceId, + userId: existingSession?.userId, + expiresAt: existingSession?.expiresAt, + updatedAt: DateTime.now().toUtc(), + ), + ); + if (!context.mounted) return; + Navigator.of(context).pop(); + ScaffoldMessenger.of(sheetContext).showSnackBar( + const SnackBar( + content: Text('Sync session saved.'), + backgroundColor: Colors.green, + ), + ); + }, + ), + if ((existingSession?.isAuthenticated ?? false)) ...[ + const SizedBox(height: AppSpacing.sm), + AppButton( + label: 'Sign out', + variant: AppButtonVariant.secondary, + onPressed: isSaving + ? null + : () async { + await ref + .read(authSessionStoreProvider) + .clearSession(); + if (!context.mounted) return; + Navigator.of(context).pop(); + ScaffoldMessenger.of(sheetContext).showSnackBar( + const SnackBar( + content: Text('Sync session cleared.'), + ), + ); + }, + ), + ], + ], + ); + }, + ); + }, + ); + } finally { + accessTokenController.dispose(); + refreshTokenController.dispose(); + deviceIdController.dispose(); + } + } + + Future _showSyncApiConfigurationHelp( + BuildContext context, + WidgetRef ref, + ) async { + final config = ref.read(syncTransportConfigProvider); + final currentBaseUrl = config.baseUrl.trim().isEmpty + ? '(not set)' + : config.baseUrl.trim(); + + await showAppDialog( + context: context, + title: const Text('Configure Sync API'), + content: Text( + 'Sync backend URL is missing.\n\n' + 'Current value: $currentBaseUrl\n\n' + 'Run the app with:\n' + '--dart-define=SYNC_API_BASE_URL=https://your-backend.example.com\n' + '--dart-define=SYNC_API_PREFIX=/api/v1', + ), + actions: [ + AppButton( + label: context.l10n.actionDismiss, + variant: AppButtonVariant.ghost, + onPressed: () => closeAppDialog(context), + ), + ], + ); + } + + Future _showFeatureDisabledHelp(BuildContext context) async { + await showAppDialog( + context: context, + title: const Text('Feature disabled'), + content: const Text( + 'Cloud sync is currently disabled by Remote Config key: app_sync_feature_enabled.', + ), + actions: [ + AppButton( + label: context.l10n.actionDismiss, + variant: AppButtonVariant.ghost, + onPressed: () => closeAppDialog(context), + ), + ], + ); + } + Future _showThemeSelector(BuildContext context, WidgetRef ref) async { await showAppSheet( context: context, @@ -1044,6 +1397,39 @@ class _FirebaseFlagStatusRow extends StatelessWidget { } } +class _CloudSyncStateRow extends StatelessWidget { + const _CloudSyncStateRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final valueStyle = Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ); + + return Row( + children: [ + SizedBox( + width: 112, + child: Text(label, style: Theme.of(context).textTheme.bodyMedium), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + value, + style: valueStyle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} + class _SettingsTile extends StatelessWidget { final IconData icon; final String title; diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index 41c017b..ecd5e41 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -59,6 +59,8 @@ dependencies: path: ../../packages/integrations/logger app_analytics: path: ../../packages/integrations/analytics + auth_session: + path: ../../packages/integrations/auth_session app_firebase: path: ../../packages/integrations/firebase_services sync_api: diff --git a/packages/integrations/auth_session/.gitignore b/packages/integrations/auth_session/.gitignore new file mode 100644 index 0000000..dd5eb98 --- /dev/null +++ b/packages/integrations/auth_session/.gitignore @@ -0,0 +1,31 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/packages/integrations/auth_session/README.md b/packages/integrations/auth_session/README.md new file mode 100644 index 0000000..91efcd8 --- /dev/null +++ b/packages/integrations/auth_session/README.md @@ -0,0 +1,8 @@ +# auth_session + +Persistent auth/session state package. + +Provides: +- Session model for access/refresh/device state +- Storage abstraction +- Secure storage implementation with reactive updates diff --git a/packages/integrations/auth_session/analysis_options.yaml b/packages/integrations/auth_session/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/packages/integrations/auth_session/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/packages/integrations/auth_session/lib/auth_session.dart b/packages/integrations/auth_session/lib/auth_session.dart new file mode 100644 index 0000000..4ef5377 --- /dev/null +++ b/packages/integrations/auth_session/lib/auth_session.dart @@ -0,0 +1,3 @@ +export 'src/models/auth_session_model.dart'; +export 'src/stores/auth_session_store.dart'; +export 'src/stores/secure_storage_auth_session_store.dart'; diff --git a/packages/integrations/auth_session/lib/src/models/auth_session_model.dart b/packages/integrations/auth_session/lib/src/models/auth_session_model.dart new file mode 100644 index 0000000..eadc271 --- /dev/null +++ b/packages/integrations/auth_session/lib/src/models/auth_session_model.dart @@ -0,0 +1,113 @@ +enum AuthSessionStatus { signedOut, signedIn } + +class AuthSession { + const AuthSession({ + required this.status, + this.accessToken, + this.refreshToken, + this.deviceId, + this.userId, + this.expiresAt, + required this.updatedAt, + }); + + AuthSession.signedOut({DateTime? updatedAt}) + : status = AuthSessionStatus.signedOut, + accessToken = null, + refreshToken = null, + deviceId = null, + userId = null, + expiresAt = null, + updatedAt = updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0); + + AuthSession.signedIn({ + required this.accessToken, + required this.refreshToken, + required this.deviceId, + this.userId, + this.expiresAt, + required this.updatedAt, + }) : status = AuthSessionStatus.signedIn; + + final AuthSessionStatus status; + final String? accessToken; + final String? refreshToken; + final String? deviceId; + final String? userId; + final DateTime? expiresAt; + final DateTime updatedAt; + + bool get hasAccessToken => + accessToken != null && accessToken!.trim().isNotEmpty; + + bool get canRefresh => + refreshToken != null && + refreshToken!.trim().isNotEmpty && + deviceId != null && + deviceId!.trim().isNotEmpty; + + bool get isAuthenticated => + status == AuthSessionStatus.signedIn && (hasAccessToken || canRefresh); + + AuthSession copyWith({ + AuthSessionStatus? status, + String? accessToken, + String? refreshToken, + String? deviceId, + String? userId, + DateTime? expiresAt, + DateTime? updatedAt, + }) { + return AuthSession( + status: status ?? this.status, + accessToken: accessToken ?? this.accessToken, + refreshToken: refreshToken ?? this.refreshToken, + deviceId: deviceId ?? this.deviceId, + userId: userId ?? this.userId, + expiresAt: expiresAt ?? this.expiresAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + Map toJson() { + return { + 'status': status.name, + 'accessToken': accessToken, + 'refreshToken': refreshToken, + 'deviceId': deviceId, + 'userId': userId, + 'expiresAt': expiresAt?.toUtc().toIso8601String(), + 'updatedAt': updatedAt.toUtc().toIso8601String(), + }; + } + + factory AuthSession.fromJson(Map json) { + final statusRaw = json['status'] as String?; + final status = AuthSessionStatus.values.firstWhere( + (value) => value.name == statusRaw, + orElse: () => AuthSessionStatus.signedOut, + ); + + final updatedAt = + DateTime.tryParse(json['updatedAt'] as String? ?? '')?.toUtc() ?? + DateTime.now().toUtc(); + + return AuthSession( + status: status, + accessToken: _asNullableString(json['accessToken']), + refreshToken: _asNullableString(json['refreshToken']), + deviceId: _asNullableString(json['deviceId']), + userId: _asNullableString(json['userId']), + expiresAt: DateTime.tryParse(json['expiresAt'] as String? ?? '')?.toUtc(), + updatedAt: updatedAt, + ); + } + + static String? _asNullableString(Object? value) { + if (value is! String) { + return null; + } + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } +} diff --git a/packages/integrations/auth_session/lib/src/stores/auth_session_store.dart b/packages/integrations/auth_session/lib/src/stores/auth_session_store.dart new file mode 100644 index 0000000..5be56fb --- /dev/null +++ b/packages/integrations/auth_session/lib/src/stores/auth_session_store.dart @@ -0,0 +1,8 @@ +import '../models/auth_session_model.dart'; + +abstract class AuthSessionStore { + Future readSession(); + Stream watchSession(); + Future saveSession(AuthSession session); + Future clearSession(); +} diff --git a/packages/integrations/auth_session/lib/src/stores/secure_storage_auth_session_store.dart b/packages/integrations/auth_session/lib/src/stores/secure_storage_auth_session_store.dart new file mode 100644 index 0000000..f3ad97b --- /dev/null +++ b/packages/integrations/auth_session/lib/src/stores/secure_storage_auth_session_store.dart @@ -0,0 +1,49 @@ +import 'dart:async'; + +import 'package:storage/storage.dart'; + +import '../models/auth_session_model.dart'; +import 'auth_session_store.dart'; + +class SecureStorageAuthSessionStore implements AuthSessionStore { + SecureStorageAuthSessionStore({ + required SecureStorageService storage, + this.storageKey = 'auth_session_payload', + }) : _storage = storage; + + final SecureStorageService _storage; + final String storageKey; + final StreamController _controller = + StreamController.broadcast(); + + @override + Future readSession() async { + final raw = await _storage.get>(storageKey); + if (raw == null) { + return AuthSession.signedOut(); + } + return AuthSession.fromJson(raw); + } + + @override + Stream watchSession() async* { + yield await readSession(); + yield* _controller.stream; + } + + @override + Future saveSession(AuthSession session) async { + await _storage.save>(storageKey, session.toJson()); + _controller.add(session); + } + + @override + Future clearSession() async { + await _storage.delete(storageKey); + _controller.add(AuthSession.signedOut()); + } + + Future dispose() async { + await _controller.close(); + } +} diff --git a/packages/integrations/auth_session/pubspec.yaml b/packages/integrations/auth_session/pubspec.yaml new file mode 100644 index 0000000..b8c5841 --- /dev/null +++ b/packages/integrations/auth_session/pubspec.yaml @@ -0,0 +1,20 @@ +name: auth_session +description: "Persistent auth/session state primitives for Collection Tracker." +version: 1.0.0 +publish_to: none +resolution: workspace + +environment: + sdk: ^3.11.0 + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + storage: + path: ../storage + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter diff --git a/packages/integrations/sync_api/lib/src/auth/nest_sync_auth_token_provider.dart b/packages/integrations/sync_api/lib/src/auth/nest_sync_auth_token_provider.dart index 7bf244d..f0ddf9b 100644 --- a/packages/integrations/sync_api/lib/src/auth/nest_sync_auth_token_provider.dart +++ b/packages/integrations/sync_api/lib/src/auth/nest_sync_auth_token_provider.dart @@ -1,5 +1,5 @@ +import 'package:auth_session/auth_session.dart'; import 'package:dio/dio.dart'; -import 'package:storage/storage.dart'; import 'sync_auth_token_provider.dart'; @@ -7,33 +7,32 @@ class NestSyncAuthTokenProvider implements SyncAuthTokenProvider { NestSyncAuthTokenProvider({ required Dio dio, required String apiBaseUrl, - required SecureStorageService storage, + required AuthSessionStore sessionStore, this.refreshPath = '/auth/refresh', - this.accessTokenKey = 'sync_access_token', - this.refreshTokenKey = 'sync_refresh_token', - this.deviceIdKey = 'sync_device_id', }) : _dio = dio, _apiBaseUrl = _normalizeBaseUrl(apiBaseUrl), - _storage = storage; + _sessionStore = sessionStore; final Dio _dio; final String _apiBaseUrl; - final SecureStorageService _storage; + final AuthSessionStore _sessionStore; final String refreshPath; - final String accessTokenKey; - final String refreshTokenKey; - final String deviceIdKey; @override - Future readAccessToken() { - return _storage.get(accessTokenKey); + Future readAccessToken() async { + final session = await _sessionStore.readSession(); + if (!session.hasAccessToken) { + return null; + } + return session.accessToken; } @override Future refreshAccessToken() async { - final refreshToken = await _storage.get(refreshTokenKey); - final deviceId = await _storage.get(deviceIdKey); + final session = await _sessionStore.readSession(); + final refreshToken = session.refreshToken; + final deviceId = session.deviceId; if (refreshToken == null || refreshToken.isEmpty || @@ -59,33 +58,30 @@ class NestSyncAuthTokenProvider implements SyncAuthTokenProvider { return null; } - await _storage.save(accessTokenKey, newAccessToken); - if (newRefreshToken != null && newRefreshToken.isNotEmpty) { - await _storage.save(refreshTokenKey, newRefreshToken); - } + await _sessionStore.saveSession( + session.copyWith( + status: AuthSessionStatus.signedIn, + accessToken: newAccessToken, + refreshToken: (newRefreshToken != null && newRefreshToken.isNotEmpty) + ? newRefreshToken + : refreshToken, + deviceId: deviceId, + updatedAt: DateTime.now().toUtc(), + ), + ); return newAccessToken; } @override - Future clearTokens() async { - await _storage.delete(accessTokenKey); - await _storage.delete(refreshTokenKey); + Future clearTokens() { + return _sessionStore.clearSession(); } @override Future hasSession() async { - final accessToken = await _storage.get(accessTokenKey); - if (accessToken != null && accessToken.isNotEmpty) { - return true; - } - - final refreshToken = await _storage.get(refreshTokenKey); - final deviceId = await _storage.get(deviceIdKey); - return refreshToken != null && - refreshToken.isNotEmpty && - deviceId != null && - deviceId.isNotEmpty; + final session = await _sessionStore.readSession(); + return session.isAuthenticated; } static String _normalizeBaseUrl(String value) { diff --git a/packages/integrations/sync_api/lib/src/auth/sync_auth_token_provider.dart b/packages/integrations/sync_api/lib/src/auth/sync_auth_token_provider.dart index 02be0af..e5e0d3d 100644 --- a/packages/integrations/sync_api/lib/src/auth/sync_auth_token_provider.dart +++ b/packages/integrations/sync_api/lib/src/auth/sync_auth_token_provider.dart @@ -1,4 +1,4 @@ -import 'package:storage/storage.dart'; +import 'package:auth_session/auth_session.dart'; abstract class SyncAuthTokenProvider { Future hasSession(); @@ -23,43 +23,32 @@ class NoopSyncAuthTokenProvider implements SyncAuthTokenProvider { Future refreshAccessToken() async => null; } -class SecureStorageSyncAuthTokenProvider implements SyncAuthTokenProvider { - SecureStorageSyncAuthTokenProvider({ - required SecureStorageService storage, - this.accessTokenKey = 'sync_access_token', - this.refreshTokenKey = 'sync_refresh_token', - }) : _storage = storage; +class AuthSessionSyncAuthTokenProvider implements SyncAuthTokenProvider { + AuthSessionSyncAuthTokenProvider({required AuthSessionStore sessionStore}) + : _sessionStore = sessionStore; - final SecureStorageService _storage; - final String accessTokenKey; - final String refreshTokenKey; + final AuthSessionStore _sessionStore; @override - Future readAccessToken() { - return _storage.get(accessTokenKey); + Future readAccessToken() async { + final session = await _sessionStore.readSession(); + return session.hasAccessToken ? session.accessToken : null; } @override - Future refreshAccessToken() async { - // Placeholder for future auth exchange flow. - // For now we return currently stored access token (if any). - return _storage.get(accessTokenKey); + Future refreshAccessToken() { + // Refresh is adapter-specific; this fallback only returns currently stored token. + return readAccessToken(); } @override Future clearTokens() async { - await _storage.delete(accessTokenKey); - await _storage.delete(refreshTokenKey); + await _sessionStore.clearSession(); } @override Future hasSession() async { - final accessToken = await _storage.get(accessTokenKey); - if (accessToken != null && accessToken.isNotEmpty) { - return true; - } - - final refreshToken = await _storage.get(refreshTokenKey); - return refreshToken != null && refreshToken.isNotEmpty; + final session = await _sessionStore.readSession(); + return session.isAuthenticated; } } diff --git a/packages/integrations/sync_api/pubspec.yaml b/packages/integrations/sync_api/pubspec.yaml index 5684027..64f5710 100644 --- a/packages/integrations/sync_api/pubspec.yaml +++ b/packages/integrations/sync_api/pubspec.yaml @@ -9,11 +9,11 @@ environment: flutter: ">=1.17.0" dependencies: + auth_session: + path: ../auth_session dio: ^5.9.0 flutter: sdk: flutter - storage: - path: ../storage dev_dependencies: flutter_lints: ^6.0.0 diff --git a/pubspec.yaml b/pubspec.yaml index e730e6d..09bb895 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ workspace: - packages/common/ui - packages/common/utils - packages/integrations/analytics + - packages/integrations/auth_session - packages/integrations/barcode_scanner - packages/integrations/database - packages/integrations/logger From c7a1d7a30877887db48c3ba069bfd0a1fec63910 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 21:47:18 +0630 Subject: [PATCH 13/31] feat: Introduce `backend_api` package for authentication and integrate it into the mobile application. --- .vscode/launch.json | 13 +- .../lib/core/auth/backend_auth_service.dart | 137 ++++++++ .../firebase_services_bootstrap.dart | 9 + .../firebase/firebase_runtime_config.dart | 3 + .../core/providers/backend_api_providers.dart | 225 +++++++++++++ apps/mobile/lib/core/providers/providers.dart | 1 + .../lib/core/providers/sync_providers.dart | 66 ++-- .../presentation/views/settings_screen.dart | 299 ++++++++++++------ apps/mobile/pubspec.yaml | 2 + packages/integrations/backend_api/.gitignore | 25 ++ packages/integrations/backend_api/README.md | 8 + .../backend_api/analysis_options.yaml | 1 + .../backend_api/lib/backend_api.dart | 3 + .../lib/src/clients/backend_auth_client.dart | 165 ++++++++++ .../src/exceptions/backend_api_exception.dart | 20 ++ .../lib/src/models/backend_auth_models.dart | 183 +++++++++++ .../integrations/backend_api/pubspec.yaml | 19 ++ pubspec.yaml | 1 + 18 files changed, 1063 insertions(+), 117 deletions(-) create mode 100644 apps/mobile/lib/core/auth/backend_auth_service.dart create mode 100644 apps/mobile/lib/core/providers/backend_api_providers.dart create mode 100644 packages/integrations/backend_api/.gitignore create mode 100644 packages/integrations/backend_api/README.md create mode 100644 packages/integrations/backend_api/analysis_options.yaml create mode 100644 packages/integrations/backend_api/lib/backend_api.dart create mode 100644 packages/integrations/backend_api/lib/src/clients/backend_auth_client.dart create mode 100644 packages/integrations/backend_api/lib/src/exceptions/backend_api_exception.dart create mode 100644 packages/integrations/backend_api/lib/src/models/backend_auth_models.dart create mode 100644 packages/integrations/backend_api/pubspec.yaml diff --git a/.vscode/launch.json b/.vscode/launch.json index f6880c8..d41d150 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,13 +1,22 @@ { "configurations": [ { - "name": "Flutter", + "name": "Launch (With ENV)", "type": "dart", "request": "launch", "program": "apps/mobile/lib/main.dart", "args": [ - "--dart-define=ENABLE_CRASHLYTICS_IN_DEBUG=true" + "--dart-define=ENABLE_CRASHLYTICS_IN_DEBUG=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/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..b07e53c --- /dev/null +++ b/apps/mobile/lib/core/auth/backend_auth_service.dart @@ -0,0 +1,137 @@ +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; + } + + 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; + } + + 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/firebase_services_bootstrap.dart b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart index adcca0c..d4d49f5 100644 --- a/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart +++ b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart @@ -24,6 +24,8 @@ abstract final class FirebaseServicesBootstrap { 'app_crashlytics_collection_enabled'; static const _performanceCollectionEnabledKey = 'app_performance_collection_enabled'; + static const _backendIntegrationEnabledKey = + 'app_backend_integration_enabled'; static const _syncFeatureEnabledKey = 'app_sync_feature_enabled'; static Future initialize() async { @@ -34,6 +36,7 @@ abstract final class FirebaseServicesBootstrap { _analyticsCollectionEnabledKey: true, _crashlyticsCollectionEnabledKey: true, _performanceCollectionEnabledKey: true, + _backendIntegrationEnabledKey: false, _syncFeatureEnabledKey: false, }, minimumFetchInterval: kDebugMode @@ -51,6 +54,7 @@ abstract final class FirebaseServicesBootstrap { 'Firebase services initialized (analytics: ${runtimeConfig.analyticsCollectionEnabled}, ' 'crashlytics: ${runtimeConfig.crashlyticsCollectionEnabled}, ' 'performance: ${runtimeConfig.performanceCollectionEnabled}, ' + 'backend: ${runtimeConfig.backendIntegrationEnabled}, ' 'sync: ${runtimeConfig.syncFeatureEnabled}).', ); @@ -87,6 +91,7 @@ abstract final class FirebaseServicesBootstrap { 'analytics: ${runtimeConfig.analyticsCollectionEnabled}, ' 'crashlytics: ${runtimeConfig.crashlyticsCollectionEnabled}, ' 'performance: ${runtimeConfig.performanceCollectionEnabled}, ' + 'backend: ${runtimeConfig.backendIntegrationEnabled}, ' 'sync: ${runtimeConfig.syncFeatureEnabled}).', ); @@ -113,6 +118,10 @@ abstract final class FirebaseServicesBootstrap { _performanceCollectionEnabledKey, fallback: true, ), + backendIntegrationEnabled: remoteConfigService.getBool( + _backendIntegrationEnabledKey, + fallback: false, + ), 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 index 7b0716e..b96daae 100644 --- a/apps/mobile/lib/core/firebase/firebase_runtime_config.dart +++ b/apps/mobile/lib/core/firebase/firebase_runtime_config.dart @@ -3,6 +3,7 @@ class FirebaseRuntimeConfig { required this.analyticsCollectionEnabled, required this.crashlyticsCollectionEnabled, required this.performanceCollectionEnabled, + required this.backendIntegrationEnabled, required this.syncFeatureEnabled, }); @@ -10,11 +11,13 @@ class FirebaseRuntimeConfig { analyticsCollectionEnabled: true, crashlyticsCollectionEnabled: true, performanceCollectionEnabled: true, + backendIntegrationEnabled: false, syncFeatureEnabled: false, ); final bool analyticsCollectionEnabled; final bool crashlyticsCollectionEnabled; final bool performanceCollectionEnabled; + final bool backendIntegrationEnabled; final bool syncFeatureEnabled; } diff --git a/apps/mobile/lib/core/providers/backend_api_providers.dart b/apps/mobile/lib/core/providers/backend_api_providers.dart new file mode 100644 index 0000000..c7edb38 --- /dev/null +++ b/apps/mobile/lib/core/providers/backend_api_providers.dart @@ -0,0 +1,225 @@ +import 'package:auth_session/auth_session.dart'; +import 'package:backend_api/backend_api.dart'; +import 'package:collection_tracker/core/auth/backend_auth_service.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:storage/storage.dart'; +import 'package:uuid/uuid.dart'; + +import 'auth_session_providers.dart'; +import 'firebase_runtime_config_provider.dart'; + +class BackendApiReadiness { + const BackendApiReadiness({required this.enabled, required this.message}); + + final bool enabled; + final String message; +} + +final backendApiBaseUrlOverrideProvider = + NotifierProvider( + BackendApiBaseUrlOverrideController.new, + ); + +class BackendApiBaseUrlOverrideController extends Notifier { + static const _key = 'backend_api_base_url_override'; + + @override + String build() { + final saved = PrefsStorageService.instance.readSync(_key); + return (saved ?? '').trim(); + } + + Future setBaseUrl(String baseUrl) async { + final normalized = baseUrl.trim(); + await PrefsStorageService.instance.save(_key, normalized); + state = normalized; + } + + Future clear() async { + await PrefsStorageService.instance.delete(_key); + state = ''; + } +} + +final backendIntegrationFeatureFlagProvider = Provider((ref) { + final runtimeConfig = ref.watch(firebaseRuntimeConfigProvider); + const envOverride = bool.fromEnvironment( + 'BACKEND_INTEGRATION_ENABLED', + defaultValue: false, + ); + return runtimeConfig.backendIntegrationEnabled || envOverride; +}); + +final backendSyncFeatureFlagProvider = Provider((ref) { + final runtimeConfig = ref.watch(firebaseRuntimeConfigProvider); + const envOverride = bool.fromEnvironment( + 'BACKEND_SYNC_ENABLED', + defaultValue: false, + ); + return runtimeConfig.syncFeatureEnabled || envOverride; +}); + +final backendApiPrefixProvider = Provider((ref) { + final raw = const String.fromEnvironment( + 'BACKEND_API_PREFIX', + defaultValue: '/api/v1', + ); + + if (raw.trim().isEmpty) { + return '/api/v1'; + } + + return raw.startsWith('/') ? raw : '/$raw'; +}); + +final backendApiBaseUrlProvider = Provider((ref) { + final override = ref.watch(backendApiBaseUrlOverrideProvider).trim(); + if (override.isNotEmpty) { + return _normalizeBaseUrl(override); + } + + final env = const String.fromEnvironment('BACKEND_API_BASE_URL'); + if (env.trim().isNotEmpty) { + return _normalizeBaseUrl(env); + } + + if (!kDebugMode) { + return ''; + } + + if (defaultTargetPlatform == TargetPlatform.android) { + return 'http://10.0.2.2:4000'; + } + + return 'http://localhost:4000'; +}); + +final backendApiRootProvider = Provider((ref) { + final baseUrl = ref.watch(backendApiBaseUrlProvider); + if (baseUrl.isEmpty) { + return ''; + } + + final prefix = ref.watch(backendApiPrefixProvider); + return '$baseUrl$prefix'; +}); + +final backendApiReadinessProvider = Provider((ref) { + final integrationEnabled = ref.watch(backendIntegrationFeatureFlagProvider); + if (!integrationEnabled) { + return const BackendApiReadiness( + enabled: false, + message: 'Backend integration is disabled by feature flags.', + ); + } + + final baseUrl = ref.watch(backendApiBaseUrlProvider); + if (baseUrl.isEmpty) { + return const BackendApiReadiness( + enabled: false, + message: 'Backend API URL is missing.', + ); + } + + return const BackendApiReadiness( + enabled: true, + message: 'Backend integration is enabled.', + ); +}); + +final backendApiDioProvider = Provider((ref) { + final dio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 20), + headers: const {'Accept': 'application/json'}, + ), + ); + + if (kDebugMode) { + dio.interceptors.add( + LogInterceptor( + requestBody: true, + responseBody: true, + error: true, + logPrint: (obj) => debugPrint('[BackendApi] $obj'), + ), + ); + } + + return dio; +}); + +final backendAuthClientProvider = Provider((ref) { + final readiness = ref.watch(backendApiReadinessProvider); + if (!readiness.enabled) { + return null; + } + + final root = ref.watch(backendApiRootProvider); + if (root.isEmpty) { + return null; + } + + return BackendAuthClient( + dio: ref.watch(backendApiDioProvider), + apiBaseUrl: root, + ); +}); + +final backendDeviceIdProvider = FutureProvider((ref) async { + final storage = SecureStorageService.instance; + + const currentKey = 'backend_device_id'; + const legacyKey = 'sync_device_id'; + + String? deviceId = await storage.get(currentKey); + if (deviceId == null || deviceId.trim().isEmpty) { + deviceId = await storage.get(legacyKey); + } + + if (deviceId == null || deviceId.trim().isEmpty) { + deviceId = const Uuid().v4(); + } + + await storage.save(currentKey, deviceId); + await storage.save(legacyKey, deviceId); + + return deviceId; +}); + +final backendAuthServiceProvider = Provider((ref) { + final client = ref.watch(backendAuthClientProvider); + if (client == null) { + return null; + } + + final sessionStore = ref.watch(authSessionStoreProvider); + + return BackendAuthService( + client: client, + sessionStore: sessionStore, + resolveDeviceId: () => ref.read(backendDeviceIdProvider.future), + appVersion: const String.fromEnvironment( + 'APP_VERSION', + defaultValue: '1.0.0', + ), + ); +}); + +final backendSessionStoreProvider = Provider((ref) { + return ref.watch(authSessionStoreProvider); +}); + +String _normalizeBaseUrl(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return ''; + } + + return trimmed.endsWith('/') + ? trimmed.substring(0, trimmed.length - 1) + : trimmed; +} diff --git a/apps/mobile/lib/core/providers/providers.dart b/apps/mobile/lib/core/providers/providers.dart index 5a0f1cc..a071d2e 100644 --- a/apps/mobile/lib/core/providers/providers.dart +++ b/apps/mobile/lib/core/providers/providers.dart @@ -4,6 +4,7 @@ export 'data_providers.dart'; export 'database_providers.dart'; export 'analytics_preferences_provider.dart'; export 'auth_session_providers.dart'; +export 'backend_api_providers.dart'; export 'firebase_runtime_config_provider.dart'; export 'theme_provider.dart'; export 'items_view_mode_provider.dart'; diff --git a/apps/mobile/lib/core/providers/sync_providers.dart b/apps/mobile/lib/core/providers/sync_providers.dart index 1d80470..42410cb 100644 --- a/apps/mobile/lib/core/providers/sync_providers.dart +++ b/apps/mobile/lib/core/providers/sync_providers.dart @@ -1,6 +1,6 @@ import 'package:collection_tracker/core/providers/database_providers.dart'; -import 'package:collection_tracker/core/providers/firebase_runtime_config_provider.dart'; import 'package:collection_tracker/core/providers/auth_session_providers.dart'; +import 'package:collection_tracker/core/providers/backend_api_providers.dart'; import 'package:collection_tracker/core/sync/sync_orchestrator.dart'; import 'package:database/database.dart'; import 'package:dio/dio.dart'; @@ -10,15 +10,19 @@ import 'package:sync_api/sync_api.dart'; class SyncTransportConfig { const SyncTransportConfig({ - required this.featureFlagEnabled, + required this.backendFeatureEnabled, + required this.syncFeatureEnabled, required this.baseUrl, required this.apiPrefix, }); - final bool featureFlagEnabled; + final bool backendFeatureEnabled; + final bool syncFeatureEnabled; final String baseUrl; final String apiPrefix; + bool get featureFlagEnabled => backendFeatureEnabled && syncFeatureEnabled; + bool get isApiBaseUrlConfigured => baseUrl.trim().isNotEmpty; String get normalizedApiBaseUrl => _joinUrl(baseUrl, apiPrefix); @@ -46,29 +50,25 @@ final syncDaoProvider = Provider((ref) { return database.syncDao; }); -final syncApiBaseUrlProvider = Provider((ref) { - return const String.fromEnvironment('SYNC_API_BASE_URL', defaultValue: ''); -}); - -final syncApiPrefixProvider = Provider((ref) { - return const String.fromEnvironment( - 'SYNC_API_PREFIX', - defaultValue: '/api/v1', - ); -}); - final syncFeatureFlagEnabledProvider = Provider((ref) { - final runtimeConfig = ref.watch(firebaseRuntimeConfigProvider); - return runtimeConfig.syncFeatureEnabled; + final backendFeatureEnabled = ref.watch( + backendIntegrationFeatureFlagProvider, + ); + final syncFeatureEnabled = ref.watch(backendSyncFeatureFlagProvider); + return backendFeatureEnabled && syncFeatureEnabled; }); final syncTransportConfigProvider = Provider((ref) { - final featureFlagEnabled = ref.watch(syncFeatureFlagEnabledProvider); - final baseUrl = ref.watch(syncApiBaseUrlProvider); - final apiPrefix = ref.watch(syncApiPrefixProvider); + final backendFeatureEnabled = ref.watch( + backendIntegrationFeatureFlagProvider, + ); + final syncFeatureEnabled = ref.watch(backendSyncFeatureFlagProvider); + final baseUrl = ref.watch(backendApiBaseUrlProvider); + final apiPrefix = ref.watch(backendApiPrefixProvider); return SyncTransportConfig( - featureFlagEnabled: featureFlagEnabled, + backendFeatureEnabled: backendFeatureEnabled, + syncFeatureEnabled: syncFeatureEnabled, baseUrl: baseUrl, apiPrefix: apiPrefix, ); @@ -130,17 +130,24 @@ final syncAuthTokenAdapterProvider = Provider((ref) { final syncBackendClientProvider = Provider((ref) { final transportConfig = ref.watch(syncTransportConfigProvider); - if (!transportConfig.featureFlagEnabled) { + if (!transportConfig.backendFeatureEnabled) { return const NoopSyncBackendClient( reason: SyncBackendUnavailableReason.featureFlagDisabled, + message: 'Backend integration is disabled by feature flags.', + ); + } + + if (!transportConfig.syncFeatureEnabled) { + return const NoopSyncBackendClient( + reason: SyncBackendUnavailableReason.featureFlagDisabled, + message: 'Sync is disabled by feature flags.', ); } if (!transportConfig.isApiBaseUrlConfigured) { return const NoopSyncBackendClient( reason: SyncBackendUnavailableReason.notConfigured, - message: - 'Sync backend URL is not configured. Set --dart-define=SYNC_API_BASE_URL.', + message: 'Backend API base URL is not configured.', ); } @@ -157,10 +164,17 @@ final syncBackendClientProvider = Provider((ref) { final syncReadinessProvider = Provider((ref) { final transportConfig = ref.watch(syncTransportConfigProvider); - if (!transportConfig.featureFlagEnabled) { + if (!transportConfig.backendFeatureEnabled) { + return const SyncReadinessState( + status: SyncReadinessStatus.disabledByFeatureFlag, + message: 'Backend integration is disabled by feature flags.', + ); + } + + if (!transportConfig.syncFeatureEnabled) { return const SyncReadinessState( status: SyncReadinessStatus.disabledByFeatureFlag, - message: 'Sync is disabled by remote config.', + message: 'Sync is disabled by feature flags.', ); } @@ -168,7 +182,7 @@ final syncReadinessProvider = Provider((ref) { return const SyncReadinessState( status: SyncReadinessStatus.missingApiConfiguration, message: - 'Sync API base URL is missing. Configure SYNC_API_BASE_URL to enable sync.', + 'Sync API base URL is missing. Configure BACKEND_API_BASE_URL or set base URL override in settings.', ); } diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart index df50b82..1934d82 100644 --- a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart +++ b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart @@ -1,5 +1,5 @@ import 'package:app_logger/app_logger.dart'; -import 'package:auth_session/auth_session.dart'; +import 'package:backend_api/backend_api.dart'; import 'package:collection_tracker/core/analytics/analytics_consent_dialog.dart'; import 'package:collection_tracker/core/analytics/analytics_preferences.dart'; import 'package:collection_tracker/core/firebase/firebase_runtime_config.dart'; @@ -297,8 +297,7 @@ class SettingsScreen extends ConsumerWidget { return switch (readiness.status) { SyncReadinessStatus.ready => 'Ready • $pendingSyncCount pending change(s)', - SyncReadinessStatus.disabledByFeatureFlag => - 'Feature disabled by runtime config', + SyncReadinessStatus.disabledByFeatureFlag => 'Feature disabled by flags', SyncReadinessStatus.missingApiConfiguration => 'Sync API is not configured', SyncReadinessStatus.checkingAuthentication => @@ -451,21 +450,16 @@ class SettingsScreen extends ConsumerWidget { Future _showSyncSignInSheet(BuildContext context, WidgetRef ref) async { final existingSession = ref.read(authSessionProvider).asData?.value; - final accessTokenController = TextEditingController( - text: existingSession?.accessToken ?? '', - ); - final refreshTokenController = TextEditingController( - text: existingSession?.refreshToken ?? '', - ); - final deviceIdController = TextEditingController( - text: existingSession?.deviceId ?? '', - ); + final emailController = TextEditingController(); + final passwordController = TextEditingController(); + final displayNameController = TextEditingController(); try { await showAppSheet( context: context, builder: (sheetContext) { - var isSaving = false; + var isRegisterMode = false; + var isSubmitting = false; return StatefulBuilder( builder: (context, setState) { return Column( @@ -473,87 +467,135 @@ class SettingsScreen extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Sign in for Sync', + isRegisterMode ? 'Create Sync Account' : 'Sign in for Sync', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: AppSpacing.sm), Text( - 'Auth is optional for the app. Add your sync session tokens only if you want cloud sync.', + 'Auth is optional for the app. Sign in only if you want cloud sync and backend features.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), const SizedBox(height: AppSpacing.md), AppInput( - controller: accessTokenController, - labelText: 'Access token (optional if refresh provided)', - ), - const SizedBox(height: AppSpacing.sm), - AppInput( - controller: refreshTokenController, - labelText: 'Refresh token', + controller: emailController, + labelText: 'Email', + keyboardType: TextInputType.emailAddress, ), const SizedBox(height: AppSpacing.sm), AppInput( - controller: deviceIdController, - labelText: 'Device id', + controller: passwordController, + labelText: 'Password', ), + if (isRegisterMode) ...[ + const SizedBox(height: AppSpacing.sm), + AppInput( + controller: displayNameController, + labelText: 'Display name (optional)', + ), + ], const SizedBox(height: AppSpacing.lg), AppButton( - label: isSaving ? 'Saving...' : 'Save session', - onPressed: isSaving + label: isSubmitting + ? 'Please wait...' + : (isRegisterMode ? 'Create account' : 'Sign in'), + onPressed: isSubmitting ? null : () async { - final accessToken = accessTokenController.text - .trim(); - final refreshToken = refreshTokenController.text - .trim(); - final deviceId = deviceIdController.text.trim(); + final service = ref.read( + backendAuthServiceProvider, + ); + if (service == null) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Backend integration is disabled or not configured.', + ), + ), + ); + return; + } - final hasAccess = accessToken.isNotEmpty; - final hasRefreshFlow = - refreshToken.isNotEmpty && deviceId.isNotEmpty; + final email = emailController.text.trim(); + final password = passwordController.text; + final displayName = displayNameController.text + .trim(); - if (!hasAccess && !hasRefreshFlow) { + if (email.isEmpty || password.isEmpty) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( - 'Provide an access token, or provide both refresh token and device id.', + 'Email and password are required.', ), ), ); return; } - setState(() => isSaving = true); - await ref - .read(authSessionStoreProvider) - .saveSession( - AuthSession( - status: AuthSessionStatus.signedIn, - accessToken: hasAccess ? accessToken : null, - refreshToken: refreshToken.isEmpty - ? null - : refreshToken, - deviceId: deviceId.isEmpty - ? null - : deviceId, - userId: existingSession?.userId, - expiresAt: existingSession?.expiresAt, - updatedAt: DateTime.now().toUtc(), - ), + setState(() => isSubmitting = true); + + try { + if (isRegisterMode) { + await service.register( + email: email, + password: password, + displayName: displayName.isEmpty + ? null + : displayName, ); - if (!context.mounted) return; - Navigator.of(context).pop(); - ScaffoldMessenger.of(sheetContext).showSnackBar( - const SnackBar( - content: Text('Sync session saved.'), - backgroundColor: Colors.green, - ), - ); + } else { + await service.signIn( + email: email, + password: password, + ); + } + + if (!context.mounted) return; + Navigator.of(context).pop(); + ScaffoldMessenger.of(sheetContext).showSnackBar( + SnackBar( + content: Text( + isRegisterMode + ? 'Account created and signed in.' + : 'Signed in successfully.', + ), + backgroundColor: Colors.green, + ), + ); + } on BackendApiException catch (error) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error.message)), + ); + } catch (error) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Sign-in failed: $error'), + ), + ); + } finally { + if (context.mounted) { + setState(() => isSubmitting = false); + } + } + }, + ), + const SizedBox(height: AppSpacing.sm), + AppButton( + label: isRegisterMode + ? 'Already have an account? Sign in' + : 'Create new account', + variant: AppButtonVariant.ghost, + onPressed: isSubmitting + ? null + : () { + setState(() => isRegisterMode = !isRegisterMode); }, ), if ((existingSession?.isAuthenticated ?? false)) ...[ @@ -561,12 +603,19 @@ class SettingsScreen extends ConsumerWidget { AppButton( label: 'Sign out', variant: AppButtonVariant.secondary, - onPressed: isSaving + onPressed: isSubmitting ? null : () async { - await ref - .read(authSessionStoreProvider) - .clearSession(); + final service = ref.read( + backendAuthServiceProvider, + ); + if (service != null) { + await service.signOut(); + } else { + await ref + .read(authSessionStoreProvider) + .clearSession(); + } if (!context.mounted) return; Navigator.of(context).pop(); ScaffoldMessenger.of(sheetContext).showSnackBar( @@ -584,9 +633,9 @@ class SettingsScreen extends ConsumerWidget { }, ); } finally { - accessTokenController.dispose(); - refreshTokenController.dispose(); - deviceIdController.dispose(); + emailController.dispose(); + passwordController.dispose(); + displayNameController.dispose(); } } @@ -594,29 +643,86 @@ class SettingsScreen extends ConsumerWidget { BuildContext context, WidgetRef ref, ) async { - final config = ref.read(syncTransportConfigProvider); - final currentBaseUrl = config.baseUrl.trim().isEmpty - ? '(not set)' - : config.baseUrl.trim(); + final initialOverride = ref.read(backendApiBaseUrlOverrideProvider); + final effectiveUrl = ref.read(backendApiBaseUrlProvider); + final controller = TextEditingController(text: initialOverride); - await showAppDialog( + await showAppSheet( context: context, - title: const Text('Configure Sync API'), - content: Text( - 'Sync backend URL is missing.\n\n' - 'Current value: $currentBaseUrl\n\n' - 'Run the app with:\n' - '--dart-define=SYNC_API_BASE_URL=https://your-backend.example.com\n' - '--dart-define=SYNC_API_PREFIX=/api/v1', - ), - actions: [ - AppButton( - label: context.l10n.actionDismiss, - variant: AppButtonVariant.ghost, - onPressed: () => closeAppDialog(context), - ), - ], + builder: (sheetContext) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Configure Backend API', + style: Theme.of( + sheetContext, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Set your local API URL. Android emulator: 10.0.2.2. iOS simulator: localhost. Physical device: use your computer LAN IP.', + style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( + color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.md), + AppInput( + controller: controller, + labelText: 'Base URL override', + hintText: 'http://localhost:4000', + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Effective URL: $effectiveUrl', + style: Theme.of(sheetContext).textTheme.bodySmall?.copyWith( + color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.lg), + AppButton( + label: 'Save', + onPressed: () async { + await ref + .read(backendApiBaseUrlOverrideProvider.notifier) + .setBaseUrl(controller.text); + if (!sheetContext.mounted) return; + Navigator.of(sheetContext).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Backend API URL updated.')), + ); + }, + ), + const SizedBox(height: AppSpacing.sm), + AppButton( + label: 'Clear override', + variant: AppButtonVariant.secondary, + onPressed: () async { + await ref + .read(backendApiBaseUrlOverrideProvider.notifier) + .clear(); + if (!sheetContext.mounted) return; + Navigator.of(sheetContext).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Backend API URL override cleared.'), + ), + ); + }, + ), + const SizedBox(height: AppSpacing.sm), + AppButton( + label: sheetContext.l10n.actionDismiss, + variant: AppButtonVariant.ghost, + onPressed: () => Navigator.of(sheetContext).pop(), + ), + ], + ); + }, ); + + controller.dispose(); } Future _showFeatureDisabledHelp(BuildContext context) async { @@ -624,7 +730,10 @@ class SettingsScreen extends ConsumerWidget { context: context, title: const Text('Feature disabled'), content: const Text( - 'Cloud sync is currently disabled by Remote Config key: app_sync_feature_enabled.', + 'Cloud sync/backend integration is disabled by feature flags. Enable ' + 'Remote Config keys app_backend_integration_enabled + ' + 'app_sync_feature_enabled, or use --dart-define BACKEND_INTEGRATION_ENABLED=true ' + 'and BACKEND_SYNC_ENABLED=true for local development.', ), actions: [ AppButton( @@ -942,6 +1051,18 @@ class SettingsScreen extends ConsumerWidget { ), ), ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.hub_outlined), + title: const Text('Backend integration'), + subtitle: const Text('app_backend_integration_enabled'), + trailing: Text( + _enabledDisabledLabel( + context, + runtimeConfig.backendIntegrationEnabled, + ), + ), + ), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.cloud_sync_outlined), diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index ecd5e41..6708048 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -61,6 +61,8 @@ dependencies: path: ../../packages/integrations/analytics auth_session: path: ../../packages/integrations/auth_session + backend_api: + path: ../../packages/integrations/backend_api app_firebase: path: ../../packages/integrations/firebase_services sync_api: diff --git a/packages/integrations/backend_api/.gitignore b/packages/integrations/backend_api/.gitignore new file mode 100644 index 0000000..d64bc84 --- /dev/null +++ b/packages/integrations/backend_api/.gitignore @@ -0,0 +1,25 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# Flutter/Dart/Pub related +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/packages/integrations/backend_api/README.md b/packages/integrations/backend_api/README.md new file mode 100644 index 0000000..b02581d --- /dev/null +++ b/packages/integrations/backend_api/README.md @@ -0,0 +1,8 @@ +# backend_api + +Backend API integration package for Collection Tracker. + +Provides: +- auth REST client (register/login/refresh/logout/profile) +- request/response models +- backend exception model diff --git a/packages/integrations/backend_api/analysis_options.yaml b/packages/integrations/backend_api/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/packages/integrations/backend_api/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/packages/integrations/backend_api/lib/backend_api.dart b/packages/integrations/backend_api/lib/backend_api.dart new file mode 100644 index 0000000..2b1f085 --- /dev/null +++ b/packages/integrations/backend_api/lib/backend_api.dart @@ -0,0 +1,3 @@ +export 'src/clients/backend_auth_client.dart'; +export 'src/exceptions/backend_api_exception.dart'; +export 'src/models/backend_auth_models.dart'; diff --git a/packages/integrations/backend_api/lib/src/clients/backend_auth_client.dart b/packages/integrations/backend_api/lib/src/clients/backend_auth_client.dart new file mode 100644 index 0000000..74d4324 --- /dev/null +++ b/packages/integrations/backend_api/lib/src/clients/backend_auth_client.dart @@ -0,0 +1,165 @@ +import 'package:dio/dio.dart'; + +import '../exceptions/backend_api_exception.dart'; +import '../models/backend_auth_models.dart'; + +class BackendAuthClient { + BackendAuthClient({ + required Dio dio, + required String apiBaseUrl, + String authPathPrefix = '/auth', + }) : _dio = dio, + _apiBaseUrl = _normalizeBaseUrl(apiBaseUrl), + _authPathPrefix = authPathPrefix; + + final Dio _dio; + final String _apiBaseUrl; + final String _authPathPrefix; + + Future register(BackendRegisterRequest request) async { + final map = await _post( + path: '$_authPathPrefix/register', + data: request.toJson(), + ); + return BackendAuthResponse.fromJson(map); + } + + Future login(BackendLoginRequest request) async { + final map = await _post( + path: '$_authPathPrefix/login', + data: request.toJson(), + ); + return BackendAuthResponse.fromJson(map); + } + + Future refresh(BackendRefreshTokenRequest request) async { + final map = await _post( + path: '$_authPathPrefix/refresh', + data: request.toJson(), + ); + return BackendTokenPair.fromJson(map); + } + + Future logout(String accessToken) async { + await _post( + path: '$_authPathPrefix/logout', + data: const {}, + accessToken: accessToken, + ); + } + + Future logoutAll(String accessToken) async { + await _post( + path: '$_authPathPrefix/logout-all', + data: const {}, + accessToken: accessToken, + ); + } + + Future me(String accessToken) async { + final map = await _get( + path: '$_authPathPrefix/me', + accessToken: accessToken, + ); + return BackendProfileResponse.fromJson(map); + } + + Future> _post({ + required String path, + required Map data, + String? accessToken, + }) async { + try { + final response = await _dio.post>( + '$_apiBaseUrl$path', + data: data, + options: _authorizedOptions(accessToken), + ); + return _unwrapToMap(response.data); + } on DioException catch (error) { + throw _mapDioError(error); + } + } + + Future> _get({ + required String path, + String? accessToken, + }) async { + try { + final response = await _dio.get>( + '$_apiBaseUrl$path', + options: _authorizedOptions(accessToken), + ); + return _unwrapToMap(response.data); + } on DioException catch (error) { + throw _mapDioError(error); + } + } + + Options? _authorizedOptions(String? accessToken) { + if (accessToken == null || accessToken.trim().isEmpty) { + return null; + } + + return Options(headers: {'Authorization': 'Bearer $accessToken'}); + } + + BackendApiException _mapDioError(DioException error) { + final statusCode = error.response?.statusCode; + final responseData = error.response?.data; + + if (responseData is Map) { + final map = responseData.cast(); + final message = + (map['message'] as String?) ?? + _extractMessageFromNestedData(map) ?? + error.message ?? + 'Backend API request failed'; + final code = map['code'] as String?; + return BackendApiException( + message: message, + statusCode: statusCode, + code: code, + raw: map, + ); + } + + return BackendApiException( + message: error.message ?? 'Backend API request failed', + statusCode: statusCode, + raw: responseData, + ); + } + + String? _extractMessageFromNestedData(Map map) { + final data = map['data']; + if (data is Map) { + return data['message'] as String?; + } + return null; + } + + Map _unwrapToMap(Object? raw) { + if (raw is! Map) { + return const {}; + } + + final map = raw.cast(); + final nested = map['data']; + if (nested is Map) { + return nested.cast(); + } + + return map; + } + + static String _normalizeBaseUrl(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return ''; + } + return trimmed.endsWith('/') + ? trimmed.substring(0, trimmed.length - 1) + : trimmed; + } +} diff --git a/packages/integrations/backend_api/lib/src/exceptions/backend_api_exception.dart b/packages/integrations/backend_api/lib/src/exceptions/backend_api_exception.dart new file mode 100644 index 0000000..40b2194 --- /dev/null +++ b/packages/integrations/backend_api/lib/src/exceptions/backend_api_exception.dart @@ -0,0 +1,20 @@ +class BackendApiException implements Exception { + const BackendApiException({ + required this.message, + this.statusCode, + this.code, + this.raw, + }); + + final String message; + final int? statusCode; + final String? code; + final Object? raw; + + @override + String toString() { + final status = statusCode != null ? ' [$statusCode]' : ''; + final errorCode = code != null ? ' <$code>' : ''; + return 'BackendApiException$status$errorCode: $message'; + } +} diff --git a/packages/integrations/backend_api/lib/src/models/backend_auth_models.dart b/packages/integrations/backend_api/lib/src/models/backend_auth_models.dart new file mode 100644 index 0000000..58c80f7 --- /dev/null +++ b/packages/integrations/backend_api/lib/src/models/backend_auth_models.dart @@ -0,0 +1,183 @@ +class BackendAuthUser { + const BackendAuthUser({ + required this.id, + required this.email, + required this.displayName, + required this.photoUrl, + required this.subscriptionTier, + }); + + final String id; + final String email; + final String? displayName; + final String? photoUrl; + final String subscriptionTier; + + factory BackendAuthUser.fromJson(Map json) { + return BackendAuthUser( + id: json['id'] as String? ?? '', + email: json['email'] as String? ?? '', + displayName: json['displayName'] as String?, + photoUrl: json['photoUrl'] as String?, + subscriptionTier: json['subscriptionTier'] as String? ?? 'FREE', + ); + } +} + +class BackendSessionInfo { + const BackendSessionInfo({ + required this.id, + required this.deviceId, + required this.deviceName, + required this.expiresAt, + }); + + final String id; + final String deviceId; + final String deviceName; + final DateTime? expiresAt; + + factory BackendSessionInfo.fromJson(Map json) { + return BackendSessionInfo( + id: json['id'] as String? ?? '', + deviceId: json['deviceId'] as String? ?? '', + deviceName: json['deviceName'] as String? ?? '', + expiresAt: DateTime.tryParse(json['expiresAt'] as String? ?? '')?.toUtc(), + ); + } +} + +class BackendAuthResponse { + const BackendAuthResponse({ + required this.accessToken, + required this.refreshToken, + required this.user, + required this.session, + }); + + final String accessToken; + final String refreshToken; + final BackendAuthUser user; + final BackendSessionInfo session; + + factory BackendAuthResponse.fromJson(Map json) { + final userJson = + (json['user'] as Map?)?.cast() ?? + const {}; + final sessionJson = + (json['session'] as Map?)?.cast() ?? + const {}; + + return BackendAuthResponse( + accessToken: json['accessToken'] as String? ?? '', + refreshToken: json['refreshToken'] as String? ?? '', + user: BackendAuthUser.fromJson(userJson), + session: BackendSessionInfo.fromJson(sessionJson), + ); + } +} + +class BackendTokenPair { + const BackendTokenPair({ + required this.accessToken, + required this.refreshToken, + }); + + final String accessToken; + final String refreshToken; + + factory BackendTokenPair.fromJson(Map json) { + return BackendTokenPair( + accessToken: json['accessToken'] as String? ?? '', + refreshToken: json['refreshToken'] as String? ?? '', + ); + } +} + +class BackendProfileResponse { + const BackendProfileResponse({required this.user}); + + final BackendAuthUser user; + + factory BackendProfileResponse.fromJson(Map json) { + final userJson = + (json['user'] as Map?)?.cast() ?? + const {}; + return BackendProfileResponse(user: BackendAuthUser.fromJson(userJson)); + } +} + +class BackendRegisterRequest { + const BackendRegisterRequest({ + required this.email, + required this.password, + required this.deviceId, + required this.deviceName, + required this.deviceOs, + this.displayName, + this.appVersion, + }); + + final String email; + final String password; + final String? displayName; + final String deviceId; + final String deviceName; + final String deviceOs; + final String? appVersion; + + Map toJson() { + return { + 'email': email, + 'password': password, + 'displayName': displayName, + 'deviceId': deviceId, + 'deviceName': deviceName, + 'deviceOs': deviceOs, + 'appVersion': appVersion, + }; + } +} + +class BackendLoginRequest { + const BackendLoginRequest({ + required this.email, + required this.password, + required this.deviceId, + required this.deviceName, + required this.deviceOs, + this.appVersion, + }); + + final String email; + final String password; + final String deviceId; + final String deviceName; + final String deviceOs; + final String? appVersion; + + Map toJson() { + return { + 'email': email, + 'password': password, + 'deviceId': deviceId, + 'deviceName': deviceName, + 'deviceOs': deviceOs, + 'appVersion': appVersion, + }; + } +} + +class BackendRefreshTokenRequest { + const BackendRefreshTokenRequest({ + required this.refreshToken, + required this.deviceId, + }); + + final String refreshToken; + final String deviceId; + + Map toJson() { + return {'refreshToken': refreshToken, 'deviceId': deviceId}; + } +} diff --git a/packages/integrations/backend_api/pubspec.yaml b/packages/integrations/backend_api/pubspec.yaml new file mode 100644 index 0000000..fd8abc0 --- /dev/null +++ b/packages/integrations/backend_api/pubspec.yaml @@ -0,0 +1,19 @@ +name: backend_api +description: "Backend API integration package for Collection Tracker." +version: 1.0.0 +publish_to: none +resolution: workspace + +environment: + sdk: ^3.11.0 + flutter: ">=1.17.0" + +dependencies: + dio: ^5.9.0 + flutter: + sdk: flutter + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 09bb895..5c156ed 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,6 +22,7 @@ workspace: - packages/common/utils - packages/integrations/analytics - packages/integrations/auth_session + - packages/integrations/backend_api - packages/integrations/barcode_scanner - packages/integrations/database - packages/integrations/logger From 55acc85de0167cfcb42b18b604f756559af4139e Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 22:29:02 +0630 Subject: [PATCH 14/31] feat: add authentication screen for user login/registration and implement sync outbox bootstrapping for data synchronization. --- .../lib/core/providers/sync_providers.dart | 12 + apps/mobile/lib/core/router/app_router.dart | 15 + apps/mobile/lib/core/router/routes.dart | 1 + .../lib/core/sync/sync_orchestrator.dart | 31 ++ .../core/sync/sync_outbox_bootstrapper.dart | 225 +++++++++ .../auth/presentation/views/auth_screen.dart | 443 ++++++++++++++++++ .../presentation/views/settings_screen.dart | 432 ++++++++--------- .../common/ui/lib/src/widgets/app_input.dart | 15 + .../lib/src/clients/backend_auth_client.dart | 81 +++- .../database/lib/src/daos/item_dao.dart | 4 + 10 files changed, 1008 insertions(+), 251 deletions(-) create mode 100644 apps/mobile/lib/core/sync/sync_outbox_bootstrapper.dart create mode 100644 apps/mobile/lib/features/auth/presentation/views/auth_screen.dart diff --git a/apps/mobile/lib/core/providers/sync_providers.dart b/apps/mobile/lib/core/providers/sync_providers.dart index 42410cb..ee5b272 100644 --- a/apps/mobile/lib/core/providers/sync_providers.dart +++ b/apps/mobile/lib/core/providers/sync_providers.dart @@ -1,6 +1,7 @@ import 'package:collection_tracker/core/providers/database_providers.dart'; import 'package:collection_tracker/core/providers/auth_session_providers.dart'; import 'package:collection_tracker/core/providers/backend_api_providers.dart'; +import 'package:collection_tracker/core/sync/sync_outbox_bootstrapper.dart'; import 'package:collection_tracker/core/sync/sync_orchestrator.dart'; import 'package:database/database.dart'; import 'package:dio/dio.dart'; @@ -215,6 +216,17 @@ final syncOrchestratorProvider = Provider((ref) { return SyncOrchestrator(syncDao: dao, backendClient: backendClient); }); +final syncOutboxBootstrapperProvider = Provider((ref) { + final syncDao = ref.watch(syncDaoProvider); + final collectionDao = ref.watch(collectionDaoProvider); + final itemDao = ref.watch(itemDaoProvider); + return SyncOutboxBootstrapper( + syncDao: syncDao, + collectionDao: collectionDao, + itemDao: itemDao, + ); +}); + final syncOutboxCountProvider = StreamProvider((ref) { final dao = ref.watch(syncDaoProvider); return dao.watchPendingOperationCount(); diff --git a/apps/mobile/lib/core/router/app_router.dart b/apps/mobile/lib/core/router/app_router.dart index b514610..c507626 100644 --- a/apps/mobile/lib/core/router/app_router.dart +++ b/apps/mobile/lib/core/router/app_router.dart @@ -7,6 +7,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../features/collections/presentation/views/collections_screen.dart'; import '../../features/collections/presentation/views/create_collection_screen.dart'; import '../../features/collections/presentation/views/edit_collection_screen.dart'; +import '../../features/auth/presentation/views/auth_screen.dart'; import '../../features/items/presentation/views/add_item_screen.dart'; import '../../features/items/presentation/views/edit_item_screen.dart'; import '../../features/items/presentation/views/item_detail_screen.dart'; @@ -46,6 +47,20 @@ GoRouter appRouter(Ref ref) { builder: (context, state) => withAnalyticsConsent(const OnboardingScreen()), ), + GoRoute( + path: Routes.auth, + name: 'auth', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) { + final mode = state.uri.queryParameters['mode']; + final initialMode = mode == 'register' + ? AuthScreenMode.register + : AuthScreenMode.signIn; + return withAnalyticsConsent( + AuthScreen(initialMode: initialMode, popOnSuccess: true), + ); + }, + ), StatefulShellRoute.indexedStack( builder: (context, state, navigationShell) { return withAnalyticsConsent( diff --git a/apps/mobile/lib/core/router/routes.dart b/apps/mobile/lib/core/router/routes.dart index 278a252..b8bcee4 100644 --- a/apps/mobile/lib/core/router/routes.dart +++ b/apps/mobile/lib/core/router/routes.dart @@ -1,6 +1,7 @@ abstract final class Routes { static const home = '/'; static const login = '/login'; + static const auth = '/auth'; static const onboarding = '/onboarding'; static const collections = '/collections'; static const items = '/items'; diff --git a/apps/mobile/lib/core/sync/sync_orchestrator.dart b/apps/mobile/lib/core/sync/sync_orchestrator.dart index 5931366..713877f 100644 --- a/apps/mobile/lib/core/sync/sync_orchestrator.dart +++ b/apps/mobile/lib/core/sync/sync_orchestrator.dart @@ -114,6 +114,30 @@ class SyncOrchestrator { ), ); + final processedOperations = _processedOperationCount(response); + if (processedOperations < pending.length) { + final errorText = + 'Sync response did not process all operations ' + '(processed: $processedOperations, pending: ${pending.length}).'; + + for (final op in pending) { + await _syncDao.markOperationFailed(op.id, errorText); + } + + await _syncDao.upsertSyncState( + consecutiveFailures: (state?.consecutiveFailures ?? 0) + 1, + ); + + return SyncAttemptResult( + executed: true, + success: false, + message: + 'Sync partially processed. Local queue kept for retry. ' + 'Processed $processedOperations of ${pending.length} change(s).', + error: errorText, + ); + } + for (final op in pending) { await _syncDao.markOperationSynced(op.id); } @@ -183,4 +207,11 @@ class SyncOrchestrator { tags: tags, ); } + + int _processedOperationCount(SyncResponsePayload response) { + return response.syncedCollections + + response.syncedItems + + response.syncedTags + + response.conflicts.length; + } } diff --git a/apps/mobile/lib/core/sync/sync_outbox_bootstrapper.dart b/apps/mobile/lib/core/sync/sync_outbox_bootstrapper.dart new file mode 100644 index 0000000..5ed0ee4 --- /dev/null +++ b/apps/mobile/lib/core/sync/sync_outbox_bootstrapper.dart @@ -0,0 +1,225 @@ +import 'dart:convert'; + +import 'package:database/database.dart'; +import 'package:storage/storage.dart'; + +class SyncOutboxBootstrapResult { + const SyncOutboxBootstrapResult({ + required this.collectionOperations, + required this.itemOperations, + required this.tagOperations, + required this.skipped, + }); + + final int collectionOperations; + final int itemOperations; + final int tagOperations; + final bool skipped; + + int get totalOperations => + collectionOperations + itemOperations + tagOperations; +} + +class SyncOutboxBootstrapper { + static const _seedStateKey = 'sync_initial_outbox_seed_completed_v1'; + static const _seedOperationCountKey = + 'sync_initial_outbox_seed_operation_count_v1'; + + const SyncOutboxBootstrapper({ + required SyncDao syncDao, + required CollectionDao collectionDao, + required ItemDao itemDao, + }) : _syncDao = syncDao, + _collectionDao = collectionDao, + _itemDao = itemDao; + + final SyncDao _syncDao; + final CollectionDao _collectionDao; + final ItemDao _itemDao; + + Future seedFromLocalDataIfNeeded() async { + final pending = await _syncDao.getPendingOperations(limit: 1); + if (pending.isNotEmpty) { + return const SyncOutboxBootstrapResult( + collectionOperations: 0, + itemOperations: 0, + tagOperations: 0, + skipped: true, + ); + } + + final seedAlreadyCompleted = + PrefsStorageService.instance.readSync(_seedStateKey) ?? false; + final previouslySeededOperationCount = + PrefsStorageService.instance.readSync(_seedOperationCountKey) ?? 0; + + // If an earlier seed run queued 0 operations, allow reseeding when data + // appears later (for example, data existed before sync was enabled). + if (seedAlreadyCompleted && previouslySeededOperationCount > 0) { + return const SyncOutboxBootstrapResult( + collectionOperations: 0, + itemOperations: 0, + tagOperations: 0, + skipped: true, + ); + } + + final result = await _seedFromLocalData(); + await PrefsStorageService.instance.save(_seedStateKey, true); + await PrefsStorageService.instance.save( + _seedOperationCountKey, + result.totalOperations, + ); + return result; + } + + Future rebuildFromLocalData() async { + await _syncDao.clearOutbox(); + final result = await _seedFromLocalData(); + await PrefsStorageService.instance.save(_seedStateKey, true); + await PrefsStorageService.instance.save( + _seedOperationCountKey, + result.totalOperations, + ); + return result; + } + + Future _seedFromLocalData() async { + final collections = await _collectionDao.getAllCollections(); + final tags = await _itemDao.getAllTags(); + final tagIdByName = { + for (final tag in tags) tag.name: tag.id, + }; + + var collectionOperations = 0; + var itemOperations = 0; + var tagOperations = 0; + + for (final tag in tags) { + await _queueUpsert( + entityType: 'tag', + entityId: tag.id, + payload: _tagPayload(tag), + ); + tagOperations++; + } + + for (final collection in collections) { + await _queueUpsert( + entityType: 'collection', + entityId: collection.id, + payload: _collectionPayload(collection), + ); + collectionOperations++; + } + + for (final collection in collections) { + final itemRows = await _itemDao.getItemsWithTags(collection.id); + for (final row in itemRows) { + final item = row.$1; + final tagNames = row.$2; + final tagIds = tagNames + .map((tagName) => tagIdByName[tagName]) + .whereType() + .toList(growable: false); + + await _queueUpsert( + entityType: 'item', + entityId: item.id, + payload: _itemPayload(item: item, tagIds: tagIds), + ); + itemOperations++; + } + } + + return SyncOutboxBootstrapResult( + collectionOperations: collectionOperations, + itemOperations: itemOperations, + tagOperations: tagOperations, + skipped: false, + ); + } + + Future _queueUpsert({ + required String entityType, + required String entityId, + required Map payload, + }) async { + await _syncDao.markOperationSynced( + _operationId(entityType, entityId, 'delete'), + ); + await _syncDao.enqueueOperation( + id: _operationId(entityType, entityId, 'upsert'), + entityType: entityType, + entityId: entityId, + operationType: 'upsert', + payload: jsonEncode(payload), + ); + } + + String _operationId( + String entityType, + String entityId, + String operationType, + ) { + return '$entityType:$entityId:$operationType'; + } + + Map _collectionPayload(CollectionData collection) { + return { + 'id': collection.id, + 'name': collection.name, + 'type': collection.type, + 'description': collection.description, + 'coverImagePath': collection.coverImagePath, + 'itemCount': collection.itemCount, + 'version': 1, + 'isDeleted': false, + 'createdAt': collection.createdAt.toUtc().toIso8601String(), + 'updatedAt': collection.updatedAt.toUtc().toIso8601String(), + }; + } + + Map _itemPayload({ + required ItemData item, + required List tagIds, + }) { + return { + 'id': item.id, + 'collectionId': item.collectionId, + 'title': item.title, + 'barcode': item.barcode, + 'coverImageUrl': item.coverImageUrl, + 'coverImagePath': item.coverImagePath, + 'description': item.description, + 'notes': item.notes, + 'metadata': item.metadata, + 'condition': item.condition, + 'purchasePrice': item.purchasePrice, + 'purchaseDate': item.purchaseDate?.toUtc().toIso8601String(), + 'currentValue': item.currentValue, + 'location': item.location, + 'isFavorite': item.isFavorite, + 'isWishlist': item.isWishlist, + 'sortOrder': item.sortOrder, + 'quantity': item.quantity, + 'version': 1, + 'isDeleted': false, + 'createdAt': item.createdAt.toUtc().toIso8601String(), + 'updatedAt': item.updatedAt.toUtc().toIso8601String(), + 'tagIds': tagIds, + }; + } + + Map _tagPayload(TagData tag) { + return { + 'id': tag.id, + 'name': tag.name, + 'color': tag.color, + 'version': 1, + 'isDeleted': false, + 'createdAt': tag.createdAt.toUtc().toIso8601String(), + 'updatedAt': tag.updatedAt.toUtc().toIso8601String(), + }; + } +} diff --git a/apps/mobile/lib/features/auth/presentation/views/auth_screen.dart b/apps/mobile/lib/features/auth/presentation/views/auth_screen.dart new file mode 100644 index 0000000..839f095 --- /dev/null +++ b/apps/mobile/lib/features/auth/presentation/views/auth_screen.dart @@ -0,0 +1,443 @@ +import 'package:auth_session/auth_session.dart'; +import 'package:backend_api/backend_api.dart'; +import 'package:collection_tracker/core/providers/providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ui/ui.dart'; + +enum AuthScreenMode { signIn, register } + +class AuthScreen extends ConsumerStatefulWidget { + const AuthScreen({ + this.initialMode = AuthScreenMode.signIn, + this.popOnSuccess = true, + super.key, + }); + + final AuthScreenMode initialMode; + final bool popOnSuccess; + + @override + ConsumerState createState() => _AuthScreenState(); +} + +class _AuthScreenState extends ConsumerState { + static final RegExp _passwordPolicyRegex = RegExp( + r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)', + ); + + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _displayNameController = TextEditingController(); + + late bool _isRegisterMode; + bool _isSubmitting = false; + + @override + void initState() { + super.initState(); + _isRegisterMode = widget.initialMode == AuthScreenMode.register; + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _displayNameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final sessionAsync = ref.watch(authSessionProvider); + final session = sessionAsync.value; + final service = ref.watch(backendAuthServiceProvider); + final readiness = ref.watch(backendApiReadinessProvider); + final canAuthenticate = service != null; + final isAuthenticated = session?.isAuthenticated ?? false; + + return Scaffold( + appBar: AppBar(title: const Text('Account')), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + AppSpacing.xxl, + ), + children: [ + if (sessionAsync.isLoading) + const Padding( + padding: EdgeInsets.only(top: AppSpacing.xxl), + child: Center(child: CircularProgressIndicator()), + ) + else if (isAuthenticated) + AppReveal( + child: _AuthenticatedCard( + session: session!, + isSubmitting: _isSubmitting, + onSignOut: _handleSignOut, + onDone: () => context.pop(true), + ), + ) + else if (!canAuthenticate) + AppReveal( + child: _AuthUnavailableCard( + message: readiness.message, + onClose: () => context.pop(false), + ), + ) + else + AppReveal( + child: AppCard( + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _isRegisterMode ? 'Create Account' : 'Sign In', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Sign in to enable cloud sync features.', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.md), + Wrap( + spacing: AppSpacing.sm, + children: [ + ChoiceChip( + label: const Text('Sign in'), + selected: !_isRegisterMode, + onSelected: _isSubmitting + ? null + : (selected) { + if (!selected) return; + setState(() => _isRegisterMode = false); + }, + ), + ChoiceChip( + label: const Text('Register'), + selected: _isRegisterMode, + onSelected: _isSubmitting + ? null + : (selected) { + if (!selected) return; + setState(() => _isRegisterMode = true); + }, + ), + ], + ), + const SizedBox(height: AppSpacing.md), + AppInput( + controller: _emailController, + labelText: 'Email', + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + validator: (value) { + final text = (value ?? '').trim(); + if (text.isEmpty) { + return 'Email is required.'; + } + if (!text.contains('@')) { + return 'Enter a valid email.'; + } + return null; + }, + ), + const SizedBox(height: AppSpacing.sm), + AppInput( + controller: _passwordController, + labelText: 'Password', + keyboardType: TextInputType.visiblePassword, + obscureText: true, + autocorrect: false, + enableSuggestions: false, + smartDashesType: SmartDashesType.disabled, + smartQuotesType: SmartQuotesType.disabled, + textInputAction: _isRegisterMode + ? TextInputAction.next + : TextInputAction.done, + validator: (value) { + final text = value ?? ''; + if (text.isEmpty) { + return 'Password is required.'; + } + if (text.length < 8) { + return 'Password must be at least 8 characters.'; + } + if (!_passwordPolicyRegex.hasMatch(text)) { + return 'Password must include uppercase, lowercase, and number.'; + } + return null; + }, + ), + if (_isRegisterMode) ...[ + const SizedBox(height: AppSpacing.sm), + AppInput( + controller: _displayNameController, + labelText: 'Display Name (optional)', + textInputAction: TextInputAction.done, + ), + ], + const SizedBox(height: AppSpacing.lg), + AppButton( + label: _isRegisterMode ? 'Create account' : 'Sign in', + isLoading: _isSubmitting, + onPressed: _isSubmitting ? null : _handleSubmit, + expand: true, + ), + const SizedBox(height: AppSpacing.sm), + AppButton( + label: 'Not now', + variant: AppButtonVariant.ghost, + onPressed: _isSubmitting + ? null + : () => context.pop(false), + expand: true, + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } + + Future _handleSubmit() async { + final service = ref.read(backendAuthServiceProvider); + if (service == null) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Authentication is currently unavailable.'), + ), + ); + return; + } + + if (!_formKey.currentState!.validate()) { + return; + } + + setState(() => _isSubmitting = true); + final email = _emailController.text.trim(); + final password = _passwordController.text; + final displayName = _displayNameController.text.trim(); + + try { + if (_isRegisterMode) { + await service.register( + email: email, + password: password, + displayName: displayName.isEmpty ? null : displayName, + ); + } else { + await service.signIn(email: email, password: password); + } + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _isRegisterMode + ? 'Account created and signed in.' + : 'Signed in successfully.', + ), + backgroundColor: Colors.green, + ), + ); + + if (widget.popOnSuccess && mounted) { + context.pop(true); + } + } on BackendApiException catch (error) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(_friendlyAuthError(error)))); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Sign-in failed: $error'))); + } finally { + if (mounted) { + setState(() => _isSubmitting = false); + } + } + } + + Future _handleSignOut() async { + setState(() => _isSubmitting = true); + try { + final service = ref.read(backendAuthServiceProvider); + if (service != null) { + await service.signOut(); + } else { + await ref.read(authSessionStoreProvider).clearSession(); + } + + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Signed out.'))); + context.pop(true); + } finally { + if (mounted) { + setState(() => _isSubmitting = false); + } + } + } + + String _friendlyAuthError(BackendApiException error) { + final message = error.message.trim(); + if (message.toLowerCase().contains('password must contain uppercase')) { + return '$message Use English keyboard letters and digits (A-Z, a-z, 0-9).'; + } + return message; + } +} + +class _AuthenticatedCard extends StatelessWidget { + const _AuthenticatedCard({ + required this.session, + required this.isSubmitting, + required this.onSignOut, + required this.onDone, + }); + + final AuthSession session; + final bool isSubmitting; + final Future Function() onSignOut; + final VoidCallback onDone; + + @override + Widget build(BuildContext context) { + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Signed in', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'You can now use cloud sync features.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.md), + _MetaRow(label: 'User ID', value: session.userId ?? 'Unknown'), + const SizedBox(height: AppSpacing.xs), + _MetaRow(label: 'Device ID', value: session.deviceId ?? 'Unknown'), + const SizedBox(height: AppSpacing.lg), + AppButton( + label: 'Sign out', + variant: AppButtonVariant.danger, + isLoading: isSubmitting, + onPressed: isSubmitting ? null : () => onSignOut(), + expand: true, + ), + const SizedBox(height: AppSpacing.sm), + AppButton( + label: 'Done', + variant: AppButtonVariant.ghost, + onPressed: isSubmitting ? null : onDone, + expand: true, + ), + ], + ), + ); + } +} + +class _AuthUnavailableCard extends StatelessWidget { + const _AuthUnavailableCard({required this.message, required this.onClose}); + + final String message; + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Authentication unavailable', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: AppSpacing.sm), + Text( + message, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.lg), + AppButton( + label: 'Back', + variant: AppButtonVariant.ghost, + onPressed: onClose, + expand: true, + ), + ], + ), + ); + } +} + +class _MetaRow extends StatelessWidget { + const _MetaRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SizedBox( + width: 88, + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: AppSpacing.xs), + Expanded( + child: Text( + value, + style: Theme.of(context).textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart index 1934d82..fe8801d 100644 --- a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart +++ b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart @@ -1,9 +1,10 @@ import 'package:app_logger/app_logger.dart'; -import 'package:backend_api/backend_api.dart'; +import 'package:auth_session/auth_session.dart'; import 'package:collection_tracker/core/analytics/analytics_consent_dialog.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/providers/providers.dart'; +import 'package:collection_tracker/core/router/routes.dart'; import 'package:collection_tracker/l10n/l10n.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; @@ -27,14 +28,18 @@ class SettingsScreen extends ConsumerWidget { final firebaseRuntimeConfig = ref.watch(firebaseRuntimeConfigProvider); final syncReadiness = ref.watch(syncReadinessProvider); final pendingSyncCount = ref.watch(syncOutboxCountProvider).value ?? 0; + final authSession = ref.watch(authSessionProvider).value; final themeSummary = '${_themeModeLabel(context, themeSettings.mode)} - ${themeSettings.variant.label}'; final languageSummary = _languageLabel(context, currentLanguage); final analyticsSummary = _analyticsSummary(context, analyticsPreferences); + final accountSummary = _authAccountSummary(authSession); final cloudSyncSummary = _cloudSyncSummary( syncReadiness, pendingSyncCount: pendingSyncCount, ); + final cloudSyncFeatureEnabled = + syncReadiness.status != SyncReadinessStatus.disabledByFeatureFlag; final firebaseRuntimeSummary = _firebaseRuntimeSummary( context, firebaseRuntimeConfig, @@ -72,6 +77,12 @@ class SettingsScreen extends ConsumerWidget { subtitle: analyticsSummary, onTap: () => _showAnalyticsSettings(context, ref), ), + _SettingsTile( + icon: Icons.person_outline_rounded, + title: 'Account', + subtitle: accountSummary, + onTap: () => context.push(Routes.auth), + ), ], ), ), @@ -108,7 +119,10 @@ class SettingsScreen extends ConsumerWidget { icon: Icons.cloud_upload, title: l10n.settingsCloudSyncTitle, subtitle: cloudSyncSummary, - onTap: () => _showCloudSyncStatusSheet(context, ref), + enabled: cloudSyncFeatureEnabled, + onTap: cloudSyncFeatureEnabled + ? () => _showCloudSyncStatusSheet(context, ref) + : null, ), _SettingsTile( icon: Icons.sell_outlined, @@ -156,6 +170,12 @@ class SettingsScreen extends ConsumerWidget { subtitle: firebaseRuntimeSummary, onTap: () => _showFirebaseRuntimeConfigSheet(context, ref), ), + _SettingsTile( + icon: Icons.cloud_sync_outlined, + title: 'Cloud Sync Diagnostics', + subtitle: 'Debug sync transport and auth readiness', + onTap: () => _showCloudSyncDiagnosticsSheet(context, ref), + ), _SettingsTile( icon: Icons.bug_report_outlined, title: l10n.settingsCrashlyticsTestTitle, @@ -296,17 +316,23 @@ class SettingsScreen extends ConsumerWidget { }) { return switch (readiness.status) { SyncReadinessStatus.ready => - 'Ready • $pendingSyncCount pending change(s)', - SyncReadinessStatus.disabledByFeatureFlag => 'Feature disabled by flags', - SyncReadinessStatus.missingApiConfiguration => - 'Sync API is not configured', - SyncReadinessStatus.checkingAuthentication => - 'Checking authentication session...', - SyncReadinessStatus.authenticationRequired => - 'Sign in required for sync features', + pendingSyncCount > 0 + ? 'Ready • $pendingSyncCount pending change(s)' + : 'Ready', + SyncReadinessStatus.disabledByFeatureFlag => 'Unavailable', + SyncReadinessStatus.missingApiConfiguration => 'Configuration required', + SyncReadinessStatus.checkingAuthentication => 'Checking session...', + SyncReadinessStatus.authenticationRequired => 'Sign in required', }; } + String _authAccountSummary(AuthSession? session) { + if (session == null || !session.isAuthenticated) { + return 'Not signed in'; + } + return 'Signed in'; + } + String _cloudSyncPrimaryCta(SyncReadinessStatus status) { return switch (status) { SyncReadinessStatus.ready => 'Sync now', @@ -329,7 +355,6 @@ class SettingsScreen extends ConsumerWidget { final readiness = ref.watch(syncReadinessProvider); final pendingCount = ref.watch(syncOutboxCountProvider).asData?.value ?? 0; - final transportConfig = ref.watch(syncTransportConfigProvider); final isBusy = readiness.status == SyncReadinessStatus.checkingAuthentication; @@ -350,25 +375,6 @@ class SettingsScreen extends ConsumerWidget { color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, ), ), - const SizedBox(height: AppSpacing.md), - _CloudSyncStateRow( - label: 'Feature flag', - value: transportConfig.featureFlagEnabled - ? 'Enabled' - : 'Disabled', - ), - const SizedBox(height: AppSpacing.xs), - _CloudSyncStateRow( - label: 'API base URL', - value: transportConfig.baseUrl.trim().isEmpty - ? 'Not set' - : transportConfig.normalizedApiBaseUrl, - ), - const SizedBox(height: AppSpacing.xs), - _CloudSyncStateRow( - label: 'Outbox pending', - value: '$pendingCount', - ), const SizedBox(height: AppSpacing.lg), AppButton( label: _cloudSyncPrimaryCta(readiness.status), @@ -407,15 +413,14 @@ class SettingsScreen extends ConsumerWidget { return; case SyncReadinessStatus.authenticationRequired: Navigator.of(sheetContext).pop(); - await _showSyncSignInSheet(context, ref); + if (!context.mounted) return; + await context.push('${Routes.auth}?mode=signin'); return; case SyncReadinessStatus.missingApiConfiguration: Navigator.of(sheetContext).pop(); await _showSyncApiConfigurationHelp(context, ref); return; case SyncReadinessStatus.disabledByFeatureFlag: - Navigator.of(sheetContext).pop(); - await _showFeatureDisabledHelp(context); return; case SyncReadinessStatus.checkingAuthentication: return; @@ -435,208 +440,49 @@ class SettingsScreen extends ConsumerWidget { return; } + final bootstrapResult = await ref + .read(syncOutboxBootstrapperProvider) + .seedFromLocalDataIfNeeded(); + final result = await ref .read(syncOrchestratorProvider) .syncNow(deviceId: deviceId); if (!context.mounted) return; + final bootstrapMessage = bootstrapResult.totalOperations > 0 + ? 'Prepared ${bootstrapResult.totalOperations} local change(s). ' + : ''; + final recoveryHint = bootstrapResult.skipped && !result.executed + ? ' If existing local data is missing on cloud, open Cloud Sync Diagnostics and use "Rebuild local sync queue".' + : ''; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(result.message), + content: Text('$bootstrapMessage${result.message}$recoveryHint'), backgroundColor: result.success ? Colors.green : Colors.orange, ), ); } - Future _showSyncSignInSheet(BuildContext context, WidgetRef ref) async { - final existingSession = ref.read(authSessionProvider).asData?.value; - final emailController = TextEditingController(); - final passwordController = TextEditingController(); - final displayNameController = TextEditingController(); - - try { - await showAppSheet( - context: context, - builder: (sheetContext) { - var isRegisterMode = false; - var isSubmitting = false; - return StatefulBuilder( - builder: (context, setState) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - isRegisterMode ? 'Create Sync Account' : 'Sign in for Sync', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: AppSpacing.sm), - Text( - 'Auth is optional for the app. Sign in only if you want cloud sync and backend features.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: AppSpacing.md), - AppInput( - controller: emailController, - labelText: 'Email', - keyboardType: TextInputType.emailAddress, - ), - const SizedBox(height: AppSpacing.sm), - AppInput( - controller: passwordController, - labelText: 'Password', - ), - if (isRegisterMode) ...[ - const SizedBox(height: AppSpacing.sm), - AppInput( - controller: displayNameController, - labelText: 'Display name (optional)', - ), - ], - const SizedBox(height: AppSpacing.lg), - AppButton( - label: isSubmitting - ? 'Please wait...' - : (isRegisterMode ? 'Create account' : 'Sign in'), - onPressed: isSubmitting - ? null - : () async { - final service = ref.read( - backendAuthServiceProvider, - ); - if (service == null) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Backend integration is disabled or not configured.', - ), - ), - ); - return; - } - - final email = emailController.text.trim(); - final password = passwordController.text; - final displayName = displayNameController.text - .trim(); - - if (email.isEmpty || password.isEmpty) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Email and password are required.', - ), - ), - ); - return; - } + Future _rebuildSyncOutboxFromLocal( + BuildContext context, + WidgetRef ref, + ) async { + final result = await ref + .read(syncOutboxBootstrapperProvider) + .rebuildFromLocalData(); - setState(() => isSubmitting = true); - - try { - if (isRegisterMode) { - await service.register( - email: email, - password: password, - displayName: displayName.isEmpty - ? null - : displayName, - ); - } else { - await service.signIn( - email: email, - password: password, - ); - } - - if (!context.mounted) return; - Navigator.of(context).pop(); - ScaffoldMessenger.of(sheetContext).showSnackBar( - SnackBar( - content: Text( - isRegisterMode - ? 'Account created and signed in.' - : 'Signed in successfully.', - ), - backgroundColor: Colors.green, - ), - ); - } on BackendApiException catch (error) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(error.message)), - ); - } catch (error) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Sign-in failed: $error'), - ), - ); - } finally { - if (context.mounted) { - setState(() => isSubmitting = false); - } - } - }, - ), - const SizedBox(height: AppSpacing.sm), - AppButton( - label: isRegisterMode - ? 'Already have an account? Sign in' - : 'Create new account', - variant: AppButtonVariant.ghost, - onPressed: isSubmitting - ? null - : () { - setState(() => isRegisterMode = !isRegisterMode); - }, - ), - if ((existingSession?.isAuthenticated ?? false)) ...[ - const SizedBox(height: AppSpacing.sm), - AppButton( - label: 'Sign out', - variant: AppButtonVariant.secondary, - onPressed: isSubmitting - ? null - : () async { - final service = ref.read( - backendAuthServiceProvider, - ); - if (service != null) { - await service.signOut(); - } else { - await ref - .read(authSessionStoreProvider) - .clearSession(); - } - if (!context.mounted) return; - Navigator.of(context).pop(); - ScaffoldMessenger.of(sheetContext).showSnackBar( - const SnackBar( - content: Text('Sync session cleared.'), - ), - ); - }, - ), - ], - ], - ); - }, - ); - }, - ); - } finally { - emailController.dispose(); - passwordController.dispose(); - displayNameController.dispose(); - } + if (!context.mounted) return; + final message = result.totalOperations > 0 + ? 'Rebuilt queue with ${result.totalOperations} local change(s). Run Sync now.' + : 'No local data found to queue for sync.'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: result.totalOperations > 0 + ? Colors.green + : Colors.orange, + ), + ); } Future _showSyncApiConfigurationHelp( @@ -662,7 +508,7 @@ class SettingsScreen extends ConsumerWidget { ), const SizedBox(height: AppSpacing.sm), Text( - 'Set your local API URL. Android emulator: 10.0.2.2. iOS simulator: localhost. Physical device: use your computer LAN IP.', + 'Enter backend API base URL used for sync and authentication.', style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, ), @@ -725,23 +571,115 @@ class SettingsScreen extends ConsumerWidget { controller.dispose(); } - Future _showFeatureDisabledHelp(BuildContext context) async { - await showAppDialog( + Future _showCloudSyncDiagnosticsSheet( + BuildContext context, + WidgetRef ref, + ) async { + await showAppSheet( context: context, - title: const Text('Feature disabled'), - content: const Text( - 'Cloud sync/backend integration is disabled by feature flags. Enable ' - 'Remote Config keys app_backend_integration_enabled + ' - 'app_sync_feature_enabled, or use --dart-define BACKEND_INTEGRATION_ENABLED=true ' - 'and BACKEND_SYNC_ENABLED=true for local development.', - ), - actions: [ - AppButton( - label: context.l10n.actionDismiss, - variant: AppButtonVariant.ghost, - onPressed: () => closeAppDialog(context), - ), - ], + builder: (sheetContext) { + return Consumer( + builder: (sheetContext, ref, _) { + final readiness = ref.watch(syncReadinessProvider); + final transportConfig = ref.watch(syncTransportConfigProvider); + final pendingCount = + ref.watch(syncOutboxCountProvider).asData?.value ?? 0; + final session = ref.watch(authSessionProvider).value; + final hasSession = session?.isAuthenticated ?? false; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Cloud Sync Diagnostics', + style: Theme.of(sheetContext).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppSpacing.sm), + _CloudSyncStateRow( + label: 'Readiness', + value: readiness.status.name, + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow(label: 'Message', value: readiness.message), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Backend flag', + value: transportConfig.backendFeatureEnabled + ? 'Enabled' + : 'Disabled', + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Sync flag', + value: transportConfig.syncFeatureEnabled + ? 'Enabled' + : 'Disabled', + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Base URL', + value: transportConfig.baseUrl.isEmpty + ? 'Not configured' + : transportConfig.baseUrl, + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Resolved URL', + value: transportConfig.isApiBaseUrlConfigured + ? transportConfig.normalizedApiBaseUrl + : 'Not available', + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Outbox', + value: '$pendingCount pending', + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Auth', + value: hasSession ? 'Signed in' : 'Signed out', + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Device', + value: session?.deviceId ?? 'Unavailable', + ), + const SizedBox(height: AppSpacing.lg), + if (!transportConfig.isApiBaseUrlConfigured) ...[ + AppButton( + label: 'Configure API', + onPressed: () async { + Navigator.of(sheetContext).pop(); + await _showSyncApiConfigurationHelp(context, ref); + }, + expand: true, + ), + const SizedBox(height: AppSpacing.sm), + ], + AppButton( + label: 'Rebuild local sync queue', + variant: AppButtonVariant.secondary, + onPressed: () async { + Navigator.of(sheetContext).pop(); + await _rebuildSyncOutboxFromLocal(context, ref); + }, + expand: true, + ), + const SizedBox(height: AppSpacing.sm), + AppButton( + label: sheetContext.l10n.actionDismiss, + variant: AppButtonVariant.ghost, + onPressed: () => Navigator.of(sheetContext).pop(), + expand: true, + ), + ], + ); + }, + ); + }, ); } @@ -1556,27 +1494,37 @@ class _SettingsTile extends StatelessWidget { final String title; final String? subtitle; final VoidCallback? onTap; + final bool enabled; const _SettingsTile({ required this.icon, required this.title, this.subtitle, this.onTap, + this.enabled = true, }); @override Widget build(BuildContext context) { + final canTap = enabled && onTap != null; + return ListTile( + enabled: enabled, leading: Icon(icon), title: Text(title), subtitle: subtitle != null ? Text(subtitle!) : null, - trailing: onTap != null + trailing: canTap ? Icon( Icons.chevron_right_rounded, color: Theme.of(context).colorScheme.onSurfaceVariant, ) - : null, - onTap: onTap, + : (!enabled + ? Icon( + Icons.lock_outline_rounded, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ) + : null), + onTap: canTap ? onTap : null, ); } } diff --git a/packages/common/ui/lib/src/widgets/app_input.dart b/packages/common/ui/lib/src/widgets/app_input.dart index bc950ce..618cb1f 100644 --- a/packages/common/ui/lib/src/widgets/app_input.dart +++ b/packages/common/ui/lib/src/widgets/app_input.dart @@ -16,6 +16,11 @@ class AppInput extends StatelessWidget { final bool readOnly; final bool autofocus; final bool enabled; + final bool obscureText; + final bool autocorrect; + final bool enableSuggestions; + final SmartDashesType? smartDashesType; + final SmartQuotesType? smartQuotesType; final int maxLines; final TextCapitalization textCapitalization; final FormFieldValidator? validator; @@ -37,6 +42,11 @@ class AppInput extends StatelessWidget { this.readOnly = false, this.autofocus = false, this.enabled = true, + this.obscureText = false, + this.autocorrect = true, + this.enableSuggestions = true, + this.smartDashesType, + this.smartQuotesType, this.maxLines = 1, this.textCapitalization = TextCapitalization.none, this.validator, @@ -73,6 +83,11 @@ class AppInput extends StatelessWidget { readOnly: readOnly, autofocus: autofocus, enabled: enabled, + obscureText: obscureText, + autocorrect: autocorrect, + enableSuggestions: enableSuggestions, + smartDashesType: smartDashesType, + smartQuotesType: smartQuotesType, maxLines: maxLines, textCapitalization: textCapitalization, validator: validator, diff --git a/packages/integrations/backend_api/lib/src/clients/backend_auth_client.dart b/packages/integrations/backend_api/lib/src/clients/backend_auth_client.dart index 74d4324..2b3963e 100644 --- a/packages/integrations/backend_api/lib/src/clients/backend_auth_client.dart +++ b/packages/integrations/backend_api/lib/src/clients/backend_auth_client.dart @@ -107,15 +107,12 @@ class BackendAuthClient { BackendApiException _mapDioError(DioException error) { final statusCode = error.response?.statusCode; final responseData = error.response?.data; + final fallbackMessage = error.message ?? 'Backend API request failed'; if (responseData is Map) { final map = responseData.cast(); - final message = - (map['message'] as String?) ?? - _extractMessageFromNestedData(map) ?? - error.message ?? - 'Backend API request failed'; - final code = map['code'] as String?; + final message = _extractMessage(map) ?? fallbackMessage; + final code = _extractCode(map); return BackendApiException( message: message, statusCode: statusCode, @@ -125,20 +122,86 @@ class BackendAuthClient { } return BackendApiException( - message: error.message ?? 'Backend API request failed', + message: _normalizeMessage(responseData) ?? fallbackMessage, statusCode: statusCode, raw: responseData, ); } - String? _extractMessageFromNestedData(Map map) { + String? _extractMessage(Map map) { + final direct = + _normalizeMessage(map['message']) ?? _normalizeMessage(map['error']); + if (direct != null) { + return direct; + } + + final data = map['data']; + if (data is Map) { + return _normalizeMessage(data['message']) ?? + _normalizeMessage(data['error']); + } + + return null; + } + + String? _extractCode(Map map) { + final directCode = _normalizeCode(map['code']); + if (directCode != null) { + return directCode; + } + final data = map['data']; if (data is Map) { - return data['message'] as String?; + return _normalizeCode(data['code']); } + return null; } + String? _normalizeCode(Object? value) { + if (value == null) { + return null; + } + + final text = value.toString().trim(); + return text.isEmpty ? null : text; + } + + String? _normalizeMessage(Object? value) { + if (value == null) { + return null; + } + + if (value is String) { + final text = value.trim(); + return text.isEmpty ? null : text; + } + + if (value is List) { + final parts = value + .map(_normalizeMessage) + .whereType() + .where((text) => text.isNotEmpty) + .toList(); + if (parts.isEmpty) { + return null; + } + return parts.join(', '); + } + + if (value is Map) { + final nested = + _normalizeMessage(value['message']) ?? + _normalizeMessage(value['error']); + if (nested != null) { + return nested; + } + } + + final text = value.toString().trim(); + return text.isEmpty ? null : text; + } + Map _unwrapToMap(Object? raw) { if (raw is! Map) { return const {}; diff --git a/packages/integrations/database/lib/src/daos/item_dao.dart b/packages/integrations/database/lib/src/daos/item_dao.dart index 5068819..bc8007f 100644 --- a/packages/integrations/database/lib/src/daos/item_dao.dart +++ b/packages/integrations/database/lib/src/daos/item_dao.dart @@ -29,6 +29,10 @@ class ItemDao extends DatabaseAccessor with _$ItemDaoMixin { .toList(); } + Future> getAllTags() { + return (select(tags)..orderBy([(tbl) => OrderingTerm.asc(tbl.name)])).get(); + } + // Watch all tags with usage count Stream> watchTagsWithUsage() { return customSelect( From 0bc3136fa6a337e0864d11f089985c786fd888e8 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 22:45:24 +0630 Subject: [PATCH 15/31] feat: Implement operational telemetry to track data transfer and sync events with detailed metrics and refine backend feature flag logic. --- .../lib/core/bootstrap/app_bootstrap.dart | 10 + .../firebase_services_bootstrap.dart | 10 + .../observability/operational_telemetry.dart | 322 ++++++++++++++++++ .../core/providers/backend_api_providers.dart | 16 +- .../operational_telemetry_provider.dart | 32 ++ apps/mobile/lib/core/providers/providers.dart | 1 + .../lib/core/sync/sync_orchestrator.dart | 66 +++- .../view_models/export_import_view_model.dart | 74 ++++ .../presentation/views/settings_screen.dart | 278 ++++++++++++++- 9 files changed, 782 insertions(+), 27 deletions(-) create mode 100644 apps/mobile/lib/core/observability/operational_telemetry.dart create mode 100644 apps/mobile/lib/core/providers/operational_telemetry_provider.dart diff --git a/apps/mobile/lib/core/bootstrap/app_bootstrap.dart b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart index 68954de..4aa5964 100644 --- a/apps/mobile/lib/core/bootstrap/app_bootstrap.dart +++ b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart @@ -2,6 +2,7 @@ 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'; @@ -32,6 +33,15 @@ abstract final class AppBootstrap { analyticsCollectionEnabled: firebaseRuntimeConfig.analyticsCollectionEnabled, ); + await OperationalTelemetry.trackRuntimeConfigApplied( + source: 'bootstrap', + analyticsEnabled: firebaseRuntimeConfig.analyticsCollectionEnabled, + crashlyticsEnabled: firebaseRuntimeConfig.crashlyticsCollectionEnabled, + performanceEnabled: firebaseRuntimeConfig.performanceCollectionEnabled, + backendEnabled: firebaseRuntimeConfig.backendIntegrationEnabled, + syncEnabled: firebaseRuntimeConfig.syncFeatureEnabled, + didActivateChanges: null, + ); final onboardingComplete = await PrefsStorageService.instance.get('onboarding_complete') ?? diff --git a/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart index d4d49f5..1689d08 100644 --- a/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart +++ b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart @@ -1,6 +1,7 @@ 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'; @@ -94,6 +95,15 @@ abstract final class FirebaseServicesBootstrap { 'backend: ${runtimeConfig.backendIntegrationEnabled}, ' 'sync: ${runtimeConfig.syncFeatureEnabled}).', ); + await OperationalTelemetry.trackRuntimeConfigApplied( + source: forceFetch ? 'manual_refresh' : 'auto_refresh', + analyticsEnabled: runtimeConfig.analyticsCollectionEnabled, + crashlyticsEnabled: runtimeConfig.crashlyticsCollectionEnabled, + performanceEnabled: runtimeConfig.performanceCollectionEnabled, + backendEnabled: runtimeConfig.backendIntegrationEnabled, + syncEnabled: runtimeConfig.syncFeatureEnabled, + didActivateChanges: didActivateChanges, + ); return FirebaseRuntimeConfigRefreshResult( runtimeConfig: runtimeConfig, 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..ab88e93 --- /dev/null +++ b/apps/mobile/lib/core/observability/operational_telemetry.dart @@ -0,0 +1,322 @@ +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, + 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, + 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, + }, + ); + } + + 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 backendEnabled, + 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, + 'backend_enabled': backendEnabled, + 'sync_enabled': syncEnabled, + 'changed': ?didActivateChanges, + }, + crashlyticsLog: true, + crashlyticsKeys: { + 'flag_analytics_enabled': analyticsEnabled, + 'flag_crashlytics_enabled': crashlyticsEnabled, + 'flag_performance_enabled': performanceEnabled, + 'flag_backend_enabled': backendEnabled, + 'flag_sync_enabled': syncEnabled, + }, + ); + } + + 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 >[]; + } + + final decoded = raw + .whereType() + .map((entry) => entry.cast()) + .toList(growable: false); + if (decoded.length <= limit) { + return decoded; + } + return decoded.take(limit).toList(growable: false); + } catch (_) { + return const >[]; + } + } + + static Future clearHistory() async { + try { + await PrefsStorageService.instance.delete(_historyKey); + } catch (_) { + // Keep operational telemetry best-effort. + } + } + + static Map _compact(Map source) { + final result = {}; + for (final entry in source.entries) { + if (entry.value != null) { + result[entry.key] = entry.value; + } + } + return result; + } + + static Map _stringifyValues(Map source) { + final result = {}; + for (final entry in source.entries) { + result[entry.key] = '${entry.value}'; + } + return result; + } + + static bool get _canUseCrashlytics => !kIsWeb && Firebase.apps.isNotEmpty; + + static Future _persistEvent({ + required String name, + required String category, + required Map properties, + required bool hasError, + }) async { + try { + final existing = await loadRecentHistory(limit: _maxHistoryEntries); + final next = >[ + { + 'name': name, + 'category': category, + 'timestamp': DateTime.now().toUtc().toIso8601String(), + 'has_error': hasError, + 'properties': _stringifyValues(properties), + }, + ...existing, + ]; + + if (next.length > _maxHistoryEntries) { + next.removeRange(_maxHistoryEntries, next.length); + } + + await PrefsStorageService.instance.save>(_historyKey, next); + } catch (_) { + // Keep operational telemetry best-effort. + } + } +} diff --git a/apps/mobile/lib/core/providers/backend_api_providers.dart b/apps/mobile/lib/core/providers/backend_api_providers.dart index c7edb38..244b96d 100644 --- a/apps/mobile/lib/core/providers/backend_api_providers.dart +++ b/apps/mobile/lib/core/providers/backend_api_providers.dart @@ -45,20 +45,28 @@ class BackendApiBaseUrlOverrideController extends Notifier { final backendIntegrationFeatureFlagProvider = Provider((ref) { final runtimeConfig = ref.watch(firebaseRuntimeConfigProvider); - const envOverride = bool.fromEnvironment( + if (runtimeConfig.backendIntegrationEnabled) { + return true; + } + + const debugEnvOverride = bool.fromEnvironment( 'BACKEND_INTEGRATION_ENABLED', defaultValue: false, ); - return runtimeConfig.backendIntegrationEnabled || envOverride; + return kDebugMode && debugEnvOverride; }); final backendSyncFeatureFlagProvider = Provider((ref) { final runtimeConfig = ref.watch(firebaseRuntimeConfigProvider); - const envOverride = bool.fromEnvironment( + if (runtimeConfig.syncFeatureEnabled) { + return true; + } + + const debugEnvOverride = bool.fromEnvironment( 'BACKEND_SYNC_ENABLED', defaultValue: false, ); - return runtimeConfig.syncFeatureEnabled || envOverride; + return kDebugMode && debugEnvOverride; }); final backendApiPrefixProvider = Provider((ref) { diff --git a/apps/mobile/lib/core/providers/operational_telemetry_provider.dart b/apps/mobile/lib/core/providers/operational_telemetry_provider.dart new file mode 100644 index 0000000..8b1c8e1 --- /dev/null +++ b/apps/mobile/lib/core/providers/operational_telemetry_provider.dart @@ -0,0 +1,32 @@ +import 'package:collection_tracker/core/observability/operational_telemetry.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final operationalTelemetryRefreshTokenProvider = + NotifierProvider( + OperationalTelemetryRefreshTokenNotifier.new, + ); + +class OperationalTelemetryRefreshTokenNotifier extends Notifier { + @override + int build() => 0; + + void bump() { + state = state + 1; + } +} + +final operationalTelemetryHistoryProvider = + FutureProvider>>((ref) async { + ref.watch(operationalTelemetryRefreshTokenProvider); + return OperationalTelemetry.loadRecentHistory(limit: 80); + }); + +Future refreshOperationalTelemetryHistory(WidgetRef ref) async { + ref.read(operationalTelemetryRefreshTokenProvider.notifier).bump(); + await ref.read(operationalTelemetryHistoryProvider.future); +} + +Future clearOperationalTelemetryHistory(WidgetRef ref) async { + await OperationalTelemetry.clearHistory(); + ref.read(operationalTelemetryRefreshTokenProvider.notifier).bump(); +} diff --git a/apps/mobile/lib/core/providers/providers.dart b/apps/mobile/lib/core/providers/providers.dart index a071d2e..d6e8693 100644 --- a/apps/mobile/lib/core/providers/providers.dart +++ b/apps/mobile/lib/core/providers/providers.dart @@ -6,6 +6,7 @@ export 'analytics_preferences_provider.dart'; export 'auth_session_providers.dart'; export 'backend_api_providers.dart'; export 'firebase_runtime_config_provider.dart'; +export 'operational_telemetry_provider.dart'; export 'theme_provider.dart'; export 'items_view_mode_provider.dart'; export 'collections_view_mode_provider.dart'; diff --git a/apps/mobile/lib/core/sync/sync_orchestrator.dart b/apps/mobile/lib/core/sync/sync_orchestrator.dart index 713877f..94cce91 100644 --- a/apps/mobile/lib/core/sync/sync_orchestrator.dart +++ b/apps/mobile/lib/core/sync/sync_orchestrator.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:app_firebase/app_firebase.dart'; import 'package:database/database.dart'; import 'package:sync_api/sync_api.dart'; import 'package:uuid/uuid.dart'; @@ -14,12 +15,28 @@ class SyncAttemptResult { required this.success, required this.message, this.error, + this.stackTrace, + this.pendingOperations = 0, + this.processedOperations = 0, + this.syncedCollections = 0, + this.syncedItems = 0, + this.syncedTags = 0, + this.conflictCount = 0, + this.partial = false, }); final bool executed; final bool success; final String message; final Object? error; + final StackTrace? stackTrace; + final int pendingOperations; + final int processedOperations; + final int syncedCollections; + final int syncedItems; + final int syncedTags; + final int conflictCount; + final bool partial; } class SyncOrchestrator { @@ -79,17 +96,20 @@ class SyncOrchestrator { required String deviceId, bool forceFullSync = false, }) async { + final pending = await _syncDao.getPendingOperations(limit: _maxBatchSize); + if (_backendClient is NoopSyncBackendClient) { final client = _backendClient; return SyncAttemptResult( executed: false, success: false, message: client.message, + pendingOperations: pending.length, + partial: false, ); } final state = await _syncDao.getSyncState(); - final pending = await _syncDao.getPendingOperations(limit: _maxBatchSize); await _syncDao.upsertSyncState(lastAttemptedSyncAt: DateTime.now()); @@ -99,19 +119,28 @@ class SyncOrchestrator { executed: false, success: true, message: 'No pending operations to sync.', + partial: false, ); } final changes = _buildChangesPayload(pending); + final performanceService = FirebasePerformanceService.instance; try { - final response = await _backendClient.sync( - SyncRequestPayload( - deviceId: deviceId, - clientRequestId: _uuid.v4(), - lastSyncAt: forceFullSync ? null : state?.lastSuccessfulSyncAt, - changes: changes.isEmpty ? null : changes, + final response = await performanceService.traceAsync( + 'sync_push_pull_now', + () => _backendClient.sync( + SyncRequestPayload( + deviceId: deviceId, + clientRequestId: _uuid.v4(), + lastSyncAt: forceFullSync ? null : state?.lastSuccessfulSyncAt, + changes: changes.isEmpty ? null : changes, + ), ), + attributes: { + 'force_full_sync': forceFullSync ? '1' : '0', + 'pending_operations': '${pending.length}', + }, ); final processedOperations = _processedOperationCount(response); @@ -135,6 +164,14 @@ class SyncOrchestrator { 'Sync partially processed. Local queue kept for retry. ' 'Processed $processedOperations of ${pending.length} change(s).', error: errorText, + stackTrace: null, + pendingOperations: pending.length, + processedOperations: processedOperations, + syncedCollections: response.syncedCollections, + syncedItems: response.syncedItems, + syncedTags: response.syncedTags, + conflictCount: response.conflicts.length, + partial: true, ); } @@ -153,6 +190,13 @@ class SyncOrchestrator { message: 'Sync completed: ${response.syncedCollections} collections, ' '${response.syncedItems} items, ${response.syncedTags} tags.', + pendingOperations: pending.length, + processedOperations: processedOperations, + syncedCollections: response.syncedCollections, + syncedItems: response.syncedItems, + syncedTags: response.syncedTags, + conflictCount: response.conflicts.length, + partial: false, ); } on SyncAuthRequiredException catch (error) { return SyncAttemptResult( @@ -160,8 +204,11 @@ class SyncOrchestrator { success: false, message: error.message, error: error, + stackTrace: null, + pendingOperations: pending.length, + partial: false, ); - } catch (error) { + } catch (error, stackTrace) { final errorText = '$error'; for (final op in pending) { await _syncDao.markOperationFailed(op.id, errorText); @@ -176,6 +223,9 @@ class SyncOrchestrator { success: false, message: 'Sync failed.', error: error, + stackTrace: stackTrace, + pendingOperations: pending.length, + partial: false, ); } } diff --git a/apps/mobile/lib/features/settings/presentation/view_models/export_import_view_model.dart b/apps/mobile/lib/features/settings/presentation/view_models/export_import_view_model.dart index f8f663a..81f5390 100644 --- a/apps/mobile/lib/features/settings/presentation/view_models/export_import_view_model.dart +++ b/apps/mobile/lib/features/settings/presentation/view_models/export_import_view_model.dart @@ -1,4 +1,5 @@ import 'package:app_firebase/app_firebase.dart'; +import 'package:collection_tracker/core/observability/operational_telemetry.dart'; import 'package:collection_tracker/core/providers/providers.dart'; import 'package:database/database.dart'; import 'package:domain/domain.dart'; @@ -17,6 +18,9 @@ class ExportImportViewModel extends _$ExportImportViewModel { Future exportAllDataToJson() async { state = const AsyncValue.loading(); final performanceService = FirebasePerformanceService.instance; + final stopwatch = Stopwatch()..start(); + var collectionCount = 0; + var itemCount = 0; final result = await AsyncValue.guard( () => performanceService.traceAsync( @@ -31,6 +35,7 @@ class ExportImportViewModel extends _$ExportImportViewModel { (exception) => throw exception, (data) => data, ); + collectionCount = collections.length; final allItems = []; for (final collection in collections) { @@ -42,6 +47,7 @@ class ExportImportViewModel extends _$ExportImportViewModel { (items) => allItems.addAll(items), ); } + itemCount = allItems.length; final exportData = { 'version': '1.0.0', @@ -87,17 +93,38 @@ class ExportImportViewModel extends _$ExportImportViewModel { ); _setStateSafely(result); + stopwatch.stop(); if (result.hasError) { + await OperationalTelemetry.trackDataTransfer( + operation: 'export_json', + success: false, + durationMs: stopwatch.elapsedMilliseconds, + collectionCount: collectionCount, + itemCount: itemCount, + error: result.error, + stackTrace: result.stackTrace, + ); throw result.error!; } + await OperationalTelemetry.trackDataTransfer( + operation: 'export_json', + success: true, + durationMs: stopwatch.elapsedMilliseconds, + collectionCount: collectionCount, + itemCount: itemCount, + ); + return result.value!; } Future exportItemsToCsv() async { state = const AsyncValue.loading(); final performanceService = FirebasePerformanceService.instance; + final stopwatch = Stopwatch()..start(); + var collectionCount = 0; + var itemCount = 0; final result = await AsyncValue.guard( () => @@ -111,6 +138,7 @@ class ExportImportViewModel extends _$ExportImportViewModel { (exception) => throw exception, (data) => data, ); + collectionCount = collections.length; final allItems = >[]; for (final collection in collections) { @@ -135,23 +163,45 @@ class ExportImportViewModel extends _$ExportImportViewModel { } }); } + itemCount = allItems.length; return exportService.exportToCsv(allItems); }), ); _setStateSafely(result); + stopwatch.stop(); if (result.hasError) { + await OperationalTelemetry.trackDataTransfer( + operation: 'export_csv', + success: false, + durationMs: stopwatch.elapsedMilliseconds, + collectionCount: collectionCount, + itemCount: itemCount, + error: result.error, + stackTrace: result.stackTrace, + ); throw result.error!; } + await OperationalTelemetry.trackDataTransfer( + operation: 'export_csv', + success: true, + durationMs: stopwatch.elapsedMilliseconds, + collectionCount: collectionCount, + itemCount: itemCount, + ); + return result.value!; } Future importFromJson() async { state = const AsyncValue.loading(); final performanceService = FirebasePerformanceService.instance; + final stopwatch = Stopwatch()..start(); + var collectionCount = 0; + var itemCount = 0; final result = await AsyncValue.guard( () => performanceService.traceAsync( @@ -164,6 +214,7 @@ class ExportImportViewModel extends _$ExportImportViewModel { final data = await exportService.importFromJson(); final collections = data['collections'] as List; + collectionCount = collections.length; for (final collectionData in collections) { final companion = CollectionsCompanion( id: Value(collectionData['id'] as String), @@ -182,6 +233,7 @@ class ExportImportViewModel extends _$ExportImportViewModel { } final items = data['items'] as List; + itemCount = items.length; for (final itemData in items) { final companion = ItemsCompanion( id: Value(itemData['id'] as String), @@ -214,6 +266,28 @@ class ExportImportViewModel extends _$ExportImportViewModel { ); _setStateSafely(result); + stopwatch.stop(); + + if (result.hasError) { + await OperationalTelemetry.trackDataTransfer( + operation: 'import_json', + success: false, + durationMs: stopwatch.elapsedMilliseconds, + collectionCount: collectionCount, + itemCount: itemCount, + error: result.error, + stackTrace: result.stackTrace, + ); + throw result.error!; + } + + await OperationalTelemetry.trackDataTransfer( + operation: 'import_json', + success: true, + durationMs: stopwatch.elapsedMilliseconds, + collectionCount: collectionCount, + itemCount: itemCount, + ); } void _setStateSafely(AsyncValue value) { diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart index fe8801d..53410e3 100644 --- a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart +++ b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart @@ -3,6 +3,7 @@ import 'package:auth_session/auth_session.dart'; import 'package:collection_tracker/core/analytics/analytics_consent_dialog.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:collection_tracker/core/providers/providers.dart'; import 'package:collection_tracker/core/router/routes.dart'; import 'package:collection_tracker/l10n/l10n.dart'; @@ -176,6 +177,12 @@ class SettingsScreen extends ConsumerWidget { subtitle: 'Debug sync transport and auth readiness', onTap: () => _showCloudSyncDiagnosticsSheet(context, ref), ), + _SettingsTile( + icon: Icons.monitor_heart_outlined, + title: 'Operational Telemetry', + subtitle: 'Inspect recent sync/data/runtime events', + onTap: () => _showOperationalTelemetrySheet(context, ref), + ), _SettingsTile( icon: Icons.bug_report_outlined, title: l10n.settingsCrashlyticsTestTitle, @@ -428,6 +435,16 @@ class SettingsScreen extends ConsumerWidget { } Future _triggerSyncNow(BuildContext context, WidgetRef ref) async { + final readiness = ref.read(syncReadinessProvider); + final pendingBefore = await ref + .read(syncOrchestratorProvider) + .getPendingOperationCount(); + await OperationalTelemetry.trackSyncAttempt( + trigger: 'settings_manual_sync', + readinessStatus: readiness.status.name, + pendingBefore: pendingBefore, + ); + final session = ref.read(authSessionProvider).asData?.value; final deviceId = session?.deviceId; if (deviceId == null || deviceId.trim().isEmpty) { @@ -443,10 +460,28 @@ class SettingsScreen extends ConsumerWidget { final bootstrapResult = await ref .read(syncOutboxBootstrapperProvider) .seedFromLocalDataIfNeeded(); + await OperationalTelemetry.trackSyncSeed( + queuedOperations: bootstrapResult.totalOperations, + skipped: bootstrapResult.skipped, + ); final result = await ref .read(syncOrchestratorProvider) .syncNow(deviceId: deviceId); + await OperationalTelemetry.trackSyncResult( + success: result.success, + executed: result.executed, + partial: result.partial, + pendingOperations: result.pendingOperations, + processedOperations: result.processedOperations, + syncedCollections: result.syncedCollections, + syncedItems: result.syncedItems, + syncedTags: result.syncedTags, + conflictCount: result.conflictCount, + message: result.message, + error: result.error, + stackTrace: result.stackTrace, + ); if (!context.mounted) return; final bootstrapMessage = bootstrapResult.totalOperations > 0 @@ -467,22 +502,42 @@ class SettingsScreen extends ConsumerWidget { BuildContext context, WidgetRef ref, ) async { - final result = await ref - .read(syncOutboxBootstrapperProvider) - .rebuildFromLocalData(); + try { + final result = await ref + .read(syncOutboxBootstrapperProvider) + .rebuildFromLocalData(); + await OperationalTelemetry.trackSyncQueueRebuild( + success: true, + queuedOperations: result.totalOperations, + ); - if (!context.mounted) return; - final message = result.totalOperations > 0 - ? 'Rebuilt queue with ${result.totalOperations} local change(s). Run Sync now.' - : 'No local data found to queue for sync.'; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - backgroundColor: result.totalOperations > 0 - ? Colors.green - : Colors.orange, - ), - ); + if (!context.mounted) return; + final message = result.totalOperations > 0 + ? 'Rebuilt queue with ${result.totalOperations} local change(s). Run Sync now.' + : 'No local data found to queue for sync.'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: result.totalOperations > 0 + ? Colors.green + : Colors.orange, + ), + ); + } catch (error, stackTrace) { + await OperationalTelemetry.trackSyncQueueRebuild( + success: false, + queuedOperations: 0, + error: error, + stackTrace: stackTrace, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to rebuild local sync queue: $error'), + backgroundColor: Colors.red, + ), + ); + } } Future _showSyncApiConfigurationHelp( @@ -683,6 +738,110 @@ class SettingsScreen extends ConsumerWidget { ); } + Future _showOperationalTelemetrySheet( + BuildContext context, + WidgetRef ref, + ) async { + await showAppSheet( + context: context, + builder: (sheetContext) { + final maxHeight = MediaQuery.sizeOf(sheetContext).height * 0.72; + return SizedBox( + height: maxHeight, + child: Consumer( + builder: (sheetContext, ref, _) { + final historyAsync = ref.watch( + operationalTelemetryHistoryProvider, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Operational Telemetry', + style: Theme.of(sheetContext).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Recent sync/data/runtime events captured locally for debug.', + style: Theme.of(sheetContext).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + sheetContext, + ).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.md), + Expanded( + child: historyAsync.when( + data: (entries) { + if (entries.isEmpty) { + return Center( + child: Text( + 'No telemetry events yet.', + style: Theme.of(sheetContext).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + sheetContext, + ).colorScheme.onSurfaceVariant, + ), + ), + ); + } + + return ListView.separated( + itemCount: entries.length, + separatorBuilder: (_, _) => + const SizedBox(height: AppSpacing.sm), + itemBuilder: (context, index) { + return _OperationalTelemetryEventTile( + entry: entries[index], + ); + }, + ); + }, + loading: () => + const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text( + 'Failed to load telemetry: $error', + textAlign: TextAlign.center, + ), + ), + ), + ), + const SizedBox(height: AppSpacing.md), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + AppButton( + label: 'Refresh', + variant: AppButtonVariant.secondary, + onPressed: () => + refreshOperationalTelemetryHistory(ref), + ), + AppButton( + label: 'Clear', + variant: AppButtonVariant.ghost, + onPressed: () => clearOperationalTelemetryHistory(ref), + ), + AppButton( + label: sheetContext.l10n.actionDismiss, + variant: AppButtonVariant.ghost, + onPressed: () => Navigator.of(sheetContext).pop(), + ), + ], + ), + ], + ); + }, + ), + ); + }, + ); + } + Future _showThemeSelector(BuildContext context, WidgetRef ref) async { await showAppSheet( context: context, @@ -1489,6 +1648,95 @@ class _CloudSyncStateRow extends StatelessWidget { } } +class _OperationalTelemetryEventTile extends StatelessWidget { + const _OperationalTelemetryEventTile({required this.entry}); + + final Map entry; + + @override + Widget build(BuildContext context) { + final name = entry['name'] as String? ?? 'unknown_event'; + final category = entry['category'] as String? ?? 'unknown'; + final hasError = entry['has_error'] as bool? ?? false; + final timestamp = _formatTimestamp(entry['timestamp'] as String?); + final rawProperties = entry['properties']; + final properties = rawProperties is Map + ? rawProperties.cast() + : const {}; + final preview = properties.entries + .take(4) + .map((entry) => '${entry.key}: ${entry.value}') + .join(' | '); + + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppSpacing.md), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.25), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + hasError ? Icons.error_outline : Icons.check_circle_outline, + size: 18, + color: hasError + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: AppSpacing.xs), + Expanded( + child: Text( + name, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xs), + Text( + '$category • $timestamp', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + if (preview.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + preview, + style: Theme.of(context).textTheme.bodySmall, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ); + } + + String _formatTimestamp(String? value) { + if (value == null || value.isEmpty) { + return 'unknown time'; + } + final parsed = DateTime.tryParse(value); + if (parsed == null) { + return value; + } + final local = parsed.toLocal(); + return '${local.year}-${_two(local.month)}-${_two(local.day)} ' + '${_two(local.hour)}:${_two(local.minute)}:${_two(local.second)}'; + } + + String _two(int value) => value < 10 ? '0$value' : '$value'; +} + class _SettingsTile extends StatelessWidget { final IconData icon; final String title; From ce283a599f9ea89323982ddb6552998ec749d451 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 22:59:44 +0630 Subject: [PATCH 16/31] feat: Implement server-side change application within the sync process, enhancing telemetry and network error handling. --- .../observability/operational_telemetry.dart | 16 + .../lib/core/providers/sync_providers.dart | 15 +- .../lib/core/sync/sync_orchestrator.dart | 100 ++++- .../sync/sync_server_changes_applier.dart | 368 ++++++++++++++++++ .../presentation/views/settings_screen.dart | 6 + .../integrations/database/lib/database.dart | 2 +- 6 files changed, 494 insertions(+), 13 deletions(-) create mode 100644 apps/mobile/lib/core/sync/sync_server_changes_applier.dart diff --git a/apps/mobile/lib/core/observability/operational_telemetry.dart b/apps/mobile/lib/core/observability/operational_telemetry.dart index ab88e93..adf7b49 100644 --- a/apps/mobile/lib/core/observability/operational_telemetry.dart +++ b/apps/mobile/lib/core/observability/operational_telemetry.dart @@ -51,6 +51,12 @@ class OperationalTelemetry { 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, @@ -68,6 +74,12 @@ class OperationalTelemetry { '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, @@ -81,6 +93,10 @@ class OperationalTelemetry { 'sync_executed': executed, 'sync_pending_ops': pendingOperations, 'sync_processed_ops': processedOperations, + 'sync_applied_server': + appliedServerCollections + appliedServerItems + appliedServerTags, + 'sync_skipped_server': + skippedServerCollections + skippedServerItems + skippedServerTags, }, ); } diff --git a/apps/mobile/lib/core/providers/sync_providers.dart b/apps/mobile/lib/core/providers/sync_providers.dart index ee5b272..0f7083d 100644 --- a/apps/mobile/lib/core/providers/sync_providers.dart +++ b/apps/mobile/lib/core/providers/sync_providers.dart @@ -3,6 +3,7 @@ import 'package:collection_tracker/core/providers/auth_session_providers.dart'; import 'package:collection_tracker/core/providers/backend_api_providers.dart'; import 'package:collection_tracker/core/sync/sync_outbox_bootstrapper.dart'; import 'package:collection_tracker/core/sync/sync_orchestrator.dart'; +import 'package:collection_tracker/core/sync/sync_server_changes_applier.dart'; import 'package:database/database.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; @@ -213,7 +214,19 @@ final syncReadinessProvider = Provider((ref) { final syncOrchestratorProvider = Provider((ref) { final dao = ref.watch(syncDaoProvider); final backendClient = ref.watch(syncBackendClientProvider); - return SyncOrchestrator(syncDao: dao, backendClient: backendClient); + final serverChangesApplier = ref.watch(syncServerChangesApplierProvider); + return SyncOrchestrator( + syncDao: dao, + backendClient: backendClient, + serverChangesApplier: serverChangesApplier, + ); +}); + +final syncServerChangesApplierProvider = Provider(( + ref, +) { + final database = ref.watch(appDatabaseProvider); + return SyncServerChangesApplier(database: database); }); final syncOutboxBootstrapperProvider = Provider((ref) { diff --git a/apps/mobile/lib/core/sync/sync_orchestrator.dart b/apps/mobile/lib/core/sync/sync_orchestrator.dart index 94cce91..e11494d 100644 --- a/apps/mobile/lib/core/sync/sync_orchestrator.dart +++ b/apps/mobile/lib/core/sync/sync_orchestrator.dart @@ -2,9 +2,12 @@ import 'dart:convert'; import 'package:app_firebase/app_firebase.dart'; import 'package:database/database.dart'; +import 'package:dio/dio.dart'; import 'package:sync_api/sync_api.dart'; import 'package:uuid/uuid.dart'; +import 'sync_server_changes_applier.dart'; + enum SyncEntityType { collection, item, tag } enum SyncOperationType { upsert, delete } @@ -23,6 +26,12 @@ class SyncAttemptResult { this.syncedTags = 0, this.conflictCount = 0, this.partial = false, + this.appliedServerCollections = 0, + this.appliedServerItems = 0, + this.appliedServerTags = 0, + this.skippedServerCollections = 0, + this.skippedServerItems = 0, + this.skippedServerTags = 0, }); final bool executed; @@ -37,21 +46,30 @@ class SyncAttemptResult { final int syncedTags; final int conflictCount; final bool partial; + final int appliedServerCollections; + final int appliedServerItems; + final int appliedServerTags; + final int skippedServerCollections; + final int skippedServerItems; + final int skippedServerTags; } class SyncOrchestrator { SyncOrchestrator({ required SyncDao syncDao, required SyncBackendClient backendClient, + required SyncServerChangesApplier serverChangesApplier, Uuid? uuid, int maxBatchSize = 1000, }) : _syncDao = syncDao, _backendClient = backendClient, + _serverChangesApplier = serverChangesApplier, _uuid = uuid ?? const Uuid(), _maxBatchSize = maxBatchSize; final SyncDao _syncDao; final SyncBackendClient _backendClient; + final SyncServerChangesApplier _serverChangesApplier; final Uuid _uuid; final int _maxBatchSize; @@ -113,16 +131,6 @@ class SyncOrchestrator { await _syncDao.upsertSyncState(lastAttemptedSyncAt: DateTime.now()); - if (pending.isEmpty && !forceFullSync) { - await _syncDao.upsertSyncState(consecutiveFailures: 0); - return const SyncAttemptResult( - executed: false, - success: true, - message: 'No pending operations to sync.', - partial: false, - ); - } - final changes = _buildChangesPayload(pending); final performanceService = FirebasePerformanceService.instance; @@ -175,6 +183,10 @@ class SyncOrchestrator { ); } + final applyResult = await _serverChangesApplier.apply( + response.serverChanges, + ); + for (final op in pending) { await _syncDao.markOperationSynced(op.id); } @@ -189,7 +201,8 @@ class SyncOrchestrator { success: true, message: 'Sync completed: ${response.syncedCollections} collections, ' - '${response.syncedItems} items, ${response.syncedTags} tags.', + '${response.syncedItems} items, ${response.syncedTags} tags. ' + 'Applied ${applyResult.appliedTotal} remote change(s).', pendingOperations: pending.length, processedOperations: processedOperations, syncedCollections: response.syncedCollections, @@ -197,6 +210,12 @@ class SyncOrchestrator { syncedTags: response.syncedTags, conflictCount: response.conflicts.length, partial: false, + appliedServerCollections: applyResult.appliedCollections, + appliedServerItems: applyResult.appliedItems, + appliedServerTags: applyResult.appliedTags, + skippedServerCollections: applyResult.skippedCollections, + skippedServerItems: applyResult.skippedItems, + skippedServerTags: applyResult.skippedTags, ); } on SyncAuthRequiredException catch (error) { return SyncAttemptResult( @@ -208,6 +227,25 @@ class SyncOrchestrator { pendingOperations: pending.length, partial: false, ); + } on DioException catch (error, stackTrace) { + final errorText = _buildDioErrorMessage(error); + for (final op in pending) { + await _syncDao.markOperationFailed(op.id, errorText); + } + + await _syncDao.upsertSyncState( + consecutiveFailures: (state?.consecutiveFailures ?? 0) + 1, + ); + + return SyncAttemptResult( + executed: true, + success: false, + message: errorText, + error: error, + stackTrace: stackTrace, + pendingOperations: pending.length, + partial: false, + ); } catch (error, stackTrace) { final errorText = '$error'; for (final op in pending) { @@ -230,6 +268,46 @@ class SyncOrchestrator { } } + String _buildDioErrorMessage(DioException error) { + final uri = error.requestOptions.uri; + final host = uri.host; + final statusCode = error.response?.statusCode; + final statusMessage = error.response?.statusMessage; + + if (error.type == DioExceptionType.badResponse && statusCode != null) { + final details = statusMessage == null || statusMessage.isEmpty + ? '' + : ' ($statusMessage)'; + return 'Sync failed with HTTP $statusCode$details.'; + } + + if (error.type == DioExceptionType.connectionTimeout || + error.type == DioExceptionType.receiveTimeout || + error.type == DioExceptionType.sendTimeout) { + return 'Sync request timed out while contacting $host. ' + 'Check backend availability and network.'; + } + + if (error.type == DioExceptionType.connectionError) { + final localhostHint = host == 'localhost' || host == '127.0.0.1' + ? ' If this is a physical device, use your computer LAN IP instead of localhost. ' + 'For Android emulator use 10.0.2.2.' + : ''; + return 'Unable to reach sync backend at ${uri.toString()}.$localhostHint'; + } + + if (error.type == DioExceptionType.cancel) { + return 'Sync request was cancelled.'; + } + + final message = error.message; + if (message != null && message.trim().isNotEmpty) { + return 'Sync request failed: $message'; + } + + return 'Sync request failed due to an unexpected network error.'; + } + SyncChangesPayload _buildChangesPayload(List pending) { final collections = >[]; final items = >[]; diff --git a/apps/mobile/lib/core/sync/sync_server_changes_applier.dart b/apps/mobile/lib/core/sync/sync_server_changes_applier.dart new file mode 100644 index 0000000..4432895 --- /dev/null +++ b/apps/mobile/lib/core/sync/sync_server_changes_applier.dart @@ -0,0 +1,368 @@ +import 'dart:convert'; + +import 'package:database/database.dart'; +import 'package:sync_api/sync_api.dart'; + +class SyncServerChangeApplyResult { + const SyncServerChangeApplyResult({ + required this.appliedCollections, + required this.appliedItems, + required this.appliedTags, + required this.skippedCollections, + required this.skippedItems, + required this.skippedTags, + }); + + final int appliedCollections; + final int appliedItems; + final int appliedTags; + final int skippedCollections; + final int skippedItems; + final int skippedTags; + + int get appliedTotal => appliedCollections + appliedItems + appliedTags; + int get skippedTotal => skippedCollections + skippedItems + skippedTags; +} + +class SyncServerChangesApplier { + const SyncServerChangesApplier({required AppDatabase database}) + : _database = database; + + final AppDatabase _database; + + Future apply(SyncChangesPayload changes) async { + var appliedCollections = 0; + var appliedItems = 0; + var appliedTags = 0; + var skippedCollections = 0; + var skippedItems = 0; + var skippedTags = 0; + final affectedCollectionIds = {}; + + await _database.transaction(() async { + // Apply tags first so item-tag relations can be linked immediately. + for (final payload in changes.tags) { + final tagId = _asString(payload['id']); + final tagName = _asString(payload['name']); + if (tagId == null || tagId.isEmpty) { + skippedTags++; + continue; + } + + if (_asBool(payload['isDeleted'])) { + await (_database.delete( + _database.tags, + )..where((tbl) => tbl.id.equals(tagId))).go(); + appliedTags++; + continue; + } + + if (tagName == null || tagName.trim().isEmpty) { + skippedTags++; + continue; + } + + final existingTag = await (_database.select( + _database.tags, + )..where((tbl) => tbl.id.equals(tagId))).getSingleOrNull(); + final now = DateTime.now().toUtc(); + + await _database + .into(_database.tags) + .insert( + TagsCompanion( + id: Value(tagId), + name: Value(tagName), + color: Value(_asString(payload['color'])), + createdAt: Value( + _asDate(payload['createdAt']) ?? + existingTag?.createdAt ?? + now, + ), + updatedAt: Value(_asDate(payload['updatedAt']) ?? now), + ), + mode: InsertMode.insertOrReplace, + ); + appliedTags++; + } + + for (final payload in changes.collections) { + final collectionId = _asString(payload['id']); + if (collectionId == null || collectionId.isEmpty) { + skippedCollections++; + continue; + } + + if (_asBool(payload['isDeleted'])) { + await (_database.delete( + _database.collections, + )..where((tbl) => tbl.id.equals(collectionId))).go(); + appliedCollections++; + continue; + } + + final collectionName = _asString(payload['name']); + final collectionType = _asString(payload['type']); + if (collectionName == null || + collectionName.isEmpty || + collectionType == null || + collectionType.isEmpty) { + skippedCollections++; + continue; + } + + final existingCollection = await (_database.select( + _database.collections, + )..where((tbl) => tbl.id.equals(collectionId))).getSingleOrNull(); + final now = DateTime.now().toUtc(); + + await _database + .into(_database.collections) + .insert( + CollectionsCompanion( + id: Value(collectionId), + name: Value(collectionName), + type: Value(collectionType), + description: Value(_asString(payload['description'])), + coverImagePath: Value(_asString(payload['coverImagePath'])), + itemCount: Value( + _asInt(payload['itemCount']) ?? + existingCollection?.itemCount ?? + 0, + ), + createdAt: Value( + _asDate(payload['createdAt']) ?? + existingCollection?.createdAt ?? + now, + ), + updatedAt: Value(_asDate(payload['updatedAt']) ?? now), + ), + mode: InsertMode.insertOrReplace, + ); + appliedCollections++; + affectedCollectionIds.add(collectionId); + } + + for (final payload in changes.items) { + final itemId = _asString(payload['id']); + if (itemId == null || itemId.isEmpty) { + skippedItems++; + continue; + } + + if (_asBool(payload['isDeleted'])) { + final existing = await (_database.select( + _database.items, + )..where((tbl) => tbl.id.equals(itemId))).getSingleOrNull(); + if (existing != null) { + affectedCollectionIds.add(existing.collectionId); + } + + await (_database.delete( + _database.items, + )..where((tbl) => tbl.id.equals(itemId))).go(); + appliedItems++; + continue; + } + + final collectionId = _asString(payload['collectionId']); + final title = _asString(payload['title']); + if (collectionId == null || + collectionId.isEmpty || + title == null || + title.isEmpty) { + skippedItems++; + continue; + } + + final existingCollection = await (_database.select( + _database.collections, + )..where((tbl) => tbl.id.equals(collectionId))).getSingleOrNull(); + if (existingCollection == null) { + skippedItems++; + continue; + } + + final existingItem = await (_database.select( + _database.items, + )..where((tbl) => tbl.id.equals(itemId))).getSingleOrNull(); + final now = DateTime.now().toUtc(); + + await _database + .into(_database.items) + .insert( + ItemsCompanion( + id: Value(itemId), + collectionId: Value(collectionId), + title: Value(title), + barcode: Value(_asString(payload['barcode'])), + coverImageUrl: Value(_asString(payload['coverImageUrl'])), + coverImagePath: Value(_asString(payload['coverImagePath'])), + description: Value(_asString(payload['description'])), + notes: Value(_asString(payload['notes'])), + metadata: Value(_asJsonString(payload['metadata'])), + condition: Value(_asString(payload['condition'])), + purchasePrice: Value(_asDouble(payload['purchasePrice'])), + purchaseDate: Value(_asDate(payload['purchaseDate'])), + currentValue: Value(_asDouble(payload['currentValue'])), + location: Value(_asString(payload['location'])), + isFavorite: Value(_asBool(payload['isFavorite'])), + isWishlist: Value(_asBool(payload['isWishlist'])), + sortOrder: Value(_asInt(payload['sortOrder']) ?? 0), + quantity: Value(_asInt(payload['quantity']) ?? 1), + createdAt: Value( + _asDate(payload['createdAt']) ?? + existingItem?.createdAt ?? + now, + ), + updatedAt: Value(_asDate(payload['updatedAt']) ?? now), + ), + mode: InsertMode.insertOrReplace, + ); + appliedItems++; + affectedCollectionIds.add(collectionId); + + await (_database.delete( + _database.itemTags, + )..where((tbl) => tbl.itemId.equals(itemId))).go(); + + final tagIds = _asStringList(payload['tagIds']); + if (tagIds.isNotEmpty) { + final existingTags = await (_database.select( + _database.tags, + )..where((tbl) => tbl.id.isIn(tagIds))).get(); + for (final tag in existingTags) { + await _database + .into(_database.itemTags) + .insert( + ItemTagsCompanion.insert(itemId: itemId, tagId: tag.id), + mode: InsertMode.insertOrIgnore, + ); + } + } + } + + for (final collectionId in affectedCollectionIds) { + final countRow = await _database + .customSelect( + 'SELECT COUNT(*) AS item_count FROM items WHERE collection_id = ?', + variables: [Variable.withString(collectionId)], + readsFrom: {_database.items}, + ) + .getSingle(); + final count = countRow.read('item_count'); + + await (_database.update( + _database.collections, + )..where((tbl) => tbl.id.equals(collectionId))).write( + CollectionsCompanion( + itemCount: Value(count), + updatedAt: Value(DateTime.now().toUtc()), + ), + ); + } + }); + + return SyncServerChangeApplyResult( + appliedCollections: appliedCollections, + appliedItems: appliedItems, + appliedTags: appliedTags, + skippedCollections: skippedCollections, + skippedItems: skippedItems, + skippedTags: skippedTags, + ); + } + + bool _asBool(Object? value, {bool fallback = false}) { + if (value is bool) { + return value; + } + if (value is num) { + return value != 0; + } + if (value is String) { + final normalized = value.trim().toLowerCase(); + if (normalized == 'true' || normalized == '1') { + return true; + } + if (normalized == 'false' || normalized == '0') { + return false; + } + } + return fallback; + } + + int? _asInt(Object? value) { + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + if (value is String) { + return int.tryParse(value); + } + return null; + } + + double? _asDouble(Object? value) { + if (value is double) { + return value; + } + if (value is num) { + return value.toDouble(); + } + if (value is String) { + return double.tryParse(value); + } + return null; + } + + String? _asString(Object? value) { + if (value == null) { + return null; + } + if (value is String) { + return value; + } + return '$value'; + } + + List _asStringList(Object? value) { + if (value is! List) { + return const []; + } + + return value + .map(_asString) + .whereType() + .map((entry) => entry.trim()) + .where((entry) => entry.isNotEmpty) + .toList(growable: false); + } + + String? _asJsonString(Object? value) { + if (value == null) { + return null; + } + if (value is String) { + return value; + } + try { + return jsonEncode(value); + } catch (_) { + return '$value'; + } + } + + DateTime? _asDate(Object? value) { + if (value is DateTime) { + return value.toUtc(); + } + if (value is String) { + final parsed = DateTime.tryParse(value); + return parsed?.toUtc(); + } + return null; + } +} diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart index 53410e3..2bfd377 100644 --- a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart +++ b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart @@ -478,6 +478,12 @@ class SettingsScreen extends ConsumerWidget { syncedItems: result.syncedItems, syncedTags: result.syncedTags, conflictCount: result.conflictCount, + appliedServerCollections: result.appliedServerCollections, + appliedServerItems: result.appliedServerItems, + appliedServerTags: result.appliedServerTags, + skippedServerCollections: result.skippedServerCollections, + skippedServerItems: result.skippedServerItems, + skippedServerTags: result.skippedServerTags, message: result.message, error: result.error, stackTrace: result.stackTrace, diff --git a/packages/integrations/database/lib/database.dart b/packages/integrations/database/lib/database.dart index df6567c..7f686cc 100644 --- a/packages/integrations/database/lib/database.dart +++ b/packages/integrations/database/lib/database.dart @@ -2,4 +2,4 @@ export 'src/app_database.dart'; export 'src/tables/tables.dart'; export 'src/daos/daos.dart'; -export 'package:drift/drift.dart' show Value, Constant; +export 'package:drift/drift.dart' show Value, Constant, InsertMode, Variable; From e514bf27c9c8673dc2d2e93396bfdc868d99e1ea Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 23:06:42 +0630 Subject: [PATCH 17/31] feat: Enhance auth token refresh with JWT expiration checks, refresh concurrency control, and improved unauthorized error handling. --- .../lib/core/auth/backend_auth_service.dart | 41 +-- .../auth/nest_sync_auth_token_provider.dart | 233 +++++++++++++++--- .../src/client/dio_sync_backend_client.dart | 56 ++++- 3 files changed, 278 insertions(+), 52 deletions(-) diff --git a/apps/mobile/lib/core/auth/backend_auth_service.dart b/apps/mobile/lib/core/auth/backend_auth_service.dart index b07e53c..93e2193 100644 --- a/apps/mobile/lib/core/auth/backend_auth_service.dart +++ b/apps/mobile/lib/core/auth/backend_auth_service.dart @@ -64,22 +64,31 @@ class BackendAuthService { return null; } - 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; + 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 { diff --git a/packages/integrations/sync_api/lib/src/auth/nest_sync_auth_token_provider.dart b/packages/integrations/sync_api/lib/src/auth/nest_sync_auth_token_provider.dart index f0ddf9b..fb1bd63 100644 --- a/packages/integrations/sync_api/lib/src/auth/nest_sync_auth_token_provider.dart +++ b/packages/integrations/sync_api/lib/src/auth/nest_sync_auth_token_provider.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:auth_session/auth_session.dart'; import 'package:dio/dio.dart'; @@ -16,23 +19,44 @@ class NestSyncAuthTokenProvider implements SyncAuthTokenProvider { final Dio _dio; final String _apiBaseUrl; final AuthSessionStore _sessionStore; + Future? _inFlightRefresh; final String refreshPath; @override Future readAccessToken() async { final session = await _sessionStore.readSession(); - if (!session.hasAccessToken) { + final token = session.accessToken?.trim(); + if (token == null || token.isEmpty) { + return null; + } + if (_isJwtExpired(token, tolerance: const Duration(seconds: 30))) { return null; } - return session.accessToken; + return token; } @override - Future refreshAccessToken() async { + Future refreshAccessToken() { + final pending = _inFlightRefresh; + if (pending != null) { + return pending; + } + + final refreshFuture = _refreshAccessTokenInternal(); + _inFlightRefresh = refreshFuture; + refreshFuture.whenComplete(() { + if (identical(_inFlightRefresh, refreshFuture)) { + _inFlightRefresh = null; + } + }); + return refreshFuture; + } + + Future _refreshAccessTokenInternal() async { final session = await _sessionStore.readSession(); final refreshToken = session.refreshToken; - final deviceId = session.deviceId; + final deviceId = _resolveDeviceId(session); if (refreshToken == null || refreshToken.isEmpty || @@ -42,35 +66,67 @@ class NestSyncAuthTokenProvider implements SyncAuthTokenProvider { return null; } - final response = await _dio.post>( - '$_apiBaseUrl$refreshPath', - data: { - 'refreshToken': refreshToken, - 'deviceId': deviceId, - }, - ); + try { + final response = await _dio.post>( + '$_apiBaseUrl$refreshPath', + data: { + 'refreshToken': refreshToken, + 'deviceId': deviceId, + }, + ); - final data = _asJsonMap(response.data); - final newAccessToken = data['accessToken'] as String?; - final newRefreshToken = data['refreshToken'] as String?; + final data = _unwrapResponseData(response.data); + final nestedTokens = _asJsonMap(data['tokens']); + final newAccessToken = _firstNonEmptyString([ + data['accessToken'], + data['access_token'], + data['token'], + nestedTokens['accessToken'], + nestedTokens['access_token'], + nestedTokens['token'], + ]); + final newRefreshToken = _firstNonEmptyString([ + data['refreshToken'], + data['refresh_token'], + nestedTokens['refreshToken'], + nestedTokens['refresh_token'], + ]); + final expiresAt = _parseExpiresAt(data); - if (newAccessToken == null || newAccessToken.isEmpty) { - return null; - } + if (newAccessToken == null) { + return null; + } - await _sessionStore.saveSession( - session.copyWith( - status: AuthSessionStatus.signedIn, - accessToken: newAccessToken, - refreshToken: (newRefreshToken != null && newRefreshToken.isNotEmpty) - ? newRefreshToken - : refreshToken, - deviceId: deviceId, - updatedAt: DateTime.now().toUtc(), - ), - ); + await _sessionStore.saveSession( + session.copyWith( + status: AuthSessionStatus.signedIn, + accessToken: newAccessToken, + refreshToken: newRefreshToken ?? refreshToken, + deviceId: deviceId, + expiresAt: expiresAt ?? session.expiresAt, + updatedAt: DateTime.now().toUtc(), + ), + ); - return newAccessToken; + return newAccessToken; + } on DioException catch (error) { + if (!_isUnauthorized(error)) { + rethrow; + } + + // If another request already refreshed in parallel, trust the latest session. + final latestSession = await _sessionStore.readSession(); + final latestToken = latestSession.accessToken?.trim(); + final previousToken = session.accessToken?.trim(); + if (latestToken != null && + latestToken.isNotEmpty && + latestToken != previousToken) { + return latestToken; + } + + await _sessionStore.clearSession(); + return null; + } } @override @@ -81,7 +137,13 @@ class NestSyncAuthTokenProvider implements SyncAuthTokenProvider { @override Future hasSession() async { final session = await _sessionStore.readSession(); - return session.isAuthenticated; + final accessToken = session.accessToken?.trim(); + final hasUsableAccessToken = + accessToken != null && + accessToken.isNotEmpty && + !_isJwtExpired(accessToken); + return session.status == AuthSessionStatus.signedIn && + (session.canRefresh || hasUsableAccessToken); } static String _normalizeBaseUrl(String value) { @@ -103,4 +165,115 @@ class NestSyncAuthTokenProvider implements SyncAuthTokenProvider { } return const {}; } + + static Map _unwrapResponseData(Object? raw) { + final map = _asJsonMap(raw); + final nested = _asJsonMap(map['data']); + return nested.isEmpty ? map : nested; + } + + static bool _isUnauthorized(DioException error) { + final status = error.response?.statusCode; + return status == 401 || status == 403; + } + + static String? _firstNonEmptyString(Iterable values) { + for (final value in values) { + if (value is! String) { + continue; + } + final trimmed = value.trim(); + if (trimmed.isNotEmpty) { + return trimmed; + } + } + return null; + } + + String? _resolveDeviceId(AuthSession session) { + final direct = _firstNonEmptyString([session.deviceId]); + if (direct != null) { + return direct; + } + + final refreshClaims = _decodeJwtClaims(session.refreshToken); + final refreshClaimDeviceId = _firstNonEmptyString([ + refreshClaims['deviceId'], + refreshClaims['device_id'], + ]); + if (refreshClaimDeviceId != null) { + return refreshClaimDeviceId; + } + + final accessClaims = _decodeJwtClaims(session.accessToken); + return _firstNonEmptyString([ + accessClaims['deviceId'], + accessClaims['device_id'], + ]); + } + + DateTime? _parseExpiresAt(Map payload) { + final sessionMap = _asJsonMap(payload['session']); + final source = _firstNonEmptyString([ + payload['expiresAt'], + payload['expires_at'], + sessionMap['expiresAt'], + sessionMap['expires_at'], + ]); + if (source == null) { + return null; + } + return DateTime.tryParse(source)?.toUtc(); + } + + static bool _isJwtExpired( + String? token, { + Duration tolerance = Duration.zero, + }) { + final claims = _decodeJwtClaims(token); + final rawExp = claims['exp']; + if (rawExp == null) { + return false; + } + + final expSeconds = rawExp is int + ? rawExp + : (rawExp is num + ? rawExp.toInt() + : (rawExp is String ? int.tryParse(rawExp) : null)); + if (expSeconds == null) { + return false; + } + + final expiryMillis = expSeconds * 1000; + final thresholdMillis = DateTime.now() + .toUtc() + .add(tolerance) + .millisecondsSinceEpoch; + return thresholdMillis >= expiryMillis; + } + + static Map _decodeJwtClaims(String? token) { + if (token == null) { + return const {}; + } + final trimmed = token.trim(); + if (trimmed.isEmpty) { + return const {}; + } + + final parts = trimmed.split('.'); + if (parts.length != 3) { + return const {}; + } + + try { + final normalized = base64Url.normalize(parts[1]); + final decoded = utf8.decode(base64Url.decode(normalized)); + final raw = jsonDecode(decoded); + return _asJsonMap(raw); + } catch (_) { + return const {}; + } + } } diff --git a/packages/integrations/sync_api/lib/src/client/dio_sync_backend_client.dart b/packages/integrations/sync_api/lib/src/client/dio_sync_backend_client.dart index 4d4548f..3ba7d58 100644 --- a/packages/integrations/sync_api/lib/src/client/dio_sync_backend_client.dart +++ b/packages/integrations/sync_api/lib/src/client/dio_sync_backend_client.dart @@ -54,7 +54,9 @@ class DioSyncBackendClient implements SyncBackendClient { var tokenApplied = await _applyAccessToken(); if (!tokenApplied) { - final refreshedToken = await _authTokenProvider.refreshAccessToken(); + final refreshedToken = await _refreshAccessToken( + requireAuth: requireAuth, + ); if (refreshedToken != null && refreshedToken.isNotEmpty) { _dio.options.headers['Authorization'] = 'Bearer $refreshedToken'; tokenApplied = true; @@ -62,7 +64,9 @@ class DioSyncBackendClient implements SyncBackendClient { } if (requireAuth && !tokenApplied) { - throw const SyncAuthRequiredException(); + throw const SyncAuthRequiredException( + message: 'Your sync session expired. Please sign in again.', + ); } try { @@ -72,16 +76,50 @@ class DioSyncBackendClient implements SyncBackendClient { rethrow; } - final refreshedToken = await _authTokenProvider.refreshAccessToken(); + final refreshedToken = await _refreshAccessToken( + requireAuth: requireAuth, + ); if (refreshedToken == null || refreshedToken.isEmpty) { if (requireAuth) { - throw const SyncAuthRequiredException(); + throw const SyncAuthRequiredException( + message: 'Your sync session expired. Please sign in again.', + ); } rethrow; } _dio.options.headers['Authorization'] = 'Bearer $refreshedToken'; - return runRequest(); + try { + return await runRequest(); + } on DioException catch (retryError) { + if (!_isUnauthorized(retryError)) { + rethrow; + } + await _clearAuthState(); + if (requireAuth) { + throw const SyncAuthRequiredException( + message: 'Your sync session expired. Please sign in again.', + ); + } + rethrow; + } + } + } + + Future _refreshAccessToken({required bool requireAuth}) async { + try { + return await _authTokenProvider.refreshAccessToken(); + } on DioException catch (error) { + if (!_isUnauthorized(error)) { + rethrow; + } + await _clearAuthState(); + if (requireAuth) { + throw const SyncAuthRequiredException( + message: 'Your sync session expired. Please sign in again.', + ); + } + return null; } } @@ -96,7 +134,13 @@ class DioSyncBackendClient implements SyncBackendClient { } bool _isUnauthorized(DioException error) { - return error.response?.statusCode == 401; + final status = error.response?.statusCode; + return status == 401 || status == 403; + } + + Future _clearAuthState() async { + _dio.options.headers.remove('Authorization'); + await _authTokenProvider.clearTokens(); } static Map _asJsonMap(Object? raw) { From 3e303a3397a34b18150d85193ca146a465a32ee7 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 23:12:46 +0630 Subject: [PATCH 18/31] feat: Implement network retry for sync requests and enhance server change application with local conflict prevention and timestamp skew handling. --- .../lib/core/sync/sync_orchestrator.dart | 107 ++++++++++++++--- .../sync/sync_server_changes_applier.dart | 112 +++++++++++++++--- 2 files changed, 188 insertions(+), 31 deletions(-) diff --git a/apps/mobile/lib/core/sync/sync_orchestrator.dart b/apps/mobile/lib/core/sync/sync_orchestrator.dart index e11494d..0661753 100644 --- a/apps/mobile/lib/core/sync/sync_orchestrator.dart +++ b/apps/mobile/lib/core/sync/sync_orchestrator.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:math'; import 'package:app_firebase/app_firebase.dart'; import 'package:database/database.dart'; @@ -61,17 +62,26 @@ class SyncOrchestrator { required SyncServerChangesApplier serverChangesApplier, Uuid? uuid, int maxBatchSize = 1000, + int maxNetworkRetries = 2, + Duration initialRetryDelay = const Duration(milliseconds: 600), + Random? random, }) : _syncDao = syncDao, _backendClient = backendClient, _serverChangesApplier = serverChangesApplier, _uuid = uuid ?? const Uuid(), - _maxBatchSize = maxBatchSize; + _maxBatchSize = maxBatchSize, + _maxNetworkRetries = maxNetworkRetries, + _initialRetryDelay = initialRetryDelay, + _random = random ?? Random.secure(); final SyncDao _syncDao; final SyncBackendClient _backendClient; final SyncServerChangesApplier _serverChangesApplier; final Uuid _uuid; final int _maxBatchSize; + final int _maxNetworkRetries; + final Duration _initialRetryDelay; + final Random _random; Stream watchPendingOperationCount() { return _syncDao.watchPendingOperationCount(); @@ -133,22 +143,19 @@ class SyncOrchestrator { final changes = _buildChangesPayload(pending); final performanceService = FirebasePerformanceService.instance; + final requestPayload = SyncRequestPayload( + deviceId: deviceId, + clientRequestId: _uuid.v4(), + lastSyncAt: forceFullSync ? null : state?.lastSuccessfulSyncAt, + changes: changes.isEmpty ? null : changes, + ); try { - final response = await performanceService.traceAsync( - 'sync_push_pull_now', - () => _backendClient.sync( - SyncRequestPayload( - deviceId: deviceId, - clientRequestId: _uuid.v4(), - lastSyncAt: forceFullSync ? null : state?.lastSuccessfulSyncAt, - changes: changes.isEmpty ? null : changes, - ), - ), - attributes: { - 'force_full_sync': forceFullSync ? '1' : '0', - 'pending_operations': '${pending.length}', - }, + final response = await _syncWithRetry( + request: requestPayload, + pendingOperationCount: pending.length, + forceFullSync: forceFullSync, + performanceService: performanceService, ); final processedOperations = _processedOperationCount(response); @@ -342,4 +349,74 @@ class SyncOrchestrator { response.syncedTags + response.conflicts.length; } + + Future _syncWithRetry({ + required SyncRequestPayload request, + required int pendingOperationCount, + required bool forceFullSync, + required FirebasePerformanceService performanceService, + }) async { + DioException? lastDioError; + + for (var attempt = 0; attempt <= _maxNetworkRetries; attempt++) { + try { + return await performanceService.traceAsync( + 'sync_push_pull_now', + () => _backendClient.sync(request), + attributes: { + 'force_full_sync': forceFullSync ? '1' : '0', + 'pending_operations': '$pendingOperationCount', + 'attempt': '${attempt + 1}', + }, + ); + } on DioException catch (error) { + lastDioError = error; + if (!_isRetryableNetworkError(error) || attempt >= _maxNetworkRetries) { + rethrow; + } + await Future.delayed(_retryDelayForAttempt(attempt)); + } + } + + throw lastDioError ?? + DioException( + requestOptions: RequestOptions(path: '/sync'), + type: DioExceptionType.unknown, + message: 'Sync request failed after retry attempts.', + ); + } + + bool _isRetryableNetworkError(DioException error) { + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + case DioExceptionType.connectionError: + return true; + case DioExceptionType.badResponse: + final statusCode = error.response?.statusCode; + if (statusCode == null) { + return false; + } + return statusCode == 408 || + statusCode == 429 || + statusCode == 500 || + statusCode == 502 || + statusCode == 503 || + statusCode == 504; + case DioExceptionType.badCertificate: + case DioExceptionType.cancel: + return false; + case DioExceptionType.unknown: + return true; + } + } + + Duration _retryDelayForAttempt(int attempt) { + final exponentialMultiplier = 1 << attempt; + final baseMillis = + _initialRetryDelay.inMilliseconds * exponentialMultiplier; + final jitterMillis = _random.nextInt(250); + return Duration(milliseconds: baseMillis + jitterMillis); + } } diff --git a/apps/mobile/lib/core/sync/sync_server_changes_applier.dart b/apps/mobile/lib/core/sync/sync_server_changes_applier.dart index 4432895..601541b 100644 --- a/apps/mobile/lib/core/sync/sync_server_changes_applier.dart +++ b/apps/mobile/lib/core/sync/sync_server_changes_applier.dart @@ -25,10 +25,14 @@ class SyncServerChangeApplyResult { } class SyncServerChangesApplier { - const SyncServerChangesApplier({required AppDatabase database}) - : _database = database; + const SyncServerChangesApplier({ + required AppDatabase database, + Duration timestampSkewTolerance = const Duration(seconds: 2), + }) : _database = database, + _timestampSkewTolerance = timestampSkewTolerance; final AppDatabase _database; + final Duration _timestampSkewTolerance; Future apply(SyncChangesPayload changes) async { var appliedCollections = 0; @@ -49,6 +53,26 @@ class SyncServerChangesApplier { continue; } + if (await _hasPendingLocalOperation( + entityType: 'tag', + entityId: tagId, + )) { + skippedTags++; + continue; + } + + final existingTag = await (_database.select( + _database.tags, + )..where((tbl) => tbl.id.equals(tagId))).getSingleOrNull(); + final serverUpdatedAt = _asDate(payload['updatedAt']); + if (_isServerPayloadOutdated( + localUpdatedAt: existingTag?.updatedAt, + serverUpdatedAt: serverUpdatedAt, + )) { + skippedTags++; + continue; + } + if (_asBool(payload['isDeleted'])) { await (_database.delete( _database.tags, @@ -62,9 +86,6 @@ class SyncServerChangesApplier { continue; } - final existingTag = await (_database.select( - _database.tags, - )..where((tbl) => tbl.id.equals(tagId))).getSingleOrNull(); final now = DateTime.now().toUtc(); await _database @@ -93,6 +114,26 @@ class SyncServerChangesApplier { continue; } + if (await _hasPendingLocalOperation( + entityType: 'collection', + entityId: collectionId, + )) { + skippedCollections++; + continue; + } + + final existingCollection = await (_database.select( + _database.collections, + )..where((tbl) => tbl.id.equals(collectionId))).getSingleOrNull(); + final serverUpdatedAt = _asDate(payload['updatedAt']); + if (_isServerPayloadOutdated( + localUpdatedAt: existingCollection?.updatedAt, + serverUpdatedAt: serverUpdatedAt, + )) { + skippedCollections++; + continue; + } + if (_asBool(payload['isDeleted'])) { await (_database.delete( _database.collections, @@ -111,9 +152,6 @@ class SyncServerChangesApplier { continue; } - final existingCollection = await (_database.select( - _database.collections, - )..where((tbl) => tbl.id.equals(collectionId))).getSingleOrNull(); final now = DateTime.now().toUtc(); await _database @@ -150,12 +188,29 @@ class SyncServerChangesApplier { continue; } + if (await _hasPendingLocalOperation( + entityType: 'item', + entityId: itemId, + )) { + skippedItems++; + continue; + } + + final existingItem = await (_database.select( + _database.items, + )..where((tbl) => tbl.id.equals(itemId))).getSingleOrNull(); + final serverUpdatedAt = _asDate(payload['updatedAt']); + if (_isServerPayloadOutdated( + localUpdatedAt: existingItem?.updatedAt, + serverUpdatedAt: serverUpdatedAt, + )) { + skippedItems++; + continue; + } + if (_asBool(payload['isDeleted'])) { - final existing = await (_database.select( - _database.items, - )..where((tbl) => tbl.id.equals(itemId))).getSingleOrNull(); - if (existing != null) { - affectedCollectionIds.add(existing.collectionId); + if (existingItem != null) { + affectedCollectionIds.add(existingItem.collectionId); } await (_database.delete( @@ -183,9 +238,6 @@ class SyncServerChangesApplier { continue; } - final existingItem = await (_database.select( - _database.items, - )..where((tbl) => tbl.id.equals(itemId))).getSingleOrNull(); final now = DateTime.now().toUtc(); await _database @@ -221,6 +273,9 @@ class SyncServerChangesApplier { ); appliedItems++; affectedCollectionIds.add(collectionId); + if (existingItem != null && existingItem.collectionId != collectionId) { + affectedCollectionIds.add(existingItem.collectionId); + } await (_database.delete( _database.itemTags, @@ -273,6 +328,31 @@ class SyncServerChangesApplier { ); } + Future _hasPendingLocalOperation({ + required String entityType, + required String entityId, + }) async { + final pending = + await (_database.select(_database.syncOutbox) + ..where((tbl) => tbl.entityType.equals(entityType)) + ..where((tbl) => tbl.entityId.equals(entityId))) + .getSingleOrNull(); + return pending != null; + } + + bool _isServerPayloadOutdated({ + required DateTime? localUpdatedAt, + required DateTime? serverUpdatedAt, + }) { + if (localUpdatedAt == null || serverUpdatedAt == null) { + return false; + } + + final local = localUpdatedAt.toUtc(); + final server = serverUpdatedAt.toUtc(); + return local.isAfter(server.add(_timestampSkewTolerance)); + } + bool _asBool(Object? value, {bool fallback = false}) { if (value is bool) { return value; From e5c728962aa6429d3ebfcf453341045f63606ec6 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 23:25:22 +0630 Subject: [PATCH 19/31] feat: Implement scheduled sync retry with database persistence, UI display, and auto-retry on app resume. --- apps/mobile/lib/app.dart | 7 +- .../lib/core/providers/sync_providers.dart | 5 + .../core/sync/sync_auto_retry_on_resume.dart | 122 ++++++++++++++++++ .../lib/core/sync/sync_orchestrator.dart | 32 ++++- .../presentation/views/settings_screen.dart | 26 ++++ .../database/lib/src/app_database.dart | 6 +- .../database/lib/src/daos/sync_dao.dart | 5 + .../lib/src/tables/sync_state_table.dart | 1 + 8 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/lib/core/sync/sync_auto_retry_on_resume.dart diff --git a/apps/mobile/lib/app.dart b/apps/mobile/lib/app.dart index 181e0b3..27fc4d5 100644 --- a/apps/mobile/lib/app.dart +++ b/apps/mobile/lib/app.dart @@ -1,6 +1,7 @@ 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/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'; @@ -57,8 +58,10 @@ class CollectionTrackerApp extends ConsumerWidget { return AnnotatedRegion( value: overlay, - child: FirebaseRuntimeConfigAutoRefresh( - child: child ?? const SizedBox.shrink(), + child: SyncAutoRetryOnResume( + child: FirebaseRuntimeConfigAutoRefresh( + child: child ?? const SizedBox.shrink(), + ), ), ); }, diff --git a/apps/mobile/lib/core/providers/sync_providers.dart b/apps/mobile/lib/core/providers/sync_providers.dart index 0f7083d..afebccd 100644 --- a/apps/mobile/lib/core/providers/sync_providers.dart +++ b/apps/mobile/lib/core/providers/sync_providers.dart @@ -245,6 +245,11 @@ final syncOutboxCountProvider = StreamProvider((ref) { return dao.watchPendingOperationCount(); }); +final syncStateProvider = StreamProvider((ref) { + final dao = ref.watch(syncDaoProvider); + return dao.watchSyncState(); +}); + String _joinUrl(String baseUrl, String prefix) { final base = baseUrl.trim().replaceAll(RegExp(r'/+$'), ''); final path = prefix.trim(); diff --git a/apps/mobile/lib/core/sync/sync_auto_retry_on_resume.dart b/apps/mobile/lib/core/sync/sync_auto_retry_on_resume.dart new file mode 100644 index 0000000..745a3c3 --- /dev/null +++ b/apps/mobile/lib/core/sync/sync_auto_retry_on_resume.dart @@ -0,0 +1,122 @@ +import 'package:app_logger/app_logger.dart'; +import 'package:collection_tracker/core/observability/operational_telemetry.dart'; +import 'package:collection_tracker/core/providers/providers.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class SyncAutoRetryOnResume extends ConsumerStatefulWidget { + const SyncAutoRetryOnResume({required this.child, super.key}); + + final Widget child; + + @override + ConsumerState createState() => + _SyncAutoRetryOnResumeState(); +} + +class _SyncAutoRetryOnResumeState extends ConsumerState + with WidgetsBindingObserver { + bool _isRunning = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + WidgetsBinding.instance.addPostFrameCallback((_) { + _attemptAutoRetry(trigger: 'app_start'); + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + // Wait briefly so runtime config refresh can settle first. + Future.delayed(const Duration(milliseconds: 350), () { + if (!mounted) return; + _attemptAutoRetry(trigger: 'resume'); + }); + } + } + + @override + Widget build(BuildContext context) { + return widget.child; + } + + Future _attemptAutoRetry({required String trigger}) async { + if (_isRunning || !mounted) { + return; + } + + _isRunning = true; + try { + final readiness = ref.read(syncReadinessProvider); + if (!readiness.isReady) { + return; + } + + final pendingBefore = await ref + .read(syncOrchestratorProvider) + .getPendingOperationCount(); + if (pendingBefore <= 0) { + return; + } + + final syncState = await ref.read(syncDaoProvider).getSyncState(); + final nextRetryAt = syncState?.nextRetryAt?.toUtc(); + if (nextRetryAt == null || DateTime.now().toUtc().isBefore(nextRetryAt)) { + return; + } + + final session = ref.read(authSessionProvider).asData?.value; + final deviceId = session?.deviceId; + if (deviceId == null || deviceId.trim().isEmpty) { + return; + } + + await OperationalTelemetry.trackSyncAttempt( + trigger: 'auto_retry_$trigger', + readinessStatus: readiness.status.name, + pendingBefore: pendingBefore, + ); + + final result = await ref + .read(syncOrchestratorProvider) + .syncNow(deviceId: deviceId); + await OperationalTelemetry.trackSyncResult( + success: result.success, + executed: result.executed, + partial: result.partial, + pendingOperations: result.pendingOperations, + processedOperations: result.processedOperations, + syncedCollections: result.syncedCollections, + syncedItems: result.syncedItems, + syncedTags: result.syncedTags, + conflictCount: result.conflictCount, + appliedServerCollections: result.appliedServerCollections, + appliedServerItems: result.appliedServerItems, + appliedServerTags: result.appliedServerTags, + skippedServerCollections: result.skippedServerCollections, + skippedServerItems: result.skippedServerItems, + skippedServerTags: result.skippedServerTags, + message: result.message, + error: result.error, + stackTrace: result.stackTrace, + ); + } catch (error, stackTrace) { + Logger.error( + 'Failed to auto-retry sync on lifecycle event.', + error, + stackTrace, + ); + } finally { + _isRunning = false; + } + } +} diff --git a/apps/mobile/lib/core/sync/sync_orchestrator.dart b/apps/mobile/lib/core/sync/sync_orchestrator.dart index 0661753..4466993 100644 --- a/apps/mobile/lib/core/sync/sync_orchestrator.dart +++ b/apps/mobile/lib/core/sync/sync_orchestrator.dart @@ -64,6 +64,8 @@ class SyncOrchestrator { int maxBatchSize = 1000, int maxNetworkRetries = 2, Duration initialRetryDelay = const Duration(milliseconds: 600), + Duration scheduledRetryBaseDelay = const Duration(seconds: 15), + Duration maxScheduledRetryDelay = const Duration(minutes: 10), Random? random, }) : _syncDao = syncDao, _backendClient = backendClient, @@ -72,6 +74,8 @@ class SyncOrchestrator { _maxBatchSize = maxBatchSize, _maxNetworkRetries = maxNetworkRetries, _initialRetryDelay = initialRetryDelay, + _scheduledRetryBaseDelay = scheduledRetryBaseDelay, + _maxScheduledRetryDelay = maxScheduledRetryDelay, _random = random ?? Random.secure(); final SyncDao _syncDao; @@ -81,6 +85,8 @@ class SyncOrchestrator { final int _maxBatchSize; final int _maxNetworkRetries; final Duration _initialRetryDelay; + final Duration _scheduledRetryBaseDelay; + final Duration _maxScheduledRetryDelay; final Random _random; Stream watchPendingOperationCount() { @@ -170,6 +176,7 @@ class SyncOrchestrator { await _syncDao.upsertSyncState( consecutiveFailures: (state?.consecutiveFailures ?? 0) + 1, + nextRetryAt: _scheduledRetryAt((state?.consecutiveFailures ?? 0) + 1), ); return SyncAttemptResult( @@ -201,6 +208,7 @@ class SyncOrchestrator { await _syncDao.upsertSyncState( lastSuccessfulSyncAt: response.lastSyncAt.toUtc(), consecutiveFailures: 0, + clearNextRetryAt: true, ); return SyncAttemptResult( @@ -225,6 +233,7 @@ class SyncOrchestrator { skippedServerTags: applyResult.skippedTags, ); } on SyncAuthRequiredException catch (error) { + await _syncDao.upsertSyncState(clearNextRetryAt: true); return SyncAttemptResult( executed: false, success: false, @@ -240,8 +249,14 @@ class SyncOrchestrator { await _syncDao.markOperationFailed(op.id, errorText); } + final nextFailureCount = (state?.consecutiveFailures ?? 0) + 1; + final shouldScheduleRetry = _isRetryableNetworkError(error); await _syncDao.upsertSyncState( - consecutiveFailures: (state?.consecutiveFailures ?? 0) + 1, + consecutiveFailures: nextFailureCount, + nextRetryAt: shouldScheduleRetry + ? _scheduledRetryAt(nextFailureCount) + : null, + clearNextRetryAt: !shouldScheduleRetry, ); return SyncAttemptResult( @@ -261,6 +276,7 @@ class SyncOrchestrator { await _syncDao.upsertSyncState( consecutiveFailures: (state?.consecutiveFailures ?? 0) + 1, + clearNextRetryAt: true, ); return SyncAttemptResult( @@ -419,4 +435,18 @@ class SyncOrchestrator { final jitterMillis = _random.nextInt(250); return Duration(milliseconds: baseMillis + jitterMillis); } + + DateTime _scheduledRetryAt(int consecutiveFailures) { + final safeFailures = consecutiveFailures < 1 ? 1 : consecutiveFailures; + final multiplier = 1 << (safeFailures - 1); + final baseMillis = _scheduledRetryBaseDelay.inMilliseconds * multiplier; + final cappedMillis = min( + baseMillis, + _maxScheduledRetryDelay.inMilliseconds, + ); + final jitterMillis = _random.nextInt(1500); + return DateTime.now().toUtc().add( + Duration(milliseconds: cappedMillis + jitterMillis), + ); + } } diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart index 2bfd377..4b15619 100644 --- a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart +++ b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart @@ -350,6 +350,21 @@ class SettingsScreen extends ConsumerWidget { }; } + String _formatSyncRetryAt(DateTime? value) { + if (value == null) { + return 'Not scheduled'; + } + + final local = value.toLocal(); + String twoDigits(int part) => part.toString().padLeft(2, '0'); + return '${local.year}-' + '${twoDigits(local.month)}-' + '${twoDigits(local.day)} ' + '${twoDigits(local.hour)}:' + '${twoDigits(local.minute)}:' + '${twoDigits(local.second)}'; + } + Future _showCloudSyncStatusSheet( BuildContext context, WidgetRef ref, @@ -645,6 +660,7 @@ class SettingsScreen extends ConsumerWidget { final transportConfig = ref.watch(syncTransportConfigProvider); final pendingCount = ref.watch(syncOutboxCountProvider).asData?.value ?? 0; + final syncState = ref.watch(syncStateProvider).asData?.value; final session = ref.watch(authSessionProvider).value; final hasSession = session?.isAuthenticated ?? false; @@ -699,6 +715,16 @@ class SettingsScreen extends ConsumerWidget { value: '$pendingCount pending', ), const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Failures', + value: '${syncState?.consecutiveFailures ?? 0}', + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Next retry', + value: _formatSyncRetryAt(syncState?.nextRetryAt), + ), + const SizedBox(height: AppSpacing.xs), _CloudSyncStateRow( label: 'Auth', value: hasSession ? 'Signed in' : 'Signed out', diff --git a/packages/integrations/database/lib/src/app_database.dart b/packages/integrations/database/lib/src/app_database.dart index 78c5777..13b615e 100644 --- a/packages/integrations/database/lib/src/app_database.dart +++ b/packages/integrations/database/lib/src/app_database.dart @@ -22,7 +22,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 6; + int get schemaVersion => 7; @override MigrationStrategy get migration { @@ -80,6 +80,10 @@ class AppDatabase extends _$AppDatabase { 'ON sync_outbox(entity_type, entity_id);', ); } + + if (from < 7) { + await m.addColumn(syncState, syncState.nextRetryAt); + } }, beforeOpen: (details) async { await customStatement('PRAGMA foreign_keys = ON'); diff --git a/packages/integrations/database/lib/src/daos/sync_dao.dart b/packages/integrations/database/lib/src/daos/sync_dao.dart index 628453f..e818164 100644 --- a/packages/integrations/database/lib/src/daos/sync_dao.dart +++ b/packages/integrations/database/lib/src/daos/sync_dao.dart @@ -25,6 +25,8 @@ class SyncDao extends DatabaseAccessor with _$SyncDaoMixin { Future upsertSyncState({ DateTime? lastSuccessfulSyncAt, DateTime? lastAttemptedSyncAt, + DateTime? nextRetryAt, + bool clearNextRetryAt = false, String? lastRemoteCursor, int? consecutiveFailures, }) async { @@ -40,6 +42,9 @@ class SyncDao extends DatabaseAccessor with _$SyncDaoMixin { lastAttemptedSyncAt: Value( lastAttemptedSyncAt ?? current?.lastAttemptedSyncAt, ), + nextRetryAt: Value( + clearNextRetryAt ? null : nextRetryAt ?? current?.nextRetryAt, + ), lastRemoteCursor: Value(lastRemoteCursor ?? current?.lastRemoteCursor), consecutiveFailures: Value( consecutiveFailures ?? current?.consecutiveFailures ?? 0, diff --git a/packages/integrations/database/lib/src/tables/sync_state_table.dart b/packages/integrations/database/lib/src/tables/sync_state_table.dart index 2f692b6..6bea823 100644 --- a/packages/integrations/database/lib/src/tables/sync_state_table.dart +++ b/packages/integrations/database/lib/src/tables/sync_state_table.dart @@ -6,6 +6,7 @@ class SyncState extends Table { text().withDefault(const Constant('primary'))(); // single-row table DateTimeColumn get lastSuccessfulSyncAt => dateTime().nullable()(); DateTimeColumn get lastAttemptedSyncAt => dateTime().nullable()(); + DateTimeColumn get nextRetryAt => dateTime().nullable()(); TextColumn get lastRemoteCursor => text().nullable()(); IntColumn get consecutiveFailures => integer().withDefault(const Constant(0))(); From f1df371ae563fcf53e14f1ed0cc93591a690d9c6 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 23:32:06 +0630 Subject: [PATCH 20/31] test: Add sync resilience and merge safety tests, and refactor SyncOrchestrator with a pluggable tracing runner. --- .../lib/core/sync/sync_orchestrator.dart | 27 +- apps/mobile/pubspec.yaml | 1 + .../test/core/sync/sync_resilience_test.dart | 303 ++++++++++++++++++ 3 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/test/core/sync/sync_resilience_test.dart diff --git a/apps/mobile/lib/core/sync/sync_orchestrator.dart b/apps/mobile/lib/core/sync/sync_orchestrator.dart index 4466993..dc7701a 100644 --- a/apps/mobile/lib/core/sync/sync_orchestrator.dart +++ b/apps/mobile/lib/core/sync/sync_orchestrator.dart @@ -9,6 +9,13 @@ import 'package:uuid/uuid.dart'; import 'sync_server_changes_applier.dart'; +typedef SyncTraceRunner = + Future Function( + String traceName, + Future Function() operation, { + Map? attributes, + }); + enum SyncEntityType { collection, item, tag } enum SyncOperationType { upsert, delete } @@ -66,6 +73,7 @@ class SyncOrchestrator { Duration initialRetryDelay = const Duration(milliseconds: 600), Duration scheduledRetryBaseDelay = const Duration(seconds: 15), Duration maxScheduledRetryDelay = const Duration(minutes: 10), + SyncTraceRunner? traceRunner, Random? random, }) : _syncDao = syncDao, _backendClient = backendClient, @@ -76,6 +84,7 @@ class SyncOrchestrator { _initialRetryDelay = initialRetryDelay, _scheduledRetryBaseDelay = scheduledRetryBaseDelay, _maxScheduledRetryDelay = maxScheduledRetryDelay, + _traceRunner = traceRunner ?? _defaultTraceRunner, _random = random ?? Random.secure(); final SyncDao _syncDao; @@ -87,6 +96,7 @@ class SyncOrchestrator { final Duration _initialRetryDelay; final Duration _scheduledRetryBaseDelay; final Duration _maxScheduledRetryDelay; + final SyncTraceRunner _traceRunner; final Random _random; Stream watchPendingOperationCount() { @@ -148,7 +158,6 @@ class SyncOrchestrator { await _syncDao.upsertSyncState(lastAttemptedSyncAt: DateTime.now()); final changes = _buildChangesPayload(pending); - final performanceService = FirebasePerformanceService.instance; final requestPayload = SyncRequestPayload( deviceId: deviceId, clientRequestId: _uuid.v4(), @@ -161,7 +170,6 @@ class SyncOrchestrator { request: requestPayload, pendingOperationCount: pending.length, forceFullSync: forceFullSync, - performanceService: performanceService, ); final processedOperations = _processedOperationCount(response); @@ -370,13 +378,12 @@ class SyncOrchestrator { required SyncRequestPayload request, required int pendingOperationCount, required bool forceFullSync, - required FirebasePerformanceService performanceService, }) async { DioException? lastDioError; for (var attempt = 0; attempt <= _maxNetworkRetries; attempt++) { try { - return await performanceService.traceAsync( + return await _traceRunner( 'sync_push_pull_now', () => _backendClient.sync(request), attributes: { @@ -449,4 +456,16 @@ class SyncOrchestrator { Duration(milliseconds: cappedMillis + jitterMillis), ); } + + static Future _defaultTraceRunner( + String traceName, + Future Function() operation, { + Map? attributes, + }) { + return FirebasePerformanceService.instance.traceAsync( + traceName, + operation, + attributes: attributes, + ); + } } diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index 6708048..ccb7a84 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -75,6 +75,7 @@ dev_dependencies: build_runner: ^2.10.5 riverpod_generator: ^4.0.2 flutter_launcher_icons: ^0.14.4 + drift: ^2.30.1 flutter: uses-material-design: true diff --git a/apps/mobile/test/core/sync/sync_resilience_test.dart b/apps/mobile/test/core/sync/sync_resilience_test.dart new file mode 100644 index 0000000..18df5bd --- /dev/null +++ b/apps/mobile/test/core/sync/sync_resilience_test.dart @@ -0,0 +1,303 @@ +import 'dart:collection'; +import 'dart:convert'; + +import 'package:collection_tracker/core/sync/sync_orchestrator.dart'; +import 'package:collection_tracker/core/sync/sync_server_changes_applier.dart'; +import 'package:database/database.dart'; +import 'package:dio/dio.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_api/sync_api.dart'; + +void main() { + group('SyncOrchestrator retry scheduling', () { + late AppDatabase database; + late SyncServerChangesApplier applier; + + setUp(() { + database = AppDatabase(NativeDatabase.memory()); + applier = SyncServerChangesApplier(database: database); + }); + + tearDown(() async { + await database.close(); + }); + + test('schedules nextRetryAt after retryable network failure', () async { + final failingClient = _SequenceBackendClient( + outcomes: [ + DioException( + requestOptions: RequestOptions(path: '/sync'), + type: DioExceptionType.connectionError, + error: Exception('offline'), + ), + ], + ); + final orchestrator = SyncOrchestrator( + syncDao: database.syncDao, + backendClient: failingClient, + serverChangesApplier: applier, + maxNetworkRetries: 0, + traceRunner: _runWithoutTrace, + ); + + await orchestrator.enqueueOperation( + entityType: SyncEntityType.collection, + entityId: 'collection-1', + operationType: SyncOperationType.upsert, + payload: _collectionPayload(id: 'collection-1'), + ); + + final before = DateTime.now().toUtc(); + final result = await orchestrator.syncNow(deviceId: 'device-1'); + final state = await database.syncDao.getSyncState(); + + expect(result.success, isFalse); + expect(state, isNotNull); + expect(state!.consecutiveFailures, 1); + expect(state.nextRetryAt, isNotNull); + expect(state.nextRetryAt!.toUtc().isAfter(before), isTrue); + }); + + test('clears retry schedule after successful retry', () async { + final sequenceClient = _SequenceBackendClient( + outcomes: [ + DioException( + requestOptions: RequestOptions(path: '/sync'), + type: DioExceptionType.connectionError, + error: Exception('offline'), + ), + SyncResponsePayload( + lastSyncAt: DateTime.now().toUtc(), + serverChanges: const SyncChangesPayload(), + conflicts: const >[], + syncedCollections: 1, + syncedItems: 0, + syncedTags: 0, + conflictsResolved: 0, + ), + ], + ); + final orchestrator = SyncOrchestrator( + syncDao: database.syncDao, + backendClient: sequenceClient, + serverChangesApplier: applier, + maxNetworkRetries: 0, + traceRunner: _runWithoutTrace, + ); + + await orchestrator.enqueueOperation( + entityType: SyncEntityType.collection, + entityId: 'collection-1', + operationType: SyncOperationType.upsert, + payload: _collectionPayload(id: 'collection-1'), + ); + + final first = await orchestrator.syncNow(deviceId: 'device-1'); + final firstState = await database.syncDao.getSyncState(); + expect(first.success, isFalse); + expect(firstState?.nextRetryAt, isNotNull); + + final second = await orchestrator.syncNow(deviceId: 'device-1'); + final secondState = await database.syncDao.getSyncState(); + final pending = await database.syncDao.getPendingOperations(limit: 10); + + expect(second.success, isTrue); + expect(secondState?.consecutiveFailures, 0); + expect(secondState?.nextRetryAt, isNull); + expect(pending, isEmpty); + }); + }); + + group('SyncServerChangesApplier merge safety', () { + late AppDatabase database; + late SyncServerChangesApplier applier; + late DateTime now; + + setUp(() async { + database = AppDatabase(NativeDatabase.memory()); + applier = SyncServerChangesApplier(database: database); + now = DateTime.now().toUtc(); + + await database.collectionDao.insertCollection( + CollectionsCompanion.insert( + id: 'collection-1', + name: 'Collection', + type: 'Custom', + createdAt: now, + updatedAt: now, + ), + ); + }); + + tearDown(() async { + await database.close(); + }); + + test('skips server item update when local outbox has pending op', () async { + final localUpdatedAt = now.add(const Duration(minutes: 10)); + await database.itemDao.insertItem( + ItemsCompanion.insert( + id: 'item-1', + collectionId: 'collection-1', + title: 'Local title', + createdAt: now, + updatedAt: localUpdatedAt, + ), + ); + + await database.syncDao.enqueueOperation( + id: 'item:item-1:upsert', + entityType: 'item', + entityId: 'item-1', + operationType: 'upsert', + payload: jsonEncode({ + 'id': 'item-1', + 'updatedAt': localUpdatedAt.toIso8601String(), + 'isDeleted': false, + }), + ); + + final result = await applier.apply( + SyncChangesPayload( + items: >[ + _itemPayload( + id: 'item-1', + title: 'Server title', + updatedAt: now.add(const Duration(minutes: 20)), + ), + ], + ), + ); + + final item = await database.itemDao.getItemById('item-1'); + expect(result.appliedItems, 0); + expect(result.skippedItems, 1); + expect(item?.title, 'Local title'); + }); + + test( + 'skips outdated server item update when local timestamp is newer', + () async { + final localUpdatedAt = now.add(const Duration(minutes: 10)); + await database.itemDao.insertItem( + ItemsCompanion.insert( + id: 'item-1', + collectionId: 'collection-1', + title: 'Local latest', + createdAt: now, + updatedAt: localUpdatedAt, + ), + ); + + final result = await applier.apply( + SyncChangesPayload( + items: >[ + _itemPayload( + id: 'item-1', + title: 'Server stale', + updatedAt: now.add(const Duration(minutes: 5)), + ), + ], + ), + ); + + final item = await database.itemDao.getItemById('item-1'); + expect(result.appliedItems, 0); + expect(result.skippedItems, 1); + expect(item?.title, 'Local latest'); + }, + ); + }); +} + +class _SequenceBackendClient implements SyncBackendClient { + _SequenceBackendClient({required List outcomes}) + : _outcomes = Queue.from(outcomes); + + final Queue _outcomes; + + @override + Future getCapabilities() async { + return const SyncCapabilities( + apiVersion: 'v1', + supportedModes: ['full', 'incremental'], + maxBatchSize: 1000, + conflictStrategy: 'last_write_wins', + acceptedSchemaVersions: ['v1'], + ); + } + + @override + Future sync(SyncRequestPayload request) async { + if (_outcomes.isEmpty) { + throw StateError('No outcome queued for sync call'); + } + + final next = _outcomes.removeFirst(); + if (next is DioException) { + throw next; + } + if (next is SyncResponsePayload) { + return next; + } + throw StateError('Unsupported queued outcome type: ${next.runtimeType}'); + } +} + +Map _collectionPayload({required String id}) { + final now = DateTime.now().toUtc().toIso8601String(); + return { + 'id': id, + 'name': 'Collection', + 'type': 'Custom', + 'description': 'Desc', + 'itemCount': 0, + 'version': 1, + 'isDeleted': false, + 'createdAt': now, + 'updatedAt': now, + }; +} + +Map _itemPayload({ + required String id, + required String title, + required DateTime updatedAt, +}) { + return { + 'id': id, + 'collectionId': 'collection-1', + 'title': title, + 'barcode': null, + 'coverImageUrl': null, + 'coverImagePath': null, + 'description': null, + 'notes': null, + 'metadata': null, + 'condition': null, + 'purchasePrice': null, + 'purchaseDate': null, + 'currentValue': null, + 'location': null, + 'isFavorite': false, + 'isWishlist': false, + 'sortOrder': 0, + 'quantity': 1, + 'version': 1, + 'isDeleted': false, + 'createdAt': updatedAt + .subtract(const Duration(minutes: 1)) + .toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + 'tagIds': const [], + }; +} + +Future _runWithoutTrace( + String traceName, + Future Function() operation, { + Map? attributes, +}) { + return operation(); +} From 45d38b7d8b4ca055d429edb7eb1984222c3158c9 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 23:38:49 +0630 Subject: [PATCH 21/31] feat: Allow overriding backend integration and sync features via debug environment variables and display override status in settings. --- .vscode/launch.json | 15 ++++++++++-- .../core/providers/backend_api_providers.dart | 24 +++++++++++++++++-- .../presentation/views/settings_screen.dart | 8 +++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index d41d150..c3f394c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,15 +1,26 @@ { "configurations": [ { - "name": "Launch (With ENV)", + "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", + "--dart-define=BACKEND_API_BASE_URL=http://localhost:4000" ] }, { diff --git a/apps/mobile/lib/core/providers/backend_api_providers.dart b/apps/mobile/lib/core/providers/backend_api_providers.dart index 244b96d..1acafac 100644 --- a/apps/mobile/lib/core/providers/backend_api_providers.dart +++ b/apps/mobile/lib/core/providers/backend_api_providers.dart @@ -17,6 +17,11 @@ class BackendApiReadiness { final String message; } +const _backendUseDebugEnvFlagOverrides = bool.fromEnvironment( + 'BACKEND_USE_ENV_FLAG_OVERRIDES', + defaultValue: false, +); + final backendApiBaseUrlOverrideProvider = NotifierProvider( BackendApiBaseUrlOverrideController.new, @@ -49,11 +54,15 @@ final backendIntegrationFeatureFlagProvider = Provider((ref) { return true; } + if (!_shouldUseDebugEnvFlagOverrides) { + return false; + } + const debugEnvOverride = bool.fromEnvironment( 'BACKEND_INTEGRATION_ENABLED', defaultValue: false, ); - return kDebugMode && debugEnvOverride; + return debugEnvOverride; }); final backendSyncFeatureFlagProvider = Provider((ref) { @@ -62,11 +71,19 @@ final backendSyncFeatureFlagProvider = Provider((ref) { return true; } + if (!_shouldUseDebugEnvFlagOverrides) { + return false; + } + const debugEnvOverride = bool.fromEnvironment( 'BACKEND_SYNC_ENABLED', defaultValue: false, ); - return kDebugMode && debugEnvOverride; + return debugEnvOverride; +}); + +final backendDebugEnvFlagOverridesActiveProvider = Provider((ref) { + return _shouldUseDebugEnvFlagOverrides; }); final backendApiPrefixProvider = Provider((ref) { @@ -231,3 +248,6 @@ String _normalizeBaseUrl(String value) { ? trimmed.substring(0, trimmed.length - 1) : trimmed; } + +bool get _shouldUseDebugEnvFlagOverrides => + kDebugMode && _backendUseDebugEnvFlagOverrides; diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart index 4b15619..e587757 100644 --- a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart +++ b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart @@ -661,6 +661,9 @@ class SettingsScreen extends ConsumerWidget { final pendingCount = ref.watch(syncOutboxCountProvider).asData?.value ?? 0; final syncState = ref.watch(syncStateProvider).asData?.value; + final envFlagOverridesActive = ref.watch( + backendDebugEnvFlagOverridesActiveProvider, + ); final session = ref.watch(authSessionProvider).value; final hasSession = session?.isAuthenticated ?? false; @@ -696,6 +699,11 @@ class SettingsScreen extends ConsumerWidget { : 'Disabled', ), const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Env overrides', + value: envFlagOverridesActive ? 'Active' : 'Inactive', + ), + const SizedBox(height: AppSpacing.xs), _CloudSyncStateRow( label: 'Base URL', value: transportConfig.baseUrl.isEmpty From 28b0bc386fbf8ece8514f4e719f4d6c1c3b54b8a Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Sun, 22 Feb 2026 23:59:38 +0630 Subject: [PATCH 22/31] docs: Restructure and expand documentation by moving detailed sections from `ARCHITECTURE.md` into new, specific files and updating existing guides. --- QUICK_START.md | 239 +-- README.md | 290 +--- apps/mobile/README.md | 23 +- documentation/APP_PROGRESS.md | 54 + documentation/ARCHITECTURE.md | 2203 ++------------------------- documentation/FIREBASE_AND_FLAGS.md | 89 ++ documentation/LOCALIZATION.md | 82 + documentation/README.md | 23 + documentation/SETUP_AND_RUN.md | 116 ++ documentation/SYNC_AND_AUTH.md | 138 ++ 10 files changed, 732 insertions(+), 2525 deletions(-) create mode 100644 documentation/APP_PROGRESS.md create mode 100644 documentation/FIREBASE_AND_FLAGS.md create mode 100644 documentation/LOCALIZATION.md create mode 100644 documentation/README.md create mode 100644 documentation/SETUP_AND_RUN.md create mode 100644 documentation/SYNC_AND_AUTH.md 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..7ecba36 100644 --- a/README.md +++ b/README.md @@ -1,268 +1,116 @@ # 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. -![Flutter](https://img.shields.io/badge/Flutter-3.38.7-02569B?logo=flutter) -![Dart](https://img.shields.io/badge/Dart-3.10.4-0175C2?logo=dart) -![License](https://img.shields.io/badge/license-MIT-green) -[![CI](https://github.com/mixin27/collection_tracker/actions/workflows/ci.yaml/badge.svg)](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 +- 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 -``` +2. Create API env file used by metadata integrations: -Or manually: ```bash -cd apps/mobile -flutter pub run build_runner build --delete-conflicting-outputs +cat > packages/common/env/.env <<'ENV' +GOOGLE_BOOKS_API_KEY=... +TMDB_API_KEY=... +TMDB_READ_ACCESS_TOKEN=... +IGDB_CLIENT_ID=... +IGDB_CLIENT_SECRET=... +ENV ``` -4. **Run the app** -```bash -cd apps/mobile -flutter run -``` - -## 🛠️ Development - -### Available Scripts +3. Materialize Firebase files (recommended, especially for CI/local parity): ```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 +./scripts/setup_firebase.sh --require dart ``` -### Using Makefile +4. Generate code: ```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 - -Run all tests: -```bash -./scripts/test_all.sh -``` - -Run tests for specific package: -```bash -cd packages/core/domain -dart test -``` - -Generate coverage: -```bash -./scripts/coverage.sh -``` - - - -## 📱 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 - -### Code Style +Detailed setup instructions: [documentation/SETUP_AND_RUN.md](documentation/SETUP_AND_RUN.md) -- 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 Flags (Important) -## 📄 License +Cloud Sync is enabled only when both Remote Config flags are `true`: -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +- `app_backend_integration_enabled` +- `app_sync_feature_enabled` -## 🙏 Acknowledgments +If either is `false`, sync UI/actions are disabled. Full explanation: [documentation/FIREBASE_AND_FLAGS.md](documentation/FIREBASE_AND_FLAGS.md) -- Flutter team for the amazing framework -- Riverpod for excellent state management -- Drift for type-safe database operations -- All open-source contributors +## Documentation -## 📞 Support +- [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 -If you have any questions or issues: +## CI/CD -- 📧 Email: kyawzayartun.contact@gmail.com -- 🐛 Issues: [GitHub Issues](https://github.com/mixin27/collection_tracker/issues) +GitHub Actions currently runs: -## 🗺️ Roadmap +- Analyze (`.github/workflows/ci.yaml`) +- Tests (`.github/workflows/ci.yaml`) +- PR checks (`.github/workflows/pr-checks.yaml`) +- Android release pipeline (`.github/workflows/release.yaml`) -- [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 +Firebase secrets are materialized during CI using `scripts/setup_firebase.sh`. ---- +## License -Made with ❤️ using Flutter +MIT. See [LICENSE](LICENSE). 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/documentation/APP_PROGRESS.md b/documentation/APP_PROGRESS.md new file mode 100644 index 0000000..1f08046 --- /dev/null +++ b/documentation/APP_PROGRESS.md @@ -0,0 +1,54 @@ +# App Progress + +Last updated: 2026-02-22 + +## Status Legend + +- Complete: implemented and wired into the main app flow +- In Progress: partially implemented, usable but still evolving +- Planned: not implemented yet + +## Feature Matrix + +| Area | Status | Notes | +| --- | --- | --- | +| Onboarding | In Progress | Multi-page onboarding exists but still has hardcoded copy and limited localization. | +| Collection management | Complete | Create/edit/delete collections, list and grid modes, action sheets, and summary metrics. | +| Collection detail -> items | Complete | Open collection to items screen with responsive list/grid support. | +| Item CRUD | Complete | Add/edit/delete items with metadata fields, image, quantity, condition, favorite/wishlist. | +| Item detail UX | Complete | Rich detail layout, quick actions, tags section, notes, and value insights. | +| Price tracking | Complete | Purchase/current value support plus historical chart points from local DB history. | +| Favorites and wishlist | Complete | Dedicated shell tabs with global item streams. | +| Search | In Progress | Collection-level search exists, currently simple title matching and partial localization coverage. | +| Barcode scanner | In Progress | Camera scan flow implemented; UI text and polish are still being localized/polished. | +| Tags system | Complete | Tag assignment, tag list with usage, rename/merge/delete, bulk multi-select actions. | +| Tag item drill-down | Complete | Tag -> grouped items by collection with per-collection open action. | +| Statistics | Complete | Portfolio value, health ratios, distribution charts, top-valued/recent/largest collection insights. | +| Import/export | Complete | JSON import/export and CSV export, integrated in settings. | +| Display preferences | Complete | Theme mode, theme variant, AMOLED option, collections/items view modes persisted. | +| Localization framework | Complete | `gen_l10n` setup with ARB files and language switcher in settings. | +| Localization coverage | In Progress | Most core flows localized; some screens still contain hardcoded English strings. | +| Custom design system | Complete | Reusable primitives (`AppButton`, `AppCard`, `AppInput`, `AppSheet`, `AppReveal`, etc.). | +| Glass bottom navigation | Complete | Custom stacked shell navigation (not `Scaffold.bottomNavigationBar`) with motion. | +| Firebase Core integration | Complete | Firebase bootstrap and options-based initialization wired at app startup. | +| Crashlytics | Complete | Global Flutter and platform error capture with runtime collection toggle support. | +| Analytics + consent | Complete | Consent gate, persistent preferences, provider-based enable/disable, auto screen tracking. | +| Remote Config runtime flags | Complete | Runtime config fetch/activate with app resume auto-refresh and status tracking. | +| Firebase Performance | Complete | Trace helpers used in export/import and sync orchestration points. | +| Operational telemetry | Complete | Local history + analytics/crashlytics logging for sync/data/runtime operational events. | +| Optional backend auth | Complete | Sign in/register/logout + secure session storage, optional for non-sync users. | +| Sync v1 (collections/items/tags) | In Progress | Outbox + push/pull + server apply + retries implemented; still feature-flag gated for rollout. | +| Sync production rollout | Planned | Feature currently gated by remote config and local readiness checks. | + +## Known Gaps and Risks + +- Some UI copy remains hardcoded in English (notably parts of onboarding, scanner, auth, and search). +- Sync currently covers collections, items, and tags; non-entity preferences are local-only. +- Search currently uses straightforward SQL `LIKE` matching and is not yet a global semantic search. +- Integration test coverage for advanced sync conflict edge cases is still limited. + +## Recommended Next Work + +1. Finish localization coverage for remaining hardcoded strings. +2. Harden sync rollout with deeper integration tests (conflicts, duplicate operations, offline replay). +3. Add stronger product analytics dashboards/events around onboarding -> auth -> sync funnel. diff --git a/documentation/ARCHITECTURE.md b/documentation/ARCHITECTURE.md index 4521f36..e8fd32f 100644 --- a/documentation/ARCHITECTURE.md +++ b/documentation/ARCHITECTURE.md @@ -1,2139 +1,178 @@ -# Collection Tracker App - Technical Documentation +# Architecture -## Table of Contents -1. [Project Overview](#project-overview) -2. [Architecture](#architecture) -3. [Project Structure](#project-structure) -4. [Core Features](#core-features) -5. [Pagination Strategy](#pagination-strategy) -6. [State Management](#state-management) -7. [Error Handling](#error-handling) -8. [Database Schema](#database-schema) -9. [API Integration](#api-integration) -10. [Development Guidelines](#development-guidelines) +This document describes the architecture that is currently implemented in the repository. ---- +## 1. Architecture Style -## 1. Project Overview +The app uses a layered setup aligned with clean architecture principles: -### Description -A universal collection tracking app built with Flutter that allows users to catalog and manage various types of collections (books, video games, movies, comics, etc.) with barcode scanning, metadata fetching, and cloud synchronization. +- Presentation layer in `apps/mobile/lib/features/*` +- Domain interfaces/entities in `packages/core/domain` +- Data/repository implementations in `packages/core/data` +- Integration packages for storage, database, Firebase, analytics, backend API, and sync transport -### Tech Stack -- **Framework:** Flutter 3.x -- **State Management:** Riverpod + riverpod_generator -- **Architecture:** Clean Architecture + MVVM -- **Local Database:** Drift -- **Workspace Management:** Dart Pub Workspace -- **Code Generation:** build_runner, freezed, json_serializable -- **Functional Programming:** fpdart (Either type) +State management is Riverpod-based (including `riverpod_generator` notifiers/providers). -### Key Requirements -- ✅ High performance (60fps animations, fast startup) -- ✅ Excellent UI/UX with smooth animations -- ✅ Offline-first architecture -- ✅ Pagination for large datasets -- ✅ Modular package structure -- ✅ Comprehensive error handling -- ✅ Clean code with best practices +## 2. Workspace Modules ---- +| Module | Purpose | +| --- | --- | +| `apps/mobile` | Main Flutter app, routing, screens, feature view models | +| `packages/core/domain` | Entities and repository contracts | +| `packages/core/data` | Repository implementations and local->sync outbox bridging | +| `packages/common/ui` | Custom design system primitives and motion widgets | +| `packages/common/utils` | Utility helpers | +| `packages/common/env` | Envied-based metadata API keys access | +| `packages/integrations/database` | Drift database, schema, DAOs | +| `packages/integrations/storage` | Shared preferences, secure storage, import/export, image helpers | +| `packages/integrations/analytics` | Provider-agnostic analytics service + middleware | +| `packages/integrations/firebase_services` | Firebase Remote Config + Performance wrappers | +| `packages/integrations/auth_session` | Persistent auth session storage abstractions | +| `packages/integrations/backend_api` | Backend auth HTTP client + models | +| `packages/integrations/sync_api` | Sync API contract, auth token adapter, backend sync client | -## 2. Architecture +## 3. App Startup Flow -### Clean Architecture Layers +Bootstrap entrypoint: `apps/mobile/lib/main.dart` and `apps/mobile/lib/core/bootstrap/app_bootstrap.dart` -``` -┌─────────────────────────────────────────┐ -│ Presentation Layer │ -│ (UI, ViewModels, Widgets, State) │ -└──────────────┬──────────────────────────┘ - │ -┌──────────────▼──────────────────────────┐ -│ Domain Layer │ -│ (Entities, Use Cases, Repositories) │ -└──────────────┬──────────────────────────┘ - │ -┌──────────────▼──────────────────────────┐ -│ Data Layer │ -│ (Models, Repository Impl, DataSources) │ -└──────────────────────────────────────────┘ -``` +Startup sequence: -### MVVM Pattern per Screen +1. Initialize logger +2. Initialize preference storage +3. Initialize Firebase Core +4. Initialize Firebase services bootstrap (Remote Config + Performance) +5. Configure Crashlytics collection state +6. Initialize analytics service (with persisted consent and enabled preference) +7. Read onboarding completion flag and provide initial app state overrides -``` -Screen (View) - ↕ -ViewModel (Business Logic + State) - ↕ -Use Cases (Domain Logic) - ↕ -Repository (Data Access) - ↕ -Data Sources (Local/Remote) -``` +At runtime, app root wraps routed content with: ---- +- `SyncAutoRetryOnResume` (retry pending sync on lifecycle resume when due) +- `FirebaseRuntimeConfigAutoRefresh` (refresh runtime flags on resume, throttled) -## 3. Project Structure +## 4. Navigation Model -``` -collection_tracker/ -├── pubspec.yaml # Workspace root configuration -│ -├── apps/ -│ └── mobile/ -│ ├── lib/ -│ │ ├── main.dart -│ │ ├── app.dart -│ │ ├── core/ -│ │ │ ├── router/ -│ │ │ │ ├── app_router.dart -│ │ │ │ └── routes.dart -│ │ │ └── di/ -│ │ │ └── injection.dart -│ │ │ -│ │ └── features/ -│ │ ├── onboarding/ -│ │ │ ├── presentation/ -│ │ │ │ ├── screens/ -│ │ │ │ ├── view_models/ -│ │ │ │ └── widgets/ -│ │ │ └── domain/ -│ │ │ -│ │ ├── collections/ -│ │ │ ├── data/ -│ │ │ │ ├── models/ -│ │ │ │ ├── repositories/ -│ │ │ │ └── datasources/ -│ │ │ ├── domain/ -│ │ │ │ ├── entities/ -│ │ │ │ ├── repositories/ -│ │ │ │ └── usecases/ -│ │ │ └── presentation/ -│ │ │ ├── screens/ -│ │ │ ├── view_models/ -│ │ │ └── widgets/ -│ │ │ -│ │ ├── items/ -│ │ │ ├── data/ -│ │ │ ├── domain/ -│ │ │ └── presentation/ -│ │ │ -│ │ ├── search/ -│ │ ├── scanner/ -│ │ ├── statistics/ -│ │ └── settings/ -│ │ -│ └── pubspec.yaml -│ -├── packages/ -│ ├── core/ -│ │ ├── domain/ -│ │ │ ├── lib/ -│ │ │ │ ├── entities/ -│ │ │ │ │ ├── collection.dart -│ │ │ │ │ ├── item.dart -│ │ │ │ │ └── user_preferences.dart -│ │ │ │ │ -│ │ │ │ ├── repositories/ -│ │ │ │ │ ├── collection_repository.dart -│ │ │ │ │ └── item_repository.dart -│ │ │ │ │ -│ │ │ │ ├── usecases/ -│ │ │ │ │ └── base_usecase.dart -│ │ │ │ │ -│ │ │ │ └── failures/ -│ │ │ │ └── app_exception.dart -│ │ │ │ -│ │ │ └── pubspec.yaml -│ │ │ -│ │ └── data/ -│ │ ├── lib/ -│ │ │ ├── models/ -│ │ │ │ ├── collection_model.dart -│ │ │ │ └── item_model.dart -│ │ │ │ -│ │ │ ├── repositories/ -│ │ │ │ ├── collection_repository_impl.dart -│ │ │ │ └── item_repository_impl.dart -│ │ │ │ -│ │ │ ├── datasources/ -│ │ │ │ ├── local/ -│ │ │ │ │ ├── collection_local_datasource.dart -│ │ │ │ │ └── item_local_datasource.dart -│ │ │ │ └── remote/ -│ │ │ │ ├── metadata_remote_datasource.dart -│ │ │ │ └── sync_remote_datasource.dart -│ │ │ │ -│ │ │ └── mappers/ -│ │ │ ├── collection_mapper.dart -│ │ │ └── item_mapper.dart -│ │ │ -│ │ └── pubspec.yaml -│ │ -│ ├── common/ -│ │ ├── ui/ -│ │ │ ├── lib/ -│ │ │ │ ├── theme/ -│ │ │ │ │ ├── app_theme.dart -│ │ │ │ │ ├── colors.dart -│ │ │ │ │ └── typography.dart -│ │ │ │ │ -│ │ │ │ ├── widgets/ -│ │ │ │ │ ├── buttons/ -│ │ │ │ │ ├── cards/ -│ │ │ │ │ ├── loading/ -│ │ │ │ │ ├── empty_state.dart -│ │ │ │ │ └── error_view.dart -│ │ │ │ │ -│ │ │ │ ├── animations/ -│ │ │ │ │ ├── fade_in.dart -│ │ │ │ │ └── slide_transition.dart -│ │ │ │ │ -│ │ │ │ └── styles/ -│ │ │ │ └── spacing.dart -│ │ │ │ -│ │ │ └── pubspec.yaml -│ │ │ -│ │ └── utils/ -│ │ ├── lib/ -│ │ │ ├── extensions/ -│ │ │ │ ├── string_extensions.dart -│ │ │ │ ├── context_extensions.dart -│ │ │ │ └── datetime_extensions.dart -│ │ │ │ -│ │ │ ├── helpers/ -│ │ │ │ ├── debouncer.dart -│ │ │ │ └── image_helper.dart -│ │ │ │ -│ │ │ ├── constants/ -│ │ │ │ └── app_constants.dart -│ │ │ │ -│ │ │ └── validators/ -│ │ │ └── input_validators.dart -│ │ │ -│ │ └── pubspec.yaml -│ │ -│ └── integrations/ -│ ├── database/ -│ │ ├── lib/ -│ │ │ ├── database.dart -│ │ │ ├── database.g.dart -│ │ │ ├── tables/ -│ │ │ │ ├── collections_table.dart -│ │ │ │ ├── items_table.dart -│ │ │ │ └── tags_table.dart -│ │ │ │ -│ │ │ ├── daos/ -│ │ │ │ ├── collection_dao.dart -│ │ │ │ └── item_dao.dart -│ │ │ │ -│ │ │ └── migrations/ -│ │ │ └── migration_v1_to_v2.dart -│ │ │ -│ │ └── pubspec.yaml -│ │ -│ ├── barcode_scanner/ -│ │ ├── lib/ -│ │ │ ├── barcode_scanner.dart -│ │ │ └── models/ -│ │ │ └── scan_result.dart -│ │ └── pubspec.yaml -│ │ -│ ├── metadata_api/ -│ │ ├── lib/ -│ │ │ ├── clients/ -│ │ │ │ ├── google_books_client.dart -│ │ │ │ ├── igdb_client.dart -│ │ │ │ └── tmdb_client.dart -│ │ │ │ -│ │ │ ├── models/ -│ │ │ │ ├── book_metadata.dart -│ │ │ │ ├── game_metadata.dart -│ │ │ │ └── movie_metadata.dart -│ │ │ │ -│ │ │ └── pagination/ -│ │ │ ├── paginated_response.dart -│ │ │ └── page_info.dart -│ │ │ -│ │ └── pubspec.yaml -│ │ -│ ├── analytics/ -│ │ ├── lib/ -│ │ │ ├── analytics_service.dart -│ │ │ └── events/ -│ │ └── pubspec.yaml -│ │ -│ ├── logging/ -│ │ ├── lib/ -│ │ │ └── logger.dart -│ │ └── pubspec.yaml -│ │ -│ ├── storage/ -│ │ ├── lib/ -│ │ │ ├── file_storage_service.dart -│ │ │ └── image_storage_service.dart -│ │ └── pubspec.yaml -│ │ -│ └── payment/ -│ ├── lib/ -│ │ └── payment_service.dart -│ └── pubspec.yaml -``` +Navigation stack uses `GoRouter` with: ---- +- `StatefulShellRoute.indexedStack` for main tabs (collections, favorites, wishlist, settings) +- Global routes for item detail/edit, scanner, statistics, auth, and tag-item listing +- Custom shell widget (`AppShell`) with: + - glass bottom navigation on compact screens + - navigation rail on larger screens -## 4. Core Features +Analytics screen tracking is connected through a navigator observer. -### Feature List +## 5. Presentation + State Patterns -#### MVP (Version 1.0) -1. **Onboarding** - - Welcome screens - - Permission requests (camera, storage) - - Quick tutorial +Main pattern per feature: -2. **Collection Management** - - Create collections (Books, Games, Movies) - - View all collections - - Edit/delete collections - - Collection statistics +- Screen widgets (`views/`) +- Riverpod providers/notifiers (`view_models/`, `providers/`) +- Reusable feature widgets (`widgets/`) -3. **Item Management** - - Add items via barcode scan - - Add items manually - - Auto-fetch metadata from APIs - - View item details - - Edit/delete items - - Upload photos - - Add notes and custom fields +Common UI primitives come from `packages/common/ui`: -4. **Search & Filter** - - Search within collections - - Filter by type, condition, tags - - Sort options +- `AppButton`, `AppCard`, `AppInput`, `AppDialog`, `AppSheet` +- `AppReveal`, `AppAnimatedSwitcher`, `LoadingView` +- `GlassSurface`, `GlassSegmentedNavigationBar` +- Theme + design tokens (`AppTheme`, `DesignTokens`, `AppMotion`, spacing/radii) -5. **Barcode Scanner** - - Quick scan to add - - Support ISBN, UPC codes +## 6. Data and Local Persistence -6. **Settings** - - Dark/light mode - - Export data (CSV) - - About page +### Drift Database -#### Future Features (v1.1+) -- Cloud sync -- Sharing collections -- Price tracking -- Loan tracking -- Advanced statistics -- Premium subscription -- Multiple photos per item -- Duplicate detection +Core tables include: ---- +- `collections` +- `items` +- `tags` +- `item_tags` +- `item_price_history` +- `sync_outbox` +- `sync_state` -## 5. Pagination Strategy +Schema is currently version `7` in `AppDatabase`. -### Overview -Pagination is critical for: -- **Network data sources** (API calls to Google Books, IGDB, TMDB) -- **Local database** (when collections have 1000+ items) +### Repositories -### Implementation Approach +- `CollectionRepositoryImpl` and `ItemRepositoryImpl` perform local DB operations. +- When sync outbox writes are enabled, repositories also enqueue outbox operations for mutations. -#### 5.1 Pagination Models +### Preferences and Secure Storage -```dart -// packages/integrations/metadata_api/lib/pagination/page_info.dart -@freezed -class PageInfo with _$PageInfo { - const factory PageInfo({ - required int currentPage, - required int pageSize, - required int totalItems, - required int totalPages, - required bool hasNextPage, - required bool hasPreviousPage, - }) = _PageInfo; +- `PrefsStorageService`: UI/settings/runtime preferences and lightweight app state +- `SecureStorageService`: auth session payload and sensitive values - factory PageInfo.fromJson(Map json) => - _$PageInfoFromJson(json); -} +## 7. Sync and Backend Integration -// packages/integrations/metadata_api/lib/pagination/paginated_response.dart -@freezed -class PaginatedResponse with _$PaginatedResponse { - const factory PaginatedResponse({ - required List items, - required PageInfo pageInfo, - }) = _PaginatedResponse; -} -``` +Sync and backend auth are optional and feature-flag gated. -#### 5.2 Pagination State +### Readiness Gates -```dart -// Pagination state for ViewModels -@freezed -class PaginatedState with _$PaginatedState { - const factory PaginatedState.initial() = _Initial; +Cloud sync becomes ready only when all are true: - const factory PaginatedState.loading({ - @Default([]) List currentItems, - @Default(false) bool isLoadingMore, - }) = _Loading; +1. Backend integration flag enabled +2. Sync feature flag enabled +3. API base URL resolved/configured +4. Auth session available (signed in) - const factory PaginatedState.loaded({ - required List items, - required PageInfo pageInfo, - @Default(false) bool isLoadingMore, - }) = _Loaded; +### Sync Pipeline (v1) - const factory PaginatedState.error({ - required AppException exception, - @Default([]) List currentItems, - }) = _Error; -} -``` +- Local mutations enqueue outbox operations (`sync_outbox`) +- Sync orchestrator builds push payload from outbox entries (collections/items/tags) +- Backend response includes counters, conflicts, and server changes +- Server changes are applied locally with timestamp checks and pending-local-op protections +- Processed outbox operations are removed +- Sync state tracks last success/attempt and retry scheduling -#### 5.3 Paginated Repository Pattern +Retry behavior includes immediate network retry attempts plus scheduled retries after failures. -```dart -// packages/core/domain/lib/repositories/item_repository.dart -abstract class ItemRepository { - // Local pagination (offset-based) - Future>> getItems({ - required String collectionId, - required int page, - required int pageSize, - }); +## 8. Firebase and Observability - // Network search with pagination (cursor or page-based) - Future>> searchItems({ - required String query, - String? cursor, - int? page, - int pageSize = 20, - }); -} -``` +### Runtime Flags -#### 5.4 Local Database Pagination (Drift) +Remote Config keys drive runtime behavior for: -```dart -// packages/integrations/database/lib/daos/item_dao.dart -@DriftAccessor(tables: [Items]) -class ItemDao extends DatabaseAccessor with _$ItemDaoMixin { - ItemDao(AppDatabase db) : super(db); +- Analytics collection +- Crashlytics collection +- Performance collection +- Backend integration +- Sync feature - // Offset-based pagination for local data - Future> getItemsPaginated({ - required String collectionId, - required int page, - required int pageSize, - }) async { - final offset = (page - 1) * pageSize; +### Analytics - // Get total count - final totalQuery = select(items) - ..where((tbl) => tbl.collectionId.equals(collectionId)); - final totalCount = await totalQuery.get().then((rows) => rows.length); +- Custom analytics service abstraction with middleware +- Consent gate shown after onboarding when consent status is unknown +- Tracking can be enabled/disabled via persisted settings - // Get paginated items - final query = select(items) - ..where((tbl) => tbl.collectionId.equals(collectionId)) - ..orderBy([(tbl) => OrderingTerm.desc(tbl.createdAt)]) - ..limit(pageSize, offset: offset); +### Crashlytics and Performance - final itemsList = await query.get(); +- Crashlytics global error handlers are wired at bootstrap +- Performance tracing wrappers are used in data transfer and sync operations - final pageInfo = PageInfo( - currentPage: page, - pageSize: pageSize, - totalItems: totalCount, - totalPages: (totalCount / pageSize).ceil(), - hasNextPage: offset + pageSize < totalCount, - hasPreviousPage: page > 1, - ); +### Operational Telemetry - return PaginatedResponse( - items: itemsList, - pageInfo: pageInfo, - ); - } -} -``` +Operational events are tracked to: -#### 5.5 Network Pagination (API Clients) +- Analytics events +- Crashlytics logs/keys/errors (best effort) +- Local rolling history for diagnostics UI in settings -```dart -// packages/integrations/metadata_api/lib/clients/google_books_client.dart -class GoogleBooksClient { - final Dio _dio; +## 9. Localization - GoogleBooksClient(this._dio); +- Flutter `gen_l10n` with ARB files under `apps/mobile/lib/l10n/arb` +- Supported locales currently include: `en`, `es`, `id`, `ja`, `ko`, `my`, `zh` +- Language selection is persisted via Riverpod notifier + shared preferences - // Page-based pagination (Google Books uses startIndex) - Future> searchBooks({ - required String query, - int page = 1, - int pageSize = 20, - }) async { - try { - final startIndex = (page - 1) * pageSize; +## 10. Build and Codegen - final response = await _dio.get( - '/volumes', - queryParameters: { - 'q': query, - 'startIndex': startIndex, - 'maxResults': pageSize, - }, - ); +Workspace relies on generated files for Riverpod/Freezed/Drift code. - final totalItems = response.data['totalItems'] as int; - final items = (response.data['items'] as List?) - ?.map((json) => BookMetadata.fromJson(json)) - .toList() ?? []; +Primary commands: - final pageInfo = PageInfo( - currentPage: page, - pageSize: pageSize, - totalItems: totalItems, - totalPages: (totalItems / pageSize).ceil(), - hasNextPage: startIndex + pageSize < totalItems, - hasPreviousPage: page > 1, - ); +- `dart pub get` +- `./scripts/build_all.sh` +- `./scripts/analyze_all.sh` +- `./scripts/test_all.sh` - return PaginatedResponse(items: items, pageInfo: pageInfo); - } catch (e) { - throw NetworkException(message: e.toString()); - } - } -} - -// packages/integrations/metadata_api/lib/clients/igdb_client.dart -class IGDBClient { - final Dio _dio; - - IGDBClient(this._dio); - - // Cursor-based pagination (if API supports it) - Future> searchGames({ - required String query, - String? cursor, - int pageSize = 20, - }) async { - try { - final response = await _dio.post( - '/games', - data: { - 'search': query, - 'limit': pageSize, - if (cursor != null) 'cursor': cursor, - }, - ); - - final items = (response.data['items'] as List) - .map((json) => GameMetadata.fromJson(json)) - .toList(); - - final nextCursor = response.data['next_cursor'] as String?; - - // For cursor-based, we estimate page info - final pageInfo = PageInfo( - currentPage: cursor == null ? 1 : -1, // Unknown for cursor - pageSize: pageSize, - totalItems: -1, // Unknown for cursor-based - totalPages: -1, - hasNextPage: nextCursor != null, - hasPreviousPage: cursor != null, - ); - - return PaginatedResponse(items: items, pageInfo: pageInfo); - } catch (e) { - throw NetworkException(message: e.toString()); - } - } -} -``` - -#### 5.6 Paginated ViewModel - -```dart -// features/items/presentation/view_models/items_view_model.dart -@riverpod -class ItemsViewModel extends _$ItemsViewModel { - @override - PaginatedState build(String collectionId) { - _loadFirstPage(); - return const PaginatedState.initial(); - } - - Future _loadFirstPage() async { - state = const PaginatedState.loading(); - - final repository = ref.read(itemRepositoryProvider); - final result = await repository.getItems( - collectionId: collectionId, - page: 1, - pageSize: 20, - ); - - result.fold( - (exception) => state = PaginatedState.error(exception: exception), - (response) => state = PaginatedState.loaded( - items: response.items, - pageInfo: response.pageInfo, - ), - ); - } - - Future loadNextPage() async { - final currentState = state; - if (currentState is! _Loaded) return; - if (!currentState.pageInfo.hasNextPage) return; - if (currentState.isLoadingMore) return; - - // Set loading more flag - state = currentState.copyWith(isLoadingMore: true); - - final repository = ref.read(itemRepositoryProvider); - final result = await repository.getItems( - collectionId: collectionId, - page: currentState.pageInfo.currentPage + 1, - pageSize: currentState.pageInfo.pageSize, - ); - - result.fold( - (exception) => state = PaginatedState.error( - exception: exception, - currentItems: currentState.items, - ), - (response) => state = PaginatedState.loaded( - items: [...currentState.items, ...response.items], - pageInfo: response.pageInfo, - isLoadingMore: false, - ), - ); - } - - Future refresh() async { - await _loadFirstPage(); - } -} -``` - -#### 5.7 Paginated UI (Infinite Scroll) - -```dart -// features/items/presentation/screens/items_screen.dart -class ItemsScreen extends ConsumerWidget { - final String collectionId; - - const ItemsScreen({required this.collectionId, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(itemsViewModelProvider(collectionId)); - final viewModel = ref.read(itemsViewModelProvider(collectionId).notifier); - - return Scaffold( - body: state.when( - initial: () => const LoadingView(), - loading: (currentItems, isLoadingMore) { - if (currentItems.isEmpty) { - return const LoadingView(); - } - return _buildList(currentItems, isLoadingMore, viewModel); - }, - loaded: (items, pageInfo, isLoadingMore) { - if (items.isEmpty) { - return const EmptyStateView(); - } - return _buildList(items, isLoadingMore, viewModel); - }, - error: (exception, currentItems) { - if (currentItems.isEmpty) { - return ErrorView( - exception: exception, - onRetry: viewModel.refresh, - ); - } - return _buildList(currentItems, false, viewModel); - }, - ), - ); - } - - Widget _buildList( - List items, - bool isLoadingMore, - ItemsViewModel viewModel, - ) { - return RefreshIndicator( - onRefresh: viewModel.refresh, - child: ListView.builder( - itemCount: items.length + (isLoadingMore ? 1 : 0), - itemBuilder: (context, index) { - // Load more when reaching end - if (index == items.length - 3) { - viewModel.loadNextPage(); - } - - if (index == items.length) { - return const LoadingMoreIndicator(); - } - - return ItemCard(item: items[index]); - }, - ), - ); - } -} -``` - -### Pagination Best Practices - -1. **Choose the right strategy:** - - **Offset-based**: For local database, small datasets - - **Page-based**: For APIs that support page numbers - - **Cursor-based**: For large datasets, real-time data - -2. **Performance optimizations:** - - Cache paginated results - - Prefetch next page when user reaches 80% of current page - - Use `ListView.builder` for efficient rendering - - Implement pull-to-refresh - -3. **Error handling:** - - Keep current items on error - - Show retry option - - Handle network timeouts gracefully - -4. **User experience:** - - Show skeleton loaders for first page - - Show subtle loading indicator for next pages - - Implement pull-to-refresh - - Handle empty states - ---- - -## 6. State Management - -### Riverpod Patterns - -#### 6.1 Provider Types - -```dart -// Simple value provider -@riverpod -AppTheme appTheme(AppThemeRef ref) { - return AppTheme.light(); -} - -// Async provider -@riverpod -Future userPreferences(UserPreferencesRef ref) async { - final repository = ref.watch(preferencesRepositoryProvider); - return repository.getPreferences(); -} - -// Stream provider -@riverpod -Stream> collectionsStream(CollectionsStreamRef ref) { - final dao = ref.watch(collectionDaoProvider); - return dao.watchAllCollections(); -} - -// Stateful provider (ViewModel) -@riverpod -class CollectionsViewModel extends _$CollectionsViewModel { - @override - Future build() async { - return _loadCollections(); - } - - // Methods... -} -``` - -#### 6.2 ViewModel Pattern - -```dart -// Base state for features -@freezed -class FeatureState with _$FeatureState { - const factory FeatureState.initial() = _Initial; - const factory FeatureState.loading() = _Loading; - const factory FeatureState.loaded(T data) = _Loaded; - const factory FeatureState.error(AppException exception) = _Error; -} - -// ViewModel with loading, success, error states -@riverpod -class ItemDetailViewModel extends _$ItemDetailViewModel { - @override - Future> build(String itemId) async { - return _loadItem(); - } - - Future> _loadItem() async { - try { - final useCase = ref.read(getItemUseCaseProvider); - final item = await useCase.execute(itemId); - return FeatureState.loaded(item); - } on AppException catch (e) { - return FeatureState.error(e); - } - } - - Future updateItem(Item updatedItem) async { - state = const AsyncValue.loading(); - - state = await AsyncValue.guard(() async { - final useCase = ref.read(updateItemUseCaseProvider); - await useCase.execute(updatedItem); - return FeatureState.loaded(updatedItem); - }); - } - - Future deleteItem() async { - state = const AsyncValue.loading(); - - state = await AsyncValue.guard(() async { - final useCase = ref.read(deleteItemUseCaseProvider); - await useCase.execute(itemId); - return const FeatureState.initial(); - }); - } -} -``` - ---- - -## 7. Error Handling - -### Exception Hierarchy - -```dart -// packages/core/domain/lib/failures/app_exception.dart -@freezed -class AppException with _$AppException implements Exception { - // Network errors - const factory AppException.network({ - required String message, - @Default('') String details, - StackTrace? stackTrace, - }) = NetworkException; - - const factory AppException.timeout({ - required String message, - }) = TimeoutException; - - // Database errors - const factory AppException.database({ - required String message, - StackTrace? stackTrace, - }) = DatabaseException; - - // Validation errors - const factory AppException.validation({ - required String message, - Map? fieldErrors, - }) = ValidationException; - - // Not found errors - const factory AppException.notFound({ - required String message, - String? resourceType, - String? resourceId, - }) = NotFoundException; - - // Permission errors - const factory AppException.permission({ - required String message, - required String permissionType, - }) = PermissionException; - - // Business logic errors - const factory AppException.business({ - required String message, - String? code, - }) = BusinessException; - - // Unknown errors - const factory AppException.unknown({ - required String message, - StackTrace? stackTrace, - }) = UnknownException; -} - -// Extension for user-friendly messages -extension AppExceptionX on AppException { - String get userMessage => when( - network: (msg, _, __) => 'Network error: Please check your connection', - timeout: (msg) => 'Request timed out. Please try again', - database: (msg, _) => 'Database error occurred', - validation: (msg, _) => msg, - notFound: (msg, _, __) => msg, - permission: (msg, _) => msg, - business: (msg, _) => msg, - unknown: (msg, _) => 'An unexpected error occurred', - ); -} -``` - -### Error Handling in Repository - -```dart -class ItemRepositoryImpl implements ItemRepository { - final ItemLocalDataSource _localDataSource; - final MetadataRemoteDataSource _remoteDataSource; - final Logger _logger; - - @override - Future> getItemById(String id) async { - try { - final model = await _localDataSource.getById(id); - if (model == null) { - return const Left(AppException.notFound( - message: 'Item not found', - resourceType: 'Item', - )); - } - return Right(model.toEntity()); - } on DriftException catch (e, stack) { - _logger.error('Database error getting item', e, stack); - return Left(AppException.database( - message: 'Failed to retrieve item', - stackTrace: stack, - )); - } catch (e, stack) { - _logger.error('Unknown error getting item', e, stack); - return Left(AppException.unknown( - message: e.toString(), - stackTrace: stack, - )); - } - } - - @override - Future> fetchMetadata( - String barcode, - ) async { - try { - final metadata = await _remoteDataSource.fetchByBarcode(barcode); - return Right(metadata); - } on DioException catch (e, stack) { - _logger.error('Network error fetching metadata', e, stack); - - if (e.type == DioExceptionType.connectionTimeout) { - return const Left(AppException.timeout( - message: 'Connection timeout', - )); - } - - return Left(AppException.network( - message: 'Failed to fetch metadata', - details: e.message ?? '', - stackTrace: stack, - )); - } catch (e, stack) { - _logger.error('Unknown error fetching metadata', e, stack); - return Left(AppException.unknown( - message: e.toString(), - stackTrace: stack, - )); - } - } -} -``` - -### Error Handling in UI - -```dart -// Common error view widget -class ErrorView extends StatelessWidget { - final AppException exception; - final VoidCallback? onRetry; - - const ErrorView({ - required this.exception, - this.onRetry, - super.key, - }); - - @override - Widget build(BuildContext context) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - _getIconForException(), - size: 64, - color: context.colors.error, - ), - const SizedBox(height: 16), - Text( - exception.userMessage, - textAlign: TextAlign.center, - style: context.textTheme.titleMedium, - ), - if (onRetry != null) ...[ - const SizedBox(height: 24), - ElevatedButton.icon( - onPressed: onRetry, - icon: const Icon(Icons.refresh), - label: const Text('Retry'), - ), - ], - ], - ), - ), - ); - } - - IconData _getIconForException() { - return exception.when( - network: (_, __, ___) => Icons.wifi_off, - timeout: (_) => Icons.timer_off, - database: (_, __) => Icons.storage, - validation: (_, __) => Icons.error_outline, - notFound: (_, __, ___) => Icons.search_off, - permission: (_, __) => Icons.lock, - business: (_, __) => Icons.warning, - unknown: (_, __) => Icons.error, - ); - } -} - -// Usage in screen -@override -Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(itemDetailViewModelProvider(itemId)); - - return state.when( - data: (featureState) => featureState.when( - initial: () => const SizedBox(), - loading: () => const LoadingView(), - loaded: (item) => ItemDetailContent(item: item), - error: (exception) => ErrorView( - exception: exception, - onRetry: () => ref.invalidate( - itemDetailViewModelProvider(itemId), - ), - ), - ), - loading: () => const LoadingView(), - error: (error, stack) => ErrorView( - exception: AppException.unknown( - message: error.toString(), - stackTrace: stack, - ), - onRetry: () => ref.invalidate( - itemDetailViewModelProvider(itemId), - ), - ), - ); -} -``` - ---- - -## 8. Database Schema - -### Drift Tables - -```dart -// packages/integrations/database/lib/tables/collections_table.dart -@DataClassName('CollectionData') -class Collections extends Table { - TextColumn get id => text()(); - TextColumn get name => text().withLength(min: 1, max: 100)(); - TextColumn get type => text()(); // 'book', 'game', 'movie', 'custom' - TextColumn get description => text().nullable()(); - TextColumn get coverImagePath => text().nullable()(); - IntColumn get itemCount => integer().withDefault(const Constant(0))(); - DateTimeColumn get createdAt => dateTime()(); - DateTimeColumn get updatedAt => dateTime()(); - - @override - Set get primaryKey => {id}; -} - -// packages/integrations/database/lib/tables/items_table.dart -@DataClassName('ItemData') -class Items extends Table { - TextColumn get id => text()(); - TextColumn get collectionId => text().references(Collections, #id)(); - TextColumn get title => text().withLength(min: 1, max: 200)(); - TextColumn get barcode => text().nullable()(); - TextColumn get coverImageUrl => text().nullable()(); - TextColumn get coverImagePath => text().nullable()(); - TextColumn get description => text().nullable()(); - TextColumn get notes => text().nullable()(); - - // Collection-specific fields stored as JSON - TextColumn get metadata => text().nullable()(); // JSON string - - // Common fields - TextColumn get condition => text().nullable()(); // 'mint', 'good', 'fair', 'poor' - RealColumn get purchasePrice => real().nullable()(); - DateTimeColumn get purchaseDate => dateTime().nullable()(); - RealColumn get currentValue => real().nullable()(); - TextColumn get location => text().nullable()(); // shelf, box, etc. - BoolColumn get isFavorite => boolean().withDefault(const Constant(false))(); - IntColumn get quantity => integer().withDefault(const Constant(1))(); - - DateTimeColumn get createdAt => dateTime()(); - DateTimeColumn get updatedAt => dateTime()(); - - @override - Set get primaryKey => {id}; - - @override - List> get uniqueKeys => [ - {collectionId, barcode}, // Unique barcode per collection - ]; -} - -// packages/integrations/database/lib/tables/tags_table.dart -@DataClassName('TagData') -class Tags extends Table { - TextColumn get id => text()(); - TextColumn get name => text().withLength(min: 1, max: 50)(); - IntColumn get color => integer()(); // Color value - DateTimeColumn get createdAt => dateTime()(); - - @override - Set get primaryKey => {id}; -} - -// packages/integrations/database/lib/tables/item_tags_table.dart -@DataClassName('ItemTagData') -class ItemTags extends Table { - TextColumn get itemId => text().references(Items, #id, onDelete: KeyAction.cascade)(); - TextColumn get tagId => text().references(Tags, #id, onDelete: KeyAction.cascade)(); - - @override - Set get primaryKey => {itemId, tagId}; -} -``` - -### Database Class - -```dart -// packages/integrations/database/lib/database.dart -@DriftDatabase( - tables: [Collections, Items, Tags, ItemTags], - daos: [CollectionDao, ItemDao, TagDao], -) -class AppDatabase extends _$AppDatabase { - AppDatabase(QueryExecutor e) : super(e); - - @override - int get schemaVersion => 1; - - @override - MigrationStrategy get migration { - return MigrationStrategy( - onCreate: (Migrator m) async { - await m.createAll(); - }, - onUpgrade: (Migrator m, int from, int to) async { - // Handle future migrations - if (from < 2) { - // Example migration for v2 - // await m.addColumn(items, items.newColumn); - } - }, - beforeOpen: (details) async { - // Enable foreign keys - await customStatement('PRAGMA foreign_keys = ON'); - - if (details.wasCreated) { - // Seed initial data if needed - } - }, - ); - } -} - -// Provider -@riverpod -AppDatabase appDatabase(AppDatabaseRef ref) { - return AppDatabase( - NativeDatabase.createInBackground( - File(path.join( - // Get app documents directory - documentsPath, - 'collection_tracker.db', - )), - ), - ); -} -``` - -### DAOs (Data Access Objects) - -```dart -// packages/integrations/database/lib/daos/item_dao.dart -@DriftAccessor(tables: [Items, ItemTags, Tags]) -class ItemDao extends DatabaseAccessor with _$ItemDaoMixin { - ItemDao(AppDatabase db) : super(db); - - // Get all items in a collection with pagination - Future> getItemsPaginated({ - required String collectionId, - required int page, - required int pageSize, - String? searchQuery, - String? condition, - List? tagIds, - }) async { - final offset = (page - 1) * pageSize; - - // Build base query - var query = select(items) - ..where((tbl) => tbl.collectionId.equals(collectionId)); - - // Apply filters - if (searchQuery != null && searchQuery.isNotEmpty) { - query.where((tbl) => tbl.title.like('%$searchQuery%')); - } - - if (condition != null) { - query.where((tbl) => tbl.condition.equals(condition)); - } - - // Get total count - final totalCount = await query.get().then((rows) => rows.length); - - // Apply pagination - query - ..orderBy([(tbl) => OrderingTerm.desc(tbl.createdAt)]) - ..limit(pageSize, offset: offset); - - final itemsList = await query.get(); - - // Filter by tags if needed (requires join) - List filteredItems = itemsList; - if (tagIds != null && tagIds.isNotEmpty) { - filteredItems = []; - for (final item in itemsList) { - final itemTagsList = await (select(itemTags) - ..where((tbl) => tbl.itemId.equals(item.id))) - .get(); - - final hasAllTags = tagIds.every( - (tagId) => itemTagsList.any((it) => it.tagId == tagId), - ); - - if (hasAllTags) { - filteredItems.add(item); - } - } - } - - final pageInfo = PageInfo( - currentPage: page, - pageSize: pageSize, - totalItems: totalCount, - totalPages: (totalCount / pageSize).ceil(), - hasNextPage: offset + pageSize < totalCount, - hasPreviousPage: page > 1, - ); - - return PaginatedResponse( - items: filteredItems, - pageInfo: pageInfo, - ); - } - - // Watch items for real-time updates - Stream> watchItems(String collectionId) { - return (select(items) - ..where((tbl) => tbl.collectionId.equals(collectionId)) - ..orderBy([(tbl) => OrderingTerm.desc(tbl.createdAt)])) - .watch(); - } - - // Get single item - Future getById(String id) { - return (select(items)..where((tbl) => tbl.id.equals(id))) - .getSingleOrNull(); - } - - // Insert item - Future insertItem(ItemsCompanion item) { - return into(items).insert(item); - } - - // Update item - Future updateItem(ItemsCompanion item) { - return (update(items)..where((tbl) => tbl.id.equals(item.id.value))) - .write(item); - } - - // Delete item - Future deleteItem(String id) { - return (delete(items)..where((tbl) => tbl.id.equals(id))).go(); - } - - // Get item with tags - Future getItemWithTags(String id) async { - final item = await getById(id); - if (item == null) throw NotFoundException(message: 'Item not found'); - - final tagsList = await (select(itemTags) - ..where((tbl) => tbl.itemId.equals(id))) - .join([ - innerJoin(tags, tags.id.equalsExp(itemTags.tagId)), - ]) - .map((row) => row.readTable(tags)) - .get(); - - return ItemWithTags(item: item, tags: tagsList); - } -} - -// Helper class -class ItemWithTags { - final ItemData item; - final List tags; - - ItemWithTags({required this.item, required this.tags}); -} -``` - ---- - -## 9. API Integration - -### Metadata API Clients - -```dart -// packages/integrations/metadata_api/lib/clients/google_books_client.dart -class GoogleBooksClient { - static const _baseUrl = 'https://www.googleapis.com/books/v1'; - final Dio _dio; - - GoogleBooksClient({Dio? dio}) - : _dio = dio ?? Dio(BaseOptions(baseUrl: _baseUrl)); - - Future> searchBooks({ - required String query, - int page = 1, - int pageSize = 20, - }) async { - try { - final startIndex = (page - 1) * pageSize; - - final response = await _dio.get( - '/volumes', - queryParameters: { - 'q': query, - 'startIndex': startIndex, - 'maxResults': pageSize, - 'printType': 'books', - }, - ); - - final totalItems = response.data['totalItems'] as int? ?? 0; - final items = (response.data['items'] as List?) - ?.map((json) => BookMetadata.fromJson(json)) - .toList() ?? []; - - return PaginatedResponse( - items: items, - pageInfo: PageInfo( - currentPage: page, - pageSize: pageSize, - totalItems: totalItems, - totalPages: totalItems > 0 ? (totalItems / pageSize).ceil() : 0, - hasNextPage: startIndex + pageSize < totalItems, - hasPreviousPage: page > 1, - ), - ); - } on DioException catch (e) { - throw NetworkException( - message: 'Failed to search books', - details: e.message ?? '', - ); - } - } - - Future getBookByISBN(String isbn) async { - try { - final response = await _dio.get( - '/volumes', - queryParameters: { - 'q': 'isbn:$isbn', - }, - ); - - final items = response.data['items'] as List?; - if (items == null || items.isEmpty) return null; - - return BookMetadata.fromJson(items.first); - } on DioException catch (e) { - throw NetworkException( - message: 'Failed to fetch book by ISBN', - details: e.message ?? '', - ); - } - } -} - -// Metadata model -@freezed -class BookMetadata with _$BookMetadata { - const factory BookMetadata({ - required String id, - required String title, - List? authors, - String? publisher, - String? publishedDate, - String? description, - List? isbn, - int? pageCount, - List? categories, - String? thumbnailUrl, - String? language, - }) = _BookMetadata; - - factory BookMetadata.fromJson(Map json) { - final volumeInfo = json['volumeInfo'] as Map; - - return BookMetadata( - id: json['id'] as String, - title: volumeInfo['title'] as String, - authors: (volumeInfo['authors'] as List?)?.cast(), - publisher: volumeInfo['publisher'] as String?, - publishedDate: volumeInfo['publishedDate'] as String?, - description: volumeInfo['description'] as String?, - isbn: (volumeInfo['industryIdentifiers'] as List?) - ?.map((e) => e['identifier'] as String) - .toList(), - pageCount: volumeInfo['pageCount'] as int?, - categories: (volumeInfo['categories'] as List?)?.cast(), - thumbnailUrl: volumeInfo['imageLinks']?['thumbnail'] as String?, - language: volumeInfo['language'] as String?, - ); - } -} -``` - -### Provider Setup - -```dart -// packages/integrations/metadata_api/lib/providers.dart -@riverpod -Dio apiDio(ApiDioRef ref) { - final dio = Dio( - BaseOptions( - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 10), - ), - ); - - // Add interceptors - dio.interceptors.add(LogInterceptor( - requestBody: true, - responseBody: true, - )); - - return dio; -} - -@riverpod -GoogleBooksClient googleBooksClient(GoogleBooksClientRef ref) { - return GoogleBooksClient(dio: ref.watch(apiDioProvider)); -} - -@riverpod -IGDBClient igdbClient(IgdbClientRef ref) { - return IGDBClient(dio: ref.watch(apiDioProvider)); -} - -@riverpod -TMDBClient tmdbClient(TmdbClientRef ref) { - return TMDBClient(dio: ref.watch(apiDioProvider)); -} -``` - ---- - -## 10. Development Guidelines - -### Code Style - -1. **Follow Effective Dart guidelines** -2. **Use meaningful names**: `getUserCollections` not `get` -3. **Keep functions small**: Single responsibility -4. **Use const constructors** where possible -5. **Prefer composition over inheritance** -6. **Use extensions** for utility methods - -### Git Workflow - -```bash -# Branch naming -feature/collection-management -fix/barcode-scanner-crash -refactor/item-repository -docs/api-integration - -# Commit messages -feat: Add pagination to items list -fix: Resolve null check error in scanner -refactor: Extract metadata fetching logic -docs: Update README with setup instructions -``` - -### Testing Strategy - -```dart -// Unit tests for use cases -test('GetItemsUseCase should return items on success', () async { - // Arrange - final mockRepo = MockItemRepository(); - final useCase = GetItemsUseCase(repository: mockRepo); - final expectedItems = [/* test data */]; - - when(() => mockRepo.getItems(any())) - .thenAnswer((_) async => Right(expectedItems)); - - // Act - final result = await useCase.execute('collection-id'); - - // Assert - expect(result, equals(expectedItems)); -}); - -// Widget tests -testWidgets('ItemCard shows item title', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: ItemCard( - item: Item(id: '1', title: 'Test Book'), - ), - ), - ); - - expect(find.text('Test Book'), findsOneWidget); -}); - -// Integration tests for repository -test('ItemRepository should save and retrieve item', () async { - final database = /* test database */; - final repository = ItemRepositoryImpl(/* dependencies */); - - final item = Item(/* test data */); - await repository.createItem(item); - - final result = await repository.getItemById(item.id); - - expect(result.isRight(), true); - result.fold( - (l) => fail('Should not fail'), - (r) => expect(r, equals(item)), - ); -}); -``` - -### Performance Checklist - -- [ ] Use `const` constructors -- [ ] Implement `ListView.builder` for lists -- [ ] Cache network images -- [ ] Optimize database queries (indexes) -- [ ] Use code splitting for large apps -- [ ] Profile with DevTools -- [ ] Implement pagination -- [ ] Use isolates for heavy computations -- [ ] Minimize rebuilds with Riverpod selectors - -### Security Best Practices - -1. **Never store API keys in code**: Use environment variables -2. **Validate all user inputs**: Use validators -3. **Sanitize data before database insertion** -4. **Use HTTPS for all network requests** -5. **Implement proper permission handling** -6. **Encrypt sensitive local data** if needed - ---- - -## Appendix - -### Workspace Configuration - -#### Root pubspec.yaml - -```yaml -name: collection_tracker_workspace -description: A workspace for the Collection Tracker app -version: 1.0.0 -publish_to: 'none' - -environment: - sdk: '>=3.0.0 <4.0.0' - -# Workspace configuration -workspace: - - apps/mobile - - packages/core/domain - - packages/core/data - - packages/common/ui - - packages/common/utils - - packages/integrations/database - - packages/integrations/barcode_scanner - - packages/integrations/metadata_api - - packages/integrations/analytics - - packages/integrations/logging - - packages/integrations/storage - - packages/integrations/payment -``` - -#### Package pubspec.yaml Example - -```yaml -# packages/core/domain/pubspec.yaml -name: core_domain -description: Domain layer - entities, repositories, use cases -version: 1.0.0 -publish_to: 'none' - -environment: - sdk: '>=3.0.0 <4.0.0' - -dependencies: - freezed_annotation: ^2.4.1 - fpdart: ^1.1.0 - -dev_dependencies: - build_runner: ^2.4.0 - freezed: ^2.4.6 - test: ^1.24.0 - -# packages/core/data/pubspec.yaml -name: core_data -description: Data layer - models, repositories, data sources -version: 1.0.0 -publish_to: 'none' - -environment: - sdk: '>=3.0.0 <4.0.0' - -dependencies: - # Local packages - core_domain: - path: ../domain - - # External packages - freezed_annotation: ^2.4.1 - json_annotation: ^4.8.1 - fpdart: ^1.1.0 - dio: ^5.4.0 - -dev_dependencies: - build_runner: ^2.4.0 - freezed: ^2.4.6 - json_serializable: ^6.7.1 - test: ^1.24.0 - mockito: ^5.4.0 - -# apps/mobile/pubspec.yaml -name: collection_tracker -description: Collection tracking mobile app -version: 1.0.0+1 - -environment: - sdk: '>=3.0.0 <4.0.0' - -dependencies: - flutter: - sdk: flutter - - # State Management - flutter_riverpod: ^2.4.0 - riverpod_annotation: ^2.3.0 - - # Routing - go_router: ^13.0.0 - - # UI - flutter_animate: ^4.5.0 - shimmer: ^3.0.0 - cached_network_image: ^3.3.0 - - # Utilities - freezed_annotation: ^2.4.1 - json_annotation: ^4.8.1 - fpdart: ^1.1.0 - - # Local workspace packages - core_domain: - path: ../../packages/core/domain - core_data: - path: ../../packages/core/data - common_ui: - path: ../../packages/common/ui - common_utils: - path: ../../packages/common/utils - integration_database: - path: ../../packages/integrations/database - integration_barcode_scanner: - path: ../../packages/integrations/barcode_scanner - integration_metadata_api: - path: ../../packages/integrations/metadata_api - integration_analytics: - path: ../../packages/integrations/analytics - integration_logging: - path: ../../packages/integrations/logging - integration_storage: - path: ../../packages/integrations/storage - -dev_dependencies: - flutter_test: - sdk: flutter - build_runner: ^2.4.0 - riverpod_generator: ^2.3.0 - riverpod_lint: ^2.3.0 - freezed: ^2.4.6 - json_serializable: ^6.7.1 - flutter_lints: ^3.0.0 -``` - -### Dart Pub Workspace Commands - -```bash -# Get dependencies for all packages in workspace -dart pub get - -# Run from root - automatically resolves workspace dependencies -cd collection_tracker -dart pub get - -# Run tests for specific package -cd packages/core/domain -dart pub run test - -# Run tests for all packages (you need to script this) -# Create a script: scripts/test_all.sh -#!/bin/bash -for dir in packages/*/; do - echo "Testing $dir" - cd "$dir" - dart pub run test - cd ../.. -done - -# Run build_runner for specific package -cd apps/mobile -dart run build_runner build --delete-conflicting-outputs - -# Watch mode -dart run build_runner watch --delete-conflicting-outputs - -# Run for all packages that need code generation -# Create script: scripts/build_all.sh -#!/bin/bash -echo "Building core/domain..." -cd packages/core/domain -dart run build_runner build --delete-conflicting-outputs - -echo "Building core/data..." -cd ../data -dart run build_runner build --delete-conflicting-outputs - -echo "Building integrations/metadata_api..." -cd ../../integrations/metadata_api -dart run build_runner build --delete-conflicting-outputs - -echo "Building apps/mobile..." -cd ../../../apps/mobile -flutter pub run build_runner build --delete-conflicting-outputs - -echo "Build completed!" - -# Analyze code -cd apps/mobile -flutter analyze - -# Format code -dart format . - -# Run app -cd apps/mobile -flutter run -``` - -### Helper Scripts - -Create a `scripts/` directory in your workspace root: - -```bash -collection_tracker/ -├── scripts/ -│ ├── setup.sh -│ ├── build_all.sh -│ ├── test_all.sh -│ ├── analyze_all.sh -│ └── clean_all.sh -``` - -**scripts/setup.sh** -```bash -#!/bin/bash -echo "Setting up Collection Tracker workspace..." - -# Get dependencies for all packages -echo "Getting dependencies..." -dart pub get - -# Run build_runner for packages that need it -echo "Running code generation..." -./scripts/build_all.sh - -echo "Setup complete!" -``` - -**scripts/clean_all.sh** -```bash -#!/bin/bash -echo "Cleaning all packages..." - -# Clean Flutter app -cd apps/mobile -flutter clean -cd ../.. - -# Clean all packages -for dir in packages/core/*/; do - echo "Cleaning $dir" - cd "$dir" - dart pub get --offline - rm -rf .dart_tool - cd ../../.. -done - -for dir in packages/common/*/; do - echo "Cleaning $dir" - cd "$dir" - rm -rf .dart_tool - cd ../../.. -done - -for dir in packages/integrations/*/; do - echo "Cleaning $dir" - cd "$dir" - rm -rf .dart_tool - cd ../../.. -done - -echo "Clean complete! Run 'dart pub get' to restore." -``` - -**scripts/analyze_all.sh** -```bash -#!/bin/bash -echo "Analyzing all packages..." - -errors=0 - -# Analyze Flutter app -echo "Analyzing apps/mobile..." -cd apps/mobile -flutter analyze -if [ $? -ne 0 ]; then - errors=$((errors+1)) -fi -cd ../.. - -# Analyze all packages -for dir in packages/core/*/; do - echo "Analyzing $dir" - cd "$dir" - dart analyze - if [ $? -ne 0 ]; then - errors=$((errors+1)) - fi - cd ../../.. -done - -for dir in packages/common/*/; do - echo "Analyzing $dir" - cd "$dir" - dart analyze - if [ $? -ne 0 ]; then - errors=$((errors+1)) - fi - cd ../../.. -done - -for dir in packages/integrations/*/; do - echo "Analyzing $dir" - cd "$dir" - dart analyze - if [ $? -ne 0 ]; then - errors=$((errors+1)) - fi - cd ../../.. -done - -if [ $errors -eq 0 ]; then - echo "✓ All packages analyzed successfully!" -else - echo "✗ $errors package(s) have analysis errors" - exit 1 -fi -``` - -### VS Code Workspace Configuration - -Create `.vscode/collection_tracker.code-workspace`: - -```json -{ - "folders": [ - { - "name": "Root", - "path": "." - }, - { - "name": "Mobile App", - "path": "apps/mobile" - }, - { - "name": "Core Domain", - "path": "packages/core/domain" - }, - { - "name": "Core Data", - "path": "packages/core/data" - }, - { - "name": "Common UI", - "path": "packages/common/ui" - }, - { - "name": "Common Utils", - "path": "packages/common/utils" - }, - { - "name": "Database Integration", - "path": "packages/integrations/database" - }, - { - "name": "Metadata API", - "path": "packages/integrations/metadata_api" - } - ], - "settings": { - "dart.lineLength": 80, - "editor.formatOnSave": true, - "editor.codeActionsOnSave": { - "source.fixAll": true - }, - "dart.analysisExcludedFolders": [ - "**/build/**", - "**/.dart_tool/**" - ] - }, - "extensions": { - "recommendations": [ - "dart-code.dart-code", - "dart-code.flutter", - "usernamehw.errorlens" - ] - } -} -``` - -### Makefile (Alternative to shell scripts) - -Create a `Makefile` in the workspace root: - -```makefile -.PHONY: setup clean build test analyze run help - -help: - @echo "Collection Tracker - Available commands:" - @echo " make setup - Setup workspace and get dependencies" - @echo " make clean - Clean all packages" - @echo " make build - Run code generation for all packages" - @echo " make test - Run tests for all packages" - @echo " make analyze - Analyze all packages" - @echo " make run - Run the mobile app" - -setup: - @echo "Setting up workspace..." - dart pub get - @$(MAKE) build - -clean: - @echo "Cleaning workspace..." - cd apps/mobile && flutter clean - find packages -name ".dart_tool" -type d -exec rm -rf {} + - @echo "Clean complete!" - -build: - @echo "Running code generation..." - cd packages/core/domain && dart run build_runner build --delete-conflicting-outputs - cd packages/core/data && dart run build_runner build --delete-conflicting-outputs - cd packages/integrations/metadata_api && dart run build_runner build --delete-conflicting-outputs - cd apps/mobile && flutter pub run build_runner build --delete-conflicting-outputs - @echo "Build complete!" - -test: - @echo "Running tests..." - cd packages/core/domain && dart test - cd packages/core/data && dart test - cd apps/mobile && flutter test - @echo "Tests complete!" - -analyze: - @echo "Analyzing code..." - cd apps/mobile && flutter analyze - cd packages/core/domain && dart analyze - cd packages/core/data && dart analyze - @echo "Analysis complete!" - -run: - cd apps/mobile && flutter run - -watch: - cd apps/mobile && flutter pub run build_runner watch --delete-conflicting-outputs -``` - -Usage: -```bash -make setup # Initial setup -make build # Generate code -make test # Run all tests -make analyze # Analyze all code -make run # Run the app -make clean # Clean everything -``` - -### Git Ignore - -Create `.gitignore` in workspace root: - -```gitignore -# Dart & Flutter -.dart_tool/ -.packages -.pub-cache/ -.pub/ -build/ -*.g.dart -*.freezed.dart - -# IDE -.idea/ -.vscode/ -*.iml -*.ipr -*.iws - -# OS -.DS_Store -Thumbs.db - -# Test coverage -coverage/ - -# Generated files -*.lock -pubspec.lock - -# Logs -*.log -``` - -### Build Runner Commands - -```bash -# Generate code once -flutter pub run build_runner build --delete-conflicting-outputs - -# Watch for changes -flutter pub run build_runner watch --delete-conflicting-outputs - -# Clean generated files -flutter pub run build_runner clean -``` - -### Useful Extensions - -```dart -// packages/common/utils/lib/extensions/context_extensions.dart -extension BuildContextX on BuildContext { - ThemeData get theme => Theme.of(this); - TextTheme get textTheme => theme.textTheme; - ColorScheme get colors => theme.colorScheme; - - void showSnackBar(String message) { - ScaffoldMessenger.of(this).showSnackBar( - SnackBar(content: Text(message)), - ); - } - - void showErrorSnackBar(String message) { - ScaffoldMessenger.of(this).showSnackBar( - SnackBar( - content: Text(message), - backgroundColor: colors.error, - ), - ); - } -} - -// packages/common/utils/lib/extensions/string_extensions.dart -extension StringX on String { - bool get isValidISBN { - final isbn = replaceAll(RegExp(r'[^0-9X]'), ''); - return isbn.length == 10 || isbn.length == 13; - } - - String get capitalizeFirst { - if (isEmpty) return this; - return '${this[0].toUpperCase()}${substring(1)}'; - } -} -``` - ---- - -## Next Steps - -1. **Setup project structure** with Melos workspace -2. **Configure dependencies** in all packages -3. **Implement database schema** with Drift -4. **Create domain entities and repositories** -5. **Implement first feature** (Collections management) -6. **Add barcode scanner integration** -7. **Integrate metadata APIs** -8. **Build UI with animations** -9. **Add pagination** for items list -10. **Implement settings and preferences** -11. **Add cloud sync** (future feature) -12. **Testing and optimization** -13. **Release MVP** - ---- - -**Document Version:** 1.0 -**Last Updated:** January 2026 -**Author:** Project Team +CI workflows follow the same pattern and materialize Firebase files from secrets before build/analyze/test. diff --git a/documentation/FIREBASE_AND_FLAGS.md b/documentation/FIREBASE_AND_FLAGS.md new file mode 100644 index 0000000..1f29ab9 --- /dev/null +++ b/documentation/FIREBASE_AND_FLAGS.md @@ -0,0 +1,89 @@ +# Firebase and Runtime Flags + +This app uses Firebase for analytics, crash reporting, performance traces, and runtime feature flags. + +## 1. Required Firebase Files + +These files are expected at runtime and are gitignored in `apps/mobile/.gitignore`: + +- `apps/mobile/lib/firebase_options.dart` +- `apps/mobile/android/app/google-services.json` +- `apps/mobile/ios/Runner/GoogleService-Info.plist` +- `apps/mobile/macos/Runner/GoogleService-Info.plist` + +Generate/materialize via: + +```bash +./scripts/setup_firebase.sh --require dart +``` + +## 2. Remote Config Keys + +Runtime flags are read in `FirebaseServicesBootstrap`. + +| Key | Default | Effect | +| --- | --- | --- | +| `app_analytics_collection_enabled` | `true` | Enables/disables analytics collection runtime behavior | +| `app_crashlytics_collection_enabled` | `true` | Enables/disables Crashlytics collection | +| `app_performance_collection_enabled` | `true` | Enables/disables Firebase Performance collection | +| `app_backend_integration_enabled` | `false` | Gates backend-auth integration paths | +| `app_sync_feature_enabled` | `false` | Gates sync transport and sync UI readiness | + +## 3. Why Cloud Sync Can Still Look Disabled + +Cloud Sync readiness requires all of the following: + +1. `app_backend_integration_enabled = true` +2. `app_sync_feature_enabled = true` +3. API base URL available (`BACKEND_API_BASE_URL` or settings override) +4. Signed-in auth session (sync is optional, but auth is required for sync) + +If any condition fails, the settings sync tile shows non-ready state and relevant CTA. + +## 4. Fetch/Refresh Behavior + +### Remote Config fetch intervals + +- Debug builds: minimum fetch interval = 5 minutes +- Release builds: minimum fetch interval = 12 hours + +### Auto refresh on app resume + +- Runtime config auto-refresh is throttled: + - Debug: at most every 1 minute + - Release: at most every 15 minutes + +### Manual refresh + +In debug settings, use Firebase Runtime Config sheet and tap refresh: + +- Refresh uses forced fetch (`forceFetch: true`) +- App re-applies analytics preference state after refresh + +## 5. Debug Overrides for Local Development + +There are optional debug-only `--dart-define` overrides: + +- `BACKEND_USE_ENV_FLAG_OVERRIDES=true` +- `BACKEND_INTEGRATION_ENABLED=true|false` +- `BACKEND_SYNC_ENABLED=true|false` + +These overrides are ignored unless `BACKEND_USE_ENV_FLAG_OVERRIDES=true` and build is debug. + +## 6. Crashlytics Notes + +Crashlytics collection is also constrained by debug behavior: + +- Disabled by default in debug unless `ENABLE_CRASHLYTICS_IN_DEBUG=true` +- Always controlled by runtime flag + build mode logic + +## 7. Troubleshooting Checklist + +If sync tile does not enable after toggling Remote Config keys: + +1. Confirm both backend and sync keys are `true`. +2. Trigger manual refresh from debug settings. +3. Verify fetch status and last fetch time in runtime config sheet. +4. Check API base URL configuration. +5. Ensure account is signed in. +6. If using debug defines, ensure override gate flag is enabled. diff --git a/documentation/LOCALIZATION.md b/documentation/LOCALIZATION.md new file mode 100644 index 0000000..cbc8475 --- /dev/null +++ b/documentation/LOCALIZATION.md @@ -0,0 +1,82 @@ +# Localization + +This app uses Flutter `gen_l10n` with ARB files and runtime language selection. + +## 1. Current Supported Locales + +| Language | Code | ARB file | +| --- | --- | --- | +| English | `en` | `apps/mobile/lib/l10n/arb/app_en.arb` | +| Spanish | `es` | `apps/mobile/lib/l10n/arb/app_es.arb` | +| Indonesian | `id` | `apps/mobile/lib/l10n/arb/app_id.arb` | +| Japanese | `ja` | `apps/mobile/lib/l10n/arb/app_ja.arb` | +| Korean | `ko` | `apps/mobile/lib/l10n/arb/app_ko.arb` | +| Burmese | `my` | `apps/mobile/lib/l10n/arb/app_my.arb` | +| Chinese (Simplified) | `zh` | `apps/mobile/lib/l10n/arb/app_zh.arb` | + +## 2. Generation Config + +`apps/mobile/l10n.yaml`: + +- `arb-dir: lib/l10n/arb` +- `template-arb-file: app_en.arb` +- output: `lib/l10n/gen/app_localizations.dart` + +Generate localizations: + +```bash +cd apps/mobile +flutter gen-l10n +``` + +## 3. Runtime Language Selection + +- Language enum/provider: `apps/mobile/lib/core/providers/locale_provider.dart` +- Preference key: `app_language` +- Settings screen allows switching at runtime + +## 4. Metadata Requirement (Important) + +To avoid ARB warnings such as: + +- `The message with key "..." does not have metadata defined.` + +Add corresponding metadata entries for each message key: + +- `"myKey": "..."` +- `"@myKey": { "description": "..." }` + +Using empty metadata objects is technically possible, but descriptions are recommended for maintainability. + +## 5. Current Coverage Notes + +Localized coverage is broad across main flows (collections/items/statistics/settings), but some screens still contain hardcoded English strings and should be migrated to l10n keys. + +Primary areas still needing cleanup: + +- Onboarding copy +- Scanner copy +- Some auth and search screen strings +- Some developer/debug-only text + +## 6. UI Overflow Guidance for Long Translations + +Compact devices and long strings can cause overflow. Current mitigation patterns used in app: + +- `maxLines: 1` + `TextOverflow.ellipsis` on navigation labels and chips +- responsive `crossAxisCount` and tile heights in grids +- conservative text scaling logic in custom navigation components + +When adding new localized strings, verify layouts on: + +- Android physical compact phones +- iOS simulator with larger text scale + +## 7. Adding a New Language + +1. Add `app_.arb` under `apps/mobile/lib/l10n/arb/`. +2. Translate all required keys and keep `@metadata` entries. +3. Run `flutter gen-l10n`. +4. Add language option to `AppLanguage` in `locale_provider.dart`. +5. Add display label in settings language selector. +6. Verify navigation labels, bottom sheets, and grids for overflow. diff --git a/documentation/README.md b/documentation/README.md new file mode 100644 index 0000000..c9dc4b6 --- /dev/null +++ b/documentation/README.md @@ -0,0 +1,23 @@ +# Documentation Index + +This folder contains up-to-date technical documentation for the current state of the app. + +## Core Docs + +- [documentation/APP_PROGRESS.md](APP_PROGRESS.md) + Current feature matrix, what's complete, what's in progress, and known gaps. + +- [documentation/ARCHITECTURE.md](ARCHITECTURE.md) + Package boundaries, app bootstrap flow, navigation model, data flow, and runtime systems. + +- [documentation/SETUP_AND_RUN.md](SETUP_AND_RUN.md) + Local setup, Firebase materialization, codegen, and run commands. + +- [documentation/FIREBASE_AND_FLAGS.md](FIREBASE_AND_FLAGS.md) + Firebase integration details, Remote Config keys, and feature-flag behavior. + +- [documentation/SYNC_AND_AUTH.md](SYNC_AND_AUTH.md) + Backend auth/session flow and sync v1 architecture currently implemented. + +- [documentation/LOCALIZATION.md](LOCALIZATION.md) + Supported languages, localization workflow, and remaining gaps. diff --git a/documentation/SETUP_AND_RUN.md b/documentation/SETUP_AND_RUN.md new file mode 100644 index 0000000..6171206 --- /dev/null +++ b/documentation/SETUP_AND_RUN.md @@ -0,0 +1,116 @@ +# Setup and Run + +This guide reflects the current workspace setup and runtime requirements. + +## 1. Prerequisites + +- Flutter stable (CI uses `3.41.x`) +- Dart SDK compatible with workspace (`^3.11.0`) +- Xcode (iOS/macOS) and/or Android SDK + +Check environment: + +```bash +flutter doctor -v +dart --version +``` + +## 2. Install Dependencies + +From workspace root: + +```bash +dart pub get +``` + +## 3. Configure Metadata API Keys + +Create `packages/common/env/.env`: + +```env +GOOGLE_BOOKS_API_KEY=... +TMDB_API_KEY=... +TMDB_READ_ACCESS_TOKEN=... +IGDB_CLIENT_ID=... +IGDB_CLIENT_SECRET=... +``` + +The app reads these through `packages/common/env/lib/src/app_env.dart`. + +## 4. Configure Firebase Files + +`apps/mobile/lib/firebase_options.dart` and platform service files are expected to exist (they are gitignored). + +Recommended approach: + +```bash +./scripts/setup_firebase.sh --require dart +``` + +For Android builds: + +```bash +./scripts/setup_firebase.sh --require dart --require android +``` + +Supported env vars (raw or base64): + +- `FIREBASE_OPTIONS_DART` or `FIREBASE_OPTIONS_DART_BASE64` +- `FIREBASE_ANDROID_GOOGLE_SERVICES_JSON` or `FIREBASE_ANDROID_GOOGLE_SERVICES_JSON_BASE64` +- `FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST` or `FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64` +- `FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST` or `FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST_BASE64` + +## 5. Generate Code + +```bash +./scripts/build_all.sh +``` + +## 6. Run the App + +```bash +cd apps/mobile +flutter run +``` + +## 7. Optional: Local Backend + Sync Integration + +By default, backend/sync features are runtime-flag disabled. + +### Option A: Use Firebase Remote Config only (recommended) + +Set both keys to `true` in Remote Config: + +- `app_backend_integration_enabled` +- `app_sync_feature_enabled` + +### Option B: Debug-only `--dart-define` overrides + +Use only when explicitly needed for local testing: + +```bash +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 +``` + +Android emulator usually needs `http://10.0.2.2:4000` instead of `localhost`. + +## 8. Quality Commands + +```bash +./scripts/analyze_all.sh +./scripts/test_all.sh +dart format --set-exit-if-changed . +``` + +Or via Makefile: + +```bash +make build +make analyze +make test +make run +``` diff --git a/documentation/SYNC_AND_AUTH.md b/documentation/SYNC_AND_AUTH.md new file mode 100644 index 0000000..e04c811 --- /dev/null +++ b/documentation/SYNC_AND_AUTH.md @@ -0,0 +1,138 @@ +# Sync and Auth Integration + +This document covers the currently implemented optional backend auth and sync architecture. + +## 1. High-Level Behavior + +- Users can use the app fully offline without signing in. +- Auth is required only for backend-connected features (primarily cloud sync). +- Sync is feature-flag gated and can be disabled at runtime. + +## 2. Auth Flow (Optional) + +### Components + +- `BackendAuthClient` (`packages/integrations/backend_api`) +- `BackendAuthService` (`apps/mobile/lib/core/auth/backend_auth_service.dart`) +- `AuthSessionStore` / `SecureStorageAuthSessionStore` (`packages/integrations/auth_session`) +- `AuthScreen` (`apps/mobile/lib/features/auth/presentation/views/auth_screen.dart`) + +### Supported auth operations + +- Register +- Login +- Refresh token +- Logout +- Fetch current profile (`/auth/me`) + +Session is persisted in secure storage and exposed as a Riverpod stream provider. + +## 3. Sync Feature Gating and Readiness + +Sync readiness provider checks: + +1. Backend integration flag enabled +2. Sync feature flag enabled +3. API base URL configured +4. Auth session available and authenticated + +Readiness statuses used by UI: + +- `ready` +- `disabledByFeatureFlag` +- `missingApiConfiguration` +- `checkingAuthentication` +- `authenticationRequired` + +## 4. Sync v1 Data Model + +### Local sync tables + +- `sync_outbox` + - operation id, entity type/id, operation type, JSON payload, attempts, errors, timestamps +- `sync_state` + - last success/attempt, next retry time, cursor, consecutive failures + +### Synced entity types + +- `collection` +- `item` +- `tag` + +## 5. Outbox Write Strategy + +When local mutation occurs in repositories: + +- mutation persists locally first +- corresponding sync outbox operation is enqueued + +Operation IDs are deterministic: + +```text +:: +``` + +This naturally coalesces duplicate same-entity operations. + +## 6. Initial Outbox Seeding + +`SyncOutboxBootstrapper` can seed outbox from existing local data: + +- used before first sync attempt if queue is empty +- guarded by prefs keys to avoid repeated heavy seeding +- can be manually rebuilt from diagnostics + +## 7. Sync Request/Response Contract + +Sync transport package models currently support: + +### Request + +- `schemaVersion` +- `deviceId` +- `clientRequestId` +- `lastSyncAt` +- `changes` (`collections`, `items`, `tags`) + +### Response + +- `lastSyncAt` +- `serverChanges` (`collections`, `items`, `tags`) +- `conflicts` +- counters for synced entities and resolved conflicts + +## 8. Retry and Failure Handling + +Sync orchestrator behavior: + +- immediate retry for transient network errors (limited attempts) +- exponential/jittered scheduled retry window via `sync_state.nextRetryAt` +- auth-required responses stop execution and ask for sign-in + +App lifecycle integration: + +- on app start/resume, pending sync may auto-retry if readiness is satisfied and retry window has elapsed + +## 9. Server Change Apply Rules + +Server changes are applied transactionally with safeguards: + +- skip applying server entity if pending local outbox op exists for that entity +- skip outdated server updates if local `updatedAt` is newer (with small skew tolerance) +- apply tags before items so relations can be re-linked immediately +- recalculate collection item counts after relevant mutations + +## 10. Access Token Refresh During Sync + +`DioSyncBackendClient` uses auth token provider that can: + +- read current access token +- refresh token using `/auth/refresh` and `deviceId` +- retry unauthorized sync requests once after refresh +- clear invalid sessions on repeated unauthorized responses + +## 11. Current Scope and Limitations + +- Sync coverage is currently limited to collections, items, tags. +- Runtime rollout remains feature-flag gated by default. +- Additional production hardening (conflict policy expansion, broader test matrix, rollout controls) is still in progress. From 5bba2ee2bb02e7c9edb2fe6698992684123f937b Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Mon, 23 Feb 2026 00:03:13 +0630 Subject: [PATCH 23/31] build: Add new integration packages and update package order in all build, test, analysis, and dependency scripts. --- scripts/analyze_all.sh | 10 +++++++--- scripts/build_all.sh | 10 +++++++--- scripts/check_dependencies.sh | 14 ++++++++++++++ scripts/clean_all.sh | 10 +++++++--- scripts/coverage.sh | 10 +++++++--- scripts/test_all.sh | 10 +++++++--- 6 files changed, 49 insertions(+), 15 deletions(-) diff --git a/scripts/analyze_all.sh b/scripts/analyze_all.sh index 1574fab..427f340 100755 --- a/scripts/analyze_all.sh +++ b/scripts/analyze_all.sh @@ -46,11 +46,15 @@ analyze_package "packages/core/data" "core_data" analyze_package "packages/common/env" "common_env" analyze_package "packages/common/ui" "common_ui" analyze_package "packages/common/utils" "common_utils" -analyze_package "packages/integrations/database" "integration_database" -analyze_package "packages/integrations/barcode_scanner" "integration_barcode_scanner" -analyze_package "packages/integrations/metadata_api" "integration_metadata_api" analyze_package "packages/integrations/analytics" "integration_analytics" +analyze_package "packages/integrations/auth_session" "integration_auth_session" +analyze_package "packages/integrations/backend_api" "integration_backend_api" +analyze_package "packages/integrations/barcode_scanner" "integration_barcode_scanner" +analyze_package "packages/integrations/database" "integration_database" +analyze_package "packages/integrations/firebase_services" "integration_firebase_services" analyze_package "packages/integrations/logger" "integration_logging" +analyze_package "packages/integrations/metadata_api" "integration_metadata_api" +analyze_package "packages/integrations/sync_api" "integration_sync_api" analyze_package "packages/integrations/storage" "integration_storage" analyze_package "packages/integrations/payment" "integration_payment" analyze_package "apps/mobile" "mobile_app" diff --git a/scripts/build_all.sh b/scripts/build_all.sh index 24e55ad..c892387 100755 --- a/scripts/build_all.sh +++ b/scripts/build_all.sh @@ -47,11 +47,15 @@ run_build "packages/core/data" "core_data" run_build "packages/common/env" "common_env" run_build "packages/common/ui" "common_ui" run_build "packages/common/utils" "common_utils" -run_build "packages/integrations/database" "integration_database" -run_build "packages/integrations/barcode_scanner" "integration_barcode_scanner" -run_build "packages/integrations/metadata_api" "integration_metadata_api" run_build "packages/integrations/analytics" "integration_analytics" +run_build "packages/integrations/auth_session" "integration_auth_session" +run_build "packages/integrations/backend_api" "integration_backend_api" +run_build "packages/integrations/barcode_scanner" "integration_barcode_scanner" +run_build "packages/integrations/database" "integration_database" +run_build "packages/integrations/firebase_services" "integration_firebase_services" run_build "packages/integrations/logger" "integration_logging" +run_build "packages/integrations/metadata_api" "integration_metadata_api" +run_build "packages/integrations/sync_api" "integration_sync_api" run_build "packages/integrations/storage" "integration_storage" run_build "packages/integrations/payment" "integration_payment" run_build "apps/mobile" "mobile_app" diff --git a/scripts/check_dependencies.sh b/scripts/check_dependencies.sh index bbd4816..950c1ac 100755 --- a/scripts/check_dependencies.sh +++ b/scripts/check_dependencies.sh @@ -30,5 +30,19 @@ cd "$WORKSPACE_ROOT" check_deps "apps/mobile" "mobile_app" check_deps "packages/core/domain" "core_domain" check_deps "packages/core/data" "core_data" +check_deps "packages/common/env" "common_env" +check_deps "packages/common/ui" "common_ui" +check_deps "packages/common/utils" "common_utils" +check_deps "packages/integrations/analytics" "integration_analytics" +check_deps "packages/integrations/auth_session" "integration_auth_session" +check_deps "packages/integrations/backend_api" "integration_backend_api" +check_deps "packages/integrations/barcode_scanner" "integration_barcode_scanner" +check_deps "packages/integrations/database" "integration_database" +check_deps "packages/integrations/firebase_services" "integration_firebase_services" +check_deps "packages/integrations/logger" "integration_logging" +check_deps "packages/integrations/metadata_api" "integration_metadata_api" +check_deps "packages/integrations/payment" "integration_payment" +check_deps "packages/integrations/storage" "integration_storage" +check_deps "packages/integrations/sync_api" "integration_sync_api" echo "✓ Dependency check complete" diff --git a/scripts/clean_all.sh b/scripts/clean_all.sh index c6b04af..0d9ef82 100755 --- a/scripts/clean_all.sh +++ b/scripts/clean_all.sh @@ -41,11 +41,15 @@ clean_package "packages/core/data" "core_data" clean_package "packages/common/env" "common_env" clean_package "packages/common/ui" "common_ui" clean_package "packages/common/utils" "common_utils" -clean_package "packages/integrations/database" "integration_database" -clean_package "packages/integrations/barcode_scanner" "integration_barcode_scanner" -clean_package "packages/integrations/metadata_api" "integration_metadata_api" clean_package "packages/integrations/analytics" "integration_analytics" +clean_package "packages/integrations/auth_session" "integration_auth_session" +clean_package "packages/integrations/backend_api" "integration_backend_api" +clean_package "packages/integrations/barcode_scanner" "integration_barcode_scanner" +clean_package "packages/integrations/database" "integration_database" +clean_package "packages/integrations/firebase_services" "integration_firebase_services" clean_package "packages/integrations/logger" "integration_logging" +clean_package "packages/integrations/metadata_api" "integration_metadata_api" +clean_package "packages/integrations/sync_api" "integration_sync_api" clean_package "packages/integrations/storage" "integration_storage" clean_package "packages/integrations/payment" "integration_payment" clean_package "apps/mobile" "mobile_app" diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 63344cb..9aeae24 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -66,11 +66,15 @@ run_coverage "packages/core/data" "core_data" run_coverage "packages/common/env" "common_env" run_coverage "packages/common/ui" "common_ui" run_coverage "packages/common/utils" "common_utils" -run_coverage "packages/integrations/database" "integration_database" -run_coverage "packages/integrations/barcode_scanner" "integration_barcode_scanner" -run_coverage "packages/integrations/metadata_api" "integration_metadata_api" run_coverage "packages/integrations/analytics" "integration_analytics" +run_coverage "packages/integrations/auth_session" "integration_auth_session" +run_coverage "packages/integrations/backend_api" "integration_backend_api" +run_coverage "packages/integrations/barcode_scanner" "integration_barcode_scanner" +run_coverage "packages/integrations/database" "integration_database" +run_coverage "packages/integrations/firebase_services" "integration_firebase_services" run_coverage "packages/integrations/logger" "integration_logging" +run_coverage "packages/integrations/metadata_api" "integration_metadata_api" +run_coverage "packages/integrations/sync_api" "integration_sync_api" run_coverage "packages/integrations/storage" "integration_storage" run_coverage "packages/integrations/payment" "integration_payment" run_coverage "apps/mobile" "mobile_app" diff --git a/scripts/test_all.sh b/scripts/test_all.sh index 699885f..c8e4645 100755 --- a/scripts/test_all.sh +++ b/scripts/test_all.sh @@ -58,11 +58,15 @@ run_tests "packages/core/data" "core_data" run_tests "packages/common/env" "common_env" run_tests "packages/common/ui" "common_ui" run_tests "packages/common/utils" "common_utils" -run_tests "packages/integrations/database" "integration_database" -run_tests "packages/integrations/barcode_scanner" "integration_barcode_scanner" -run_tests "packages/integrations/metadata_api" "integration_metadata_api" run_tests "packages/integrations/analytics" "integration_analytics" +run_tests "packages/integrations/auth_session" "integration_auth_session" +run_tests "packages/integrations/backend_api" "integration_backend_api" +run_tests "packages/integrations/barcode_scanner" "integration_barcode_scanner" +run_tests "packages/integrations/database" "integration_database" +run_tests "packages/integrations/firebase_services" "integration_firebase_services" run_tests "packages/integrations/logger" "integration_logging" +run_tests "packages/integrations/metadata_api" "integration_metadata_api" +run_tests "packages/integrations/sync_api" "integration_sync_api" run_tests "packages/integrations/storage" "integration_storage" run_tests "packages/integrations/payment" "integration_payment" run_tests "apps/mobile" "mobile_app" From d9c5b79617440df78becfc0f1953888f8e1ea35c Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Mon, 23 Feb 2026 00:16:48 +0630 Subject: [PATCH 24/31] refactor: Centralize workspace package lists into a new shared script for improved maintainability across various build and test scripts. --- .github/workflows/pr-checks.yaml | 4 ++-- scripts/analyze_all.sh | 25 ++++++++---------------- scripts/build_all.sh | 25 ++++++++---------------- scripts/check_dependencies.sh | 25 ++++++++---------------- scripts/clean_all.sh | 25 ++++++++---------------- scripts/coverage.sh | 25 ++++++++---------------- scripts/test_all.sh | 25 ++++++++---------------- scripts/workspace_packages.sh | 33 ++++++++++++++++++++++++++++++++ 8 files changed, 83 insertions(+), 104 deletions(-) create mode 100644 scripts/workspace_packages.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/scripts/analyze_all.sh b/scripts/analyze_all.sh index 427f340..641cc1c 100755 --- a/scripts/analyze_all.sh +++ b/scripts/analyze_all.sh @@ -1,6 +1,9 @@ #!/bin/bash set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/workspace_packages.sh" + echo "🔍 Analyzing all packages..." echo "" @@ -41,23 +44,11 @@ WORKSPACE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$WORKSPACE_ROOT" # Analyze all packages -analyze_package "packages/core/domain" "core_domain" -analyze_package "packages/core/data" "core_data" -analyze_package "packages/common/env" "common_env" -analyze_package "packages/common/ui" "common_ui" -analyze_package "packages/common/utils" "common_utils" -analyze_package "packages/integrations/analytics" "integration_analytics" -analyze_package "packages/integrations/auth_session" "integration_auth_session" -analyze_package "packages/integrations/backend_api" "integration_backend_api" -analyze_package "packages/integrations/barcode_scanner" "integration_barcode_scanner" -analyze_package "packages/integrations/database" "integration_database" -analyze_package "packages/integrations/firebase_services" "integration_firebase_services" -analyze_package "packages/integrations/logger" "integration_logging" -analyze_package "packages/integrations/metadata_api" "integration_metadata_api" -analyze_package "packages/integrations/sync_api" "integration_sync_api" -analyze_package "packages/integrations/storage" "integration_storage" -analyze_package "packages/integrations/payment" "integration_payment" -analyze_package "apps/mobile" "mobile_app" +for entry in "${WORKSPACE_PACKAGES_WITH_APP[@]}"; do + package_path="${entry%%:*}" + package_name="${entry#*:}" + analyze_package "$package_path" "$package_name" +done echo "════════════════════════════════════════" if [ $ANALYZE_ERRORS -eq 0 ]; then diff --git a/scripts/build_all.sh b/scripts/build_all.sh index c892387..483936e 100755 --- a/scripts/build_all.sh +++ b/scripts/build_all.sh @@ -1,6 +1,9 @@ #!/bin/bash set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/workspace_packages.sh" + echo "🔨 Running code generation for all packages..." echo "" @@ -42,23 +45,11 @@ WORKSPACE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$WORKSPACE_ROOT" # Build packages in order (dependencies first) -run_build "packages/core/domain" "core_domain" -run_build "packages/core/data" "core_data" -run_build "packages/common/env" "common_env" -run_build "packages/common/ui" "common_ui" -run_build "packages/common/utils" "common_utils" -run_build "packages/integrations/analytics" "integration_analytics" -run_build "packages/integrations/auth_session" "integration_auth_session" -run_build "packages/integrations/backend_api" "integration_backend_api" -run_build "packages/integrations/barcode_scanner" "integration_barcode_scanner" -run_build "packages/integrations/database" "integration_database" -run_build "packages/integrations/firebase_services" "integration_firebase_services" -run_build "packages/integrations/logger" "integration_logging" -run_build "packages/integrations/metadata_api" "integration_metadata_api" -run_build "packages/integrations/sync_api" "integration_sync_api" -run_build "packages/integrations/storage" "integration_storage" -run_build "packages/integrations/payment" "integration_payment" -run_build "apps/mobile" "mobile_app" +for entry in "${WORKSPACE_PACKAGES_WITH_APP[@]}"; do + package_path="${entry%%:*}" + package_name="${entry#*:}" + run_build "$package_path" "$package_name" +done if [ $BUILD_ERRORS -eq 0 ]; then echo "✅ All builds completed successfully!" diff --git a/scripts/check_dependencies.sh b/scripts/check_dependencies.sh index 950c1ac..fb845dd 100755 --- a/scripts/check_dependencies.sh +++ b/scripts/check_dependencies.sh @@ -1,5 +1,8 @@ #!/bin/bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/workspace_packages.sh" + echo "🔍 Checking for outdated dependencies..." echo "" @@ -27,22 +30,10 @@ check_deps() { WORKSPACE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$WORKSPACE_ROOT" -check_deps "apps/mobile" "mobile_app" -check_deps "packages/core/domain" "core_domain" -check_deps "packages/core/data" "core_data" -check_deps "packages/common/env" "common_env" -check_deps "packages/common/ui" "common_ui" -check_deps "packages/common/utils" "common_utils" -check_deps "packages/integrations/analytics" "integration_analytics" -check_deps "packages/integrations/auth_session" "integration_auth_session" -check_deps "packages/integrations/backend_api" "integration_backend_api" -check_deps "packages/integrations/barcode_scanner" "integration_barcode_scanner" -check_deps "packages/integrations/database" "integration_database" -check_deps "packages/integrations/firebase_services" "integration_firebase_services" -check_deps "packages/integrations/logger" "integration_logging" -check_deps "packages/integrations/metadata_api" "integration_metadata_api" -check_deps "packages/integrations/payment" "integration_payment" -check_deps "packages/integrations/storage" "integration_storage" -check_deps "packages/integrations/sync_api" "integration_sync_api" +for entry in "${WORKSPACE_PACKAGES_APP_FIRST[@]}"; do + package_path="${entry%%:*}" + package_name="${entry#*:}" + check_deps "$package_path" "$package_name" +done echo "✓ Dependency check complete" diff --git a/scripts/clean_all.sh b/scripts/clean_all.sh index 0d9ef82..724cbf0 100755 --- a/scripts/clean_all.sh +++ b/scripts/clean_all.sh @@ -1,6 +1,9 @@ #!/bin/bash set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/workspace_packages.sh" + echo "🧹 Cleaning all packages..." echo "" @@ -36,23 +39,11 @@ WORKSPACE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$WORKSPACE_ROOT" # Clean all packages -clean_package "packages/core/domain" "core_domain" -clean_package "packages/core/data" "core_data" -clean_package "packages/common/env" "common_env" -clean_package "packages/common/ui" "common_ui" -clean_package "packages/common/utils" "common_utils" -clean_package "packages/integrations/analytics" "integration_analytics" -clean_package "packages/integrations/auth_session" "integration_auth_session" -clean_package "packages/integrations/backend_api" "integration_backend_api" -clean_package "packages/integrations/barcode_scanner" "integration_barcode_scanner" -clean_package "packages/integrations/database" "integration_database" -clean_package "packages/integrations/firebase_services" "integration_firebase_services" -clean_package "packages/integrations/logger" "integration_logging" -clean_package "packages/integrations/metadata_api" "integration_metadata_api" -clean_package "packages/integrations/sync_api" "integration_sync_api" -clean_package "packages/integrations/storage" "integration_storage" -clean_package "packages/integrations/payment" "integration_payment" -clean_package "apps/mobile" "mobile_app" +for entry in "${WORKSPACE_PACKAGES_WITH_APP[@]}"; do + package_path="${entry%%:*}" + package_name="${entry#*:}" + clean_package "$package_path" "$package_name" +done # Clean workspace root rm -rf .dart_tool diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 9aeae24..dd41208 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -1,6 +1,9 @@ #!/bin/bash set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/workspace_packages.sh" + echo "📊 Generating consolidated test coverage report..." echo "" @@ -61,23 +64,11 @@ run_coverage() { } # Process all relevant packages -run_coverage "packages/core/domain" "core_domain" -run_coverage "packages/core/data" "core_data" -run_coverage "packages/common/env" "common_env" -run_coverage "packages/common/ui" "common_ui" -run_coverage "packages/common/utils" "common_utils" -run_coverage "packages/integrations/analytics" "integration_analytics" -run_coverage "packages/integrations/auth_session" "integration_auth_session" -run_coverage "packages/integrations/backend_api" "integration_backend_api" -run_coverage "packages/integrations/barcode_scanner" "integration_barcode_scanner" -run_coverage "packages/integrations/database" "integration_database" -run_coverage "packages/integrations/firebase_services" "integration_firebase_services" -run_coverage "packages/integrations/logger" "integration_logging" -run_coverage "packages/integrations/metadata_api" "integration_metadata_api" -run_coverage "packages/integrations/sync_api" "integration_sync_api" -run_coverage "packages/integrations/storage" "integration_storage" -run_coverage "packages/integrations/payment" "integration_payment" -run_coverage "apps/mobile" "mobile_app" +for entry in "${WORKSPACE_PACKAGES_WITH_APP[@]}"; do + package_path="${entry%%:*}" + package_name="${entry#*:}" + run_coverage "$package_path" "$package_name" +done echo "════════════════════════════════════════" diff --git a/scripts/test_all.sh b/scripts/test_all.sh index c8e4645..9b4ddad 100755 --- a/scripts/test_all.sh +++ b/scripts/test_all.sh @@ -1,6 +1,9 @@ #!/bin/bash set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/workspace_packages.sh" + echo "🧪 Running tests for all packages..." echo "" @@ -53,23 +56,11 @@ WORKSPACE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$WORKSPACE_ROOT" # Run tests for all packages -run_tests "packages/core/domain" "core_domain" -run_tests "packages/core/data" "core_data" -run_tests "packages/common/env" "common_env" -run_tests "packages/common/ui" "common_ui" -run_tests "packages/common/utils" "common_utils" -run_tests "packages/integrations/analytics" "integration_analytics" -run_tests "packages/integrations/auth_session" "integration_auth_session" -run_tests "packages/integrations/backend_api" "integration_backend_api" -run_tests "packages/integrations/barcode_scanner" "integration_barcode_scanner" -run_tests "packages/integrations/database" "integration_database" -run_tests "packages/integrations/firebase_services" "integration_firebase_services" -run_tests "packages/integrations/logger" "integration_logging" -run_tests "packages/integrations/metadata_api" "integration_metadata_api" -run_tests "packages/integrations/sync_api" "integration_sync_api" -run_tests "packages/integrations/storage" "integration_storage" -run_tests "packages/integrations/payment" "integration_payment" -run_tests "apps/mobile" "mobile_app" +for entry in "${WORKSPACE_PACKAGES_WITH_APP[@]}"; do + package_path="${entry%%:*}" + package_name="${entry#*:}" + run_tests "$package_path" "$package_name" +done echo "════════════════════════════════════════" if [ $TEST_ERRORS -eq 0 ]; then diff --git a/scripts/workspace_packages.sh b/scripts/workspace_packages.sh new file mode 100644 index 0000000..6690933 --- /dev/null +++ b/scripts/workspace_packages.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# Shared workspace package lists used by helper scripts. +# Format: ":" + +WORKSPACE_PACKAGES=( + "packages/core/domain:core_domain" + "packages/core/data:core_data" + "packages/common/env:common_env" + "packages/common/ui:common_ui" + "packages/common/utils:common_utils" + "packages/integrations/analytics:integration_analytics" + "packages/integrations/auth_session:integration_auth_session" + "packages/integrations/backend_api:integration_backend_api" + "packages/integrations/barcode_scanner:integration_barcode_scanner" + "packages/integrations/database:integration_database" + "packages/integrations/firebase_services:integration_firebase_services" + "packages/integrations/logger:integration_logging" + "packages/integrations/metadata_api:integration_metadata_api" + "packages/integrations/sync_api:integration_sync_api" + "packages/integrations/storage:integration_storage" + "packages/integrations/payment:integration_payment" +) + +WORKSPACE_PACKAGES_WITH_APP=( + "${WORKSPACE_PACKAGES[@]}" + "apps/mobile:mobile_app" +) + +WORKSPACE_PACKAGES_APP_FIRST=( + "apps/mobile:mobile_app" + "${WORKSPACE_PACKAGES[@]}" +) From 78ebed57240413bfa21d4cd7b34cdaade0daa628 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Mon, 23 Feb 2026 00:29:51 +0630 Subject: [PATCH 25/31] feat: Add `authFeatureEnabled` flag to control authentication features and integrate it into backend and sync readiness checks. --- README.md | 3 +- .../lib/core/bootstrap/app_bootstrap.dart | 1 + .../firebase_services_bootstrap.dart | 9 ++++ .../firebase/firebase_runtime_config.dart | 3 ++ .../observability/operational_telemetry.dart | 3 ++ .../core/providers/backend_api_providers.dart | 50 ++++++++++++++++++- .../lib/core/providers/sync_providers.dart | 24 ++++++++- .../auth/presentation/views/auth_screen.dart | 2 +- .../presentation/views/settings_screen.dart | 40 +++++++++++++-- documentation/FIREBASE_AND_FLAGS.md | 11 ++-- documentation/SETUP_AND_RUN.md | 4 +- 11 files changed, 137 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7ecba36..396a378 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,10 @@ Detailed setup instructions: [documentation/SETUP_AND_RUN.md](documentation/SETU ## Cloud Sync Flags (Important) -Cloud Sync is enabled only when both Remote Config flags are `true`: +Cloud Sync is enabled only when all required Remote Config flags are `true`: - `app_backend_integration_enabled` +- `app_auth_feature_enabled` - `app_sync_feature_enabled` If either is `false`, sync UI/actions are disabled. Full explanation: [documentation/FIREBASE_AND_FLAGS.md](documentation/FIREBASE_AND_FLAGS.md) diff --git a/apps/mobile/lib/core/bootstrap/app_bootstrap.dart b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart index 4aa5964..1ef919c 100644 --- a/apps/mobile/lib/core/bootstrap/app_bootstrap.dart +++ b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart @@ -39,6 +39,7 @@ abstract final class AppBootstrap { crashlyticsEnabled: firebaseRuntimeConfig.crashlyticsCollectionEnabled, performanceEnabled: firebaseRuntimeConfig.performanceCollectionEnabled, backendEnabled: firebaseRuntimeConfig.backendIntegrationEnabled, + authEnabled: firebaseRuntimeConfig.authFeatureEnabled, syncEnabled: firebaseRuntimeConfig.syncFeatureEnabled, didActivateChanges: null, ); diff --git a/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart index 1689d08..dfa5024 100644 --- a/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart +++ b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart @@ -27,6 +27,7 @@ abstract final class FirebaseServicesBootstrap { 'app_performance_collection_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 { @@ -38,6 +39,7 @@ abstract final class FirebaseServicesBootstrap { _crashlyticsCollectionEnabledKey: true, _performanceCollectionEnabledKey: true, _backendIntegrationEnabledKey: false, + _authFeatureEnabledKey: true, _syncFeatureEnabledKey: false, }, minimumFetchInterval: kDebugMode @@ -56,6 +58,7 @@ abstract final class FirebaseServicesBootstrap { 'crashlytics: ${runtimeConfig.crashlyticsCollectionEnabled}, ' 'performance: ${runtimeConfig.performanceCollectionEnabled}, ' 'backend: ${runtimeConfig.backendIntegrationEnabled}, ' + 'auth: ${runtimeConfig.authFeatureEnabled}, ' 'sync: ${runtimeConfig.syncFeatureEnabled}).', ); @@ -93,6 +96,7 @@ abstract final class FirebaseServicesBootstrap { 'crashlytics: ${runtimeConfig.crashlyticsCollectionEnabled}, ' 'performance: ${runtimeConfig.performanceCollectionEnabled}, ' 'backend: ${runtimeConfig.backendIntegrationEnabled}, ' + 'auth: ${runtimeConfig.authFeatureEnabled}, ' 'sync: ${runtimeConfig.syncFeatureEnabled}).', ); await OperationalTelemetry.trackRuntimeConfigApplied( @@ -101,6 +105,7 @@ abstract final class FirebaseServicesBootstrap { crashlyticsEnabled: runtimeConfig.crashlyticsCollectionEnabled, performanceEnabled: runtimeConfig.performanceCollectionEnabled, backendEnabled: runtimeConfig.backendIntegrationEnabled, + authEnabled: runtimeConfig.authFeatureEnabled, syncEnabled: runtimeConfig.syncFeatureEnabled, didActivateChanges: didActivateChanges, ); @@ -132,6 +137,10 @@ abstract final class FirebaseServicesBootstrap { _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 index b96daae..5ce3bcd 100644 --- a/apps/mobile/lib/core/firebase/firebase_runtime_config.dart +++ b/apps/mobile/lib/core/firebase/firebase_runtime_config.dart @@ -4,6 +4,7 @@ class FirebaseRuntimeConfig { required this.crashlyticsCollectionEnabled, required this.performanceCollectionEnabled, required this.backendIntegrationEnabled, + required this.authFeatureEnabled, required this.syncFeatureEnabled, }); @@ -12,6 +13,7 @@ class FirebaseRuntimeConfig { crashlyticsCollectionEnabled: true, performanceCollectionEnabled: true, backendIntegrationEnabled: false, + authFeatureEnabled: true, syncFeatureEnabled: false, ); @@ -19,5 +21,6 @@ class FirebaseRuntimeConfig { final bool crashlyticsCollectionEnabled; final bool performanceCollectionEnabled; final bool backendIntegrationEnabled; + final bool authFeatureEnabled; final bool syncFeatureEnabled; } diff --git a/apps/mobile/lib/core/observability/operational_telemetry.dart b/apps/mobile/lib/core/observability/operational_telemetry.dart index adf7b49..4454f56 100644 --- a/apps/mobile/lib/core/observability/operational_telemetry.dart +++ b/apps/mobile/lib/core/observability/operational_telemetry.dart @@ -152,6 +152,7 @@ class OperationalTelemetry { required bool crashlyticsEnabled, required bool performanceEnabled, required bool backendEnabled, + required bool authEnabled, required bool syncEnabled, bool? didActivateChanges, }) { @@ -164,6 +165,7 @@ class OperationalTelemetry { 'crashlytics_enabled': crashlyticsEnabled, 'performance_enabled': performanceEnabled, 'backend_enabled': backendEnabled, + 'auth_enabled': authEnabled, 'sync_enabled': syncEnabled, 'changed': ?didActivateChanges, }, @@ -173,6 +175,7 @@ class OperationalTelemetry { 'flag_crashlytics_enabled': crashlyticsEnabled, 'flag_performance_enabled': performanceEnabled, 'flag_backend_enabled': backendEnabled, + 'flag_auth_enabled': authEnabled, 'flag_sync_enabled': syncEnabled, }, ); diff --git a/apps/mobile/lib/core/providers/backend_api_providers.dart b/apps/mobile/lib/core/providers/backend_api_providers.dart index 1acafac..1d9876e 100644 --- a/apps/mobile/lib/core/providers/backend_api_providers.dart +++ b/apps/mobile/lib/core/providers/backend_api_providers.dart @@ -82,6 +82,23 @@ final backendSyncFeatureFlagProvider = Provider((ref) { return debugEnvOverride; }); +final backendAuthFeatureFlagProvider = Provider((ref) { + final runtimeConfig = ref.watch(firebaseRuntimeConfigProvider); + if (runtimeConfig.authFeatureEnabled) { + return true; + } + + if (!_shouldUseDebugEnvFlagOverrides) { + return false; + } + + const debugEnvOverride = bool.fromEnvironment( + 'BACKEND_AUTH_ENABLED', + defaultValue: false, + ); + return debugEnvOverride; +}); + final backendDebugEnvFlagOverridesActiveProvider = Provider((ref) { return _shouldUseDebugEnvFlagOverrides; }); @@ -154,6 +171,37 @@ final backendApiReadinessProvider = Provider((ref) { ); }); +final backendAuthReadinessProvider = Provider((ref) { + final integrationEnabled = ref.watch(backendIntegrationFeatureFlagProvider); + if (!integrationEnabled) { + return const BackendApiReadiness( + enabled: false, + message: 'Backend integration is disabled by feature flags.', + ); + } + + final authEnabled = ref.watch(backendAuthFeatureFlagProvider); + if (!authEnabled) { + return const BackendApiReadiness( + enabled: false, + message: 'Authentication is disabled by feature flags.', + ); + } + + final baseUrl = ref.watch(backendApiBaseUrlProvider); + if (baseUrl.isEmpty) { + return const BackendApiReadiness( + enabled: false, + message: 'Backend API URL is missing.', + ); + } + + return const BackendApiReadiness( + enabled: true, + message: 'Authentication is enabled.', + ); +}); + final backendApiDioProvider = Provider((ref) { final dio = Dio( BaseOptions( @@ -178,7 +226,7 @@ final backendApiDioProvider = Provider((ref) { }); final backendAuthClientProvider = Provider((ref) { - final readiness = ref.watch(backendApiReadinessProvider); + final readiness = ref.watch(backendAuthReadinessProvider); if (!readiness.enabled) { return null; } diff --git a/apps/mobile/lib/core/providers/sync_providers.dart b/apps/mobile/lib/core/providers/sync_providers.dart index afebccd..dca23c6 100644 --- a/apps/mobile/lib/core/providers/sync_providers.dart +++ b/apps/mobile/lib/core/providers/sync_providers.dart @@ -13,17 +13,20 @@ import 'package:sync_api/sync_api.dart'; class SyncTransportConfig { const SyncTransportConfig({ required this.backendFeatureEnabled, + required this.authFeatureEnabled, required this.syncFeatureEnabled, required this.baseUrl, required this.apiPrefix, }); final bool backendFeatureEnabled; + final bool authFeatureEnabled; final bool syncFeatureEnabled; final String baseUrl; final String apiPrefix; - bool get featureFlagEnabled => backendFeatureEnabled && syncFeatureEnabled; + bool get featureFlagEnabled => + backendFeatureEnabled && authFeatureEnabled && syncFeatureEnabled; bool get isApiBaseUrlConfigured => baseUrl.trim().isNotEmpty; @@ -56,20 +59,23 @@ final syncFeatureFlagEnabledProvider = Provider((ref) { final backendFeatureEnabled = ref.watch( backendIntegrationFeatureFlagProvider, ); + final authFeatureEnabled = ref.watch(backendAuthFeatureFlagProvider); final syncFeatureEnabled = ref.watch(backendSyncFeatureFlagProvider); - return backendFeatureEnabled && syncFeatureEnabled; + return backendFeatureEnabled && authFeatureEnabled && syncFeatureEnabled; }); final syncTransportConfigProvider = Provider((ref) { final backendFeatureEnabled = ref.watch( backendIntegrationFeatureFlagProvider, ); + final authFeatureEnabled = ref.watch(backendAuthFeatureFlagProvider); final syncFeatureEnabled = ref.watch(backendSyncFeatureFlagProvider); final baseUrl = ref.watch(backendApiBaseUrlProvider); final apiPrefix = ref.watch(backendApiPrefixProvider); return SyncTransportConfig( backendFeatureEnabled: backendFeatureEnabled, + authFeatureEnabled: authFeatureEnabled, syncFeatureEnabled: syncFeatureEnabled, baseUrl: baseUrl, apiPrefix: apiPrefix, @@ -146,6 +152,13 @@ final syncBackendClientProvider = Provider((ref) { ); } + if (!transportConfig.authFeatureEnabled) { + return const NoopSyncBackendClient( + reason: SyncBackendUnavailableReason.featureFlagDisabled, + message: 'Authentication is disabled by feature flags.', + ); + } + if (!transportConfig.isApiBaseUrlConfigured) { return const NoopSyncBackendClient( reason: SyncBackendUnavailableReason.notConfigured, @@ -180,6 +193,13 @@ final syncReadinessProvider = Provider((ref) { ); } + if (!transportConfig.authFeatureEnabled) { + return const SyncReadinessState( + status: SyncReadinessStatus.disabledByFeatureFlag, + message: 'Authentication is disabled by feature flags.', + ); + } + if (!transportConfig.isApiBaseUrlConfigured) { return const SyncReadinessState( status: SyncReadinessStatus.missingApiConfiguration, diff --git a/apps/mobile/lib/features/auth/presentation/views/auth_screen.dart b/apps/mobile/lib/features/auth/presentation/views/auth_screen.dart index 839f095..109b5ad 100644 --- a/apps/mobile/lib/features/auth/presentation/views/auth_screen.dart +++ b/apps/mobile/lib/features/auth/presentation/views/auth_screen.dart @@ -54,7 +54,7 @@ class _AuthScreenState extends ConsumerState { final sessionAsync = ref.watch(authSessionProvider); final session = sessionAsync.value; final service = ref.watch(backendAuthServiceProvider); - final readiness = ref.watch(backendApiReadinessProvider); + final readiness = ref.watch(backendAuthReadinessProvider); final canAuthenticate = service != null; final isAuthenticated = session?.isAuthenticated ?? false; diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart index e587757..eda6c4d 100644 --- a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart +++ b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart @@ -28,13 +28,15 @@ class SettingsScreen extends ConsumerWidget { final analyticsPreferences = ref.watch(analyticsPreferencesProvider); final firebaseRuntimeConfig = ref.watch(firebaseRuntimeConfigProvider); final syncReadiness = ref.watch(syncReadinessProvider); + final accountReadiness = ref.watch(backendAuthReadinessProvider); final pendingSyncCount = ref.watch(syncOutboxCountProvider).value ?? 0; final authSession = ref.watch(authSessionProvider).value; final themeSummary = '${_themeModeLabel(context, themeSettings.mode)} - ${themeSettings.variant.label}'; final languageSummary = _languageLabel(context, currentLanguage); final analyticsSummary = _analyticsSummary(context, analyticsPreferences); - final accountSummary = _authAccountSummary(authSession); + final accountSummary = _authAccountSummary(authSession, accountReadiness); + final accountFeatureEnabled = accountReadiness.enabled; final cloudSyncSummary = _cloudSyncSummary( syncReadiness, pendingSyncCount: pendingSyncCount, @@ -82,7 +84,10 @@ class SettingsScreen extends ConsumerWidget { icon: Icons.person_outline_rounded, title: 'Account', subtitle: accountSummary, - onTap: () => context.push(Routes.auth), + enabled: accountFeatureEnabled, + onTap: accountFeatureEnabled + ? () => context.push(Routes.auth) + : null, ), ], ), @@ -333,7 +338,17 @@ class SettingsScreen extends ConsumerWidget { }; } - String _authAccountSummary(AuthSession? session) { + String _authAccountSummary( + AuthSession? session, + BackendApiReadiness readiness, + ) { + if (!readiness.enabled) { + final message = readiness.message.toLowerCase(); + if (message.contains('missing') || message.contains('configure')) { + return 'Configuration required'; + } + return 'Unavailable'; + } if (session == null || !session.isAuthenticated) { return 'Not signed in'; } @@ -699,6 +714,13 @@ class SettingsScreen extends ConsumerWidget { : 'Disabled', ), const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Auth flag', + value: transportConfig.authFeatureEnabled + ? 'Enabled' + : 'Disabled', + ), + const SizedBox(height: AppSpacing.xs), _CloudSyncStateRow( label: 'Env overrides', value: envFlagOverridesActive ? 'Active' : 'Inactive', @@ -1200,6 +1222,18 @@ class SettingsScreen extends ConsumerWidget { ), ), ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.person_outline_rounded), + title: const Text('Authentication'), + subtitle: const Text('app_auth_feature_enabled'), + trailing: Text( + _enabledDisabledLabel( + context, + runtimeConfig.authFeatureEnabled, + ), + ), + ), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.cloud_sync_outlined), diff --git a/documentation/FIREBASE_AND_FLAGS.md b/documentation/FIREBASE_AND_FLAGS.md index 1f29ab9..5d6966f 100644 --- a/documentation/FIREBASE_AND_FLAGS.md +++ b/documentation/FIREBASE_AND_FLAGS.md @@ -27,6 +27,7 @@ Runtime flags are read in `FirebaseServicesBootstrap`. | `app_crashlytics_collection_enabled` | `true` | Enables/disables Crashlytics collection | | `app_performance_collection_enabled` | `true` | Enables/disables Firebase Performance collection | | `app_backend_integration_enabled` | `false` | Gates backend-auth integration paths | +| `app_auth_feature_enabled` | `true` | Gates account authentication UI/service availability | | `app_sync_feature_enabled` | `false` | Gates sync transport and sync UI readiness | ## 3. Why Cloud Sync Can Still Look Disabled @@ -34,9 +35,10 @@ Runtime flags are read in `FirebaseServicesBootstrap`. Cloud Sync readiness requires all of the following: 1. `app_backend_integration_enabled = true` -2. `app_sync_feature_enabled = true` -3. API base URL available (`BACKEND_API_BASE_URL` or settings override) -4. Signed-in auth session (sync is optional, but auth is required for sync) +2. `app_auth_feature_enabled = true` +3. `app_sync_feature_enabled = true` +4. API base URL available (`BACKEND_API_BASE_URL` or settings override) +5. Signed-in auth session (sync is optional, but auth is required for sync) If any condition fails, the settings sync tile shows non-ready state and relevant CTA. @@ -66,6 +68,7 @@ There are optional debug-only `--dart-define` overrides: - `BACKEND_USE_ENV_FLAG_OVERRIDES=true` - `BACKEND_INTEGRATION_ENABLED=true|false` +- `BACKEND_AUTH_ENABLED=true|false` - `BACKEND_SYNC_ENABLED=true|false` These overrides are ignored unless `BACKEND_USE_ENV_FLAG_OVERRIDES=true` and build is debug. @@ -81,7 +84,7 @@ Crashlytics collection is also constrained by debug behavior: If sync tile does not enable after toggling Remote Config keys: -1. Confirm both backend and sync keys are `true`. +1. Confirm backend, auth, and sync keys are all `true`. 2. Trigger manual refresh from debug settings. 3. Verify fetch status and last fetch time in runtime config sheet. 4. Check API base URL configuration. diff --git a/documentation/SETUP_AND_RUN.md b/documentation/SETUP_AND_RUN.md index 6171206..044a289 100644 --- a/documentation/SETUP_AND_RUN.md +++ b/documentation/SETUP_AND_RUN.md @@ -79,9 +79,10 @@ By default, backend/sync features are runtime-flag disabled. ### Option A: Use Firebase Remote Config only (recommended) -Set both keys to `true` in Remote Config: +Set these keys to `true` in Remote Config: - `app_backend_integration_enabled` +- `app_auth_feature_enabled` - `app_sync_feature_enabled` ### Option B: Debug-only `--dart-define` overrides @@ -92,6 +93,7 @@ Use only when explicitly needed for local testing: flutter run \ --dart-define=BACKEND_USE_ENV_FLAG_OVERRIDES=true \ --dart-define=BACKEND_INTEGRATION_ENABLED=true \ + --dart-define=BACKEND_AUTH_ENABLED=true \ --dart-define=BACKEND_SYNC_ENABLED=true \ --dart-define=BACKEND_API_BASE_URL=http://localhost:4000 ``` From 08bc5efef4383f3c2decb9e14d0d7d0fb2b9c1b3 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Mon, 23 Feb 2026 09:38:34 +0630 Subject: [PATCH 26/31] build: refactor firebase_setup script to fix firebase_options.dart creating --- scripts/setup_firebase.sh | 68 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/scripts/setup_firebase.sh b/scripts/setup_firebase.sh index cdec65b..d11ccff 100755 --- a/scripts/setup_firebase.sh +++ b/scripts/setup_firebase.sh @@ -86,6 +86,46 @@ decode_base64_to_file() { printf '%s' "$encoded" | base64 -D > "$out_file" } +validate_target_config() { + local target="$1" + local out_file="$2" + local source_label="$3" + + if [[ ! -s "$out_file" ]]; then + echo "[error] '$target' config is empty from $source_label." >&2 + return 1 + fi + + case "$target" in + dart) + if ! grep -q "class DefaultFirebaseOptions" "$out_file"; then + echo "[error] '$out_file' does not look like FlutterFire Dart config." >&2 + echo " Check FIREBASE_OPTIONS_DART / FIREBASE_OPTIONS_DART_BASE64." >&2 + echo " Source used: $source_label" >&2 + return 1 + fi + ;; + android) + if ! grep -q '"project_info"' "$out_file"; then + echo "[error] '$out_file' does not look like Android google-services.json." >&2 + echo " Check FIREBASE_ANDROID_GOOGLE_SERVICES_JSON / _BASE64." >&2 + echo " Source used: $source_label" >&2 + return 1 + fi + ;; + ios|macos) + if ! grep -q "&2 + echo " Check FIREBASE_${target^^}_GOOGLE_SERVICE_INFO_PLIST / _BASE64." >&2 + echo " Source used: $source_label" >&2 + return 1 + fi + ;; + esac + + return 0 +} + write_secret_file() { local target="$1" local out_file="$2" @@ -94,19 +134,39 @@ write_secret_file() { local raw_value="${!raw_var_name:-}" local b64_value="${!b64_var_name:-}" + local source_label="" mkdir -p "$(dirname "$out_file")" if [[ -n "$b64_value" ]]; then decode_base64_to_file "$b64_value" "$out_file" - echo "[ok] Wrote $target config: $out_file" - return 0 + source_label="$b64_var_name" + if validate_target_config "$target" "$out_file" "$source_label"; then + echo "[ok] Wrote $target config: $out_file (source: $source_label)" + return 0 + fi + + if [[ -n "$raw_value" ]]; then + echo "[warn] Falling back to $raw_var_name for '$target'..." >&2 + printf '%s' "$raw_value" > "$out_file" + source_label="$raw_var_name" + if validate_target_config "$target" "$out_file" "$source_label"; then + echo "[ok] Wrote $target config: $out_file (source: $source_label)" + return 0 + fi + fi + + return 1 fi if [[ -n "$raw_value" ]]; then printf '%s' "$raw_value" > "$out_file" - echo "[ok] Wrote $target config: $out_file" - return 0 + source_label="$raw_var_name" + if validate_target_config "$target" "$out_file" "$source_label"; then + echo "[ok] Wrote $target config: $out_file (source: $source_label)" + return 0 + fi + return 1 fi if is_required "$target"; then From ec0975fd75a8f0c19192e65e0a48f780e161ee1d Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Mon, 23 Feb 2026 10:04:10 +0630 Subject: [PATCH 27/31] feat: Integrate Firebase App Check with a dedicated service and remote config flag. --- README.md | 2 +- .../lib/core/bootstrap/app_bootstrap.dart | 1 + .../firebase_services_bootstrap.dart | 15 ++ .../firebase/firebase_runtime_config.dart | 3 + .../observability/operational_telemetry.dart | 3 + .../presentation/views/settings_screen.dart | 12 ++ .../Flutter/GeneratedPluginRegistrant.swift | 2 + documentation/FIREBASE_AND_FLAGS.md | 13 +- documentation/SETUP_AND_RUN.md | 1 + .../firebase_services/lib/app_firebase.dart | 1 + .../services/firebase_app_check_service.dart | 129 ++++++++++++++++++ .../firebase_services/pubspec.yaml | 3 +- 12 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 packages/integrations/firebase_services/lib/src/services/firebase_app_check_service.dart diff --git a/README.md b/README.md index 396a378..acdbc4c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ This repository is actively developed and already includes: - Barcode scanner flow - Localization support for 7 languages - Custom design system and glass bottom navigation -- Firebase Crashlytics, Analytics (consent-based), Performance, Remote Config +- Firebase Crashlytics, Analytics (consent-based), Performance, Remote Config, App Check - Optional backend auth and sync (feature-flag gated) For a detailed status matrix, see [documentation/APP_PROGRESS.md](documentation/APP_PROGRESS.md). diff --git a/apps/mobile/lib/core/bootstrap/app_bootstrap.dart b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart index 1ef919c..caff064 100644 --- a/apps/mobile/lib/core/bootstrap/app_bootstrap.dart +++ b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart @@ -38,6 +38,7 @@ abstract final class AppBootstrap { analyticsEnabled: firebaseRuntimeConfig.analyticsCollectionEnabled, crashlyticsEnabled: firebaseRuntimeConfig.crashlyticsCollectionEnabled, performanceEnabled: firebaseRuntimeConfig.performanceCollectionEnabled, + appCheckEnabled: firebaseRuntimeConfig.appCheckEnabled, backendEnabled: firebaseRuntimeConfig.backendIntegrationEnabled, authEnabled: firebaseRuntimeConfig.authFeatureEnabled, syncEnabled: firebaseRuntimeConfig.syncFeatureEnabled, diff --git a/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart index dfa5024..e4f9714 100644 --- a/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart +++ b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart @@ -25,6 +25,7 @@ abstract final class FirebaseServicesBootstrap { 'app_crashlytics_collection_enabled'; static const _performanceCollectionEnabledKey = 'app_performance_collection_enabled'; + static const _appCheckEnabledKey = 'app_app_check_enabled'; static const _backendIntegrationEnabledKey = 'app_backend_integration_enabled'; static const _authFeatureEnabledKey = 'app_auth_feature_enabled'; @@ -38,6 +39,7 @@ abstract final class FirebaseServicesBootstrap { _analyticsCollectionEnabledKey: true, _crashlyticsCollectionEnabledKey: true, _performanceCollectionEnabledKey: true, + _appCheckEnabledKey: false, _backendIntegrationEnabledKey: false, _authFeatureEnabledKey: true, _syncFeatureEnabledKey: false, @@ -49,6 +51,9 @@ abstract final class FirebaseServicesBootstrap { final runtimeConfig = _readRuntimeConfig(remoteConfigService); + await FirebaseAppCheckService.instance.initialize( + enabled: runtimeConfig.appCheckEnabled, + ); await FirebasePerformanceService.instance.initialize( enabled: runtimeConfig.performanceCollectionEnabled, ); @@ -57,6 +62,7 @@ abstract final class FirebaseServicesBootstrap { 'Firebase services initialized (analytics: ${runtimeConfig.analyticsCollectionEnabled}, ' 'crashlytics: ${runtimeConfig.crashlyticsCollectionEnabled}, ' 'performance: ${runtimeConfig.performanceCollectionEnabled}, ' + 'appCheck: ${runtimeConfig.appCheckEnabled}, ' 'backend: ${runtimeConfig.backendIntegrationEnabled}, ' 'auth: ${runtimeConfig.authFeatureEnabled}, ' 'sync: ${runtimeConfig.syncFeatureEnabled}).', @@ -83,6 +89,9 @@ abstract final class FirebaseServicesBootstrap { : await remoteConfigService.refresh(); final runtimeConfig = _readRuntimeConfig(remoteConfigService); + await FirebaseAppCheckService.instance.setEnabled( + runtimeConfig.appCheckEnabled, + ); await FirebasePerformanceService.instance.setCollectionEnabled( runtimeConfig.performanceCollectionEnabled, ); @@ -95,6 +104,7 @@ abstract final class FirebaseServicesBootstrap { 'analytics: ${runtimeConfig.analyticsCollectionEnabled}, ' 'crashlytics: ${runtimeConfig.crashlyticsCollectionEnabled}, ' 'performance: ${runtimeConfig.performanceCollectionEnabled}, ' + 'appCheck: ${runtimeConfig.appCheckEnabled}, ' 'backend: ${runtimeConfig.backendIntegrationEnabled}, ' 'auth: ${runtimeConfig.authFeatureEnabled}, ' 'sync: ${runtimeConfig.syncFeatureEnabled}).', @@ -104,6 +114,7 @@ abstract final class FirebaseServicesBootstrap { analyticsEnabled: runtimeConfig.analyticsCollectionEnabled, crashlyticsEnabled: runtimeConfig.crashlyticsCollectionEnabled, performanceEnabled: runtimeConfig.performanceCollectionEnabled, + appCheckEnabled: runtimeConfig.appCheckEnabled, backendEnabled: runtimeConfig.backendIntegrationEnabled, authEnabled: runtimeConfig.authFeatureEnabled, syncEnabled: runtimeConfig.syncFeatureEnabled, @@ -133,6 +144,10 @@ abstract final class FirebaseServicesBootstrap { _performanceCollectionEnabledKey, fallback: true, ), + appCheckEnabled: remoteConfigService.getBool( + _appCheckEnabledKey, + fallback: false, + ), backendIntegrationEnabled: remoteConfigService.getBool( _backendIntegrationEnabledKey, fallback: false, diff --git a/apps/mobile/lib/core/firebase/firebase_runtime_config.dart b/apps/mobile/lib/core/firebase/firebase_runtime_config.dart index 5ce3bcd..5c87b2f 100644 --- a/apps/mobile/lib/core/firebase/firebase_runtime_config.dart +++ b/apps/mobile/lib/core/firebase/firebase_runtime_config.dart @@ -3,6 +3,7 @@ class FirebaseRuntimeConfig { required this.analyticsCollectionEnabled, required this.crashlyticsCollectionEnabled, required this.performanceCollectionEnabled, + required this.appCheckEnabled, required this.backendIntegrationEnabled, required this.authFeatureEnabled, required this.syncFeatureEnabled, @@ -12,6 +13,7 @@ class FirebaseRuntimeConfig { analyticsCollectionEnabled: true, crashlyticsCollectionEnabled: true, performanceCollectionEnabled: true, + appCheckEnabled: false, backendIntegrationEnabled: false, authFeatureEnabled: true, syncFeatureEnabled: false, @@ -20,6 +22,7 @@ class FirebaseRuntimeConfig { final bool analyticsCollectionEnabled; final bool crashlyticsCollectionEnabled; final bool performanceCollectionEnabled; + final bool appCheckEnabled; final bool backendIntegrationEnabled; final bool authFeatureEnabled; final bool syncFeatureEnabled; diff --git a/apps/mobile/lib/core/observability/operational_telemetry.dart b/apps/mobile/lib/core/observability/operational_telemetry.dart index 4454f56..41f6687 100644 --- a/apps/mobile/lib/core/observability/operational_telemetry.dart +++ b/apps/mobile/lib/core/observability/operational_telemetry.dart @@ -151,6 +151,7 @@ class OperationalTelemetry { required bool analyticsEnabled, required bool crashlyticsEnabled, required bool performanceEnabled, + required bool appCheckEnabled, required bool backendEnabled, required bool authEnabled, required bool syncEnabled, @@ -164,6 +165,7 @@ class OperationalTelemetry { 'analytics_enabled': analyticsEnabled, 'crashlytics_enabled': crashlyticsEnabled, 'performance_enabled': performanceEnabled, + 'app_check_enabled': appCheckEnabled, 'backend_enabled': backendEnabled, 'auth_enabled': authEnabled, 'sync_enabled': syncEnabled, @@ -174,6 +176,7 @@ class OperationalTelemetry { 'flag_analytics_enabled': analyticsEnabled, 'flag_crashlytics_enabled': crashlyticsEnabled, 'flag_performance_enabled': performanceEnabled, + 'flag_app_check_enabled': appCheckEnabled, 'flag_backend_enabled': backendEnabled, 'flag_auth_enabled': authEnabled, 'flag_sync_enabled': syncEnabled, diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart index eda6c4d..13f0e5b 100644 --- a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart +++ b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart @@ -1210,6 +1210,18 @@ class SettingsScreen extends ConsumerWidget { ), ), ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.verified_user_outlined), + title: const Text('App Check'), + subtitle: const Text('app_app_check_enabled'), + trailing: Text( + _enabledDisabledLabel( + context, + runtimeConfig.appCheckEnabled, + ), + ), + ), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.hub_outlined), diff --git a/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift index b5c3fb9..636b33e 100644 --- a/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import connectivity_plus import file_picker import file_selector_macos import firebase_analytics +import firebase_app_check import firebase_core import firebase_crashlytics import firebase_remote_config @@ -28,6 +29,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin")) + FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCrashlyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCrashlyticsPlugin")) FirebaseRemoteConfigPlugin.register(with: registry.registrar(forPlugin: "FirebaseRemoteConfigPlugin")) diff --git a/documentation/FIREBASE_AND_FLAGS.md b/documentation/FIREBASE_AND_FLAGS.md index 5d6966f..1151003 100644 --- a/documentation/FIREBASE_AND_FLAGS.md +++ b/documentation/FIREBASE_AND_FLAGS.md @@ -26,6 +26,7 @@ Runtime flags are read in `FirebaseServicesBootstrap`. | `app_analytics_collection_enabled` | `true` | Enables/disables analytics collection runtime behavior | | `app_crashlytics_collection_enabled` | `true` | Enables/disables Crashlytics collection | | `app_performance_collection_enabled` | `true` | Enables/disables Firebase Performance collection | +| `app_app_check_enabled` | `false` | Enables/disables Firebase App Check activation | | `app_backend_integration_enabled` | `false` | Gates backend-auth integration paths | | `app_auth_feature_enabled` | `true` | Gates account authentication UI/service availability | | `app_sync_feature_enabled` | `false` | Gates sync transport and sync UI readiness | @@ -80,7 +81,17 @@ Crashlytics collection is also constrained by debug behavior: - Disabled by default in debug unless `ENABLE_CRASHLYTICS_IN_DEBUG=true` - Always controlled by runtime flag + build mode logic -## 7. Troubleshooting Checklist +## 7. App Check Notes + +- App Check is activated from runtime config via `app_app_check_enabled`. +- Provider strategy: + - Android debug: `AndroidDebugProvider` + - Android release: `AndroidPlayIntegrityProvider` + - Apple debug: `AppleDebugProvider` + - Apple release: `AppleAppAttestWithDeviceCheckFallbackProvider` +- On web/unsupported desktop platforms, App Check activation is skipped. + +## 8. Troubleshooting Checklist If sync tile does not enable after toggling Remote Config keys: diff --git a/documentation/SETUP_AND_RUN.md b/documentation/SETUP_AND_RUN.md index 044a289..64d52ae 100644 --- a/documentation/SETUP_AND_RUN.md +++ b/documentation/SETUP_AND_RUN.md @@ -81,6 +81,7 @@ By default, backend/sync features are runtime-flag disabled. Set these keys to `true` in Remote Config: +- `app_app_check_enabled` (optional, for App Check) - `app_backend_integration_enabled` - `app_auth_feature_enabled` - `app_sync_feature_enabled` diff --git a/packages/integrations/firebase_services/lib/app_firebase.dart b/packages/integrations/firebase_services/lib/app_firebase.dart index a328d03..b3ba6f0 100644 --- a/packages/integrations/firebase_services/lib/app_firebase.dart +++ b/packages/integrations/firebase_services/lib/app_firebase.dart @@ -1,5 +1,6 @@ library; export 'src/models/firebase_remote_config_status.dart'; +export 'src/services/firebase_app_check_service.dart'; export 'src/services/firebase_performance_service.dart'; export 'src/services/firebase_remote_config_service.dart'; diff --git a/packages/integrations/firebase_services/lib/src/services/firebase_app_check_service.dart b/packages/integrations/firebase_services/lib/src/services/firebase_app_check_service.dart new file mode 100644 index 0000000..7473154 --- /dev/null +++ b/packages/integrations/firebase_services/lib/src/services/firebase_app_check_service.dart @@ -0,0 +1,129 @@ +import 'package:firebase_app_check/firebase_app_check.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; + +class FirebaseAppCheckService { + FirebaseAppCheckService._({FirebaseAppCheck? appCheck}) + : _appCheck = appCheck ?? FirebaseAppCheck.instance; + + static FirebaseAppCheckService? _instance; + + static FirebaseAppCheckService get instance { + _instance ??= FirebaseAppCheckService._(); + return _instance!; + } + + final FirebaseAppCheck _appCheck; + + bool _initialized = false; + bool _enabled = false; + String _providerLabel = 'none'; + + bool get isInitialized => _initialized; + bool get isEnabled => _enabled; + String get providerLabel => _providerLabel; + + Future initialize({required bool enabled}) async { + if (Firebase.apps.isEmpty) { + _initialized = false; + _enabled = false; + _providerLabel = 'unavailable'; + return; + } + + if (!enabled) { + await _setTokenAutoRefreshEnabled(false); + _initialized = true; + _enabled = false; + _providerLabel = 'disabled'; + return; + } + + final providerLabel = _resolveProviderLabel(); + if (providerLabel == null) { + _initialized = true; + _enabled = false; + _providerLabel = 'unsupported'; + return; + } + + try { + await _appCheck.activate( + providerAndroid: _androidProvider, + providerApple: _appleProvider, + ); + await _setTokenAutoRefreshEnabled(true); + + _initialized = true; + _enabled = true; + _providerLabel = providerLabel; + } catch (error) { + _initialized = true; + _enabled = false; + _providerLabel = 'failed'; + if (kDebugMode) { + debugPrint('FirebaseAppCheck initialize failed: $error'); + } + } + } + + Future setEnabled(bool enabled) async { + if (!_initialized) { + await initialize(enabled: enabled); + return; + } + + if (enabled == _enabled) { + await _setTokenAutoRefreshEnabled(enabled); + return; + } + + if (enabled) { + await initialize(enabled: true); + return; + } + + await _setTokenAutoRefreshEnabled(false); + _enabled = false; + _providerLabel = 'disabled'; + } + + Future _setTokenAutoRefreshEnabled(bool enabled) async { + try { + await _appCheck.setTokenAutoRefreshEnabled(enabled); + } catch (error) { + if (kDebugMode) { + debugPrint( + 'FirebaseAppCheck setTokenAutoRefreshEnabled($enabled) failed: ' + '$error', + ); + } + } + } + + String? _resolveProviderLabel() { + if (kIsWeb) { + return null; + } + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return kDebugMode ? 'android_debug' : 'android_play_integrity'; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return kDebugMode ? 'apple_debug' : 'apple_app_attest_fallback'; + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return null; + } + } + + AndroidAppCheckProvider get _androidProvider => kDebugMode + ? const AndroidDebugProvider() + : const AndroidPlayIntegrityProvider(); + + AppleAppCheckProvider get _appleProvider => kDebugMode + ? const AppleDebugProvider() + : const AppleAppAttestWithDeviceCheckFallbackProvider(); +} diff --git a/packages/integrations/firebase_services/pubspec.yaml b/packages/integrations/firebase_services/pubspec.yaml index dce4783..dedbe52 100644 --- a/packages/integrations/firebase_services/pubspec.yaml +++ b/packages/integrations/firebase_services/pubspec.yaml @@ -1,5 +1,5 @@ name: app_firebase -description: "Firebase integration services for Remote Config and Performance." +description: "Firebase integration services for Runtime Config, Performance, and App Check." version: 1.0.0 publish_to: none resolution: workspace @@ -9,6 +9,7 @@ environment: flutter: ">=1.17.0" dependencies: + firebase_app_check: ^0.4.1+2 firebase_core: ^4.4.0 firebase_performance: ^0.11.0+9 firebase_remote_config: ^6.1.1 From 26b7c31537b008f02d59313ff08048718f40a648 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Mon, 23 Feb 2026 10:35:14 +0630 Subject: [PATCH 28/31] feat: implement Firebase Cloud Messaging for push notifications with preference management and platform-specific configurations. --- README.md | 2 +- .../android/app/src/main/AndroidManifest.xml | 1 + .../ios/Runner.xcodeproj/project.pbxproj | 5 + apps/mobile/ios/Runner/Info.plist | 5 + apps/mobile/ios/Runner/Runner.entitlements | 8 + apps/mobile/lib/app.dart | 9 +- .../lib/core/bootstrap/app_bootstrap.dart | 1 + .../firebase_services_bootstrap.dart | 12 + .../firebase/firebase_runtime_config.dart | 3 + .../push_notification_bridge.dart | 209 +++++++++ .../observability/operational_telemetry.dart | 64 +++ apps/mobile/lib/core/providers/providers.dart | 1 + .../push_notifications_provider.dart | 409 ++++++++++++++++++ .../presentation/views/onboarding_screen.dart | 58 ++- .../presentation/views/settings_screen.dart | 260 +++++++++++ .../Flutter/GeneratedPluginRegistrant.swift | 2 + documentation/FIREBASE_AND_FLAGS.md | 15 +- documentation/SETUP_AND_RUN.md | 1 + .../firebase_services/lib/app_firebase.dart | 3 + .../models/firebase_messaging_message.dart | 15 + .../firebase_messaging_permission_status.dart | 14 + .../services/firebase_messaging_service.dart | 279 ++++++++++++ .../firebase_services/pubspec.yaml | 3 +- 23 files changed, 1370 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/ios/Runner/Runner.entitlements create mode 100644 apps/mobile/lib/core/notifications/push_notification_bridge.dart create mode 100644 apps/mobile/lib/core/providers/push_notifications_provider.dart create mode 100644 packages/integrations/firebase_services/lib/src/models/firebase_messaging_message.dart create mode 100644 packages/integrations/firebase_services/lib/src/models/firebase_messaging_permission_status.dart create mode 100644 packages/integrations/firebase_services/lib/src/services/firebase_messaging_service.dart diff --git a/README.md b/README.md index acdbc4c..99c747a 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ This repository is actively developed and already includes: - 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 +- Firebase Crashlytics, Analytics (consent-based), Performance, Remote Config, App Check, FCM - Optional backend auth and sync (feature-flag gated) For a detailed status matrix, see [documentation/APP_PROGRESS.md](documentation/APP_PROGRESS.md). 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 27fc4d5..1d79e03 100644 --- a/apps/mobile/lib/app.dart +++ b/apps/mobile/lib/app.dart @@ -1,5 +1,6 @@ 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'; @@ -58,9 +59,11 @@ class CollectionTrackerApp extends ConsumerWidget { return AnnotatedRegion( value: overlay, - child: SyncAutoRetryOnResume( - child: FirebaseRuntimeConfigAutoRefresh( - child: child ?? const SizedBox.shrink(), + child: PushNotificationBridge( + child: SyncAutoRetryOnResume( + child: FirebaseRuntimeConfigAutoRefresh( + child: child ?? const SizedBox.shrink(), + ), ), ), ); diff --git a/apps/mobile/lib/core/bootstrap/app_bootstrap.dart b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart index caff064..acd0c86 100644 --- a/apps/mobile/lib/core/bootstrap/app_bootstrap.dart +++ b/apps/mobile/lib/core/bootstrap/app_bootstrap.dart @@ -39,6 +39,7 @@ abstract final class AppBootstrap { crashlyticsEnabled: firebaseRuntimeConfig.crashlyticsCollectionEnabled, performanceEnabled: firebaseRuntimeConfig.performanceCollectionEnabled, appCheckEnabled: firebaseRuntimeConfig.appCheckEnabled, + fcmEnabled: firebaseRuntimeConfig.fcmEnabled, backendEnabled: firebaseRuntimeConfig.backendIntegrationEnabled, authEnabled: firebaseRuntimeConfig.authFeatureEnabled, syncEnabled: firebaseRuntimeConfig.syncFeatureEnabled, diff --git a/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart index e4f9714..20ee3cc 100644 --- a/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart +++ b/apps/mobile/lib/core/bootstrap/firebase_services_bootstrap.dart @@ -26,6 +26,7 @@ abstract final class FirebaseServicesBootstrap { 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'; @@ -40,6 +41,7 @@ abstract final class FirebaseServicesBootstrap { _crashlyticsCollectionEnabledKey: true, _performanceCollectionEnabledKey: true, _appCheckEnabledKey: false, + _fcmEnabledKey: false, _backendIntegrationEnabledKey: false, _authFeatureEnabledKey: true, _syncFeatureEnabledKey: false, @@ -54,6 +56,9 @@ abstract final class FirebaseServicesBootstrap { await FirebaseAppCheckService.instance.initialize( enabled: runtimeConfig.appCheckEnabled, ); + await FirebaseMessagingService.instance.initialize( + enabled: runtimeConfig.fcmEnabled, + ); await FirebasePerformanceService.instance.initialize( enabled: runtimeConfig.performanceCollectionEnabled, ); @@ -63,6 +68,7 @@ abstract final class FirebaseServicesBootstrap { 'crashlytics: ${runtimeConfig.crashlyticsCollectionEnabled}, ' 'performance: ${runtimeConfig.performanceCollectionEnabled}, ' 'appCheck: ${runtimeConfig.appCheckEnabled}, ' + 'fcm: ${runtimeConfig.fcmEnabled}, ' 'backend: ${runtimeConfig.backendIntegrationEnabled}, ' 'auth: ${runtimeConfig.authFeatureEnabled}, ' 'sync: ${runtimeConfig.syncFeatureEnabled}).', @@ -92,6 +98,9 @@ abstract final class FirebaseServicesBootstrap { await FirebaseAppCheckService.instance.setEnabled( runtimeConfig.appCheckEnabled, ); + await FirebaseMessagingService.instance.setEnabled( + runtimeConfig.fcmEnabled, + ); await FirebasePerformanceService.instance.setCollectionEnabled( runtimeConfig.performanceCollectionEnabled, ); @@ -105,6 +114,7 @@ abstract final class FirebaseServicesBootstrap { 'crashlytics: ${runtimeConfig.crashlyticsCollectionEnabled}, ' 'performance: ${runtimeConfig.performanceCollectionEnabled}, ' 'appCheck: ${runtimeConfig.appCheckEnabled}, ' + 'fcm: ${runtimeConfig.fcmEnabled}, ' 'backend: ${runtimeConfig.backendIntegrationEnabled}, ' 'auth: ${runtimeConfig.authFeatureEnabled}, ' 'sync: ${runtimeConfig.syncFeatureEnabled}).', @@ -115,6 +125,7 @@ abstract final class FirebaseServicesBootstrap { crashlyticsEnabled: runtimeConfig.crashlyticsCollectionEnabled, performanceEnabled: runtimeConfig.performanceCollectionEnabled, appCheckEnabled: runtimeConfig.appCheckEnabled, + fcmEnabled: runtimeConfig.fcmEnabled, backendEnabled: runtimeConfig.backendIntegrationEnabled, authEnabled: runtimeConfig.authFeatureEnabled, syncEnabled: runtimeConfig.syncFeatureEnabled, @@ -148,6 +159,7 @@ abstract final class FirebaseServicesBootstrap { _appCheckEnabledKey, fallback: false, ), + fcmEnabled: remoteConfigService.getBool(_fcmEnabledKey, fallback: false), backendIntegrationEnabled: remoteConfigService.getBool( _backendIntegrationEnabledKey, fallback: false, diff --git a/apps/mobile/lib/core/firebase/firebase_runtime_config.dart b/apps/mobile/lib/core/firebase/firebase_runtime_config.dart index 5c87b2f..ba53a07 100644 --- a/apps/mobile/lib/core/firebase/firebase_runtime_config.dart +++ b/apps/mobile/lib/core/firebase/firebase_runtime_config.dart @@ -4,6 +4,7 @@ class FirebaseRuntimeConfig { required this.crashlyticsCollectionEnabled, required this.performanceCollectionEnabled, required this.appCheckEnabled, + required this.fcmEnabled, required this.backendIntegrationEnabled, required this.authFeatureEnabled, required this.syncFeatureEnabled, @@ -14,6 +15,7 @@ class FirebaseRuntimeConfig { crashlyticsCollectionEnabled: true, performanceCollectionEnabled: true, appCheckEnabled: false, + fcmEnabled: false, backendIntegrationEnabled: false, authFeatureEnabled: true, syncFeatureEnabled: false, @@ -23,6 +25,7 @@ class FirebaseRuntimeConfig { 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/notifications/push_notification_bridge.dart b/apps/mobile/lib/core/notifications/push_notification_bridge.dart new file mode 100644 index 0000000..06c6786 --- /dev/null +++ b/apps/mobile/lib/core/notifications/push_notification_bridge.dart @@ -0,0 +1,209 @@ +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'; + +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; + bool _initialized = false; + + @override + void initState() { + super.initState(); + unawaited(_initializeMessagingListeners()); + } + + @override + void dispose() { + unawaited(_foregroundSubscription?.cancel()); + unawaited(_openedSubscription?.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; + _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, + ); + + if (!mounted) { + return; + } + + final content = message.title?.trim().isNotEmpty == true + ? message.title!.trim() + : (message.body?.trim().isNotEmpty == true + ? message.body!.trim() + : 'Notification received'); + + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) { + return; + } + + messenger.showSnackBar( + SnackBar( + content: Text(content), + action: route == null + ? null + : SnackBarAction( + label: 'Open', + onPressed: () => _navigateToRoute(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 index 41f6687..dcbaa1c 100644 --- a/apps/mobile/lib/core/observability/operational_telemetry.dart +++ b/apps/mobile/lib/core/observability/operational_telemetry.dart @@ -152,6 +152,7 @@ class OperationalTelemetry { required bool crashlyticsEnabled, required bool performanceEnabled, required bool appCheckEnabled, + required bool fcmEnabled, required bool backendEnabled, required bool authEnabled, required bool syncEnabled, @@ -166,6 +167,7 @@ class OperationalTelemetry { 'crashlytics_enabled': crashlyticsEnabled, 'performance_enabled': performanceEnabled, 'app_check_enabled': appCheckEnabled, + 'fcm_enabled': fcmEnabled, 'backend_enabled': backendEnabled, 'auth_enabled': authEnabled, 'sync_enabled': syncEnabled, @@ -177,6 +179,7 @@ class OperationalTelemetry { '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, @@ -184,6 +187,67 @@ class OperationalTelemetry { ); } + 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, diff --git a/apps/mobile/lib/core/providers/providers.dart b/apps/mobile/lib/core/providers/providers.dart index d6e8693..b8512ee 100644 --- a/apps/mobile/lib/core/providers/providers.dart +++ b/apps/mobile/lib/core/providers/providers.dart @@ -11,6 +11,7 @@ export 'theme_provider.dart'; export 'items_view_mode_provider.dart'; export 'collections_view_mode_provider.dart'; export 'locale_provider.dart'; +export 'push_notifications_provider.dart'; export 'sync_providers.dart'; final onboardingCompleteProvider = Provider((ref) => false); diff --git a/apps/mobile/lib/core/providers/push_notifications_provider.dart b/apps/mobile/lib/core/providers/push_notifications_provider.dart new file mode 100644 index 0000000..d126d80 --- /dev/null +++ b/apps/mobile/lib/core/providers/push_notifications_provider.dart @@ -0,0 +1,409 @@ +import 'dart:async'; + +import 'package:app_firebase/app_firebase.dart'; +import 'package:app_logger/app_logger.dart'; +import 'package:auth_session/auth_session.dart'; +import 'package:collection_tracker/core/firebase/firebase_runtime_config.dart'; +import 'package:collection_tracker/core/observability/operational_telemetry.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:storage/storage.dart'; + +import 'auth_session_providers.dart'; +import 'firebase_runtime_config_provider.dart'; + +enum PushNotificationTopic { + syncNeeded, + priceAlerts, + reminders, + accountSecurity, +} + +extension PushNotificationTopicX on PushNotificationTopic { + String get topicName => switch (this) { + PushNotificationTopic.syncNeeded => 'sync_needed', + PushNotificationTopic.priceAlerts => 'price_alerts', + PushNotificationTopic.reminders => 'reminders', + PushNotificationTopic.accountSecurity => 'account_security', + }; +} + +class PushNotificationPreferencesState { + static const Object _unset = Object(); + + const PushNotificationPreferencesState({ + required this.preferenceEnabled, + required this.syncNeededEnabled, + required this.priceAlertsEnabled, + required this.remindersEnabled, + required this.accountSecurityEnabled, + required this.runtimeFeatureEnabled, + required this.permissionStatus, + required this.deviceToken, + required this.apnsToken, + required this.isApplying, + }); + + final bool preferenceEnabled; + final bool syncNeededEnabled; + final bool priceAlertsEnabled; + final bool remindersEnabled; + final bool accountSecurityEnabled; + final bool runtimeFeatureEnabled; + final FirebaseMessagingPermissionStatus permissionStatus; + final String? deviceToken; + final String? apnsToken; + final bool isApplying; + + bool get isEffectivelyEnabled => + runtimeFeatureEnabled && preferenceEnabled && permissionStatus.isGranted; + + PushNotificationPreferencesState copyWith({ + bool? preferenceEnabled, + bool? syncNeededEnabled, + bool? priceAlertsEnabled, + bool? remindersEnabled, + bool? accountSecurityEnabled, + bool? runtimeFeatureEnabled, + FirebaseMessagingPermissionStatus? permissionStatus, + Object? deviceToken = _unset, + Object? apnsToken = _unset, + bool? isApplying, + }) { + return PushNotificationPreferencesState( + preferenceEnabled: preferenceEnabled ?? this.preferenceEnabled, + syncNeededEnabled: syncNeededEnabled ?? this.syncNeededEnabled, + priceAlertsEnabled: priceAlertsEnabled ?? this.priceAlertsEnabled, + remindersEnabled: remindersEnabled ?? this.remindersEnabled, + accountSecurityEnabled: + accountSecurityEnabled ?? this.accountSecurityEnabled, + runtimeFeatureEnabled: + runtimeFeatureEnabled ?? this.runtimeFeatureEnabled, + permissionStatus: permissionStatus ?? this.permissionStatus, + deviceToken: identical(deviceToken, _unset) + ? this.deviceToken + : deviceToken as String?, + apnsToken: identical(apnsToken, _unset) + ? this.apnsToken + : apnsToken as String?, + isApplying: isApplying ?? this.isApplying, + ); + } +} + +final pushNotificationPreferencesProvider = + NotifierProvider< + PushNotificationPreferencesController, + PushNotificationPreferencesState + >(PushNotificationPreferencesController.new); + +class PushNotificationPreferencesController + extends Notifier { + static const _enabledKey = 'push_notifications_enabled'; + static const _topicSyncKey = 'push_topic_sync_needed'; + static const _topicPriceKey = 'push_topic_price_alerts'; + static const _topicReminderKey = 'push_topic_reminders'; + static const _topicSecurityKey = 'push_topic_account_security'; + static const _tokenKey = 'push_device_token'; + static const _apnsTokenKey = 'push_apns_token'; + + late final PrefsStorageService _prefs; + StreamSubscription? _tokenRefreshSubscription; + Set _subscribedTopics = {}; + bool _bootstrapped = false; + + @override + PushNotificationPreferencesState build() { + _prefs = PrefsStorageService.instance; + + ref.listen(firebaseRuntimeConfigProvider, ( + previous, + next, + ) { + if (previous?.fcmEnabled == next.fcmEnabled) { + return; + } + unawaited( + _applyMessagingConfiguration( + runtimeFeatureEnabled: next.fcmEnabled, + reason: 'runtime_config', + ), + ); + }); + + ref.listen>(authSessionProvider, (previous, next) { + final wasAuthenticated = previous?.value?.isAuthenticated ?? false; + final isAuthenticated = next.value?.isAuthenticated ?? false; + if (wasAuthenticated == isAuthenticated) { + return; + } + unawaited(_applyTopicSubscriptions(reason: 'auth_state_change')); + }); + + ref.onDispose(() async { + await _tokenRefreshSubscription?.cancel(); + }); + + final initial = PushNotificationPreferencesState( + preferenceEnabled: _prefs.readSync(_enabledKey) ?? false, + syncNeededEnabled: _prefs.readSync(_topicSyncKey) ?? true, + priceAlertsEnabled: _prefs.readSync(_topicPriceKey) ?? true, + remindersEnabled: _prefs.readSync(_topicReminderKey) ?? true, + accountSecurityEnabled: _prefs.readSync(_topicSecurityKey) ?? true, + runtimeFeatureEnabled: ref.read(firebaseRuntimeConfigProvider).fcmEnabled, + permissionStatus: FirebaseMessagingPermissionStatus.notDetermined, + deviceToken: _prefs.readSync(_tokenKey), + apnsToken: _prefs.readSync(_apnsTokenKey), + isApplying: false, + ); + + if (!_bootstrapped) { + _bootstrapped = true; + unawaited(_bootstrap()); + } + + return initial; + } + + Future setPreferenceEnabled(bool enabled) async { + var permission = state.permissionStatus; + if (enabled && !permission.isGranted) { + permission = await FirebaseMessagingService.instance.requestPermission(); + if (!ref.mounted) { + return; + } + + state = state.copyWith(permissionStatus: permission); + await OperationalTelemetry.trackPushPermission( + status: permission.name, + preferenceEnabled: enabled, + runtimeEnabled: state.runtimeFeatureEnabled, + ); + } + + final canEnable = !enabled || permission.isGranted; + final nextEnabled = enabled && canEnable; + + await _prefs.save(_enabledKey, nextEnabled); + state = state.copyWith(preferenceEnabled: nextEnabled); + + await _applyMessagingConfiguration(reason: 'preference_toggle'); + } + + Future setTopicEnabled( + PushNotificationTopic topic, + bool enabled, + ) async { + final prefKey = switch (topic) { + PushNotificationTopic.syncNeeded => _topicSyncKey, + PushNotificationTopic.priceAlerts => _topicPriceKey, + PushNotificationTopic.reminders => _topicReminderKey, + PushNotificationTopic.accountSecurity => _topicSecurityKey, + }; + + await _prefs.save(prefKey, enabled); + + state = switch (topic) { + PushNotificationTopic.syncNeeded => state.copyWith( + syncNeededEnabled: enabled, + ), + PushNotificationTopic.priceAlerts => state.copyWith( + priceAlertsEnabled: enabled, + ), + PushNotificationTopic.reminders => state.copyWith( + remindersEnabled: enabled, + ), + PushNotificationTopic.accountSecurity => state.copyWith( + accountSecurityEnabled: enabled, + ), + }; + + await _applyTopicSubscriptions(reason: 'topic_toggle'); + } + + Future refreshPermissionStatus() async { + final permission = await FirebaseMessagingService.instance + .getPermissionStatus(); + if (!ref.mounted) { + return; + } + + state = state.copyWith(permissionStatus: permission); + await OperationalTelemetry.trackPushPermission( + status: permission.name, + preferenceEnabled: state.preferenceEnabled, + runtimeEnabled: state.runtimeFeatureEnabled, + ); + await _refreshApnsTokenStatus(); + await _applyMessagingConfiguration(reason: 'permission_refresh'); + } + + Future handleTokenUpdated(String token, {String source = 'stream'}) { + return _handleTokenUpdate(token, source: source); + } + + Future _bootstrap() async { + _tokenRefreshSubscription = FirebaseMessagingService.instance.onTokenRefresh + .listen((token) { + unawaited(_handleTokenUpdate(token, source: 'refresh_stream')); + }); + + final permission = await FirebaseMessagingService.instance + .getPermissionStatus(); + if (!ref.mounted) { + return; + } + + state = state.copyWith(permissionStatus: permission); + await OperationalTelemetry.trackPushPermission( + status: permission.name, + preferenceEnabled: state.preferenceEnabled, + runtimeEnabled: state.runtimeFeatureEnabled, + ); + await _refreshApnsTokenStatus(); + + await _applyMessagingConfiguration(reason: 'bootstrap'); + + final token = await FirebaseMessagingService.instance.getToken(); + if (!ref.mounted || token == null || token.trim().isEmpty) { + return; + } + await _handleTokenUpdate(token, source: 'bootstrap'); + } + + Future _applyMessagingConfiguration({ + bool? runtimeFeatureEnabled, + required String reason, + }) async { + final runtimeEnabled = + runtimeFeatureEnabled ?? + ref.read(firebaseRuntimeConfigProvider).fcmEnabled; + final effectiveEnabled = + runtimeEnabled && + state.preferenceEnabled && + state.permissionStatus.isGranted; + + state = state.copyWith( + runtimeFeatureEnabled: runtimeEnabled, + isApplying: true, + ); + + await FirebaseMessagingService.instance.setEnabled(effectiveEnabled); + + if (!ref.mounted) { + return; + } + + if (!effectiveEnabled) { + await _unsubscribeAllTopics(); + state = state.copyWith(isApplying: false); + Logger.info( + 'Push notifications disabled (runtime=$runtimeEnabled, ' + 'preference=${state.preferenceEnabled}, ' + 'permission=${state.permissionStatus.name}, reason=$reason).', + ); + return; + } + + await _refreshApnsTokenStatus(); + await _applyTopicSubscriptions(reason: reason); + + final token = await FirebaseMessagingService.instance.getToken(); + if (!ref.mounted) { + return; + } + if (token != null && token.trim().isNotEmpty) { + await _handleTokenUpdate(token, source: 'config_apply'); + } + + state = state.copyWith(isApplying: false); + } + + Future _refreshApnsTokenStatus() async { + final apnsToken = await FirebaseMessagingService.instance.getApnsToken(); + if (!ref.mounted) { + return; + } + + final sanitized = apnsToken?.trim(); + if (sanitized != null && sanitized.isNotEmpty) { + await _prefs.save(_apnsTokenKey, sanitized); + state = state.copyWith(apnsToken: sanitized); + return; + } + + state = state.copyWith(apnsToken: null); + } + + Future _applyTopicSubscriptions({required String reason}) async { + if (!state.isEffectivelyEnabled) { + await _unsubscribeAllTopics(); + if (ref.mounted) { + state = state.copyWith(isApplying: false); + } + return; + } + + final isAuthenticated = + ref.read(authSessionProvider).value?.isAuthenticated ?? false; + final desiredTopics = { + if (state.syncNeededEnabled) PushNotificationTopic.syncNeeded.topicName, + if (state.priceAlertsEnabled) PushNotificationTopic.priceAlerts.topicName, + if (state.remindersEnabled) PushNotificationTopic.reminders.topicName, + if (state.accountSecurityEnabled && isAuthenticated) + PushNotificationTopic.accountSecurity.topicName, + }; + + final topicsToUnsubscribe = _subscribedTopics.difference(desiredTopics); + for (final topic in topicsToUnsubscribe) { + await FirebaseMessagingService.instance.unsubscribeFromTopic(topic); + } + + final topicsToSubscribe = desiredTopics.difference(_subscribedTopics); + for (final topic in topicsToSubscribe) { + await FirebaseMessagingService.instance.subscribeToTopic(topic); + } + + _subscribedTopics = desiredTopics; + if (ref.mounted) { + state = state.copyWith(isApplying: false); + } + Logger.info( + 'Push topic subscriptions updated (${_subscribedTopics.join(",")}) ' + '(reason=$reason).', + ); + } + + Future _unsubscribeAllTopics() async { + if (_subscribedTopics.isEmpty) { + _subscribedTopics = {}; + return; + } + + for (final topic in _subscribedTopics) { + await FirebaseMessagingService.instance.unsubscribeFromTopic(topic); + } + _subscribedTopics = {}; + } + + Future _handleTokenUpdate( + String token, { + required String source, + }) async { + final sanitized = token.trim(); + if (sanitized.isEmpty) { + return; + } + + await _prefs.save(_tokenKey, sanitized); + + if (!ref.mounted) { + return; + } + + state = state.copyWith(deviceToken: sanitized); + await OperationalTelemetry.trackPushTokenUpdate( + hasToken: true, + source: source, + ); + } +} diff --git a/apps/mobile/lib/features/onboarding/presentation/views/onboarding_screen.dart b/apps/mobile/lib/features/onboarding/presentation/views/onboarding_screen.dart index 8fa9504..25c5e97 100644 --- a/apps/mobile/lib/features/onboarding/presentation/views/onboarding_screen.dart +++ b/apps/mobile/lib/features/onboarding/presentation/views/onboarding_screen.dart @@ -1,16 +1,20 @@ import 'package:app_analytics/app_analytics.dart'; +import 'package:app_firebase/app_firebase.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:storage/storage.dart'; -class OnboardingScreen extends StatefulWidget { +import '../../../../core/providers/push_notifications_provider.dart'; + +class OnboardingScreen extends ConsumerStatefulWidget { const OnboardingScreen({super.key}); @override - State createState() => _OnboardingScreenState(); + ConsumerState createState() => _OnboardingScreenState(); } -class _OnboardingScreenState extends State { +class _OnboardingScreenState extends ConsumerState { final PageController _pageController = PageController(); int _currentPage = 0; @@ -66,11 +70,59 @@ class _OnboardingScreenState extends State { AnalyticsEvent.custom(name: 'onboarding_completed'), ); + await _maybeShowNotificationPrePrompt(); + if (mounted) { context.go('/collections'); } } + Future _maybeShowNotificationPrePrompt() async { + final preferences = ref.read(pushNotificationPreferencesProvider); + if (!preferences.runtimeFeatureEnabled) { + return; + } + if (preferences.preferenceEnabled || + preferences.permissionStatus.isGranted) { + return; + } + + final shouldEnable = await showDialog( + context: context, + barrierDismissible: true, + builder: (context) { + final theme = Theme.of(context); + return AlertDialog( + title: const Text('Stay in sync'), + content: const Text( + 'Enable notifications for sync-needed updates, price alerts, reminders, and account security events.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Not now'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text( + 'Enable', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onPrimary, + ), + ), + ), + ], + ); + }, + ); + + if (shouldEnable == true && mounted) { + await ref + .read(pushNotificationPreferencesProvider.notifier) + .setPreferenceEnabled(true); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart index 13f0e5b..c735402 100644 --- a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart +++ b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart @@ -1,5 +1,6 @@ import 'package:app_logger/app_logger.dart'; import 'package:auth_session/auth_session.dart'; +import 'package:app_firebase/app_firebase.dart'; import 'package:collection_tracker/core/analytics/analytics_consent_dialog.dart'; import 'package:collection_tracker/core/analytics/analytics_preferences.dart'; import 'package:collection_tracker/core/firebase/firebase_runtime_config.dart'; @@ -27,6 +28,7 @@ class SettingsScreen extends ConsumerWidget { final currentLanguage = ref.watch(localeSettingsProvider); final analyticsPreferences = ref.watch(analyticsPreferencesProvider); final firebaseRuntimeConfig = ref.watch(firebaseRuntimeConfigProvider); + final pushPreferences = ref.watch(pushNotificationPreferencesProvider); final syncReadiness = ref.watch(syncReadinessProvider); final accountReadiness = ref.watch(backendAuthReadinessProvider); final pendingSyncCount = ref.watch(syncOutboxCountProvider).value ?? 0; @@ -41,6 +43,7 @@ class SettingsScreen extends ConsumerWidget { syncReadiness, pendingSyncCount: pendingSyncCount, ); + final pushSummary = _pushNotificationSummary(pushPreferences); final cloudSyncFeatureEnabled = syncReadiness.status != SyncReadinessStatus.disabledByFeatureFlag; final firebaseRuntimeSummary = _firebaseRuntimeSummary( @@ -89,6 +92,12 @@ class SettingsScreen extends ConsumerWidget { ? () => context.push(Routes.auth) : null, ), + _SettingsTile( + icon: Icons.notifications_outlined, + title: 'Push Notifications', + subtitle: pushSummary, + onTap: () => _showPushNotificationSettings(context, ref), + ), ], ), ), @@ -1130,6 +1139,199 @@ class SettingsScreen extends ConsumerWidget { ); } + Future _showPushNotificationSettings( + BuildContext context, + WidgetRef ref, + ) async { + await showAppSheet( + context: context, + builder: (context) { + return Consumer( + builder: (context, ref, _) { + final preferences = ref.watch(pushNotificationPreferencesProvider); + final notifier = ref.read( + pushNotificationPreferencesProvider.notifier, + ); + + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Push Notifications', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + preferences.runtimeFeatureEnabled + ? 'Manage sync-needed, price alert, reminder, and account security notifications.' + : 'Push notifications are currently disabled by runtime feature flag.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.md), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Enable push notifications'), + subtitle: Text( + _pushPermissionLabel(preferences.permissionStatus), + ), + value: preferences.preferenceEnabled, + onChanged: preferences.runtimeFeatureEnabled + ? (value) async { + await notifier.setPreferenceEnabled(value); + if (!context.mounted) { + return; + } + final updated = ref.read( + pushNotificationPreferencesProvider, + ); + if (value && !updated.preferenceEnabled) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Notification permission is not granted.', + ), + ), + ); + } + } + : null, + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.tune_outlined), + title: const Text('Feature flag'), + subtitle: const Text('app_fcm_enabled'), + trailing: Text( + _enabledDisabledLabel( + context, + preferences.runtimeFeatureEnabled, + ), + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.perm_device_information_outlined), + title: const Text('Permission'), + subtitle: Text( + _pushPermissionLabel(preferences.permissionStatus), + ), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Sync needed'), + subtitle: const Text('Topic: sync_needed'), + value: preferences.syncNeededEnabled, + onChanged: preferences.isEffectivelyEnabled + ? (value) { + notifier.setTopicEnabled( + PushNotificationTopic.syncNeeded, + value, + ); + } + : null, + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Price alerts'), + subtitle: const Text('Topic: price_alerts'), + value: preferences.priceAlertsEnabled, + onChanged: preferences.isEffectivelyEnabled + ? (value) { + notifier.setTopicEnabled( + PushNotificationTopic.priceAlerts, + value, + ); + } + : null, + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Reminders'), + subtitle: const Text('Topic: reminders'), + value: preferences.remindersEnabled, + onChanged: preferences.isEffectivelyEnabled + ? (value) { + notifier.setTopicEnabled( + PushNotificationTopic.reminders, + value, + ); + } + : null, + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Account security'), + subtitle: const Text('Topic: account_security'), + value: preferences.accountSecurityEnabled, + onChanged: preferences.isEffectivelyEnabled + ? (value) { + notifier.setTopicEnabled( + PushNotificationTopic.accountSecurity, + value, + ); + } + : null, + ), + if (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.phone_iphone_outlined), + title: const Text('APNs token'), + subtitle: Text( + _apnsTokenLabel(preferences.apnsToken), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.vpn_key_outlined), + title: const Text('Device token'), + subtitle: Text( + preferences.deviceToken != null && + preferences.deviceToken!.trim().isNotEmpty + ? _truncateToken(preferences.deviceToken!) + : 'Not available yet', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (defaultTargetPlatform == TargetPlatform.iOS && + (preferences.apnsToken == null || + preferences.apnsToken!.trim().isEmpty)) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.info_outline), + title: const Text('iOS Simulator note'), + subtitle: Text( + 'APNs token is often unavailable on simulator. Test FCM delivery on a physical iPhone.', + ), + ), + const SizedBox(height: AppSpacing.sm), + AppButton( + label: preferences.isApplying + ? 'Applying...' + : 'Refresh permission status', + onPressed: preferences.isApplying + ? null + : () => notifier.refreshPermissionStatus(), + ), + ], + ), + ); + }, + ); + }, + ); + } + Future _showFirebaseRuntimeConfigSheet( BuildContext context, WidgetRef ref, @@ -1222,6 +1424,15 @@ class SettingsScreen extends ConsumerWidget { ), ), ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.notifications_active_outlined), + title: const Text('Push notifications'), + subtitle: const Text('app_fcm_enabled'), + trailing: Text( + _enabledDisabledLabel(context, runtimeConfig.fcmEnabled), + ), + ), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.hub_outlined), @@ -1485,6 +1696,55 @@ class SettingsScreen extends ConsumerWidget { }; } + String _pushNotificationSummary( + PushNotificationPreferencesState preferences, + ) { + if (!preferences.runtimeFeatureEnabled) { + return 'Feature disabled'; + } + if (!preferences.preferenceEnabled) { + return 'Disabled'; + } + if (!preferences.permissionStatus.isGranted) { + return 'Permission required'; + } + + final enabledTopics = [ + preferences.syncNeededEnabled, + preferences.priceAlertsEnabled, + preferences.remindersEnabled, + preferences.accountSecurityEnabled, + ].where((enabled) => enabled).length; + + return 'Enabled ($enabledTopics topics)'; + } + + String _pushPermissionLabel(FirebaseMessagingPermissionStatus status) { + return switch (status) { + FirebaseMessagingPermissionStatus.notDetermined => 'Not determined', + FirebaseMessagingPermissionStatus.denied => 'Denied', + FirebaseMessagingPermissionStatus.authorized => 'Authorized', + FirebaseMessagingPermissionStatus.provisional => 'Provisional', + FirebaseMessagingPermissionStatus.unsupported => 'Unsupported', + }; + } + + String _truncateToken(String token) { + final sanitized = token.trim(); + if (sanitized.length <= 18) { + return sanitized; + } + return '${sanitized.substring(0, 10)}...${sanitized.substring(sanitized.length - 8)}'; + } + + String _apnsTokenLabel(String? apnsToken) { + final sanitized = apnsToken?.trim() ?? ''; + if (sanitized.isEmpty) { + return 'Not available'; + } + return _truncateToken(sanitized); + } + String _firebaseRuntimeSummary( BuildContext context, FirebaseRuntimeConfig config, diff --git a/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift index 636b33e..0fe353b 100644 --- a/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift @@ -13,6 +13,7 @@ import firebase_analytics import firebase_app_check import firebase_core import firebase_crashlytics +import firebase_messaging import firebase_remote_config import flutter_secure_storage_darwin import mobile_scanner @@ -32,6 +33,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCrashlyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCrashlyticsPlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FirebaseRemoteConfigPlugin.register(with: registry.registrar(forPlugin: "FirebaseRemoteConfigPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) diff --git a/documentation/FIREBASE_AND_FLAGS.md b/documentation/FIREBASE_AND_FLAGS.md index 1151003..7cbe176 100644 --- a/documentation/FIREBASE_AND_FLAGS.md +++ b/documentation/FIREBASE_AND_FLAGS.md @@ -27,6 +27,7 @@ Runtime flags are read in `FirebaseServicesBootstrap`. | `app_crashlytics_collection_enabled` | `true` | Enables/disables Crashlytics collection | | `app_performance_collection_enabled` | `true` | Enables/disables Firebase Performance collection | | `app_app_check_enabled` | `false` | Enables/disables Firebase App Check activation | +| `app_fcm_enabled` | `false` | Enables/disables Firebase Cloud Messaging runtime activation | | `app_backend_integration_enabled` | `false` | Gates backend-auth integration paths | | `app_auth_feature_enabled` | `true` | Gates account authentication UI/service availability | | `app_sync_feature_enabled` | `false` | Gates sync transport and sync UI readiness | @@ -91,7 +92,19 @@ Crashlytics collection is also constrained by debug behavior: - Apple release: `AppleAppAttestWithDeviceCheckFallbackProvider` - On web/unsupported desktop platforms, App Check activation is skipped. -## 8. Troubleshooting Checklist +## 8. FCM Notes + +- FCM is activated from runtime config via `app_fcm_enabled`. +- User-level push preference is stored locally and can be toggled from settings. +- Topic subscriptions currently used: + - `sync_needed` + - `price_alerts` + - `reminders` + - `account_security` (subscribed only when authenticated) +- iOS simulator may not provide an APNs token for FCM delivery. Validate end-to-end push delivery on a physical iPhone. +- Android 13+ requires notification runtime permission; iOS requires APNs capability + notification permission. + +## 9. Troubleshooting Checklist If sync tile does not enable after toggling Remote Config keys: diff --git a/documentation/SETUP_AND_RUN.md b/documentation/SETUP_AND_RUN.md index 64d52ae..b30c204 100644 --- a/documentation/SETUP_AND_RUN.md +++ b/documentation/SETUP_AND_RUN.md @@ -82,6 +82,7 @@ By default, backend/sync features are runtime-flag disabled. Set these keys to `true` in Remote Config: - `app_app_check_enabled` (optional, for App Check) +- `app_fcm_enabled` (optional, for push notifications) - `app_backend_integration_enabled` - `app_auth_feature_enabled` - `app_sync_feature_enabled` diff --git a/packages/integrations/firebase_services/lib/app_firebase.dart b/packages/integrations/firebase_services/lib/app_firebase.dart index b3ba6f0..7290bfc 100644 --- a/packages/integrations/firebase_services/lib/app_firebase.dart +++ b/packages/integrations/firebase_services/lib/app_firebase.dart @@ -1,6 +1,9 @@ library; +export 'src/models/firebase_messaging_message.dart'; +export 'src/models/firebase_messaging_permission_status.dart'; export 'src/models/firebase_remote_config_status.dart'; export 'src/services/firebase_app_check_service.dart'; +export 'src/services/firebase_messaging_service.dart'; export 'src/services/firebase_performance_service.dart'; export 'src/services/firebase_remote_config_service.dart'; diff --git a/packages/integrations/firebase_services/lib/src/models/firebase_messaging_message.dart b/packages/integrations/firebase_services/lib/src/models/firebase_messaging_message.dart new file mode 100644 index 0000000..be8a286 --- /dev/null +++ b/packages/integrations/firebase_services/lib/src/models/firebase_messaging_message.dart @@ -0,0 +1,15 @@ +class FirebaseMessagingMessage { + const FirebaseMessagingMessage({ + required this.data, + this.messageId, + this.title, + this.body, + this.sentTime, + }); + + final String? messageId; + final String? title; + final String? body; + final DateTime? sentTime; + final Map data; +} diff --git a/packages/integrations/firebase_services/lib/src/models/firebase_messaging_permission_status.dart b/packages/integrations/firebase_services/lib/src/models/firebase_messaging_permission_status.dart new file mode 100644 index 0000000..ce8a656 --- /dev/null +++ b/packages/integrations/firebase_services/lib/src/models/firebase_messaging_permission_status.dart @@ -0,0 +1,14 @@ +enum FirebaseMessagingPermissionStatus { + notDetermined, + denied, + authorized, + provisional, + unsupported, +} + +extension FirebaseMessagingPermissionStatusX + on FirebaseMessagingPermissionStatus { + bool get isGranted => + this == FirebaseMessagingPermissionStatus.authorized || + this == FirebaseMessagingPermissionStatus.provisional; +} diff --git a/packages/integrations/firebase_services/lib/src/services/firebase_messaging_service.dart b/packages/integrations/firebase_services/lib/src/services/firebase_messaging_service.dart new file mode 100644 index 0000000..c7bcb65 --- /dev/null +++ b/packages/integrations/firebase_services/lib/src/services/firebase_messaging_service.dart @@ -0,0 +1,279 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; + +import '../models/firebase_messaging_message.dart'; +import '../models/firebase_messaging_permission_status.dart'; + +class FirebaseMessagingService { + FirebaseMessagingService._({FirebaseMessaging? messaging}) + : _messaging = messaging ?? FirebaseMessaging.instance; + + static FirebaseMessagingService? _instance; + + static FirebaseMessagingService get instance { + _instance ??= FirebaseMessagingService._(); + return _instance!; + } + + final FirebaseMessaging _messaging; + + bool _initialized = false; + bool _enabled = false; + + bool get isInitialized => _initialized; + bool get isEnabled => _enabled; + bool get isApplePlatform => _isApplePlatform; + + Stream get onMessage => FirebaseMessaging.onMessage + .map(_mapRemoteMessage) + .handleError((Object error, StackTrace stackTrace) { + if (kDebugMode) { + debugPrint('FirebaseMessaging onMessage stream error: $error'); + } + }); + + Stream get onMessageOpenedApp => FirebaseMessaging + .onMessageOpenedApp + .map(_mapRemoteMessage) + .handleError((Object error, StackTrace stackTrace) { + if (kDebugMode) { + debugPrint( + 'FirebaseMessaging onMessageOpenedApp stream error: $error', + ); + } + }); + + Stream get onTokenRefresh => + _messaging.onTokenRefresh.handleError((Object error) { + if (kDebugMode) { + debugPrint('FirebaseMessaging onTokenRefresh stream error: $error'); + } + }); + + Future initialize({required bool enabled}) async { + if (!_isSupported) { + _initialized = true; + _enabled = false; + return; + } + + try { + await _messaging.setAutoInitEnabled(enabled); + if (_isApplePlatform) { + await _messaging.setForegroundNotificationPresentationOptions( + alert: true, + badge: true, + sound: true, + ); + } + _initialized = true; + _enabled = enabled; + } catch (error) { + _initialized = true; + _enabled = false; + if (kDebugMode) { + debugPrint('FirebaseMessaging initialize failed: $error'); + } + } + } + + Future setEnabled(bool enabled) async { + if (!_isSupported) { + _enabled = false; + return; + } + + try { + await _messaging.setAutoInitEnabled(enabled); + _enabled = enabled; + if (!_initialized) { + _initialized = true; + } + } catch (error) { + if (kDebugMode) { + debugPrint('FirebaseMessaging setEnabled failed: $error'); + } + } + } + + Future requestPermission() async { + if (!_isSupported) { + return FirebaseMessagingPermissionStatus.unsupported; + } + + try { + final settings = await _messaging.requestPermission( + alert: true, + badge: true, + sound: true, + provisional: false, + ); + return _mapAuthorizationStatus(settings.authorizationStatus); + } catch (error) { + if (kDebugMode) { + debugPrint('FirebaseMessaging requestPermission failed: $error'); + } + return FirebaseMessagingPermissionStatus.denied; + } + } + + Future getPermissionStatus() async { + if (!_isSupported) { + return FirebaseMessagingPermissionStatus.unsupported; + } + + try { + final settings = await _messaging.getNotificationSettings(); + return _mapAuthorizationStatus(settings.authorizationStatus); + } catch (error) { + if (kDebugMode) { + debugPrint('FirebaseMessaging getPermissionStatus failed: $error'); + } + return FirebaseMessagingPermissionStatus.notDetermined; + } + } + + Future getToken() async { + if (!_isSupported || !_enabled) { + return null; + } + + if (_isApplePlatform) { + final apnsToken = await waitForApnsToken(); + if (apnsToken == null || apnsToken.trim().isEmpty) { + if (kDebugMode) { + debugPrint( + 'FirebaseMessaging getToken skipped: APNs token not available.', + ); + } + return null; + } + } + + try { + return await _messaging.getToken(); + } catch (error) { + if (kDebugMode) { + debugPrint('FirebaseMessaging getToken failed: $error'); + } + return null; + } + } + + Future getInitialMessage() async { + if (!_isSupported) { + return null; + } + + try { + final message = await _messaging.getInitialMessage(); + if (message == null) { + return null; + } + return _mapRemoteMessage(message); + } catch (error) { + if (kDebugMode) { + debugPrint('FirebaseMessaging getInitialMessage failed: $error'); + } + return null; + } + } + + Future getApnsToken() async { + if (!_isSupported || !_isApplePlatform) { + return null; + } + + try { + return await _messaging.getAPNSToken(); + } catch (error) { + if (kDebugMode) { + debugPrint('FirebaseMessaging getAPNSToken failed: $error'); + } + return null; + } + } + + Future waitForApnsToken({ + Duration timeout = const Duration(seconds: 8), + Duration pollInterval = const Duration(milliseconds: 250), + }) async { + if (!_isSupported || !_isApplePlatform) { + return null; + } + + final deadline = DateTime.now().add(timeout); + + while (DateTime.now().isBefore(deadline)) { + final token = await getApnsToken(); + if (token != null && token.trim().isNotEmpty) { + return token; + } + + await Future.delayed(pollInterval); + } + + return null; + } + + Future subscribeToTopic(String topic) async { + if (!_isSupported || !_enabled || topic.trim().isEmpty) { + return; + } + + try { + await _messaging.subscribeToTopic(topic.trim()); + } catch (error) { + if (kDebugMode) { + debugPrint('FirebaseMessaging subscribeToTopic($topic) failed: $error'); + } + } + } + + Future unsubscribeFromTopic(String topic) async { + if (!_isSupported || topic.trim().isEmpty) { + return; + } + + try { + await _messaging.unsubscribeFromTopic(topic.trim()); + } catch (error) { + if (kDebugMode) { + debugPrint( + 'FirebaseMessaging unsubscribeFromTopic($topic) failed: $error', + ); + } + } + } + + FirebaseMessagingMessage _mapRemoteMessage(RemoteMessage message) { + return FirebaseMessagingMessage( + messageId: message.messageId, + title: message.notification?.title, + body: message.notification?.body, + sentTime: message.sentTime, + data: message.data.map((key, value) => MapEntry(key, '$value')), + ); + } + + FirebaseMessagingPermissionStatus _mapAuthorizationStatus( + AuthorizationStatus status, + ) { + return switch (status) { + AuthorizationStatus.authorized => + FirebaseMessagingPermissionStatus.authorized, + AuthorizationStatus.denied => FirebaseMessagingPermissionStatus.denied, + AuthorizationStatus.provisional => + FirebaseMessagingPermissionStatus.provisional, + AuthorizationStatus.notDetermined => + FirebaseMessagingPermissionStatus.notDetermined, + }; + } + + bool get _isSupported => !kIsWeb && Firebase.apps.isNotEmpty; + + bool get _isApplePlatform => + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS; +} diff --git a/packages/integrations/firebase_services/pubspec.yaml b/packages/integrations/firebase_services/pubspec.yaml index dedbe52..035b79b 100644 --- a/packages/integrations/firebase_services/pubspec.yaml +++ b/packages/integrations/firebase_services/pubspec.yaml @@ -1,5 +1,5 @@ name: app_firebase -description: "Firebase integration services for Runtime Config, Performance, and App Check." +description: "Firebase integration services for Runtime Config, Performance, App Check, and Messaging." version: 1.0.0 publish_to: none resolution: workspace @@ -11,6 +11,7 @@ environment: dependencies: firebase_app_check: ^0.4.1+2 firebase_core: ^4.4.0 + firebase_messaging: ^16.0.2 firebase_performance: ^0.11.0+9 firebase_remote_config: ^6.1.1 flutter: From f7989b9eeb882e03b518a6d317497b13309dd3d6 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Mon, 23 Feb 2026 10:46:18 +0630 Subject: [PATCH 29/31] feat: Implement local notifications to handle foreground push messages and provide consistent user experience across platforms. --- apps/mobile/android/app/build.gradle.kts | 5 + .../local_notification_service.dart | 211 ++++++++++++++++++ .../push_notification_bridge.dart | 47 ++-- .../Flutter/GeneratedPluginRegistrant.swift | 2 + apps/mobile/pubspec.yaml | 1 + .../windows/flutter/generated_plugins.cmake | 1 + .../services/firebase_messaging_service.dart | 7 +- 7 files changed, 246 insertions(+), 28 deletions(-) create mode 100644 apps/mobile/lib/core/notifications/local_notification_service.dart diff --git a/apps/mobile/android/app/build.gradle.kts b/apps/mobile/android/app/build.gradle.kts index 5ca21fe..0d7a8c4 100644 --- a/apps/mobile/android/app/build.gradle.kts +++ b/apps/mobile/android/app/build.gradle.kts @@ -23,6 +23,7 @@ android { ndkVersion = flutter.ndkVersion compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -60,6 +61,10 @@ android { } } +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} + flutter { source = "../.." } 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 index 06c6786..a16947a 100644 --- a/apps/mobile/lib/core/notifications/push_notification_bridge.dart +++ b/apps/mobile/lib/core/notifications/push_notification_bridge.dart @@ -9,6 +9,8 @@ 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}); @@ -23,6 +25,7 @@ class _PushNotificationBridgeState extends ConsumerState { StreamSubscription? _foregroundSubscription; StreamSubscription? _openedSubscription; + StreamSubscription? _localNotificationTapSubscription; bool _initialized = false; @override @@ -35,6 +38,7 @@ class _PushNotificationBridgeState void dispose() { unawaited(_foregroundSubscription?.cancel()); unawaited(_openedSubscription?.cancel()); + unawaited(_localNotificationTapSubscription?.cancel()); super.dispose(); } @@ -52,6 +56,20 @@ class _PushNotificationBridgeState _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)); }); @@ -83,31 +101,10 @@ class _PushNotificationBridgeState hasRoute: route != null, ); - if (!mounted) { - return; - } - - final content = message.title?.trim().isNotEmpty == true - ? message.title!.trim() - : (message.body?.trim().isNotEmpty == true - ? message.body!.trim() - : 'Notification received'); - - final messenger = ScaffoldMessenger.maybeOf(context); - if (messenger == null) { - return; - } - - messenger.showSnackBar( - SnackBar( - content: Text(content), - action: route == null - ? null - : SnackBarAction( - label: 'Open', - onPressed: () => _navigateToRoute(route), - ), - ), + await LocalNotificationService.instance.showForegroundMessage( + message: message, + notificationType: notificationType, + route: route, ); } diff --git a/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift index 0fe353b..1c09eb2 100644 --- a/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift @@ -15,6 +15,7 @@ import firebase_core import firebase_crashlytics import firebase_messaging import firebase_remote_config +import flutter_local_notifications import flutter_secure_storage_darwin import mobile_scanner import package_info_plus @@ -35,6 +36,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseCrashlyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCrashlyticsPlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FirebaseRemoteConfigPlugin.register(with: registry.registrar(forPlugin: "FirebaseRemoteConfigPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index ccb7a84..d652bb1 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: firebase_core: ^4.4.0 firebase_analytics: ^12.1.2 firebase_crashlytics: ^5.0.7 + flutter_local_notifications: ^19.4.2 # Local workspace packages domain: diff --git a/apps/mobile/windows/flutter/generated_plugins.cmake b/apps/mobile/windows/flutter/generated_plugins.cmake index f77a4ba..e3ad812 100644 --- a/apps/mobile/windows/flutter/generated_plugins.cmake +++ b/apps/mobile/windows/flutter/generated_plugins.cmake @@ -14,6 +14,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/packages/integrations/firebase_services/lib/src/services/firebase_messaging_service.dart b/packages/integrations/firebase_services/lib/src/services/firebase_messaging_service.dart index c7bcb65..9e520b3 100644 --- a/packages/integrations/firebase_services/lib/src/services/firebase_messaging_service.dart +++ b/packages/integrations/firebase_services/lib/src/services/firebase_messaging_service.dart @@ -61,10 +61,11 @@ class FirebaseMessagingService { try { await _messaging.setAutoInitEnabled(enabled); if (_isApplePlatform) { + // Foreground alerts are shown via local notifications for consistent UX. await _messaging.setForegroundNotificationPresentationOptions( - alert: true, - badge: true, - sound: true, + alert: false, + badge: false, + sound: false, ); } _initialized = true; From 427531f3909402c8dc575d670f4fe66ae41a6176 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Mon, 23 Feb 2026 11:14:43 +0630 Subject: [PATCH 30/31] feat: Implement dedicated settings screens for DevTools and Notifications, introducing reusable settings UI primitives. --- apps/mobile/lib/core/router/app_router.dart | 14 + apps/mobile/lib/core/router/routes.dart | 2 + .../views/settings_devtools_screen.dart | 1218 +++++++++++ .../views/settings_notifications_screen.dart | 178 ++ .../presentation/views/settings_screen.dart | 1805 +++-------------- .../widgets/settings_primitives.dart | 210 ++ 6 files changed, 1904 insertions(+), 1523 deletions(-) create mode 100644 apps/mobile/lib/features/settings/presentation/views/settings_devtools_screen.dart create mode 100644 apps/mobile/lib/features/settings/presentation/views/settings_notifications_screen.dart create mode 100644 apps/mobile/lib/features/settings/presentation/widgets/settings_primitives.dart diff --git a/apps/mobile/lib/core/router/app_router.dart b/apps/mobile/lib/core/router/app_router.dart index c507626..52f9743 100644 --- a/apps/mobile/lib/core/router/app_router.dart +++ b/apps/mobile/lib/core/router/app_router.dart @@ -17,6 +17,8 @@ import '../../features/onboarding/presentation/views/onboarding_screen.dart'; import '../../features/scanner/presentation/views/scanner_screen.dart'; import '../../features/search/presentation/views/search_screen.dart'; import '../../features/settings/presentation/views/settings_screen.dart'; +import '../../features/settings/presentation/views/settings_devtools_screen.dart'; +import '../../features/settings/presentation/views/settings_notifications_screen.dart'; import '../../features/statistics/presentation/views/statistics_screen.dart'; import '../../features/items/presentation/views/tag_management_screen.dart'; import 'app_shell.dart'; @@ -155,6 +157,18 @@ GoRouter appRouter(Ref ref) { parentNavigatorKey: _rootNavigatorKey, builder: (_, _) => const TagManagementScreen(), ), + GoRoute( + path: 'notifications', + name: 'settings-notifications', + parentNavigatorKey: _rootNavigatorKey, + builder: (_, _) => const SettingsNotificationsScreen(), + ), + GoRoute( + path: 'devtools', + name: 'settings-devtools', + parentNavigatorKey: _rootNavigatorKey, + builder: (_, _) => const SettingsDevToolsScreen(), + ), ], ), ], diff --git a/apps/mobile/lib/core/router/routes.dart b/apps/mobile/lib/core/router/routes.dart index b8bcee4..4e74f1b 100644 --- a/apps/mobile/lib/core/router/routes.dart +++ b/apps/mobile/lib/core/router/routes.dart @@ -9,6 +9,8 @@ abstract final class Routes { static const statistics = '/statistics'; static const settings = '/settings'; static const settingsTags = '/settings/tags'; + static const settingsNotifications = '/settings/notifications'; + static const settingsDevtools = '/settings/devtools'; static const tagItems = '/tags/items'; // static String bookingWithId(int id) => '$booking/$id'; diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_devtools_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_devtools_screen.dart new file mode 100644 index 0000000..c8a3759 --- /dev/null +++ b/apps/mobile/lib/features/settings/presentation/views/settings_devtools_screen.dart @@ -0,0 +1,1218 @@ +import 'package:app_logger/app_logger.dart'; +import 'package:app_firebase/app_firebase.dart'; +import 'package:collection_tracker/core/firebase/firebase_runtime_config.dart'; +import 'package:collection_tracker/core/observability/operational_telemetry.dart'; +import 'package:collection_tracker/core/providers/providers.dart'; +import 'package:collection_tracker/l10n/l10n.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ui/ui.dart'; + +import '../widgets/settings_primitives.dart'; + +class SettingsDevToolsScreen extends ConsumerWidget { + const SettingsDevToolsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final runtimeConfig = ref.watch(firebaseRuntimeConfigProvider); + final runtimeSummary = _firebaseRuntimeSummary(context, runtimeConfig); + final pushPreferences = ref.watch(pushNotificationPreferencesProvider); + final pushSummary = _pushDiagnosticsSummary(pushPreferences); + + return Scaffold( + appBar: AppBar(title: Text('${l10n.settingsSectionDeveloper} Tools')), + body: ListView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + AppSpacing.xxl, + ), + children: [ + AppReveal(child: _FirebaseRuntimeHealthCard(config: runtimeConfig)), + const SizedBox(height: AppSpacing.lg), + AppReveal( + delay: AppMotion.stagger, + child: SettingsSection( + title: l10n.settingsSectionDeveloper, + children: [ + SettingsTile( + icon: Icons.settings_remote_outlined, + title: l10n.settingsFirebaseRuntimeConfigTitle, + subtitle: runtimeSummary, + onTap: () => _showFirebaseRuntimeConfigSheet(context, ref), + ), + SettingsTile( + icon: Icons.cloud_sync_outlined, + title: 'Cloud Sync Diagnostics', + subtitle: 'Inspect sync readiness, failures, and queue state', + onTap: () => _showCloudSyncDiagnosticsSheet(context, ref), + ), + SettingsTile( + icon: Icons.cloud_outlined, + title: 'Backend API Configuration', + subtitle: 'Set base URL override for local/staging backend', + onTap: () => _showSyncApiConfigurationHelp(context, ref), + ), + SettingsTile( + icon: Icons.notifications_active_outlined, + title: 'Push Diagnostics', + subtitle: pushSummary, + onTap: () => _showPushDiagnosticsSheet(context, ref), + ), + SettingsTile( + icon: Icons.monitor_heart_outlined, + title: 'Operational Telemetry', + subtitle: 'Inspect recent sync/data/runtime events', + onTap: () => _showOperationalTelemetrySheet(context, ref), + ), + SettingsTile( + icon: Icons.bug_report_outlined, + title: l10n.settingsCrashlyticsTestTitle, + subtitle: l10n.settingsCrashlyticsTestSubtitle, + onTap: () => _handleCrashlyticsTest(context), + ), + ], + ), + ), + if (!kDebugMode) ...[ + const SizedBox(height: AppSpacing.md), + AppReveal( + delay: AppMotion.stagger * 2, + child: AppCard( + child: Text( + 'DevTools are available in debug builds only.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ], + ], + ), + ); + } + + String _firebaseRuntimeSummary( + BuildContext context, + FirebaseRuntimeConfig config, + ) { + final enabledCount = [ + config.analyticsCollectionEnabled, + config.crashlyticsCollectionEnabled, + config.performanceCollectionEnabled, + ].where((value) => value).length; + return context.l10n.settingsFirebaseRuntimeConfigSummary(enabledCount); + } + + String _pushDiagnosticsSummary(PushNotificationPreferencesState preferences) { + if (!preferences.runtimeFeatureEnabled) { + return 'Feature disabled'; + } + final tokenStatus = preferences.deviceToken?.trim().isNotEmpty == true + ? 'Token available' + : 'Token unavailable'; + return '$tokenStatus • ${_pushPermissionLabel(preferences.permissionStatus)}'; + } + + Future _showPushDiagnosticsSheet( + BuildContext context, + WidgetRef ref, + ) async { + await showAppSheet( + context: context, + builder: (sheetContext) { + return Consumer( + builder: (sheetContext, ref, _) { + final preferences = ref.watch(pushNotificationPreferencesProvider); + final notifier = ref.read( + pushNotificationPreferencesProvider.notifier, + ); + + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Push Diagnostics', + style: Theme.of(sheetContext).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: AppSpacing.sm), + _CloudSyncStateRow( + label: 'Feature flag', + value: preferences.runtimeFeatureEnabled + ? 'Enabled' + : 'Disabled', + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Preference', + value: preferences.preferenceEnabled + ? 'Enabled' + : 'Disabled', + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Permission', + value: _pushPermissionLabel(preferences.permissionStatus), + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Sync topic', + value: preferences.syncNeededEnabled ? 'On' : 'Off', + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Price topic', + value: preferences.priceAlertsEnabled ? 'On' : 'Off', + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Reminder topic', + value: preferences.remindersEnabled ? 'On' : 'Off', + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Security topic', + value: preferences.accountSecurityEnabled ? 'On' : 'Off', + ), + if (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS) ...[ + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'APNs token', + value: _apnsTokenLabel(preferences.apnsToken), + ), + ], + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Device token', + value: + preferences.deviceToken != null && + preferences.deviceToken!.trim().isNotEmpty + ? _truncateToken(preferences.deviceToken!) + : 'Not available', + ), + if (defaultTargetPlatform == TargetPlatform.iOS && + (preferences.apnsToken == null || + preferences.apnsToken!.trim().isEmpty)) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + 'APNs token is often unavailable on simulator. Test on a physical iPhone.', + style: Theme.of(sheetContext).textTheme.bodySmall + ?.copyWith( + color: Theme.of( + sheetContext, + ).colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: AppSpacing.lg), + AppButton( + label: preferences.isApplying + ? 'Refreshing...' + : 'Refresh permission status', + onPressed: preferences.isApplying + ? null + : () => notifier.refreshPermissionStatus(), + ), + const SizedBox(height: AppSpacing.sm), + AppButton( + label: sheetContext.l10n.actionDismiss, + variant: AppButtonVariant.ghost, + onPressed: () => Navigator.of(sheetContext).pop(), + ), + ], + ), + ); + }, + ); + }, + ); + } + + Future _showSyncApiConfigurationHelp( + BuildContext context, + WidgetRef ref, + ) async { + final initialOverride = ref.read(backendApiBaseUrlOverrideProvider); + final effectiveUrl = ref.read(backendApiBaseUrlProvider); + final controller = TextEditingController(text: initialOverride); + + await showAppSheet( + context: context, + builder: (sheetContext) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Configure Backend API', + style: Theme.of( + sheetContext, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Enter backend API base URL used for sync and authentication.', + style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( + color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.md), + AppInput( + controller: controller, + labelText: 'Base URL override', + hintText: 'http://localhost:4000', + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Effective URL: $effectiveUrl', + style: Theme.of(sheetContext).textTheme.bodySmall?.copyWith( + color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.lg), + AppButton( + label: 'Save', + onPressed: () async { + await ref + .read(backendApiBaseUrlOverrideProvider.notifier) + .setBaseUrl(controller.text); + if (!sheetContext.mounted) { + return; + } + Navigator.of(sheetContext).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Backend API URL updated.')), + ); + }, + ), + const SizedBox(height: AppSpacing.sm), + AppButton( + label: 'Clear override', + variant: AppButtonVariant.secondary, + onPressed: () async { + await ref + .read(backendApiBaseUrlOverrideProvider.notifier) + .clear(); + if (!sheetContext.mounted) { + return; + } + Navigator.of(sheetContext).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Backend API URL override cleared.'), + ), + ); + }, + ), + const SizedBox(height: AppSpacing.sm), + AppButton( + label: sheetContext.l10n.actionDismiss, + variant: AppButtonVariant.ghost, + onPressed: () => Navigator.of(sheetContext).pop(), + ), + ], + ); + }, + ); + + controller.dispose(); + } + + Future _showCloudSyncDiagnosticsSheet( + BuildContext context, + WidgetRef ref, + ) async { + await showAppSheet( + context: context, + builder: (sheetContext) { + return Consumer( + builder: (sheetContext, ref, _) { + final readiness = ref.watch(syncReadinessProvider); + final transportConfig = ref.watch(syncTransportConfigProvider); + final pendingCount = + ref.watch(syncOutboxCountProvider).asData?.value ?? 0; + final syncState = ref.watch(syncStateProvider).asData?.value; + final envFlagOverridesActive = ref.watch( + backendDebugEnvFlagOverridesActiveProvider, + ); + final session = ref.watch(authSessionProvider).value; + final hasSession = session?.isAuthenticated ?? false; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Cloud Sync Diagnostics', + style: Theme.of(sheetContext).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppSpacing.sm), + _CloudSyncStateRow( + label: 'Readiness', + value: readiness.status.name, + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow(label: 'Message', value: readiness.message), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Backend flag', + value: transportConfig.backendFeatureEnabled + ? 'Enabled' + : 'Disabled', + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Sync flag', + value: transportConfig.syncFeatureEnabled + ? 'Enabled' + : 'Disabled', + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Auth flag', + value: transportConfig.authFeatureEnabled + ? 'Enabled' + : 'Disabled', + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Env overrides', + value: envFlagOverridesActive ? 'Active' : 'Inactive', + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Base URL', + value: transportConfig.baseUrl.isEmpty + ? 'Not configured' + : transportConfig.baseUrl, + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Resolved URL', + value: transportConfig.isApiBaseUrlConfigured + ? transportConfig.normalizedApiBaseUrl + : 'Not available', + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Outbox', + value: '$pendingCount pending', + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Failures', + value: '${syncState?.consecutiveFailures ?? 0}', + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Next retry', + value: _formatSyncRetryAt(syncState?.nextRetryAt), + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Auth', + value: hasSession ? 'Signed in' : 'Signed out', + ), + const SizedBox(height: AppSpacing.xs), + _CloudSyncStateRow( + label: 'Device', + value: session?.deviceId ?? 'Unavailable', + ), + const SizedBox(height: AppSpacing.lg), + AppButton( + label: 'Configure API', + onPressed: () async { + Navigator.of(sheetContext).pop(); + await _showSyncApiConfigurationHelp(context, ref); + }, + expand: true, + ), + const SizedBox(height: AppSpacing.sm), + AppButton( + label: 'Rebuild local sync queue', + variant: AppButtonVariant.secondary, + onPressed: () async { + Navigator.of(sheetContext).pop(); + await _rebuildSyncOutboxFromLocal(context, ref); + }, + expand: true, + ), + const SizedBox(height: AppSpacing.sm), + AppButton( + label: sheetContext.l10n.actionDismiss, + variant: AppButtonVariant.ghost, + onPressed: () => Navigator.of(sheetContext).pop(), + expand: true, + ), + ], + ); + }, + ); + }, + ); + } + + Future _rebuildSyncOutboxFromLocal( + BuildContext context, + WidgetRef ref, + ) async { + try { + final result = await ref + .read(syncOutboxBootstrapperProvider) + .rebuildFromLocalData(); + await OperationalTelemetry.trackSyncQueueRebuild( + success: true, + queuedOperations: result.totalOperations, + ); + + if (!context.mounted) { + return; + } + final message = result.totalOperations > 0 + ? 'Rebuilt queue with ${result.totalOperations} local change(s). Run Sync now.' + : 'No local data found to queue for sync.'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: result.totalOperations > 0 + ? Colors.green + : Colors.orange, + ), + ); + } catch (error, stackTrace) { + await OperationalTelemetry.trackSyncQueueRebuild( + success: false, + queuedOperations: 0, + error: error, + stackTrace: stackTrace, + ); + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to rebuild local sync queue: $error'), + backgroundColor: Colors.red, + ), + ); + } + } + + Future _showOperationalTelemetrySheet( + BuildContext context, + WidgetRef ref, + ) async { + await showAppSheet( + context: context, + builder: (sheetContext) { + final maxHeight = MediaQuery.sizeOf(sheetContext).height * 0.72; + return SizedBox( + height: maxHeight, + child: Consumer( + builder: (sheetContext, ref, _) { + final historyAsync = ref.watch( + operationalTelemetryHistoryProvider, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Operational Telemetry', + style: Theme.of(sheetContext).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Recent sync/data/runtime events captured locally for debug.', + style: Theme.of(sheetContext).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + sheetContext, + ).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.md), + Expanded( + child: historyAsync.when( + data: (entries) { + if (entries.isEmpty) { + return Center( + child: Text( + 'No telemetry events yet.', + style: Theme.of(sheetContext).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + sheetContext, + ).colorScheme.onSurfaceVariant, + ), + ), + ); + } + + return ListView.separated( + itemCount: entries.length, + separatorBuilder: (_, _) => + const SizedBox(height: AppSpacing.sm), + itemBuilder: (context, index) { + return _OperationalTelemetryEventTile( + entry: entries[index], + ); + }, + ); + }, + loading: () => + const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text( + 'Failed to load telemetry: $error', + textAlign: TextAlign.center, + ), + ), + ), + ), + const SizedBox(height: AppSpacing.md), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + AppButton( + label: 'Refresh', + variant: AppButtonVariant.secondary, + onPressed: () => + refreshOperationalTelemetryHistory(ref), + ), + AppButton( + label: 'Clear', + variant: AppButtonVariant.ghost, + onPressed: () => clearOperationalTelemetryHistory(ref), + ), + AppButton( + label: sheetContext.l10n.actionDismiss, + variant: AppButtonVariant.ghost, + onPressed: () => Navigator.of(sheetContext).pop(), + ), + ], + ), + ], + ); + }, + ), + ); + }, + ); + } + + Future _showFirebaseRuntimeConfigSheet( + BuildContext context, + WidgetRef ref, + ) async { + await showAppSheet( + context: context, + builder: (sheetContext) { + return Consumer( + builder: (sheetContext, ref, _) { + final l10n = sheetContext.l10n; + final runtimeConfig = ref.watch(firebaseRuntimeConfigProvider); + final remoteConfigStatus = ref.watch( + firebaseRemoteConfigStatusProvider, + ); + final isRefreshing = ref.watch( + firebaseRuntimeConfigRefreshInProgressProvider, + ); + final lastFetchTimeText = _lastFetchTimeLabel( + sheetContext, + remoteConfigStatus.lastFetchTime, + ); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.settingsFirebaseRuntimeConfigSheetTitle, + style: Theme.of(sheetContext).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + l10n.settingsFirebaseRuntimeConfigDescription, + style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( + color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.md), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.insights_outlined), + title: Text(l10n.settingsFirebaseRuntimeConfigAnalyticsLabel), + subtitle: const Text('app_analytics_collection_enabled'), + trailing: Text( + _enabledDisabledLabel( + sheetContext, + runtimeConfig.analyticsCollectionEnabled, + ), + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.bug_report_outlined), + title: Text( + l10n.settingsFirebaseRuntimeConfigCrashlyticsLabel, + ), + subtitle: const Text('app_crashlytics_collection_enabled'), + trailing: Text( + _enabledDisabledLabel( + sheetContext, + runtimeConfig.crashlyticsCollectionEnabled, + ), + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.speed_outlined), + title: Text( + l10n.settingsFirebaseRuntimeConfigPerformanceLabel, + ), + subtitle: const Text('app_performance_collection_enabled'), + trailing: Text( + _enabledDisabledLabel( + sheetContext, + runtimeConfig.performanceCollectionEnabled, + ), + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.verified_user_outlined), + title: const Text('App Check'), + subtitle: const Text('app_app_check_enabled'), + trailing: Text( + _enabledDisabledLabel( + sheetContext, + runtimeConfig.appCheckEnabled, + ), + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.notifications_active_outlined), + title: const Text('Push notifications'), + subtitle: const Text('app_fcm_enabled'), + trailing: Text( + _enabledDisabledLabel( + sheetContext, + runtimeConfig.fcmEnabled, + ), + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.hub_outlined), + title: const Text('Backend integration'), + subtitle: const Text('app_backend_integration_enabled'), + trailing: Text( + _enabledDisabledLabel( + sheetContext, + runtimeConfig.backendIntegrationEnabled, + ), + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.person_outline_rounded), + title: const Text('Authentication'), + subtitle: const Text('app_auth_feature_enabled'), + trailing: Text( + _enabledDisabledLabel( + sheetContext, + runtimeConfig.authFeatureEnabled, + ), + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.cloud_sync_outlined), + title: Text(l10n.settingsCloudSyncTitle), + subtitle: const Text('app_sync_feature_enabled'), + trailing: Text( + _enabledDisabledLabel( + sheetContext, + runtimeConfig.syncFeatureEnabled, + ), + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.sync_alt_outlined), + title: Text( + l10n.settingsFirebaseRuntimeConfigFetchStatusTitle, + ), + subtitle: Text( + _remoteConfigFetchStatusLabel( + sheetContext, + remoteConfigStatus.lastFetchStatus, + ), + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.schedule_outlined), + title: Text(l10n.settingsFirebaseRuntimeConfigLastFetchTitle), + subtitle: Text(lastFetchTimeText), + ), + const SizedBox(height: AppSpacing.md), + AppButton( + label: isRefreshing + ? l10n.settingsFirebaseRuntimeConfigRefreshingAction + : l10n.settingsFirebaseRuntimeConfigRefreshAction, + onPressed: isRefreshing + ? null + : () => _refreshFirebaseRuntimeConfig(sheetContext, ref), + ), + ], + ); + }, + ); + }, + ); + } + + Future _refreshFirebaseRuntimeConfig( + BuildContext context, + WidgetRef ref, + ) async { + final isRefreshing = ref.read( + firebaseRuntimeConfigRefreshInProgressProvider, + ); + if (isRefreshing) { + return; + } + + final l10n = context.l10n; + + try { + final result = await ref + .read(firebaseRuntimeConfigControllerProvider.notifier) + .refreshFromRemoteConfig(forceFetch: true); + await ref + .read(analyticsPreferencesProvider.notifier) + .syncToAnalyticsService(); + + if (!context.mounted) { + return; + } + + final message = result.didActivateChanges + ? l10n.settingsFirebaseRuntimeConfigRefreshSuccess + : l10n.settingsFirebaseRuntimeConfigRefreshNoChanges; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } catch (error, stackTrace) { + Logger.error( + 'Failed to refresh Firebase runtime config.', + error, + stackTrace, + ); + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + l10n.settingsFirebaseRuntimeConfigRefreshFailed('$error'), + ), + backgroundColor: Colors.red, + ), + ); + } + } + + Future _handleCrashlyticsTest(BuildContext context) async { + final l10n = context.l10n; + final shouldCrash = await showAppDialog( + context: context, + title: Text(l10n.settingsCrashlyticsTestConfirmTitle), + content: Text(l10n.settingsCrashlyticsTestConfirmMessage), + actions: [ + AppButton( + label: l10n.actionCancel, + variant: AppButtonVariant.ghost, + onPressed: () => closeAppDialog(context, false), + ), + AppButton( + label: l10n.settingsCrashlyticsTestConfirmAction, + variant: AppButtonVariant.danger, + onPressed: () => closeAppDialog(context, true), + ), + ], + ); + + if (shouldCrash != true || !context.mounted) { + return; + } + + try { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.settingsCrashlyticsTestTriggered)), + ); + + await FirebaseCrashlytics.instance.log( + 'Manual Crashlytics test from debug settings action.', + ); + await FirebaseCrashlytics.instance.setCustomKey( + 'debug_action', + 'settings_crashlytics_test', + ); + + await Future.delayed(const Duration(milliseconds: 350)); + FirebaseCrashlytics.instance.crash(); + } catch (error, stackTrace) { + Logger.error( + 'Failed to trigger Crashlytics test crash.', + error, + stackTrace, + ); + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.settingsCrashlyticsTestFailed('$error')), + backgroundColor: Colors.red, + ), + ); + } + } + + String _formatSyncRetryAt(DateTime? value) { + if (value == null) { + return 'Not scheduled'; + } + + final local = value.toLocal(); + String twoDigits(int part) => part.toString().padLeft(2, '0'); + return '${local.year}-' + '${twoDigits(local.month)}-' + '${twoDigits(local.day)} ' + '${twoDigits(local.hour)}:' + '${twoDigits(local.minute)}:' + '${twoDigits(local.second)}'; + } + + String _enabledDisabledLabel(BuildContext context, bool enabled) { + final l10n = context.l10n; + return enabled + ? l10n.settingsFirebaseRuntimeConfigValueEnabled + : l10n.settingsFirebaseRuntimeConfigValueDisabled; + } + + String _remoteConfigFetchStatusLabel( + BuildContext context, + Object? lastFetchStatus, + ) { + final l10n = context.l10n; + final statusText = lastFetchStatus?.toString() ?? ''; + + if (statusText.contains('success')) { + return l10n.settingsFirebaseRuntimeConfigFetchStatusSuccess; + } + if (statusText.contains('failure')) { + return l10n.settingsFirebaseRuntimeConfigFetchStatusFailure; + } + if (statusText.contains('throttle')) { + return l10n.settingsFirebaseRuntimeConfigFetchStatusThrottled; + } + + return l10n.settingsFirebaseRuntimeConfigFetchStatusNoFetch; + } + + String _lastFetchTimeLabel(BuildContext context, DateTime? lastFetchTime) { + if (lastFetchTime == null) { + return context.l10n.settingsFirebaseRuntimeConfigFetchStatusNoFetch; + } + + final materialLocalizations = MaterialLocalizations.of(context); + final localTime = lastFetchTime.toLocal(); + final dateLabel = materialLocalizations.formatShortDate(localTime); + final timeLabel = materialLocalizations.formatTimeOfDay( + TimeOfDay.fromDateTime(localTime), + alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), + ); + + return '$dateLabel $timeLabel'; + } + + String _pushPermissionLabel(FirebaseMessagingPermissionStatus status) { + return switch (status) { + FirebaseMessagingPermissionStatus.notDetermined => 'Not determined', + FirebaseMessagingPermissionStatus.denied => 'Denied', + FirebaseMessagingPermissionStatus.authorized => 'Authorized', + FirebaseMessagingPermissionStatus.provisional => 'Provisional', + FirebaseMessagingPermissionStatus.unsupported => 'Unsupported', + }; + } + + String _truncateToken(String token) { + final sanitized = token.trim(); + if (sanitized.length <= 18) { + return sanitized; + } + return '${sanitized.substring(0, 10)}...${sanitized.substring(sanitized.length - 8)}'; + } + + String _apnsTokenLabel(String? apnsToken) { + final sanitized = apnsToken?.trim() ?? ''; + if (sanitized.isEmpty) { + return 'Not available'; + } + return _truncateToken(sanitized); + } +} + +class _CloudSyncStateRow extends StatelessWidget { + const _CloudSyncStateRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final valueStyle = Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ); + + return Row( + children: [ + SizedBox( + width: 112, + child: Text(label, style: Theme.of(context).textTheme.bodyMedium), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + value, + style: valueStyle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} + +class _OperationalTelemetryEventTile extends StatelessWidget { + const _OperationalTelemetryEventTile({required this.entry}); + + final Map entry; + + @override + Widget build(BuildContext context) { + final name = entry['name'] as String? ?? 'unknown_event'; + final category = entry['category'] as String? ?? 'unknown'; + final hasError = entry['has_error'] as bool? ?? false; + final timestamp = _formatTimestamp(entry['timestamp'] as String?); + final rawProperties = entry['properties']; + final properties = rawProperties is Map + ? rawProperties.cast() + : const {}; + final preview = properties.entries + .take(4) + .map((entry) => '${entry.key}: ${entry.value}') + .join(' | '); + + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppSpacing.md), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.25), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + hasError ? Icons.error_outline : Icons.check_circle_outline, + size: 18, + color: hasError + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: AppSpacing.xs), + Expanded( + child: Text( + name, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xs), + Text( + '$category • $timestamp', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + if (preview.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + preview, + style: Theme.of(context).textTheme.bodySmall, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ); + } + + String _formatTimestamp(String? value) { + if (value == null || value.isEmpty) { + return 'unknown time'; + } + final parsed = DateTime.tryParse(value); + if (parsed == null) { + return value; + } + final local = parsed.toLocal(); + return '${local.year}-${_two(local.month)}-${_two(local.day)} ' + '${_two(local.hour)}:${_two(local.minute)}:${_two(local.second)}'; + } + + String _two(int value) => value < 10 ? '0$value' : '$value'; +} + +class _FirebaseRuntimeHealthCard extends StatelessWidget { + const _FirebaseRuntimeHealthCard({required this.config}); + + final FirebaseRuntimeConfig config; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final enabledCount = [ + config.analyticsCollectionEnabled, + config.crashlyticsCollectionEnabled, + config.performanceCollectionEnabled, + ].where((value) => value).length; + + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.settingsFirebaseRuntimeConfigTitle, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: AppSpacing.xs), + Text( + l10n.settingsFirebaseRuntimeConfigSummary(enabledCount), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.sm), + _FirebaseFlagStatusRow( + icon: Icons.insights_outlined, + label: l10n.settingsFirebaseRuntimeConfigAnalyticsLabel, + enabled: config.analyticsCollectionEnabled, + ), + const SizedBox(height: AppSpacing.xs), + _FirebaseFlagStatusRow( + icon: Icons.bug_report_outlined, + label: l10n.settingsFirebaseRuntimeConfigCrashlyticsLabel, + enabled: config.crashlyticsCollectionEnabled, + ), + const SizedBox(height: AppSpacing.xs), + _FirebaseFlagStatusRow( + icon: Icons.speed_outlined, + label: l10n.settingsFirebaseRuntimeConfigPerformanceLabel, + enabled: config.performanceCollectionEnabled, + ), + ], + ), + ); + } +} + +class _FirebaseFlagStatusRow extends StatelessWidget { + const _FirebaseFlagStatusRow({ + required this.icon, + required this.label, + required this.enabled, + }); + + final IconData icon; + final String label; + final bool enabled; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final labelText = enabled + ? l10n.settingsFirebaseRuntimeConfigValueEnabled + : l10n.settingsFirebaseRuntimeConfigValueDisabled; + final badgeColor = enabled + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outline; + final badgeForeground = enabled + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurfaceVariant; + + return Row( + children: [ + Icon( + icon, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text(label, style: Theme.of(context).textTheme.bodyMedium), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: badgeColor, + borderRadius: BorderRadius.circular(AppRadii.pill), + ), + child: Text( + labelText, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: badgeForeground, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ); + } +} diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_notifications_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_notifications_screen.dart new file mode 100644 index 0000000..e515408 --- /dev/null +++ b/apps/mobile/lib/features/settings/presentation/views/settings_notifications_screen.dart @@ -0,0 +1,178 @@ +import 'package:app_firebase/app_firebase.dart'; +import 'package:collection_tracker/core/providers/providers.dart'; +import 'package:collection_tracker/l10n/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ui/ui.dart'; + +import '../widgets/settings_primitives.dart'; + +class SettingsNotificationsScreen extends ConsumerWidget { + const SettingsNotificationsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final preferences = ref.watch(pushNotificationPreferencesProvider); + final notifier = ref.read(pushNotificationPreferencesProvider.notifier); + + return Scaffold( + appBar: AppBar(title: const Text('Push Notifications')), + body: ListView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + AppSpacing.xxl, + ), + children: [ + AppReveal( + child: AppCard( + child: Text( + preferences.runtimeFeatureEnabled + ? 'Manage sync-needed, price alert, reminder, and account security notifications.' + : 'Push notifications are currently disabled by runtime feature flag.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + AppReveal( + delay: AppMotion.stagger, + child: SettingsSection( + title: 'Preferences', + children: [ + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + ), + title: const Text('Enable push notifications'), + subtitle: Text( + _pushPermissionLabel(preferences.permissionStatus), + ), + value: preferences.preferenceEnabled, + onChanged: preferences.runtimeFeatureEnabled + ? (value) async { + await notifier.setPreferenceEnabled(value); + if (!context.mounted) { + return; + } + final updated = ref.read( + pushNotificationPreferencesProvider, + ); + if (value && !updated.preferenceEnabled) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Notification permission is not granted.', + ), + ), + ); + } + } + : null, + ), + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + AppReveal( + delay: AppMotion.stagger * 2, + child: SettingsSection( + title: 'Notification Types', + children: [ + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + ), + title: const Text('Sync needed'), + subtitle: const Text('When your local data should be synced'), + value: preferences.syncNeededEnabled, + onChanged: preferences.isEffectivelyEnabled + ? (value) { + notifier.setTopicEnabled( + PushNotificationTopic.syncNeeded, + value, + ); + } + : null, + ), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + ), + title: const Text('Price alerts'), + subtitle: const Text('When tracked item prices change'), + value: preferences.priceAlertsEnabled, + onChanged: preferences.isEffectivelyEnabled + ? (value) { + notifier.setTopicEnabled( + PushNotificationTopic.priceAlerts, + value, + ); + } + : null, + ), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + ), + title: const Text('Reminders'), + subtitle: const Text('Scheduled reminders and nudges'), + value: preferences.remindersEnabled, + onChanged: preferences.isEffectivelyEnabled + ? (value) { + notifier.setTopicEnabled( + PushNotificationTopic.reminders, + value, + ); + } + : null, + ), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + ), + title: const Text('Account security'), + subtitle: const Text('Important account and session events'), + value: preferences.accountSecurityEnabled, + onChanged: preferences.isEffectivelyEnabled + ? (value) { + notifier.setTopicEnabled( + PushNotificationTopic.accountSecurity, + value, + ); + } + : null, + ), + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + AppReveal( + delay: AppMotion.stagger * 3, + child: AppButton( + label: preferences.isApplying + ? 'Refreshing...' + : context.l10n.actionRefresh, + onPressed: preferences.isApplying + ? null + : () => notifier.refreshPermissionStatus(), + ), + ), + ], + ), + ); + } + + String _pushPermissionLabel(FirebaseMessagingPermissionStatus status) { + return switch (status) { + FirebaseMessagingPermissionStatus.notDetermined => 'Not determined', + FirebaseMessagingPermissionStatus.denied => 'Denied', + FirebaseMessagingPermissionStatus.authorized => 'Authorized', + FirebaseMessagingPermissionStatus.provisional => 'Provisional', + FirebaseMessagingPermissionStatus.unsupported => 'Unsupported', + }; + } +} diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart index c735402..59bc1cf 100644 --- a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart +++ b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart @@ -1,14 +1,11 @@ -import 'package:app_logger/app_logger.dart'; -import 'package:auth_session/auth_session.dart'; import 'package:app_firebase/app_firebase.dart'; +import 'package:auth_session/auth_session.dart'; import 'package:collection_tracker/core/analytics/analytics_consent_dialog.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:collection_tracker/core/providers/providers.dart'; import 'package:collection_tracker/core/router/routes.dart'; import 'package:collection_tracker/l10n/l10n.dart'; -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -17,6 +14,7 @@ import 'package:storage/storage.dart'; import 'package:ui/ui.dart'; import '../view_models/export_import_view_model.dart'; +import '../widgets/settings_primitives.dart'; class SettingsScreen extends ConsumerWidget { const SettingsScreen({super.key}); @@ -27,12 +25,12 @@ class SettingsScreen extends ConsumerWidget { final themeSettings = ref.watch(themeSettingsProvider); final currentLanguage = ref.watch(localeSettingsProvider); final analyticsPreferences = ref.watch(analyticsPreferencesProvider); - final firebaseRuntimeConfig = ref.watch(firebaseRuntimeConfigProvider); final pushPreferences = ref.watch(pushNotificationPreferencesProvider); final syncReadiness = ref.watch(syncReadinessProvider); final accountReadiness = ref.watch(backendAuthReadinessProvider); final pendingSyncCount = ref.watch(syncOutboxCountProvider).value ?? 0; final authSession = ref.watch(authSessionProvider).value; + final themeSummary = '${_themeModeLabel(context, themeSettings.mode)} - ${themeSettings.variant.label}'; final languageSummary = _languageLabel(context, currentLanguage); @@ -46,10 +44,6 @@ class SettingsScreen extends ConsumerWidget { final pushSummary = _pushNotificationSummary(pushPreferences); final cloudSyncFeatureEnabled = syncReadiness.status != SyncReadinessStatus.disabledByFeatureFlag; - final firebaseRuntimeSummary = _firebaseRuntimeSummary( - context, - firebaseRuntimeConfig, - ); return Scaffold( appBar: AppBar(title: Text(l10n.settingsTitle)), @@ -62,28 +56,39 @@ class SettingsScreen extends ConsumerWidget { ), children: [ AppReveal( - child: _SettingsSection( + child: SettingsStatusCard( + title: l10n.settingsTitle, + firstLabel: l10n.settingsCloudSyncTitle, + secondLabel: 'Push', + syncStatus: cloudSyncSummary, + notificationStatus: pushSummary, + ), + ), + const SizedBox(height: AppSpacing.lg), + AppReveal( + delay: AppMotion.stagger, + child: SettingsSection( title: l10n.settingsSectionGeneral, children: [ - _SettingsTile( + SettingsTile( icon: Icons.palette, title: l10n.settingsThemeTitle, subtitle: themeSummary, onTap: () => _showThemeSelector(context, ref), ), - _SettingsTile( + SettingsTile( icon: Icons.language, title: l10n.settingsLanguageTitle, subtitle: languageSummary, onTap: () => _showLanguageSelector(context, ref), ), - _SettingsTile( + SettingsTile( icon: Icons.insights_outlined, title: l10n.settingsAnalyticsTitle, subtitle: analyticsSummary, onTap: () => _showAnalyticsSettings(context, ref), ), - _SettingsTile( + SettingsTile( icon: Icons.person_outline_rounded, title: 'Account', subtitle: accountSummary, @@ -92,45 +97,40 @@ class SettingsScreen extends ConsumerWidget { ? () => context.push(Routes.auth) : null, ), - _SettingsTile( + SettingsTile( icon: Icons.notifications_outlined, title: 'Push Notifications', subtitle: pushSummary, - onTap: () => _showPushNotificationSettings(context, ref), + onTap: () => context.push(Routes.settingsNotifications), ), ], ), ), - const SizedBox(height: AppSpacing.md), - AppReveal( - delay: AppMotion.stagger, - child: _FirebaseRuntimeHealthCard(config: firebaseRuntimeConfig), - ), const SizedBox(height: AppSpacing.lg), AppReveal( delay: AppMotion.stagger * 2, - child: _SettingsSection( + child: SettingsSection( title: l10n.settingsSectionData, children: [ - _SettingsTile( + SettingsTile( icon: Icons.file_download, title: l10n.settingsExportJsonTitle, subtitle: l10n.settingsExportJsonSubtitle, onTap: () => _handleExportJson(context, ref), ), - _SettingsTile( + SettingsTile( icon: Icons.table_chart, title: l10n.settingsExportCsvTitle, subtitle: l10n.settingsExportCsvSubtitle, onTap: () => _handleExportCsv(context, ref), ), - _SettingsTile( + SettingsTile( icon: Icons.file_upload, title: l10n.settingsImportJsonTitle, subtitle: l10n.settingsImportJsonSubtitle, onTap: () => _handleImportJson(context, ref), ), - _SettingsTile( + SettingsTile( icon: Icons.cloud_upload, title: l10n.settingsCloudSyncTitle, subtitle: cloudSyncSummary, @@ -139,11 +139,11 @@ class SettingsScreen extends ConsumerWidget { ? () => _showCloudSyncStatusSheet(context, ref) : null, ), - _SettingsTile( + SettingsTile( icon: Icons.sell_outlined, title: l10n.settingsManageTagsTitle, subtitle: l10n.settingsManageTagsSubtitle, - onTap: () => context.push('/settings/tags'), + onTap: () => context.push(Routes.settingsTags), ), ], ), @@ -151,24 +151,14 @@ class SettingsScreen extends ConsumerWidget { const SizedBox(height: AppSpacing.lg), AppReveal( delay: AppMotion.stagger * 3, - child: _SettingsSection( + child: SettingsSection( title: l10n.settingsSectionAbout, children: [ - _SettingsTile( + SettingsTile( icon: Icons.info, title: l10n.settingsVersionTitle, subtitle: '1.0.0', ), - _SettingsTile( - icon: Icons.description, - title: l10n.settingsPrivacyPolicyTitle, - onTap: () {}, - ), - _SettingsTile( - icon: Icons.gavel, - title: l10n.settingsTermsTitle, - onTap: () {}, - ), ], ), ), @@ -176,32 +166,14 @@ class SettingsScreen extends ConsumerWidget { const SizedBox(height: AppSpacing.lg), AppReveal( delay: AppMotion.stagger * 4, - child: _SettingsSection( + child: SettingsSection( title: l10n.settingsSectionDeveloper, children: [ - _SettingsTile( - icon: Icons.settings_remote_outlined, - title: l10n.settingsFirebaseRuntimeConfigTitle, - subtitle: firebaseRuntimeSummary, - onTap: () => _showFirebaseRuntimeConfigSheet(context, ref), - ), - _SettingsTile( - icon: Icons.cloud_sync_outlined, - title: 'Cloud Sync Diagnostics', - subtitle: 'Debug sync transport and auth readiness', - onTap: () => _showCloudSyncDiagnosticsSheet(context, ref), - ), - _SettingsTile( - icon: Icons.monitor_heart_outlined, - title: 'Operational Telemetry', - subtitle: 'Inspect recent sync/data/runtime events', - onTap: () => _showOperationalTelemetrySheet(context, ref), - ), - _SettingsTile( - icon: Icons.bug_report_outlined, - title: l10n.settingsCrashlyticsTestTitle, - subtitle: l10n.settingsCrashlyticsTestSubtitle, - onTap: () => _handleCrashlyticsTest(context), + SettingsTile( + icon: Icons.developer_mode_outlined, + title: '${l10n.settingsSectionDeveloper} Tools', + subtitle: 'Runtime flags, diagnostics, and crash testing', + onTap: () => context.push(Routes.settingsDevtools), ), ], ), @@ -227,23 +199,25 @@ class SettingsScreen extends ConsumerWidget { final exportService = ExportImportService(); await exportService.shareFile(filePath, 'collection_tracker_export.json'); - if (context.mounted) { - messenger.showSnackBar( - SnackBar( - content: Text(l10n.settingsDataExportSuccess), - backgroundColor: Colors.green, - ), - ); + if (!context.mounted) { + return; } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.settingsExportFailed('$e')), - backgroundColor: Colors.red, - ), - ); + messenger.showSnackBar( + SnackBar( + content: Text(l10n.settingsDataExportSuccess), + backgroundColor: Colors.green, + ), + ); + } catch (error) { + if (!context.mounted) { + return; } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.settingsExportFailed('$error')), + backgroundColor: Colors.red, + ), + ); } } @@ -262,23 +236,25 @@ class SettingsScreen extends ConsumerWidget { final exportService = ExportImportService(); await exportService.shareFile(filePath, 'collection_tracker_export.csv'); - if (context.mounted) { - messenger.showSnackBar( - SnackBar( - content: Text(l10n.settingsDataExportSuccess), - backgroundColor: Colors.green, - ), - ); + if (!context.mounted) { + return; } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.settingsExportFailed('$e')), - backgroundColor: Colors.red, - ), - ); + messenger.showSnackBar( + SnackBar( + content: Text(l10n.settingsDataExportSuccess), + backgroundColor: Colors.green, + ), + ); + } catch (error) { + if (!context.mounted) { + return; } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.settingsExportFailed('$error')), + backgroundColor: Colors.red, + ), + ); } } @@ -301,7 +277,9 @@ class SettingsScreen extends ConsumerWidget { ], ); - if (confirmed != true || !context.mounted) return; + if (confirmed != true || !context.mounted) { + return; + } try { final messenger = ScaffoldMessenger.of(context); @@ -311,82 +289,26 @@ class SettingsScreen extends ConsumerWidget { await ref.read(exportImportViewModelProvider.notifier).importFromJson(); - if (context.mounted) { - messenger.showSnackBar( - SnackBar( - content: Text(l10n.settingsDataImportSuccess), - backgroundColor: Colors.green, - ), - ); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.settingsImportFailed('$e')), - backgroundColor: Colors.red, - ), - ); + if (!context.mounted) { + return; } - } - } - - String _cloudSyncSummary( - SyncReadinessState readiness, { - required int pendingSyncCount, - }) { - return switch (readiness.status) { - SyncReadinessStatus.ready => - pendingSyncCount > 0 - ? 'Ready • $pendingSyncCount pending change(s)' - : 'Ready', - SyncReadinessStatus.disabledByFeatureFlag => 'Unavailable', - SyncReadinessStatus.missingApiConfiguration => 'Configuration required', - SyncReadinessStatus.checkingAuthentication => 'Checking session...', - SyncReadinessStatus.authenticationRequired => 'Sign in required', - }; - } - - String _authAccountSummary( - AuthSession? session, - BackendApiReadiness readiness, - ) { - if (!readiness.enabled) { - final message = readiness.message.toLowerCase(); - if (message.contains('missing') || message.contains('configure')) { - return 'Configuration required'; + messenger.showSnackBar( + SnackBar( + content: Text(l10n.settingsDataImportSuccess), + backgroundColor: Colors.green, + ), + ); + } catch (error) { + if (!context.mounted) { + return; } - return 'Unavailable'; - } - if (session == null || !session.isAuthenticated) { - return 'Not signed in'; - } - return 'Signed in'; - } - - String _cloudSyncPrimaryCta(SyncReadinessStatus status) { - return switch (status) { - SyncReadinessStatus.ready => 'Sync now', - SyncReadinessStatus.authenticationRequired => 'Sign in', - SyncReadinessStatus.missingApiConfiguration => 'Configure API', - SyncReadinessStatus.disabledByFeatureFlag => 'Feature disabled', - SyncReadinessStatus.checkingAuthentication => 'Checking session', - }; - } - - String _formatSyncRetryAt(DateTime? value) { - if (value == null) { - return 'Not scheduled'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.settingsImportFailed('$error')), + backgroundColor: Colors.red, + ), + ); } - - final local = value.toLocal(); - String twoDigits(int part) => part.toString().padLeft(2, '0'); - return '${local.year}-' - '${twoDigits(local.month)}-' - '${twoDigits(local.day)} ' - '${twoDigits(local.hour)}:' - '${twoDigits(local.minute)}:' - '${twoDigits(local.second)}'; } Future _showCloudSyncStatusSheet( @@ -433,6 +355,21 @@ class SettingsScreen extends ConsumerWidget { readiness: readiness, ), ), + if (kDebugMode) ...[ + const SizedBox(height: AppSpacing.sm), + AppButton( + label: + '${sheetContext.l10n.settingsSectionDeveloper} Tools', + variant: AppButtonVariant.secondary, + onPressed: () { + Navigator.of(sheetContext).pop(); + if (!context.mounted) { + return; + } + context.push(Routes.settingsDevtools); + }, + ), + ], const SizedBox(height: AppSpacing.sm), AppButton( label: sheetContext.l10n.actionDismiss, @@ -459,12 +396,25 @@ class SettingsScreen extends ConsumerWidget { return; case SyncReadinessStatus.authenticationRequired: Navigator.of(sheetContext).pop(); - if (!context.mounted) return; + if (!context.mounted) { + return; + } await context.push('${Routes.auth}?mode=signin'); return; case SyncReadinessStatus.missingApiConfiguration: Navigator.of(sheetContext).pop(); - await _showSyncApiConfigurationHelp(context, ref); + if (!context.mounted) { + return; + } + if (kDebugMode) { + context.push(Routes.settingsDevtools); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Cloud sync is currently unavailable.'), + ), + ); + } return; case SyncReadinessStatus.disabledByFeatureFlag: return; @@ -487,7 +437,9 @@ class SettingsScreen extends ConsumerWidget { final session = ref.read(authSessionProvider).asData?.value; final deviceId = session?.deviceId; if (deviceId == null || deviceId.trim().isEmpty) { - if (!context.mounted) return; + if (!context.mounted) { + return; + } ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Missing device id. Sign in again to enable sync.'), @@ -528,12 +480,14 @@ class SettingsScreen extends ConsumerWidget { stackTrace: result.stackTrace, ); - if (!context.mounted) return; + if (!context.mounted) { + return; + } final bootstrapMessage = bootstrapResult.totalOperations > 0 ? 'Prepared ${bootstrapResult.totalOperations} local change(s). ' : ''; final recoveryHint = bootstrapResult.skipped && !result.executed - ? ' If existing local data is missing on cloud, open Cloud Sync Diagnostics and use "Rebuild local sync queue".' + ? ' If existing local data is missing on cloud, open Developer Tools and use "Rebuild local sync queue".' : ''; ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -543,263 +497,102 @@ class SettingsScreen extends ConsumerWidget { ); } - Future _rebuildSyncOutboxFromLocal( - BuildContext context, - WidgetRef ref, - ) async { - try { - final result = await ref - .read(syncOutboxBootstrapperProvider) - .rebuildFromLocalData(); - await OperationalTelemetry.trackSyncQueueRebuild( - success: true, - queuedOperations: result.totalOperations, - ); - - if (!context.mounted) return; - final message = result.totalOperations > 0 - ? 'Rebuilt queue with ${result.totalOperations} local change(s). Run Sync now.' - : 'No local data found to queue for sync.'; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - backgroundColor: result.totalOperations > 0 - ? Colors.green - : Colors.orange, - ), - ); - } catch (error, stackTrace) { - await OperationalTelemetry.trackSyncQueueRebuild( - success: false, - queuedOperations: 0, - error: error, - stackTrace: stackTrace, - ); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to rebuild local sync queue: $error'), - backgroundColor: Colors.red, - ), - ); - } - } - - Future _showSyncApiConfigurationHelp( - BuildContext context, - WidgetRef ref, - ) async { - final initialOverride = ref.read(backendApiBaseUrlOverrideProvider); - final effectiveUrl = ref.read(backendApiBaseUrlProvider); - final controller = TextEditingController(text: initialOverride); - - await showAppSheet( - context: context, - builder: (sheetContext) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Configure Backend API', - style: Theme.of( - sheetContext, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), - ), - const SizedBox(height: AppSpacing.sm), - Text( - 'Enter backend API base URL used for sync and authentication.', - style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( - color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: AppSpacing.md), - AppInput( - controller: controller, - labelText: 'Base URL override', - hintText: 'http://localhost:4000', - ), - const SizedBox(height: AppSpacing.sm), - Text( - 'Effective URL: $effectiveUrl', - style: Theme.of(sheetContext).textTheme.bodySmall?.copyWith( - color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: AppSpacing.lg), - AppButton( - label: 'Save', - onPressed: () async { - await ref - .read(backendApiBaseUrlOverrideProvider.notifier) - .setBaseUrl(controller.text); - if (!sheetContext.mounted) return; - Navigator.of(sheetContext).pop(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Backend API URL updated.')), - ); - }, - ), - const SizedBox(height: AppSpacing.sm), - AppButton( - label: 'Clear override', - variant: AppButtonVariant.secondary, - onPressed: () async { - await ref - .read(backendApiBaseUrlOverrideProvider.notifier) - .clear(); - if (!sheetContext.mounted) return; - Navigator.of(sheetContext).pop(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Backend API URL override cleared.'), - ), - ); - }, - ), - const SizedBox(height: AppSpacing.sm), - AppButton( - label: sheetContext.l10n.actionDismiss, - variant: AppButtonVariant.ghost, - onPressed: () => Navigator.of(sheetContext).pop(), - ), - ], - ); - }, - ); - - controller.dispose(); - } - - Future _showCloudSyncDiagnosticsSheet( - BuildContext context, - WidgetRef ref, - ) async { + Future _showThemeSelector(BuildContext context, WidgetRef ref) async { await showAppSheet( context: context, builder: (sheetContext) { return Consumer( builder: (sheetContext, ref, _) { - final readiness = ref.watch(syncReadinessProvider); - final transportConfig = ref.watch(syncTransportConfigProvider); - final pendingCount = - ref.watch(syncOutboxCountProvider).asData?.value ?? 0; - final syncState = ref.watch(syncStateProvider).asData?.value; - final envFlagOverridesActive = ref.watch( - backendDebugEnvFlagOverridesActiveProvider, - ); - final session = ref.watch(authSessionProvider).value; - final hasSession = session?.isAuthenticated ?? false; + final settings = ref.watch(themeSettingsProvider); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Cloud Sync Diagnostics', + sheetContext.l10n.settingsThemeModeTitle, style: Theme.of(sheetContext).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), - const SizedBox(height: AppSpacing.sm), - _CloudSyncStateRow( - label: 'Readiness', - value: readiness.status.name, - ), - const SizedBox(height: AppSpacing.xs), - _CloudSyncStateRow(label: 'Message', value: readiness.message), - const SizedBox(height: AppSpacing.xs), - _CloudSyncStateRow( - label: 'Backend flag', - value: transportConfig.backendFeatureEnabled - ? 'Enabled' - : 'Disabled', - ), - const SizedBox(height: AppSpacing.xs), - _CloudSyncStateRow( - label: 'Sync flag', - value: transportConfig.syncFeatureEnabled - ? 'Enabled' - : 'Disabled', - ), - const SizedBox(height: AppSpacing.xs), - _CloudSyncStateRow( - label: 'Auth flag', - value: transportConfig.authFeatureEnabled - ? 'Enabled' - : 'Disabled', - ), - const SizedBox(height: AppSpacing.xs), - _CloudSyncStateRow( - label: 'Env overrides', - value: envFlagOverridesActive ? 'Active' : 'Inactive', - ), - const SizedBox(height: AppSpacing.xs), - _CloudSyncStateRow( - label: 'Base URL', - value: transportConfig.baseUrl.isEmpty - ? 'Not configured' - : transportConfig.baseUrl, - ), - const SizedBox(height: AppSpacing.xs), - _CloudSyncStateRow( - label: 'Resolved URL', - value: transportConfig.isApiBaseUrlConfigured - ? transportConfig.normalizedApiBaseUrl - : 'Not available', - ), - const SizedBox(height: AppSpacing.xs), - _CloudSyncStateRow( - label: 'Outbox', - value: '$pendingCount pending', - ), - const SizedBox(height: AppSpacing.xs), - _CloudSyncStateRow( - label: 'Failures', - value: '${syncState?.consecutiveFailures ?? 0}', - ), - const SizedBox(height: AppSpacing.xs), - _CloudSyncStateRow( - label: 'Next retry', - value: _formatSyncRetryAt(syncState?.nextRetryAt), - ), - const SizedBox(height: AppSpacing.xs), - _CloudSyncStateRow( - label: 'Auth', - value: hasSession ? 'Signed in' : 'Signed out', + const SizedBox(height: AppSpacing.md), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: ThemeMode.values.map((mode) { + final isSelected = settings.mode == mode; + return ChoiceChip( + label: Text(_themeModeLabel(sheetContext, mode)), + selected: isSelected, + onSelected: (selected) { + if (!selected) { + return; + } + ref + .read(themeSettingsProvider.notifier) + .setThemeMode(mode); + }, + ); + }).toList(), ), - const SizedBox(height: AppSpacing.xs), - _CloudSyncStateRow( - label: 'Device', - value: session?.deviceId ?? 'Unavailable', + const SizedBox(height: AppSpacing.xl), + Text( + sheetContext.l10n.settingsThemeColorVariantTitle, + style: Theme.of(sheetContext).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), ), - const SizedBox(height: AppSpacing.lg), - if (!transportConfig.isApiBaseUrlConfigured) ...[ - AppButton( - label: 'Configure API', - onPressed: () async { - Navigator.of(sheetContext).pop(); - await _showSyncApiConfigurationHelp(context, ref); + const SizedBox(height: AppSpacing.md), + SizedBox( + height: 56, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: AppThemeVariant.values.length, + separatorBuilder: (_, _) => + const SizedBox(width: AppSpacing.md), + itemBuilder: (context, index) { + final variant = AppThemeVariant.values[index]; + final isSelected = settings.variant == variant; + return GestureDetector( + onTap: () { + ref + .read(themeSettingsProvider.notifier) + .setThemeVariant(variant); + }, + child: AnimatedContainer( + duration: AppMotion.fast, + curve: AppMotion.emphasized, + width: 46, + height: 46, + decoration: BoxDecoration( + color: variant.color, + shape: BoxShape.circle, + border: isSelected + ? Border.all( + color: Theme.of( + sheetContext, + ).colorScheme.primary, + width: 3, + ) + : null, + ), + child: isSelected + ? const Icon(Icons.check, color: Colors.white) + : null, + ), + ); }, - expand: true, ), - const SizedBox(height: AppSpacing.sm), - ], - AppButton( - label: 'Rebuild local sync queue', - variant: AppButtonVariant.secondary, - onPressed: () async { - Navigator.of(sheetContext).pop(); - await _rebuildSyncOutboxFromLocal(context, ref); - }, - expand: true, ), - const SizedBox(height: AppSpacing.sm), - AppButton( - label: sheetContext.l10n.actionDismiss, - variant: AppButtonVariant.ghost, - onPressed: () => Navigator.of(sheetContext).pop(), - expand: true, + const SizedBox(height: AppSpacing.lg), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(sheetContext.l10n.settingsAmoledTitle), + subtitle: Text(sheetContext.l10n.settingsAmoledSubtitle), + value: settings.amoled, + onChanged: (value) { + ref.read(themeSettingsProvider.notifier).setAmoled(value); + }, ), ], ); @@ -809,205 +602,46 @@ class SettingsScreen extends ConsumerWidget { ); } - Future _showOperationalTelemetrySheet( + Future _showLanguageSelector( BuildContext context, WidgetRef ref, ) async { await showAppSheet( context: context, builder: (sheetContext) { - final maxHeight = MediaQuery.sizeOf(sheetContext).height * 0.72; - return SizedBox( - height: maxHeight, - child: Consumer( - builder: (sheetContext, ref, _) { - final historyAsync = ref.watch( - operationalTelemetryHistoryProvider, - ); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Operational Telemetry', - style: Theme.of(sheetContext).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.w700), - ), - const SizedBox(height: AppSpacing.sm), - Text( - 'Recent sync/data/runtime events captured locally for debug.', - style: Theme.of(sheetContext).textTheme.bodyMedium - ?.copyWith( - color: Theme.of( - sheetContext, - ).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: AppSpacing.md), - Expanded( - child: historyAsync.when( - data: (entries) { - if (entries.isEmpty) { - return Center( - child: Text( - 'No telemetry events yet.', - style: Theme.of(sheetContext).textTheme.bodyMedium - ?.copyWith( - color: Theme.of( - sheetContext, - ).colorScheme.onSurfaceVariant, - ), - ), - ); - } - - return ListView.separated( - itemCount: entries.length, - separatorBuilder: (_, _) => - const SizedBox(height: AppSpacing.sm), - itemBuilder: (context, index) { - return _OperationalTelemetryEventTile( - entry: entries[index], - ); - }, - ); - }, - loading: () => - const Center(child: CircularProgressIndicator()), - error: (error, _) => Center( - child: Text( - 'Failed to load telemetry: $error', - textAlign: TextAlign.center, - ), - ), - ), - ), - const SizedBox(height: AppSpacing.md), - Wrap( - spacing: AppSpacing.sm, - runSpacing: AppSpacing.sm, - children: [ - AppButton( - label: 'Refresh', - variant: AppButtonVariant.secondary, - onPressed: () => - refreshOperationalTelemetryHistory(ref), - ), - AppButton( - label: 'Clear', - variant: AppButtonVariant.ghost, - onPressed: () => clearOperationalTelemetryHistory(ref), - ), - AppButton( - label: sheetContext.l10n.actionDismiss, - variant: AppButtonVariant.ghost, - onPressed: () => Navigator.of(sheetContext).pop(), - ), - ], - ), - ], - ); - }, - ), - ); - }, - ); - } - - Future _showThemeSelector(BuildContext context, WidgetRef ref) async { - await showAppSheet( - context: context, - builder: (context) { return Consumer( - builder: (context, ref, _) { - final settings = ref.watch(themeSettingsProvider); - + builder: (sheetContext, ref, _) { + final selectedLanguage = ref.watch(localeSettingsProvider); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - context.l10n.settingsThemeModeTitle, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: AppSpacing.md), - Wrap( - spacing: AppSpacing.sm, - runSpacing: AppSpacing.sm, - children: ThemeMode.values.map((mode) { - final isSelected = settings.mode == mode; - return ChoiceChip( - label: Text(_themeModeLabel(context, mode)), - selected: isSelected, - onSelected: (selected) { - if (!selected) return; - ref - .read(themeSettingsProvider.notifier) - .setThemeMode(mode); - }, - ); - }).toList(), - ), - const SizedBox(height: AppSpacing.xl), - Text( - context.l10n.settingsThemeColorVariantTitle, - style: Theme.of(context).textTheme.titleMedium?.copyWith( + sheetContext.l10n.settingsLanguageTitle, + style: Theme.of(sheetContext).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), - const SizedBox(height: AppSpacing.md), - SizedBox( - height: 56, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: AppThemeVariant.values.length, - separatorBuilder: (_, _) => - const SizedBox(width: AppSpacing.md), - itemBuilder: (context, index) { - final variant = AppThemeVariant.values[index]; - final isSelected = settings.variant == variant; - return GestureDetector( - onTap: () { - ref - .read(themeSettingsProvider.notifier) - .setThemeVariant(variant); - }, - child: AnimatedContainer( - duration: AppMotion.fast, - curve: AppMotion.emphasized, - width: 46, - height: 46, - decoration: BoxDecoration( - color: variant.color, - shape: BoxShape.circle, - border: isSelected - ? Border.all( - color: Theme.of( - context, - ).colorScheme.primary, - width: 3, - ) - : null, - ), - child: isSelected - ? const Icon(Icons.check, color: Colors.white) - : null, - ), - ); + const SizedBox(height: AppSpacing.sm), + ...AppLanguage.values.map((language) { + final selected = selectedLanguage == language; + return ListTile( + contentPadding: EdgeInsets.zero, + title: Text(_languageLabel(sheetContext, language)), + trailing: selected + ? Icon( + Icons.check_circle_rounded, + color: Theme.of(sheetContext).colorScheme.primary, + ) + : null, + onTap: () { + ref + .read(localeSettingsProvider.notifier) + .setLanguage(language); + Navigator.pop(sheetContext); }, - ), - ), - const SizedBox(height: AppSpacing.lg), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(context.l10n.settingsAmoledTitle), - subtitle: Text(context.l10n.settingsAmoledSubtitle), - value: settings.amoled, - onChanged: (value) { - ref.read(themeSettingsProvider.notifier).setAmoled(value); - }, - ), + ); + }), ], ); }, @@ -1022,10 +656,10 @@ class SettingsScreen extends ConsumerWidget { ) async { await showAppSheet( context: context, - builder: (context) { + builder: (sheetContext) { return Consumer( - builder: (context, ref, _) { - final l10n = context.l10n; + builder: (sheetContext, ref, _) { + final l10n = sheetContext.l10n; final preferences = ref.watch(analyticsPreferencesProvider); final notifier = ref.read(analyticsPreferencesProvider.notifier); final consentLabel = switch (preferences.consentStatus) { @@ -1043,15 +677,15 @@ class SettingsScreen extends ConsumerWidget { children: [ Text( l10n.settingsAnalyticsSheetTitle, - style: Theme.of(context).textTheme.titleMedium?.copyWith( + style: Theme.of(sheetContext).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: AppSpacing.sm), Text( l10n.settingsAnalyticsDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( + color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, ), ), const SizedBox(height: AppSpacing.md), @@ -1082,14 +716,16 @@ class SettingsScreen extends ConsumerWidget { label: l10n.settingsAnalyticsReviewConsentAction, onPressed: () async { final decision = await showAnalyticsConsentDialog( - context, + sheetContext, barrierDismissible: true, ); - if (!context.mounted) return; + if (!sheetContext.mounted) { + return; + } if (decision == AnalyticsConsentDecision.allow) { await notifier.grantConsent(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( + if (sheetContext.mounted) { + ScaffoldMessenger.of(sheetContext).showSnackBar( SnackBar( content: Text( l10n.settingsAnalyticsConsentAccepted, @@ -1099,8 +735,8 @@ class SettingsScreen extends ConsumerWidget { } } else { await notifier.denyConsent(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( + if (sheetContext.mounted) { + ScaffoldMessenger.of(sheetContext).showSnackBar( SnackBar( content: Text( l10n.settingsAnalyticsConsentDeclined, @@ -1118,8 +754,8 @@ class SettingsScreen extends ConsumerWidget { variant: AppButtonVariant.ghost, onPressed: () async { await notifier.denyConsent(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( + if (sheetContext.mounted) { + ScaffoldMessenger.of(sheetContext).showSnackBar( SnackBar( content: Text( l10n.settingsAnalyticsConsentDeclined, @@ -1139,522 +775,48 @@ class SettingsScreen extends ConsumerWidget { ); } - Future _showPushNotificationSettings( - BuildContext context, - WidgetRef ref, - ) async { - await showAppSheet( - context: context, - builder: (context) { - return Consumer( - builder: (context, ref, _) { - final preferences = ref.watch(pushNotificationPreferencesProvider); - final notifier = ref.read( - pushNotificationPreferencesProvider.notifier, - ); - - return SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Push Notifications', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: AppSpacing.sm), - Text( - preferences.runtimeFeatureEnabled - ? 'Manage sync-needed, price alert, reminder, and account security notifications.' - : 'Push notifications are currently disabled by runtime feature flag.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: AppSpacing.md), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Enable push notifications'), - subtitle: Text( - _pushPermissionLabel(preferences.permissionStatus), - ), - value: preferences.preferenceEnabled, - onChanged: preferences.runtimeFeatureEnabled - ? (value) async { - await notifier.setPreferenceEnabled(value); - if (!context.mounted) { - return; - } - final updated = ref.read( - pushNotificationPreferencesProvider, - ); - if (value && !updated.preferenceEnabled) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Notification permission is not granted.', - ), - ), - ); - } - } - : null, - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.tune_outlined), - title: const Text('Feature flag'), - subtitle: const Text('app_fcm_enabled'), - trailing: Text( - _enabledDisabledLabel( - context, - preferences.runtimeFeatureEnabled, - ), - ), - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.perm_device_information_outlined), - title: const Text('Permission'), - subtitle: Text( - _pushPermissionLabel(preferences.permissionStatus), - ), - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Sync needed'), - subtitle: const Text('Topic: sync_needed'), - value: preferences.syncNeededEnabled, - onChanged: preferences.isEffectivelyEnabled - ? (value) { - notifier.setTopicEnabled( - PushNotificationTopic.syncNeeded, - value, - ); - } - : null, - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Price alerts'), - subtitle: const Text('Topic: price_alerts'), - value: preferences.priceAlertsEnabled, - onChanged: preferences.isEffectivelyEnabled - ? (value) { - notifier.setTopicEnabled( - PushNotificationTopic.priceAlerts, - value, - ); - } - : null, - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Reminders'), - subtitle: const Text('Topic: reminders'), - value: preferences.remindersEnabled, - onChanged: preferences.isEffectivelyEnabled - ? (value) { - notifier.setTopicEnabled( - PushNotificationTopic.reminders, - value, - ); - } - : null, - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Account security'), - subtitle: const Text('Topic: account_security'), - value: preferences.accountSecurityEnabled, - onChanged: preferences.isEffectivelyEnabled - ? (value) { - notifier.setTopicEnabled( - PushNotificationTopic.accountSecurity, - value, - ); - } - : null, - ), - if (defaultTargetPlatform == TargetPlatform.iOS || - defaultTargetPlatform == TargetPlatform.macOS) - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.phone_iphone_outlined), - title: const Text('APNs token'), - subtitle: Text( - _apnsTokenLabel(preferences.apnsToken), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.vpn_key_outlined), - title: const Text('Device token'), - subtitle: Text( - preferences.deviceToken != null && - preferences.deviceToken!.trim().isNotEmpty - ? _truncateToken(preferences.deviceToken!) - : 'Not available yet', - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - if (defaultTargetPlatform == TargetPlatform.iOS && - (preferences.apnsToken == null || - preferences.apnsToken!.trim().isEmpty)) - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.info_outline), - title: const Text('iOS Simulator note'), - subtitle: Text( - 'APNs token is often unavailable on simulator. Test FCM delivery on a physical iPhone.', - ), - ), - const SizedBox(height: AppSpacing.sm), - AppButton( - label: preferences.isApplying - ? 'Applying...' - : 'Refresh permission status', - onPressed: preferences.isApplying - ? null - : () => notifier.refreshPermissionStatus(), - ), - ], - ), - ); - }, - ); - }, - ); + String _cloudSyncSummary( + SyncReadinessState readiness, { + required int pendingSyncCount, + }) { + return switch (readiness.status) { + SyncReadinessStatus.ready => + pendingSyncCount > 0 + ? 'Ready • $pendingSyncCount pending change(s)' + : 'Ready', + SyncReadinessStatus.disabledByFeatureFlag => 'Unavailable', + SyncReadinessStatus.missingApiConfiguration => 'Configuration required', + SyncReadinessStatus.checkingAuthentication => 'Checking session...', + SyncReadinessStatus.authenticationRequired => 'Sign in required', + }; } - Future _showFirebaseRuntimeConfigSheet( - BuildContext context, - WidgetRef ref, - ) async { - await showAppSheet( - context: context, - builder: (context) { - return Consumer( - builder: (context, ref, _) { - final l10n = context.l10n; - final runtimeConfig = ref.watch(firebaseRuntimeConfigProvider); - final remoteConfigStatus = ref.watch( - firebaseRemoteConfigStatusProvider, - ); - final isRefreshing = ref.watch( - firebaseRuntimeConfigRefreshInProgressProvider, - ); - final lastFetchTimeText = _lastFetchTimeLabel( - context, - remoteConfigStatus.lastFetchTime, - ); + String _authAccountSummary( + AuthSession? session, + BackendApiReadiness readiness, + ) { + if (!readiness.enabled) { + final message = readiness.message.toLowerCase(); + if (message.contains('missing') || message.contains('configure')) { + return 'Configuration required'; + } + return 'Unavailable'; + } + if (session == null || !session.isAuthenticated) { + return 'Not signed in'; + } + return 'Signed in'; + } - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.settingsFirebaseRuntimeConfigSheetTitle, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: AppSpacing.sm), - Text( - l10n.settingsFirebaseRuntimeConfigDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: AppSpacing.md), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.insights_outlined), - title: Text(l10n.settingsFirebaseRuntimeConfigAnalyticsLabel), - subtitle: const Text('app_analytics_collection_enabled'), - trailing: Text( - _enabledDisabledLabel( - context, - runtimeConfig.analyticsCollectionEnabled, - ), - ), - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.bug_report_outlined), - title: Text( - l10n.settingsFirebaseRuntimeConfigCrashlyticsLabel, - ), - subtitle: const Text('app_crashlytics_collection_enabled'), - trailing: Text( - _enabledDisabledLabel( - context, - runtimeConfig.crashlyticsCollectionEnabled, - ), - ), - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.speed_outlined), - title: Text( - l10n.settingsFirebaseRuntimeConfigPerformanceLabel, - ), - subtitle: const Text('app_performance_collection_enabled'), - trailing: Text( - _enabledDisabledLabel( - context, - runtimeConfig.performanceCollectionEnabled, - ), - ), - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.verified_user_outlined), - title: const Text('App Check'), - subtitle: const Text('app_app_check_enabled'), - trailing: Text( - _enabledDisabledLabel( - context, - runtimeConfig.appCheckEnabled, - ), - ), - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.notifications_active_outlined), - title: const Text('Push notifications'), - subtitle: const Text('app_fcm_enabled'), - trailing: Text( - _enabledDisabledLabel(context, runtimeConfig.fcmEnabled), - ), - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.hub_outlined), - title: const Text('Backend integration'), - subtitle: const Text('app_backend_integration_enabled'), - trailing: Text( - _enabledDisabledLabel( - context, - runtimeConfig.backendIntegrationEnabled, - ), - ), - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.person_outline_rounded), - title: const Text('Authentication'), - subtitle: const Text('app_auth_feature_enabled'), - trailing: Text( - _enabledDisabledLabel( - context, - runtimeConfig.authFeatureEnabled, - ), - ), - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.cloud_sync_outlined), - title: Text(l10n.settingsCloudSyncTitle), - subtitle: const Text('app_sync_feature_enabled'), - trailing: Text( - _enabledDisabledLabel( - context, - runtimeConfig.syncFeatureEnabled, - ), - ), - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.sync_alt_outlined), - title: Text( - l10n.settingsFirebaseRuntimeConfigFetchStatusTitle, - ), - subtitle: Text( - _remoteConfigFetchStatusLabel( - context, - remoteConfigStatus.lastFetchStatus, - ), - ), - ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.schedule_outlined), - title: Text(l10n.settingsFirebaseRuntimeConfigLastFetchTitle), - subtitle: Text(lastFetchTimeText), - ), - const SizedBox(height: AppSpacing.md), - AppButton( - label: isRefreshing - ? l10n.settingsFirebaseRuntimeConfigRefreshingAction - : l10n.settingsFirebaseRuntimeConfigRefreshAction, - onPressed: isRefreshing - ? null - : () => _refreshFirebaseRuntimeConfig(context, ref), - ), - ], - ); - }, - ); - }, - ); - } - - Future _refreshFirebaseRuntimeConfig( - BuildContext context, - WidgetRef ref, - ) async { - final isRefreshing = ref.read( - firebaseRuntimeConfigRefreshInProgressProvider, - ); - if (isRefreshing) { - return; - } - - final l10n = context.l10n; - - try { - final result = await ref - .read(firebaseRuntimeConfigControllerProvider.notifier) - .refreshFromRemoteConfig(forceFetch: true); - await ref - .read(analyticsPreferencesProvider.notifier) - .syncToAnalyticsService(); - - if (!context.mounted) { - return; - } - - final message = result.didActivateChanges - ? l10n.settingsFirebaseRuntimeConfigRefreshSuccess - : l10n.settingsFirebaseRuntimeConfigRefreshNoChanges; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); - } catch (error, stackTrace) { - Logger.error( - 'Failed to refresh Firebase runtime config.', - error, - stackTrace, - ); - if (!context.mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - l10n.settingsFirebaseRuntimeConfigRefreshFailed('$error'), - ), - backgroundColor: Colors.red, - ), - ); - } - } - - Future _handleCrashlyticsTest(BuildContext context) async { - final l10n = context.l10n; - final shouldCrash = await showAppDialog( - context: context, - title: Text(l10n.settingsCrashlyticsTestConfirmTitle), - content: Text(l10n.settingsCrashlyticsTestConfirmMessage), - actions: [ - AppButton( - label: l10n.actionCancel, - variant: AppButtonVariant.ghost, - onPressed: () => closeAppDialog(context, false), - ), - AppButton( - label: l10n.settingsCrashlyticsTestConfirmAction, - variant: AppButtonVariant.danger, - onPressed: () => closeAppDialog(context, true), - ), - ], - ); - - if (shouldCrash != true || !context.mounted) return; - - try { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.settingsCrashlyticsTestTriggered)), - ); - - await FirebaseCrashlytics.instance.log( - 'Manual Crashlytics test from debug settings action.', - ); - await FirebaseCrashlytics.instance.setCustomKey( - 'debug_action', - 'settings_crashlytics_test', - ); - - await Future.delayed(const Duration(milliseconds: 350)); - FirebaseCrashlytics.instance.crash(); - } catch (error, stackTrace) { - Logger.error( - 'Failed to trigger Crashlytics test crash.', - error, - stackTrace, - ); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.settingsCrashlyticsTestFailed('$error')), - backgroundColor: Colors.red, - ), - ); - } - } - - Future _showLanguageSelector( - BuildContext context, - WidgetRef ref, - ) async { - await showAppSheet( - context: context, - builder: (context) { - return Consumer( - builder: (context, ref, _) { - final selectedLanguage = ref.watch(localeSettingsProvider); - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.settingsLanguageTitle, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: AppSpacing.sm), - ...AppLanguage.values.map((language) { - final selected = selectedLanguage == language; - return ListTile( - contentPadding: EdgeInsets.zero, - title: Text(_languageLabel(context, language)), - trailing: selected - ? Icon( - Icons.check_circle_rounded, - color: Theme.of(context).colorScheme.primary, - ) - : null, - onTap: () { - ref - .read(localeSettingsProvider.notifier) - .setLanguage(language); - Navigator.pop(context); - }, - ); - }), - ], - ); - }, - ); - }, - ); + String _cloudSyncPrimaryCta(SyncReadinessStatus status) { + return switch (status) { + SyncReadinessStatus.ready => 'Sync now', + SyncReadinessStatus.authenticationRequired => 'Sign in', + SyncReadinessStatus.missingApiConfiguration => + kDebugMode ? 'Open dev tools' : 'Unavailable', + SyncReadinessStatus.disabledByFeatureFlag => 'Feature disabled', + SyncReadinessStatus.checkingAuthentication => 'Checking session', + }; } String _themeModeLabel(BuildContext context, ThemeMode mode) { @@ -1718,407 +880,4 @@ class SettingsScreen extends ConsumerWidget { return 'Enabled ($enabledTopics topics)'; } - - String _pushPermissionLabel(FirebaseMessagingPermissionStatus status) { - return switch (status) { - FirebaseMessagingPermissionStatus.notDetermined => 'Not determined', - FirebaseMessagingPermissionStatus.denied => 'Denied', - FirebaseMessagingPermissionStatus.authorized => 'Authorized', - FirebaseMessagingPermissionStatus.provisional => 'Provisional', - FirebaseMessagingPermissionStatus.unsupported => 'Unsupported', - }; - } - - String _truncateToken(String token) { - final sanitized = token.trim(); - if (sanitized.length <= 18) { - return sanitized; - } - return '${sanitized.substring(0, 10)}...${sanitized.substring(sanitized.length - 8)}'; - } - - String _apnsTokenLabel(String? apnsToken) { - final sanitized = apnsToken?.trim() ?? ''; - if (sanitized.isEmpty) { - return 'Not available'; - } - return _truncateToken(sanitized); - } - - String _firebaseRuntimeSummary( - BuildContext context, - FirebaseRuntimeConfig config, - ) { - final enabledCount = [ - config.analyticsCollectionEnabled, - config.crashlyticsCollectionEnabled, - config.performanceCollectionEnabled, - ].where((value) => value).length; - return context.l10n.settingsFirebaseRuntimeConfigSummary(enabledCount); - } - - String _enabledDisabledLabel(BuildContext context, bool enabled) { - final l10n = context.l10n; - return enabled - ? l10n.settingsFirebaseRuntimeConfigValueEnabled - : l10n.settingsFirebaseRuntimeConfigValueDisabled; - } - - String _remoteConfigFetchStatusLabel( - BuildContext context, - Object? lastFetchStatus, - ) { - final l10n = context.l10n; - final statusText = lastFetchStatus?.toString() ?? ''; - - if (statusText.contains('success')) { - return l10n.settingsFirebaseRuntimeConfigFetchStatusSuccess; - } - if (statusText.contains('failure')) { - return l10n.settingsFirebaseRuntimeConfigFetchStatusFailure; - } - if (statusText.contains('throttle')) { - return l10n.settingsFirebaseRuntimeConfigFetchStatusThrottled; - } - - return l10n.settingsFirebaseRuntimeConfigFetchStatusNoFetch; - } - - String _lastFetchTimeLabel(BuildContext context, DateTime? lastFetchTime) { - if (lastFetchTime == null) { - return context.l10n.settingsFirebaseRuntimeConfigFetchStatusNoFetch; - } - - final materialLocalizations = MaterialLocalizations.of(context); - final localTime = lastFetchTime.toLocal(); - final dateLabel = materialLocalizations.formatShortDate(localTime); - final timeLabel = materialLocalizations.formatTimeOfDay( - TimeOfDay.fromDateTime(localTime), - alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), - ); - - return '$dateLabel $timeLabel'; - } -} - -class _SettingsSection extends StatelessWidget { - final String title; - final List children; - - const _SettingsSection({required this.title, required this.children}); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: AppSpacing.xs), - child: Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(height: AppSpacing.sm), - AppCard( - padding: EdgeInsets.zero, - child: Column(children: _withDividers(context, children)), - ), - ], - ); - } - - static List _withDividers(BuildContext context, List items) { - if (items.isEmpty) return const []; - final out = []; - for (var i = 0; i < items.length; i++) { - out.add(items[i]); - if (i < items.length - 1) { - out.add( - Divider( - height: 1, - color: Theme.of(context).colorScheme.outlineVariant, - ), - ); - } - } - return out; - } -} - -class _FirebaseRuntimeHealthCard extends StatelessWidget { - const _FirebaseRuntimeHealthCard({required this.config}); - - final FirebaseRuntimeConfig config; - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final enabledCount = [ - config.analyticsCollectionEnabled, - config.crashlyticsCollectionEnabled, - config.performanceCollectionEnabled, - ].where((value) => value).length; - - return AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.settingsFirebaseRuntimeConfigTitle, - style: Theme.of( - context, - ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), - ), - const SizedBox(height: AppSpacing.xs), - Text( - l10n.settingsFirebaseRuntimeConfigSummary(enabledCount), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: AppSpacing.sm), - _FirebaseFlagStatusRow( - icon: Icons.insights_outlined, - label: l10n.settingsFirebaseRuntimeConfigAnalyticsLabel, - enabled: config.analyticsCollectionEnabled, - ), - const SizedBox(height: AppSpacing.xs), - _FirebaseFlagStatusRow( - icon: Icons.bug_report_outlined, - label: l10n.settingsFirebaseRuntimeConfigCrashlyticsLabel, - enabled: config.crashlyticsCollectionEnabled, - ), - const SizedBox(height: AppSpacing.xs), - _FirebaseFlagStatusRow( - icon: Icons.speed_outlined, - label: l10n.settingsFirebaseRuntimeConfigPerformanceLabel, - enabled: config.performanceCollectionEnabled, - ), - ], - ), - ); - } -} - -class _FirebaseFlagStatusRow extends StatelessWidget { - const _FirebaseFlagStatusRow({ - required this.icon, - required this.label, - required this.enabled, - }); - - final IconData icon; - final String label; - final bool enabled; - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final labelText = enabled - ? l10n.settingsFirebaseRuntimeConfigValueEnabled - : l10n.settingsFirebaseRuntimeConfigValueDisabled; - final badgeColor = enabled - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.outline; - final badgeForeground = enabled - ? Theme.of(context).colorScheme.onPrimary - : Theme.of(context).colorScheme.onSurfaceVariant; - - return Row( - children: [ - Icon( - icon, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text(label, style: Theme.of(context).textTheme.bodyMedium), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.xs, - ), - decoration: BoxDecoration( - color: badgeColor, - borderRadius: BorderRadius.circular(AppRadii.pill), - ), - child: Text( - labelText, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: badgeForeground, - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ); - } -} - -class _CloudSyncStateRow extends StatelessWidget { - const _CloudSyncStateRow({required this.label, required this.value}); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - final valueStyle = Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ); - - return Row( - children: [ - SizedBox( - width: 112, - child: Text(label, style: Theme.of(context).textTheme.bodyMedium), - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - value, - style: valueStyle, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ); - } -} - -class _OperationalTelemetryEventTile extends StatelessWidget { - const _OperationalTelemetryEventTile({required this.entry}); - - final Map entry; - - @override - Widget build(BuildContext context) { - final name = entry['name'] as String? ?? 'unknown_event'; - final category = entry['category'] as String? ?? 'unknown'; - final hasError = entry['has_error'] as bool? ?? false; - final timestamp = _formatTimestamp(entry['timestamp'] as String?); - final rawProperties = entry['properties']; - final properties = rawProperties is Map - ? rawProperties.cast() - : const {}; - final preview = properties.entries - .take(4) - .map((entry) => '${entry.key}: ${entry.value}') - .join(' | '); - - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppSpacing.md), - border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.25), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - hasError ? Icons.error_outline : Icons.check_circle_outline, - size: 18, - color: hasError - ? Theme.of(context).colorScheme.error - : Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: AppSpacing.xs), - Expanded( - child: Text( - name, - style: Theme.of( - context, - ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), - ), - ), - ], - ), - const SizedBox(height: AppSpacing.xs), - Text( - '$category • $timestamp', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - if (preview.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.xs), - Text( - preview, - style: Theme.of(context).textTheme.bodySmall, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ); - } - - String _formatTimestamp(String? value) { - if (value == null || value.isEmpty) { - return 'unknown time'; - } - final parsed = DateTime.tryParse(value); - if (parsed == null) { - return value; - } - final local = parsed.toLocal(); - return '${local.year}-${_two(local.month)}-${_two(local.day)} ' - '${_two(local.hour)}:${_two(local.minute)}:${_two(local.second)}'; - } - - String _two(int value) => value < 10 ? '0$value' : '$value'; -} - -class _SettingsTile extends StatelessWidget { - final IconData icon; - final String title; - final String? subtitle; - final VoidCallback? onTap; - final bool enabled; - - const _SettingsTile({ - required this.icon, - required this.title, - this.subtitle, - this.onTap, - this.enabled = true, - }); - - @override - Widget build(BuildContext context) { - final canTap = enabled && onTap != null; - - return ListTile( - enabled: enabled, - leading: Icon(icon), - title: Text(title), - subtitle: subtitle != null ? Text(subtitle!) : null, - trailing: canTap - ? Icon( - Icons.chevron_right_rounded, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ) - : (!enabled - ? Icon( - Icons.lock_outline_rounded, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ) - : null), - onTap: canTap ? onTap : null, - ); - } } diff --git a/apps/mobile/lib/features/settings/presentation/widgets/settings_primitives.dart b/apps/mobile/lib/features/settings/presentation/widgets/settings_primitives.dart new file mode 100644 index 0000000..8f43729 --- /dev/null +++ b/apps/mobile/lib/features/settings/presentation/widgets/settings_primitives.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:ui/ui.dart'; + +class SettingsSection extends StatelessWidget { + const SettingsSection({ + required this.title, + required this.children, + this.trailing, + super.key, + }); + + final String title; + final List children; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: AppSpacing.xs), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + ?trailing, + ], + ), + ), + const SizedBox(height: AppSpacing.sm), + AppCard( + padding: EdgeInsets.zero, + child: Column(children: _withDividers(context, children)), + ), + ], + ); + } + + static List _withDividers(BuildContext context, List items) { + if (items.isEmpty) { + return const []; + } + final out = []; + for (var i = 0; i < items.length; i++) { + out.add(items[i]); + if (i < items.length - 1) { + out.add( + Divider( + height: 1, + color: Theme.of(context).colorScheme.outlineVariant, + ), + ); + } + } + return out; + } +} + +class SettingsTile extends StatelessWidget { + const SettingsTile({ + required this.icon, + required this.title, + this.subtitle, + this.onTap, + this.enabled = true, + super.key, + }); + + final IconData icon; + final String title; + final String? subtitle; + final VoidCallback? onTap; + final bool enabled; + + @override + Widget build(BuildContext context) { + final canTap = enabled && onTap != null; + final colors = Theme.of(context).colorScheme; + + return ListTile( + enabled: enabled, + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadii.sm), + ), + child: Icon(icon, size: 20, color: colors.primary), + ), + title: Text(title), + subtitle: subtitle == null + ? null + : Text(subtitle!, maxLines: 2, overflow: TextOverflow.ellipsis), + trailing: canTap + ? Icon(Icons.chevron_right_rounded, color: colors.onSurfaceVariant) + : (!enabled + ? Icon( + Icons.lock_outline_rounded, + color: colors.onSurfaceVariant, + ) + : null), + onTap: canTap ? onTap : null, + ); + } +} + +class SettingsStatusCard extends StatelessWidget { + const SettingsStatusCard({ + required this.title, + required this.firstLabel, + required this.secondLabel, + required this.syncStatus, + required this.notificationStatus, + super.key, + }); + + final String title; + final String firstLabel; + final String secondLabel; + final String syncStatus; + final String notificationStatus; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final colors = Theme.of(context).colorScheme; + + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: AppSpacing.md), + _StatusRow( + icon: Icons.cloud_done_outlined, + label: firstLabel, + value: syncStatus, + ), + const SizedBox(height: AppSpacing.sm), + Divider(height: 1, color: colors.outlineVariant), + const SizedBox(height: AppSpacing.sm), + _StatusRow( + icon: Icons.notifications_active_outlined, + label: secondLabel, + value: notificationStatus, + ), + ], + ), + ); + } +} + +class _StatusRow extends StatelessWidget { + const _StatusRow({ + required this.icon, + required this.label, + required this.value, + }); + + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon( + icon, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: AppSpacing.sm), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ); + } +} From 655a9129a06a65e56780141f309f211641fe72c2 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Mon, 23 Feb 2026 11:35:53 +0630 Subject: [PATCH 31/31] ci: fix firebase setup script --- .github/workflows/ci.yaml | 12 - .github/workflows/release.yaml | 4 - README.md | 3 + apps/mobile/android/app/build.gradle.kts | 1 + apps/mobile/android/settings.gradle.kts | 1 + .../ios/Runner.xcodeproj/project.pbxproj | 19 ++ .../macos/Runner.xcodeproj/project.pbxproj | 19 ++ documentation/SETUP_AND_RUN.md | 19 ++ scripts/setup_firebase.sh | 283 ++++++++++++++++-- 9 files changed, 322 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fc70e64..71e2773 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -55,12 +55,6 @@ jobs: 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 }} - FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST: ${{ secrets.FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST }} - FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64: ${{ secrets.FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64 }} - FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST: ${{ secrets.FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST }} - FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST_BASE64: ${{ secrets.FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST_BASE64 }} run: | chmod +x scripts/setup_firebase.sh ./scripts/setup_firebase.sh --require dart @@ -111,12 +105,6 @@ jobs: 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 }} - FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST: ${{ secrets.FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST }} - FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64: ${{ secrets.FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64 }} - FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST: ${{ secrets.FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST }} - FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST_BASE64: ${{ secrets.FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST_BASE64 }} run: | chmod +x scripts/setup_firebase.sh ./scripts/setup_firebase.sh --require dart diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 03a4aa9..5539c75 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -54,10 +54,6 @@ jobs: 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 }} - FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST: ${{ secrets.FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST }} - FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64: ${{ secrets.FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64 }} - FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST: ${{ secrets.FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST }} - FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST_BASE64: ${{ secrets.FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST_BASE64 }} run: | chmod +x scripts/setup_firebase.sh ./scripts/setup_firebase.sh --require dart --require android diff --git a/README.md b/README.md index 99c747a..33b71dc 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,9 @@ GitHub Actions currently runs: Firebase secrets are materialized during CI using `scripts/setup_firebase.sh`. +- `CI analyze/test` requires: `FIREBASE_OPTIONS_DART` (or `_BASE64`) +- `Release Android` requires: `FIREBASE_OPTIONS_DART` + `FIREBASE_ANDROID_GOOGLE_SERVICES_JSON` (or `_BASE64`) + ## License MIT. See [LICENSE](LICENSE). diff --git a/apps/mobile/android/app/build.gradle.kts b/apps/mobile/android/app/build.gradle.kts index 0d7a8c4..3fdac2a 100644 --- a/apps/mobile/android/app/build.gradle.kts +++ b/apps/mobile/android/app/build.gradle.kts @@ -5,6 +5,7 @@ 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. diff --git a/apps/mobile/android/settings.gradle.kts b/apps/mobile/android/settings.gradle.kts index 174f408..d6b1b1b 100644 --- a/apps/mobile/android/settings.gradle.kts +++ b/apps/mobile/android/settings.gradle.kts @@ -22,6 +22,7 @@ plugins { id("com.android.application") version "8.11.1" apply false // START: FlutterFire Configuration id("com.google.gms.google-services") version("4.3.15") apply false + id("com.google.firebase.crashlytics") version("2.8.1") apply false // END: FlutterFire Configuration id("org.jetbrains.kotlin.android") version "2.2.20" apply false } diff --git a/apps/mobile/ios/Runner.xcodeproj/project.pbxproj b/apps/mobile/ios/Runner.xcodeproj/project.pbxproj index 587f021..6fbfae1 100644 --- a/apps/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -211,6 +211,7 @@ 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 604DCC3D9670E850D46834B2 /* [CP] Embed Pods Frameworks */, 1F64168C46FC184669E421E7 /* [CP] Copy Pods Resources */, + 80DD7663B4C5736C708DB96B /* FlutterFire: "flutterfire upload-crashlytics-symbols" */, ); buildRules = ( ); @@ -339,6 +340,24 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + 80DD7663B4C5736C708DB96B /* FlutterFire: "flutterfire upload-crashlytics-symbols" */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "FlutterFire: \"flutterfire upload-crashlytics-symbols\""; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\n#!/bin/bash\nPATH=\"${PATH}:$FLUTTER_ROOT/bin:${PUB_CACHE}/bin:$HOME/.pub-cache/bin\"\n\nif [ -z \"$PODS_ROOT\" ] || [ ! -d \"$PODS_ROOT/FirebaseCrashlytics\" ]; then\n # Cannot use \"BUILD_DIR%/Build/*\" as per Firebase documentation, it points to \"flutter-project/build/ios/*\" path which doesn't have run script\n DERIVED_DATA_PATH=$(echo \"$BUILD_ROOT\" | sed -E 's|(.*DerivedData/[^/]+).*|\\1|')\n PATH_TO_CRASHLYTICS_UPLOAD_SCRIPT=\"${DERIVED_DATA_PATH}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\nelse\n PATH_TO_CRASHLYTICS_UPLOAD_SCRIPT=\"$PODS_ROOT/FirebaseCrashlytics/run\"\nfi\n\n# Command to upload symbols script used to upload symbols to Firebase server\nflutterfire upload-crashlytics-symbols --upload-symbols-script-path=\"$PATH_TO_CRASHLYTICS_UPLOAD_SCRIPT\" --platform=ios --apple-project-path=\"${SRCROOT}\" --env-platform-name=\"${PLATFORM_NAME}\" --env-configuration=\"${CONFIGURATION}\" --env-project-dir=\"${PROJECT_DIR}\" --env-built-products-dir=\"${BUILT_PRODUCTS_DIR}\" --env-dwarf-dsym-folder-path=\"${DWARF_DSYM_FOLDER_PATH}\" --env-dwarf-dsym-file-name=\"${DWARF_DSYM_FILE_NAME}\" --env-infoplist-path=\"${INFOPLIST_PATH}\" --default-config=default\n"; + }; 9093DFB3836001CFC7BF27B7 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; diff --git a/apps/mobile/macos/Runner.xcodeproj/project.pbxproj b/apps/mobile/macos/Runner.xcodeproj/project.pbxproj index 563e734..971a6c1 100644 --- a/apps/mobile/macos/Runner.xcodeproj/project.pbxproj +++ b/apps/mobile/macos/Runner.xcodeproj/project.pbxproj @@ -216,6 +216,7 @@ 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + 00EE99620ED07FC3D10F61CC /* FlutterFire: "flutterfire upload-crashlytics-symbols" */, ); buildRules = ( ); @@ -305,6 +306,24 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 00EE99620ED07FC3D10F61CC /* FlutterFire: "flutterfire upload-crashlytics-symbols" */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "FlutterFire: \"flutterfire upload-crashlytics-symbols\""; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\n#!/bin/bash\nPATH=\"${PATH}:$FLUTTER_ROOT/bin:${PUB_CACHE}/bin:$HOME/.pub-cache/bin\"\n\nif [ -z \"$PODS_ROOT\" ] || [ ! -d \"$PODS_ROOT/FirebaseCrashlytics\" ]; then\n # Cannot use \"BUILD_DIR%/Build/*\" as per Firebase documentation, it points to \"flutter-project/build/ios/*\" path which doesn't have run script\n DERIVED_DATA_PATH=$(echo \"$BUILD_ROOT\" | sed -E 's|(.*DerivedData/[^/]+).*|\\1|')\n PATH_TO_CRASHLYTICS_UPLOAD_SCRIPT=\"${DERIVED_DATA_PATH}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\nelse\n PATH_TO_CRASHLYTICS_UPLOAD_SCRIPT=\"$PODS_ROOT/FirebaseCrashlytics/run\"\nfi\n\n# Command to upload symbols script used to upload symbols to Firebase server\nflutterfire upload-crashlytics-symbols --upload-symbols-script-path=\"$PATH_TO_CRASHLYTICS_UPLOAD_SCRIPT\" --platform=macos --apple-project-path=\"${SRCROOT}\" --env-platform-name=\"${PLATFORM_NAME}\" --env-configuration=\"${CONFIGURATION}\" --env-project-dir=\"${PROJECT_DIR}\" --env-built-products-dir=\"${BUILT_PRODUCTS_DIR}\" --env-dwarf-dsym-folder-path=\"${DWARF_DSYM_FOLDER_PATH}\" --env-dwarf-dsym-file-name=\"${DWARF_DSYM_FILE_NAME}\" --env-infoplist-path=\"${INFOPLIST_PATH}\" --default-config=default\n"; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; diff --git a/documentation/SETUP_AND_RUN.md b/documentation/SETUP_AND_RUN.md index b30c204..deb2170 100644 --- a/documentation/SETUP_AND_RUN.md +++ b/documentation/SETUP_AND_RUN.md @@ -60,6 +60,25 @@ Supported env vars (raw or base64): - `FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST` or `FIREBASE_IOS_GOOGLE_SERVICE_INFO_PLIST_BASE64` - `FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST` or `FIREBASE_MACOS_GOOGLE_SERVICE_INFO_PLIST_BASE64` +### CI secret format recommendations + +For GitHub Actions, prefer `*_BASE64` secrets to avoid multiline/escaping issues: + +```bash +# macOS/Linux examples from workspace root +base64 < apps/mobile/lib/firebase_options.dart | tr -d '\n' +base64 < apps/mobile/android/app/google-services.json | tr -d '\n' +base64 < apps/mobile/ios/Runner/GoogleService-Info.plist | tr -d '\n' +base64 < apps/mobile/macos/Runner/GoogleService-Info.plist | tr -d '\n' +``` + +Notes: + +- Do not store file paths in secrets (store full file contents or base64 only). +- Do not wrap secret values in extra quotes. +- CI `analyze`/`test` currently requires only Dart Firebase options. +- CI `release` currently requires Dart + Android Firebase config. + ## 5. Generate Code ```bash diff --git a/scripts/setup_firebase.sh b/scripts/setup_firebase.sh index d11ccff..054edd4 100755 --- a/scripts/setup_firebase.sh +++ b/scripts/setup_firebase.sh @@ -83,41 +83,62 @@ decode_base64_to_file() { return 0 fi - printf '%s' "$encoded" | base64 -D > "$out_file" + if printf '%s' "$encoded" | base64 -D > "$out_file" 2>/dev/null; then + return 0 + fi + + return 1 +} + +write_plain_to_file() { + local value="$1" + local out_file="$2" + printf '%s' "$value" > "$out_file" } validate_target_config() { local target="$1" local out_file="$2" local source_label="$3" + local quiet="${4:-0}" if [[ ! -s "$out_file" ]]; then - echo "[error] '$target' config is empty from $source_label." >&2 + if [[ "$quiet" != "1" ]]; then + echo "[error] '$target' config is empty from $source_label." >&2 + fi return 1 fi case "$target" in dart) if ! grep -q "class DefaultFirebaseOptions" "$out_file"; then - echo "[error] '$out_file' does not look like FlutterFire Dart config." >&2 - echo " Check FIREBASE_OPTIONS_DART / FIREBASE_OPTIONS_DART_BASE64." >&2 - echo " Source used: $source_label" >&2 + if [[ "$quiet" != "1" ]]; then + echo "[error] '$out_file' does not look like FlutterFire Dart config." >&2 + echo " Check FIREBASE_OPTIONS_DART / FIREBASE_OPTIONS_DART_BASE64." >&2 + echo " Source used: $source_label" >&2 + fi return 1 fi ;; android) if ! grep -q '"project_info"' "$out_file"; then - echo "[error] '$out_file' does not look like Android google-services.json." >&2 - echo " Check FIREBASE_ANDROID_GOOGLE_SERVICES_JSON / _BASE64." >&2 - echo " Source used: $source_label" >&2 + if [[ "$quiet" != "1" ]]; then + echo "[error] '$out_file' does not look like Android google-services.json." >&2 + echo " Check FIREBASE_ANDROID_GOOGLE_SERVICES_JSON / _BASE64." >&2 + echo " Source used: $source_label" >&2 + fi return 1 fi ;; ios|macos) if ! grep -q "&2 - echo " Check FIREBASE_${target^^}_GOOGLE_SERVICE_INFO_PLIST / _BASE64." >&2 - echo " Source used: $source_label" >&2 + if [[ "$quiet" != "1" ]]; then + local target_upper + target_upper="$(printf '%s' "$target" | tr '[:lower:]' '[:upper:]')" + echo "[error] '$out_file' does not look like Apple GoogleService-Info.plist." >&2 + echo " Check FIREBASE_${target_upper}_GOOGLE_SERVICE_INFO_PLIST / _BASE64." >&2 + echo " Source used: $source_label" >&2 + fi return 1 fi ;; @@ -126,6 +147,195 @@ validate_target_config() { return 0 } +emit_invalid_target_error() { + local target="$1" + local out_file="$2" + local source_label="$3" + + case "$target" in + dart) + echo "[error] '$out_file' does not look like FlutterFire Dart config." >&2 + echo " Check FIREBASE_OPTIONS_DART / FIREBASE_OPTIONS_DART_BASE64." >&2 + echo " Source used: $source_label" >&2 + ;; + android) + echo "[error] '$out_file' does not look like Android google-services.json." >&2 + echo " Check FIREBASE_ANDROID_GOOGLE_SERVICES_JSON / _BASE64." >&2 + echo " Source used: $source_label" >&2 + ;; + ios|macos) + local target_upper + target_upper="$(printf '%s' "$target" | tr '[:lower:]' '[:upper:]')" + echo "[error] '$out_file' does not look like Apple GoogleService-Info.plist." >&2 + echo " Check FIREBASE_${target_upper}_GOOGLE_SERVICE_INFO_PLIST / _BASE64." >&2 + echo " Source used: $source_label" >&2 + ;; + esac +} + +strip_surrounding_quotes() { + local value="$1" + if [[ ${#value} -ge 2 ]]; then + local first="${value:0:1}" + local last="${value: -1}" + if [[ "$first" == "$last" && ( "$first" == "\"" || "$first" == "'" ) ]]; then + printf '%s' "${value:1:${#value}-2}" + return 0 + fi + fi + printf '%s' "$value" +} + +looks_like_file_path_secret() { + local value="$1" + if [[ "$value" == /* && "$value" != *$'\n'* ]]; then + return 0 + fi + if [[ "$value" =~ ^[[:alnum:]_.-]+(\.[[:alnum:]]+)$ && "$value" != *$'\n'* ]]; then + return 0 + fi + return 1 +} + +try_write_plain_candidate() { + local target="$1" + local out_file="$2" + local source_label="$3" + local candidate="$4" + + write_plain_to_file "$candidate" "$out_file" + validate_target_config "$target" "$out_file" "$source_label" "1" +} + +try_write_base64_candidate() { + local target="$1" + local out_file="$2" + local source_label="$3" + local candidate="$4" + + if ! decode_base64_to_file "$candidate" "$out_file"; then + return 1 + fi + + validate_target_config "$target" "$out_file" "$source_label" "1" +} + +try_value_variants_as_plain() { + local target="$1" + local out_file="$2" + local source_label="$3" + local value="$4" + + local candidate="$value" + if try_write_plain_candidate "$target" "$out_file" "$source_label" "$candidate"; then + return 0 + fi + + candidate="${value//$'\r'/}" + if [[ "$candidate" != "$value" ]]; then + if try_write_plain_candidate "$target" "$out_file" "$source_label" "$candidate"; then + return 0 + fi + fi + + local unquoted + unquoted="$(strip_surrounding_quotes "$value")" + if [[ "$unquoted" != "$value" ]]; then + if try_write_plain_candidate "$target" "$out_file" "$source_label" "$unquoted"; then + return 0 + fi + fi + + local unquoted_no_cr="${unquoted//$'\r'/}" + if [[ "$unquoted_no_cr" != "$unquoted" ]]; then + if try_write_plain_candidate "$target" "$out_file" "$source_label" "$unquoted_no_cr"; then + return 0 + fi + fi + + # Handle secrets pasted as escaped text (e.g. with '\n' sequences). + if [[ "$value" == *"\\n"* && "$value" != *$'\n'* ]]; then + local escaped + escaped="$(printf '%b' "$value")" + if try_write_plain_candidate "$target" "$out_file" "$source_label" "$escaped"; then + return 0 + fi + fi + if [[ "$unquoted_no_cr" == *"\\n"* && "$unquoted_no_cr" != *$'\n'* ]]; then + local unquoted_escaped + unquoted_escaped="$(printf '%b' "$unquoted_no_cr")" + if try_write_plain_candidate \ + "$target" \ + "$out_file" \ + "$source_label" \ + "$unquoted_escaped"; then + return 0 + fi + fi + + return 1 +} + +try_value_variants_as_base64() { + local target="$1" + local out_file="$2" + local source_label="$3" + local value="$4" + + local candidate="$value" + if try_write_base64_candidate "$target" "$out_file" "$source_label" "$candidate"; then + return 0 + fi + + candidate="${value//$'\r'/}" + if [[ "$candidate" != "$value" ]]; then + if try_write_base64_candidate "$target" "$out_file" "$source_label" "$candidate"; then + return 0 + fi + fi + + local unquoted + unquoted="$(strip_surrounding_quotes "$value")" + if [[ "$unquoted" != "$value" ]]; then + if try_write_base64_candidate "$target" "$out_file" "$source_label" "$unquoted"; then + return 0 + fi + fi + + local unquoted_no_cr="${unquoted//$'\r'/}" + if [[ "$unquoted_no_cr" != "$unquoted" ]]; then + if try_write_base64_candidate \ + "$target" \ + "$out_file" \ + "$source_label" \ + "$unquoted_no_cr"; then + return 0 + fi + fi + + if [[ "$value" == *"\\n"* && "$value" != *$'\n'* ]]; then + local escaped + escaped="$(printf '%b' "$value")" + if try_write_base64_candidate "$target" "$out_file" "$source_label" "$escaped"; then + return 0 + fi + fi + + if [[ "$unquoted_no_cr" == *"\\n"* && "$unquoted_no_cr" != *$'\n'* ]]; then + local unquoted_escaped + unquoted_escaped="$(printf '%b' "$unquoted_no_cr")" + if try_write_base64_candidate \ + "$target" \ + "$out_file" \ + "$source_label" \ + "$unquoted_escaped"; then + return 0 + fi + fi + + return 1 +} + write_secret_file() { local target="$1" local out_file="$2" @@ -139,33 +349,60 @@ write_secret_file() { mkdir -p "$(dirname "$out_file")" if [[ -n "$b64_value" ]]; then - decode_base64_to_file "$b64_value" "$out_file" source_label="$b64_var_name" - if validate_target_config "$target" "$out_file" "$source_label"; then + if try_value_variants_as_base64 \ + "$target" \ + "$out_file" \ + "$source_label" \ + "$b64_value"; then + echo "[ok] Wrote $target config: $out_file (source: $source_label)" + return 0 + fi + + # Tolerate mistakenly pasted raw file content in *_BASE64 variables. + if try_value_variants_as_plain \ + "$target" \ + "$out_file" \ + "$source_label" \ + "$b64_value"; then echo "[ok] Wrote $target config: $out_file (source: $source_label)" return 0 fi if [[ -n "$raw_value" ]]; then echo "[warn] Falling back to $raw_var_name for '$target'..." >&2 - printf '%s' "$raw_value" > "$out_file" - source_label="$raw_var_name" - if validate_target_config "$target" "$out_file" "$source_label"; then - echo "[ok] Wrote $target config: $out_file (source: $source_label)" - return 0 - fi fi - - return 1 fi if [[ -n "$raw_value" ]]; then - printf '%s' "$raw_value" > "$out_file" source_label="$raw_var_name" - if validate_target_config "$target" "$out_file" "$source_label"; then + + if try_value_variants_as_plain \ + "$target" \ + "$out_file" \ + "$source_label" \ + "$raw_value"; then echo "[ok] Wrote $target config: $out_file (source: $source_label)" return 0 fi + + # Tolerate mistakenly pasted base64 in raw variables. + if try_value_variants_as_base64 \ + "$target" \ + "$out_file" \ + "$source_label" \ + "$raw_value"; then + echo "[ok] Wrote $target config: $out_file (source: $source_label)" + return 0 + fi + + if looks_like_file_path_secret "$raw_value"; then + echo "[error] '$source_label' appears to contain a path or filename, not file content." >&2 + echo " Paste full file contents or use the *_BASE64 secret variable." >&2 + fi + + emit_invalid_target_error "$target" "$out_file" "$source_label" + rm -f "$out_file" return 1 fi