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
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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…
Reference in new issue