All checks were successful
ci/woodpecker/tag/release Pipeline was successful
Enable downloading entire series for offline reading. Includes database tables and migration for downloaded series/chapters, download orchestration with progress tracking, per-chapter EPUB and image stream downloading, a Downloaded section in the library, and offline-first reader support. Key changes: - DB migration v11 with downloaded_series and downloaded_chapters tables - Series download service with pagination, cancellation, and progress stream - Feed screen download button with status indicators and completion feedback - Library screen Downloaded section with series cards and detail screen - Reader pre-caching using local files (including two-page spread mode) - Next-in-series navigation resolves from downloaded chapters when offline - Skip image downloads and progress sync when device is offline - Settings screen shows download storage usage with clear option Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
118 lines
3.5 KiB
Dart
118 lines
3.5 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
|
|
|
part 'downloaded_series.freezed.dart';
|
|
part 'downloaded_series.g.dart';
|
|
|
|
/// Status of a download operation
|
|
enum DownloadStatus {
|
|
pending,
|
|
downloading,
|
|
complete,
|
|
partial,
|
|
failed;
|
|
|
|
static DownloadStatus fromName(String name) {
|
|
return DownloadStatus.values.firstWhere(
|
|
(e) => e.name == name,
|
|
orElse: () {
|
|
debugPrint('Unknown DownloadStatus "$name", defaulting to pending');
|
|
return DownloadStatus.pending;
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Content type of a downloaded chapter
|
|
enum ChapterContentType {
|
|
epub,
|
|
imageStream;
|
|
|
|
static ChapterContentType fromName(String name) {
|
|
return ChapterContentType.values.firstWhere(
|
|
(e) => e.name == name,
|
|
orElse: () {
|
|
debugPrint('Unknown ChapterContentType "$name", defaulting to epub');
|
|
return ChapterContentType.epub;
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Represents a downloaded series for offline reading
|
|
@freezed
|
|
class DownloadedSeries with _$DownloadedSeries {
|
|
const factory DownloadedSeries({
|
|
required String id,
|
|
required String serverId,
|
|
required String seriesFeedUrl,
|
|
required String title,
|
|
String? coverUrl,
|
|
String? coverPath,
|
|
String? thumbnailUrl,
|
|
String? thumbnailPath,
|
|
@Default(DownloadStatus.pending) DownloadStatus status,
|
|
@Default(0) int totalChapters,
|
|
@Default(0) int downloadedChaptersCount,
|
|
DateTime? downloadedAt,
|
|
required DateTime updatedAt,
|
|
}) = _DownloadedSeries;
|
|
|
|
const DownloadedSeries._();
|
|
|
|
factory DownloadedSeries.fromJson(Map<String, dynamic> json) =>
|
|
_$DownloadedSeriesFromJson(json);
|
|
|
|
/// Create from database row
|
|
factory DownloadedSeries.fromDatabase(Map<String, dynamic> row) {
|
|
return DownloadedSeries(
|
|
id: row['id'] as String,
|
|
serverId: row['server_id'] as String,
|
|
seriesFeedUrl: row['series_feed_url'] as String,
|
|
title: row['title'] as String,
|
|
coverUrl: row['cover_url'] as String?,
|
|
coverPath: row['cover_path'] as String?,
|
|
thumbnailUrl: row['thumbnail_url'] as String?,
|
|
thumbnailPath: row['thumbnail_path'] as String?,
|
|
status: DownloadStatus.fromName(row['status'] as String),
|
|
totalChapters: row['total_chapters'] as int,
|
|
downloadedChaptersCount: row['downloaded_chapters_count'] as int,
|
|
downloadedAt: row['downloaded_at'] != null
|
|
? DateTime.fromMillisecondsSinceEpoch(row['downloaded_at'] as int)
|
|
: null,
|
|
updatedAt: DateTime.fromMillisecondsSinceEpoch(row['updated_at'] as int),
|
|
);
|
|
}
|
|
|
|
/// Whether the download is fully complete
|
|
bool get isComplete => status == DownloadStatus.complete;
|
|
|
|
/// Whether the download is in progress
|
|
bool get isDownloading => status == DownloadStatus.downloading;
|
|
|
|
/// Progress fraction (0.0 to 1.0)
|
|
double get progress {
|
|
if (totalChapters == 0) return 0.0;
|
|
return (downloadedChaptersCount / totalChapters).clamp(0.0, 1.0);
|
|
}
|
|
}
|
|
|
|
extension DownloadedSeriesX on DownloadedSeries {
|
|
Map<String, dynamic> toDatabase() {
|
|
return {
|
|
'id': id,
|
|
'server_id': serverId,
|
|
'series_feed_url': seriesFeedUrl,
|
|
'title': title,
|
|
'cover_url': coverUrl,
|
|
'cover_path': coverPath,
|
|
'thumbnail_url': thumbnailUrl,
|
|
'thumbnail_path': thumbnailPath,
|
|
'status': status.name,
|
|
'total_chapters': totalChapters,
|
|
'downloaded_chapters_count': downloadedChaptersCount,
|
|
'downloaded_at': downloadedAt?.millisecondsSinceEpoch,
|
|
'updated_at': updatedAt.millisecondsSinceEpoch,
|
|
};
|
|
}
|
|
}
|