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 {
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,
),
),
+10 -2
View File
@@ -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);
+18 -3
View File
@@ -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,
);
}
+20 -1
View File
@@ -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,
+39 -4
View File
@@ -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);