import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import '../api/marianumcloud/webdav/webdav_api.dart'; import '../model/account_data.dart'; import 'file_downloader.dart'; /// Snapshot of a single download's lifecycle. UI widgets rebuild whenever the /// owning [DownloadJob.status] notifier emits a new instance. sealed class DownloadStatus { const DownloadStatus(); } class DownloadInProgress extends DownloadStatus { const DownloadInProgress(this.percent); final double percent; } class DownloadDone extends DownloadStatus { const DownloadDone(this.localPath); final String localPath; } class DownloadCancelled extends DownloadStatus { const DownloadCancelled(); } class DownloadFailed extends DownloadStatus { const DownloadFailed(this.message); final String message; } /// Tracks a single in-flight or finished download. Survives widget dispose so /// that re-entering the screen reattaches to the same job. class DownloadJob { DownloadJob({ required this.remotePath, required this.name, required this.localPath, required FileDownloader downloader, }) : _downloader = downloader; final String remotePath; final String name; final String localPath; final FileDownloader _downloader; final ValueNotifier status = ValueNotifier(const DownloadInProgress(0)); bool _disposed = false; bool get isFinished => status.value is DownloadDone || status.value is DownloadFailed || status.value is DownloadCancelled; void cancel() { if (isFinished) return; _downloader.cancel(); status.value = const DownloadCancelled(); } void _dispose() { if (_disposed) return; _disposed = true; status.dispose(); } } /// Central in-memory registry for downloads. Keyed by remote path so a file /// triggered from multiple screens (Files + Chat) reuses one job. /// /// Not persistent across app restarts — restarting abandons in-flight /// downloads and partial files are cleaned on next start attempt. class DownloadManager { DownloadManager._(); static final DownloadManager instance = DownloadManager._(); final Map _jobs = {}; /// Active or recently finished job for [remotePath], or null if none. DownloadJob? jobFor(String remotePath) => _jobs[remotePath]; /// Returns the existing job if a download is in progress for [remotePath], /// otherwise starts a new one. Caller listens on [DownloadJob.status]. Future start({required String remotePath, required String name}) async { final existing = _jobs[remotePath]; if (existing != null && !existing.isFinished) return existing; if (existing != null) { _jobs.remove(remotePath); scheduleMicrotask(existing._dispose); } final tempDir = await getTemporaryDirectory(); final encodedPath = Uri.encodeComponent(remotePath).replaceAll('%2F', '/'); final localPath = '${tempDir.path}${Platform.pathSeparator}$name'; final downloader = FileDownloader(); final job = DownloadJob( remotePath: remotePath, name: name, localPath: localPath, downloader: downloader, ); _jobs[remotePath] = job; downloader.run( client: Dio(BaseOptions(headers: AccountData().authHeaders())), url: '${WebdavApi.buildWebdavUrl()}$encodedPath', savePath: localPath, onProgress: (percent) { if (job.isFinished) return; job.status.value = DownloadInProgress(percent); }, onDone: () { if (job.isFinished) return; job.status.value = DownloadDone(localPath); }, onError: (error) { if (job.isFinished) return; try { File(localPath).deleteSync(); } on FileSystemException { // partial file may not exist — ignore } job.status.value = DownloadFailed(error.toString()); }, ); return job; } /// Removes a finished job from the registry. Safe to call from a status /// listener: actual disposal of the underlying notifier is deferred to the /// next microtask so the in-flight `notifyListeners` cycle can finish before /// the notifier is destroyed. Active (unfinished) jobs are left untouched. void clear(String remotePath) { final job = _jobs[remotePath]; if (job == null || !job.isFinished) return; _jobs.remove(remotePath); scheduleMicrotask(job._dispose); } }