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
@@ -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,12 +80,17 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
}
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(
@@ -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,
);
}