claude refactorings, flutter best practices, platform dependent changes, general cleanup
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Static map-based debouncer/throttler. Replaces the `easy_debounce` package
|
||||
/// with a minimal in-house implementation: each unique [tag] tracks its own
|
||||
/// pending Timer and gating flag.
|
||||
class Debouncer {
|
||||
Debouncer._();
|
||||
|
||||
static final Map<String, Timer> _debounceTimers = {};
|
||||
static final Map<String, Timer> _throttleTimers = {};
|
||||
|
||||
/// Coalesces calls under [tag]: the [action] runs once [delay] has elapsed
|
||||
/// without further calls for the same tag.
|
||||
static void debounce(String tag, Duration delay, void Function() action) {
|
||||
_debounceTimers[tag]?.cancel();
|
||||
_debounceTimers[tag] = Timer(delay, () {
|
||||
_debounceTimers.remove(tag);
|
||||
action();
|
||||
});
|
||||
}
|
||||
|
||||
/// Runs [action] immediately and ignores subsequent calls under the same
|
||||
/// [tag] until [duration] has elapsed.
|
||||
static void throttle(String tag, Duration duration, void Function() action) {
|
||||
if (_throttleTimers.containsKey(tag)) return;
|
||||
_throttleTimers[tag] = Timer(duration, () => _throttleTimers.remove(tag));
|
||||
action();
|
||||
}
|
||||
|
||||
static void cancel(String tag) {
|
||||
_debounceTimers.remove(tag)?.cancel();
|
||||
_throttleTimers.remove(tag)?.cancel();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// Lightweight cancel handle around a single `Dio.download` call. The download
|
||||
/// itself is started by [run]; the handle returns synchronously so callers can
|
||||
/// install it into shared state before the first progress event can fire.
|
||||
class FileDownloader {
|
||||
FileDownloader();
|
||||
|
||||
final CancelToken _cancelToken = CancelToken();
|
||||
bool _cancelled = false;
|
||||
|
||||
bool get isCancelled => _cancelled;
|
||||
|
||||
void cancel() {
|
||||
if (_cancelled) return;
|
||||
_cancelled = true;
|
||||
_cancelToken.cancel('user cancelled');
|
||||
}
|
||||
|
||||
/// Kicks off the download. Returns immediately; the download progresses in
|
||||
/// the background and events are delivered via callbacks. Callbacks are not
|
||||
/// invoked once [cancel] has been called.
|
||||
void run({
|
||||
required Dio client,
|
||||
required String url,
|
||||
required String savePath,
|
||||
required void Function(double percent) onProgress,
|
||||
required void Function() onDone,
|
||||
required void Function(Object error) onError,
|
||||
}) {
|
||||
client.download(
|
||||
url,
|
||||
savePath,
|
||||
cancelToken: _cancelToken,
|
||||
onReceiveProgress: (received, total) {
|
||||
if (_cancelled || total <= 0) return;
|
||||
onProgress((received / total) * 100);
|
||||
},
|
||||
).then((_) {
|
||||
if (_cancelled) return;
|
||||
onDone();
|
||||
}).catchError((Object error) {
|
||||
if (_cancelled) return;
|
||||
onError(error);
|
||||
}).ignore();
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
// only tested on android!
|
||||
class FileSaver {
|
||||
static Future<String> getExternalDocumentPath() async {
|
||||
var permission = await Permission.storage.status;
|
||||
if(!permission.isGranted) {
|
||||
await Permission.storage.request();
|
||||
}
|
||||
var directory = Directory('/storage/emulated/0/Download');
|
||||
final externalPath = directory.path;
|
||||
await Directory(externalPath).create(recursive: true);
|
||||
return externalPath;
|
||||
}
|
||||
|
||||
static Future<File> writeBytes(List<int> bytes, String name) async {
|
||||
final path = await getExternalDocumentPath();
|
||||
var file = File('$path/$name');
|
||||
return file.writeAsBytes(bytes);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user