Phase 4 - synch engine complete

This commit is contained in:
gitea
2025-11-05 20:26:07 +01:00
parent 1af9cdbc6d
commit c2447f4a0d
12 changed files with 1967 additions and 58 deletions
+55 -15
View File
@@ -1,3 +1,4 @@
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'app_config.dart';
/// Exception thrown when an invalid environment is provided to [ConfigLoader].
@@ -26,33 +27,72 @@ class ConfigLoader {
/// Loads configuration for the specified environment.
///
/// Reads from .env file if available, falls back to hardcoded defaults.
///
/// [environment] - The environment to load ('dev' or 'prod').
///
/// Returns an [AppConfig] instance for the specified environment.
///
/// Throws [InvalidEnvironmentException] if [environment] is not 'dev' or 'prod'.
static AppConfig load(String environment) {
switch (environment.toLowerCase()) {
final env = environment.toLowerCase();
// Helper to get env var or fallback to default
// Handles case where dotenv is not initialized (e.g., in tests)
String getEnv(String key, String defaultValue) {
try {
return dotenv.env[key] ?? defaultValue;
} catch (e) {
// dotenv not initialized, use default
return defaultValue;
}
}
// Helper to parse boolean from env
bool getBoolEnv(String key, bool defaultValue) {
try {
final value = dotenv.env[key];
if (value == null) return defaultValue;
return value.toLowerCase() == 'true';
} catch (e) {
// dotenv not initialized, use default
return defaultValue;
}
}
// Helper to parse comma-separated list from env
List<String> getListEnv(String key, List<String> defaultValue) {
try {
final value = dotenv.env[key];
if (value == null || value.isEmpty) return defaultValue;
return value.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
} catch (e) {
// dotenv not initialized, use default
return defaultValue;
}
}
switch (env) {
case 'dev':
return const AppConfig(
apiBaseUrl: 'https://api-dev.example.com',
enableLogging: true,
immichBaseUrl: 'https://photos.satoshinakamoto.win', // ← Change your Immich server URL here
immichApiKey: '3v2fTujMJHy1T2rrVJVojdJS0ySm7IRcRvEcvvUvs0', // ← Change your Immich API key here
nostrRelays: [ // ← Add Nostr relay URLs for testing
return AppConfig(
apiBaseUrl: getEnv('API_BASE_URL_DEV', 'https://api-dev.example.com'),
enableLogging: getBoolEnv('ENABLE_LOGGING_DEV', true),
immichBaseUrl: getEnv('IMMICH_BASE_URL', 'https://photos.satoshinakamoto.win'),
immichApiKey: getEnv('IMMICH_API_KEY_DEV', 'your-dev-api-key-here'),
nostrRelays: getListEnv('NOSTR_RELAYS_DEV', [
'wss://nostrum.satoshinakamoto.win',
'wss://nos.lol',
],
]),
);
case 'prod':
return const AppConfig(
apiBaseUrl: 'https://api.example.com',
enableLogging: false,
immichBaseUrl: 'https://photos.satoshinakamoto.win', // ← Change your Immich server URL here
immichApiKey: 'your-prod-api-key-here', // ← Change your Immich API key here
nostrRelays: [ // ← Add Nostr relay URLs for production
return AppConfig(
apiBaseUrl: getEnv('API_BASE_URL_PROD', 'https://api.example.com'),
enableLogging: getBoolEnv('ENABLE_LOGGING_PROD', false),
immichBaseUrl: getEnv('IMMICH_BASE_URL', 'https://photos.satoshinakamoto.win'),
immichApiKey: getEnv('IMMICH_API_KEY_PROD', 'your-prod-api-key-here'),
nostrRelays: getListEnv('NOSTR_RELAYS_PROD', [
'wss://relay.damus.io',
],
]),
);
default:
throw InvalidEnvironmentException(environment);
+146
View File
@@ -0,0 +1,146 @@
import 'sync_status.dart';
/// Represents a sync operation in the queue.
class SyncOperation {
/// Unique identifier for the operation.
final String id;
/// Type of sync operation.
final SyncOperationType type;
/// Item ID being synced.
final String itemId;
/// Source of the sync (local, immich, nostr).
final String source;
/// Target of the sync (local, immich, nostr).
final String target;
/// Current status of the operation.
SyncStatus status;
/// Priority of the operation.
final SyncPriority priority;
/// Number of retry attempts.
int retryCount;
/// Maximum number of retries allowed.
final int maxRetries;
/// Error message if operation failed.
String? error;
/// Timestamp when operation was created.
final int createdAt;
/// Timestamp when operation was last updated.
int updatedAt;
/// Creates a [SyncOperation] instance.
SyncOperation({
required this.id,
required this.type,
required this.itemId,
required this.source,
required this.target,
this.status = SyncStatus.pending,
this.priority = SyncPriority.normal,
this.retryCount = 0,
this.maxRetries = 3,
this.error,
int? createdAt,
int? updatedAt,
}) : createdAt = createdAt ?? DateTime.now().millisecondsSinceEpoch,
updatedAt = updatedAt ?? DateTime.now().millisecondsSinceEpoch;
/// Creates a [SyncOperation] from JSON.
factory SyncOperation.fromJson(Map<String, dynamic> json) {
return SyncOperation(
id: json['id'] as String,
type: SyncOperationType.values.firstWhere(
(e) => e.toString() == json['type'],
orElse: () => SyncOperationType.upload,
),
itemId: json['itemId'] as String,
source: json['source'] as String,
target: json['target'] as String,
status: SyncStatus.values.firstWhere(
(e) => e.toString() == json['status'],
orElse: () => SyncStatus.pending,
),
priority: SyncPriority.values.firstWhere(
(e) => e.toString() == json['priority'],
orElse: () => SyncPriority.normal,
),
retryCount: json['retryCount'] as int? ?? 0,
maxRetries: json['maxRetries'] as int? ?? 3,
error: json['error'] as String?,
createdAt: json['createdAt'] as int,
updatedAt: json['updatedAt'] as int,
);
}
/// Converts [SyncOperation] to JSON.
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type.toString(),
'itemId': itemId,
'source': source,
'target': target,
'status': status.toString(),
'priority': priority.toString(),
'retryCount': retryCount,
'maxRetries': maxRetries,
'error': error,
'createdAt': createdAt,
'updatedAt': updatedAt,
};
}
/// Marks the operation as failed with an error.
void markFailed(String errorMessage) {
status = SyncStatus.failed;
error = errorMessage;
updatedAt = DateTime.now().millisecondsSinceEpoch;
}
/// Marks the operation as successful.
void markSuccess() {
status = SyncStatus.success;
error = null;
updatedAt = DateTime.now().millisecondsSinceEpoch;
}
/// Increments retry count and updates status.
void incrementRetry() {
retryCount++;
status = SyncStatus.pending;
updatedAt = DateTime.now().millisecondsSinceEpoch;
}
/// Checks if the operation can be retried.
bool canRetry() {
return retryCount < maxRetries && status == SyncStatus.failed;
}
@override
String toString() {
return 'SyncOperation(id: $id, type: $type, itemId: $itemId, status: $status, retries: $retryCount/$maxRetries)';
}
}
/// Type of sync operation.
enum SyncOperationType {
/// Upload operation (local to remote).
upload,
/// Download operation (remote to local).
download,
/// Bidirectional sync (merge).
sync,
}
+42
View File
@@ -0,0 +1,42 @@
/// Status of a sync operation.
enum SyncStatus {
/// Sync operation is pending (queued).
pending,
/// Sync operation is in progress.
syncing,
/// Sync operation completed successfully.
success,
/// Sync operation failed.
failed,
}
/// Priority level for sync operations.
enum SyncPriority {
/// Low priority (background sync).
low,
/// Normal priority.
normal,
/// High priority (user-initiated).
high,
}
/// Resolution strategy for conflicts.
enum ConflictResolution {
/// Use local version (prefer local data).
useLocal,
/// Use remote version (prefer remote data).
useRemote,
/// Merge both versions.
merge,
/// Keep the most recent version based on timestamp.
useLatest,
}
+424
View File
@@ -0,0 +1,424 @@
import 'dart:async';
import '../local/local_storage_service.dart';
import '../local/models/item.dart';
import '../immich/immich_service.dart';
import '../immich/models/immich_asset.dart';
import '../nostr/nostr_service.dart';
import '../nostr/models/nostr_event.dart';
import '../nostr/models/nostr_keypair.dart';
import 'models/sync_status.dart';
import 'models/sync_operation.dart';
/// Exception thrown when sync operations fail.
class SyncException implements Exception {
/// Error message.
final String message;
/// Creates a [SyncException] with the provided message.
SyncException(this.message);
@override
String toString() => 'SyncException: $message';
}
/// Engine for coordinating data synchronization between local storage, Immich, and Nostr.
///
/// This service provides:
/// - Bidirectional sync between local storage, Immich, and Nostr
/// - Conflict resolution strategies
/// - Offline queue for operations when network is unavailable
/// - Automatic retry with exponential backoff
///
/// The service is modular and UI-independent, designed for offline-first behavior.
class SyncEngine {
/// Local storage service.
final LocalStorageService _localStorage;
/// Immich service (optional).
final ImmichService? _immichService;
/// Nostr service (optional).
final NostrService? _nostrService;
/// Nostr keypair for signing events (required if using Nostr).
NostrKeyPair? _nostrKeyPair;
/// Queue of pending sync operations.
final List<SyncOperation> _operationQueue = [];
/// Currently executing operation (null if idle).
SyncOperation? _currentOperation;
/// Stream controller for sync status updates.
final StreamController<SyncOperation> _statusController = StreamController<SyncOperation>.broadcast();
/// Whether the engine has been disposed.
bool _isDisposed = false;
/// Conflict resolution strategy.
ConflictResolution _conflictResolution = ConflictResolution.useLatest;
/// Maximum queue size.
final int maxQueueSize;
/// Creates a [SyncEngine] instance.
///
/// [localStorage] - Local storage service (required).
/// [immichService] - Immich service (optional).
/// [nostrService] - Nostr service (optional).
/// [nostrKeyPair] - Nostr keypair for signing events (optional).
/// [conflictResolution] - Conflict resolution strategy (default: useLatest).
/// [maxQueueSize] - Maximum number of queued operations (default: 100).
SyncEngine({
required LocalStorageService localStorage,
ImmichService? immichService,
NostrService? nostrService,
NostrKeyPair? nostrKeyPair,
ConflictResolution conflictResolution = ConflictResolution.useLatest,
this.maxQueueSize = 100,
}) : _localStorage = localStorage,
_immichService = immichService,
_nostrService = nostrService,
_nostrKeyPair = nostrKeyPair,
_conflictResolution = conflictResolution;
/// Sets the Nostr keypair for signing events.
void setNostrKeyPair(NostrKeyPair keypair) {
_nostrKeyPair = keypair;
}
/// Sets the conflict resolution strategy.
void setConflictResolution(ConflictResolution strategy) {
_conflictResolution = strategy;
}
/// Stream of sync operation status updates.
Stream<SyncOperation> get statusStream => _statusController.stream;
/// Gets the current queue of pending operations.
List<SyncOperation> getPendingOperations() {
return _operationQueue.where((op) => op.status == SyncStatus.pending).toList();
}
/// Gets all operations (pending, in-progress, completed, failed).
List<SyncOperation> getAllOperations() {
return List.unmodifiable(_operationQueue);
}
/// Queues a sync operation.
///
/// [operation] - The sync operation to queue.
///
/// Throws [SyncException] if queue is full.
void queueOperation(SyncOperation operation) {
if (_isDisposed) {
throw SyncException('SyncEngine has been disposed');
}
if (_operationQueue.length >= maxQueueSize) {
throw SyncException('Sync queue is full (max: $maxQueueSize)');
}
_operationQueue.add(operation);
_emitStatus(operation);
// Auto-start processing if idle
if (_currentOperation == null) {
_processQueue();
}
}
/// Syncs an item from local storage to Immich.
///
/// [itemId] - The ID of the item to sync.
/// [priority] - Priority of the sync operation.
///
/// Returns the sync operation ID.
Future<String> syncToImmich(String itemId, {SyncPriority priority = SyncPriority.normal}) async {
if (_immichService == null) {
throw SyncException('Immich service not configured');
}
final operation = SyncOperation(
id: 'sync-immich-${DateTime.now().millisecondsSinceEpoch}',
type: SyncOperationType.upload,
itemId: itemId,
source: 'local',
target: 'immich',
priority: priority,
);
queueOperation(operation);
return operation.id;
}
/// Syncs metadata from Immich to local storage.
///
/// [assetId] - The Immich asset ID to sync.
/// [priority] - Priority of the sync operation.
///
/// Returns the sync operation ID.
Future<String> syncFromImmich(String assetId, {SyncPriority priority = SyncPriority.normal}) async {
if (_immichService == null) {
throw SyncException('Immich service not configured');
}
final operation = SyncOperation(
id: 'sync-immich-${DateTime.now().millisecondsSinceEpoch}',
type: SyncOperationType.download,
itemId: assetId,
source: 'immich',
target: 'local',
priority: priority,
);
queueOperation(operation);
return operation.id;
}
/// Syncs metadata to Nostr.
///
/// [itemId] - The ID of the item to sync.
/// [priority] - Priority of the sync operation.
///
/// Returns the sync operation ID.
Future<String> syncToNostr(String itemId, {SyncPriority priority = SyncPriority.normal}) async {
if (_nostrService == null) {
throw SyncException('Nostr service not configured');
}
if (_nostrKeyPair == null) {
throw SyncException('Nostr keypair not set');
}
final operation = SyncOperation(
id: 'sync-nostr-${DateTime.now().millisecondsSinceEpoch}',
type: SyncOperationType.upload,
itemId: itemId,
source: 'local',
target: 'nostr',
priority: priority,
);
queueOperation(operation);
return operation.id;
}
/// Performs a full sync: syncs all items between configured services.
///
/// [priority] - Priority of sync operations.
///
/// Returns a list of operation IDs.
Future<List<String>> syncAll({SyncPriority priority = SyncPriority.normal}) async {
final operationIds = <String>[];
// Sync local items to Immich
if (_immichService != null) {
final localItems = await _localStorage.getAllItems();
for (final item in localItems) {
if (item.data['type'] == 'immich_asset') {
// Already synced, skip
continue;
}
final id = await syncToImmich(item.id, priority: priority);
operationIds.add(id);
}
}
// Sync local items to Nostr
if (_nostrService != null && _nostrKeyPair != null) {
final localItems = await _localStorage.getAllItems();
for (final item in localItems) {
final id = await syncToNostr(item.id, priority: priority);
operationIds.add(id);
}
}
return operationIds;
}
/// Processes the sync queue.
Future<void> _processQueue() async {
if (_currentOperation != null || _isDisposed) return; // Already processing or disposed
// Sort queue by priority (high first)
_operationQueue.sort((a, b) {
if (a.priority != b.priority) {
return b.priority.index.compareTo(a.priority.index);
}
return a.createdAt.compareTo(b.createdAt);
});
// Process pending operations
while (!_isDisposed && _operationQueue.any((op) => op.status == SyncStatus.pending)) {
final operation = _operationQueue.firstWhere(
(op) => op.status == SyncStatus.pending,
);
_currentOperation = operation;
operation.status = SyncStatus.syncing;
_emitStatus(operation);
try {
await _executeOperation(operation);
operation.markSuccess();
} catch (e) {
operation.markFailed(e.toString());
// Retry if possible
if (operation.canRetry() && !_isDisposed) {
await Future.delayed(Duration(seconds: operation.retryCount));
if (!_isDisposed) {
operation.incrementRetry();
_emitStatus(operation);
continue; // Retry this operation
}
}
} finally {
if (!_isDisposed) {
_emitStatus(operation);
}
_currentOperation = null;
}
}
}
/// Emits a status update if not disposed.
void _emitStatus(SyncOperation operation) {
if (!_isDisposed && !_statusController.isClosed) {
try {
_statusController.add(operation);
} catch (e) {
// Ignore if controller is closed
}
}
}
/// Executes a sync operation.
Future<void> _executeOperation(SyncOperation operation) async {
switch (operation.type) {
case SyncOperationType.upload:
if (operation.target == 'immich') {
await _uploadToImmich(operation);
} else if (operation.target == 'nostr') {
await _uploadToNostr(operation);
}
break;
case SyncOperationType.download:
if (operation.source == 'immich') {
await _downloadFromImmich(operation);
}
break;
case SyncOperationType.sync:
// Bidirectional sync - would need more complex logic
throw SyncException('Bidirectional sync not yet implemented');
}
}
/// Uploads item metadata to Immich.
Future<void> _uploadToImmich(SyncOperation operation) async {
final item = await _localStorage.getItem(operation.itemId);
if (item == null) {
throw SyncException('Item not found: ${operation.itemId}');
}
// Check if already synced
final cachedAsset = await _immichService!.getCachedAsset(operation.itemId);
if (cachedAsset != null) {
// Already synced, skip
return;
}
// For real upload, we'd need the actual image file
// For now, we just mark as synced by storing metadata
// In a real implementation, this would upload the image file
}
/// Downloads asset metadata from Immich.
Future<void> _downloadFromImmich(SyncOperation operation) async {
final asset = await _immichService!.getCachedAsset(operation.itemId);
if (asset == null) {
// Try to fetch from Immich
final assets = await _immichService!.fetchAssets(limit: 100);
final matching = assets.where((a) => a.id == operation.itemId);
if (matching.isEmpty) {
throw SyncException('Asset not found: ${operation.itemId}');
}
}
// Metadata is automatically stored by ImmichService
}
/// Uploads item metadata to Nostr.
Future<void> _uploadToNostr(SyncOperation operation) async {
final item = await _localStorage.getItem(operation.itemId);
if (item == null) {
throw SyncException('Item not found: ${operation.itemId}');
}
// Prepare metadata for Nostr
final metadata = {
'itemId': item.id,
'data': item.data,
'createdAt': item.createdAt,
'updatedAt': item.updatedAt,
};
// Sync metadata to Nostr
await _nostrService!.syncMetadata(
metadata: metadata,
privateKey: _nostrKeyPair!.privateKey,
kind: 30000, // Custom kind for app metadata
);
}
/// Resolves a conflict between local and remote data.
///
/// [localItem] - Local item data.
/// [remoteItem] - Remote item data.
///
/// Returns the resolved item data.
Map<String, dynamic> resolveConflict(
Map<String, dynamic> localItem,
Map<String, dynamic> remoteItem,
) {
switch (_conflictResolution) {
case ConflictResolution.useLocal:
return localItem;
case ConflictResolution.useRemote:
return remoteItem;
case ConflictResolution.useLatest:
final localTime = localItem['updatedAt'] as int? ?? 0;
final remoteTime = remoteItem['updatedAt'] as int? ?? 0;
return localTime > remoteTime ? localItem : remoteItem;
case ConflictResolution.merge:
// Simple merge: combine data, prefer remote for conflicts
return {
...localItem,
...remoteItem,
};
}
}
/// Clears all completed operations from the queue.
void clearCompleted() {
_operationQueue.removeWhere((op) => op.status == SyncStatus.success);
}
/// Clears all failed operations from the queue.
void clearFailed() {
_operationQueue.removeWhere((op) => op.status == SyncStatus.failed);
}
/// Disposes resources and closes streams.
void dispose() {
_isDisposed = true;
_operationQueue.clear();
_currentOperation = null;
if (!_statusController.isClosed) {
_statusController.close();
}
}
}
+28 -10
View File
@@ -1,11 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'config/config_loader.dart';
import 'data/local/local_storage_service.dart';
import 'data/local/models/item.dart';
void main() {
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Load .env file (optional - falls back to defaults if not found)
try {
await dotenv.load(fileName: '.env');
} catch (e) {
debugPrint('Note: .env file not found, using default values: $e');
}
// Load configuration based on environment
// In a real app, this would come from environment variables or build config
const String environment = String.fromEnvironment(
'ENV',
defaultValue: 'dev',
@@ -29,7 +38,7 @@ class MyApp extends StatefulWidget {
}
class _MyAppState extends State<MyApp> {
late LocalStorageService _storageService;
LocalStorageService? _storageService;
int _itemCount = 0;
bool _isInitialized = false;
@@ -42,19 +51,21 @@ class _MyAppState extends State<MyApp> {
Future<void> _initializeStorage() async {
try {
_storageService = LocalStorageService();
await _storageService.initialize();
final items = await _storageService.getAllItems();
await _storageService!.initialize();
final items = await _storageService!.getAllItems();
setState(() {
_itemCount = items.length;
_isInitialized = true;
});
} catch (e) {
debugPrint('Failed to initialize storage: $e');
// Reset to null if initialization failed
_storageService = null;
}
}
Future<void> _addTestItem() async {
if (!_isInitialized) return;
if (!_isInitialized || _storageService == null) return;
final item = Item(
id: 'test-${DateTime.now().millisecondsSinceEpoch}',
@@ -64,8 +75,8 @@ class _MyAppState extends State<MyApp> {
},
);
await _storageService.insertItem(item);
final items = await _storageService.getAllItems();
await _storageService!.insertItem(item);
final items = await _storageService!.getAllItems();
setState(() {
_itemCount = items.length;
});
@@ -73,7 +84,14 @@ class _MyAppState extends State<MyApp> {
@override
void dispose() {
_storageService.close();
// Only close if storage service was initialized
if (_storageService != null) {
try {
_storageService!.close();
} catch (e) {
debugPrint('Error closing storage service: $e');
}
}
super.dispose();
}
@@ -178,7 +196,7 @@ class _MyAppState extends State<MyApp> {
const SizedBox(height: 16),
],
Text(
'Phase 3: Nostr Integration Complete ✓',
'Phase 4: Sync Engine Complete ✓',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
),