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 {
|
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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +80,17 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_isAllDay = false;
|
_isAllDay = widget.initialAllDay ?? false;
|
||||||
final rawStart = widget.initialStart?.toTimeOfDay() ?? _defaultStart;
|
if (_isAllDay) {
|
||||||
final rawEnd = widget.initialEnd?.toTimeOfDay() ?? _defaultEnd;
|
_startTime = _defaultStart;
|
||||||
final clamped = _clampToVisibleWindow(rawStart, rawEnd);
|
_endTime = _defaultEnd;
|
||||||
_startTime = clamped.$1;
|
} else {
|
||||||
_endTime = clamped.$2;
|
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(
|
static (TimeOfDay, TimeOfDay) _clampToVisibleWindow(
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user