worldhopper/lib/services/download_storage_service.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

85 lines
2.8 KiB
Dart

import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
/// Manages the persistent downloads directory structure
class DownloadStorageService {
/// Get the root downloads directory
Future<Directory> getDownloadsDir() async {
final appDir = await getApplicationDocumentsDirectory();
final downloadsDir = Directory(path.join(appDir.path, 'downloads'));
if (!await downloadsDir.exists()) {
await downloadsDir.create(recursive: true);
}
return downloadsDir;
}
/// Get the directory for a specific series
Future<Directory> getSeriesDir(String seriesId) async {
final downloadsDir = await getDownloadsDir();
final seriesDir = Directory(path.join(downloadsDir.path, seriesId));
if (!await seriesDir.exists()) {
await seriesDir.create(recursive: true);
}
return seriesDir;
}
/// Get the directory for an image stream chapter
Future<Directory> getChapterDir(String seriesId, String chapterId) async {
final seriesDir = await getSeriesDir(seriesId);
final chapterDir = Directory(path.join(seriesDir.path, chapterId));
if (!await chapterDir.exists()) {
await chapterDir.create(recursive: true);
}
return chapterDir;
}
/// Get the file path for an EPUB chapter
Future<String> getEpubPath(String seriesId, String chapterId) async {
final seriesDir = await getSeriesDir(seriesId);
return path.join(seriesDir.path, '$chapterId.epub');
}
/// Delete all files for a series
Future<void> deleteSeriesFiles(String seriesId) async {
final downloadsDir = await getDownloadsDir();
final seriesDir = Directory(path.join(downloadsDir.path, seriesId));
if (await seriesDir.exists()) {
await seriesDir.delete(recursive: true);
}
}
/// Delete files for a single chapter
Future<void> deleteChapterFiles(
String seriesId, String chapterId, bool isEpub) async {
if (isEpub) {
final epubPath = await getEpubPath(seriesId, chapterId);
final file = File(epubPath);
if (await file.exists()) {
await file.delete();
}
} else {
final downloadsDir = await getDownloadsDir();
final chapterDir =
Directory(path.join(downloadsDir.path, seriesId, chapterId));
if (await chapterDir.exists()) {
await chapterDir.delete(recursive: true);
}
}
}
/// Get total disk usage for all downloads
Future<int> getTotalDownloadSize() async {
final downloadsDir = await getDownloadsDir();
if (!await downloadsDir.exists()) return 0;
int totalSize = 0;
await for (final entity
in downloadsDir.list(recursive: true, followLinks: false)) {
if (entity is File) {
totalSize += await entity.length();
}
}
return totalSize;
}
}