worldhopper/lib/models/downloaded_series.dart
Felipe M. 30df288552
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
feat: add offline series download with local reading support
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>
2026-02-20 23:40:15 +01:00

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