Phase 7 - Added Firebase layer

This commit is contained in:
gitea
2025-11-05 21:53:38 +01:00
parent 13ec9e5a9d
commit 5d3f8c68bd
14 changed files with 1255 additions and 5 deletions
+24 -4
View File
@@ -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<String> 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;
}
+15
View File
@@ -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);
+353
View File
@@ -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<void> 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<firebase_auth.User> 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<void> 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<void> 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<void> 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<String, dynamic>,
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<String> 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<String?> 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<void> logEvent(String eventName, {Map<String, dynamic>? parameters}) async {
if (!config.enabled || !config.analyticsEnabled || _analytics == null) {
return;
}
try {
// Convert Map<String, dynamic> to Map<String, Object> for Firebase Analytics
Map<String, Object>? 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<void> dispose() async {
if (_auth != null) {
await _auth!.signOut();
}
_firebaseUser = null;
_initialized = false;
}
}
@@ -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)';
}
}
+28
View File
@@ -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<String, String> _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);
+67 -1
View File
@@ -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<void> 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<MyApp> {
NostrService? _nostrService;
SyncEngine? _syncEngine;
NostrKeyPair? _nostrKeyPair;
FirebaseService? _firebaseService;
SessionService? _sessionService;
int _itemCount = 0;
bool _isInitialized = false;
@@ -79,6 +98,30 @@ class _MyAppState extends State<MyApp> {
_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<MyApp> {
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<MyApp> {
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<MyApp> {
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<MyApp> {
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<MyApp> {
),
);
}
/// Helper method to get Firebase services status string.
String _getFirebaseServicesStatus(FirebaseConfig config) {
final services = <String>[];
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.