Files
Client/lib/utils/download_manager.dart
T

147 lines
4.4 KiB
Dart

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<DownloadStatus> 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<String, DownloadJob> _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<DownloadJob> 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);
}
}