Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c37dce0222 | ||
|
|
5d3f8c68bd |
@@ -18,3 +18,12 @@ NOSTR_RELAYS_PROD=wss://relay.damus.io
|
|||||||
# Logging
|
# Logging
|
||||||
ENABLE_LOGGING_DEV=true
|
ENABLE_LOGGING_DEV=true
|
||||||
ENABLE_LOGGING_PROD=false
|
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
|
||||||
|
|||||||
@@ -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.
|
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
|
## Phase 6 - User Session Management
|
||||||
|
|
||||||
- User login, logout, and session switching
|
- 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.
|
**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
|
||||||
|
|
||||||
**Configuration uses `.env` files for sensitive values (API keys, URLs) with fallback defaults in `lib/config/config_loader.dart`.**
|
**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
|
│ │ ├── session_service.dart
|
||||||
│ │ └── models/
|
│ │ └── models/
|
||||||
│ │ └── user.dart
|
│ │ └── user.dart
|
||||||
|
│ ├── firebase/
|
||||||
|
│ │ ├── firebase_service.dart
|
||||||
|
│ │ └── models/
|
||||||
|
│ │ └── firebase_config.dart
|
||||||
│ └── sync/
|
│ └── sync/
|
||||||
│ ├── sync_engine.dart
|
│ ├── sync_engine.dart
|
||||||
│ └── models/
|
│ └── models/
|
||||||
@@ -259,6 +288,8 @@ test/
|
|||||||
│ └── nostr_service_test.dart
|
│ └── nostr_service_test.dart
|
||||||
├── session/
|
├── session/
|
||||||
│ └── session_service_test.dart
|
│ └── session_service_test.dart
|
||||||
|
├── firebase/
|
||||||
|
│ └── firebase_service_test.dart
|
||||||
├── sync/
|
├── sync/
|
||||||
│ └── sync_engine_test.dart
|
│ └── sync_engine_test.dart
|
||||||
└── ui/
|
└── ui/
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import '../data/firebase/models/firebase_config.dart';
|
||||||
|
|
||||||
/// Configuration class that holds application settings.
|
/// Configuration class that holds application settings.
|
||||||
///
|
///
|
||||||
/// This class contains environment-specific configuration values
|
/// 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 {
|
class AppConfig {
|
||||||
/// The base URL for API requests.
|
/// The base URL for API requests.
|
||||||
final String apiBaseUrl;
|
final String apiBaseUrl;
|
||||||
@@ -18,6 +20,9 @@ class AppConfig {
|
|||||||
/// List of Nostr relay URLs for testing and production.
|
/// List of Nostr relay URLs for testing and production.
|
||||||
final List<String> nostrRelays;
|
final List<String> nostrRelays;
|
||||||
|
|
||||||
|
/// Firebase configuration for this environment.
|
||||||
|
final FirebaseConfig firebaseConfig;
|
||||||
|
|
||||||
/// Creates an [AppConfig] instance with the provided values.
|
/// Creates an [AppConfig] instance with the provided values.
|
||||||
///
|
///
|
||||||
/// [apiBaseUrl] - The base URL for API requests.
|
/// [apiBaseUrl] - The base URL for API requests.
|
||||||
@@ -25,18 +30,21 @@ class AppConfig {
|
|||||||
/// [immichBaseUrl] - Immich server base URL.
|
/// [immichBaseUrl] - Immich server base URL.
|
||||||
/// [immichApiKey] - Immich API key for authentication.
|
/// [immichApiKey] - Immich API key for authentication.
|
||||||
/// [nostrRelays] - List of Nostr relay URLs (e.g., ['wss://relay.example.com']).
|
/// [nostrRelays] - List of Nostr relay URLs (e.g., ['wss://relay.example.com']).
|
||||||
|
/// [firebaseConfig] - Firebase configuration for this environment.
|
||||||
const AppConfig({
|
const AppConfig({
|
||||||
required this.apiBaseUrl,
|
required this.apiBaseUrl,
|
||||||
required this.enableLogging,
|
required this.enableLogging,
|
||||||
required this.immichBaseUrl,
|
required this.immichBaseUrl,
|
||||||
required this.immichApiKey,
|
required this.immichApiKey,
|
||||||
required this.nostrRelays,
|
required this.nostrRelays,
|
||||||
|
required this.firebaseConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'AppConfig(apiBaseUrl: $apiBaseUrl, enableLogging: $enableLogging, '
|
return 'AppConfig(apiBaseUrl: $apiBaseUrl, enableLogging: $enableLogging, '
|
||||||
'immichBaseUrl: $immichBaseUrl, nostrRelays: ${nostrRelays.length})';
|
'immichBaseUrl: $immichBaseUrl, nostrRelays: ${nostrRelays.length}, '
|
||||||
|
'firebaseConfig: $firebaseConfig)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -47,7 +55,13 @@ class AppConfig {
|
|||||||
other.enableLogging == enableLogging &&
|
other.enableLogging == enableLogging &&
|
||||||
other.immichBaseUrl == immichBaseUrl &&
|
other.immichBaseUrl == immichBaseUrl &&
|
||||||
other.immichApiKey == immichApiKey &&
|
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
|
@override
|
||||||
@@ -56,6 +70,12 @@ class AppConfig {
|
|||||||
enableLogging.hashCode ^
|
enableLogging.hashCode ^
|
||||||
immichBaseUrl.hashCode ^
|
immichBaseUrl.hashCode ^
|
||||||
immichApiKey.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'app_config.dart';
|
import 'app_config.dart';
|
||||||
|
import '../data/firebase/models/firebase_config.dart';
|
||||||
|
|
||||||
/// Exception thrown when an invalid environment is provided to [ConfigLoader].
|
/// Exception thrown when an invalid environment is provided to [ConfigLoader].
|
||||||
class InvalidEnvironmentException implements Exception {
|
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) {
|
switch (env) {
|
||||||
case 'dev':
|
case 'dev':
|
||||||
return AppConfig(
|
return AppConfig(
|
||||||
@@ -83,6 +96,7 @@ class ConfigLoader {
|
|||||||
'wss://nostrum.satoshinakamoto.win',
|
'wss://nostrum.satoshinakamoto.win',
|
||||||
'wss://nos.lol',
|
'wss://nos.lol',
|
||||||
]),
|
]),
|
||||||
|
firebaseConfig: createFirebaseConfig(),
|
||||||
);
|
);
|
||||||
case 'prod':
|
case 'prod':
|
||||||
return AppConfig(
|
return AppConfig(
|
||||||
@@ -93,6 +107,7 @@ class ConfigLoader {
|
|||||||
nostrRelays: getListEnv('NOSTR_RELAYS_PROD', [
|
nostrRelays: getListEnv('NOSTR_RELAYS_PROD', [
|
||||||
'wss://relay.damus.io',
|
'wss://relay.damus.io',
|
||||||
]),
|
]),
|
||||||
|
firebaseConfig: createFirebaseConfig(),
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
throw InvalidEnvironmentException(environment);
|
throw InvalidEnvironmentException(environment);
|
||||||
|
|||||||
@@ -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)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import '../local/local_storage_service.dart';
|
import '../local/local_storage_service.dart';
|
||||||
import '../sync/sync_engine.dart';
|
import '../sync/sync_engine.dart';
|
||||||
|
import '../firebase/firebase_service.dart';
|
||||||
import 'models/user.dart';
|
import 'models/user.dart';
|
||||||
|
|
||||||
/// Exception thrown when session operations fail.
|
/// Exception thrown when session operations fail.
|
||||||
@@ -37,6 +39,9 @@ class SessionService {
|
|||||||
/// Sync engine for coordinating sync operations (optional).
|
/// Sync engine for coordinating sync operations (optional).
|
||||||
final SyncEngine? _syncEngine;
|
final SyncEngine? _syncEngine;
|
||||||
|
|
||||||
|
/// Firebase service for optional cloud sync (optional).
|
||||||
|
final FirebaseService? _firebaseService;
|
||||||
|
|
||||||
/// Map of user IDs to their session storage paths.
|
/// Map of user IDs to their session storage paths.
|
||||||
final Map<String, String> _userDbPaths = {};
|
final Map<String, String> _userDbPaths = {};
|
||||||
|
|
||||||
@@ -53,15 +58,18 @@ class SessionService {
|
|||||||
///
|
///
|
||||||
/// [localStorage] - Local storage service for data persistence.
|
/// [localStorage] - Local storage service for data persistence.
|
||||||
/// [syncEngine] - Optional sync engine for coordinating sync operations.
|
/// [syncEngine] - Optional sync engine for coordinating sync operations.
|
||||||
|
/// [firebaseService] - Optional Firebase service for cloud sync.
|
||||||
/// [testDbPath] - Optional database path for testing.
|
/// [testDbPath] - Optional database path for testing.
|
||||||
/// [testCacheDir] - Optional cache directory for testing.
|
/// [testCacheDir] - Optional cache directory for testing.
|
||||||
SessionService({
|
SessionService({
|
||||||
required LocalStorageService localStorage,
|
required LocalStorageService localStorage,
|
||||||
SyncEngine? syncEngine,
|
SyncEngine? syncEngine,
|
||||||
|
FirebaseService? firebaseService,
|
||||||
String? testDbPath,
|
String? testDbPath,
|
||||||
Directory? testCacheDir,
|
Directory? testCacheDir,
|
||||||
}) : _localStorage = localStorage,
|
}) : _localStorage = localStorage,
|
||||||
_syncEngine = syncEngine,
|
_syncEngine = syncEngine,
|
||||||
|
_firebaseService = firebaseService,
|
||||||
_testDbPath = testDbPath,
|
_testDbPath = testDbPath,
|
||||||
_testCacheDir = testCacheDir;
|
_testCacheDir = testCacheDir;
|
||||||
|
|
||||||
@@ -101,6 +109,16 @@ class SessionService {
|
|||||||
// Create user-specific storage paths
|
// Create user-specific storage paths
|
||||||
await _setupUserStorage(user);
|
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
|
// Set as current user
|
||||||
_currentUser = user;
|
_currentUser = user;
|
||||||
|
|
||||||
@@ -123,6 +141,16 @@ class SessionService {
|
|||||||
try {
|
try {
|
||||||
final userId = _currentUser!.id;
|
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
|
// Clear user-specific data if requested
|
||||||
if (clearCache) {
|
if (clearCache) {
|
||||||
await _clearUserData(userId);
|
await _clearUserData(userId);
|
||||||
|
|||||||
+67
-1
@@ -1,11 +1,15 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'config/config_loader.dart';
|
import 'config/config_loader.dart';
|
||||||
import 'data/local/local_storage_service.dart';
|
import 'data/local/local_storage_service.dart';
|
||||||
import 'data/local/models/item.dart';
|
import 'data/local/models/item.dart';
|
||||||
import 'data/nostr/nostr_service.dart';
|
import 'data/nostr/nostr_service.dart';
|
||||||
import 'data/nostr/models/nostr_keypair.dart';
|
import 'data/nostr/models/nostr_keypair.dart';
|
||||||
import 'data/sync/sync_engine.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_screen.dart';
|
||||||
import 'ui/relay_management/relay_management_controller.dart';
|
import 'ui/relay_management/relay_management_controller.dart';
|
||||||
|
|
||||||
@@ -27,6 +31,19 @@ Future<void> main() async {
|
|||||||
|
|
||||||
final config = ConfigLoader.load(environment);
|
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) {
|
if (config.enableLogging) {
|
||||||
debugPrint('App initialized with config: $config');
|
debugPrint('App initialized with config: $config');
|
||||||
}
|
}
|
||||||
@@ -47,6 +64,8 @@ class _MyAppState extends State<MyApp> {
|
|||||||
NostrService? _nostrService;
|
NostrService? _nostrService;
|
||||||
SyncEngine? _syncEngine;
|
SyncEngine? _syncEngine;
|
||||||
NostrKeyPair? _nostrKeyPair;
|
NostrKeyPair? _nostrKeyPair;
|
||||||
|
FirebaseService? _firebaseService;
|
||||||
|
SessionService? _sessionService;
|
||||||
int _itemCount = 0;
|
int _itemCount = 0;
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
|
|
||||||
@@ -79,6 +98,30 @@ class _MyAppState extends State<MyApp> {
|
|||||||
_nostrService!.addRelay(relayUrl);
|
_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(() {
|
setState(() {
|
||||||
_itemCount = items.length;
|
_itemCount = items.length;
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
@@ -87,6 +130,10 @@ class _MyAppState extends State<MyApp> {
|
|||||||
debugPrint('Failed to initialize storage: $e');
|
debugPrint('Failed to initialize storage: $e');
|
||||||
// Reset to null if initialization failed
|
// Reset to null if initialization failed
|
||||||
_storageService = null;
|
_storageService = null;
|
||||||
|
_nostrService = null;
|
||||||
|
_syncEngine = null;
|
||||||
|
_firebaseService = null;
|
||||||
|
_sessionService = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +159,7 @@ class _MyAppState extends State<MyApp> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_syncEngine?.dispose();
|
_syncEngine?.dispose();
|
||||||
_nostrService?.dispose();
|
_nostrService?.dispose();
|
||||||
|
_firebaseService?.dispose();
|
||||||
// Only close if storage service was initialized
|
// Only close if storage service was initialized
|
||||||
if (_storageService != null) {
|
if (_storageService != null) {
|
||||||
try {
|
try {
|
||||||
@@ -189,6 +237,13 @@ class _MyAppState extends State<MyApp> {
|
|||||||
label: 'Logging',
|
label: 'Logging',
|
||||||
value: config.enableLogging ? 'Enabled' : 'Disabled',
|
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),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
Text(
|
Text(
|
||||||
'Phase 6: User Session Management Complete ✓',
|
'Phase 7: Firebase Layer Complete ✓',
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: Colors.grey,
|
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.
|
/// Widget to display a configuration row.
|
||||||
|
|||||||
@@ -5,10 +5,22 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
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 path_provider_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
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"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
+157
@@ -9,6 +9,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "85.0.0"
|
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:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -153,6 +161,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
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:
|
code_builder:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -249,6 +281,126 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
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:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -275,6 +427,11 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_web_plugins:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
frontend_server_client:
|
frontend_server_client:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+11
-3
@@ -17,6 +17,13 @@ dependencies:
|
|||||||
crypto: ^3.0.3
|
crypto: ^3.0.3
|
||||||
web_socket_channel: ^2.4.0
|
web_socket_channel: ^2.4.0
|
||||||
flutter_dotenv: ^5.1.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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -29,8 +36,9 @@ dev_dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
# Assets for .env file (optional - only if you want to bundle defaults)
|
# Assets for .env file
|
||||||
# .env should be in .gitignore and not committed
|
# .env should be in .gitignore and not committed
|
||||||
# assets:
|
# Copy .env.example to .env and fill in your values
|
||||||
# - .env
|
assets:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ void main() {
|
|||||||
expect(config.immichBaseUrl, isNotEmpty);
|
expect(config.immichBaseUrl, isNotEmpty);
|
||||||
expect(config.immichApiKey, isNotEmpty);
|
expect(config.immichApiKey, isNotEmpty);
|
||||||
expect(config.nostrRelays, isNotEmpty);
|
expect(config.nostrRelays, isNotEmpty);
|
||||||
|
expect(config.firebaseConfig, isNotNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Tests that loading 'prod' environment returns the correct configuration.
|
/// Tests that loading 'prod' environment returns the correct configuration.
|
||||||
@@ -27,6 +28,7 @@ void main() {
|
|||||||
expect(config.immichBaseUrl, isNotEmpty);
|
expect(config.immichBaseUrl, isNotEmpty);
|
||||||
expect(config.immichApiKey, isNotEmpty);
|
expect(config.immichApiKey, isNotEmpty);
|
||||||
expect(config.nostrRelays, isNotEmpty);
|
expect(config.nostrRelays, isNotEmpty);
|
||||||
|
expect(config.firebaseConfig, isNotNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Tests that loading configuration is case-insensitive.
|
/// Tests that loading configuration is case-insensitive.
|
||||||
|
|||||||
@@ -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<FirebaseException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '[email protected]',
|
||||||
|
password: 'password123',
|
||||||
|
),
|
||||||
|
throwsA(isA<FirebaseException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loginWithEmailPassword throws when not initialized', () async {
|
||||||
|
final config = FirebaseConfig.enabled();
|
||||||
|
final service = FirebaseService(
|
||||||
|
config: config,
|
||||||
|
localStorage: mockLocalStorage,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => service.loginWithEmailPassword(
|
||||||
|
email: '[email protected]',
|
||||||
|
password: 'password123',
|
||||||
|
),
|
||||||
|
throwsA(isA<FirebaseException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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<FirebaseException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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<FirebaseException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('syncItemsToFirestore throws when not initialized', () async {
|
||||||
|
final config = FirebaseConfig.enabled();
|
||||||
|
final service = FirebaseService(
|
||||||
|
config: config,
|
||||||
|
localStorage: mockLocalStorage,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => service.syncItemsToFirestore('user1'),
|
||||||
|
throwsA(isA<FirebaseException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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<FirebaseException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('syncItemsFromFirestore throws when not initialized', () async {
|
||||||
|
final config = FirebaseConfig.enabled();
|
||||||
|
final service = FirebaseService(
|
||||||
|
config: config,
|
||||||
|
localStorage: mockLocalStorage,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => service.syncItemsFromFirestore('user1'),
|
||||||
|
throwsA(isA<FirebaseException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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<FirebaseException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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<FirebaseException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<void> initialize({
|
||||||
|
String? sessionDbPath,
|
||||||
|
_i2.Directory? sessionCacheDir,
|
||||||
|
}) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#initialize,
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
#sessionDbPath: sessionDbPath,
|
||||||
|
#sessionCacheDir: sessionCacheDir,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
returnValue: _i4.Future<void>.value(),
|
||||||
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i4.Future<void> reinitializeForSession({
|
||||||
|
required String? newDbPath,
|
||||||
|
required _i2.Directory? newCacheDir,
|
||||||
|
}) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#reinitializeForSession,
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
#newDbPath: newDbPath,
|
||||||
|
#newCacheDir: newCacheDir,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
returnValue: _i4.Future<void>.value(),
|
||||||
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i4.Future<void> clearAllData() => (super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#clearAllData,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
returnValue: _i4.Future<void>.value(),
|
||||||
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i4.Future<void> insertItem(_i5.Item? item) => (super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#insertItem,
|
||||||
|
[item],
|
||||||
|
),
|
||||||
|
returnValue: _i4.Future<void>.value(),
|
||||||
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
|
@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<List<_i5.Item>> getAllItems() => (super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#getAllItems,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
returnValue: _i4.Future<List<_i5.Item>>.value(<_i5.Item>[]),
|
||||||
|
) as _i4.Future<List<_i5.Item>>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i4.Future<void> deleteItem(String? id) => (super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#deleteItem,
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
returnValue: _i4.Future<void>.value(),
|
||||||
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i4.Future<void> updateItem(_i5.Item? item) => (super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#updateItem,
|
||||||
|
[item],
|
||||||
|
),
|
||||||
|
returnValue: _i4.Future<void>.value(),
|
||||||
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
|
@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<void> clearImageCache() => (super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#clearImageCache,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
returnValue: _i4.Future<void>.value(),
|
||||||
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i4.Future<void> close() => (super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#close,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
returnValue: _i4.Future<void>.value(),
|
||||||
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
|
) as _i4.Future<void>);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user