147 lines
4.4 KiB
Dart
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);
|
|
}
|
|
}
|