import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:http/http.dart' as http; import '../api/http_errors.dart'; import '../model/endpoint_data.dart'; import '../push/push_avatar.dart'; import '../session/session_manager.dart'; import 'a11y/a11y_labels.dart'; import 'avatar_disk_cache.dart'; class UserAvatar extends StatefulWidget { final String id; final bool isGroup; final int size; /// Screenreader-Beschreibung. Aufrufer, die den Namen kennen, sollten ihn /// mitgeben; sonst greift ein generisches „Profilbild"/„Gruppenbild". final String? semanticLabel; /// Server-side pixel size requested for user avatars. `null` lets the /// widget pick `(size * 4).clamp(64, 1024)` — enough headroom for typical /// device pixel ratios. Group avatars ignore this (Spreed serves one /// fixed-size image per token). final int? requestSize; const UserAvatar({ required this.id, this.isGroup = false, this.size = 20, this.requestSize, this.semanticLabel, super.key, }); @override State createState() => _UserAvatarState(); } /// Raw avatar bytes plus the one bit callers need to render them: Nextcloud /// and Spreed answer with SVG for generated (never uploaded) avatars, which no /// `ImageProvider` can decode. class AvatarPayload { final Uint8List bytes; final bool isSvg; AvatarPayload(this.bytes, this.isSvg); } class _AvatarCacheEntry { final AvatarPayload? payload; final DateTime fetchedAt; _AvatarCacheEntry(this.payload, this.fetchedAt); } // LRU via LinkedHashMap insertion order + remove-on-hit. TTL so // server-side avatar updates become visible within a session. const int _kAvatarCacheMax = 256; const Duration _kAvatarCacheTtl = Duration(minutes: 30); // Pending map dedups concurrent mounts onto a single HTTP call. final LinkedHashMap _resolvedAvatars = LinkedHashMap(); final Map> _pendingAvatars = {}; // Bumped by invalidateAvatarCache so *already mounted* avatars re-resolve. // Clearing the cache map alone only affects future mounts — a UserAvatar // elsewhere on screen (chat list, chat header) holds its bytes in State and // would keep showing the stale image until rebuilt. Each state listens here // and re-attaches: invalidated urls miss the cache and re-fetch, the rest // hit the cache and cost nothing. final ValueNotifier _avatarCacheGeneration = ValueNotifier(0); String avatarUrl({required String id, required bool isGroup, int size = 512}) { final host = EndpointData().nextcloud().full(); if (isGroup) { return 'https://$host/ocs/v2.php/apps/spreed/api/v1/room/$id/avatar'; } return 'https://$host/avatar/$id/$size'; } /// Resolves an avatar through the shared caches: in-memory first, then disk, /// then the network — the fetch being deduped across every concurrent caller /// and written back to both caches. /// /// Returns `null` when the server has no avatar (HTTP 404) and throws on a /// transient failure, so callers can keep whatever they already show. Future loadAvatarPayload({ required String id, required bool isGroup, int size = 512, }) async { final url = avatarUrl(id: id, isGroup: isGroup, size: size); final cached = _readAvatarCache(url); if (cached != null) return cached.payload; final diskBytes = await AvatarDiskCache.instance.read( id: id, isGroup: isGroup, size: size, ); if (diskBytes != null) { final payload = _payloadFromBytes(diskBytes); _writeAvatarCache(url, payload); return payload; } final payload = await _fetchDeduped(url); _commitAvatar(url, id: id, isGroup: isGroup, size: size, payload: payload); return payload; } /// Shares one in-flight request per URL across all callers. Future _fetchDeduped(String url) => _pendingAvatars.putIfAbsent(url, () { final future = _fetchAvatarPayload(url); // Cleanup hangs off an error-neutralised copy: whenComplete on `future` // itself returns a second future that forwards the error unawaited. unawaited( future.then((_) {}, onError: (_) {}).whenComplete(() { if (identical(_pendingAvatars[url], future)) { _pendingAvatars.remove(url); } }), ); return future; }); /// Persists a resolved result to the in-memory and disk caches. void _commitAvatar( String url, { required String id, required bool isGroup, required int size, required AvatarPayload? payload, }) { _writeAvatarCache(url, payload); if (payload != null) { unawaited( AvatarDiskCache.instance.write( id: id, isGroup: isGroup, size: size, bytes: payload.bytes, ), ); } else { unawaited(AvatarDiskCache.instance.evict(id: id, isGroup: isGroup)); } } /// Returns the avatar bytes, `null` for a definitive miss (HTTP 404 — no /// avatar exists), or throws on a transient error (offline, non-200) so the /// caller keeps the cached picture instead of blanking it. Future _fetchAvatarPayload(String url) async { final response = await sendGuarded( 'Avatar $url', () => http.get( Uri.parse(url), headers: { ...SessionManager().requireNextcloud().authHeaders, 'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml', }, ), ); if (response == null) return null; if (response.statusCode == 404) return null; if (response.statusCode != 200 || response.bodyBytes.isEmpty) { throwForStatus( response.statusCode, httpErrorDetail('Avatar $url', response.body, response.statusCode), ); } final contentType = response.headers['content-type']?.toLowerCase() ?? ''; final bytes = response.bodyBytes; final isSvg = contentType.contains('svg') || _looksLikeSvg(bytes); return AvatarPayload(bytes, isSvg); } AvatarPayload _payloadFromBytes(Uint8List bytes) => AvatarPayload(bytes, _looksLikeSvg(bytes)); bool _looksLikeSvg(Uint8List bytes) { final head = utf8 .decode( bytes.sublist(0, bytes.length < 256 ? bytes.length : 256), allowMalformed: true, ) .trimLeft(); return head.startsWith(' url.startsWith(prefix)); _pendingAvatars.removeWhere((url, _) => url.startsWith(prefix)); unawaited(AvatarDiskCache.instance.evict(id: id, isGroup: false)); } _avatarCacheGeneration.value++; } _AvatarCacheEntry? _readAvatarCache(String url) { final entry = _resolvedAvatars.remove(url); if (entry == null) return null; if (DateTime.now().difference(entry.fetchedAt) > _kAvatarCacheTtl) { return null; } // Re-insert at the tail so it counts as most-recently-used. _resolvedAvatars[url] = entry; return entry; } void _writeAvatarCache(String url, AvatarPayload? payload) { _resolvedAvatars.remove(url); _resolvedAvatars[url] = _AvatarCacheEntry(payload, DateTime.now()); while (_resolvedAvatars.length > _kAvatarCacheMax) { _resolvedAvatars.remove(_resolvedAvatars.keys.first); } } class _UserAvatarState extends State { AvatarPayload? _payload; @override void initState() { super.initState(); _attach(); _avatarCacheGeneration.addListener(_onCacheInvalidated); } @override void dispose() { _avatarCacheGeneration.removeListener(_onCacheInvalidated); super.dispose(); } // Re-resolve when the cache generation changes. Cache hit → no network and // the visible bytes stay; cache miss (our url was invalidated) → re-fetch. void _onCacheInvalidated() { if (!mounted) return; setState(_attach); } @override void didUpdateWidget(UserAvatar oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.id != widget.id || oldWidget.isGroup != widget.isGroup || oldWidget.size != widget.size || oldWidget.requestSize != widget.requestSize) { _attach(); } } int _resolvedRequestSize() => widget.requestSize ?? (widget.size * 4).clamp(64, 1024); String _url() => avatarUrl( id: widget.id, isGroup: widget.isGroup, size: _resolvedRequestSize(), ); void _attach() { final url = _url(); final cached = _readAvatarCache(url); if (cached != null) { _payload = cached.payload; return; } // Capture the subject once — later async steps must not read widget.* since // the widget may have been recycled onto a different id by then. final id = widget.id; final isGroup = widget.isGroup; final size = _resolvedRequestSize(); // Persistent disk cache: on a warm session (cache directory already known) // this hits synchronously, so a cold app start paints the last-known // picture on the first frame instead of a blank placeholder. final diskBytes = AvatarDiskCache.instance.readSync( id: id, isGroup: isGroup, size: size, ); if (diskBytes != null) { final payload = _payloadFromBytes(diskBytes); _payload = payload; _writeAvatarCache(url, payload); } else { _payload = null; } unawaited(_resolve(url, id, isGroup, size, haveBytes: _payload != null)); } /// Fills the placeholder from disk (async path, for the first avatar of a /// session), then always refreshes over the network so a changed server-side /// picture replaces the cached one. Network work is deduped across every /// widget showing the same avatar via [_pendingAvatars]. Future _resolve( String url, String id, bool isGroup, int size, { required bool haveBytes, }) async { if (!haveBytes) { final diskBytes = await AvatarDiskCache.instance.read( id: id, isGroup: isGroup, size: size, ); if (diskBytes != null && mounted && _url() == url && _payload == null) { final payload = _payloadFromBytes(diskBytes); _writeAvatarCache(url, payload); setState(() => _payload = payload); } } AvatarPayload? fresh; try { fresh = await _fetchDeduped(url); } on Object { // Transient failure (offline, 5xx). Keep showing the cached picture; the // next mount retries. Deliberately no null-cache so we don't mask it. return; } _commitAvatar(url, id: id, isGroup: isGroup, size: size, payload: fresh); if (!mounted || _url() != url) return; if (fresh == null) { // HTTP 404 — the avatar was removed server-side. Fall back to the icon. if (_payload != null) setState(() => _payload = null); } else if (!_sameBytes(_payload, fresh)) { setState(() => _payload = fresh); } } static bool _sameBytes(AvatarPayload? a, AvatarPayload? b) { if (a == null || b == null) return a == b; return listEquals(a.bytes, b.bytes); } @override Widget build(BuildContext context) { final radius = widget.size.toDouble(); final theme = Theme.of(context); final payload = _payload; Widget content; if (payload != null) { if (payload.isSvg) { content = SvgPicture.memory( payload.bytes, width: radius * 2, height: radius * 2, fit: BoxFit.cover, ); } else { content = Image.memory( payload.bytes, width: radius * 2, height: radius * 2, fit: BoxFit.cover, gaplessPlayback: true, ); } } else { content = Icon( widget.isGroup ? Icons.group : Icons.person, size: radius, color: Colors.white, ); } return Semantics( image: true, label: widget.semanticLabel ?? (widget.isGroup ? A11yLabels.groupPicture : A11yLabels.profilePicture), child: CircleAvatar( radius: radius, backgroundColor: theme.primaryColor, foregroundColor: Colors.white, child: ClipOval( child: SizedBox( width: radius * 2, height: radius * 2, child: content, ), ), ), ); } }