From a0bc46f522805823fab2931c7ad4977ad13e505a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 10 May 2026 16:40:39 +0200 Subject: [PATCH] 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 --- lib/app.dart | 34 +++++++-- lib/notification/notification_controller.dart | 12 ++- .../app/modules/chat/bloc/chat_bloc.dart | 21 +++++- .../chat_list/bloc/chat_list_bloc.dart | 13 ++++ .../widgets/event_list_tile.dart | 1 + .../settings/sections/about_section.dart | 10 +-- .../widgets/chat_message_options_dialog.dart | 6 +- .../talk/widgets/highlighted_linkify.dart | 45 +++++++++--- .../custom_event_edit_dialog.dart | 19 +++-- .../details/webuntis_lesson_sheet.dart | 73 ++++++++++++------- lib/widget/file_viewer.dart | 21 +++++- lib/widget/user_avatar.dart | 43 ++++++++++- 12 files changed, 234 insertions(+), 64 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 94cb313..7edab89 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -38,6 +38,9 @@ class App extends StatefulWidget { class _AppState extends State with WidgetsBindingObserver { late Timer _updateTimings; StreamSubscription? _timetableWidgetSync; + StreamSubscription? _onMessageSub; + StreamSubscription? _onMessageOpenedAppSub; + StreamSubscription? _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 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 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 with WidgetsBindingObserver { if (context.read().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 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 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 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, ), ), diff --git a/lib/notification/notification_controller.dart b/lib/notification/notification_controller.dart index e8a7a2c..f02e3af 100644 --- a/lib/notification/notification_controller.dart +++ b/lib/notification/notification_controller.dart @@ -26,9 +26,17 @@ class NotificationController { BuildContext context, ) async { final pushToken = _extractChatToken(message); - final activeToken = context.read().state.data?.currentToken ?? ''; + final chatBloc = context.read(); + // 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); diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index 7a6422f..05a97b8 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -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); diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart index 0c4a353..fbbaa8b 100644 --- a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart @@ -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]). diff --git a/lib/view/pages/marianum_dates/widgets/event_list_tile.dart b/lib/view/pages/marianum_dates/widgets/event_list_tile.dart index 8fb09f9..86ef73a 100644 --- a/lib/view/pages/marianum_dates/widgets/event_list_tile.dart +++ b/lib/view/pages/marianum_dates/widgets/event_list_tile.dart @@ -102,6 +102,7 @@ class MarianumDateRow extends StatelessWidget { initialDescription: event.description, initialStart: event.start, initialEnd: event.end, + initialAllDay: event.isAllDay, ), barrierDismissible: false, ), diff --git a/lib/view/pages/settings/sections/about_section.dart b/lib/view/pages/settings/sections/about_section.dart index b2fa812..9b18443 100644 --- a/lib/view/pages/settings/sections/about_section.dart +++ b/lib/view/pages/settings/sections/about_section.dart @@ -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', diff --git a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart index c7e0fea..99a9f43 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -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); } diff --git a/lib/view/pages/talk/widgets/highlighted_linkify.dart b/lib/view/pages/talk/widgets/highlighted_linkify.dart index 026b5a4..f128b9c 100644 --- a/lib/view/pages/talk/widgets/highlighted_linkify.dart +++ b/lib/view/pages/talk/widgets/highlighted_linkify.dart @@ -78,22 +78,48 @@ class HighlightedLinkify extends StatefulWidget { } class _HighlightedLinkifyState extends State { - final List _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 _recognizers = {}; + final Set _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 { 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 { } } + _pruneUnseen(); + return Text.rich(TextSpan(children: spans)); } } diff --git a/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart index 8dd64f2..cbb3754 100644 --- a/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart +++ b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart @@ -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,12 +80,17 @@ class _CustomEventEditDialogState extends State { } return; } - _isAllDay = false; - final rawStart = widget.initialStart?.toTimeOfDay() ?? _defaultStart; - final rawEnd = widget.initialEnd?.toTimeOfDay() ?? _defaultEnd; - final clamped = _clampToVisibleWindow(rawStart, rawEnd); - _startTime = clamped.$1; - _endTime = clamped.$2; + _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( diff --git a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart index a5cd101..62c7d15 100644 --- a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart +++ b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart @@ -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( + (e) => [ + Text(e.main), + if (e.sub != null) + Padding( + padding: const EdgeInsets.only(left: 12), + child: Text(e.sub!), + ), + ], + ) + .toList(), + ), trailing: trailing, ); } diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index 55b09ef..f9fe9c1 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -254,7 +254,26 @@ class _FileViewerState extends State { 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, diff --git a/lib/widget/user_avatar.dart b/lib/widget/user_avatar.dart index a853b39..804846e 100644 --- a/lib/widget/user_avatar.dart +++ b/lib/widget/user_avatar.dart @@ -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 _resolvedAvatars = {}; +final LinkedHashMap _resolvedAvatars = + LinkedHashMap(); final Map> _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 { _AvatarPayload? _payload; @@ -63,14 +97,15 @@ class _UserAvatarState extends State { 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);