claude refactorings, flutter best practices, platform dependent changes, general cleanup
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user