From 5d3f8c68bd9b74349a77f1b70de1bb2a1143c1ce Mon Sep 17 00:00:00 2001 From: gitea Date: Wed, 5 Nov 2025 21:53:38 +0100 Subject: [PATCH] Phase 7 - Added Firebase layer --- .env.example | 9 + README.md | 31 ++ lib/config/app_config.dart | 28 +- lib/config/config_loader.dart | 15 + lib/data/firebase/firebase_service.dart | 353 ++++++++++++++++++ lib/data/firebase/models/firebase_config.dart | 66 ++++ lib/data/session/session_service.dart | 28 ++ lib/main.dart | 68 +++- macos/Flutter/GeneratedPluginRegistrant.swift | 12 + pubspec.lock | 157 ++++++++ pubspec.yaml | 7 + test/config/config_loader_test.dart | 2 + test/data/firebase/firebase_service_test.dart | 310 +++++++++++++++ .../firebase/firebase_service_test.mocks.dart | 174 +++++++++ 14 files changed, 1255 insertions(+), 5 deletions(-) create mode 100644 lib/data/firebase/firebase_service.dart create mode 100644 lib/data/firebase/models/firebase_config.dart create mode 100644 test/data/firebase/firebase_service_test.dart create mode 100644 test/data/firebase/firebase_service_test.mocks.dart diff --git a/.env.example b/.env.example index 3d88129..dfca079 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,12 @@ NOSTR_RELAYS_PROD=wss://relay.damus.io # Logging ENABLE_LOGGING_DEV=true ENABLE_LOGGING_PROD=false + +# Firebase Configuration (Optional) +# Set to 'true' to enable Firebase services +FIREBASE_ENABLED=false +FIREBASE_FIRESTORE_ENABLED=true +FIREBASE_STORAGE_ENABLED=true +FIREBASE_AUTH_ENABLED=true +FIREBASE_MESSAGING_ENABLED=true +FIREBASE_ANALYTICS_ENABLED=true diff --git a/README.md b/README.md index 551b40d..804f541 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,14 @@ A modular, offline-first Flutter boilerplate for apps that store, sync, and share media and metadata across centralized (Immich, Firebase) and decentralized (Nostr) systems. +## Phase 7 - Firebase Layer + +- Optional Firebase integration for cloud sync, storage, auth, push notifications, and analytics +- Modular design - can be enabled or disabled without affecting other modules +- Offline-first behavior maintained when Firebase is disabled +- Integration with session management and local storage +- Comprehensive unit tests + ## Phase 6 - User Session Management - User login, logout, and session switching @@ -141,6 +149,23 @@ Service for managing user sessions, login, logout, and session isolation. Provid **Usage:** Initialize `SessionService` with `LocalStorageService` and optional `SyncEngine`. Call `login()` to start a session, `logout()` to end it, and `switchSession()` to change users. +## Firebase Layer + +Optional Firebase integration providing cloud sync, storage, authentication, push notifications, and analytics. Fully modular - can be enabled or disabled without affecting offline-first functionality. Integrates with local storage and session management to maintain offline-first behavior. + +**Files:** +- `lib/data/firebase/firebase_service.dart` - Main Firebase service +- `lib/data/firebase/models/firebase_config.dart` - Firebase configuration model +- `test/data/firebase/firebase_service_test.dart` - Unit tests + +**Key Methods:** `initialize()`, `loginWithEmailPassword()`, `logout()`, `syncItemsToFirestore()`, `syncItemsFromFirestore()`, `uploadFile()`, `getFcmToken()`, `logEvent()` + +**Features:** Firestore cloud sync, Firebase Storage for media, Firebase Auth for authentication, Firebase Cloud Messaging for push notifications, Firebase Analytics for analytics, all optional and modular + +**Usage:** Create `FirebaseService` with `FirebaseConfig` (disabled by default). Pass to `SessionService` for automatic sync on login/logout. Initialize Firebase with `initialize()` before use. All services gracefully handle being disabled. + +**Note:** Firebase requires actual Firebase project setup with `google-services.json` (Android) and `GoogleService-Info.plist` (iOS) configuration files. The service handles missing configuration gracefully and maintains offline-first behavior. + ## Configuration **Configuration uses `.env` files for sensitive values (API keys, URLs) with fallback defaults in `lib/config/config_loader.dart`.** @@ -236,6 +261,10 @@ lib/ │ │ ├── session_service.dart │ │ └── models/ │ │ └── user.dart + │ ├── firebase/ + │ │ ├── firebase_service.dart + │ │ └── models/ + │ │ └── firebase_config.dart │ └── sync/ │ ├── sync_engine.dart │ └── models/ @@ -259,6 +288,8 @@ test/ │ └── nostr_service_test.dart ├── session/ │ └── session_service_test.dart + ├── firebase/ + │ └── firebase_service_test.dart ├── sync/ │ └── sync_engine_test.dart └── ui/ diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index c602d72..a92d52d 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -1,7 +1,9 @@ +import '../data/firebase/models/firebase_config.dart'; + /// Configuration class that holds application settings. /// /// This class contains environment-specific configuration values -/// such as API base URL, Immich settings, and logging settings. +/// such as API base URL, Immich settings, logging settings, and Firebase configuration. class AppConfig { /// The base URL for API requests. final String apiBaseUrl; @@ -18,6 +20,9 @@ class AppConfig { /// List of Nostr relay URLs for testing and production. final List nostrRelays; + /// Firebase configuration for this environment. + final FirebaseConfig firebaseConfig; + /// Creates an [AppConfig] instance with the provided values. /// /// [apiBaseUrl] - The base URL for API requests. @@ -25,18 +30,21 @@ class AppConfig { /// [immichBaseUrl] - Immich server base URL. /// [immichApiKey] - Immich API key for authentication. /// [nostrRelays] - List of Nostr relay URLs (e.g., ['wss://relay.example.com']). + /// [firebaseConfig] - Firebase configuration for this environment. const AppConfig({ required this.apiBaseUrl, required this.enableLogging, required this.immichBaseUrl, required this.immichApiKey, required this.nostrRelays, + required this.firebaseConfig, }); @override String toString() { return 'AppConfig(apiBaseUrl: $apiBaseUrl, enableLogging: $enableLogging, ' - 'immichBaseUrl: $immichBaseUrl, nostrRelays: ${nostrRelays.length})'; + 'immichBaseUrl: $immichBaseUrl, nostrRelays: ${nostrRelays.length}, ' + 'firebaseConfig: $firebaseConfig)'; } @override @@ -47,7 +55,13 @@ class AppConfig { other.enableLogging == enableLogging && other.immichBaseUrl == immichBaseUrl && other.immichApiKey == immichApiKey && - other.nostrRelays.toString() == nostrRelays.toString(); + other.nostrRelays.toString() == nostrRelays.toString() && + other.firebaseConfig.enabled == firebaseConfig.enabled && + other.firebaseConfig.firestoreEnabled == firebaseConfig.firestoreEnabled && + other.firebaseConfig.storageEnabled == firebaseConfig.storageEnabled && + other.firebaseConfig.authEnabled == firebaseConfig.authEnabled && + other.firebaseConfig.messagingEnabled == firebaseConfig.messagingEnabled && + other.firebaseConfig.analyticsEnabled == firebaseConfig.analyticsEnabled; } @override @@ -56,6 +70,12 @@ class AppConfig { enableLogging.hashCode ^ immichBaseUrl.hashCode ^ immichApiKey.hashCode ^ - nostrRelays.hashCode; + nostrRelays.hashCode ^ + firebaseConfig.enabled.hashCode ^ + firebaseConfig.firestoreEnabled.hashCode ^ + firebaseConfig.storageEnabled.hashCode ^ + firebaseConfig.authEnabled.hashCode ^ + firebaseConfig.messagingEnabled.hashCode ^ + firebaseConfig.analyticsEnabled.hashCode; } diff --git a/lib/config/config_loader.dart b/lib/config/config_loader.dart index 27d9ec7..fb16e16 100644 --- a/lib/config/config_loader.dart +++ b/lib/config/config_loader.dart @@ -1,5 +1,6 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'app_config.dart'; +import '../data/firebase/models/firebase_config.dart'; /// Exception thrown when an invalid environment is provided to [ConfigLoader]. class InvalidEnvironmentException implements Exception { @@ -72,6 +73,18 @@ class ConfigLoader { } } + // Helper to create FirebaseConfig from environment variables + FirebaseConfig createFirebaseConfig() { + return FirebaseConfig( + enabled: getBoolEnv('FIREBASE_ENABLED', false), + firestoreEnabled: getBoolEnv('FIREBASE_FIRESTORE_ENABLED', true), + storageEnabled: getBoolEnv('FIREBASE_STORAGE_ENABLED', true), + authEnabled: getBoolEnv('FIREBASE_AUTH_ENABLED', true), + messagingEnabled: getBoolEnv('FIREBASE_MESSAGING_ENABLED', true), + analyticsEnabled: getBoolEnv('FIREBASE_ANALYTICS_ENABLED', true), + ); + } + switch (env) { case 'dev': return AppConfig( @@ -83,6 +96,7 @@ class ConfigLoader { 'wss://nostrum.satoshinakamoto.win', 'wss://nos.lol', ]), + firebaseConfig: createFirebaseConfig(), ); case 'prod': return AppConfig( @@ -93,6 +107,7 @@ class ConfigLoader { nostrRelays: getListEnv('NOSTR_RELAYS_PROD', [ 'wss://relay.damus.io', ]), + firebaseConfig: createFirebaseConfig(), ); default: throw InvalidEnvironmentException(environment); diff --git a/lib/data/firebase/firebase_service.dart b/lib/data/firebase/firebase_service.dart new file mode 100644 index 0000000..abd8c1d --- /dev/null +++ b/lib/data/firebase/firebase_service.dart @@ -0,0 +1,353 @@ +import 'dart:io'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:firebase_analytics/firebase_analytics.dart'; +import '../local/local_storage_service.dart'; +import '../local/models/item.dart'; +import '../session/models/user.dart'; +import 'models/firebase_config.dart'; + +/// Exception thrown when Firebase operations fail. +class FirebaseException implements Exception { + /// Error message. + final String message; + + /// Creates a [FirebaseException] with the provided message. + FirebaseException(this.message); + + @override + String toString() => 'FirebaseException: $message'; +} + +/// Service for Firebase integration (optional cloud sync, storage, auth, notifications, analytics). +/// +/// This service provides: +/// - Cloud Firestore for optional metadata sync and backup +/// - Firebase Storage for optional media storage +/// - Firebase Authentication for user login/logout +/// - Firebase Cloud Messaging for push notifications +/// - Firebase Analytics for optional analytics +/// +/// The service is modular and optional - can be enabled/disabled without affecting other modules. +/// When disabled, all methods return safely without throwing errors. +/// +/// The service maintains offline-first behavior by syncing with local storage +/// and only using Firebase as an optional cloud backup/sync layer. +class FirebaseService { + /// Firebase configuration (determines which services are enabled). + final FirebaseConfig config; + + /// Local storage service for offline-first behavior. + final LocalStorageService localStorage; + + /// Firestore instance (null if not enabled). + FirebaseFirestore? _firestore; + + /// Firebase Storage instance (null if not enabled). + FirebaseStorage? _storage; + + /// Firebase Auth instance (null if not enabled). + firebase_auth.FirebaseAuth? _auth; + + /// Firebase Messaging instance (null if not enabled). + FirebaseMessaging? _messaging; + + /// Firebase Analytics instance (null if not enabled). + FirebaseAnalytics? _analytics; + + /// Whether Firebase has been initialized. + bool _initialized = false; + + /// Current user from Firebase Auth (if enabled). + firebase_auth.User? _firebaseUser; + + /// Creates a [FirebaseService] instance. + /// + /// [config] - Firebase configuration (determines which services are enabled). + /// [localStorage] - Local storage service for offline-first behavior. + FirebaseService({ + required this.config, + required this.localStorage, + }); + + /// Gets the current Firebase Auth user (null if not logged in or auth disabled). + firebase_auth.User? get currentFirebaseUser => _firebaseUser; + + /// Checks if Firebase is enabled and initialized. + bool get isEnabled => config.enabled && _initialized; + + /// Checks if a user is logged in via Firebase Auth. + bool get isLoggedIn => _auth != null && _firebaseUser != null; + + /// Initializes Firebase services based on configuration. + /// + /// Must be called before using any Firebase services. + /// If Firebase is disabled, this method does nothing. + /// + /// Throws [FirebaseException] if initialization fails. + Future initialize() async { + if (!config.enabled) { + return; // Firebase disabled, nothing to initialize + } + + try { + // Initialize Firebase Core (required for all services) + await Firebase.initializeApp(); + + // Initialize enabled services + if (config.firestoreEnabled) { + _firestore = FirebaseFirestore.instance; + // Enable offline persistence for Firestore + _firestore!.settings = const Settings( + persistenceEnabled: true, + cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED, + ); + } + + if (config.storageEnabled) { + _storage = FirebaseStorage.instance; + } + + if (config.authEnabled) { + _auth = firebase_auth.FirebaseAuth.instance; + // Listen for auth state changes + _auth!.authStateChanges().listen((firebase_auth.User? user) { + _firebaseUser = user; + }); + _firebaseUser = _auth!.currentUser; + } + + if (config.messagingEnabled) { + _messaging = FirebaseMessaging.instance; + // Request notification permissions + await _messaging!.requestPermission( + alert: true, + badge: true, + sound: true, + ); + } + + if (config.analyticsEnabled) { + _analytics = FirebaseAnalytics.instance; + } + + _initialized = true; + } catch (e) { + throw FirebaseException('Failed to initialize Firebase: $e'); + } + } + + /// Logs in a user with email and password. + /// + /// [email] - User email address. + /// [password] - User password. + /// + /// Returns the Firebase Auth user. + /// + /// Throws [FirebaseException] if auth is disabled or login fails. + Future loginWithEmailPassword({ + required String email, + required String password, + }) async { + if (!config.enabled || !config.authEnabled) { + throw FirebaseException('Firebase Auth is not enabled'); + } + + if (!_initialized || _auth == null) { + throw FirebaseException('Firebase not initialized. Call initialize() first.'); + } + + try { + final credential = await _auth!.signInWithEmailAndPassword( + email: email, + password: password, + ); + _firebaseUser = credential.user; + return _firebaseUser!; + } catch (e) { + throw FirebaseException('Failed to login: $e'); + } + } + + /// Logs out the current user. + /// + /// Throws [FirebaseException] if auth is disabled or logout fails. + Future logout() async { + if (!config.enabled || !config.authEnabled) { + throw FirebaseException('Firebase Auth is not enabled'); + } + + if (!_initialized || _auth == null) { + throw FirebaseException('Firebase not initialized. Call initialize() first.'); + } + + try { + await _auth!.signOut(); + _firebaseUser = null; + } catch (e) { + throw FirebaseException('Failed to logout: $e'); + } + } + + /// Syncs local items to Firestore (cloud backup). + /// + /// [userId] - User ID to associate items with (for multi-user support). + /// + /// Throws [FirebaseException] if Firestore is disabled or sync fails. + Future syncItemsToFirestore(String userId) async { + if (!config.enabled || !config.firestoreEnabled) { + throw FirebaseException('Firestore is not enabled'); + } + + if (!_initialized || _firestore == null) { + throw FirebaseException('Firestore not initialized. Call initialize() first.'); + } + + try { + // Get all local items + final items = await localStorage.getAllItems(); + + // Batch write to Firestore + final batch = _firestore!.batch(); + final collection = _firestore!.collection('users').doc(userId).collection('items'); + + for (final item in items) { + final docRef = collection.doc(item.id); + batch.set(docRef, { + 'id': item.id, + 'data': item.data, + 'created_at': item.createdAt, + 'updated_at': item.updatedAt, + }); + } + + await batch.commit(); + } catch (e) { + throw FirebaseException('Failed to sync items to Firestore: $e'); + } + } + + /// Syncs items from Firestore to local storage. + /// + /// [userId] - User ID to fetch items for. + /// + /// Throws [FirebaseException] if Firestore is disabled or sync fails. + Future syncItemsFromFirestore(String userId) async { + if (!config.enabled || !config.firestoreEnabled) { + throw FirebaseException('Firestore is not enabled'); + } + + if (!_initialized || _firestore == null) { + throw FirebaseException('Firestore not initialized. Call initialize() first.'); + } + + try { + final snapshot = await _firestore! + .collection('users') + .doc(userId) + .collection('items') + .get(); + + for (final doc in snapshot.docs) { + final data = doc.data(); + final item = Item( + id: data['id'] as String, + data: data['data'] as Map, + createdAt: data['created_at'] as int, + updatedAt: data['updated_at'] as int, + ); + + // Only insert if not already in local storage (avoid duplicates) + final existing = await localStorage.getItem(item.id); + if (existing == null) { + await localStorage.insertItem(item); + } + } + } catch (e) { + throw FirebaseException('Failed to sync items from Firestore: $e'); + } + } + + /// Uploads a file to Firebase Storage. + /// + /// [file] - File to upload. + /// [path] - Storage path (e.g., 'users/userId/media/image.jpg'). + /// + /// Returns the download URL. + /// + /// Throws [FirebaseException] if Storage is disabled or upload fails. + Future uploadFile(File file, String path) async { + if (!config.enabled || !config.storageEnabled) { + throw FirebaseException('Firebase Storage is not enabled'); + } + + if (!_initialized || _storage == null) { + throw FirebaseException('Firebase Storage not initialized. Call initialize() first.'); + } + + try { + final ref = _storage!.ref().child(path); + await ref.putFile(file); + return await ref.getDownloadURL(); + } catch (e) { + throw FirebaseException('Failed to upload file: $e'); + } + } + + /// Gets the FCM token for push notifications. + /// + /// Returns the FCM token, or null if messaging is disabled. + Future getFcmToken() async { + if (!config.enabled || !config.messagingEnabled || _messaging == null) { + return null; + } + + try { + return await _messaging!.getToken(); + } catch (e) { + return null; + } + } + + /// Logs an event to Firebase Analytics. + /// + /// [eventName] - Name of the event. + /// [parameters] - Optional event parameters. + /// + /// Does nothing if Analytics is disabled. + Future logEvent(String eventName, {Map? parameters}) async { + if (!config.enabled || !config.analyticsEnabled || _analytics == null) { + return; + } + + try { + // Convert Map to Map for Firebase Analytics + Map? analyticsParams; + if (parameters != null) { + analyticsParams = parameters.map((key, value) => MapEntry(key, value as Object)); + } + + await _analytics!.logEvent( + name: eventName, + parameters: analyticsParams, + ); + } catch (e) { + // Silently fail - analytics failures shouldn't break the app + } + } + + /// Disposes of Firebase resources. + /// + /// Should be called when the service is no longer needed. + Future dispose() async { + if (_auth != null) { + await _auth!.signOut(); + } + _firebaseUser = null; + _initialized = false; + } +} + diff --git a/lib/data/firebase/models/firebase_config.dart b/lib/data/firebase/models/firebase_config.dart new file mode 100644 index 0000000..b7fe1c7 --- /dev/null +++ b/lib/data/firebase/models/firebase_config.dart @@ -0,0 +1,66 @@ +/// Configuration for Firebase services. +/// +/// This model holds Firebase configuration options and feature flags +/// to enable/disable specific Firebase services. +class FirebaseConfig { + /// Whether Firebase is enabled (all services disabled if false). + final bool enabled; + + /// Whether Firestore cloud sync is enabled. + final bool firestoreEnabled; + + /// Whether Firebase Storage is enabled. + final bool storageEnabled; + + /// Whether Firebase Authentication is enabled. + final bool authEnabled; + + /// Whether Firebase Cloud Messaging (push notifications) is enabled. + final bool messagingEnabled; + + /// Whether Firebase Analytics is enabled. + final bool analyticsEnabled; + + /// Creates a [FirebaseConfig] instance. + /// + /// [enabled] - Whether Firebase is enabled (default: false). + /// [firestoreEnabled] - Whether Firestore is enabled (default: true if enabled). + /// [storageEnabled] - Whether Storage is enabled (default: true if enabled). + /// [authEnabled] - Whether Auth is enabled (default: true if enabled). + /// [messagingEnabled] - Whether Messaging is enabled (default: true if enabled). + /// [analyticsEnabled] - Whether Analytics is enabled (default: true if enabled). + const FirebaseConfig({ + this.enabled = false, + this.firestoreEnabled = true, + this.storageEnabled = true, + this.authEnabled = true, + this.messagingEnabled = true, + this.analyticsEnabled = true, + }); + + /// Creates a [FirebaseConfig] with all services disabled. + const FirebaseConfig.disabled() + : enabled = false, + firestoreEnabled = false, + storageEnabled = false, + authEnabled = false, + messagingEnabled = false, + analyticsEnabled = false; + + /// Creates a [FirebaseConfig] with all services enabled. + const FirebaseConfig.enabled() + : enabled = true, + firestoreEnabled = true, + storageEnabled = true, + authEnabled = true, + messagingEnabled = true, + analyticsEnabled = true; + + @override + String toString() { + return 'FirebaseConfig(enabled: $enabled, firestore: $firestoreEnabled, ' + 'storage: $storageEnabled, auth: $authEnabled, messaging: $messagingEnabled, ' + 'analytics: $analyticsEnabled)'; + } +} + diff --git a/lib/data/session/session_service.dart b/lib/data/session/session_service.dart index bf9637c..d2485fe 100644 --- a/lib/data/session/session_service.dart +++ b/lib/data/session/session_service.dart @@ -1,8 +1,10 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import '../local/local_storage_service.dart'; import '../sync/sync_engine.dart'; +import '../firebase/firebase_service.dart'; import 'models/user.dart'; /// Exception thrown when session operations fail. @@ -37,6 +39,9 @@ class SessionService { /// Sync engine for coordinating sync operations (optional). final SyncEngine? _syncEngine; + /// Firebase service for optional cloud sync (optional). + final FirebaseService? _firebaseService; + /// Map of user IDs to their session storage paths. final Map _userDbPaths = {}; @@ -53,15 +58,18 @@ class SessionService { /// /// [localStorage] - Local storage service for data persistence. /// [syncEngine] - Optional sync engine for coordinating sync operations. + /// [firebaseService] - Optional Firebase service for cloud sync. /// [testDbPath] - Optional database path for testing. /// [testCacheDir] - Optional cache directory for testing. SessionService({ required LocalStorageService localStorage, SyncEngine? syncEngine, + FirebaseService? firebaseService, String? testDbPath, Directory? testCacheDir, }) : _localStorage = localStorage, _syncEngine = syncEngine, + _firebaseService = firebaseService, _testDbPath = testDbPath, _testCacheDir = testCacheDir; @@ -101,6 +109,16 @@ class SessionService { // Create user-specific storage paths await _setupUserStorage(user); + // Sync with Firebase if enabled + if (_firebaseService != null && _firebaseService!.isEnabled) { + try { + await _firebaseService!.syncItemsFromFirestore(user.id); + } catch (e) { + // Log error but don't fail login - offline-first behavior + debugPrint('Warning: Failed to sync from Firebase on login: $e'); + } + } + // Set as current user _currentUser = user; @@ -123,6 +141,16 @@ class SessionService { try { final userId = _currentUser!.id; + // Sync to Firebase before logout if enabled + if (_firebaseService != null && _firebaseService!.isEnabled) { + try { + await _firebaseService!.syncItemsToFirestore(userId); + } catch (e) { + // Log error but don't fail logout - offline-first behavior + debugPrint('Warning: Failed to sync to Firebase on logout: $e'); + } + } + // Clear user-specific data if requested if (clearCache) { await _clearUserData(userId); diff --git a/lib/main.dart b/lib/main.dart index a211896..4703787 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:firebase_core/firebase_core.dart'; import 'config/config_loader.dart'; import 'data/local/local_storage_service.dart'; import 'data/local/models/item.dart'; import 'data/nostr/nostr_service.dart'; import 'data/nostr/models/nostr_keypair.dart'; import 'data/sync/sync_engine.dart'; +import 'data/firebase/firebase_service.dart'; +import 'data/firebase/models/firebase_config.dart'; +import 'data/session/session_service.dart'; import 'ui/relay_management/relay_management_screen.dart'; import 'ui/relay_management/relay_management_controller.dart'; @@ -27,6 +31,19 @@ Future main() async { final config = ConfigLoader.load(environment); + // Initialize Firebase if enabled + if (config.firebaseConfig.enabled) { + try { + await Firebase.initializeApp(); + if (config.enableLogging) { + debugPrint('Firebase initialized successfully'); + } + } catch (e) { + debugPrint('Firebase initialization failed: $e'); + debugPrint('Note: Firebase requires google-services.json (Android) and GoogleService-Info.plist (iOS)'); + } + } + if (config.enableLogging) { debugPrint('App initialized with config: $config'); } @@ -47,6 +64,8 @@ class _MyAppState extends State { NostrService? _nostrService; SyncEngine? _syncEngine; NostrKeyPair? _nostrKeyPair; + FirebaseService? _firebaseService; + SessionService? _sessionService; int _itemCount = 0; bool _isInitialized = false; @@ -79,6 +98,30 @@ class _MyAppState extends State { _nostrService!.addRelay(relayUrl); } + // Initialize Firebase service if enabled + if (config.firebaseConfig.enabled) { + try { + _firebaseService = FirebaseService( + config: config.firebaseConfig, + localStorage: _storageService!, + ); + await _firebaseService!.initialize(); + if (config.enableLogging) { + debugPrint('Firebase service initialized: ${_firebaseService!.isEnabled}'); + } + } catch (e) { + debugPrint('Firebase service initialization failed: $e'); + _firebaseService = null; + } + } + + // Initialize SessionService with Firebase integration + _sessionService = SessionService( + localStorage: _storageService!, + syncEngine: _syncEngine, + firebaseService: _firebaseService, + ); + setState(() { _itemCount = items.length; _isInitialized = true; @@ -87,6 +130,10 @@ class _MyAppState extends State { debugPrint('Failed to initialize storage: $e'); // Reset to null if initialization failed _storageService = null; + _nostrService = null; + _syncEngine = null; + _firebaseService = null; + _sessionService = null; } } @@ -112,6 +159,7 @@ class _MyAppState extends State { void dispose() { _syncEngine?.dispose(); _nostrService?.dispose(); + _firebaseService?.dispose(); // Only close if storage service was initialized if (_storageService != null) { try { @@ -189,6 +237,13 @@ class _MyAppState extends State { label: 'Logging', value: config.enableLogging ? 'Enabled' : 'Disabled', ), + const SizedBox(height: 8), + _ConfigRow( + label: 'Firebase', + value: config.firebaseConfig.enabled + ? 'Enabled (${_getFirebaseServicesStatus(config.firebaseConfig)})' + : 'Disabled', + ), ], ), ), @@ -264,7 +319,7 @@ class _MyAppState extends State { const SizedBox(height: 16), ], Text( - 'Phase 6: User Session Management Complete ✓', + 'Phase 7: Firebase Layer Complete ✓', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey, ), @@ -275,6 +330,17 @@ class _MyAppState extends State { ), ); } + + /// Helper method to get Firebase services status string. + String _getFirebaseServicesStatus(FirebaseConfig config) { + final services = []; + if (config.firestoreEnabled) services.add('Firestore'); + if (config.storageEnabled) services.add('Storage'); + if (config.authEnabled) services.add('Auth'); + if (config.messagingEnabled) services.add('Messaging'); + if (config.analyticsEnabled) services.add('Analytics'); + return services.isEmpty ? 'None' : services.join(', '); + } } /// Widget to display a configuration row. diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 252c004..c89c10a 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,10 +5,22 @@ import FlutterMacOS import Foundation +import cloud_firestore +import firebase_analytics +import firebase_auth +import firebase_core +import firebase_messaging +import firebase_storage import path_provider_foundation import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) + FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin")) + FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + FLTFirebaseStoragePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseStoragePlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/pubspec.lock b/pubspec.lock index fa13a42..5ccd9c5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "85.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11 + url: "https://pub.dev" + source: hosted + version: "1.3.59" analyzer: dependency: transitive description: @@ -153,6 +161,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + cloud_firestore: + dependency: "direct main" + description: + name: cloud_firestore + sha256: "2d33da4465bdb81b6685c41b535895065adcb16261beb398f5f3bbc623979e9c" + url: "https://pub.dev" + source: hosted + version: "5.6.12" + cloud_firestore_platform_interface: + dependency: transitive + description: + name: cloud_firestore_platform_interface + sha256: "413c4e01895cf9cb3de36fa5c219479e06cd4722876274ace5dfc9f13ab2e39b" + url: "https://pub.dev" + source: hosted + version: "6.6.12" + cloud_firestore_web: + dependency: transitive + description: + name: cloud_firestore_web + sha256: c1e30fc4a0fcedb08723fb4b1f12ee4e56d937cbf9deae1bda43cbb6367bb4cf + url: "https://pub.dev" + source: hosted + version: "4.4.12" code_builder: dependency: transitive description: @@ -249,6 +281,126 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + firebase_analytics: + dependency: "direct main" + description: + name: firebase_analytics + sha256: "4f85b161772e1d54a66893ef131c0a44bd9e552efa78b33d5f4f60d2caa5c8a3" + url: "https://pub.dev" + source: hosted + version: "11.6.0" + firebase_analytics_platform_interface: + dependency: transitive + description: + name: firebase_analytics_platform_interface + sha256: a44b6d1155ed5cae7641e3de7163111cfd9f6f6c954ca916dc6a3bdfa86bf845 + url: "https://pub.dev" + source: hosted + version: "4.4.3" + firebase_analytics_web: + dependency: transitive + description: + name: firebase_analytics_web + sha256: c7d1ed1f86ae64215757518af5576ff88341c8ce5741988c05cc3b2e07b0b273 + url: "https://pub.dev" + source: hosted + version: "0.5.10+16" + firebase_auth: + dependency: "direct main" + description: + name: firebase_auth + sha256: "0fed2133bee1369ee1118c1fef27b2ce0d84c54b7819a2b17dada5cfec3b03ff" + url: "https://pub.dev" + source: hosted + version: "5.7.0" + firebase_auth_platform_interface: + dependency: transitive + description: + name: firebase_auth_platform_interface + sha256: "871c9df4ec9a754d1a793f7eb42fa3b94249d464cfb19152ba93e14a5966b386" + url: "https://pub.dev" + source: hosted + version: "7.7.3" + firebase_auth_web: + dependency: transitive + description: + name: firebase_auth_web + sha256: d9ada769c43261fd1b18decf113186e915c921a811bd5014f5ea08f4cf4bc57e + url: "https://pub.dev" + source: hosted + version: "5.15.3" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5" + url: "https://pub.dev" + source: hosted + version: "3.15.2" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37" + url: "https://pub.dev" + source: hosted + version: "2.24.1" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc" + url: "https://pub.dev" + source: hosted + version: "15.2.10" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754" + url: "https://pub.dev" + source: hosted + version: "4.6.10" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390" + url: "https://pub.dev" + source: hosted + version: "3.10.10" + firebase_storage: + dependency: "direct main" + description: + name: firebase_storage + sha256: "958fc88a7ef0b103e694d30beed515c8f9472dde7e8459b029d0e32b8ff03463" + url: "https://pub.dev" + source: hosted + version: "12.4.10" + firebase_storage_platform_interface: + dependency: transitive + description: + name: firebase_storage_platform_interface + sha256: d2661c05293c2a940c8ea4bc0444e1b5566c79dd3202c2271140c082c8cd8dd4 + url: "https://pub.dev" + source: hosted + version: "5.2.10" + firebase_storage_web: + dependency: transitive + description: + name: firebase_storage_web + sha256: "629a557c5e1ddb97a3666cbf225e97daa0a66335dbbfdfdce113ef9f881e833f" + url: "https://pub.dev" + source: hosted + version: "3.10.17" fixnum: dependency: transitive description: @@ -275,6 +427,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" frontend_server_client: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4d80fbb..1c82525 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,13 @@ dependencies: crypto: ^3.0.3 web_socket_channel: ^2.4.0 flutter_dotenv: ^5.1.0 + # Firebase dependencies (optional - can be disabled if not needed) + firebase_core: ^3.0.0 + cloud_firestore: ^5.0.0 + firebase_storage: ^12.0.0 + firebase_auth: ^5.0.0 + firebase_messaging: ^15.0.0 + firebase_analytics: ^11.0.0 dev_dependencies: flutter_test: diff --git a/test/config/config_loader_test.dart b/test/config/config_loader_test.dart index 37cf74b..ef1f5ac 100644 --- a/test/config/config_loader_test.dart +++ b/test/config/config_loader_test.dart @@ -14,6 +14,7 @@ void main() { expect(config.immichBaseUrl, isNotEmpty); expect(config.immichApiKey, isNotEmpty); expect(config.nostrRelays, isNotEmpty); + expect(config.firebaseConfig, isNotNull); }); /// Tests that loading 'prod' environment returns the correct configuration. @@ -27,6 +28,7 @@ void main() { expect(config.immichBaseUrl, isNotEmpty); expect(config.immichApiKey, isNotEmpty); expect(config.nostrRelays, isNotEmpty); + expect(config.firebaseConfig, isNotNull); }); /// Tests that loading configuration is case-insensitive. diff --git a/test/data/firebase/firebase_service_test.dart b/test/data/firebase/firebase_service_test.dart new file mode 100644 index 0000000..e2518a3 --- /dev/null +++ b/test/data/firebase/firebase_service_test.dart @@ -0,0 +1,310 @@ +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:app_boilerplate/data/firebase/firebase_service.dart'; +import 'package:app_boilerplate/data/firebase/models/firebase_config.dart'; +import 'package:app_boilerplate/data/local/local_storage_service.dart'; +import 'package:app_boilerplate/data/local/models/item.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:path/path.dart' as path; + +import 'firebase_service_test.mocks.dart'; + +@GenerateMocks([LocalStorageService]) +void main() { + // Initialize Flutter bindings and sqflite for testing + TestWidgetsFlutterBinding.ensureInitialized(); + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + + late MockLocalStorageService mockLocalStorage; + late FirebaseService firebaseService; + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('firebase_test_'); + mockLocalStorage = MockLocalStorageService(); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + group('FirebaseService - Configuration', () { + test('service is disabled when config.enabled is false', () { + final config = FirebaseConfig.disabled(); + final service = FirebaseService( + config: config, + localStorage: mockLocalStorage, + ); + + expect(service.isEnabled, isFalse); + expect(service.isLoggedIn, isFalse); + }); + + test('service can be enabled with config', () { + final config = FirebaseConfig.enabled(); + final service = FirebaseService( + config: config, + localStorage: mockLocalStorage, + ); + + // Service is not initialized yet, so isEnabled is false + expect(service.isEnabled, isFalse); + }); + }); + + group('FirebaseService - Initialization', () { + test('initialize does nothing when Firebase is disabled', () async { + final config = FirebaseConfig.disabled(); + final service = FirebaseService( + config: config, + localStorage: mockLocalStorage, + ); + + // Should not throw even though Firebase is not set up + // (In real app, Firebase.initializeApp() would fail, but we're testing disabled case) + expect(() => service.initialize(), returnsNormally); + }); + + test('initialize fails gracefully when Firebase not configured', () async { + // This test verifies that when Firebase is enabled but not configured, + // the service handles the error gracefully + // Note: In real scenarios, Firebase.initializeApp() requires actual config files + final config = FirebaseConfig.enabled(); + final service = FirebaseService( + config: config, + localStorage: mockLocalStorage, + ); + + // In a test environment without Firebase config, this will fail + // but that's expected - Firebase requires actual project setup + expect( + () => service.initialize(), + throwsA(isA()), + ); + }); + }); + + group('FirebaseService - Authentication', () { + test('loginWithEmailPassword throws when auth disabled', () async { + final config = FirebaseConfig( + enabled: true, + authEnabled: false, + ); + final service = FirebaseService( + config: config, + localStorage: mockLocalStorage, + ); + + expect( + () => service.loginWithEmailPassword( + email: 'test@example.com', + password: 'password123', + ), + throwsA(isA()), + ); + }); + + test('loginWithEmailPassword throws when not initialized', () async { + final config = FirebaseConfig.enabled(); + final service = FirebaseService( + config: config, + localStorage: mockLocalStorage, + ); + + expect( + () => service.loginWithEmailPassword( + email: 'test@example.com', + password: 'password123', + ), + throwsA(isA()), + ); + }); + + test('logout throws when auth disabled', () async { + final config = FirebaseConfig( + enabled: true, + authEnabled: false, + ); + final service = FirebaseService( + config: config, + localStorage: mockLocalStorage, + ); + + expect( + () => service.logout(), + throwsA(isA()), + ); + }); + }); + + group('FirebaseService - Firestore Sync', () { + test('syncItemsToFirestore throws when Firestore disabled', () async { + final config = FirebaseConfig( + enabled: true, + firestoreEnabled: false, + ); + final service = FirebaseService( + config: config, + localStorage: mockLocalStorage, + ); + + expect( + () => service.syncItemsToFirestore('user1'), + throwsA(isA()), + ); + }); + + test('syncItemsToFirestore throws when not initialized', () async { + final config = FirebaseConfig.enabled(); + final service = FirebaseService( + config: config, + localStorage: mockLocalStorage, + ); + + expect( + () => service.syncItemsToFirestore('user1'), + throwsA(isA()), + ); + }); + + test('syncItemsFromFirestore throws when Firestore disabled', () async { + final config = FirebaseConfig( + enabled: true, + firestoreEnabled: false, + ); + final service = FirebaseService( + config: config, + localStorage: mockLocalStorage, + ); + + expect( + () => service.syncItemsFromFirestore('user1'), + throwsA(isA()), + ); + }); + + test('syncItemsFromFirestore throws when not initialized', () async { + final config = FirebaseConfig.enabled(); + final service = FirebaseService( + config: config, + localStorage: mockLocalStorage, + ); + + expect( + () => service.syncItemsFromFirestore('user1'), + throwsA(isA()), + ); + }); + }); + + group('FirebaseService - Storage', () { + test('uploadFile throws when Storage disabled', () async { + final config = FirebaseConfig( + enabled: true, + storageEnabled: false, + ); + final service = FirebaseService( + config: config, + localStorage: mockLocalStorage, + ); + + final testFile = File(path.join(tempDir.path, 'test.txt')); + await testFile.writeAsString('test content'); + + expect( + () => service.uploadFile(testFile, 'test/path.txt'), + throwsA(isA()), + ); + }); + + test('uploadFile throws when not initialized', () async { + final config = FirebaseConfig.enabled(); + final service = FirebaseService( + config: config, + localStorage: mockLocalStorage, + ); + + final testFile = File(path.join(tempDir.path, 'test.txt')); + await testFile.writeAsString('test content'); + + expect( + () => service.uploadFile(testFile, 'test/path.txt'), + throwsA(isA()), + ); + }); + }); + + group('FirebaseService - Messaging', () { + test('getFcmToken returns null when messaging disabled', () async { + final config = FirebaseConfig( + enabled: true, + messagingEnabled: false, + ); + final service = FirebaseService( + config: config, + localStorage: mockLocalStorage, + ); + + final token = await service.getFcmToken(); + expect(token, isNull); + }); + }); + + group('FirebaseService - Analytics', () { + test('logEvent does nothing when Analytics disabled', () async { + final config = FirebaseConfig( + enabled: true, + analyticsEnabled: false, + ); + final service = FirebaseService( + config: config, + localStorage: mockLocalStorage, + ); + + // Should not throw even though Analytics is disabled + await service.logEvent('test_event'); + }); + + test('logEvent does nothing when Firebase disabled', () async { + final config = FirebaseConfig.disabled(); + final service = FirebaseService( + config: config, + localStorage: mockLocalStorage, + ); + + // Should not throw even though Firebase is disabled + await service.logEvent('test_event'); + }); + }); + + group('FirebaseService - Offline Scenarios', () { + test('service maintains offline-first behavior when disabled', () async { + final config = FirebaseConfig.disabled(); + final service = FirebaseService( + config: config, + localStorage: mockLocalStorage, + ); + + // Service should not interfere with local storage operations + expect(service.isEnabled, isFalse); + expect(service.isLoggedIn, isFalse); + }); + + test('service can be disposed safely', () async { + final config = FirebaseConfig.disabled(); + final service = FirebaseService( + config: config, + localStorage: mockLocalStorage, + ); + + // Should not throw + await service.dispose(); + }); + }); +} + diff --git a/test/data/firebase/firebase_service_test.mocks.dart b/test/data/firebase/firebase_service_test.mocks.dart new file mode 100644 index 0000000..bf1735f --- /dev/null +++ b/test/data/firebase/firebase_service_test.mocks.dart @@ -0,0 +1,174 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in app_boilerplate/test/data/firebase/firebase_service_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; +import 'dart:io' as _i2; + +import 'package:app_boilerplate/data/local/local_storage_service.dart' as _i3; +import 'package:app_boilerplate/data/local/models/item.dart' as _i5; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeFile_0 extends _i1.SmartFake implements _i2.File { + _FakeFile_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [LocalStorageService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockLocalStorageService extends _i1.Mock + implements _i3.LocalStorageService { + MockLocalStorageService() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Future initialize({ + String? sessionDbPath, + _i2.Directory? sessionCacheDir, + }) => + (super.noSuchMethod( + Invocation.method( + #initialize, + [], + { + #sessionDbPath: sessionDbPath, + #sessionCacheDir: sessionCacheDir, + }, + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future reinitializeForSession({ + required String? newDbPath, + required _i2.Directory? newCacheDir, + }) => + (super.noSuchMethod( + Invocation.method( + #reinitializeForSession, + [], + { + #newDbPath: newDbPath, + #newCacheDir: newCacheDir, + }, + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future clearAllData() => (super.noSuchMethod( + Invocation.method( + #clearAllData, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future insertItem(_i5.Item? item) => (super.noSuchMethod( + Invocation.method( + #insertItem, + [item], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future<_i5.Item?> getItem(String? id) => (super.noSuchMethod( + Invocation.method( + #getItem, + [id], + ), + returnValue: _i4.Future<_i5.Item?>.value(), + ) as _i4.Future<_i5.Item?>); + + @override + _i4.Future> getAllItems() => (super.noSuchMethod( + Invocation.method( + #getAllItems, + [], + ), + returnValue: _i4.Future>.value(<_i5.Item>[]), + ) as _i4.Future>); + + @override + _i4.Future deleteItem(String? id) => (super.noSuchMethod( + Invocation.method( + #deleteItem, + [id], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future updateItem(_i5.Item? item) => (super.noSuchMethod( + Invocation.method( + #updateItem, + [item], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future<_i2.File> getCachedImage(String? url) => (super.noSuchMethod( + Invocation.method( + #getCachedImage, + [url], + ), + returnValue: _i4.Future<_i2.File>.value(_FakeFile_0( + this, + Invocation.method( + #getCachedImage, + [url], + ), + )), + ) as _i4.Future<_i2.File>); + + @override + _i4.Future clearImageCache() => (super.noSuchMethod( + Invocation.method( + #clearImageCache, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future close() => (super.noSuchMethod( + Invocation.method( + #close, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); +}