feat: koreader sync #4
25 changed files with 1540 additions and 55 deletions
|
|
@ -7,7 +7,7 @@ class AppConstants {
|
|||
|
||||
// Database
|
||||
static const String databaseName = 'worldhopper.db';
|
||||
static const int databaseVersion = 8;
|
||||
static const int databaseVersion = 10;
|
||||
|
||||
// Cache settings
|
||||
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(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
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ class ReadingProgressTable {
|
|||
last_read_at INTEGER NOT NULL,
|
||||
publication_cache_id TEXT,
|
||||
series_feed_url TEXT,
|
||||
sync_hash TEXT,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (publication_cache_id) REFERENCES publications(id) ON DELETE SET NULL,
|
||||
UNIQUE(publication_id, server_id)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ class ServersTable {
|
|||
username TEXT,
|
||||
password TEXT,
|
||||
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",
|
||||
"editServerNotFound": "Server not found",
|
||||
"editServerInfo": "Server Information",
|
||||
|
|
|
|||
|
|
@ -147,6 +147,23 @@
|
|||
"addServerSuccess": "{name} añadido correctamente",
|
||||
"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",
|
||||
"editServerNotFound": "Servidor no encontrado",
|
||||
"editServerInfo": "Información del servidor",
|
||||
|
|
|
|||
|
|
@ -890,6 +890,102 @@ abstract class AppLocalizations {
|
|||
/// **'Error adding server: {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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -428,6 +428,65 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
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
|
||||
String get editServerTitle => 'Edit Server';
|
||||
|
||||
|
|
|
|||
|
|
@ -436,6 +436,65 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||
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
|
||||
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
|
||||
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;
|
||||
|
||||
/// Create OPDSServer from JSON
|
||||
|
|
@ -46,6 +58,10 @@ class OPDSServer with _$OPDSServer {
|
|||
lastSyncedAt: row['last_synced_at'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(row['last_synced_at'] as int)
|
||||
: 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,
|
||||
'created_at': createdAt.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
|
||||
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.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
|
||||
|
|
@ -64,7 +76,11 @@ abstract class $OPDSServerCopyWith<$Res> {
|
|||
String? username,
|
||||
String? password,
|
||||
DateTime createdAt,
|
||||
DateTime? lastSyncedAt});
|
||||
DateTime? lastSyncedAt,
|
||||
bool koreaderSyncEnabled,
|
||||
String? koreaderSyncUrl,
|
||||
String? koreaderSyncUsername,
|
||||
String? koreaderSyncPassword});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
|
@ -89,6 +105,10 @@ class _$OPDSServerCopyWithImpl<$Res, $Val extends OPDSServer>
|
|||
Object? password = freezed,
|
||||
Object? createdAt = null,
|
||||
Object? lastSyncedAt = freezed,
|
||||
Object? koreaderSyncEnabled = null,
|
||||
Object? koreaderSyncUrl = freezed,
|
||||
Object? koreaderSyncUsername = freezed,
|
||||
Object? koreaderSyncPassword = freezed,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
id: null == id
|
||||
|
|
@ -119,6 +139,22 @@ class _$OPDSServerCopyWithImpl<$Res, $Val extends OPDSServer>
|
|||
? _value.lastSyncedAt
|
||||
: lastSyncedAt // ignore: cast_nullable_to_non_nullable
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -138,7 +174,11 @@ abstract class _$$OPDSServerImplCopyWith<$Res>
|
|||
String? username,
|
||||
String? password,
|
||||
DateTime createdAt,
|
||||
DateTime? lastSyncedAt});
|
||||
DateTime? lastSyncedAt,
|
||||
bool koreaderSyncEnabled,
|
||||
String? koreaderSyncUrl,
|
||||
String? koreaderSyncUsername,
|
||||
String? koreaderSyncPassword});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
|
@ -161,6 +201,10 @@ class __$$OPDSServerImplCopyWithImpl<$Res>
|
|||
Object? password = freezed,
|
||||
Object? createdAt = null,
|
||||
Object? lastSyncedAt = freezed,
|
||||
Object? koreaderSyncEnabled = null,
|
||||
Object? koreaderSyncUrl = freezed,
|
||||
Object? koreaderSyncUsername = freezed,
|
||||
Object? koreaderSyncPassword = freezed,
|
||||
}) {
|
||||
return _then(_$OPDSServerImpl(
|
||||
id: null == id
|
||||
|
|
@ -191,6 +235,22 @@ class __$$OPDSServerImplCopyWithImpl<$Res>
|
|||
? _value.lastSyncedAt
|
||||
: lastSyncedAt // ignore: cast_nullable_to_non_nullable
|
||||
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.password,
|
||||
required this.createdAt,
|
||||
this.lastSyncedAt});
|
||||
this.lastSyncedAt,
|
||||
this.koreaderSyncEnabled = false,
|
||||
this.koreaderSyncUrl,
|
||||
this.koreaderSyncUsername,
|
||||
this.koreaderSyncPassword});
|
||||
|
||||
factory _$OPDSServerImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$OPDSServerImplFromJson(json);
|
||||
|
|
@ -238,9 +302,26 @@ class _$OPDSServerImpl implements _OPDSServer {
|
|||
@override
|
||||
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
|
||||
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
|
||||
|
|
@ -258,13 +339,32 @@ class _$OPDSServerImpl implements _OPDSServer {
|
|||
(identical(other.createdAt, createdAt) ||
|
||||
other.createdAt == createdAt) &&
|
||||
(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)
|
||||
@override
|
||||
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
|
||||
/// 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? password,
|
||||
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) =
|
||||
_$OPDSServerImpl.fromJson;
|
||||
|
|
@ -323,6 +427,22 @@ abstract class _OPDSServer implements OPDSServer {
|
|||
@override
|
||||
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
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ _$OPDSServerImpl _$$OPDSServerImplFromJson(Map<String, dynamic> json) =>
|
|||
lastSyncedAt: json['lastSyncedAt'] == null
|
||||
? null
|
||||
: 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) =>
|
||||
|
|
@ -28,4 +32,8 @@ Map<String, dynamic> _$$OPDSServerImplToJson(_$OPDSServerImpl instance) =>
|
|||
'password': instance.password,
|
||||
'createdAt': instance.createdAt.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)
|
||||
String? seriesFeedUrl,
|
||||
|
||||
/// KOReader sync document hash (partial MD5 of the EPUB file)
|
||||
String? syncHash,
|
||||
}) = _ReadingProgress;
|
||||
|
||||
const ReadingProgress._();
|
||||
|
|
@ -54,12 +57,18 @@ class ReadingProgress with _$ReadingProgress {
|
|||
DateTime.fromMillisecondsSinceEpoch(row['last_read_at'] as int),
|
||||
publicationCacheId: row['publication_cache_id'] as String?,
|
||||
seriesFeedUrl: row['series_feed_url'] as String?,
|
||||
syncHash: row['sync_hash'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Calculate reading progress percentage
|
||||
double get progressPercentage {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -67,7 +76,12 @@ class ReadingProgress with _$ReadingProgress {
|
|||
bool get isStarted => currentPage > 0;
|
||||
|
||||
/// 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 {
|
||||
|
|
@ -83,6 +97,7 @@ extension ReadingProgressX on ReadingProgress {
|
|||
'last_read_at': lastReadAt.millisecondsSinceEpoch,
|
||||
'publication_cache_id': publicationCacheId,
|
||||
'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)
|
||||
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.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
|
||||
|
|
@ -72,7 +75,8 @@ abstract class $ReadingProgressCopyWith<$Res> {
|
|||
String? epubLocation,
|
||||
DateTime lastReadAt,
|
||||
String? publicationCacheId,
|
||||
String? seriesFeedUrl});
|
||||
String? seriesFeedUrl,
|
||||
String? syncHash});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
|
@ -99,6 +103,7 @@ class _$ReadingProgressCopyWithImpl<$Res, $Val extends ReadingProgress>
|
|||
Object? lastReadAt = null,
|
||||
Object? publicationCacheId = freezed,
|
||||
Object? seriesFeedUrl = freezed,
|
||||
Object? syncHash = freezed,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
id: null == id
|
||||
|
|
@ -137,6 +142,10 @@ class _$ReadingProgressCopyWithImpl<$Res, $Val extends ReadingProgress>
|
|||
? _value.seriesFeedUrl
|
||||
: seriesFeedUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
syncHash: freezed == syncHash
|
||||
? _value.syncHash
|
||||
: syncHash // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
) as $Val);
|
||||
}
|
||||
}
|
||||
|
|
@ -158,7 +167,8 @@ abstract class _$$ReadingProgressImplCopyWith<$Res>
|
|||
String? epubLocation,
|
||||
DateTime lastReadAt,
|
||||
String? publicationCacheId,
|
||||
String? seriesFeedUrl});
|
||||
String? seriesFeedUrl,
|
||||
String? syncHash});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
|
@ -183,6 +193,7 @@ class __$$ReadingProgressImplCopyWithImpl<$Res>
|
|||
Object? lastReadAt = null,
|
||||
Object? publicationCacheId = freezed,
|
||||
Object? seriesFeedUrl = freezed,
|
||||
Object? syncHash = freezed,
|
||||
}) {
|
||||
return _then(_$ReadingProgressImpl(
|
||||
id: null == id
|
||||
|
|
@ -221,6 +232,10 @@ class __$$ReadingProgressImplCopyWithImpl<$Res>
|
|||
? _value.seriesFeedUrl
|
||||
: seriesFeedUrl // ignore: cast_nullable_to_non_nullable
|
||||
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,
|
||||
required this.lastReadAt,
|
||||
this.publicationCacheId,
|
||||
this.seriesFeedUrl})
|
||||
this.seriesFeedUrl,
|
||||
this.syncHash})
|
||||
: super._();
|
||||
|
||||
factory _$ReadingProgressImpl.fromJson(Map<String, dynamic> json) =>
|
||||
|
|
@ -279,9 +295,13 @@ class _$ReadingProgressImpl extends _ReadingProgress {
|
|||
@override
|
||||
final String? seriesFeedUrl;
|
||||
|
||||
/// KOReader sync document hash (partial MD5 of the EPUB file)
|
||||
@override
|
||||
final String? syncHash;
|
||||
|
||||
@override
|
||||
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
|
||||
|
|
@ -305,7 +325,9 @@ class _$ReadingProgressImpl extends _ReadingProgress {
|
|||
(identical(other.publicationCacheId, publicationCacheId) ||
|
||||
other.publicationCacheId == publicationCacheId) &&
|
||||
(identical(other.seriesFeedUrl, seriesFeedUrl) ||
|
||||
other.seriesFeedUrl == seriesFeedUrl));
|
||||
other.seriesFeedUrl == seriesFeedUrl) &&
|
||||
(identical(other.syncHash, syncHash) ||
|
||||
other.syncHash == syncHash));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
|
|
@ -320,7 +342,8 @@ class _$ReadingProgressImpl extends _ReadingProgress {
|
|||
epubLocation,
|
||||
lastReadAt,
|
||||
publicationCacheId,
|
||||
seriesFeedUrl);
|
||||
seriesFeedUrl,
|
||||
syncHash);
|
||||
|
||||
/// Create a copy of ReadingProgress
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
|
|
@ -349,7 +372,8 @@ abstract class _ReadingProgress extends ReadingProgress {
|
|||
final String? epubLocation,
|
||||
required final DateTime lastReadAt,
|
||||
final String? publicationCacheId,
|
||||
final String? seriesFeedUrl}) = _$ReadingProgressImpl;
|
||||
final String? seriesFeedUrl,
|
||||
final String? syncHash}) = _$ReadingProgressImpl;
|
||||
const _ReadingProgress._() : super._();
|
||||
|
||||
factory _ReadingProgress.fromJson(Map<String, dynamic> json) =
|
||||
|
|
@ -391,6 +415,10 @@ abstract class _ReadingProgress extends ReadingProgress {
|
|||
@override
|
||||
String? get seriesFeedUrl;
|
||||
|
||||
/// KOReader sync document hash (partial MD5 of the EPUB file)
|
||||
@override
|
||||
String? get syncHash;
|
||||
|
||||
/// Create a copy of ReadingProgress
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ _$ReadingProgressImpl _$$ReadingProgressImplFromJson(
|
|||
lastReadAt: DateTime.parse(json['lastReadAt'] as String),
|
||||
publicationCacheId: json['publicationCacheId'] as String?,
|
||||
seriesFeedUrl: json['seriesFeedUrl'] as String?,
|
||||
syncHash: json['syncHash'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$ReadingProgressImplToJson(
|
||||
|
|
@ -32,4 +33,5 @@ Map<String, dynamic> _$$ReadingProgressImplToJson(
|
|||
'lastReadAt': instance.lastReadAt.toIso8601String(),
|
||||
'publicationCacheId': instance.publicationCacheId,
|
||||
'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/models/opds_entry.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/models/reading_progress.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
|
@ -175,6 +176,12 @@ class PublicationDetailScreen extends ConsumerWidget {
|
|||
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();
|
||||
}
|
||||
|
||||
|
|
@ -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(
|
||||
BuildContext context, {
|
||||
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/connectivity_provider.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:worldhopper/widgets/next_in_series_overlay.dart';
|
||||
|
||||
|
|
@ -55,6 +57,12 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
|||
|
||||
bool _reachedEndThisSession = false;
|
||||
|
||||
// KOReader sync state
|
||||
String? _koreaderDocumentHash;
|
||||
KoreaderSyncService? _koreaderSyncService;
|
||||
bool _hasSavedOnExit = false;
|
||||
bool _koreaderAuthFailed = false;
|
||||
|
||||
// Gesture tracking for tap vs swipe detection
|
||||
Offset? _pointerDownPosition;
|
||||
DateTime? _pointerDownTime;
|
||||
|
|
@ -66,12 +74,13 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
|||
static const Duration _tapMaxDuration =
|
||||
Duration(milliseconds: 500); // taps complete quickly
|
||||
|
||||
bool _didStartLoad = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Create a new controller for each instance to avoid state retention
|
||||
_epubController = EpubController();
|
||||
_loadEpub();
|
||||
|
||||
// Keep screen awake while reading
|
||||
WakelockPlus.enable();
|
||||
|
|
@ -80,6 +89,18 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
|||
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 {
|
||||
try {
|
||||
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;
|
||||
final l10nFinal = AppLocalizations.of(context);
|
||||
setState(() {
|
||||
|
|
@ -184,9 +255,23 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
bool _hasReceivedNonZeroProgress = false;
|
||||
|
||||
/// Handle page navigation - save progress with CFI
|
||||
void _handleRelocated(EpubLocation 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) {
|
||||
setState(() {
|
||||
_reachedEndThisSession = true;
|
||||
|
|
@ -221,8 +306,16 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
|||
serverId: widget.serverId,
|
||||
);
|
||||
|
||||
final existingProgress =
|
||||
await ref.read(readingProgressProvider(progressKey).future);
|
||||
// Capture all ref-dependent values synchronously before any await.
|
||||
// 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
|
||||
final percentage = location.progress;
|
||||
|
|
@ -240,8 +333,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
|||
});
|
||||
|
||||
// Get cached publication
|
||||
final cachedPub = await ref.read(
|
||||
cachedPublicationProvider(widget.serverId, widget.entry.id).future);
|
||||
final cachedPub = await cachedPubFuture;
|
||||
|
||||
final progress = existingProgress?.copyWith(
|
||||
currentPage: currentPage,
|
||||
|
|
@ -251,6 +343,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
|||
publicationCacheId:
|
||||
cachedPub?.id ?? existingProgress.publicationCacheId,
|
||||
seriesFeedUrl: widget.feedUrl ?? existingProgress.seriesFeedUrl,
|
||||
syncHash: _koreaderDocumentHash ?? existingProgress.syncHash,
|
||||
) ??
|
||||
ReadingProgress(
|
||||
id: const Uuid().v4(),
|
||||
|
|
@ -262,14 +355,43 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
|||
lastReadAt: DateTime.now(),
|
||||
publicationCacheId: cachedPub?.id,
|
||||
seriesFeedUrl: widget.feedUrl,
|
||||
syncHash: _koreaderDocumentHash,
|
||||
);
|
||||
|
||||
await ref
|
||||
.read(readingProgressNotifierProvider.notifier)
|
||||
.saveProgress(progress);
|
||||
await notifier.saveProgress(progress);
|
||||
|
||||
ref.invalidate(readingProgressProvider(progressKey));
|
||||
ref.invalidate(recentlyReadProvider);
|
||||
// Only invalidate providers if the widget is still mounted.
|
||||
// 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
|
||||
|
|
@ -419,7 +541,8 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
|||
return PopScope(
|
||||
canPop: true,
|
||||
onPopInvokedWithResult: (bool didPop, Object? result) {
|
||||
if (didPop) {
|
||||
if (didPop && !_hasSavedOnExit) {
|
||||
_hasSavedOnExit = true;
|
||||
if (_reachedEndThisSession) {
|
||||
_deleteCompletedProgress();
|
||||
} else if (_lastLocation != null) {
|
||||
|
|
@ -431,42 +554,31 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
|||
body: Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: (event) {
|
||||
// Record initial touch position and time
|
||||
_pointerDownPosition = event.position;
|
||||
_pointerDownTime = DateTime.now();
|
||||
_isSwipe = false;
|
||||
},
|
||||
onPointerMove: (event) {
|
||||
// Track movement to detect swipes
|
||||
if (_pointerDownPosition != null) {
|
||||
final delta = (event.position - _pointerDownPosition!).distance;
|
||||
|
||||
// If movement exceeds threshold, mark as swipe (not a tap)
|
||||
if (delta > _swipeThreshold) {
|
||||
_isSwipe = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
// Only toggle on tap (not swipe)
|
||||
if (_pointerDownTime != null && _pointerDownPosition != null) {
|
||||
final duration = DateTime.now().difference(_pointerDownTime!);
|
||||
|
||||
// Check if this was a tap (minimal movement, quick release)
|
||||
if (!_isSwipe && duration < _tapMaxDuration) {
|
||||
// Apply existing position check for app bar exclusion
|
||||
if (_showAppBar) {
|
||||
final appBarHeight = MediaQuery.of(context).padding.top + 56;
|
||||
if (event.position.dy < appBarHeight) {
|
||||
return; // Tap on app bar, don't toggle
|
||||
}
|
||||
}
|
||||
|
||||
_toggleAppBarVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
// Reset tracking state
|
||||
_pointerDownPosition = null;
|
||||
_pointerDownTime = null;
|
||||
_isSwipe = false;
|
||||
|
|
@ -483,18 +595,18 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
|||
return SafeArea(
|
||||
child: EpubViewer(
|
||||
key: ValueKey(
|
||||
'$_viewerKey-${shouldUseDualPage ? "dual" : "single"}'), // Include spread in key to force recreation
|
||||
'$_viewerKey-${shouldUseDualPage ? "dual" : "single"}'),
|
||||
epubController: _epubController!,
|
||||
epubSource: EpubSource.fromFile(_epubFile!),
|
||||
initialCfi: _initialCfi,
|
||||
displaySettings: _buildDisplaySettings(orientation),
|
||||
onEpubLoaded: () {
|
||||
debugPrint('EPUB loaded successfully');
|
||||
_disableEpubJsTapNavigation();
|
||||
},
|
||||
onChaptersLoaded: _handleChaptersLoaded,
|
||||
onRelocated: _handleRelocated,
|
||||
onTextSelected: (selection) {
|
||||
// Future: Handle text selection for highlights/notes
|
||||
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
|
||||
String _colorToHex(Color color) {
|
||||
return '#${color.toARGB32().toRadixString(16).substring(2).toUpperCase()}';
|
||||
|
|
@ -795,12 +954,15 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
|||
// Cancel timers
|
||||
_appBarAutoHideTimer?.cancel();
|
||||
|
||||
// If the user finished the chapter, remove from recently read;
|
||||
// otherwise save progress one final time before disposing.
|
||||
if (_reachedEndThisSession) {
|
||||
_deleteCompletedProgress();
|
||||
} else if (_lastLocation != null) {
|
||||
_saveProgress(_lastLocation!);
|
||||
// Save progress one final time if not already saved by onPopInvokedWithResult.
|
||||
// The guard flag prevents double saves when both onPop and dispose fire.
|
||||
if (!_hasSavedOnExit) {
|
||||
_hasSavedOnExit = true;
|
||||
if (_reachedEndThisSession) {
|
||||
_deleteCompletedProgress();
|
||||
} else if (_lastLocation != null) {
|
||||
_saveProgress(_lastLocation!);
|
||||
}
|
||||
}
|
||||
|
||||
// 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/helpers/snackbar_helper.dart';
|
||||
import 'package:worldhopper/providers/server_provider.dart';
|
||||
import 'package:worldhopper/services/koreader_sync_service.dart';
|
||||
|
||||
/// Screen for adding a new OPDS server
|
||||
class AddServerScreen extends ConsumerStatefulWidget {
|
||||
|
|
@ -25,12 +26,23 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
|
|||
bool _obscurePassword = true;
|
||||
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
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_urlController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_koreaderSyncUrlController.dispose();
|
||||
_koreaderSyncUsernameController.dispose();
|
||||
_koreaderSyncPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -152,7 +164,136 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
|
|||
}
|
||||
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),
|
||||
],
|
||||
|
|
@ -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 {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
|
|
@ -234,6 +430,15 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
|
|||
username: _requiresAuth ? _usernameController.text.trim() : null,
|
||||
password: _requiresAuth ? _passwordController.text : null,
|
||||
createdAt: DateTime.now(),
|
||||
koreaderSyncEnabled: _koreaderSyncEnabled,
|
||||
koreaderSyncUrl: _koreaderSyncEnabled
|
||||
? _koreaderSyncUrlController.text.trim()
|
||||
: null,
|
||||
koreaderSyncUsername: _koreaderSyncEnabled
|
||||
? _koreaderSyncUsernameController.text.trim()
|
||||
: null,
|
||||
koreaderSyncPassword:
|
||||
_koreaderSyncEnabled ? _koreaderSyncPasswordController.text : null,
|
||||
);
|
||||
|
||||
// Save server
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:worldhopper/l10n/app_localizations.dart';
|
|||
import 'package:worldhopper/helpers/snackbar_helper.dart';
|
||||
import 'package:worldhopper/models/opds_server.dart';
|
||||
import 'package:worldhopper/providers/server_provider.dart';
|
||||
import 'package:worldhopper/services/koreader_sync_service.dart';
|
||||
|
||||
/// Screen for editing an existing OPDS server
|
||||
class EditServerScreen extends ConsumerStatefulWidget {
|
||||
|
|
@ -30,12 +31,23 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
|||
bool _isLoading = false;
|
||||
OPDSServer? _originalServer;
|
||||
|
||||
// KOReader sync fields
|
||||
bool _koreaderSyncEnabled = false;
|
||||
final _koreaderSyncUrlController = TextEditingController();
|
||||
final _koreaderSyncUsernameController = TextEditingController();
|
||||
final _koreaderSyncPasswordController = TextEditingController();
|
||||
bool _obscureKoreaderPassword = true;
|
||||
bool _isTestingKoreaderConnection = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_urlController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_koreaderSyncUrlController.dispose();
|
||||
_koreaderSyncUsernameController.dispose();
|
||||
_koreaderSyncPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -68,6 +80,19 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
|||
if (server.password != null) {
|
||||
_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(
|
||||
|
|
@ -178,7 +203,140 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
|||
}
|
||||
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),
|
||||
],
|
||||
|
|
@ -273,6 +431,59 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
|||
'${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 {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
|
|
@ -291,6 +502,15 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
|||
url: _urlController.text.trim(),
|
||||
username: _requiresAuth ? _usernameController.text.trim() : 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
|
||||
|
|
|
|||
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) {
|
||||
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
|
||||
final publicationAsync = ref.watch(
|
||||
cachedPublicationProvider(progress.serverId, progress.publicationId),
|
||||
|
|
@ -53,8 +57,8 @@ class RecentlyReadCard extends ConsumerWidget {
|
|||
// Display with full metadata
|
||||
return GestureDetector(
|
||||
onTap: () => _handleTap(context, ref, publication),
|
||||
onLongPressStart: (details) =>
|
||||
_showContextMenu(context, ref, details, publication),
|
||||
onLongPressStart: (details) => _showContextMenu(
|
||||
context, ref, details, publication, isDeveloperMode),
|
||||
child: Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
|
|
@ -284,6 +288,7 @@ class RecentlyReadCard extends ConsumerWidget {
|
|||
WidgetRef ref,
|
||||
LongPressStartDetails details,
|
||||
dynamic publication,
|
||||
bool isDeveloperMode,
|
||||
) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final RenderBox overlay =
|
||||
|
|
@ -293,8 +298,6 @@ class RecentlyReadCard extends ConsumerWidget {
|
|||
Offset.zero & overlay.size,
|
||||
);
|
||||
|
||||
final isDeveloperMode = ref.read(developerModeNotifierProvider);
|
||||
|
||||
final items = <PopupMenuEntry<String>>[];
|
||||
|
||||
if (isDeveloperMode) {
|
||||
|
|
@ -419,7 +422,13 @@ class RecentlyReadCard extends ConsumerWidget {
|
|||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SelectableText(
|
||||
'// ReadingProgress\n$progressJson\n\n// Publication\n$publicationJson',
|
||||
[
|
||||
'// ReadingProgress',
|
||||
progressJson,
|
||||
'',
|
||||
'// Publication',
|
||||
publicationJson,
|
||||
].join('\n'),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ packages:
|
|||
source: hosted
|
||||
version: "3.1.2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
|
|
|
|||
|
|
@ -72,6 +72,9 @@ dependencies:
|
|||
# Encryption for credentials
|
||||
encrypt: ^5.0.3
|
||||
|
||||
# Cryptographic hashing (MD5 for KOReader sync document IDs)
|
||||
crypto: ^3.0.3
|
||||
|
||||
# Icons
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue