Files
Holzleitner-Lieferservice-App/lib/data/cache/attachment_cache.dart
Dennis Nemec a9bf8ecdd1 Final commit.
2026-06-01 17:12:28 +02:00

163 lines
4.9 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}
}