add ticker page bloc and avatar disk cache

This commit is contained in:
2026-07-12 23:18:53 +02:00
parent 94794ff092
commit 9b5198c6db
18 changed files with 944 additions and 233 deletions
+128 -25
View File
@@ -1,8 +1,8 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:http/http.dart' as http;
@@ -10,6 +10,7 @@ import 'package:http/http.dart' as http;
import '../model/account_data.dart';
import '../model/endpoint_data.dart';
import '../push/push_avatar.dart';
import 'avatar_disk_cache.dart';
class UserAvatar extends StatefulWidget {
final String id;
@@ -84,10 +85,12 @@ void invalidateAvatarCache({String? id, bool? isGroup}) {
if (id == null) {
_resolvedAvatars.clear();
_pendingAvatars.clear();
unawaited(AvatarDiskCache.instance.clear());
} else if (isGroup == true) {
final url = avatarUrl(id: id, isGroup: true);
_resolvedAvatars.remove(url);
_pendingAvatars.remove(url);
unawaited(AvatarDiskCache.instance.evict(id: id, isGroup: true));
// Keep the push-notification disk cache in sync — it serves the same
// room avatar to the FCM background isolate.
unawaited(PushAvatarStore.evict(id));
@@ -97,6 +100,7 @@ void invalidateAvatarCache({String? id, bool? isGroup}) {
final prefix = 'https://$host/avatar/$id/';
_resolvedAvatars.removeWhere((url, _) => url.startsWith(prefix));
_pendingAvatars.removeWhere((url, _) => url.startsWith(prefix));
unawaited(AvatarDiskCache.instance.evict(id: id, isGroup: false));
}
_avatarCacheGeneration.value++;
}
@@ -170,34 +174,133 @@ class _UserAvatarState extends State<UserAvatar> {
_payload = cached.payload;
return;
}
_payload = null;
final pending = _pendingAvatars.putIfAbsent(url, () => _fetch(url));
pending.then((p) {
_writeAvatarCache(url, p);
_pendingAvatars.remove(url);
if (!mounted || _url() != url) return;
setState(() => _payload = p);
});
// 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));
}
Future<_AvatarPayload?> _fetch(String url) async {
try {
final response = await http.get(
Uri.parse(url),
headers: {
'Authorization': AccountData().getBasicAuthHeader(),
'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml',
},
/// 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<void> _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 (response.statusCode != 200 || response.bodyBytes.isEmpty) return null;
final contentType = response.headers['content-type']?.toLowerCase() ?? '';
final bytes = response.bodyBytes;
final isSvg = contentType.contains('svg') || _looksLikeSvg(bytes);
return _AvatarPayload(bytes, isSvg);
} catch (_) {
return null;
if (diskBytes != null && mounted && _url() == url && _payload == null) {
final payload = _payloadFromBytes(diskBytes);
_writeAvatarCache(url, payload);
setState(() => _payload = payload);
}
}
final pending = _pendingAvatars.putIfAbsent(url, () {
final future = _fetch(url);
future.whenComplete(() {
if (identical(_pendingAvatars[url], future)) _pendingAvatars.remove(url);
});
return future;
});
_AvatarPayload? fresh;
try {
fresh = await pending;
} 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;
}
_commit(url, id, isGroup, size, 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);
}
}
// Persists a resolved result to the in-memory and disk caches. Uses the
// captured subject (not widget.*) so a recycled widget can't misfile bytes.
void _commit(
String url,
String id,
bool isGroup,
int size,
_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));
}
}
static _AvatarPayload _payloadFromBytes(Uint8List bytes) =>
_AvatarPayload(bytes, _looksLikeSvg(bytes));
static bool _sameBytes(_AvatarPayload? a, _AvatarPayload? b) {
if (a == null || b == null) return a == b;
return listEquals(a.bytes, b.bytes);
}
/// 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<_AvatarPayload?> _fetch(String url) async {
final response = await http.get(
Uri.parse(url),
headers: {
'Authorization': AccountData().getBasicAuthHeader(),
'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml',
},
);
if (response.statusCode == 404) return null;
if (response.statusCode != 200 || response.bodyBytes.isEmpty) {
throw Exception('avatar fetch failed: HTTP ${response.statusCode}');
}
final contentType = response.headers['content-type']?.toLowerCase() ?? '';
final bytes = response.bodyBytes;
final isSvg = contentType.contains('svg') || _looksLikeSvg(bytes);
return _AvatarPayload(bytes, isSvg);
}
static bool _looksLikeSvg(Uint8List bytes) {