claude refactorings, flutter best practices, platform dependent changes, general cleanup

This commit is contained in:
2026-05-06 11:58:50 +02:00
parent 4b1d4379a0
commit 4e1272aba9
281 changed files with 1948 additions and 1041 deletions
+20
View File
@@ -0,0 +1,20 @@
import 'dart:async';
/// App-wide pub/sub channel for cache invalidations. Producers (e.g. webdav
/// move/delete handlers) call [notifyListFiles] after they have dropped the
/// cached listing for a folder so that any [_FilesView] currently sitting on
/// that folder — possibly in the background, beneath a child route — can
/// refresh itself instead of showing the stale snapshot it loaded earlier.
class CacheInvalidationBus {
CacheInvalidationBus._();
static final StreamController<String> _listFiles = StreamController<String>.broadcast();
/// Emits the invalidated `pathString` (in `FilesBloc` format: relative,
/// no leading or trailing slash; root is '/').
static Stream<String> get listFilesStream => _listFiles.stream;
static void notifyListFiles(String pathString) {
_listFiles.add(pathString);
}
}
+34
View File
@@ -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();
}
}
+146
View File
@@ -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);
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/foundation.dart';
import '../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
enum FileClipboardOperation { cut, copy }
/// In-memory clipboard for file operations within the app. Mirrors the
/// cut/copy/paste pattern of native file managers (iOS Files, Android Files,
/// Finder). Contents are not persisted across app restarts.
///
/// Listen via [ChangeNotifier] (e.g. `ListenableBuilder`) to render a paste
/// banner when [isEmpty] is false.
class FileClipboard extends ChangeNotifier {
FileClipboard._();
static final FileClipboard instance = FileClipboard._();
FileClipboardOperation? _operation;
List<CacheableFile> _files = const [];
FileClipboardOperation? get operation => _operation;
List<CacheableFile> get files => List.unmodifiable(_files);
bool get isEmpty => _files.isEmpty;
void cut(List<CacheableFile> files) {
if (files.isEmpty) return;
_operation = FileClipboardOperation.cut;
_files = List.of(files);
notifyListeners();
}
void copy(List<CacheableFile> files) {
if (files.isEmpty) return;
_operation = FileClipboardOperation.copy;
_files = List.of(files);
notifyListeners();
}
void clear() {
if (_operation == null && _files.isEmpty) return;
_operation = null;
_files = const [];
notifyListeners();
}
}
+47
View File
@@ -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();
}
}
-23
View File
@@ -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);
}
}