import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:worldhopper/database/database.dart'; import 'package:worldhopper/models/downloaded_chapter.dart'; import 'package:worldhopper/models/downloaded_series.dart'; import 'package:worldhopper/models/entry.dart'; import 'package:worldhopper/models/feed.dart'; import 'package:worldhopper/models/server.dart'; import 'package:worldhopper/providers/feed_provider.dart'; import 'package:worldhopper/repositories/downloaded_chapters_repository.dart'; import 'package:worldhopper/repositories/downloaded_series_repository.dart'; import 'package:worldhopper/repositories/server_repository.dart'; import 'package:worldhopper/services/download_storage_service.dart'; import 'package:worldhopper/services/series_download_service.dart'; // --- Repository providers --- final downloadedSeriesRepositoryProvider = Provider((ref) { return DownloadedSeriesRepository(DatabaseHelper.instance); }); final downloadedChaptersRepositoryProvider = Provider((ref) { return DownloadedChaptersRepository(DatabaseHelper.instance); }); // --- Service providers --- final downloadStorageServiceProvider = Provider((ref) { return DownloadStorageService(); }); final seriesDownloadServiceProvider = Provider((ref) { final seriesRepo = ref.watch(downloadedSeriesRepositoryProvider); final chaptersRepo = ref.watch(downloadedChaptersRepositoryProvider); final opdsRepo = ref.watch(feedRepositoryProvider); final service = SeriesDownloadService( seriesRepo: seriesRepo, chaptersRepo: chaptersRepo, opdsRepo: opdsRepo, ); ref.onDispose(() => service.dispose()); return service; }); // --- Polling provider --- /// Whether download polling is active (enables auto-refresh of download lists) final downloadPollingActiveProvider = StateProvider((ref) => false); /// Check if there are any active downloads in the DB final hasActiveDownloadsProvider = FutureProvider((ref) async { final repository = ref.watch(downloadedSeriesRepositoryProvider); final series = await repository.getByStatus(DownloadStatus.downloading); return series.isNotEmpty; }); /// Server lookup by ID (needed for retry operations) final serverByIdProvider = FutureProvider.family( (ref, serverId) async { final repo = ServerRepository(); return await repo.getServer(serverId); }, ); // --- Data providers --- /// All downloaded series for the library section final downloadedSeriesListProvider = FutureProvider>((ref) async { final repository = ref.watch(downloadedSeriesRepositoryProvider); final isPolling = ref.watch(downloadPollingActiveProvider); if (isPolling) { Timer? timer; timer = Timer.periodic(const Duration(seconds: 2), (_) { ref.invalidateSelf(); }); ref.onDispose(() => timer?.cancel()); } return await repository.getAll(); }); /// Chapters for a specific downloaded series final downloadedChaptersProvider = FutureProvider.family, String>( (ref, seriesId) async { final repository = ref.watch(downloadedChaptersRepositoryProvider); final isPolling = ref.watch(downloadPollingActiveProvider); if (isPolling) { Timer? timer; timer = Timer.periodic(const Duration(seconds: 2), (_) { ref.invalidateSelf(); }); ref.onDispose(() => timer?.cancel()); } return await repository.getChaptersForSeries(seriesId); }, ); /// Check if a series feed URL is downloaded (for badges on browse screen) final seriesDownloadStatusProvider = FutureProvider.family( (ref, key) async { final repository = ref.watch(downloadedSeriesRepositoryProvider); return await repository.getByFeedUrl(key.serverId, key.feedUrl); }, ); /// Check if a specific chapter is downloaded (for badges) final chapterDownloadStatusProvider = FutureProvider.family( (ref, key) async { final repository = ref.watch(downloadedChaptersRepositoryProvider); return await repository.getByEntryId(key.serverId, key.entryId); }, ); /// Find the next downloaded chapter after the current one in the same series final nextDownloadedChapterProvider = FutureProvider.family( (ref, key) async { final repository = ref.watch(downloadedChaptersRepositoryProvider); final current = await repository.getByEntryId(key.serverId, key.entryId); if (current == null) return null; final chapters = await repository.getChaptersForSeries(current.seriesId); final currentIndex = chapters.indexWhere((c) => c.id == current.id); if (currentIndex < 0 || currentIndex >= chapters.length - 1) return null; final next = chapters[currentIndex + 1]; return next.isComplete ? next : null; }, ); /// Stream of active download progress final activeDownloadProgressProvider = StreamProvider((ref) { final service = ref.watch(seriesDownloadServiceProvider); return service.progressStream; }); // --- Notifier --- /// Notifier for triggering download/cancel/delete actions class DownloadNotifier extends StateNotifier> { final SeriesDownloadService _downloadService; final Ref _ref; DownloadNotifier(this._downloadService, this._ref) : super(const AsyncValue.data(null)); /// Start downloading an entire series Future startSeriesDownload({ required Server server, required String feedUrl, required String feedTitle, required Feed initialFeed, String? coverUrl, String? thumbnailUrl, }) async { state = const AsyncValue.loading(); _ref.read(downloadPollingActiveProvider.notifier).state = true; try { state = await AsyncValue.guard(() async { await _downloadService.startSeriesDownload( server: server, feedUrl: feedUrl, feedTitle: feedTitle, initialFeed: initialFeed, coverUrl: coverUrl, thumbnailUrl: thumbnailUrl, ); _invalidateProviders(); }); } finally { _ref.read(downloadPollingActiveProvider.notifier).state = false; } } /// Download a single chapter Future downloadChapter({ required Server server, required Entry entry, required String seriesId, required String feedUrl, required String feedTitle, }) async { state = const AsyncValue.loading(); state = await AsyncValue.guard(() async { await _downloadService.downloadSingleChapter( server: server, entry: entry, seriesId: seriesId, feedUrl: feedUrl, feedTitle: feedTitle, ); _invalidateProviders(); }); } /// Cancel an active download void cancelDownload(String id) { _downloadService.cancelDownload(id); _invalidateProviders(); } /// Delete a downloaded series Future deleteSeries(String seriesId) async { state = const AsyncValue.loading(); state = await AsyncValue.guard(() async { await _downloadService.deleteSeries(seriesId); _invalidateProviders(); }); } /// Delete a single downloaded chapter Future deleteChapter(DownloadedChapter chapter) async { state = const AsyncValue.loading(); state = await AsyncValue.guard(() async { await _downloadService.deleteChapter(chapter); _invalidateProviders(); }); } /// Retry a single failed chapter Future retryChapter({ required Server server, required DownloadedChapter chapter, }) async { _ref.read(downloadPollingActiveProvider.notifier).state = true; try { state = await AsyncValue.guard(() async { await _downloadService.retryChapter(server: server, chapter: chapter); _invalidateProviders(); }); } finally { _ref.read(downloadPollingActiveProvider.notifier).state = false; } } /// Retry all failed chapters in a series Future retryFailedChapters({ required Server server, required String seriesId, }) async { _ref.read(downloadPollingActiveProvider.notifier).state = true; try { state = await AsyncValue.guard(() async { await _downloadService.retryFailedChapters( server: server, seriesId: seriesId, ); _invalidateProviders(); }); } finally { _ref.read(downloadPollingActiveProvider.notifier).state = false; } } void _invalidateProviders() { _ref.invalidate(downloadedSeriesListProvider); _ref.invalidate(seriesDownloadStatusProvider); _ref.invalidate(chapterDownloadStatusProvider); } } final downloadNotifierProvider = StateNotifierProvider>((ref) { final downloadService = ref.watch(seriesDownloadServiceProvider); return DownloadNotifier(downloadService, ref); }); // --- Key classes --- class SeriesDownloadKey { final String serverId; final String feedUrl; const SeriesDownloadKey({ required this.serverId, required this.feedUrl, }); @override bool operator ==(Object other) => identical(this, other) || other is SeriesDownloadKey && runtimeType == other.runtimeType && serverId == other.serverId && feedUrl == other.feedUrl; @override int get hashCode => Object.hash(serverId, feedUrl); } class ChapterDownloadKey { final String serverId; final String entryId; const ChapterDownloadKey({ required this.serverId, required this.entryId, }); @override bool operator ==(Object other) => identical(this, other) || other is ChapterDownloadKey && runtimeType == other.runtimeType && serverId == other.serverId && entryId == other.entryId; @override int get hashCode => Object.hash(serverId, entryId); }