add ticker page bloc and avatar disk cache
This commit is contained in:
+128
-25
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user