feat: koreader sync #4
25 changed files with 1540 additions and 55 deletions
|
|
@ -7,7 +7,7 @@ class AppConstants {
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
static const String databaseName = 'worldhopper.db';
|
static const String databaseName = 'worldhopper.db';
|
||||||
static const int databaseVersion = 8;
|
static const int databaseVersion = 10;
|
||||||
|
|
||||||
// Cache settings
|
// Cache settings
|
||||||
static const int imageCacheMaxAgeDays = 7;
|
static const int imageCacheMaxAgeDays = 7;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,18 @@ final Map<int, Future<void> Function(Database)> _migrations = {
|
||||||
await db.execute(SeriesToPageModeTable.createTable);
|
await db.execute(SeriesToPageModeTable.createTable);
|
||||||
await db.execute(SeriesCoverPageTable.createTable);
|
await db.execute(SeriesCoverPageTable.createTable);
|
||||||
},
|
},
|
||||||
|
9: (db) async {
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE servers ADD COLUMN koreader_sync_enabled INTEGER NOT NULL DEFAULT 0');
|
||||||
|
await db.execute('ALTER TABLE servers ADD COLUMN koreader_sync_url TEXT');
|
||||||
|
await db
|
||||||
|
.execute('ALTER TABLE servers ADD COLUMN koreader_sync_username TEXT');
|
||||||
|
await db
|
||||||
|
.execute('ALTER TABLE servers ADD COLUMN koreader_sync_password TEXT');
|
||||||
|
},
|
||||||
|
10: (db) async {
|
||||||
|
await db.execute('ALTER TABLE reading_progress ADD COLUMN sync_hash TEXT');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Database helper for SQLite operations
|
/// Database helper for SQLite operations
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ class ReadingProgressTable {
|
||||||
last_read_at INTEGER NOT NULL,
|
last_read_at INTEGER NOT NULL,
|
||||||
publication_cache_id TEXT,
|
publication_cache_id TEXT,
|
||||||
series_feed_url TEXT,
|
series_feed_url TEXT,
|
||||||
|
sync_hash TEXT,
|
||||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (publication_cache_id) REFERENCES publications(id) ON DELETE SET NULL,
|
FOREIGN KEY (publication_cache_id) REFERENCES publications(id) ON DELETE SET NULL,
|
||||||
UNIQUE(publication_id, server_id)
|
UNIQUE(publication_id, server_id)
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,11 @@ class ServersTable {
|
||||||
username TEXT,
|
username TEXT,
|
||||||
password TEXT,
|
password TEXT,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
last_synced_at INTEGER
|
last_synced_at INTEGER,
|
||||||
|
koreader_sync_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
koreader_sync_url TEXT,
|
||||||
|
koreader_sync_username TEXT,
|
||||||
|
koreader_sync_password TEXT
|
||||||
)
|
)
|
||||||
''';
|
''';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,33 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"koreaderSyncToggle": "KOReader API Sync",
|
||||||
|
"koreaderSyncToggleSubtitle": "Sync EPUB reading progress with a KOReader-compatible server",
|
||||||
|
"koreaderSyncUrl": "Sync Server URL",
|
||||||
|
"koreaderSyncUrlHint": "https://sync.example.com",
|
||||||
|
"koreaderSyncUsername": "Sync Username",
|
||||||
|
"koreaderSyncPassword": "Sync Password",
|
||||||
|
"koreaderSyncTestConnection": "Test Connection",
|
||||||
|
"koreaderSyncTestSuccess": "Connection successful! Authenticated as {username}.",
|
||||||
|
"@koreaderSyncTestSuccess": {
|
||||||
|
"placeholders": {
|
||||||
|
"username": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"koreaderSyncTestFailed": "Connection failed: {error}",
|
||||||
|
"@koreaderSyncTestFailed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"koreaderSyncValidationUrlRequired": "Sync server URL is required when KOReader sync is enabled",
|
||||||
|
"koreaderSyncValidationUrlInvalid": "Please enter a valid URL",
|
||||||
|
"koreaderSyncValidationUsernameRequired": "Sync username is required",
|
||||||
|
"koreaderSyncValidationPasswordRequired": "Sync password is required",
|
||||||
|
"koreaderSyncAuthFailed": "KOReader sync: authentication failed. Check your server credentials.",
|
||||||
|
"koreaderSyncHelp": "Connect to a KOReader-compatible sync server to synchronize EPUB reading positions across devices.",
|
||||||
|
"koreaderSyncResumeInfo": "KOReader sync is enabled. Your reading position will be automatically restored from the sync server when you open this book.",
|
||||||
|
|
||||||
"editServerTitle": "Edit Server",
|
"editServerTitle": "Edit Server",
|
||||||
"editServerNotFound": "Server not found",
|
"editServerNotFound": "Server not found",
|
||||||
"editServerInfo": "Server Information",
|
"editServerInfo": "Server Information",
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,23 @@
|
||||||
"addServerSuccess": "{name} añadido correctamente",
|
"addServerSuccess": "{name} añadido correctamente",
|
||||||
"addServerError": "Error al añadir servidor: {error}",
|
"addServerError": "Error al añadir servidor: {error}",
|
||||||
|
|
||||||
|
"koreaderSyncToggle": "Sincronización KOReader API",
|
||||||
|
"koreaderSyncToggleSubtitle": "Sincronizar el progreso de lectura EPUB con un servidor compatible con KOReader",
|
||||||
|
"koreaderSyncUrl": "URL del servidor de sincronización",
|
||||||
|
"koreaderSyncUrlHint": "https://sync.ejemplo.com",
|
||||||
|
"koreaderSyncUsername": "Usuario de sincronización",
|
||||||
|
"koreaderSyncPassword": "Contraseña de sincronización",
|
||||||
|
"koreaderSyncTestConnection": "Probar conexión",
|
||||||
|
"koreaderSyncTestSuccess": "¡Conexión exitosa! Autenticado como {username}.",
|
||||||
|
"koreaderSyncTestFailed": "Error de conexión: {error}",
|
||||||
|
"koreaderSyncValidationUrlRequired": "La URL del servidor de sincronización es obligatoria cuando la sincronización KOReader está activada",
|
||||||
|
"koreaderSyncValidationUrlInvalid": "Introduce una URL válida",
|
||||||
|
"koreaderSyncValidationUsernameRequired": "El usuario de sincronización es obligatorio",
|
||||||
|
"koreaderSyncValidationPasswordRequired": "La contraseña de sincronización es obligatoria",
|
||||||
|
"koreaderSyncAuthFailed": "Sincronización KOReader: error de autenticación. Revisa las credenciales del servidor.",
|
||||||
|
"koreaderSyncHelp": "Conéctate a un servidor de sincronización compatible con KOReader para sincronizar las posiciones de lectura EPUB entre dispositivos.",
|
||||||
|
"koreaderSyncResumeInfo": "La sincronización KOReader está activada. Tu posición de lectura se restaurará automáticamente desde el servidor de sincronización al abrir este libro.",
|
||||||
|
|
||||||
"editServerTitle": "Editar servidor",
|
"editServerTitle": "Editar servidor",
|
||||||
"editServerNotFound": "Servidor no encontrado",
|
"editServerNotFound": "Servidor no encontrado",
|
||||||
"editServerInfo": "Información del servidor",
|
"editServerInfo": "Información del servidor",
|
||||||
|
|
|
||||||
|
|
@ -890,6 +890,102 @@ abstract class AppLocalizations {
|
||||||
/// **'Error adding server: {error}'**
|
/// **'Error adding server: {error}'**
|
||||||
String addServerError(String error);
|
String addServerError(String error);
|
||||||
|
|
||||||
|
/// No description provided for @koreaderSyncToggle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'KOReader API Sync'**
|
||||||
|
String get koreaderSyncToggle;
|
||||||
|
|
||||||
|
/// No description provided for @koreaderSyncToggleSubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync EPUB reading progress with a KOReader-compatible server'**
|
||||||
|
String get koreaderSyncToggleSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @koreaderSyncUrl.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync Server URL'**
|
||||||
|
String get koreaderSyncUrl;
|
||||||
|
|
||||||
|
/// No description provided for @koreaderSyncUrlHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'https://sync.example.com'**
|
||||||
|
String get koreaderSyncUrlHint;
|
||||||
|
|
||||||
|
/// No description provided for @koreaderSyncUsername.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync Username'**
|
||||||
|
String get koreaderSyncUsername;
|
||||||
|
|
||||||
|
/// No description provided for @koreaderSyncPassword.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync Password'**
|
||||||
|
String get koreaderSyncPassword;
|
||||||
|
|
||||||
|
/// No description provided for @koreaderSyncTestConnection.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Test Connection'**
|
||||||
|
String get koreaderSyncTestConnection;
|
||||||
|
|
||||||
|
/// No description provided for @koreaderSyncTestSuccess.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connection successful! Authenticated as {username}.'**
|
||||||
|
String koreaderSyncTestSuccess(String username);
|
||||||
|
|
||||||
|
/// No description provided for @koreaderSyncTestFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connection failed: {error}'**
|
||||||
|
String koreaderSyncTestFailed(String error);
|
||||||
|
|
||||||
|
/// No description provided for @koreaderSyncValidationUrlRequired.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync server URL is required when KOReader sync is enabled'**
|
||||||
|
String get koreaderSyncValidationUrlRequired;
|
||||||
|
|
||||||
|
/// No description provided for @koreaderSyncValidationUrlInvalid.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Please enter a valid URL'**
|
||||||
|
String get koreaderSyncValidationUrlInvalid;
|
||||||
|
|
||||||
|
/// No description provided for @koreaderSyncValidationUsernameRequired.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync username is required'**
|
||||||
|
String get koreaderSyncValidationUsernameRequired;
|
||||||
|
|
||||||
|
/// No description provided for @koreaderSyncValidationPasswordRequired.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync password is required'**
|
||||||
|
String get koreaderSyncValidationPasswordRequired;
|
||||||
|
|
||||||
|
/// No description provided for @koreaderSyncAuthFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'KOReader sync: authentication failed. Check your server credentials.'**
|
||||||
|
String get koreaderSyncAuthFailed;
|
||||||
|
|
||||||
|
/// No description provided for @koreaderSyncHelp.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connect to a KOReader-compatible sync server to synchronize EPUB reading positions across devices.'**
|
||||||
|
String get koreaderSyncHelp;
|
||||||
|
|
||||||
|
/// No description provided for @koreaderSyncResumeInfo.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'KOReader sync is enabled. Your reading position will be automatically restored from the sync server when you open this book.'**
|
||||||
|
String get koreaderSyncResumeInfo;
|
||||||
|
|
||||||
/// No description provided for @editServerTitle.
|
/// No description provided for @editServerTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -428,6 +428,65 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
return 'Error adding server: $error';
|
return 'Error adding server: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncToggle => 'KOReader API Sync';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncToggleSubtitle =>
|
||||||
|
'Sync EPUB reading progress with a KOReader-compatible server';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncUrl => 'Sync Server URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncUrlHint => 'https://sync.example.com';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncUsername => 'Sync Username';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncPassword => 'Sync Password';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncTestConnection => 'Test Connection';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String koreaderSyncTestSuccess(String username) {
|
||||||
|
return 'Connection successful! Authenticated as $username.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String koreaderSyncTestFailed(String error) {
|
||||||
|
return 'Connection failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncValidationUrlRequired =>
|
||||||
|
'Sync server URL is required when KOReader sync is enabled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncValidationUrlInvalid => 'Please enter a valid URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncValidationUsernameRequired =>
|
||||||
|
'Sync username is required';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncValidationPasswordRequired =>
|
||||||
|
'Sync password is required';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncAuthFailed =>
|
||||||
|
'KOReader sync: authentication failed. Check your server credentials.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncHelp =>
|
||||||
|
'Connect to a KOReader-compatible sync server to synchronize EPUB reading positions across devices.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncResumeInfo =>
|
||||||
|
'KOReader sync is enabled. Your reading position will be automatically restored from the sync server when you open this book.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get editServerTitle => 'Edit Server';
|
String get editServerTitle => 'Edit Server';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -436,6 +436,65 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||||
return 'Error al añadir servidor: $error';
|
return 'Error al añadir servidor: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncToggle => 'Sincronización KOReader API';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncToggleSubtitle =>
|
||||||
|
'Sincronizar el progreso de lectura EPUB con un servidor compatible con KOReader';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncUrl => 'URL del servidor de sincronización';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncUrlHint => 'https://sync.ejemplo.com';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncUsername => 'Usuario de sincronización';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncPassword => 'Contraseña de sincronización';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncTestConnection => 'Probar conexión';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String koreaderSyncTestSuccess(String username) {
|
||||||
|
return '¡Conexión exitosa! Autenticado como $username.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String koreaderSyncTestFailed(String error) {
|
||||||
|
return 'Error de conexión: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncValidationUrlRequired =>
|
||||||
|
'La URL del servidor de sincronización es obligatoria cuando la sincronización KOReader está activada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncValidationUrlInvalid => 'Introduce una URL válida';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncValidationUsernameRequired =>
|
||||||
|
'El usuario de sincronización es obligatorio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncValidationPasswordRequired =>
|
||||||
|
'La contraseña de sincronización es obligatoria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncAuthFailed =>
|
||||||
|
'Sincronización KOReader: error de autenticación. Revisa las credenciales del servidor.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncHelp =>
|
||||||
|
'Conéctate a un servidor de sincronización compatible con KOReader para sincronizar las posiciones de lectura EPUB entre dispositivos.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get koreaderSyncResumeInfo =>
|
||||||
|
'La sincronización KOReader está activada. Tu posición de lectura se restaurará automáticamente desde el servidor de sincronización al abrir este libro.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get editServerTitle => 'Editar servidor';
|
String get editServerTitle => 'Editar servidor';
|
||||||
|
|
||||||
|
|
|
||||||
60
lib/models/koreader_progress.dart
Normal file
60
lib/models/koreader_progress.dart
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
/// Represents reading progress data from a KOReader sync server
|
||||||
|
class KoreaderProgress {
|
||||||
|
/// MD5 hash identifying the document
|
||||||
|
final String document;
|
||||||
|
|
||||||
|
/// Reading progress percentage (0.0 to 1.0)
|
||||||
|
final double percentage;
|
||||||
|
|
||||||
|
/// Position string (e.g., CFI for EPUB, page number for PDF)
|
||||||
|
final String progress;
|
||||||
|
|
||||||
|
/// Device name that last updated the progress
|
||||||
|
final String device;
|
||||||
|
|
||||||
|
/// Device identifier
|
||||||
|
final String? deviceId;
|
||||||
|
|
||||||
|
/// Unix timestamp (seconds) of last update
|
||||||
|
final int? timestamp;
|
||||||
|
|
||||||
|
const KoreaderProgress({
|
||||||
|
required this.document,
|
||||||
|
required this.percentage,
|
||||||
|
required this.progress,
|
||||||
|
required this.device,
|
||||||
|
this.deviceId,
|
||||||
|
this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Create from JSON response
|
||||||
|
factory KoreaderProgress.fromJson(Map<String, dynamic> json) {
|
||||||
|
return KoreaderProgress(
|
||||||
|
document: json['document'] as String? ?? '',
|
||||||
|
percentage: (json['percentage'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
progress: json['progress'] as String? ?? '',
|
||||||
|
device: json['device'] as String? ?? '',
|
||||||
|
deviceId: json['device_id'] as String?,
|
||||||
|
timestamp: json['timestamp'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to JSON for API request
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'document': document,
|
||||||
|
'percentage': percentage,
|
||||||
|
'progress': progress,
|
||||||
|
'device': device,
|
||||||
|
if (deviceId != null) 'device_id': deviceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this progress has meaningful data
|
||||||
|
bool get isEmpty => document.isEmpty;
|
||||||
|
|
||||||
|
/// Convert timestamp to DateTime
|
||||||
|
DateTime? get lastUpdated => timestamp != null
|
||||||
|
? DateTime.fromMillisecondsSinceEpoch(timestamp! * 1000)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,18 @@ class OPDSServer with _$OPDSServer {
|
||||||
|
|
||||||
/// Timestamp of the last successful sync
|
/// Timestamp of the last successful sync
|
||||||
DateTime? lastSyncedAt,
|
DateTime? lastSyncedAt,
|
||||||
|
|
||||||
|
/// Whether KOReader sync is enabled for this server
|
||||||
|
@Default(false) bool koreaderSyncEnabled,
|
||||||
|
|
||||||
|
/// KOReader sync server URL (e.g., https://sync.example.com)
|
||||||
|
String? koreaderSyncUrl,
|
||||||
|
|
||||||
|
/// KOReader sync username
|
||||||
|
String? koreaderSyncUsername,
|
||||||
|
|
||||||
|
/// KOReader sync password
|
||||||
|
String? koreaderSyncPassword,
|
||||||
}) = _OPDSServer;
|
}) = _OPDSServer;
|
||||||
|
|
||||||
/// Create OPDSServer from JSON
|
/// Create OPDSServer from JSON
|
||||||
|
|
@ -46,6 +58,10 @@ class OPDSServer with _$OPDSServer {
|
||||||
lastSyncedAt: row['last_synced_at'] != null
|
lastSyncedAt: row['last_synced_at'] != null
|
||||||
? DateTime.fromMillisecondsSinceEpoch(row['last_synced_at'] as int)
|
? DateTime.fromMillisecondsSinceEpoch(row['last_synced_at'] as int)
|
||||||
: null,
|
: null,
|
||||||
|
koreaderSyncEnabled: (row['koreader_sync_enabled'] as int?) == 1,
|
||||||
|
koreaderSyncUrl: row['koreader_sync_url'] as String?,
|
||||||
|
koreaderSyncUsername: row['koreader_sync_username'] as String?,
|
||||||
|
koreaderSyncPassword: row['koreader_sync_password'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -61,6 +77,10 @@ extension OPDSServerX on OPDSServer {
|
||||||
'password': password,
|
'password': password,
|
||||||
'created_at': createdAt.millisecondsSinceEpoch,
|
'created_at': createdAt.millisecondsSinceEpoch,
|
||||||
'last_synced_at': lastSyncedAt?.millisecondsSinceEpoch,
|
'last_synced_at': lastSyncedAt?.millisecondsSinceEpoch,
|
||||||
|
'koreader_sync_enabled': koreaderSyncEnabled ? 1 : 0,
|
||||||
|
'koreader_sync_url': koreaderSyncUrl,
|
||||||
|
'koreader_sync_username': koreaderSyncUsername,
|
||||||
|
'koreader_sync_password': koreaderSyncPassword,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,18 @@ mixin _$OPDSServer {
|
||||||
/// Timestamp of the last successful sync
|
/// Timestamp of the last successful sync
|
||||||
DateTime? get lastSyncedAt => throw _privateConstructorUsedError;
|
DateTime? get lastSyncedAt => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Whether KOReader sync is enabled for this server
|
||||||
|
bool get koreaderSyncEnabled => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// KOReader sync server URL (e.g., https://sync.example.com)
|
||||||
|
String? get koreaderSyncUrl => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// KOReader sync username
|
||||||
|
String? get koreaderSyncUsername => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// KOReader sync password
|
||||||
|
String? get koreaderSyncPassword => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
/// Serializes this OPDSServer to a JSON map.
|
/// Serializes this OPDSServer to a JSON map.
|
||||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
|
@ -64,7 +76,11 @@ abstract class $OPDSServerCopyWith<$Res> {
|
||||||
String? username,
|
String? username,
|
||||||
String? password,
|
String? password,
|
||||||
DateTime createdAt,
|
DateTime createdAt,
|
||||||
DateTime? lastSyncedAt});
|
DateTime? lastSyncedAt,
|
||||||
|
bool koreaderSyncEnabled,
|
||||||
|
String? koreaderSyncUrl,
|
||||||
|
String? koreaderSyncUsername,
|
||||||
|
String? koreaderSyncPassword});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @nodoc
|
/// @nodoc
|
||||||
|
|
@ -89,6 +105,10 @@ class _$OPDSServerCopyWithImpl<$Res, $Val extends OPDSServer>
|
||||||
Object? password = freezed,
|
Object? password = freezed,
|
||||||
Object? createdAt = null,
|
Object? createdAt = null,
|
||||||
Object? lastSyncedAt = freezed,
|
Object? lastSyncedAt = freezed,
|
||||||
|
Object? koreaderSyncEnabled = null,
|
||||||
|
Object? koreaderSyncUrl = freezed,
|
||||||
|
Object? koreaderSyncUsername = freezed,
|
||||||
|
Object? koreaderSyncPassword = freezed,
|
||||||
}) {
|
}) {
|
||||||
return _then(_value.copyWith(
|
return _then(_value.copyWith(
|
||||||
id: null == id
|
id: null == id
|
||||||
|
|
@ -119,6 +139,22 @@ class _$OPDSServerCopyWithImpl<$Res, $Val extends OPDSServer>
|
||||||
? _value.lastSyncedAt
|
? _value.lastSyncedAt
|
||||||
: lastSyncedAt // ignore: cast_nullable_to_non_nullable
|
: lastSyncedAt // ignore: cast_nullable_to_non_nullable
|
||||||
as DateTime?,
|
as DateTime?,
|
||||||
|
koreaderSyncEnabled: null == koreaderSyncEnabled
|
||||||
|
? _value.koreaderSyncEnabled
|
||||||
|
: koreaderSyncEnabled // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
koreaderSyncUrl: freezed == koreaderSyncUrl
|
||||||
|
? _value.koreaderSyncUrl
|
||||||
|
: koreaderSyncUrl // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
koreaderSyncUsername: freezed == koreaderSyncUsername
|
||||||
|
? _value.koreaderSyncUsername
|
||||||
|
: koreaderSyncUsername // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
koreaderSyncPassword: freezed == koreaderSyncPassword
|
||||||
|
? _value.koreaderSyncPassword
|
||||||
|
: koreaderSyncPassword // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
) as $Val);
|
) as $Val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +174,11 @@ abstract class _$$OPDSServerImplCopyWith<$Res>
|
||||||
String? username,
|
String? username,
|
||||||
String? password,
|
String? password,
|
||||||
DateTime createdAt,
|
DateTime createdAt,
|
||||||
DateTime? lastSyncedAt});
|
DateTime? lastSyncedAt,
|
||||||
|
bool koreaderSyncEnabled,
|
||||||
|
String? koreaderSyncUrl,
|
||||||
|
String? koreaderSyncUsername,
|
||||||
|
String? koreaderSyncPassword});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @nodoc
|
/// @nodoc
|
||||||
|
|
@ -161,6 +201,10 @@ class __$$OPDSServerImplCopyWithImpl<$Res>
|
||||||
Object? password = freezed,
|
Object? password = freezed,
|
||||||
Object? createdAt = null,
|
Object? createdAt = null,
|
||||||
Object? lastSyncedAt = freezed,
|
Object? lastSyncedAt = freezed,
|
||||||
|
Object? koreaderSyncEnabled = null,
|
||||||
|
Object? koreaderSyncUrl = freezed,
|
||||||
|
Object? koreaderSyncUsername = freezed,
|
||||||
|
Object? koreaderSyncPassword = freezed,
|
||||||
}) {
|
}) {
|
||||||
return _then(_$OPDSServerImpl(
|
return _then(_$OPDSServerImpl(
|
||||||
id: null == id
|
id: null == id
|
||||||
|
|
@ -191,6 +235,22 @@ class __$$OPDSServerImplCopyWithImpl<$Res>
|
||||||
? _value.lastSyncedAt
|
? _value.lastSyncedAt
|
||||||
: lastSyncedAt // ignore: cast_nullable_to_non_nullable
|
: lastSyncedAt // ignore: cast_nullable_to_non_nullable
|
||||||
as DateTime?,
|
as DateTime?,
|
||||||
|
koreaderSyncEnabled: null == koreaderSyncEnabled
|
||||||
|
? _value.koreaderSyncEnabled
|
||||||
|
: koreaderSyncEnabled // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
koreaderSyncUrl: freezed == koreaderSyncUrl
|
||||||
|
? _value.koreaderSyncUrl
|
||||||
|
: koreaderSyncUrl // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
koreaderSyncUsername: freezed == koreaderSyncUsername
|
||||||
|
? _value.koreaderSyncUsername
|
||||||
|
: koreaderSyncUsername // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
koreaderSyncPassword: freezed == koreaderSyncPassword
|
||||||
|
? _value.koreaderSyncPassword
|
||||||
|
: koreaderSyncPassword // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -205,7 +265,11 @@ class _$OPDSServerImpl implements _OPDSServer {
|
||||||
this.username,
|
this.username,
|
||||||
this.password,
|
this.password,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
this.lastSyncedAt});
|
this.lastSyncedAt,
|
||||||
|
this.koreaderSyncEnabled = false,
|
||||||
|
this.koreaderSyncUrl,
|
||||||
|
this.koreaderSyncUsername,
|
||||||
|
this.koreaderSyncPassword});
|
||||||
|
|
||||||
factory _$OPDSServerImpl.fromJson(Map<String, dynamic> json) =>
|
factory _$OPDSServerImpl.fromJson(Map<String, dynamic> json) =>
|
||||||
_$$OPDSServerImplFromJson(json);
|
_$$OPDSServerImplFromJson(json);
|
||||||
|
|
@ -238,9 +302,26 @@ class _$OPDSServerImpl implements _OPDSServer {
|
||||||
@override
|
@override
|
||||||
final DateTime? lastSyncedAt;
|
final DateTime? lastSyncedAt;
|
||||||
|
|
||||||
|
/// Whether KOReader sync is enabled for this server
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final bool koreaderSyncEnabled;
|
||||||
|
|
||||||
|
/// KOReader sync server URL (e.g., https://sync.example.com)
|
||||||
|
@override
|
||||||
|
final String? koreaderSyncUrl;
|
||||||
|
|
||||||
|
/// KOReader sync username
|
||||||
|
@override
|
||||||
|
final String? koreaderSyncUsername;
|
||||||
|
|
||||||
|
/// KOReader sync password
|
||||||
|
@override
|
||||||
|
final String? koreaderSyncPassword;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'OPDSServer(id: $id, name: $name, url: $url, username: $username, password: $password, createdAt: $createdAt, lastSyncedAt: $lastSyncedAt)';
|
return 'OPDSServer(id: $id, name: $name, url: $url, username: $username, password: $password, createdAt: $createdAt, lastSyncedAt: $lastSyncedAt, koreaderSyncEnabled: $koreaderSyncEnabled, koreaderSyncUrl: $koreaderSyncUrl, koreaderSyncUsername: $koreaderSyncUsername, koreaderSyncPassword: $koreaderSyncPassword)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -258,13 +339,32 @@ class _$OPDSServerImpl implements _OPDSServer {
|
||||||
(identical(other.createdAt, createdAt) ||
|
(identical(other.createdAt, createdAt) ||
|
||||||
other.createdAt == createdAt) &&
|
other.createdAt == createdAt) &&
|
||||||
(identical(other.lastSyncedAt, lastSyncedAt) ||
|
(identical(other.lastSyncedAt, lastSyncedAt) ||
|
||||||
other.lastSyncedAt == lastSyncedAt));
|
other.lastSyncedAt == lastSyncedAt) &&
|
||||||
|
(identical(other.koreaderSyncEnabled, koreaderSyncEnabled) ||
|
||||||
|
other.koreaderSyncEnabled == koreaderSyncEnabled) &&
|
||||||
|
(identical(other.koreaderSyncUrl, koreaderSyncUrl) ||
|
||||||
|
other.koreaderSyncUrl == koreaderSyncUrl) &&
|
||||||
|
(identical(other.koreaderSyncUsername, koreaderSyncUsername) ||
|
||||||
|
other.koreaderSyncUsername == koreaderSyncUsername) &&
|
||||||
|
(identical(other.koreaderSyncPassword, koreaderSyncPassword) ||
|
||||||
|
other.koreaderSyncPassword == koreaderSyncPassword));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(
|
int get hashCode => Object.hash(
|
||||||
runtimeType, id, name, url, username, password, createdAt, lastSyncedAt);
|
runtimeType,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
createdAt,
|
||||||
|
lastSyncedAt,
|
||||||
|
koreaderSyncEnabled,
|
||||||
|
koreaderSyncUrl,
|
||||||
|
koreaderSyncUsername,
|
||||||
|
koreaderSyncPassword);
|
||||||
|
|
||||||
/// Create a copy of OPDSServer
|
/// Create a copy of OPDSServer
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
|
@ -290,7 +390,11 @@ abstract class _OPDSServer implements OPDSServer {
|
||||||
final String? username,
|
final String? username,
|
||||||
final String? password,
|
final String? password,
|
||||||
required final DateTime createdAt,
|
required final DateTime createdAt,
|
||||||
final DateTime? lastSyncedAt}) = _$OPDSServerImpl;
|
final DateTime? lastSyncedAt,
|
||||||
|
final bool koreaderSyncEnabled,
|
||||||
|
final String? koreaderSyncUrl,
|
||||||
|
final String? koreaderSyncUsername,
|
||||||
|
final String? koreaderSyncPassword}) = _$OPDSServerImpl;
|
||||||
|
|
||||||
factory _OPDSServer.fromJson(Map<String, dynamic> json) =
|
factory _OPDSServer.fromJson(Map<String, dynamic> json) =
|
||||||
_$OPDSServerImpl.fromJson;
|
_$OPDSServerImpl.fromJson;
|
||||||
|
|
@ -323,6 +427,22 @@ abstract class _OPDSServer implements OPDSServer {
|
||||||
@override
|
@override
|
||||||
DateTime? get lastSyncedAt;
|
DateTime? get lastSyncedAt;
|
||||||
|
|
||||||
|
/// Whether KOReader sync is enabled for this server
|
||||||
|
@override
|
||||||
|
bool get koreaderSyncEnabled;
|
||||||
|
|
||||||
|
/// KOReader sync server URL (e.g., https://sync.example.com)
|
||||||
|
@override
|
||||||
|
String? get koreaderSyncUrl;
|
||||||
|
|
||||||
|
/// KOReader sync username
|
||||||
|
@override
|
||||||
|
String? get koreaderSyncUsername;
|
||||||
|
|
||||||
|
/// KOReader sync password
|
||||||
|
@override
|
||||||
|
String? get koreaderSyncPassword;
|
||||||
|
|
||||||
/// Create a copy of OPDSServer
|
/// Create a copy of OPDSServer
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ _$OPDSServerImpl _$$OPDSServerImplFromJson(Map<String, dynamic> json) =>
|
||||||
lastSyncedAt: json['lastSyncedAt'] == null
|
lastSyncedAt: json['lastSyncedAt'] == null
|
||||||
? null
|
? null
|
||||||
: DateTime.parse(json['lastSyncedAt'] as String),
|
: DateTime.parse(json['lastSyncedAt'] as String),
|
||||||
|
koreaderSyncEnabled: json['koreaderSyncEnabled'] as bool? ?? false,
|
||||||
|
koreaderSyncUrl: json['koreaderSyncUrl'] as String?,
|
||||||
|
koreaderSyncUsername: json['koreaderSyncUsername'] as String?,
|
||||||
|
koreaderSyncPassword: json['koreaderSyncPassword'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$$OPDSServerImplToJson(_$OPDSServerImpl instance) =>
|
Map<String, dynamic> _$$OPDSServerImplToJson(_$OPDSServerImpl instance) =>
|
||||||
|
|
@ -28,4 +32,8 @@ Map<String, dynamic> _$$OPDSServerImplToJson(_$OPDSServerImpl instance) =>
|
||||||
'password': instance.password,
|
'password': instance.password,
|
||||||
'createdAt': instance.createdAt.toIso8601String(),
|
'createdAt': instance.createdAt.toIso8601String(),
|
||||||
'lastSyncedAt': instance.lastSyncedAt?.toIso8601String(),
|
'lastSyncedAt': instance.lastSyncedAt?.toIso8601String(),
|
||||||
|
'koreaderSyncEnabled': instance.koreaderSyncEnabled,
|
||||||
|
'koreaderSyncUrl': instance.koreaderSyncUrl,
|
||||||
|
'koreaderSyncUsername': instance.koreaderSyncUsername,
|
||||||
|
'koreaderSyncPassword': instance.koreaderSyncPassword,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ class ReadingProgress with _$ReadingProgress {
|
||||||
|
|
||||||
/// Feed URL this entry was opened from (for "next in series" navigation)
|
/// Feed URL this entry was opened from (for "next in series" navigation)
|
||||||
String? seriesFeedUrl,
|
String? seriesFeedUrl,
|
||||||
|
|
||||||
|
/// KOReader sync document hash (partial MD5 of the EPUB file)
|
||||||
|
String? syncHash,
|
||||||
}) = _ReadingProgress;
|
}) = _ReadingProgress;
|
||||||
|
|
||||||
const ReadingProgress._();
|
const ReadingProgress._();
|
||||||
|
|
@ -54,12 +57,18 @@ class ReadingProgress with _$ReadingProgress {
|
||||||
DateTime.fromMillisecondsSinceEpoch(row['last_read_at'] as int),
|
DateTime.fromMillisecondsSinceEpoch(row['last_read_at'] as int),
|
||||||
publicationCacheId: row['publication_cache_id'] as String?,
|
publicationCacheId: row['publication_cache_id'] as String?,
|
||||||
seriesFeedUrl: row['series_feed_url'] as String?,
|
seriesFeedUrl: row['series_feed_url'] as String?,
|
||||||
|
syncHash: row['sync_hash'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate reading progress percentage
|
/// Calculate reading progress percentage
|
||||||
double get progressPercentage {
|
double get progressPercentage {
|
||||||
if (totalPages == 0) return 0.0;
|
if (totalPages == 0) return 0.0;
|
||||||
|
// EPUB progress stores currentPage as a 0-100 percentage value directly,
|
||||||
|
// while OPDS-PS stores 0-indexed page numbers.
|
||||||
|
if (epubLocation != null) {
|
||||||
|
return currentPage / totalPages;
|
||||||
|
}
|
||||||
return (currentPage + 1) / totalPages;
|
return (currentPage + 1) / totalPages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,7 +76,12 @@ class ReadingProgress with _$ReadingProgress {
|
||||||
bool get isStarted => currentPage > 0;
|
bool get isStarted => currentPage > 0;
|
||||||
|
|
||||||
/// Check if the publication is completed
|
/// Check if the publication is completed
|
||||||
bool get isCompleted => currentPage >= totalPages - 1;
|
bool get isCompleted {
|
||||||
|
if (epubLocation != null) {
|
||||||
|
return currentPage >= totalPages;
|
||||||
|
}
|
||||||
|
return currentPage >= totalPages - 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ReadingProgressX on ReadingProgress {
|
extension ReadingProgressX on ReadingProgress {
|
||||||
|
|
@ -83,6 +97,7 @@ extension ReadingProgressX on ReadingProgress {
|
||||||
'last_read_at': lastReadAt.millisecondsSinceEpoch,
|
'last_read_at': lastReadAt.millisecondsSinceEpoch,
|
||||||
'publication_cache_id': publicationCacheId,
|
'publication_cache_id': publicationCacheId,
|
||||||
'series_feed_url': seriesFeedUrl,
|
'series_feed_url': seriesFeedUrl,
|
||||||
|
'sync_hash': syncHash,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,9 @@ mixin _$ReadingProgress {
|
||||||
/// Feed URL this entry was opened from (for "next in series" navigation)
|
/// Feed URL this entry was opened from (for "next in series" navigation)
|
||||||
String? get seriesFeedUrl => throw _privateConstructorUsedError;
|
String? get seriesFeedUrl => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// KOReader sync document hash (partial MD5 of the EPUB file)
|
||||||
|
String? get syncHash => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
/// Serializes this ReadingProgress to a JSON map.
|
/// Serializes this ReadingProgress to a JSON map.
|
||||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
|
@ -72,7 +75,8 @@ abstract class $ReadingProgressCopyWith<$Res> {
|
||||||
String? epubLocation,
|
String? epubLocation,
|
||||||
DateTime lastReadAt,
|
DateTime lastReadAt,
|
||||||
String? publicationCacheId,
|
String? publicationCacheId,
|
||||||
String? seriesFeedUrl});
|
String? seriesFeedUrl,
|
||||||
|
String? syncHash});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @nodoc
|
/// @nodoc
|
||||||
|
|
@ -99,6 +103,7 @@ class _$ReadingProgressCopyWithImpl<$Res, $Val extends ReadingProgress>
|
||||||
Object? lastReadAt = null,
|
Object? lastReadAt = null,
|
||||||
Object? publicationCacheId = freezed,
|
Object? publicationCacheId = freezed,
|
||||||
Object? seriesFeedUrl = freezed,
|
Object? seriesFeedUrl = freezed,
|
||||||
|
Object? syncHash = freezed,
|
||||||
}) {
|
}) {
|
||||||
return _then(_value.copyWith(
|
return _then(_value.copyWith(
|
||||||
id: null == id
|
id: null == id
|
||||||
|
|
@ -137,6 +142,10 @@ class _$ReadingProgressCopyWithImpl<$Res, $Val extends ReadingProgress>
|
||||||
? _value.seriesFeedUrl
|
? _value.seriesFeedUrl
|
||||||
: seriesFeedUrl // ignore: cast_nullable_to_non_nullable
|
: seriesFeedUrl // ignore: cast_nullable_to_non_nullable
|
||||||
as String?,
|
as String?,
|
||||||
|
syncHash: freezed == syncHash
|
||||||
|
? _value.syncHash
|
||||||
|
: syncHash // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
) as $Val);
|
) as $Val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -158,7 +167,8 @@ abstract class _$$ReadingProgressImplCopyWith<$Res>
|
||||||
String? epubLocation,
|
String? epubLocation,
|
||||||
DateTime lastReadAt,
|
DateTime lastReadAt,
|
||||||
String? publicationCacheId,
|
String? publicationCacheId,
|
||||||
String? seriesFeedUrl});
|
String? seriesFeedUrl,
|
||||||
|
String? syncHash});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @nodoc
|
/// @nodoc
|
||||||
|
|
@ -183,6 +193,7 @@ class __$$ReadingProgressImplCopyWithImpl<$Res>
|
||||||
Object? lastReadAt = null,
|
Object? lastReadAt = null,
|
||||||
Object? publicationCacheId = freezed,
|
Object? publicationCacheId = freezed,
|
||||||
Object? seriesFeedUrl = freezed,
|
Object? seriesFeedUrl = freezed,
|
||||||
|
Object? syncHash = freezed,
|
||||||
}) {
|
}) {
|
||||||
return _then(_$ReadingProgressImpl(
|
return _then(_$ReadingProgressImpl(
|
||||||
id: null == id
|
id: null == id
|
||||||
|
|
@ -221,6 +232,10 @@ class __$$ReadingProgressImplCopyWithImpl<$Res>
|
||||||
? _value.seriesFeedUrl
|
? _value.seriesFeedUrl
|
||||||
: seriesFeedUrl // ignore: cast_nullable_to_non_nullable
|
: seriesFeedUrl // ignore: cast_nullable_to_non_nullable
|
||||||
as String?,
|
as String?,
|
||||||
|
syncHash: freezed == syncHash
|
||||||
|
? _value.syncHash
|
||||||
|
: syncHash // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -237,7 +252,8 @@ class _$ReadingProgressImpl extends _ReadingProgress {
|
||||||
this.epubLocation,
|
this.epubLocation,
|
||||||
required this.lastReadAt,
|
required this.lastReadAt,
|
||||||
this.publicationCacheId,
|
this.publicationCacheId,
|
||||||
this.seriesFeedUrl})
|
this.seriesFeedUrl,
|
||||||
|
this.syncHash})
|
||||||
: super._();
|
: super._();
|
||||||
|
|
||||||
factory _$ReadingProgressImpl.fromJson(Map<String, dynamic> json) =>
|
factory _$ReadingProgressImpl.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|
@ -279,9 +295,13 @@ class _$ReadingProgressImpl extends _ReadingProgress {
|
||||||
@override
|
@override
|
||||||
final String? seriesFeedUrl;
|
final String? seriesFeedUrl;
|
||||||
|
|
||||||
|
/// KOReader sync document hash (partial MD5 of the EPUB file)
|
||||||
|
@override
|
||||||
|
final String? syncHash;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'ReadingProgress(id: $id, publicationId: $publicationId, serverId: $serverId, currentPage: $currentPage, totalPages: $totalPages, epubLocation: $epubLocation, lastReadAt: $lastReadAt, publicationCacheId: $publicationCacheId, seriesFeedUrl: $seriesFeedUrl)';
|
return 'ReadingProgress(id: $id, publicationId: $publicationId, serverId: $serverId, currentPage: $currentPage, totalPages: $totalPages, epubLocation: $epubLocation, lastReadAt: $lastReadAt, publicationCacheId: $publicationCacheId, seriesFeedUrl: $seriesFeedUrl, syncHash: $syncHash)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -305,7 +325,9 @@ class _$ReadingProgressImpl extends _ReadingProgress {
|
||||||
(identical(other.publicationCacheId, publicationCacheId) ||
|
(identical(other.publicationCacheId, publicationCacheId) ||
|
||||||
other.publicationCacheId == publicationCacheId) &&
|
other.publicationCacheId == publicationCacheId) &&
|
||||||
(identical(other.seriesFeedUrl, seriesFeedUrl) ||
|
(identical(other.seriesFeedUrl, seriesFeedUrl) ||
|
||||||
other.seriesFeedUrl == seriesFeedUrl));
|
other.seriesFeedUrl == seriesFeedUrl) &&
|
||||||
|
(identical(other.syncHash, syncHash) ||
|
||||||
|
other.syncHash == syncHash));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
|
@ -320,7 +342,8 @@ class _$ReadingProgressImpl extends _ReadingProgress {
|
||||||
epubLocation,
|
epubLocation,
|
||||||
lastReadAt,
|
lastReadAt,
|
||||||
publicationCacheId,
|
publicationCacheId,
|
||||||
seriesFeedUrl);
|
seriesFeedUrl,
|
||||||
|
syncHash);
|
||||||
|
|
||||||
/// Create a copy of ReadingProgress
|
/// Create a copy of ReadingProgress
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
|
@ -349,7 +372,8 @@ abstract class _ReadingProgress extends ReadingProgress {
|
||||||
final String? epubLocation,
|
final String? epubLocation,
|
||||||
required final DateTime lastReadAt,
|
required final DateTime lastReadAt,
|
||||||
final String? publicationCacheId,
|
final String? publicationCacheId,
|
||||||
final String? seriesFeedUrl}) = _$ReadingProgressImpl;
|
final String? seriesFeedUrl,
|
||||||
|
final String? syncHash}) = _$ReadingProgressImpl;
|
||||||
const _ReadingProgress._() : super._();
|
const _ReadingProgress._() : super._();
|
||||||
|
|
||||||
factory _ReadingProgress.fromJson(Map<String, dynamic> json) =
|
factory _ReadingProgress.fromJson(Map<String, dynamic> json) =
|
||||||
|
|
@ -391,6 +415,10 @@ abstract class _ReadingProgress extends ReadingProgress {
|
||||||
@override
|
@override
|
||||||
String? get seriesFeedUrl;
|
String? get seriesFeedUrl;
|
||||||
|
|
||||||
|
/// KOReader sync document hash (partial MD5 of the EPUB file)
|
||||||
|
@override
|
||||||
|
String? get syncHash;
|
||||||
|
|
||||||
/// Create a copy of ReadingProgress
|
/// Create a copy of ReadingProgress
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ _$ReadingProgressImpl _$$ReadingProgressImplFromJson(
|
||||||
lastReadAt: DateTime.parse(json['lastReadAt'] as String),
|
lastReadAt: DateTime.parse(json['lastReadAt'] as String),
|
||||||
publicationCacheId: json['publicationCacheId'] as String?,
|
publicationCacheId: json['publicationCacheId'] as String?,
|
||||||
seriesFeedUrl: json['seriesFeedUrl'] as String?,
|
seriesFeedUrl: json['seriesFeedUrl'] as String?,
|
||||||
|
syncHash: json['syncHash'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$$ReadingProgressImplToJson(
|
Map<String, dynamic> _$$ReadingProgressImplToJson(
|
||||||
|
|
@ -32,4 +33,5 @@ Map<String, dynamic> _$$ReadingProgressImplToJson(
|
||||||
'lastReadAt': instance.lastReadAt.toIso8601String(),
|
'lastReadAt': instance.lastReadAt.toIso8601String(),
|
||||||
'publicationCacheId': instance.publicationCacheId,
|
'publicationCacheId': instance.publicationCacheId,
|
||||||
'seriesFeedUrl': instance.seriesFeedUrl,
|
'seriesFeedUrl': instance.seriesFeedUrl,
|
||||||
|
'syncHash': instance.syncHash,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
81
lib/providers/koreader_sync_provider.dart
Normal file
81
lib/providers/koreader_sync_provider.dart
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:worldhopper/models/koreader_progress.dart';
|
||||||
|
import 'package:worldhopper/models/opds_server.dart';
|
||||||
|
import 'package:worldhopper/providers/server_provider.dart';
|
||||||
|
import 'package:worldhopper/services/koreader_sync_service.dart';
|
||||||
|
|
||||||
|
/// Provider for creating a KoreaderSyncService for a given server.
|
||||||
|
/// Only returns a service if the server has KOReader sync enabled.
|
||||||
|
final koreaderSyncServiceProvider =
|
||||||
|
Provider.family<KoreaderSyncService?, OPDSServer>((ref, server) {
|
||||||
|
if (!server.koreaderSyncEnabled ||
|
||||||
|
server.koreaderSyncUrl == null ||
|
||||||
|
server.koreaderSyncUrl!.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return KoreaderSyncService(server);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Provider to get a KoreaderSyncService by server ID.
|
||||||
|
/// Returns null if server not found or KOReader sync is not enabled.
|
||||||
|
final koreaderSyncServiceByIdProvider =
|
||||||
|
FutureProvider.family<KoreaderSyncService?, String>((ref, serverId) async {
|
||||||
|
final server = await ref.watch(serverProvider(serverId).future);
|
||||||
|
if (server == null) return null;
|
||||||
|
return ref.watch(koreaderSyncServiceProvider(server));
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Push reading progress to KOReader sync server.
|
||||||
|
/// Returns true if sync was successful, false otherwise.
|
||||||
|
/// Fails silently (logs error) to not interrupt the reading experience.
|
||||||
|
Future<bool> pushKoreaderProgress({
|
||||||
|
required KoreaderSyncService syncService,
|
||||||
|
required String documentHash,
|
||||||
|
required double percentage,
|
||||||
|
required String progress,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await syncService.updateProgress(
|
||||||
|
documentHash: documentHash,
|
||||||
|
percentage: percentage,
|
||||||
|
progress: progress,
|
||||||
|
);
|
||||||
|
} on KoreaderSyncAuthException {
|
||||||
|
rethrow;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to push KOReader progress: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull reading progress from KOReader sync server.
|
||||||
|
/// Returns the remote progress if available, null otherwise.
|
||||||
|
Future<KoreaderProgress?> pullKoreaderProgress({
|
||||||
|
required KoreaderSyncService syncService,
|
||||||
|
required String documentHash,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await syncService.getProgress(documentHash);
|
||||||
|
} on KoreaderSyncAuthException {
|
||||||
|
rethrow;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to pull KOReader progress: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the document hash for a given entry.
|
||||||
|
/// If the EPUB file is available, uses the file's partial MD5 hash (compatible with KOReader).
|
||||||
|
/// Falls back to hashing the OPDS entry ID.
|
||||||
|
Future<String> computeDocumentHash({
|
||||||
|
required String entryId,
|
||||||
|
File? epubFile,
|
||||||
|
}) async {
|
||||||
|
if (epubFile != null && await epubFile.exists()) {
|
||||||
|
return await KoreaderSyncService.computeFileHash(epubFile);
|
||||||
|
}
|
||||||
|
return KoreaderSyncService.computeStringHash(entryId);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:worldhopper/l10n/app_localizations.dart';
|
||||||
import 'package:worldhopper/config/constants.dart';
|
import 'package:worldhopper/config/constants.dart';
|
||||||
import 'package:worldhopper/models/opds_entry.dart';
|
import 'package:worldhopper/models/opds_entry.dart';
|
||||||
import 'package:worldhopper/providers/reading_progress_provider.dart';
|
import 'package:worldhopper/providers/reading_progress_provider.dart';
|
||||||
|
import 'package:worldhopper/providers/server_provider.dart';
|
||||||
import 'package:worldhopper/providers/connectivity_provider.dart';
|
import 'package:worldhopper/providers/connectivity_provider.dart';
|
||||||
import 'package:worldhopper/models/reading_progress.dart';
|
import 'package:worldhopper/models/reading_progress.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
@ -175,6 +176,12 @@ class PublicationDetailScreen extends ConsumerWidget {
|
||||||
label: l10n.publicationPageOfTotal(lastRead + 1, pageCount),
|
label: l10n.publicationPageOfTotal(lastRead + 1, pageCount),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show KOReader sync info for EPUBs when sync is enabled
|
||||||
|
if (entry.isEpub) {
|
||||||
|
return _buildKoreaderSyncInfo(context, ref);
|
||||||
|
}
|
||||||
|
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,6 +200,50 @@ class PublicationDetailScreen extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show an informational panel when KOReader sync is enabled,
|
||||||
|
/// telling the user that reading position will be restored automatically.
|
||||||
|
Widget _buildKoreaderSyncInfo(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final serverAsync = ref.watch(serverProvider(serverId));
|
||||||
|
|
||||||
|
return serverAsync.when(
|
||||||
|
data: (server) {
|
||||||
|
if (server == null || !server.koreaderSyncEnabled) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: Card(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.sync,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.koreaderSyncResumeInfo,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const SizedBox.shrink(),
|
||||||
|
error: (_, __) => const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildProgressCardContent(
|
Widget _buildProgressCardContent(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required double percentage,
|
required double percentage,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ import 'package:worldhopper/providers/reading_progress_provider.dart';
|
||||||
import 'package:worldhopper/providers/server_provider.dart';
|
import 'package:worldhopper/providers/server_provider.dart';
|
||||||
import 'package:worldhopper/providers/connectivity_provider.dart';
|
import 'package:worldhopper/providers/connectivity_provider.dart';
|
||||||
import 'package:worldhopper/services/epub_download_service.dart';
|
import 'package:worldhopper/services/epub_download_service.dart';
|
||||||
|
import 'package:worldhopper/services/koreader_sync_service.dart';
|
||||||
|
import 'package:worldhopper/providers/koreader_sync_provider.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import 'package:worldhopper/widgets/next_in_series_overlay.dart';
|
import 'package:worldhopper/widgets/next_in_series_overlay.dart';
|
||||||
|
|
||||||
|
|
@ -55,6 +57,12 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
|
|
||||||
bool _reachedEndThisSession = false;
|
bool _reachedEndThisSession = false;
|
||||||
|
|
||||||
|
// KOReader sync state
|
||||||
|
String? _koreaderDocumentHash;
|
||||||
|
KoreaderSyncService? _koreaderSyncService;
|
||||||
|
bool _hasSavedOnExit = false;
|
||||||
|
bool _koreaderAuthFailed = false;
|
||||||
|
|
||||||
// Gesture tracking for tap vs swipe detection
|
// Gesture tracking for tap vs swipe detection
|
||||||
Offset? _pointerDownPosition;
|
Offset? _pointerDownPosition;
|
||||||
DateTime? _pointerDownTime;
|
DateTime? _pointerDownTime;
|
||||||
|
|
@ -66,12 +74,13 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
static const Duration _tapMaxDuration =
|
static const Duration _tapMaxDuration =
|
||||||
Duration(milliseconds: 500); // taps complete quickly
|
Duration(milliseconds: 500); // taps complete quickly
|
||||||
|
|
||||||
|
bool _didStartLoad = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Create a new controller for each instance to avoid state retention
|
// Create a new controller for each instance to avoid state retention
|
||||||
_epubController = EpubController();
|
_epubController = EpubController();
|
||||||
_loadEpub();
|
|
||||||
|
|
||||||
// Keep screen awake while reading
|
// Keep screen awake while reading
|
||||||
WakelockPlus.enable();
|
WakelockPlus.enable();
|
||||||
|
|
@ -80,6 +89,18 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
// _loadEpub uses AppLocalizations.of(context) which requires inherited
|
||||||
|
// widgets to be available. initState() runs before they are ready, so
|
||||||
|
// we trigger the load here instead (guarded to run only once).
|
||||||
|
if (!_didStartLoad) {
|
||||||
|
_didStartLoad = true;
|
||||||
|
_loadEpub();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadEpub() async {
|
Future<void> _loadEpub() async {
|
||||||
try {
|
try {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
@ -162,6 +183,56 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always compute the document hash for storage (used by KOReader sync
|
||||||
|
// and shown in developer-mode raw data).
|
||||||
|
_koreaderDocumentHash = await computeDocumentHash(
|
||||||
|
entryId: widget.entry.id,
|
||||||
|
epubFile: file,
|
||||||
|
);
|
||||||
|
|
||||||
|
// KOReader sync: pull remote progress.
|
||||||
|
// Only restore from remote when there is NO local progress (i.e.
|
||||||
|
// cross-device resume). When local progress exists we always trust it
|
||||||
|
// because we are the ones who wrote it; the remote timestamp will
|
||||||
|
// often be slightly later (server-side) and would otherwise always
|
||||||
|
// win, overwriting the perfectly valid local CFI.
|
||||||
|
if (server.koreaderSyncEnabled) {
|
||||||
|
try {
|
||||||
|
final syncService = ref.read(koreaderSyncServiceProvider(server));
|
||||||
|
if (syncService != null) {
|
||||||
|
_koreaderSyncService = syncService;
|
||||||
|
|
||||||
|
// Only pull remote progress when we have no local position
|
||||||
|
if (cfi == null) {
|
||||||
|
final remoteProgress = await pullKoreaderProgress(
|
||||||
|
syncService: syncService,
|
||||||
|
documentHash: _koreaderDocumentHash!,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (remoteProgress != null &&
|
||||||
|
remoteProgress.progress.isNotEmpty &&
|
||||||
|
_isValidEpubCfi(remoteProgress.progress)) {
|
||||||
|
cfi = remoteProgress.progress;
|
||||||
|
debugPrint('KOReader sync: restoring remote position '
|
||||||
|
'(${remoteProgress.percentage * 100}%)');
|
||||||
|
} else if (remoteProgress != null &&
|
||||||
|
remoteProgress.progress.isNotEmpty) {
|
||||||
|
debugPrint('KOReader sync: ignoring non-CFI progress string: '
|
||||||
|
'${remoteProgress.progress}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} on KoreaderSyncAuthException {
|
||||||
|
_koreaderAuthFailed = true;
|
||||||
|
if (mounted) {
|
||||||
|
final l10nSync = AppLocalizations.of(context);
|
||||||
|
context.showWarningSnackBar(l10nSync.koreaderSyncAuthFailed);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('KOReader sync pull failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final l10nFinal = AppLocalizations.of(context);
|
final l10nFinal = AppLocalizations.of(context);
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -184,9 +255,23 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _hasReceivedNonZeroProgress = false;
|
||||||
|
|
||||||
/// Handle page navigation - save progress with CFI
|
/// Handle page navigation - save progress with CFI
|
||||||
void _handleRelocated(EpubLocation location) {
|
void _handleRelocated(EpubLocation location) {
|
||||||
_lastLocation = location;
|
_lastLocation = location;
|
||||||
|
|
||||||
|
// epub.js fires onRelocated immediately when restoring a saved position,
|
||||||
|
// but reports progress as 0.0 before it finishes calculating the actual
|
||||||
|
// value. Skip saving/pushing until we get a real progress value to avoid
|
||||||
|
// overwriting the stored position with 0%.
|
||||||
|
if (location.progress > 0.0) {
|
||||||
|
_hasReceivedNonZeroProgress = true;
|
||||||
|
}
|
||||||
|
if (!_hasReceivedNonZeroProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (location.progress >= 0.99 && !_reachedEndThisSession) {
|
if (location.progress >= 0.99 && !_reachedEndThisSession) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_reachedEndThisSession = true;
|
_reachedEndThisSession = true;
|
||||||
|
|
@ -221,8 +306,16 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
serverId: widget.serverId,
|
serverId: widget.serverId,
|
||||||
);
|
);
|
||||||
|
|
||||||
final existingProgress =
|
// Capture all ref-dependent values synchronously before any await.
|
||||||
await ref.read(readingProgressProvider(progressKey).future);
|
// This is critical when called from dispose(), where ref becomes
|
||||||
|
// invalid after super.dispose().
|
||||||
|
final existingProgressFuture =
|
||||||
|
ref.read(readingProgressProvider(progressKey).future);
|
||||||
|
final notifier = ref.read(readingProgressNotifierProvider.notifier);
|
||||||
|
final cachedPubFuture = ref.read(
|
||||||
|
cachedPublicationProvider(widget.serverId, widget.entry.id).future);
|
||||||
|
|
||||||
|
final existingProgress = await existingProgressFuture;
|
||||||
|
|
||||||
// Calculate current/total pages from location percentage
|
// Calculate current/total pages from location percentage
|
||||||
final percentage = location.progress;
|
final percentage = location.progress;
|
||||||
|
|
@ -240,8 +333,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get cached publication
|
// Get cached publication
|
||||||
final cachedPub = await ref.read(
|
final cachedPub = await cachedPubFuture;
|
||||||
cachedPublicationProvider(widget.serverId, widget.entry.id).future);
|
|
||||||
|
|
||||||
final progress = existingProgress?.copyWith(
|
final progress = existingProgress?.copyWith(
|
||||||
currentPage: currentPage,
|
currentPage: currentPage,
|
||||||
|
|
@ -251,6 +343,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
publicationCacheId:
|
publicationCacheId:
|
||||||
cachedPub?.id ?? existingProgress.publicationCacheId,
|
cachedPub?.id ?? existingProgress.publicationCacheId,
|
||||||
seriesFeedUrl: widget.feedUrl ?? existingProgress.seriesFeedUrl,
|
seriesFeedUrl: widget.feedUrl ?? existingProgress.seriesFeedUrl,
|
||||||
|
syncHash: _koreaderDocumentHash ?? existingProgress.syncHash,
|
||||||
) ??
|
) ??
|
||||||
ReadingProgress(
|
ReadingProgress(
|
||||||
id: const Uuid().v4(),
|
id: const Uuid().v4(),
|
||||||
|
|
@ -262,14 +355,43 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
lastReadAt: DateTime.now(),
|
lastReadAt: DateTime.now(),
|
||||||
publicationCacheId: cachedPub?.id,
|
publicationCacheId: cachedPub?.id,
|
||||||
seriesFeedUrl: widget.feedUrl,
|
seriesFeedUrl: widget.feedUrl,
|
||||||
|
syncHash: _koreaderDocumentHash,
|
||||||
);
|
);
|
||||||
|
|
||||||
await ref
|
await notifier.saveProgress(progress);
|
||||||
.read(readingProgressNotifierProvider.notifier)
|
|
||||||
.saveProgress(progress);
|
|
||||||
|
|
||||||
ref.invalidate(readingProgressProvider(progressKey));
|
// Only invalidate providers if the widget is still mounted.
|
||||||
ref.invalidate(recentlyReadProvider);
|
// During dispose(), ref is no longer valid for invalidation.
|
||||||
|
if (mounted) {
|
||||||
|
ref.invalidate(readingProgressProvider(progressKey));
|
||||||
|
ref.invalidate(recentlyReadProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push to KOReader sync if enabled
|
||||||
|
if (_koreaderSyncService != null && _koreaderDocumentHash != null) {
|
||||||
|
// Round to 5 decimal places to match KOReader's precision
|
||||||
|
final syncPercentage = double.parse(percentage.toStringAsFixed(5));
|
||||||
|
debugPrint(
|
||||||
|
'KOReader sync: pushing progress $syncPercentage (${location.startCfi})');
|
||||||
|
try {
|
||||||
|
final pushed = await pushKoreaderProgress(
|
||||||
|
syncService: _koreaderSyncService!,
|
||||||
|
documentHash: _koreaderDocumentHash!,
|
||||||
|
percentage: syncPercentage,
|
||||||
|
progress: location.startCfi,
|
||||||
|
);
|
||||||
|
debugPrint('KOReader sync: push result=$pushed');
|
||||||
|
} on KoreaderSyncAuthException {
|
||||||
|
if (!_koreaderAuthFailed && mounted) {
|
||||||
|
_koreaderAuthFailed = true;
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
context.showWarningSnackBar(l10n.koreaderSyncAuthFailed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debugPrint(
|
||||||
|
'KOReader sync: skipping push (service=${_koreaderSyncService != null}, hash=${_koreaderDocumentHash != null})');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build display settings based on orientation
|
/// Build display settings based on orientation
|
||||||
|
|
@ -419,7 +541,8 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: true,
|
canPop: true,
|
||||||
onPopInvokedWithResult: (bool didPop, Object? result) {
|
onPopInvokedWithResult: (bool didPop, Object? result) {
|
||||||
if (didPop) {
|
if (didPop && !_hasSavedOnExit) {
|
||||||
|
_hasSavedOnExit = true;
|
||||||
if (_reachedEndThisSession) {
|
if (_reachedEndThisSession) {
|
||||||
_deleteCompletedProgress();
|
_deleteCompletedProgress();
|
||||||
} else if (_lastLocation != null) {
|
} else if (_lastLocation != null) {
|
||||||
|
|
@ -431,42 +554,31 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
body: Listener(
|
body: Listener(
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
onPointerDown: (event) {
|
onPointerDown: (event) {
|
||||||
// Record initial touch position and time
|
|
||||||
_pointerDownPosition = event.position;
|
_pointerDownPosition = event.position;
|
||||||
_pointerDownTime = DateTime.now();
|
_pointerDownTime = DateTime.now();
|
||||||
_isSwipe = false;
|
_isSwipe = false;
|
||||||
},
|
},
|
||||||
onPointerMove: (event) {
|
onPointerMove: (event) {
|
||||||
// Track movement to detect swipes
|
|
||||||
if (_pointerDownPosition != null) {
|
if (_pointerDownPosition != null) {
|
||||||
final delta = (event.position - _pointerDownPosition!).distance;
|
final delta = (event.position - _pointerDownPosition!).distance;
|
||||||
|
|
||||||
// If movement exceeds threshold, mark as swipe (not a tap)
|
|
||||||
if (delta > _swipeThreshold) {
|
if (delta > _swipeThreshold) {
|
||||||
_isSwipe = true;
|
_isSwipe = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onPointerUp: (event) {
|
onPointerUp: (event) {
|
||||||
// Only toggle on tap (not swipe)
|
|
||||||
if (_pointerDownTime != null && _pointerDownPosition != null) {
|
if (_pointerDownTime != null && _pointerDownPosition != null) {
|
||||||
final duration = DateTime.now().difference(_pointerDownTime!);
|
final duration = DateTime.now().difference(_pointerDownTime!);
|
||||||
|
|
||||||
// Check if this was a tap (minimal movement, quick release)
|
|
||||||
if (!_isSwipe && duration < _tapMaxDuration) {
|
if (!_isSwipe && duration < _tapMaxDuration) {
|
||||||
// Apply existing position check for app bar exclusion
|
|
||||||
if (_showAppBar) {
|
if (_showAppBar) {
|
||||||
final appBarHeight = MediaQuery.of(context).padding.top + 56;
|
final appBarHeight = MediaQuery.of(context).padding.top + 56;
|
||||||
if (event.position.dy < appBarHeight) {
|
if (event.position.dy < appBarHeight) {
|
||||||
return; // Tap on app bar, don't toggle
|
return; // Tap on app bar, don't toggle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_toggleAppBarVisibility();
|
_toggleAppBarVisibility();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset tracking state
|
|
||||||
_pointerDownPosition = null;
|
_pointerDownPosition = null;
|
||||||
_pointerDownTime = null;
|
_pointerDownTime = null;
|
||||||
_isSwipe = false;
|
_isSwipe = false;
|
||||||
|
|
@ -483,18 +595,18 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: EpubViewer(
|
child: EpubViewer(
|
||||||
key: ValueKey(
|
key: ValueKey(
|
||||||
'$_viewerKey-${shouldUseDualPage ? "dual" : "single"}'), // Include spread in key to force recreation
|
'$_viewerKey-${shouldUseDualPage ? "dual" : "single"}'),
|
||||||
epubController: _epubController!,
|
epubController: _epubController!,
|
||||||
epubSource: EpubSource.fromFile(_epubFile!),
|
epubSource: EpubSource.fromFile(_epubFile!),
|
||||||
initialCfi: _initialCfi,
|
initialCfi: _initialCfi,
|
||||||
displaySettings: _buildDisplaySettings(orientation),
|
displaySettings: _buildDisplaySettings(orientation),
|
||||||
onEpubLoaded: () {
|
onEpubLoaded: () {
|
||||||
debugPrint('EPUB loaded successfully');
|
debugPrint('EPUB loaded successfully');
|
||||||
|
_disableEpubJsTapNavigation();
|
||||||
},
|
},
|
||||||
onChaptersLoaded: _handleChaptersLoaded,
|
onChaptersLoaded: _handleChaptersLoaded,
|
||||||
onRelocated: _handleRelocated,
|
onRelocated: _handleRelocated,
|
||||||
onTextSelected: (selection) {
|
onTextSelected: (selection) {
|
||||||
// Future: Handle text selection for highlights/notes
|
|
||||||
debugPrint('Selected: ${selection.selectedText}');
|
debugPrint('Selected: ${selection.selectedText}');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -640,6 +752,53 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Disable epub.js tap-to-navigate so that taps only toggle the app bar.
|
||||||
|
/// Swipe gestures continue to work for page turning.
|
||||||
|
///
|
||||||
|
/// The custom swipe handler uses touch events (touchstart/touchmove/touchend)
|
||||||
|
/// to detect swipes and calls rendition.next()/prev() from the touchend handler.
|
||||||
|
/// epub.js built-in navigation uses click events on content documents.
|
||||||
|
///
|
||||||
|
/// Since taps generate: touchstart → touchend → click
|
||||||
|
/// and swipes generate: touchstart → touchmove → touchend (no click),
|
||||||
|
/// blocking click events in the content documents disables tap navigation
|
||||||
|
/// while keeping swipe navigation intact.
|
||||||
|
void _disableEpubJsTapNavigation() {
|
||||||
|
_epubController?.webViewController?.evaluateJavascript(source: '''
|
||||||
|
(function() {
|
||||||
|
function blockClicks(doc) {
|
||||||
|
doc.addEventListener('click', function(e) {
|
||||||
|
if (e.target.closest && e.target.closest('a')) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook into new content loads (each chapter/spine item)
|
||||||
|
rendition.hooks.content.register(function(contents) {
|
||||||
|
if (contents.document) {
|
||||||
|
blockClicks(contents.document);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply to already-loaded views
|
||||||
|
var views = rendition.views();
|
||||||
|
if (views && views.forEach) {
|
||||||
|
views.forEach(function(view) {
|
||||||
|
if (view.document) blockClicks(view.document);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a progress string looks like a valid EPUB CFI.
|
||||||
|
/// KOReader devices may store XPointer-based progress strings (e.g.
|
||||||
|
/// "/body/DocFragment[20]/body/p[22]") which epub.js cannot parse.
|
||||||
|
/// We only accept strings that start with "epubcfi(".
|
||||||
|
bool _isValidEpubCfi(String value) {
|
||||||
|
return value.trimLeft().startsWith('epubcfi(');
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper method to convert Flutter Color to CSS hex string
|
/// Helper method to convert Flutter Color to CSS hex string
|
||||||
String _colorToHex(Color color) {
|
String _colorToHex(Color color) {
|
||||||
return '#${color.toARGB32().toRadixString(16).substring(2).toUpperCase()}';
|
return '#${color.toARGB32().toRadixString(16).substring(2).toUpperCase()}';
|
||||||
|
|
@ -795,12 +954,15 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
// Cancel timers
|
// Cancel timers
|
||||||
_appBarAutoHideTimer?.cancel();
|
_appBarAutoHideTimer?.cancel();
|
||||||
|
|
||||||
// If the user finished the chapter, remove from recently read;
|
// Save progress one final time if not already saved by onPopInvokedWithResult.
|
||||||
// otherwise save progress one final time before disposing.
|
// The guard flag prevents double saves when both onPop and dispose fire.
|
||||||
if (_reachedEndThisSession) {
|
if (!_hasSavedOnExit) {
|
||||||
_deleteCompletedProgress();
|
_hasSavedOnExit = true;
|
||||||
} else if (_lastLocation != null) {
|
if (_reachedEndThisSession) {
|
||||||
_saveProgress(_lastLocation!);
|
_deleteCompletedProgress();
|
||||||
|
} else if (_lastLocation != null) {
|
||||||
|
_saveProgress(_lastLocation!);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow screen to sleep again
|
// Allow screen to sleep again
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:worldhopper/l10n/app_localizations.dart';
|
||||||
import 'package:worldhopper/models/opds_server.dart';
|
import 'package:worldhopper/models/opds_server.dart';
|
||||||
import 'package:worldhopper/helpers/snackbar_helper.dart';
|
import 'package:worldhopper/helpers/snackbar_helper.dart';
|
||||||
import 'package:worldhopper/providers/server_provider.dart';
|
import 'package:worldhopper/providers/server_provider.dart';
|
||||||
|
import 'package:worldhopper/services/koreader_sync_service.dart';
|
||||||
|
|
||||||
/// Screen for adding a new OPDS server
|
/// Screen for adding a new OPDS server
|
||||||
class AddServerScreen extends ConsumerStatefulWidget {
|
class AddServerScreen extends ConsumerStatefulWidget {
|
||||||
|
|
@ -25,12 +26,23 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
|
||||||
bool _obscurePassword = true;
|
bool _obscurePassword = true;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
// KOReader sync fields
|
||||||
|
bool _koreaderSyncEnabled = false;
|
||||||
|
final _koreaderSyncUrlController = TextEditingController();
|
||||||
|
final _koreaderSyncUsernameController = TextEditingController();
|
||||||
|
final _koreaderSyncPasswordController = TextEditingController();
|
||||||
|
bool _obscureKoreaderPassword = true;
|
||||||
|
bool _isTestingKoreaderConnection = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
_urlController.dispose();
|
_urlController.dispose();
|
||||||
_usernameController.dispose();
|
_usernameController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
|
_koreaderSyncUrlController.dispose();
|
||||||
|
_koreaderSyncUsernameController.dispose();
|
||||||
|
_koreaderSyncPasswordController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,7 +164,136 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
onFieldSubmitted: (_) => _saveServer(),
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
|
||||||
|
// KOReader Sync toggle
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(l10n.koreaderSyncToggle),
|
||||||
|
subtitle: Text(l10n.koreaderSyncToggleSubtitle),
|
||||||
|
value: _koreaderSyncEnabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_koreaderSyncEnabled = value;
|
||||||
|
if (!value) {
|
||||||
|
_koreaderSyncUrlController.clear();
|
||||||
|
_koreaderSyncUsernameController.clear();
|
||||||
|
_koreaderSyncPasswordController.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// KOReader sync fields (conditionally shown)
|
||||||
|
if (_koreaderSyncEnabled) ...[
|
||||||
|
TextFormField(
|
||||||
|
controller: _koreaderSyncUrlController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.koreaderSyncUrl,
|
||||||
|
hintText: l10n.koreaderSyncUrlHint,
|
||||||
|
prefixIcon: const Icon(Icons.sync),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
validator: (value) {
|
||||||
|
if (_koreaderSyncEnabled &&
|
||||||
|
(value == null || value.trim().isEmpty)) {
|
||||||
|
return l10n.koreaderSyncValidationUrlRequired;
|
||||||
|
}
|
||||||
|
if (value != null && value.trim().isNotEmpty) {
|
||||||
|
final uri = Uri.tryParse(value.trim());
|
||||||
|
if (uri == null || !uri.hasScheme || !uri.hasAuthority) {
|
||||||
|
return l10n.koreaderSyncValidationUrlInvalid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _koreaderSyncUsernameController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.koreaderSyncUsername,
|
||||||
|
prefixIcon: const Icon(Icons.person_outline),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
validator: (value) {
|
||||||
|
if (_koreaderSyncEnabled &&
|
||||||
|
(value == null || value.trim().isEmpty)) {
|
||||||
|
return l10n.koreaderSyncValidationUsernameRequired;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _koreaderSyncPasswordController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.koreaderSyncPassword,
|
||||||
|
prefixIcon: const Icon(Icons.lock_outline),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_obscureKoreaderPassword
|
||||||
|
? Icons.visibility
|
||||||
|
: Icons.visibility_off,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_obscureKoreaderPassword = !_obscureKoreaderPassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
obscureText: _obscureKoreaderPassword,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
validator: (value) {
|
||||||
|
if (_koreaderSyncEnabled &&
|
||||||
|
(value == null || value.isEmpty)) {
|
||||||
|
return l10n.koreaderSyncValidationPasswordRequired;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Test connection button
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _isTestingKoreaderConnection
|
||||||
|
? null
|
||||||
|
: _testKoreaderConnection,
|
||||||
|
icon: _isTestingKoreaderConnection
|
||||||
|
? const SizedBox(
|
||||||
|
height: 16,
|
||||||
|
width: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.wifi_tethering),
|
||||||
|
label: Text(l10n.koreaderSyncTestConnection),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// KOReader sync help text
|
||||||
|
Card(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.koreaderSyncHelp,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
],
|
],
|
||||||
|
|
@ -199,6 +340,61 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _testKoreaderConnection() async {
|
||||||
|
// Validate KOReader fields before testing
|
||||||
|
final url = _koreaderSyncUrlController.text.trim();
|
||||||
|
final username = _koreaderSyncUsernameController.text.trim();
|
||||||
|
final password = _koreaderSyncPasswordController.text;
|
||||||
|
|
||||||
|
if (url.isEmpty || username.isEmpty || password.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isTestingKoreaderConnection = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
// Create a temporary server to test with
|
||||||
|
final testServer = OPDSServer(
|
||||||
|
id: 'test',
|
||||||
|
name: 'test',
|
||||||
|
url: '',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
koreaderSyncEnabled: true,
|
||||||
|
koreaderSyncUrl: url,
|
||||||
|
koreaderSyncUsername: username,
|
||||||
|
koreaderSyncPassword: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
final syncService = KoreaderSyncService(testServer);
|
||||||
|
|
||||||
|
// Test authentication (also verifies server is reachable)
|
||||||
|
final authenticated = await syncService.authenticate();
|
||||||
|
if (mounted) {
|
||||||
|
if (authenticated) {
|
||||||
|
context.showSuccessSnackBar(l10n.koreaderSyncTestSuccess(username));
|
||||||
|
} else {
|
||||||
|
context
|
||||||
|
.showErrorSnackBar(l10n.koreaderSyncTestFailed('Unauthorized'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
context.showErrorSnackBar(l10n.koreaderSyncTestFailed(e.toString()));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isTestingKoreaderConnection = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _saveServer() async {
|
Future<void> _saveServer() async {
|
||||||
if (!_formKey.currentState!.validate()) {
|
if (!_formKey.currentState!.validate()) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -234,6 +430,15 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
|
||||||
username: _requiresAuth ? _usernameController.text.trim() : null,
|
username: _requiresAuth ? _usernameController.text.trim() : null,
|
||||||
password: _requiresAuth ? _passwordController.text : null,
|
password: _requiresAuth ? _passwordController.text : null,
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
|
koreaderSyncEnabled: _koreaderSyncEnabled,
|
||||||
|
koreaderSyncUrl: _koreaderSyncEnabled
|
||||||
|
? _koreaderSyncUrlController.text.trim()
|
||||||
|
: null,
|
||||||
|
koreaderSyncUsername: _koreaderSyncEnabled
|
||||||
|
? _koreaderSyncUsernameController.text.trim()
|
||||||
|
: null,
|
||||||
|
koreaderSyncPassword:
|
||||||
|
_koreaderSyncEnabled ? _koreaderSyncPasswordController.text : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save server
|
// Save server
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:worldhopper/l10n/app_localizations.dart';
|
||||||
import 'package:worldhopper/helpers/snackbar_helper.dart';
|
import 'package:worldhopper/helpers/snackbar_helper.dart';
|
||||||
import 'package:worldhopper/models/opds_server.dart';
|
import 'package:worldhopper/models/opds_server.dart';
|
||||||
import 'package:worldhopper/providers/server_provider.dart';
|
import 'package:worldhopper/providers/server_provider.dart';
|
||||||
|
import 'package:worldhopper/services/koreader_sync_service.dart';
|
||||||
|
|
||||||
/// Screen for editing an existing OPDS server
|
/// Screen for editing an existing OPDS server
|
||||||
class EditServerScreen extends ConsumerStatefulWidget {
|
class EditServerScreen extends ConsumerStatefulWidget {
|
||||||
|
|
@ -30,12 +31,23 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
OPDSServer? _originalServer;
|
OPDSServer? _originalServer;
|
||||||
|
|
||||||
|
// KOReader sync fields
|
||||||
|
bool _koreaderSyncEnabled = false;
|
||||||
|
final _koreaderSyncUrlController = TextEditingController();
|
||||||
|
final _koreaderSyncUsernameController = TextEditingController();
|
||||||
|
final _koreaderSyncPasswordController = TextEditingController();
|
||||||
|
bool _obscureKoreaderPassword = true;
|
||||||
|
bool _isTestingKoreaderConnection = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
_urlController.dispose();
|
_urlController.dispose();
|
||||||
_usernameController.dispose();
|
_usernameController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
|
_koreaderSyncUrlController.dispose();
|
||||||
|
_koreaderSyncUsernameController.dispose();
|
||||||
|
_koreaderSyncPasswordController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,6 +80,19 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
||||||
if (server.password != null) {
|
if (server.password != null) {
|
||||||
_passwordController.text = server.password!;
|
_passwordController.text = server.password!;
|
||||||
}
|
}
|
||||||
|
// Initialize KOReader sync fields
|
||||||
|
_koreaderSyncEnabled = server.koreaderSyncEnabled;
|
||||||
|
if (server.koreaderSyncUrl != null) {
|
||||||
|
_koreaderSyncUrlController.text = server.koreaderSyncUrl!;
|
||||||
|
}
|
||||||
|
if (server.koreaderSyncUsername != null) {
|
||||||
|
_koreaderSyncUsernameController.text =
|
||||||
|
server.koreaderSyncUsername!;
|
||||||
|
}
|
||||||
|
if (server.koreaderSyncPassword != null) {
|
||||||
|
_koreaderSyncPasswordController.text =
|
||||||
|
server.koreaderSyncPassword!;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Form(
|
return Form(
|
||||||
|
|
@ -178,7 +203,140 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
onFieldSubmitted: (_) => _saveServer(),
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
|
||||||
|
// KOReader Sync toggle
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(l10n.koreaderSyncToggle),
|
||||||
|
subtitle: Text(l10n.koreaderSyncToggleSubtitle),
|
||||||
|
value: _koreaderSyncEnabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_koreaderSyncEnabled = value;
|
||||||
|
if (!value) {
|
||||||
|
_koreaderSyncUrlController.clear();
|
||||||
|
_koreaderSyncUsernameController.clear();
|
||||||
|
_koreaderSyncPasswordController.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// KOReader sync fields (conditionally shown)
|
||||||
|
if (_koreaderSyncEnabled) ...[
|
||||||
|
TextFormField(
|
||||||
|
controller: _koreaderSyncUrlController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.koreaderSyncUrl,
|
||||||
|
hintText: l10n.koreaderSyncUrlHint,
|
||||||
|
prefixIcon: const Icon(Icons.sync),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
validator: (value) {
|
||||||
|
if (_koreaderSyncEnabled &&
|
||||||
|
(value == null || value.trim().isEmpty)) {
|
||||||
|
return l10n.koreaderSyncValidationUrlRequired;
|
||||||
|
}
|
||||||
|
if (value != null && value.trim().isNotEmpty) {
|
||||||
|
final uri = Uri.tryParse(value.trim());
|
||||||
|
if (uri == null ||
|
||||||
|
!uri.hasScheme ||
|
||||||
|
!uri.hasAuthority) {
|
||||||
|
return l10n.koreaderSyncValidationUrlInvalid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _koreaderSyncUsernameController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.koreaderSyncUsername,
|
||||||
|
prefixIcon: const Icon(Icons.person_outline),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
validator: (value) {
|
||||||
|
if (_koreaderSyncEnabled &&
|
||||||
|
(value == null || value.trim().isEmpty)) {
|
||||||
|
return l10n.koreaderSyncValidationUsernameRequired;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _koreaderSyncPasswordController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.koreaderSyncPassword,
|
||||||
|
prefixIcon: const Icon(Icons.lock_outline),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_obscureKoreaderPassword
|
||||||
|
? Icons.visibility
|
||||||
|
: Icons.visibility_off,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_obscureKoreaderPassword =
|
||||||
|
!_obscureKoreaderPassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
obscureText: _obscureKoreaderPassword,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
validator: (value) {
|
||||||
|
if (_koreaderSyncEnabled &&
|
||||||
|
(value == null || value.isEmpty)) {
|
||||||
|
return l10n.koreaderSyncValidationPasswordRequired;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Test connection button
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _isTestingKoreaderConnection
|
||||||
|
? null
|
||||||
|
: _testKoreaderConnection,
|
||||||
|
icon: _isTestingKoreaderConnection
|
||||||
|
? const SizedBox(
|
||||||
|
height: 16,
|
||||||
|
width: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.wifi_tethering),
|
||||||
|
label: Text(l10n.koreaderSyncTestConnection),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// KOReader sync help text
|
||||||
|
Card(
|
||||||
|
color:
|
||||||
|
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.koreaderSyncHelp,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
],
|
],
|
||||||
|
|
@ -273,6 +431,59 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
||||||
'${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
|
'${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _testKoreaderConnection() async {
|
||||||
|
final url = _koreaderSyncUrlController.text.trim();
|
||||||
|
final username = _koreaderSyncUsernameController.text.trim();
|
||||||
|
final password = _koreaderSyncPasswordController.text;
|
||||||
|
|
||||||
|
if (url.isEmpty || username.isEmpty || password.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isTestingKoreaderConnection = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
final testServer = OPDSServer(
|
||||||
|
id: 'test',
|
||||||
|
name: 'test',
|
||||||
|
url: '',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
koreaderSyncEnabled: true,
|
||||||
|
koreaderSyncUrl: url,
|
||||||
|
koreaderSyncUsername: username,
|
||||||
|
koreaderSyncPassword: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
final syncService = KoreaderSyncService(testServer);
|
||||||
|
|
||||||
|
// Test authentication (also verifies server is reachable)
|
||||||
|
final authenticated = await syncService.authenticate();
|
||||||
|
if (mounted) {
|
||||||
|
if (authenticated) {
|
||||||
|
context.showSuccessSnackBar(l10n.koreaderSyncTestSuccess(username));
|
||||||
|
} else {
|
||||||
|
context
|
||||||
|
.showErrorSnackBar(l10n.koreaderSyncTestFailed('Unauthorized'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
context.showErrorSnackBar(l10n.koreaderSyncTestFailed(e.toString()));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isTestingKoreaderConnection = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _saveServer() async {
|
Future<void> _saveServer() async {
|
||||||
if (!_formKey.currentState!.validate()) {
|
if (!_formKey.currentState!.validate()) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -291,6 +502,15 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
||||||
url: _urlController.text.trim(),
|
url: _urlController.text.trim(),
|
||||||
username: _requiresAuth ? _usernameController.text.trim() : null,
|
username: _requiresAuth ? _usernameController.text.trim() : null,
|
||||||
password: _requiresAuth ? _passwordController.text : null,
|
password: _requiresAuth ? _passwordController.text : null,
|
||||||
|
koreaderSyncEnabled: _koreaderSyncEnabled,
|
||||||
|
koreaderSyncUrl: _koreaderSyncEnabled
|
||||||
|
? _koreaderSyncUrlController.text.trim()
|
||||||
|
: null,
|
||||||
|
koreaderSyncUsername: _koreaderSyncEnabled
|
||||||
|
? _koreaderSyncUsernameController.text.trim()
|
||||||
|
: null,
|
||||||
|
koreaderSyncPassword:
|
||||||
|
_koreaderSyncEnabled ? _koreaderSyncPasswordController.text : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save server
|
// Save server
|
||||||
|
|
|
||||||
226
lib/services/koreader_sync_service.dart
Normal file
226
lib/services/koreader_sync_service.dart
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import 'package:worldhopper/models/koreader_progress.dart';
|
||||||
|
import 'package:worldhopper/models/opds_server.dart';
|
||||||
|
|
||||||
|
/// Exception thrown when KOReader sync authentication fails (401).
|
||||||
|
class KoreaderSyncAuthException implements Exception {
|
||||||
|
final String message;
|
||||||
|
KoreaderSyncAuthException([this.message = 'Authentication failed']);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'KoreaderSyncAuthException: $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service for interacting with a KOReader-compatible sync server.
|
||||||
|
///
|
||||||
|
/// The KOReader sync protocol uses:
|
||||||
|
/// - Custom headers `x-auth-user` and `x-auth-key` for authentication
|
||||||
|
/// - MD5 hashes as document identifiers
|
||||||
|
/// - API versioning via Accept header (`application/vnd.koreader.v1+json`)
|
||||||
|
class KoreaderSyncService {
|
||||||
|
final OPDSServer _server;
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
static const String _deviceIdKey = 'koreader_sync_device_id';
|
||||||
|
static const String _deviceName = 'Worldhopper';
|
||||||
|
|
||||||
|
KoreaderSyncService(this._server) : _dio = _createClient(_server);
|
||||||
|
|
||||||
|
static Dio _createClient(OPDSServer server) {
|
||||||
|
final baseUrl = server.koreaderSyncUrl ?? '';
|
||||||
|
|
||||||
|
final dio = Dio(
|
||||||
|
BaseOptions(
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
connectTimeout: const Duration(seconds: 15),
|
||||||
|
receiveTimeout: const Duration(seconds: 15),
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/vnd.koreader.v1+json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add KOReader auth headers
|
||||||
|
final username = server.koreaderSyncUsername;
|
||||||
|
final password = server.koreaderSyncPassword;
|
||||||
|
|
||||||
|
if (username != null &&
|
||||||
|
username.isNotEmpty &&
|
||||||
|
password != null &&
|
||||||
|
password.isNotEmpty) {
|
||||||
|
// KOReader clients send the MD5 of the password as x-auth-key
|
||||||
|
final hashedPassword = md5.convert(utf8.encode(password)).toString();
|
||||||
|
|
||||||
|
dio.interceptors.add(
|
||||||
|
InterceptorsWrapper(
|
||||||
|
onRequest: (options, handler) {
|
||||||
|
options.headers['x-auth-user'] = username;
|
||||||
|
options.headers['x-auth-key'] = hashedPassword;
|
||||||
|
return handler.next(options);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test authentication against the sync server.
|
||||||
|
/// Returns `true` if credentials are valid.
|
||||||
|
/// Also serves as a connectivity check — a connection error means
|
||||||
|
/// the server is unreachable, while a 401 means bad credentials.
|
||||||
|
Future<bool> authenticate() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/users/auth');
|
||||||
|
return response.statusCode == 200;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.response?.statusCode == 401) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new user on the sync server.
|
||||||
|
/// Returns `true` if registration succeeded.
|
||||||
|
/// Throws if the server has registration disabled or user already exists.
|
||||||
|
Future<bool> registerUser() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.post(
|
||||||
|
'/users/create',
|
||||||
|
data: {
|
||||||
|
'username': _server.koreaderSyncUsername,
|
||||||
|
'password': md5
|
||||||
|
.convert(utf8.encode(_server.koreaderSyncPassword ?? ''))
|
||||||
|
.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.statusCode == 201;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
debugPrint('KOReader sync registration failed: ${e.message}');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get reading progress for a document.
|
||||||
|
/// [documentHash] is the MD5 hash identifying the document.
|
||||||
|
/// Returns `null` if no progress is stored for this document.
|
||||||
|
Future<KoreaderProgress?> getProgress(String documentHash) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/syncs/progress/$documentHash');
|
||||||
|
|
||||||
|
if (response.statusCode == 200 && response.data is Map) {
|
||||||
|
final data = response.data as Map<String, dynamic>;
|
||||||
|
// Empty response means no progress stored
|
||||||
|
if (data.isEmpty) return null;
|
||||||
|
// Response without document field means no real progress
|
||||||
|
if (!data.containsKey('document')) return null;
|
||||||
|
return KoreaderProgress.fromJson(data);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.response?.statusCode == 401) {
|
||||||
|
throw KoreaderSyncAuthException();
|
||||||
|
}
|
||||||
|
debugPrint('KOReader sync getProgress error: ${e.message}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update reading progress for a document.
|
||||||
|
/// Returns `true` if the update was successful.
|
||||||
|
Future<bool> updateProgress({
|
||||||
|
required String documentHash,
|
||||||
|
required double percentage,
|
||||||
|
required String progress,
|
||||||
|
String? device,
|
||||||
|
String? deviceId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final resolvedDeviceId = deviceId ?? await _getDeviceId();
|
||||||
|
final response = await _dio.put(
|
||||||
|
'/syncs/progress',
|
||||||
|
data: {
|
||||||
|
'document': documentHash,
|
||||||
|
'percentage': percentage,
|
||||||
|
'progress': progress,
|
||||||
|
'device': device ?? _deviceName,
|
||||||
|
'device_id': resolvedDeviceId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.statusCode == 200;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.response?.statusCode == 401) {
|
||||||
|
throw KoreaderSyncAuthException();
|
||||||
|
}
|
||||||
|
debugPrint('KOReader sync updateProgress error: ${e.message}');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the partial MD5 hash of a file, matching KOReader's algorithm.
|
||||||
|
///
|
||||||
|
/// KOReader uses a non-uniform sampling strategy: it reads 1024-byte chunks
|
||||||
|
/// at exponentially increasing offsets (0, 1024, 4096, 16384, …). This
|
||||||
|
/// ensures the hash is dominated by the file head and is resilient to data
|
||||||
|
/// appended at the tail (e.g. PDF highlight annotations).
|
||||||
|
///
|
||||||
|
/// See `util.partialMD5` in KOReader source.
|
||||||
|
static Future<String> computeFileHash(File file) async {
|
||||||
|
const int step = 1024;
|
||||||
|
const int size = 1024;
|
||||||
|
|
||||||
|
// Offsets sampled by KOReader's partialMD5:
|
||||||
|
// i = -1 → lshift(1024, -2) overflows 32-bit → 0
|
||||||
|
// i = 0 → 1024
|
||||||
|
// i = 1 → 4096
|
||||||
|
// ...
|
||||||
|
// i = 10 → 1073741824
|
||||||
|
final offsets = <int>[
|
||||||
|
0, // i = -1 (LuaJIT 32-bit overflow gives 0)
|
||||||
|
for (int i = 0; i <= 10; i++) step << (2 * i),
|
||||||
|
];
|
||||||
|
|
||||||
|
final raf = await file.open(mode: FileMode.read);
|
||||||
|
try {
|
||||||
|
final samples = <int>[];
|
||||||
|
for (final offset in offsets) {
|
||||||
|
await raf.setPosition(offset);
|
||||||
|
final chunk = await raf.read(size);
|
||||||
|
if (chunk.isEmpty) break;
|
||||||
|
samples.addAll(chunk);
|
||||||
|
}
|
||||||
|
return md5.convert(samples).toString();
|
||||||
|
} finally {
|
||||||
|
await raf.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute MD5 hash from a string (e.g., entry ID) for document identification.
|
||||||
|
/// Used as a fallback when the actual file is not available.
|
||||||
|
static String computeStringHash(String input) {
|
||||||
|
return md5.convert(utf8.encode(input)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get or create a persistent device ID for this app installation.
|
||||||
|
static Future<String> _getDeviceId() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
var deviceId = prefs.getString(_deviceIdKey);
|
||||||
|
if (deviceId == null) {
|
||||||
|
deviceId = const Uuid().v4();
|
||||||
|
await prefs.setString(_deviceIdKey, deviceId);
|
||||||
|
}
|
||||||
|
return deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the device ID (public accessor for use in providers).
|
||||||
|
static Future<String> getDeviceId() => _getDeviceId();
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,10 @@ class RecentlyReadCard extends ConsumerWidget {
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
// Watch developer mode so the provider stays alive and its async-loaded
|
||||||
|
// value is available when the context menu is opened.
|
||||||
|
final isDeveloperMode = ref.watch(developerModeNotifierProvider);
|
||||||
|
|
||||||
// Load cached publication
|
// Load cached publication
|
||||||
final publicationAsync = ref.watch(
|
final publicationAsync = ref.watch(
|
||||||
cachedPublicationProvider(progress.serverId, progress.publicationId),
|
cachedPublicationProvider(progress.serverId, progress.publicationId),
|
||||||
|
|
@ -53,8 +57,8 @@ class RecentlyReadCard extends ConsumerWidget {
|
||||||
// Display with full metadata
|
// Display with full metadata
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _handleTap(context, ref, publication),
|
onTap: () => _handleTap(context, ref, publication),
|
||||||
onLongPressStart: (details) =>
|
onLongPressStart: (details) => _showContextMenu(
|
||||||
_showContextMenu(context, ref, details, publication),
|
context, ref, details, publication, isDeveloperMode),
|
||||||
child: Card(
|
child: Card(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -284,6 +288,7 @@ class RecentlyReadCard extends ConsumerWidget {
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
LongPressStartDetails details,
|
LongPressStartDetails details,
|
||||||
dynamic publication,
|
dynamic publication,
|
||||||
|
bool isDeveloperMode,
|
||||||
) async {
|
) async {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final RenderBox overlay =
|
final RenderBox overlay =
|
||||||
|
|
@ -293,8 +298,6 @@ class RecentlyReadCard extends ConsumerWidget {
|
||||||
Offset.zero & overlay.size,
|
Offset.zero & overlay.size,
|
||||||
);
|
);
|
||||||
|
|
||||||
final isDeveloperMode = ref.read(developerModeNotifierProvider);
|
|
||||||
|
|
||||||
final items = <PopupMenuEntry<String>>[];
|
final items = <PopupMenuEntry<String>>[];
|
||||||
|
|
||||||
if (isDeveloperMode) {
|
if (isDeveloperMode) {
|
||||||
|
|
@ -419,7 +422,13 @@ class RecentlyReadCard extends ConsumerWidget {
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: SelectableText(
|
child: SelectableText(
|
||||||
'// ReadingProgress\n$progressJson\n\n// Publication\n$publicationJson',
|
[
|
||||||
|
'// ReadingProgress',
|
||||||
|
progressJson,
|
||||||
|
'',
|
||||||
|
'// Publication',
|
||||||
|
publicationJson,
|
||||||
|
].join('\n'),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "3.1.2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,9 @@ dependencies:
|
||||||
# Encryption for credentials
|
# Encryption for credentials
|
||||||
encrypt: ^5.0.3
|
encrypt: ^5.0.3
|
||||||
|
|
||||||
|
# Cryptographic hashing (MD5 for KOReader sync document IDs)
|
||||||
|
crypto: ^3.0.3
|
||||||
|
|
||||||
# Icons
|
# Icons
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue