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:
+26
-8
@@ -38,6 +38,9 @@ class App extends StatefulWidget {
|
||||
class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
late Timer _updateTimings;
|
||||
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
|
||||
// user's actual position, even between rapid setting emits where the
|
||||
// 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, …)
|
||||
// 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
|
||||
// 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);
|
||||
if (navigator.canPop()) {
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
navigator.popUntil((route) => route.isFirst || route is PopupRoute);
|
||||
}
|
||||
AppRoutes.goToTab(context, Modules.timetable);
|
||||
}
|
||||
@@ -98,10 +103,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
if (share == null) return;
|
||||
// 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
|
||||
// (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);
|
||||
if (navigator.canPop()) {
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
navigator.popUntil((route) => route.isFirst || route is PopupRoute);
|
||||
}
|
||||
AppRoutes.openShareTarget(context, share);
|
||||
}
|
||||
@@ -165,11 +171,13 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
|
||||
if (context.read<SettingsCubit>().val().notificationSettings.enabled) {
|
||||
void update() => NotifyUpdater.registerToServer();
|
||||
FirebaseMessaging.instance.onTokenRefresh.listen((_) => update());
|
||||
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen(
|
||||
(_) => update(),
|
||||
);
|
||||
update();
|
||||
}
|
||||
|
||||
FirebaseMessaging.onMessage.listen((message) {
|
||||
_onMessageSub = FirebaseMessaging.onMessage.listen((message) {
|
||||
if (!mounted) return;
|
||||
NotificationController.onForegroundMessageHandler(message, context);
|
||||
});
|
||||
@@ -177,7 +185,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
NotificationController.onBackgroundMessageHandler,
|
||||
);
|
||||
|
||||
FirebaseMessaging.onMessageOpenedApp.listen((message) {
|
||||
_onMessageOpenedAppSub = FirebaseMessaging.onMessageOpenedApp.listen((
|
||||
message,
|
||||
) {
|
||||
if (!mounted) return;
|
||||
NotificationController.onAppOpenedByNotification(message, context);
|
||||
});
|
||||
@@ -193,6 +203,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
void dispose() {
|
||||
_updateTimings.cancel();
|
||||
_timetableWidgetSync?.cancel();
|
||||
_onMessageSub?.cancel();
|
||||
_onMessageOpenedAppSub?.cancel();
|
||||
_fcmTokenRefreshSub?.cancel();
|
||||
ShareIntentListener.pending.removeListener(_handlePendingShare);
|
||||
ShareIntentListener.instance.detach();
|
||||
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
||||
@@ -279,7 +292,12 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
key: ValueKey(config.items.length),
|
||||
navBarConfig: config,
|
||||
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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -26,9 +26,17 @@ class NotificationController {
|
||||
BuildContext context,
|
||||
) async {
|
||||
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 =
|
||||
pushToken != null && pushToken.isNotEmpty && pushToken == activeToken;
|
||||
chatBloc.hasOpenChat &&
|
||||
pushToken != null &&
|
||||
pushToken.isNotEmpty &&
|
||||
pushToken == activeToken;
|
||||
|
||||
NotificationTasks.updateBadgeCount(message);
|
||||
|
||||
|
||||
@@ -36,6 +36,12 @@ class ChatBloc
|
||||
/// popping a stacked chat.
|
||||
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);
|
||||
|
||||
ChatBloc({ChatListBloc? chatListBloc}) : _chatListBloc = chatListBloc {
|
||||
@@ -166,10 +172,19 @@ class ChatBloc
|
||||
},
|
||||
onNetworkData: (data) {
|
||||
// Server-side mark runs unconditionally with the freshly-fetched
|
||||
// maxId. Skipping it on stillCurrent==false would leave the
|
||||
// server cursor wherever a quick navigation away left it.
|
||||
// maxId — skipping it on stillCurrent==false would leave the
|
||||
// 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);
|
||||
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;
|
||||
_applyChatResponse(data);
|
||||
if (maxId > 0) _chatListBloc?.markRoomAsRead(token, maxId);
|
||||
|
||||
@@ -107,6 +107,19 @@ class ChatListBloc
|
||||
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
|
||||
/// reacts before a refresh roundtrip lands. Server-side mark-as-read
|
||||
/// is the caller's job (see [ChatBloc.sendServerReadMarker]).
|
||||
|
||||
@@ -102,6 +102,7 @@ class MarianumDateRow extends StatelessWidget {
|
||||
initialDescription: event.description,
|
||||
initialStart: event.start,
|
||||
initialEnd: event.end,
|
||||
initialAllDay: event.isAllDay,
|
||||
),
|
||||
barrierDismissible: false,
|
||||
),
|
||||
|
||||
@@ -67,12 +67,12 @@ class AboutSection extends StatelessWidget {
|
||||
applicationIcon: const Icon(Icons.apps),
|
||||
applicationName: 'MarianumMobile',
|
||||
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:
|
||||
'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'
|
||||
"${kReleaseMode ? "Production" : "Development"} build\n"
|
||||
'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller',
|
||||
"${kReleaseMode ? "Production" : "Development ${kProfileMode ? "(Profiling)" : "(Debug)"}"} build.\n\n"
|
||||
'Marianum Fulda 2019-2020, 2023-${Jiffy.now().year}\nElias Müller',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ class AboutSection extends StatelessWidget {
|
||||
),
|
||||
ListTile(
|
||||
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'),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () => PrivacyInfo(
|
||||
@@ -106,7 +106,7 @@ class AboutSection extends StatelessWidget {
|
||||
Icon(Icons.send_time_extension_outlined),
|
||||
),
|
||||
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),
|
||||
onTap: () => PrivacyInfo(
|
||||
providerText: 'mhsl',
|
||||
|
||||
@@ -186,7 +186,11 @@ void _openOrCreateDirectChat(
|
||||
// Pop the current ChatView before swapping the global ChatBloc token —
|
||||
// otherwise the previous group chat stays mounted in the back-stack and
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -78,22 +78,48 @@ class HighlightedLinkify extends StatefulWidget {
|
||||
}
|
||||
|
||||
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
|
||||
void dispose() {
|
||||
for (final r in _recognizers) {
|
||||
for (final r in _recognizers.values) {
|
||||
r.dispose();
|
||||
}
|
||||
_recognizers.clear();
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
for (final r in _recognizers) {
|
||||
r.dispose();
|
||||
}
|
||||
_recognizers.clear();
|
||||
_seenLinkKeys.clear();
|
||||
|
||||
final defaultStyle = widget.style ??
|
||||
Theme.of(context).textTheme.bodyMedium ??
|
||||
@@ -124,9 +150,8 @@ class _HighlightedLinkifyState extends State<HighlightedLinkify> {
|
||||
|
||||
for (final el in elements) {
|
||||
if (el is LinkableElement) {
|
||||
final recognizer = TapGestureRecognizer()
|
||||
..onTap = () => widget.onOpen?.call(el);
|
||||
_recognizers.add(recognizer);
|
||||
_seenLinkKeys.add(el.text);
|
||||
final recognizer = _recognizerFor(el);
|
||||
spans.addAll(
|
||||
buildHighlightedSpans(
|
||||
text: el.text,
|
||||
@@ -147,6 +172,8 @@ class _HighlightedLinkifyState extends State<HighlightedLinkify> {
|
||||
}
|
||||
}
|
||||
|
||||
_pruneUnseen();
|
||||
|
||||
return Text.rich(TextSpan(children: spans));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ class CustomEventEditDialog extends StatefulWidget {
|
||||
final DateTime? initialEnd;
|
||||
final String? initialTitle;
|
||||
final String? initialDescription;
|
||||
final bool? initialAllDay;
|
||||
|
||||
const CustomEventEditDialog({
|
||||
this.existingEvent,
|
||||
@@ -26,6 +27,7 @@ class CustomEventEditDialog extends StatefulWidget {
|
||||
this.initialEnd,
|
||||
this.initialTitle,
|
||||
this.initialDescription,
|
||||
this.initialAllDay,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -78,13 +80,18 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
_isAllDay = false;
|
||||
_isAllDay = widget.initialAllDay ?? false;
|
||||
if (_isAllDay) {
|
||||
_startTime = _defaultStart;
|
||||
_endTime = _defaultEnd;
|
||||
} else {
|
||||
final rawStart = widget.initialStart?.toTimeOfDay() ?? _defaultStart;
|
||||
final rawEnd = widget.initialEnd?.toTimeOfDay() ?? _defaultEnd;
|
||||
final clamped = _clampToVisibleWindow(rawStart, rawEnd);
|
||||
_startTime = clamped.$1;
|
||||
_endTime = clamped.$2;
|
||||
}
|
||||
}
|
||||
|
||||
static (TimeOfDay, TimeOfDay) _clampToVisibleWindow(
|
||||
TimeOfDay rawStart,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.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 '../../../../widget/debug/debug_tile.dart';
|
||||
import '../../../../widget/details_bottom_sheet.dart';
|
||||
import '../../../../widget/unimplemented_dialog.dart';
|
||||
|
||||
class WebuntisLessonSheet {
|
||||
static void show(
|
||||
@@ -72,7 +70,7 @@ class WebuntisLessonSheet {
|
||||
}).toList(),
|
||||
),
|
||||
_roomTile(context, state, lesson),
|
||||
_teacherTile(context, lesson),
|
||||
_teacherTile(lesson),
|
||||
if ((lesson.activityType ?? '').trim().isNotEmpty)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.abc),
|
||||
@@ -120,14 +118,15 @@ class WebuntisLessonSheet {
|
||||
final name = firstNonEmpty([resolved.name, r.name, '?']);
|
||||
final longname = firstNonEmpty([resolved.longName, r.longname, '']);
|
||||
final building = resolved.building.trim();
|
||||
return LessonFormatter.formatLine(
|
||||
final main = LessonFormatter.formatLine(
|
||||
name,
|
||||
longname: longname,
|
||||
extra: (building.isNotEmpty && building != '?') ? building : null,
|
||||
);
|
||||
final sub = (longname.isNotEmpty && longname != name) ? longname : null;
|
||||
return (main: main, sub: sub);
|
||||
}).toList();
|
||||
|
||||
return _listTile(
|
||||
return _listTileWithSubs(
|
||||
icon: Icons.room,
|
||||
label: lesson.ro.length == 1 ? 'Raum' : 'Räume',
|
||||
entries: entries,
|
||||
@@ -135,39 +134,63 @@ class WebuntisLessonSheet {
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _teacherTile(
|
||||
BuildContext context,
|
||||
GetTimetableResponseObject lesson,
|
||||
) {
|
||||
final trailing = Visibility(
|
||||
visible: !kReleaseMode,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.textsms_outlined),
|
||||
onPressed: () => UnimplementedDialog.show(context),
|
||||
),
|
||||
);
|
||||
|
||||
static Widget _teacherTile(GetTimetableResponseObject lesson) {
|
||||
if (lesson.te.isEmpty) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.person),
|
||||
title: const Text('Lehrkraft: ?'),
|
||||
trailing: trailing,
|
||||
return const ListTile(
|
||||
leading: Icon(Icons.person),
|
||||
title: Text('Lehrkraft: ?'),
|
||||
);
|
||||
}
|
||||
|
||||
final entries = lesson.te.map((t) {
|
||||
final base = LessonFormatter.formatLine(
|
||||
final main = LessonFormatter.formatLine(
|
||||
t.name.isNotEmpty ? t.name : '?',
|
||||
longname: t.longname,
|
||||
);
|
||||
final orgname = (t.orgname ?? '').trim();
|
||||
return orgname.isEmpty ? base : '$base · ehemals $orgname';
|
||||
return (main: main, sub: orgname.isEmpty ? null : 'ehemals $orgname');
|
||||
}).toList();
|
||||
|
||||
return _listTile(
|
||||
return _listTileWithSubs(
|
||||
icon: Icons.person,
|
||||
label: lesson.te.length == 1 ? 'Lehrkraft' : 'Lehrkräfte',
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -254,7 +254,26 @@ class _FileViewerState extends State<FileViewer> {
|
||||
break;
|
||||
case FileViewingActions.save:
|
||||
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(
|
||||
fileName: widget.path.split('/').last,
|
||||
bytes: bytes,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
@@ -29,11 +30,44 @@ class _AvatarPayload {
|
||||
_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
|
||||
// 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 = {};
|
||||
|
||||
_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> {
|
||||
_AvatarPayload? _payload;
|
||||
|
||||
@@ -63,14 +97,15 @@ class _UserAvatarState extends State<UserAvatar> {
|
||||
|
||||
void _attach() {
|
||||
final url = _url();
|
||||
if (_resolvedAvatars.containsKey(url)) {
|
||||
_payload = _resolvedAvatars[url];
|
||||
final cached = _readAvatarCache(url);
|
||||
if (cached != null) {
|
||||
_payload = cached.payload;
|
||||
return;
|
||||
}
|
||||
_payload = null;
|
||||
final pending = _pendingAvatars.putIfAbsent(url, () => _fetch(url));
|
||||
pending.then((p) {
|
||||
_resolvedAvatars[url] = p;
|
||||
_writeAvatarCache(url, p);
|
||||
_pendingAvatars.remove(url);
|
||||
if (!mounted || _url() != url) return;
|
||||
setState(() => _payload = p);
|
||||
|
||||
Reference in New Issue
Block a user