Final commit.

This commit is contained in:
Dennis Nemec
2026-06-01 17:12:28 +02:00
parent 3ecbc82885
commit a9bf8ecdd1
385 changed files with 29081 additions and 12089 deletions

162
lib/data/cache/attachment_cache.dart vendored Normal file
View File

@ -0,0 +1,162 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:path_provider/path_provider.dart';
/// Persistenter Datei-Cache für heruntergeladene Attachment-Vorschauen.
///
/// **Warum überhaupt ein Cache:** Vorschaubilder werden über
/// `GET /attachments/{id}` aus DOCUframe gerendert — das kostet Zeit und
/// Bandbreite und funktioniert offline gar nicht. Einmal geholte Varianten
/// landen deshalb auf der Platte und werden danach lokal bedient.
///
/// **Warum keine Content-Revalidierung:** Attachments sind unveränderlich.
/// Ein hochgeladenes Bild (DOCUframe-Objekt) ändert seinen Inhalt nie. Eine
/// einmal gecachte Variante ist daher dauerhaft gültig — kein ETag, kein
/// If-None-Match, kein HEAD nötig. Das Einzige, was den Cache betrifft, ist
/// das **Löschen** eines Attachments; dafür gibt es [retainOnly], das den
/// Cache auf die Menge noch gültiger Attachment-IDs eindampft.
///
/// **Datei-Layout:** Pro Attachment können mehrere *Varianten* liegen
/// (Thumbnail 600×600, Vollbild 2048×2048, …). Der Dateiname kodiert ID und
/// Render-Parameter:
///
/// `{attachmentId}__{w}x{h}_q{q}_{ext}`
///
/// Die ID steht vorne und ist als UUID frei von `__`, sodass [retainOnly] sie
/// zuverlässig wieder herausschneiden kann.
///
/// Der Cache ist durchweg **best-effort**: Lese-/Schreib-/Lösch-Fehler werden
/// geschluckt und führen höchstens zu einem erneuten Download, nie zu einem
/// Crash.
class AttachmentCache {
AttachmentCache();
static const _subdir = 'attachment_previews';
static const _separator = '__';
/// Einmal aufgelöstes Verzeichnis — `getApplicationCacheDirectory` nicht
/// bei jedem Zugriff neu abfragen.
Future<Directory>? _dirFuture;
Future<Directory> _dir() => _dirFuture ??= _resolveDir();
Future<Directory> _resolveDir() async {
final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/$_subdir');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
String _fileName({
required String attachmentId,
required int w,
required int h,
required int q,
required String ext,
}) =>
'$attachmentId$_separator${w}x${h}_q${q}_$ext';
Future<File> _file({
required String attachmentId,
required int w,
required int h,
required int q,
required String ext,
}) async {
final dir = await _dir();
return File(
'${dir.path}/${_fileName(attachmentId: attachmentId, w: w, h: h, q: q, ext: ext)}',
);
}
/// Liest eine gecachte Variante. `null`, wenn nichts da ist oder das Lesen
/// scheitert — der Aufrufer lädt dann frisch.
Future<Uint8List?> read({
required String attachmentId,
required int w,
required int h,
required int q,
required String ext,
}) async {
try {
final f = await _file(
attachmentId: attachmentId,
w: w,
h: h,
q: q,
ext: ext,
);
if (!await f.exists()) return null;
final bytes = await f.readAsBytes();
return bytes.isEmpty ? null : bytes;
} catch (_) {
return null;
}
}
/// Schreibt eine Variante. Atomar via temp-Datei + rename, damit ein
/// paralleler Read nie ein halb geschriebenes File sieht. Leere Bytes
/// werden ignoriert (kaputter Download soll keinen leeren Cache-Eintrag
/// hinterlassen).
Future<void> write({
required String attachmentId,
required int w,
required int h,
required int q,
required String ext,
required Uint8List bytes,
}) async {
if (bytes.isEmpty) return;
try {
final f = await _file(
attachmentId: attachmentId,
w: w,
h: h,
q: q,
ext: ext,
);
final tmp = File('${f.path}.tmp');
await tmp.writeAsBytes(bytes, flush: true);
await tmp.rename(f.path);
} catch (_) {
// best-effort
}
}
/// Entfernt alle gecachten Dateien, deren Attachment-ID **nicht** in
/// [validAttachmentIds] vorkommt. So verschwinden die Vorschauen gelöschter
/// Foto-Notizen beim nächsten Tour-Load aus dem Cache. Verwaiste
/// temp-Dateien (abgebrochene Writes) werden immer mit entfernt.
Future<void> retainOnly(Set<String> validAttachmentIds) async {
try {
final dir = await _dir();
if (!await dir.exists()) return;
await for (final entity in dir.list()) {
if (entity is! File) continue;
final name = entity.uri.pathSegments.last;
if (name.endsWith('.tmp')) {
await _deleteQuietly(entity);
continue;
}
final sepIdx = name.indexOf(_separator);
final id = sepIdx == -1 ? name : name.substring(0, sepIdx);
if (!validAttachmentIds.contains(id)) {
await _deleteQuietly(entity);
}
}
} catch (_) {
// best-effort — Pruning darf nie den Tour-Load stören
}
}
Future<void> _deleteQuietly(File f) async {
try {
await f.delete();
} catch (_) {
// ignore
}
}
}