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

185 lines
5.4 KiB
Dart

import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi;
import 'package:hl_lieferservice/data/cache/attachment_cache.dart';
import 'package:hl_lieferservice/main.dart' show locator;
/// Lädt und zeigt ein Attachment-Vorschaubild über
/// `GET /attachments/{id}` an. Nutzt die geteilte, authentifizierte
/// Dio-Instanz (`HolzleitnerApi.dio`) — der Auth-Interceptor hängt den
/// Bearer-Token an, ein generierter Client-Methodenaufruf ist für die
/// Binär-Antwort nicht zuverlässig.
///
/// Reines Lese-Widget (kein Bloc): Bild-Anzeige ist kein App-State.
class AttachmentImage extends StatefulWidget {
const AttachmentImage({
super.key,
required this.attachmentId,
this.width = 1024,
this.height = 1024,
this.quality = 80,
this.fit = BoxFit.cover,
this.deleted = false,
});
/// Unsere Attachment-UUID (steht in `DeliveryNote.imageAttachment`).
final String attachmentId;
/// Wenn `true`: die lokale Bilddatei wurde nach dem Report-Upload gelöscht
/// (das Bild steckt im Lieferbericht in DOCUframe). Statt eines Downloads
/// wird ein Hinweis gezeigt.
final bool deleted;
/// Angefragte Vorschau-Abmessungen (Backend rendert per DOCUframe).
final int width;
final int height;
final int quality;
final BoxFit fit;
@override
State<AttachmentImage> createState() => _AttachmentImageState();
}
class _AttachmentImageState extends State<AttachmentImage> {
late Future<Uint8List> _future;
@override
void initState() {
super.initState();
// Gelöschte Anhänge nicht laden — es gibt keine lokale Datei mehr.
_future = widget.deleted ? Future.value(Uint8List(0)) : _load();
}
@override
void didUpdateWidget(AttachmentImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.attachmentId != widget.attachmentId ||
oldWidget.width != widget.width ||
oldWidget.height != widget.height ||
oldWidget.quality != widget.quality) {
_future = _load();
}
}
static const _ext = 'jpeg';
Future<Uint8List> _load() async {
final cache = locator<AttachmentCache>();
// 1. Disk-Cache zuerst — Attachments sind unveränderlich, ein Treffer
// ist also immer gültig (auch offline).
final cached = await cache.read(
attachmentId: widget.attachmentId,
w: widget.width,
h: widget.height,
q: widget.quality,
ext: _ext,
);
if (cached != null) return cached;
// 2. Miss → über die authentifizierte Dio-Instanz aus DOCUframe holen.
final api = locator<HolzleitnerApi>();
final response = await api.dio.get<List<int>>(
'/attachments/${widget.attachmentId}',
queryParameters: {
'w': widget.width,
'h': widget.height,
'q': widget.quality,
'ext': _ext,
},
options: Options(responseType: ResponseType.bytes),
);
final bytes = Uint8List.fromList(response.data ?? const []);
// 3. Erfolgreichen Download persistieren (best-effort, blockiert die
// Anzeige nicht — write schluckt Fehler).
if (bytes.isNotEmpty) {
await cache.write(
attachmentId: widget.attachmentId,
w: widget.width,
h: widget.height,
q: widget.quality,
ext: _ext,
bytes: bytes,
);
}
return bytes;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (widget.deleted) {
return _DeletedHint(fit: widget.fit);
}
return FutureBuilder<Uint8List>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
if (snapshot.hasError ||
snapshot.data == null ||
snapshot.data!.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Icon(
Icons.broken_image_outlined,
color: theme.colorScheme.onSurfaceVariant,
),
),
);
}
return Image.memory(snapshot.data!, fit: widget.fit);
},
);
}
}
/// Hinweis für gelöschte Bild-Anhänge: das Bild liegt im Lieferbericht.
class _DeletedHint extends StatelessWidget {
const _DeletedHint({required this.fit});
final BoxFit fit;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
color: theme.colorScheme.surfaceContainerHighest,
alignment: Alignment.center,
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.picture_as_pdf_outlined,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 8),
Text(
'Bild im Lieferbericht enthalten',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}