optimized avatar and linkify performance, refined navigation to preserve popups, implemented read marker caching, and added file size limits for saving, minor timetable details changes

This commit is contained in:
2026-05-10 16:40:39 +02:00
parent 1458d8ce49
commit a0bc46f522
12 changed files with 234 additions and 64 deletions
+26 -8
View File
@@ -38,6 +38,9 @@ class App extends StatefulWidget {
class _AppState extends State<App> with WidgetsBindingObserver { class _AppState extends State<App> with WidgetsBindingObserver {
late Timer _updateTimings; late Timer _updateTimings;
StreamSubscription<dynamic>? _timetableWidgetSync; StreamSubscription<dynamic>? _timetableWidgetSync;
StreamSubscription<RemoteMessage>? _onMessageSub;
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
StreamSubscription<String>? _fcmTokenRefreshSub;
// Tracked via the bottom-nav controller's listener so it always reflects the // Tracked via the bottom-nav controller's listener so it always reflects the
// user's actual position, even between rapid setting emits where the // user's actual position, even between rapid setting emits where the
// controller hasn't caught up to a scheduled jump yet. // controller hasn't caught up to a scheduled jump yet.
@@ -84,10 +87,12 @@ class _AppState extends State<App> with WidgetsBindingObserver {
// Routes pushed with `withNavBar: false` (chat views, file viewers, …) // Routes pushed with `withNavBar: false` (chat views, file viewers, …)
// sit on the root navigator above the bottom-nav, so a bare jumpToTab // sit on the root navigator above the bottom-nav, so a bare jumpToTab
// would swap the tab behind them and leave the user staring at the // would swap the tab behind them and leave the user staring at the
// previous screen. Reset to the tab root first. // previous screen. Reset to the tab root first — but stop at any open
// popup so a confirmation dialog or bottom sheet that the user hasn't
// dismissed yet doesn't get silently torn down.
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
if (navigator.canPop()) { if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst); navigator.popUntil((route) => route.isFirst || route is PopupRoute);
} }
AppRoutes.goToTab(context, Modules.timetable); AppRoutes.goToTab(context, Modules.timetable);
} }
@@ -98,10 +103,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (share == null) return; if (share == null) return;
// A second share arriving while a previous share-flow page is still on // A second share arriving while a previous share-flow page is still on
// the stack would otherwise leave the old page sitting on top with stale // the stack would otherwise leave the old page sitting on top with stale
// (already-cleared) file paths. Reset to the tab root before pushing. // (already-cleared) file paths. Reset to the tab root before pushing
// but stop at any open popup so dialogs/bottom-sheets remain intact.
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
if (navigator.canPop()) { if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst); navigator.popUntil((route) => route.isFirst || route is PopupRoute);
} }
AppRoutes.openShareTarget(context, share); AppRoutes.openShareTarget(context, share);
} }
@@ -165,11 +171,13 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (context.read<SettingsCubit>().val().notificationSettings.enabled) { if (context.read<SettingsCubit>().val().notificationSettings.enabled) {
void update() => NotifyUpdater.registerToServer(); void update() => NotifyUpdater.registerToServer();
FirebaseMessaging.instance.onTokenRefresh.listen((_) => update()); _fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen(
(_) => update(),
);
update(); update();
} }
FirebaseMessaging.onMessage.listen((message) { _onMessageSub = FirebaseMessaging.onMessage.listen((message) {
if (!mounted) return; if (!mounted) return;
NotificationController.onForegroundMessageHandler(message, context); NotificationController.onForegroundMessageHandler(message, context);
}); });
@@ -177,7 +185,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
NotificationController.onBackgroundMessageHandler, NotificationController.onBackgroundMessageHandler,
); );
FirebaseMessaging.onMessageOpenedApp.listen((message) { _onMessageOpenedAppSub = FirebaseMessaging.onMessageOpenedApp.listen((
message,
) {
if (!mounted) return; if (!mounted) return;
NotificationController.onAppOpenedByNotification(message, context); NotificationController.onAppOpenedByNotification(message, context);
}); });
@@ -193,6 +203,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
void dispose() { void dispose() {
_updateTimings.cancel(); _updateTimings.cancel();
_timetableWidgetSync?.cancel(); _timetableWidgetSync?.cancel();
_onMessageSub?.cancel();
_onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel();
ShareIntentListener.pending.removeListener(_handlePendingShare); ShareIntentListener.pending.removeListener(_handlePendingShare);
ShareIntentListener.instance.detach(); ShareIntentListener.instance.detach();
Main.bottomNavigator.removeListener(_onTabControllerChanged); Main.bottomNavigator.removeListener(_onTabControllerChanged);
@@ -279,7 +292,12 @@ class _AppState extends State<App> with WidgetsBindingObserver {
key: ValueKey(config.items.length), key: ValueKey(config.items.length),
navBarConfig: config, navBarConfig: config,
navBarDecoration: NavBarDecoration( navBarDecoration: NavBarDecoration(
border: const Border(top: BorderSide(width: 1, color: Colors.grey)), border: Border(
top: BorderSide(
width: 1,
color: Theme.of(context).colorScheme.outlineVariant,
),
),
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
), ),
), ),
+10 -2
View File
@@ -26,9 +26,17 @@ class NotificationController {
BuildContext context, BuildContext context,
) async { ) async {
final pushToken = _extractChatToken(message); final pushToken = _extractChatToken(message);
final activeToken = context.read<ChatBloc>().state.data?.currentToken ?? ''; final chatBloc = context.read<ChatBloc>();
// hasOpenChat (not currentToken) is the source of truth here:
// currentToken sticks around after leaveChat so that didPopNext can
// re-claim a stacked chat. Using it would suppress notifications for
// the last-opened chat even after the user has navigated away.
final activeToken = chatBloc.state.data?.currentToken ?? '';
final chatIsOpen = final chatIsOpen =
pushToken != null && pushToken.isNotEmpty && pushToken == activeToken; chatBloc.hasOpenChat &&
pushToken != null &&
pushToken.isNotEmpty &&
pushToken == activeToken;
NotificationTasks.updateBadgeCount(message); NotificationTasks.updateBadgeCount(message);
+18 -3
View File
@@ -36,6 +36,12 @@ class ChatBloc
/// popping a stacked chat. /// popping a stacked chat.
bool _chatViewActive = false; bool _chatViewActive = false;
/// True only while a ChatView is actually mounted and tracking its room.
/// Read by the notification controller to decide whether an incoming push
/// belongs to the chat the user is currently looking at — `currentToken`
/// alone would yield false-positives for the last opened chat.
bool get hasOpenChat => _chatViewActive;
DateTime _lastTokenSet = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastTokenSet = DateTime.fromMillisecondsSinceEpoch(0);
ChatBloc({ChatListBloc? chatListBloc}) : _chatListBloc = chatListBloc { ChatBloc({ChatListBloc? chatListBloc}) : _chatListBloc = chatListBloc {
@@ -166,10 +172,19 @@ class ChatBloc
}, },
onNetworkData: (data) { onNetworkData: (data) {
// Server-side mark runs unconditionally with the freshly-fetched // Server-side mark runs unconditionally with the freshly-fetched
// maxId. Skipping it on stillCurrent==false would leave the // maxId — skipping it on stillCurrent==false would leave the
// server cursor wherever a quick navigation away left it. // server cursor wherever a quick navigation away left it. The
// cache check below avoids a redundant POST when the long-poll
// (setReadMarker=on) or a previous open already moved the cursor
// to this exact id; without it every chat-open did one extra
// round-trip even when there was nothing to mark.
final maxId = _maxMessageId(data); final maxId = _maxMessageId(data);
if (maxId > 0) unawaited(sendServerReadMarker(token, maxId)); if (maxId > 0) {
final cached = _chatListBloc?.lastReadMessageFor(token);
if (cached == null || cached < maxId) {
unawaited(sendServerReadMarker(token, maxId));
}
}
if (!stillCurrent()) return; if (!stillCurrent()) return;
_applyChatResponse(data); _applyChatResponse(data);
if (maxId > 0) _chatListBloc?.markRoomAsRead(token, maxId); if (maxId > 0) _chatListBloc?.markRoomAsRead(token, maxId);
@@ -107,6 +107,19 @@ class ChatListBloc
await refresh(); await refresh();
} }
/// Returns the cached `lastReadMessage` for the room with [token], or
/// `null` if the room is not (yet) known. Used by [ChatBloc] to skip
/// redundant server read-marker calls when the local cache already
/// reflects the same position.
int? lastReadMessageFor(String token) {
final rooms = innerState?.rooms;
if (rooms == null) return null;
for (final room in rooms.data) {
if (room.token == token) return room.lastReadMessage;
}
return null;
}
/// Optimistically clears the unread counter for [token] so the tile /// Optimistically clears the unread counter for [token] so the tile
/// reacts before a refresh roundtrip lands. Server-side mark-as-read /// reacts before a refresh roundtrip lands. Server-side mark-as-read
/// is the caller's job (see [ChatBloc.sendServerReadMarker]). /// is the caller's job (see [ChatBloc.sendServerReadMarker]).
@@ -102,6 +102,7 @@ class MarianumDateRow extends StatelessWidget {
initialDescription: event.description, initialDescription: event.description,
initialStart: event.start, initialStart: event.start,
initialEnd: event.end, initialEnd: event.end,
initialAllDay: event.isAllDay,
), ),
barrierDismissible: false, barrierDismissible: false,
), ),
@@ -67,12 +67,12 @@ class AboutSection extends StatelessWidget {
applicationIcon: const Icon(Icons.apps), applicationIcon: const Icon(Icons.apps),
applicationName: 'MarianumMobile', applicationName: 'MarianumMobile',
applicationVersion: applicationVersion:
'${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}', '${appInfo.appName}\n\n${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild/Relase-nummer: ${appInfo.buildNumber}',
applicationLegalese: applicationLegalese:
'Dies ist ein Inoffizieller Marianum-Cloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n' 'Dies ist ein Inoffizieller Marianum-Cloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n'
'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n' 'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n'
"${kReleaseMode ? "Production" : "Development"} build\n" "${kReleaseMode ? "Production" : "Development ${kProfileMode ? "(Profiling)" : "(Debug)"}"} build.\n\n"
'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller', 'Marianum Fulda 2019-2020, 2023-${Jiffy.now().year}\nElias Müller',
); );
} }
@@ -92,7 +92,7 @@ class AboutSection extends StatelessWidget {
), ),
ListTile( ListTile(
leading: const CenteredLeading(Icon(Icons.date_range_outlined)), leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
title: const Text('Infos zu Web-/ Untis'), title: const Text('Infos zu (Web) Untis'),
subtitle: const Text('Für den Stundenplan'), subtitle: const Text('Für den Stundenplan'),
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo( onTap: () => PrivacyInfo(
@@ -106,7 +106,7 @@ class AboutSection extends StatelessWidget {
Icon(Icons.send_time_extension_outlined), Icon(Icons.send_time_extension_outlined),
), ),
title: const Text('Infos zu mhsl'), title: const Text('Infos zu mhsl'),
subtitle: const Text('Für Countdowns, Marianum Message und mehr'), subtitle: const Text('Für Push, Kalendertermine, Marianum Message und mehr'),
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo( onTap: () => PrivacyInfo(
providerText: 'mhsl', providerText: 'mhsl',
@@ -186,7 +186,11 @@ void _openOrCreateDirectChat(
// Pop the current ChatView before swapping the global ChatBloc token — // Pop the current ChatView before swapping the global ChatBloc token —
// otherwise the previous group chat stays mounted in the back-stack and // otherwise the previous group chat stays mounted in the back-stack and
// would render empty after a back-swipe (currentToken no longer matches). // would render empty after a back-swipe (currentToken no longer matches).
Navigator.of(context).popUntil((route) => route.isFirst); // Stops at any open popup so a confirmation dialog still in flight does
// not get silently dismissed.
Navigator.of(
context,
).popUntil((route) => route.isFirst || route is PopupRoute);
AppRoutes.openChatByToken(context, room.token); AppRoutes.openChatByToken(context, room.token);
} }
@@ -78,22 +78,48 @@ class HighlightedLinkify extends StatefulWidget {
} }
class _HighlightedLinkifyState extends State<HighlightedLinkify> { class _HighlightedLinkifyState extends State<HighlightedLinkify> {
final List<TapGestureRecognizer> _recognizers = []; // Cached per link text so character-by-character search rebuilds don't
// churn through allocate/dispose on every keystroke. Stale entries are
// pruned at the end of each build via [_seenLinkKeys].
final Map<String, TapGestureRecognizer> _recognizers = {};
final Set<String> _seenLinkKeys = {};
@override @override
void dispose() { void dispose() {
for (final r in _recognizers) { for (final r in _recognizers.values) {
r.dispose(); r.dispose();
} }
_recognizers.clear();
super.dispose(); super.dispose();
} }
TapGestureRecognizer _recognizerFor(LinkableElement el) {
final key = el.text;
final existing = _recognizers[key];
if (existing != null) {
// Refresh onTap so a new widget.onOpen callback (from a parent
// rebuild) picks up the latest closure.
existing.onTap = () => widget.onOpen?.call(el);
return existing;
}
final created = TapGestureRecognizer()
..onTap = () => widget.onOpen?.call(el);
_recognizers[key] = created;
return created;
}
void _pruneUnseen() {
final stale = _recognizers.keys
.where((k) => !_seenLinkKeys.contains(k))
.toList(growable: false);
for (final k in stale) {
_recognizers.remove(k)?.dispose();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
for (final r in _recognizers) { _seenLinkKeys.clear();
r.dispose();
}
_recognizers.clear();
final defaultStyle = widget.style ?? final defaultStyle = widget.style ??
Theme.of(context).textTheme.bodyMedium ?? Theme.of(context).textTheme.bodyMedium ??
@@ -124,9 +150,8 @@ class _HighlightedLinkifyState extends State<HighlightedLinkify> {
for (final el in elements) { for (final el in elements) {
if (el is LinkableElement) { if (el is LinkableElement) {
final recognizer = TapGestureRecognizer() _seenLinkKeys.add(el.text);
..onTap = () => widget.onOpen?.call(el); final recognizer = _recognizerFor(el);
_recognizers.add(recognizer);
spans.addAll( spans.addAll(
buildHighlightedSpans( buildHighlightedSpans(
text: el.text, text: el.text,
@@ -147,6 +172,8 @@ class _HighlightedLinkifyState extends State<HighlightedLinkify> {
} }
} }
_pruneUnseen();
return Text.rich(TextSpan(children: spans)); return Text.rich(TextSpan(children: spans));
} }
} }
@@ -19,6 +19,7 @@ class CustomEventEditDialog extends StatefulWidget {
final DateTime? initialEnd; final DateTime? initialEnd;
final String? initialTitle; final String? initialTitle;
final String? initialDescription; final String? initialDescription;
final bool? initialAllDay;
const CustomEventEditDialog({ const CustomEventEditDialog({
this.existingEvent, this.existingEvent,
@@ -26,6 +27,7 @@ class CustomEventEditDialog extends StatefulWidget {
this.initialEnd, this.initialEnd,
this.initialTitle, this.initialTitle,
this.initialDescription, this.initialDescription,
this.initialAllDay,
super.key, super.key,
}); });
@@ -78,13 +80,18 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
} }
return; return;
} }
_isAllDay = false; _isAllDay = widget.initialAllDay ?? false;
if (_isAllDay) {
_startTime = _defaultStart;
_endTime = _defaultEnd;
} else {
final rawStart = widget.initialStart?.toTimeOfDay() ?? _defaultStart; final rawStart = widget.initialStart?.toTimeOfDay() ?? _defaultStart;
final rawEnd = widget.initialEnd?.toTimeOfDay() ?? _defaultEnd; final rawEnd = widget.initialEnd?.toTimeOfDay() ?? _defaultEnd;
final clamped = _clampToVisibleWindow(rawStart, rawEnd); final clamped = _clampToVisibleWindow(rawStart, rawEnd);
_startTime = clamped.$1; _startTime = clamped.$1;
_endTime = clamped.$2; _endTime = clamped.$2;
} }
}
static (TimeOfDay, TimeOfDay) _clampToVisibleWindow( static (TimeOfDay, TimeOfDay) _clampToVisibleWindow(
TimeOfDay rawStart, TimeOfDay rawStart,
@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart';
@@ -11,7 +10,6 @@ import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/unimplemented_dialog.dart';
class WebuntisLessonSheet { class WebuntisLessonSheet {
static void show( static void show(
@@ -72,7 +70,7 @@ class WebuntisLessonSheet {
}).toList(), }).toList(),
), ),
_roomTile(context, state, lesson), _roomTile(context, state, lesson),
_teacherTile(context, lesson), _teacherTile(lesson),
if ((lesson.activityType ?? '').trim().isNotEmpty) if ((lesson.activityType ?? '').trim().isNotEmpty)
ListTile( ListTile(
leading: const Icon(Icons.abc), leading: const Icon(Icons.abc),
@@ -120,14 +118,15 @@ class WebuntisLessonSheet {
final name = firstNonEmpty([resolved.name, r.name, '?']); final name = firstNonEmpty([resolved.name, r.name, '?']);
final longname = firstNonEmpty([resolved.longName, r.longname, '']); final longname = firstNonEmpty([resolved.longName, r.longname, '']);
final building = resolved.building.trim(); final building = resolved.building.trim();
return LessonFormatter.formatLine( final main = LessonFormatter.formatLine(
name, name,
longname: longname,
extra: (building.isNotEmpty && building != '?') ? building : null, extra: (building.isNotEmpty && building != '?') ? building : null,
); );
final sub = (longname.isNotEmpty && longname != name) ? longname : null;
return (main: main, sub: sub);
}).toList(); }).toList();
return _listTile( return _listTileWithSubs(
icon: Icons.room, icon: Icons.room,
label: lesson.ro.length == 1 ? 'Raum' : 'Räume', label: lesson.ro.length == 1 ? 'Raum' : 'Räume',
entries: entries, entries: entries,
@@ -135,39 +134,63 @@ class WebuntisLessonSheet {
); );
} }
static Widget _teacherTile( static Widget _teacherTile(GetTimetableResponseObject lesson) {
BuildContext context,
GetTimetableResponseObject lesson,
) {
final trailing = Visibility(
visible: !kReleaseMode,
child: IconButton(
icon: const Icon(Icons.textsms_outlined),
onPressed: () => UnimplementedDialog.show(context),
),
);
if (lesson.te.isEmpty) { if (lesson.te.isEmpty) {
return ListTile( return const ListTile(
leading: const Icon(Icons.person), leading: Icon(Icons.person),
title: const Text('Lehrkraft: ?'), title: Text('Lehrkraft: ?'),
trailing: trailing,
); );
} }
final entries = lesson.te.map((t) { final entries = lesson.te.map((t) {
final base = LessonFormatter.formatLine( final main = LessonFormatter.formatLine(
t.name.isNotEmpty ? t.name : '?', t.name.isNotEmpty ? t.name : '?',
longname: t.longname, longname: t.longname,
); );
final orgname = (t.orgname ?? '').trim(); final orgname = (t.orgname ?? '').trim();
return orgname.isEmpty ? base : '$base · ehemals $orgname'; return (main: main, sub: orgname.isEmpty ? null : 'ehemals $orgname');
}).toList(); }).toList();
return _listTile( return _listTileWithSubs(
icon: Icons.person, icon: Icons.person,
label: lesson.te.length == 1 ? 'Lehrkraft' : 'Lehrkräfte', label: lesson.te.length == 1 ? 'Lehrkraft' : 'Lehrkräfte',
entries: entries, entries: entries,
);
}
static Widget _listTileWithSubs({
required IconData icon,
required String label,
required List<({String main, String? sub})> entries,
Widget? trailing,
}) {
if (entries.length == 1) {
final e = entries.first;
return ListTile(
leading: Icon(icon),
title: Text('$label: ${e.main}'),
subtitle: e.sub != null ? Text(e.sub!) : null,
trailing: trailing,
);
}
return ListTile(
leading: Icon(icon),
title: Text(label),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: entries
.expand<Widget>(
(e) => [
Text(e.main),
if (e.sub != null)
Padding(
padding: const EdgeInsets.only(left: 12),
child: Text(e.sub!),
),
],
)
.toList(),
),
trailing: trailing, trailing: trailing,
); );
} }
+20 -1
View File
@@ -254,7 +254,26 @@ class _FileViewerState extends State<FileViewer> {
break; break;
case FileViewingActions.save: case FileViewingActions.save:
try { try {
final bytes = await File(widget.path).readAsBytes(); final source = File(widget.path);
final size = await source.length();
// Hard-cap to avoid loading the entire file into memory just to
// hand it back to the platform's saveFile dialog. The package
// currently has no streaming/path-based save path, so for big
// media the user has to fall back to "Teilen" → save-to-files.
// 200 MB peak is comfortable on modern mid-range devices and big
// enough for typical school videos.
const maxBytes = 200 * 1024 * 1024; // 200 MB
if (size > maxBytes) {
if (!mounted) return;
InfoDialog.show(
context,
'Diese Datei ist zu groß (${(size / (1024 * 1024)).toStringAsFixed(0)} MB), '
'um direkt gespeichert zu werden. Nutze stattdessen die Teilen-Funktion.',
title: 'Speichern nicht möglich',
);
return;
}
final bytes = await source.readAsBytes();
final saved = await FilePicker.saveFile( final saved = await FilePicker.saveFile(
fileName: widget.path.split('/').last, fileName: widget.path.split('/').last,
bytes: bytes, bytes: bytes,
+39 -4
View File
@@ -1,3 +1,4 @@
import 'dart:collection';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
@@ -29,11 +30,44 @@ class _AvatarPayload {
_AvatarPayload(this.bytes, this.isSvg); _AvatarPayload(this.bytes, this.isSvg);
} }
class _AvatarCacheEntry {
final _AvatarPayload? payload;
final DateTime fetchedAt;
_AvatarCacheEntry(this.payload, this.fetchedAt);
}
// Cap keeps the heap bounded for power-users in Talk; TTL ensures
// server-side avatar updates become visible within a session without
// requiring an app restart. LinkedHashMap insertion-order plus a remove
// on hit gives us LRU eviction.
const int _kAvatarCacheMax = 256;
const Duration _kAvatarCacheTtl = Duration(minutes: 30);
// Resolved payloads are cached so re-mounts render synchronously; in-flight // Resolved payloads are cached so re-mounts render synchronously; in-flight
// requests are deduped so concurrent mounts share one HTTP call. // requests are deduped so concurrent mounts share one HTTP call.
final Map<String, _AvatarPayload?> _resolvedAvatars = {}; final LinkedHashMap<String, _AvatarCacheEntry> _resolvedAvatars =
LinkedHashMap<String, _AvatarCacheEntry>();
final Map<String, Future<_AvatarPayload?>> _pendingAvatars = {}; final Map<String, Future<_AvatarPayload?>> _pendingAvatars = {};
_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<UserAvatar> { class _UserAvatarState extends State<UserAvatar> {
_AvatarPayload? _payload; _AvatarPayload? _payload;
@@ -63,14 +97,15 @@ class _UserAvatarState extends State<UserAvatar> {
void _attach() { void _attach() {
final url = _url(); final url = _url();
if (_resolvedAvatars.containsKey(url)) { final cached = _readAvatarCache(url);
_payload = _resolvedAvatars[url]; if (cached != null) {
_payload = cached.payload;
return; return;
} }
_payload = null; _payload = null;
final pending = _pendingAvatars.putIfAbsent(url, () => _fetch(url)); final pending = _pendingAvatars.putIfAbsent(url, () => _fetch(url));
pending.then((p) { pending.then((p) {
_resolvedAvatars[url] = p; _writeAvatarCache(url, p);
_pendingAvatars.remove(url); _pendingAvatars.remove(url);
if (!mounted || _url() != url) return; if (!mounted || _url() != url) return;
setState(() => _payload = p); setState(() => _payload = p);