Files
pyramid/lib/widgets/mxc_image.dart
T
Bernd Steckmeister 25ed765a03 wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.)
als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen
Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-03 05:47:18 +02:00

206 lines
5.9 KiB
Dart

import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/media_cache.dart';
class MxcAvatar extends StatefulWidget {
final Uri? mxcUri;
final Client client;
final double size;
final BorderRadius? borderRadius;
final Widget Function(BuildContext) placeholder;
const MxcAvatar({
super.key,
required this.mxcUri,
required this.client,
required this.size,
required this.placeholder,
this.borderRadius,
});
@override
State<MxcAvatar> createState() => _MxcAvatarState();
}
class _MxcAvatarState extends State<MxcAvatar> {
late Future<Uint8List?> _future;
@override
void initState() {
super.initState();
_future = _load();
}
@override
void didUpdateWidget(MxcAvatar old) {
super.didUpdateWidget(old);
if (old.mxcUri != widget.mxcUri) _future = _load();
}
Future<Uint8List?> _load() async {
final mxc = widget.mxcUri;
if (mxc == null) return null;
final key = 'avatar:${mxc}_${widget.size.round()}';
// Memory → Disk → Netzwerk. Disk-Treffer machen Avatare nach einem
// App-Neustart sofort sichtbar statt nachzuladen.
final hit = await MediaCache.instance.getPersistent(key);
if (hit != null) return hit;
final headers = {'authorization': 'Bearer ${widget.client.accessToken}'};
try {
// Try thumbnail first; fall back to full download if 404 (server
// doesn't support on-the-fly thumbnailing, e.g. Continuwuity).
final thumbUri = await mxc.getThumbnailUri(
widget.client,
width: widget.size.round(),
height: widget.size.round(),
method: ThumbnailMethod.crop,
);
final thumbResp = await widget.client.httpClient.get(thumbUri, headers: headers);
if (thumbResp.statusCode == 200) {
await MediaCache.instance.putPersistent(key, thumbResp.bodyBytes);
return thumbResp.bodyBytes;
}
// Thumbnail not available — download full image.
final dlUri = await mxc.getDownloadUri(widget.client);
final dlResp = await widget.client.httpClient.get(dlUri, headers: headers);
if (dlResp.statusCode != 200) return null;
await MediaCache.instance.putPersistent(key, dlResp.bodyBytes);
return dlResp.bodyBytes;
} catch (_) {
return null;
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Uint8List?>(
future: _future,
builder: (context, snap) {
if (snap.hasData && snap.data != null) {
return ClipRRect(
borderRadius: widget.borderRadius ??
BorderRadius.circular(widget.size / 2),
child: Image.memory(
snap.data!,
width: widget.size,
height: widget.size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => widget.placeholder(context),
),
);
}
return widget.placeholder(context);
},
);
}
}
// Rectangular MXC image (no fixed size, fills parent)
class MxcImage extends StatefulWidget {
final String mxcUri;
final Client client;
final BoxFit fit;
final double? width;
final double? height;
final Widget? placeholder;
const MxcImage({
super.key,
required this.mxcUri,
required this.client,
this.fit = BoxFit.cover,
this.width,
this.height,
this.placeholder,
});
@override
State<MxcImage> createState() => _MxcImageState();
}
class _MxcImageState extends State<MxcImage> {
late Future<Uint8List?> _future;
@override
void initState() {
super.initState();
_future = _load();
}
@override
void didUpdateWidget(MxcImage old) {
super.didUpdateWidget(old);
if (old.mxcUri != widget.mxcUri) {
// Block-Body: die Closure darf KEIN Future zurückgeben, sonst wirft
// Flutter "setState() callback returned a Future".
setState(() {
_future = _load();
});
}
}
Future<Uint8List?> _load() async {
final key = 'img:${widget.mxcUri}';
final hit = await MediaCache.instance.getPersistent(key);
if (hit != null) return hit;
try {
final uri = Uri.parse(widget.mxcUri);
final headers = {
'authorization': 'Bearer ${widget.client.accessToken}',
};
// Erst Thumbnail versuchen (spart Bandbreite, v.a. mobil) — Fallback
// auf Volldownload für Server ohne Thumbnail-Support (Continuwuity).
try {
final thumbUri = await uri.getThumbnailUri(
widget.client,
width: 800,
height: 600,
method: ThumbnailMethod.scale,
);
final res = await widget.client.httpClient.get(thumbUri, headers: headers);
if (res.statusCode == 200) {
await MediaCache.instance.putPersistent(key, res.bodyBytes);
return res.bodyBytes;
}
} catch (_) {}
final httpUri = await uri.getDownloadUri(widget.client);
final res = await widget.client.httpClient.get(httpUri, headers: headers);
if (res.statusCode != 200) return null;
final bytes = res.bodyBytes;
await MediaCache.instance.putPersistent(key, bytes);
return bytes;
} catch (_) {
return null;
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Uint8List?>(
future: _future,
builder: (_, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return widget.placeholder ?? const SizedBox.shrink();
}
if (snap.hasData && snap.data != null) {
return Image.memory(
snap.data!,
fit: widget.fit,
width: widget.width,
height: widget.height,
gaplessPlayback: true,
errorBuilder: (_, __, ___) =>
widget.placeholder ?? const SizedBox.shrink(),
);
}
return widget.placeholder ?? const SizedBox.shrink();
},
);
}
}