Phase 4 - synch engine complete

master
gitea 2 months ago
parent 1af9cdbc6d
commit c2447f4a0d

@ -0,0 +1,20 @@
# Environment Configuration
# Copy this file to .env and fill in your actual values
# DO NOT commit .env to version control
# API Configuration
API_BASE_URL_DEV=https://api-dev.example.com
API_BASE_URL_PROD=https://api.example.com
# Immich Configuration
IMMICH_BASE_URL=https://photos.satoshinakamoto.win
IMMICH_API_KEY_DEV=your-dev-api-key-here
IMMICH_API_KEY_PROD=your-prod-api-key-here
# Nostr Relays (comma-separated)
NOSTR_RELAYS_DEV=wss://nostrum.satoshinakamoto.win,wss://nos.lol
NOSTR_RELAYS_PROD=wss://relay.damus.io
# Logging
ENABLE_LOGGING_DEV=true
ENABLE_LOGGING_PROD=false

5
.gitignore vendored

@ -49,3 +49,8 @@ app.*.map.json
*.g.dart
*.freezed.dart
# Environment variables
.env
.env.local
.env.*.local

@ -2,6 +2,14 @@
A modular, offline-first Flutter boilerplate for apps that store, sync, and share media and metadata across centralized (Immich, Firebase) and decentralized (Nostr) systems.
## Phase 4 - Sync Engine
- Coordinates data synchronization between local storage, Immich, and Nostr
- Conflict resolution strategies (useLocal, useRemote, useLatest, merge)
- Offline queue with automatic retry
- Priority-based operation processing
- Comprehensive unit and integration tests
## Phase 3 - Nostr Integration
- Nostr protocol service for decentralized metadata synchronization
@ -74,24 +82,53 @@ Service for decentralized metadata synchronization using Nostr protocol. Generat
**Key Methods:** `generateKeyPair()`, `addRelay()`, `connectRelay()`, `publishEvent()`, `syncMetadata()`, `dispose()`
## Sync Engine
Engine for coordinating data synchronization between local storage, Immich, and Nostr. Handles conflict resolution, offline queuing, and automatic retries. Processes operations by priority with configurable conflict resolution strategies.
**Files:**
- `lib/data/sync/sync_engine.dart` - Main sync engine class
- `lib/data/sync/models/sync_status.dart` - Status and priority enums
- `lib/data/sync/models/sync_operation.dart` - Operation model
- `test/data/sync/sync_engine_test.dart` - Unit and integration tests
**Key Methods:** `syncToImmich()`, `syncFromImmich()`, `syncToNostr()`, `syncAll()`, `queueOperation()`, `resolveConflict()`, `getPendingOperations()`
**Conflict Resolution:** `useLocal`, `useRemote`, `useLatest`, `merge` - set via `setConflictResolution()`
## Configuration
**All configuration is 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`.**
### Setup .env File
Edit this file to change API URLs, Immich server URL and API key, logging settings, and other environment-specific values. Replace placeholder values (`'your-dev-api-key-here'`, `'your-prod-api-key-here'`) with your actual API keys.
1. Copy `.env.example` to `.env` in the project root:
```bash
cp .env.example .env
```
**Nostr Relays:** Configure relay URLs in `lib/config/config_loader.dart` in the `nostrRelays` list (lines 43-46 for dev, lines 52-54 for prod). Add or remove relay URLs as needed.
2. Edit `.env` and fill in your actual values:
- `IMMICH_BASE_URL` - Your Immich server URL
- `IMMICH_API_KEY_DEV` - Your development Immich API key
- `IMMICH_API_KEY_PROD` - Your production Immich API key
- `NOSTR_RELAYS_DEV` - Comma-separated Nostr relay URLs for dev
- `NOSTR_RELAYS_PROD` - Comma-separated Nostr relay URLs for prod
- Other configuration values as needed
**Important:** The `.env` file is in `.gitignore` and should never be committed to version control. Only commit `.env.example` as a template.
### Environment Variables
Currently, the app uses compile-time environment variables via `--dart-define`:
The app uses:
- **`.env` file** (recommended) - Loaded at runtime, falls back to defaults if not found
- **`--dart-define`** - For environment selection:
```bash
# Set environment at runtime
flutter run --dart-define=ENV=prod
```
**Future:** `.env` file support can be added in later phases. When implemented, `.env` files would go in the project root and be loaded at runtime.
If `.env` file is not found or variables are missing, the app uses default values from `lib/config/config_loader.dart`.
### Available Environments
@ -144,12 +181,17 @@ lib/
│ │ └── models/
│ │ ├── immich_asset.dart
│ │ └── upload_response.dart
│ └── nostr/
│ ├── nostr_service.dart
│ ├── nostr/
│ │ ├── nostr_service.dart
│ │ └── models/
│ │ ├── nostr_keypair.dart
│ │ ├── nostr_event.dart
│ │ └── nostr_relay.dart
│ └── sync/
│ ├── sync_engine.dart
│ └── models/
│ ├── nostr_keypair.dart
│ ├── nostr_event.dart
│ └── nostr_relay.dart
│ ├── sync_status.dart
│ └── sync_operation.dart
└── main.dart
test/
@ -160,6 +202,8 @@ test/
│ └── local_storage_service_test.dart
├── immich/
│ └── immich_service_test.dart
└── nostr/
└── nostr_service_test.dart
├── nostr/
│ └── nostr_service_test.dart
└── sync/
└── sync_engine_test.dart
```

@ -1,26 +1,668 @@
SF:lib/config/app_config.dart
DA:28,1
DA:36,0
DA:38,0
DA:39,0
DA:42,0
DA:45,0
DA:46,0
DA:47,0
DA:48,0
DA:49,0
DA:50,0
DA:53,0
DA:55,0
DA:56,0
DA:57,0
DA:58,0
DA:59,0
LF:17
LH:1
end_of_record
SF:lib/config/config_loader.dart
DA:10,1
DA:12,1
DA:14,2
DA:26,0
DA:37,1
DA:38,1
DA:42,1
DA:44,2
DA:52,1
DA:54,2
DA:56,0
DA:64,1
DA:66,2
DA:67,0
DA:68,0
DA:76,1
DA:77,1
DA:78,1
DA:79,1
DA:80,1
DA:81,1
DA:82,2
DA:87,1
DA:88,1
DA:89,1
DA:90,1
DA:91,1
DA:92,1
DA:93,2
DA:98,1
LF:30
LH:26
end_of_record
SF:lib/data/immich/immich_service.dart
DA:17,2
DA:19,2
DA:21,2
DA:22,6
DA:24,2
DA:55,2
DA:63,0
DA:64,6
DA:65,8
DA:66,8
DA:78,1
DA:83,1
DA:84,3
DA:88,2
DA:89,2
DA:90,1
DA:91,3
DA:93,0
DA:97,2
DA:102,2
DA:103,0
DA:104,0
DA:105,0
DA:109,2
DA:112,2
DA:115,1
DA:118,1
DA:119,1
DA:120,2
DA:121,1
DA:124,2
DA:137,2
DA:142,4
DA:144,2
DA:150,2
DA:151,1
DA:152,2
DA:153,1
DA:157,1
DA:159,3
DA:160,1
DA:163,2
DA:164,1
DA:168,2
DA:169,1
DA:170,2
DA:171,2
DA:174,2
DA:185,1
DA:187,3
DA:189,2
DA:190,0
DA:191,0
DA:192,0
DA:196,2
DA:197,0
DA:198,0
DA:199,0
DA:200,0
DA:203,0
DA:210,1
DA:212,1
DA:213,2
DA:214,1
DA:216,1
DA:220,2
DA:232,2
DA:234,6
DA:237,2
DA:238,1
DA:247,1
DA:249,2
DA:250,1
DA:252,2
DA:253,3
DA:254,2
DA:255,2
DA:261,0
LF:78
LH:64
end_of_record
SF:lib/data/immich/models/immich_asset.dart
DA:25,1
DA:36,1
DA:37,1
DA:38,1
DA:39,1
DA:40,2
DA:41,1
DA:42,1
DA:43,1
DA:44,1
DA:49,1
DA:50,1
DA:51,1
DA:52,1
DA:53,2
DA:54,1
DA:55,1
DA:56,1
DA:57,1
DA:62,1
DA:63,1
DA:64,1
DA:65,1
DA:66,2
DA:67,1
DA:68,1
DA:69,1
DA:70,1
DA:74,0
DA:76,0
LF:30
LH:28
end_of_record
SF:lib/data/immich/models/upload_response.dart
DA:10,1
DA:16,1
DA:21,0
DA:17,1
DA:18,1
DA:19,1
DA:23,0
DA:26,0
DA:29,0
DA:30,0
DA:31,0
DA:25,0
LF:7
LH:5
end_of_record
SF:lib/data/local/local_storage_service.dart
DA:37,3
DA:48,3
DA:51,3
DA:52,6
DA:55,3
DA:59,3
DA:60,6
DA:62,0
DA:63,0
DA:66,6
DA:67,6
DA:70,0
DA:75,3
DA:76,3
DA:87,3
DA:88,3
DA:92,3
DA:93,3
DA:97,0
DA:98,0
DA:99,0
DA:105,3
DA:106,3
DA:107,1
DA:116,3
DA:117,3
DA:119,3
DA:120,9
DA:121,6
DA:127,0
DA:137,3
DA:138,3
DA:140,6
DA:143,3
DA:147,3
DA:152,3
DA:153,6
DA:155,3
DA:156,3
DA:158,3
DA:159,3
DA:162,2
DA:171,2
DA:172,2
DA:174,4
DA:179,4
DA:180,4
DA:181,2
DA:182,2
DA:184,2
DA:185,2
DA:187,2
DA:189,0
DA:198,1
DA:199,1
DA:201,2
DA:204,1
DA:207,1
DA:208,2
DA:211,2
DA:220,1
DA:221,1
DA:224,1
DA:225,2
DA:228,1
DA:229,3
DA:231,2
DA:235,2
DA:238,1
DA:239,3
DA:242,2
DA:257,1
DA:258,1
DA:259,1
DA:263,2
DA:264,4
DA:267,1
DA:268,0
DA:274,2
DA:276,2
DA:277,3
DA:280,0
DA:281,0
DA:285,2
DA:292,1
DA:293,1
DA:294,0
DA:298,2
DA:299,3
DA:300,0
DA:301,0
DA:305,2
DA:307,0
DA:314,3
DA:315,6
DA:316,3
LF:96
LH:81
end_of_record
SF:lib/data/local/models/item.dart
DA:25,3
DA:30,6
DA:31,6
DA:34,0
DA:35,0
LF:9
LH:1
DA:36,0
DA:37,0
DA:38,0
DA:39,0
DA:44,3
DA:45,3
DA:46,3
DA:47,3
DA:48,3
DA:49,3
DA:54,1
DA:60,1
DA:61,1
DA:62,1
DA:63,1
DA:64,1
DA:68,0
DA:70,0
DA:73,0
DA:76,0
DA:77,0
DA:78,0
DA:79,0
DA:82,0
DA:83,0
LF:30
LH:15
end_of_record
SF:lib/config/config_loader.dart
DA:9,1
DA:11,1
DA:13,2
DA:25,0
DA:34,1
DA:35,1
DA:36,1
SF:lib/data/nostr/nostr_service.dart
DA:14,1
DA:16,1
DA:17,2
DA:39,2
DA:44,2
DA:45,2
DA:51,1
DA:52,1
DA:53,2
DA:54,2
DA:61,1
DA:62,5
DA:63,1
DA:67,1
DA:68,2
DA:78,0
DA:80,0
DA:82,0
DA:85,0
DA:86,0
DA:88,0
DA:89,0
DA:92,0
DA:93,0
DA:96,0
DA:97,0
DA:99,0
DA:100,0
DA:101,0
DA:102,0
DA:103,0
DA:110,0
DA:111,0
DA:112,0
DA:114,0
DA:115,0
DA:116,0
DA:120,0
DA:122,0
DA:129,1
DA:130,2
DA:132,0
DA:133,0
DA:136,2
DA:138,0
DA:139,0
DA:142,4
DA:143,1
DA:154,1
DA:156,2
DA:158,2
DA:162,0
DA:163,0
DA:165,2
DA:174,2
DA:175,2
DA:177,3
DA:178,1
DA:180,0
DA:181,0
DA:183,0
DA:186,2
DA:202,2
DA:209,2
DA:210,2
DA:217,2
DA:221,0
DA:226,1
DA:227,3
DA:228,0
DA:230,2
LF:71
LH:36
end_of_record
SF:lib/data/nostr/models/nostr_keypair.dart
DA:16,2
DA:24,2
DA:26,10
DA:27,16
DA:28,10
DA:31,6
DA:32,10
DA:34,2
DA:41,1
DA:42,1
DA:43,1
DA:44,1
DA:49,1
DA:50,1
DA:51,1
DA:52,1
DA:56,0
DA:58,0
LF:18
LH:16
end_of_record
SF:lib/data/nostr/models/nostr_event.dart
DA:28,2
DA:39,1
DA:40,1
DA:41,1
DA:42,1
DA:43,1
DA:44,1
DA:45,1
DA:46,1
DA:47,1
DA:48,1
DA:49,1
DA:54,1
DA:55,1
DA:56,1
DA:57,1
DA:58,1
DA:59,1
DA:60,1
DA:61,1
DA:62,1
DA:72,2
DA:79,2
DA:80,6
DA:81,10
DA:83,6
DA:84,2
DA:87,2
DA:97,2
DA:98,8
DA:99,10
DA:102,10
DA:103,10
DA:105,2
DA:117,2
DA:118,2
DA:119,6
DA:120,8
DA:125,0
DA:127,0
LF:40
LH:38
end_of_record
SF:lib/data/nostr/models/nostr_relay.dart
DA:10,1
DA:16,1
DA:17,1
DA:20,0
DA:22,0
DA:25,1
DA:28,4
DA:31,0
DA:32,0
LF:9
LH:8
LH:5
end_of_record
SF:lib/data/sync/models/sync_operation.dart
DA:42,1
DA:55,2
DA:56,2
DA:59,0
DA:60,0
DA:61,0
DA:62,0
DA:63,0
DA:64,0
DA:66,0
DA:67,0
DA:68,0
DA:69,0
DA:70,0
DA:71,0
DA:73,0
DA:74,0
DA:75,0
DA:77,0
DA:78,0
DA:79,0
DA:80,0
DA:81,0
DA:86,0
DA:87,0
DA:88,0
DA:89,0
DA:90,0
DA:91,0
DA:92,0
DA:93,0
DA:94,0
DA:95,0
DA:96,0
DA:97,0
DA:98,0
DA:99,0
DA:104,1
DA:105,1
DA:106,1
DA:107,3
DA:111,1
DA:112,1
DA:113,1
DA:114,3
DA:118,1
DA:119,2
DA:120,1
DA:121,3
DA:125,1
DA:126,5
DA:129,0
DA:131,0
LF:53
LH:17
end_of_record
SF:lib/data/sync/sync_engine.dart
DA:18,1
DA:20,1
DA:21,2
DA:72,1
DA:86,0
DA:87,0
DA:91,1
DA:92,1
DA:96,3
DA:99,0
DA:100,0
DA:104,1
DA:105,2
DA:113,1
DA:114,1
DA:115,0
DA:118,4
DA:119,3
DA:122,2
DA:123,1
DA:126,1
DA:127,1
DA:137,1
DA:138,1
DA:139,1
DA:142,1
DA:143,3
DA:151,1
DA:152,1
DA:161,1
DA:162,1
DA:163,0
DA:166,1
DA:167,3
DA:175,1
DA:176,1
DA:185,1
DA:186,1
DA:187,0
DA:190,1
DA:191,1
DA:194,1
DA:195,3
DA:203,1
DA:204,1
DA:212,0
DA:213,0
DA:216,0
DA:217,0
DA:218,0
DA:219,0
DA:223,0
DA:224,0
DA:229,0
DA:230,0
DA:231,0
DA:232,0
DA:233,0
DA:241,1
DA:242,2
DA:245,3
DA:246,3
DA:247,0
DA:249,3
DA:253,6
DA:254,2
DA:255,3
DA:258,1
DA:259,1
DA:260,1
DA:263,1
DA:264,1
DA:266,2
DA:269,2
DA:270,3
DA:271,1
DA:272,1
DA:273,1
DA:278,1
DA:279,1
DA:281,1
DA:287,1
DA:288,3
DA:290,2
DA:298,1
DA:299,1
DA:300,1
DA:301,2
DA:302,1
DA:303,2
DA:304,1
DA:308,1
DA:309,2
DA:310,1
DA:314,0
DA:316,0
DA:321,1
DA:322,3
DA:324,3
DA:328,3
DA:340,1
DA:341,3
DA:344,2
DA:345,0
DA:346,0
DA:347,0
DA:354,1
DA:355,3
DA:357,0
DA:361,1
DA:362,1
DA:363,1
DA:364,1
DA:365,1
DA:369,2
DA:371,2
DA:382,1
DA:386,1
DA:387,1
DA:389,1
DA:391,1
DA:392,1
DA:393,1
DA:394,1
DA:395,1
DA:397,1
DA:399,1
DA:405,1
DA:406,5
DA:410,1
DA:411,5
DA:415,1
DA:416,1
DA:417,2
DA:418,1
DA:419,2
DA:420,2
LF:137
LH:110
end_of_record

@ -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);

@ -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,
}

@ -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,
}

@ -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();
}
}
}

@ -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,
),

@ -262,6 +262,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_dotenv:
dependency: "direct main"
description:
name: flutter_dotenv
sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b
url: "https://pub.dev"
source: hosted
version: "5.2.1"
flutter_test:
dependency: "direct dev"
description: flutter

@ -16,6 +16,7 @@ dependencies:
dio: ^5.4.0
crypto: ^3.0.3
web_socket_channel: ^2.4.0
flutter_dotenv: ^5.1.0
dev_dependencies:
flutter_test:
@ -28,3 +29,8 @@ dev_dependencies:
flutter:
uses-material-design: true
# Assets for .env file (optional - only if you want to bundle defaults)
# .env should be in .gitignore and not committed
# assets:
# - .env

@ -0,0 +1,514 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:app_boilerplate/data/sync/sync_engine.dart';
import 'package:app_boilerplate/data/sync/models/sync_status.dart';
import 'package:app_boilerplate/data/sync/models/sync_operation.dart';
import 'package:app_boilerplate/data/local/local_storage_service.dart';
import 'package:app_boilerplate/data/local/models/item.dart';
import 'package:app_boilerplate/data/immich/immich_service.dart';
import 'package:app_boilerplate/data/nostr/nostr_service.dart';
import 'package:app_boilerplate/data/nostr/models/nostr_keypair.dart';
import 'package:path/path.dart' as path;
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:dio/dio.dart';
import 'dart:convert';
void main() {
// Initialize Flutter bindings and sqflite for testing
TestWidgetsFlutterBinding.ensureInitialized();
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
late LocalStorageService localStorage;
late Directory testDir;
late String testDbPath;
late Directory testCacheDir;
late ImmichService immichService;
late NostrService nostrService;
late NostrKeyPair nostrKeyPair;
late SyncEngine syncEngine;
setUp(() async {
// Create temporary directory for testing
testDir = await Directory.systemTemp.createTemp('sync_test_');
testDbPath = path.join(testDir.path, 'test_local_storage.db');
testCacheDir = Directory(path.join(testDir.path, 'image_cache'));
// Initialize local storage
localStorage = LocalStorageService(
testDbPath: testDbPath,
testCacheDir: testCacheDir,
);
await localStorage.initialize();
// Create mock services
final mockDio = _createMockDio();
immichService = ImmichService(
baseUrl: 'https://immich.example.com',
apiKey: 'test-api-key',
localStorage: localStorage,
dio: mockDio,
);
nostrService = NostrService();
nostrKeyPair = nostrService.generateKeyPair();
// Create sync engine
syncEngine = SyncEngine(
localStorage: localStorage,
immichService: immichService,
nostrService: nostrService,
nostrKeyPair: nostrKeyPair,
);
});
tearDown(() async {
syncEngine.dispose();
await localStorage.close();
try {
if (await testDir.exists()) {
await testDir.delete(recursive: true);
}
} catch (_) {
// Ignore cleanup errors
}
});
group('SyncEngine - Queue Management', () {
/// Tests queuing a sync operation.
test('queueOperation - success', () {
// Arrange
final operation = SyncOperation(
id: 'test-op-1',
type: SyncOperationType.upload,
itemId: 'item-1',
source: 'local',
target: 'immich',
);
// Act
syncEngine.queueOperation(operation);
// Assert - operation is queued (may be processing, so check all operations)
final all = syncEngine.getAllOperations();
expect(all.length, equals(1));
expect(all[0].id, equals('test-op-1'));
});
/// Tests that queue is sorted by priority.
test('queueOperation - priority sorting', () {
// Arrange
final lowOp = SyncOperation(
id: 'low',
type: SyncOperationType.upload,
itemId: 'item-1',
source: 'local',
target: 'immich',
priority: SyncPriority.low,
);
final highOp = SyncOperation(
id: 'high',
type: SyncOperationType.upload,
itemId: 'item-2',
source: 'local',
target: 'immich',
priority: SyncPriority.high,
);
// Act
syncEngine.queueOperation(lowOp);
syncEngine.queueOperation(highOp);
// Assert - both operations are queued (may be processing, so check all operations)
final all = syncEngine.getAllOperations();
expect(all.length, equals(2));
// Processing order is tested in integration tests
});
/// Tests that queue throws exception when full.
test('queueOperation - queue full', () {
// Arrange
final engine = SyncEngine(
localStorage: localStorage,
maxQueueSize: 2,
);
// Act
engine.queueOperation(SyncOperation(
id: 'op-1',
type: SyncOperationType.upload,
itemId: 'item-1',
source: 'local',
target: 'immich',
));
engine.queueOperation(SyncOperation(
id: 'op-2',
type: SyncOperationType.upload,
itemId: 'item-2',
source: 'local',
target: 'immich',
));
// Assert
expect(
() => engine.queueOperation(SyncOperation(
id: 'op-3',
type: SyncOperationType.upload,
itemId: 'item-3',
source: 'local',
target: 'immich',
)),
throwsA(isA<SyncException>()),
);
engine.dispose();
});
});
group('SyncEngine - Sync Operations', () {
/// Tests syncing to Immich.
test('syncToImmich - queues operation', () async {
// Arrange
final item = Item(id: 'item-1', data: {'name': 'Test'});
await localStorage.insertItem(item);
// Act
final operationId = await syncEngine.syncToImmich('item-1');
// Assert
expect(operationId, isNotEmpty);
final operations = syncEngine.getAllOperations();
expect(operations.length, equals(1));
expect(operations[0].target, equals('immich'));
});
/// Tests syncing to Immich fails when service not configured.
test('syncToImmich - fails when service not configured', () async {
// Arrange
final engine = SyncEngine(localStorage: localStorage);
// Act & Assert
expect(
() => engine.syncToImmich('item-1'),
throwsA(isA<SyncException>()),
);
engine.dispose();
});
/// Tests syncing to Nostr.
test('syncToNostr - queues operation', () async {
// Arrange
final item = Item(id: 'item-1', data: {'name': 'Test'});
await localStorage.insertItem(item);
// Act
final operationId = await syncEngine.syncToNostr('item-1');
// Assert
expect(operationId, isNotEmpty);
final operations = syncEngine.getAllOperations();
expect(operations.length, equals(1));
expect(operations[0].target, equals('nostr'));
});
/// Tests syncing to Nostr fails when keypair not set.
test('syncToNostr - fails when keypair not set', () async {
// Arrange
final engine = SyncEngine(
localStorage: localStorage,
nostrService: nostrService,
// No keypair
);
// Act & Assert
expect(
() => engine.syncToNostr('item-1'),
throwsA(isA<SyncException>()),
);
engine.dispose();
});
/// Tests syncing from Immich.
test('syncFromImmich - queues operation', () async {
// Act
final operationId = await syncEngine.syncFromImmich('asset-1');
// Assert
expect(operationId, isNotEmpty);
final operations = syncEngine.getAllOperations();
expect(operations.length, greaterThanOrEqualTo(1));
expect(operations[0].source, equals('immich'));
expect(operations[0].target, equals('local'));
});
});
group('SyncEngine - Conflict Resolution', () {
/// Tests conflict resolution with useLocal strategy.
test('resolveConflict - useLocal', () {
// Arrange
syncEngine.setConflictResolution(ConflictResolution.useLocal);
final local = {'id': 'item-1', 'value': 'local', 'updatedAt': 1000};
final remote = {'id': 'item-1', 'value': 'remote', 'updatedAt': 2000};
// Act
final resolved = syncEngine.resolveConflict(local, remote);
// Assert
expect(resolved['value'], equals('local'));
});
/// Tests conflict resolution with useRemote strategy.
test('resolveConflict - useRemote', () {
// Arrange
syncEngine.setConflictResolution(ConflictResolution.useRemote);
final local = {'id': 'item-1', 'value': 'local', 'updatedAt': 1000};
final remote = {'id': 'item-1', 'value': 'remote', 'updatedAt': 2000};
// Act
final resolved = syncEngine.resolveConflict(local, remote);
// Assert
expect(resolved['value'], equals('remote'));
});
/// Tests conflict resolution with useLatest strategy.
test('resolveConflict - useLatest', () {
// Arrange
syncEngine.setConflictResolution(ConflictResolution.useLatest);
final local = {'id': 'item-1', 'value': 'local', 'updatedAt': 3000};
final remote = {'id': 'item-1', 'value': 'remote', 'updatedAt': 2000};
// Act
final resolved = syncEngine.resolveConflict(local, remote);
// Assert
expect(resolved['value'], equals('local')); // Local is newer
});
/// Tests conflict resolution with merge strategy.
test('resolveConflict - merge', () {
// Arrange
syncEngine.setConflictResolution(ConflictResolution.merge);
final local = {'id': 'item-1', 'value': 'local', 'field1': 'local1'};
final remote = {'id': 'item-1', 'value': 'remote', 'field2': 'remote2'};
// Act
final resolved = syncEngine.resolveConflict(local, remote);
// Assert
expect(resolved['field1'], equals('local1'));
expect(resolved['field2'], equals('remote2'));
expect(resolved['value'], equals('remote')); // Remote overwrites in merge
});
});
group('SyncEngine - Retry and Offline Queue', () {
/// Tests that failed operations can be retried.
test('retry - operation can retry', () {
// Arrange
final operation = SyncOperation(
id: 'test-op',
type: SyncOperationType.upload,
itemId: 'item-1',
source: 'local',
target: 'immich',
maxRetries: 3,
);
// Act
operation.markFailed('Test error');
expect(operation.canRetry(), isTrue);
operation.incrementRetry();
operation.markFailed('Test error');
expect(operation.canRetry(), isTrue);
operation.incrementRetry();
operation.markFailed('Test error');
expect(operation.canRetry(), isTrue);
operation.incrementRetry();
operation.markFailed('Test error');
// Assert
expect(operation.canRetry(), isFalse); // Max retries reached
expect(operation.retryCount, equals(3));
});
/// Tests that operations are queued offline.
test('offline queue - operations persist', () {
// Arrange
final operation1 = SyncOperation(
id: 'op-1',
type: SyncOperationType.upload,
itemId: 'item-1',
source: 'local',
target: 'immich',
);
final operation2 = SyncOperation(
id: 'op-2',
type: SyncOperationType.upload,
itemId: 'item-2',
source: 'local',
target: 'nostr',
);
// Act
syncEngine.queueOperation(operation1);
syncEngine.queueOperation(operation2);
// Assert - operations are queued (may be processing, so check all operations)
final all = syncEngine.getAllOperations();
expect(all.length, equals(2));
});
});
group('SyncEngine - Error Handling', () {
/// Tests error handling when item not found.
test('syncToImmich - handles missing item', () async {
// Act
final operationId = await syncEngine.syncToImmich('non-existent');
// Assert - operation is queued but will fail when executed
expect(operationId, isNotEmpty);
final operations = syncEngine.getAllOperations();
expect(operations.length, equals(1));
});
/// Tests error handling when service not available.
test('syncToImmich - handles missing service', () async {
// Arrange
final engine = SyncEngine(localStorage: localStorage);
// Act & Assert
expect(
() => engine.syncToImmich('item-1'),
throwsA(isA<SyncException>()),
);
engine.dispose();
});
});
group('SyncEngine - Status Stream', () {
/// Tests that status updates are streamed.
test('statusStream - emits updates', () async {
// Arrange
final updates = <SyncOperation>[];
syncEngine.statusStream.listen((op) {
updates.add(op);
});
// Act
final operation = SyncOperation(
id: 'test-op',
type: SyncOperationType.upload,
itemId: 'item-1',
source: 'local',
target: 'immich',
);
syncEngine.queueOperation(operation);
// Assert
await Future.delayed(const Duration(milliseconds: 100));
expect(updates.length, greaterThan(0));
});
});
group('SyncEngine - Cleanup', () {
/// Tests clearing completed operations.
test('clearCompleted - removes completed operations', () {
// Arrange
final op1 = SyncOperation(
id: 'op-1',
type: SyncOperationType.upload,
itemId: 'item-1',
source: 'local',
target: 'immich',
);
final op2 = SyncOperation(
id: 'op-2',
type: SyncOperationType.upload,
itemId: 'item-2',
source: 'local',
target: 'immich',
);
op1.markSuccess();
op2.markFailed('Error');
syncEngine.queueOperation(op1);
syncEngine.queueOperation(op2);
// Act
syncEngine.clearCompleted();
// Assert
final operations = syncEngine.getAllOperations();
expect(operations.length, equals(1)); // Only failed remains
expect(operations[0].status, equals(SyncStatus.failed));
});
/// Tests clearing failed operations.
test('clearFailed - removes failed operations', () {
// Arrange
final op1 = SyncOperation(
id: 'op-1',
type: SyncOperationType.upload,
itemId: 'item-1',
source: 'local',
target: 'immich',
);
final op2 = SyncOperation(
id: 'op-2',
type: SyncOperationType.upload,
itemId: 'item-2',
source: 'local',
target: 'immich',
);
op1.markSuccess();
op2.markFailed('Error');
syncEngine.queueOperation(op1);
syncEngine.queueOperation(op2);
// Act
syncEngine.clearFailed();
// Assert
final operations = syncEngine.getAllOperations();
expect(operations.length, equals(1)); // Only success remains
expect(operations[0].status, equals(SyncStatus.success));
});
});
}
/// Helper to create a mock Dio instance for testing.
Dio _createMockDio({
Response Function(String path, dynamic data)? onPost,
Response Function(String path)? onGet,
}) {
final dio = Dio();
dio.options.baseUrl = 'https://immich.example.com';
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
if (options.method == 'POST' && onPost != null) {
final response = onPost(options.path, options.data);
handler.resolve(response);
} else if (options.method == 'GET' && onGet != null) {
final queryString = options.queryParameters.entries
.map((e) => '${e.key}=${e.value}')
.join('&');
final fullPath = queryString.isNotEmpty
? '${options.path}?$queryString'
: options.path;
final response = onGet(fullPath);
handler.resolve(response);
} else {
handler.next(options);
}
},
));
return dio;
}
Loading…
Cancel
Save

Powered by TurnKey Linux.