simplify: dedupe boilerplate and remove dead code across all layers

This commit is contained in:
2026-07-13 22:22:39 +02:00
parent 15791423ea
commit 398b147c76
69 changed files with 345 additions and 651 deletions
+10 -18
View File
@@ -62,12 +62,14 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
);
}
void _resetProgress() {
_isUploading = false;
_overallProgressValue = 0.0;
_infoText = '';
}
void _showUploadError(String message) {
setState(() {
_isUploading = false;
_overallProgressValue = 0.0;
_infoText = '';
});
setState(_resetProgress);
InfoDialog.show(
context,
message,
@@ -157,9 +159,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
if (replaceFiles != true) {
setState(() {
_isUploading = false;
_overallProgressValue = 0.0;
_infoText = '';
_resetProgress();
for (var element in conflictingFiles) {
element.isConflicting = true;
}
@@ -222,11 +222,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
}
if (uploadTask.statusCode < 200 || uploadTask.statusCode > 299) {
setState(() {
_isUploading = false;
_overallProgressValue = 0.0;
_infoText = '';
});
setState(_resetProgress);
if (!mounted) return;
Navigator.of(context).pop();
showHttpErrorCode(uploadTask.statusCode);
@@ -235,11 +231,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
}
}
setState(() {
_isUploading = false;
_overallProgressValue = 0.0;
_infoText = '';
});
setState(_resetProgress);
if (!mounted) return;
Navigator.of(context).pop();
widget.onUploadFinished(uploadetFilePaths);
@@ -24,19 +24,12 @@ class GradeAveragesView extends StatelessWidget {
Visibility(
visible: bloc.state.grades.isNotEmpty,
child: IconButton(
onPressed: () {
showDialog(
context: context,
builder: (context) => ConfirmDialog(
title: 'Zurücksetzen?',
content: 'Alle Einträge werden entfernt.',
confirmButton: 'Zurücksetzen',
onConfirm: () {
bloc.add(ResetAll());
},
),
);
},
onPressed: () => ConfirmDialog(
title: 'Zurücksetzen?',
content: 'Alle Einträge werden entfernt.',
confirmButton: 'Zurücksetzen',
onConfirm: () => bloc.add(ResetAll()),
).asDialog(context),
icon: const Icon(Icons.delete_forever),
),
),
@@ -64,17 +57,14 @@ class GradeAveragesView extends StatelessWidget {
.toList(),
onSelected: (isMiddleSchool) {
if (bloc.state.grades.isNotEmpty) {
showDialog(
context: context,
builder: (context) => ConfirmDialog(
title: 'Notensystem wechseln',
content:
'Beim Wechsel des Notensystems werden alle Einträge zurückgesetzt.',
confirmButton: 'Fortfahren',
onConfirm: () =>
bloc.add(GradingSystemChanged(isMiddleSchool)),
),
);
ConfirmDialog(
title: 'Notensystem wechseln',
content:
'Beim Wechsel des Notensystems werden alle Einträge zurückgesetzt.',
confirmButton: 'Fortfahren',
onConfirm: () =>
bloc.add(GradingSystemChanged(isMiddleSchool)),
).asDialog(context);
} else {
bloc.add(GradingSystemChanged(isMiddleSchool));
}
@@ -27,7 +27,7 @@ class MarianumDatesView extends StatelessWidget {
return keys.map((key) {
final first = byMonth[key]!.first.start;
final label = first.formatMonthYear().toUpperCase();
return _MonthGroup(key: key, label: label, events: byMonth[key]!);
return _MonthGroup(label: label, events: byMonth[key]!);
}).toList();
}
@@ -117,8 +117,7 @@ class MarianumDatesView extends StatelessWidget {
}
class _MonthGroup {
final String key;
final String label;
final List<MarianumDate> events;
_MonthGroup({required this.key, required this.label, required this.events});
_MonthGroup({required this.label, required this.events});
}
@@ -6,6 +6,7 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_bloc.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
import '../../../widget/centered_leading.dart';
import 'search_marianum_messages.dart';
class MarianumMessageListView extends StatelessWidget {
@@ -40,12 +41,9 @@ class MarianumMessageListView extends StatelessWidget {
child: (state, loading) => ListView.builder(
itemCount: state.messageList.messages.length,
itemBuilder: (context, index) {
var message = state.messageList.messages.toList()[index];
var message = state.messageList.messages[index];
return ListTile(
leading: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [Icon(Icons.newspaper)],
),
leading: const CenteredLeading(Icon(Icons.newspaper)),
title: Text(message.name, overflow: TextOverflow.ellipsis),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -2,7 +2,6 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../api/marianumconnect/queries/get_newsletter_file/get_newsletter_file.dart';
import '../../../widget/app_progress_indicator.dart';
@@ -39,20 +38,8 @@ class _MessageViewState extends State<MessageView> {
return SfPdfViewer.memory(
snapshot.data!,
enableHyperlinkNavigation: true,
onHyperlinkClicked: (PdfHyperlinkClickedDetails e) {
showDialog(
context: context,
builder: (context) => ConfirmDialog(
title: 'Link öffnen',
content: 'Möchtest du den folgenden Link öffnen?\n${e.uri}',
confirmButton: 'Öffnen',
onConfirm: () => launchUrl(
Uri.parse(e.uri),
mode: LaunchMode.externalApplication,
),
),
);
},
onHyperlinkClicked: (PdfHyperlinkClickedDetails e) =>
ConfirmDialog.openBrowser(context, e.uri),
);
},
),
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../../routing/app_routes.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
import '../../../widget/centered_leading.dart';
import '../../../widget/placeholder_view.dart';
class SearchMarianumMessages extends SearchDelegate<MarianumMessage?> {
@@ -45,10 +46,7 @@ class SearchMarianumMessages extends SearchDelegate<MarianumMessage?> {
itemBuilder: (_, i) {
final message = matches[i];
return ListTile(
leading: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [Icon(Icons.newspaper)],
),
leading: const CenteredLeading(Icon(Icons.newspaper)),
title: Text(message.name, overflow: TextOverflow.ellipsis),
subtitle: Text('vom ${message.date}'),
trailing: const Icon(Icons.arrow_right),
@@ -1,7 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:jiffy/jiffy.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
@@ -71,7 +70,7 @@ class AboutSection extends StatelessWidget {
applicationLegalese:
'Alles für deinen Schulalltag am Marianum Fulda.\n\n'
"${kReleaseMode ? "Production" : "Development ${kProfileMode ? "(Profiling)" : "(Debug)"}"} build.\n\n"
'Marianum Fulda\n2019-2020 & 2022-${Jiffy.now().year}\nElias Müller',
'Marianum Fulda\n2019-2020 & 2022-${DateTime.now().year}\nElias Müller',
);
}
@@ -166,13 +166,24 @@ Future<void> _afterExternalFilesUploaded(
GetRoomResponseObject room,
List<String> uploadedRemotePaths,
PendingShare share,
) async {
) => _runShareFlow(
context,
action: () =>
shareFilesToChat(token: room.token, remoteFilePaths: uploadedRemotePaths),
onSuccess: () => _setExternalDraftAndOpenChat(context, room, share),
);
/// Shared share-flow scaffolding: shows the blocking spinner, runs [action],
/// maps failures to an error dialog (popping the spinner first), and invokes
/// [onSuccess] on success while still mounted.
Future<void> _runShareFlow(
BuildContext context, {
required Future<void> Function() action,
required VoidCallback onSuccess,
}) async {
unawaited(_showBlockingSpinner(context));
try {
await shareFilesToChat(
token: room.token,
remoteFilePaths: uploadedRemotePaths,
);
await action();
} catch (e) {
if (context.mounted) Navigator.of(context).pop();
if (context.mounted) {
@@ -186,7 +197,7 @@ Future<void> _afterExternalFilesUploaded(
return;
}
if (!context.mounted) return;
_setExternalDraftAndOpenChat(context, room, share);
onSuccess();
}
void _setExternalDraftAndOpenChat(
@@ -213,61 +224,30 @@ Future<void> _internalShareFlow(
BuildContext context,
GetRoomResponseObject room,
RemoteFileRef file,
) async {
unawaited(_showBlockingSpinner(context));
try {
await shareFilesToChat(
token: room.token,
remoteFilePaths: [file.path],
);
} catch (e) {
if (context.mounted) Navigator.of(context).pop();
if (context.mounted) {
InfoDialog.show(
context,
errorToUserMessage(e),
title: 'Fehler',
copyable: true,
);
}
return;
}
if (!context.mounted) return;
_finishWithChat(context, room);
}
) => _runShareFlow(
context,
action: () =>
shareFilesToChat(token: room.token, remoteFilePaths: [file.path]),
onSuccess: () => _finishWithChat(context, room),
);
Future<void> _forwardMessageFlow(
BuildContext context,
GetRoomResponseObject room,
String? text,
RemoteFileRef? file,
) async {
unawaited(_showBlockingSpinner(context));
try {
) => _runShareFlow(
context,
action: () async {
if (file != null) {
await shareFilesToChat(
token: room.token,
remoteFilePaths: [file.path],
);
await shareFilesToChat(token: room.token, remoteFilePaths: [file.path]);
}
if (text != null && text.isNotEmpty) {
await SendMessage(room.token, SendMessageParams(text)).run();
}
} catch (e) {
if (context.mounted) Navigator.of(context).pop();
if (context.mounted) {
InfoDialog.show(
context,
errorToUserMessage(e),
title: 'Fehler',
copyable: true,
);
}
return;
}
if (!context.mounted) return;
_finishWithChat(context, room);
}
},
onSuccess: () => _finishWithChat(context, room),
);
/// Modal progress overlay shown during share-API roundtrips. The dialog is
/// popped together with the picker by the subsequent popUntil(isFirst).
@@ -121,26 +121,15 @@ class ShareTargetPage extends StatelessWidget {
Widget _buildFilePreview(BuildContext context) {
if (share.filePaths.length == 1) {
final path = share.filePaths.first;
final name = path.split(Platform.pathSeparator).last;
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 320),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(12),
),
clipBehavior: Clip.antiAlias,
child: _isImagePath(path)
? Image.file(
File(path),
fit: BoxFit.contain,
// Decode at most ~1080px so 50-MP gallery photos don't
// balloon the decode buffer just to render at <320px high.
cacheWidth: 1080,
errorBuilder: (_, _, _) => _fileFallbackLarge(name),
)
: _fileFallbackLarge(name),
// Decode at most ~1080px so 50-MP gallery photos don't balloon the
// decode buffer just to render at <320px high.
child: _filePreviewTile(
context,
share.filePaths.first,
fit: BoxFit.contain,
cacheWidth: 1080,
),
);
}
@@ -153,28 +142,38 @@ class ShareTargetPage extends StatelessWidget {
mainAxisSpacing: 10,
),
itemCount: share.filePaths.length,
itemBuilder: (context, i) {
final path = share.filePaths[i];
final name = path.split(Platform.pathSeparator).last;
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(12),
),
clipBehavior: Clip.antiAlias,
child: _isImagePath(path)
? Image.file(
File(path),
fit: BoxFit.cover,
// Grid tiles are ~half-screen wide; 480px decode is
// sharp on 3x displays without blowing up memory when
// many files are shared at once.
cacheWidth: 480,
errorBuilder: (_, _, _) => _fileFallbackLarge(name),
)
: _fileFallbackLarge(name),
);
},
// Grid tiles are ~half-screen wide; 480px decode is sharp on 3x displays
// without blowing up memory when many files are shared at once.
itemBuilder: (context, i) => _filePreviewTile(
context,
share.filePaths[i],
fit: BoxFit.cover,
cacheWidth: 480,
),
);
}
Widget _filePreviewTile(
BuildContext context,
String path, {
required BoxFit fit,
required int cacheWidth,
}) {
final name = path.split(Platform.pathSeparator).last;
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(12),
),
clipBehavior: Clip.antiAlias,
child: _isImagePath(path)
? Image.file(
File(path),
fit: fit,
cacheWidth: cacheWidth,
errorBuilder: (_, _, _) => _fileFallbackLarge(name),
)
: _fileFallbackLarge(name),
);
}
+7 -11
View File
@@ -110,10 +110,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
if (state.currentToken != widget.room.token) return;
final response = state.chatResponse;
if (response == null) return;
var maxId = 0;
for (final m in response.data) {
if (m.id > maxId) maxId = m.id;
}
final maxId = response.data.map((m) => m.id).fold<int>(0, math.max);
if (maxId == 0) return;
_chatListBlocRef?.markRoomAsRead(widget.room.token, maxId);
unawaited(_chatBlocRef!.sendServerReadMarker(widget.room.token, maxId));
@@ -230,21 +227,20 @@ class _ChatViewState extends State<ChatView> with RouteAware {
? _searchQuery
: null;
final commonRead = int.parse(
response.headers?['x-chat-last-common-read'] ?? '0',
);
final messages = <Widget>[];
final chronologicalMatchIndex = <int, int>{};
var lastDate = DateTime.now();
for (final element in response.sortByTimestamp()) {
if (ChatSearchController.isHiddenSystemMessage(element)) continue;
final elementDate = DateTime.fromMillisecondsSinceEpoch(
element.timestamp * 1000,
);
if (element.systemMessage.contains('reaction')) continue;
if (element.systemMessage.contains('poll_voted')) continue;
if (element.systemMessage.contains('message_deleted')) continue;
final commonRead = int.parse(
response.headers?['x-chat-last-common-read'] ?? '0',
);
if (!elementDate.isSameDay(lastDate)) {
lastDate = elementDate;
messages.add(
@@ -4,18 +4,6 @@ import '../../../../theming/app_theme.dart';
import '../widgets/bubble.dart';
extension ColorExtensions on Color {
Color invert() {
final invertedR = 1.0 - r;
final invertedG = 1.0 - g;
final invertedB = 1.0 - b;
return Color.from(
alpha: a,
red: invertedR,
green: invertedG,
blue: invertedB,
);
}
Color withWhite(int whiteValue) {
final value = whiteValue / 255.0;
return Color.from(alpha: a, red: value, green: value, blue: value);
@@ -16,8 +16,6 @@ class ChatMessage {
RichObjectString? file;
String content = '';
bool get containsFile => file != null;
ChatMessage({required this.originalMessage, this.originalData}) {
if (originalData?.containsKey('file') ?? false) {
file = originalData?['file'];
@@ -10,6 +10,14 @@ class ChatSearchMatch {
}
class ChatSearchController {
/// System messages that are folded into other bubbles (reactions, poll
/// votes, deletions) and therefore never rendered nor searched as their own
/// entry.
static bool isHiddenSystemMessage(GetChatResponseObject element) =>
element.systemMessage.contains('reaction') ||
element.systemMessage.contains('poll_voted') ||
element.systemMessage.contains('message_deleted');
static List<ChatSearchMatch> findMatches(
GetChatResponse response,
String query,
@@ -19,9 +27,7 @@ class ChatSearchController {
final matches = <ChatSearchMatch>[];
for (final element in response.sortByTimestamp()) {
if (element.systemMessage.contains('reaction')) continue;
if (element.systemMessage.contains('poll_voted')) continue;
if (element.systemMessage.contains('message_deleted')) continue;
if (isHiddenSystemMessage(element)) continue;
final haystackText = RichObjectStringProcessor.parseToString(
element.message,
@@ -17,28 +17,17 @@ class AnswerReference extends StatelessWidget {
@override
Widget build(BuildContext context) {
var style = ChatBubbleStyles(context);
final style = ChatBubbleStyles(context);
final isSelf = referenceMessage.actorId == selfId;
final accent = isSelf
? style.getSelfStyle(false).color!.withGreen(200)
: style.getRemoteStyle(false).color!.withWhite(200);
return DecoratedBox(
decoration: BoxDecoration(
color: referenceMessage.actorId == selfId
? style
.getSelfStyle(false)
.color!
.withGreen(200)
.withValues(alpha: 0.2)
: style
.getRemoteStyle(false)
.color!
.withWhite(200)
.withValues(alpha: 0.2),
color: accent.withValues(alpha: 0.2),
borderRadius: const BorderRadius.all(Radius.circular(5)),
border: Border(
left: BorderSide(
color: referenceMessage.actorId == selfId
? style.getSelfStyle(false).color!.withGreen(200)
: style.getRemoteStyle(false).color!.withWhite(200),
width: 5,
),
left: BorderSide(color: accent, width: 5),
),
),
child: Padding(
@@ -51,9 +40,7 @@ class AnswerReference extends StatelessWidget {
maxLines: 1,
style: TextStyle(
overflow: TextOverflow.ellipsis,
color: referenceMessage.actorId == selfId
? style.getSelfStyle(false).color!.withGreen(200)
: style.getRemoteStyle(false).color!.withWhite(200),
color: accent,
fontSize: 12,
),
),
@@ -8,6 +8,7 @@ import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'
import '../../../../storage/timetable_settings.dart';
import 'arbitrary_appointment.dart';
import 'lesson_color.dart';
import 'lesson_merger.dart';
import 'lesson_status.dart';
import 'lesson_type_label.dart';
import 'rrule_with_exceptions.dart';
@@ -33,7 +34,7 @@ class TimetableAppointmentFactory {
List<Appointment> build() {
final source = settings.connectDoubleLessons
? _mergeAdjacentLessons(lessons)
? LessonMerger.merge(lessons)
: lessons;
return [
...source.map(_lessonToAppointment),
@@ -267,68 +268,4 @@ class TimetableAppointmentFactory {
.trim();
return cleaned.isEmpty ? null : cleaned;
}
// Pure: builds a new list, does not mutate inputs. The previous version
// mutated `previous.endTime` in place which caused merged blocks to grow
// further on subsequent rebuilds when the same lesson objects were observed
// again by the next merge pass.
static List<McTimetableEntry> _mergeAdjacentLessons(
List<McTimetableEntry> input, {
Duration maxGap = const Duration(minutes: 5),
}) {
if (input.isEmpty) return const [];
final sorted = [...input]
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
final merged = <McTimetableEntry>[];
for (final current in sorted) {
if (merged.isNotEmpty && _canMerge(merged.last, current, maxGap)) {
final prev = merged.removeLast();
merged.add(_extendedEnd(prev, current.endTime));
} else {
merged.add(current);
}
}
return merged;
}
static McTimetableEntry _extendedEnd(
McTimetableEntry source,
DateTime newEndTime,
) => McTimetableEntry(
id: source.id,
date: source.date,
startTime: source.startTime,
endTime: newEndTime,
subjects: source.subjects,
teachers: source.teachers,
rooms: source.rooms,
classNames: source.classNames,
lessonType: source.lessonType,
status: source.status,
substitutionText: source.substitutionText,
lessonText: source.lessonText,
infoText: source.infoText,
);
static bool _canMerge(
McTimetableEntry a,
McTimetableEntry b,
Duration maxGap,
) {
if (a.subjects.firstOrNull != b.subjects.firstOrNull) return false;
if (a.rooms.firstOrNull != b.rooms.firstOrNull) return false;
if (a.teachers.firstOrNull?.shortName !=
b.teachers.firstOrNull?.shortName) {
return false;
}
if (a.status != b.status) return false;
// Merge only sequential lessons (b starts at or after a ends, within the
// tolerance). Without the lower bound, identical-metadata lessons that
// overlap in time would silently collapse into one.
final gap = b.startDateTime.difference(a.endDateTime);
return !gap.isNegative && gap <= maxGap;
}
}
@@ -268,12 +268,10 @@ class LessonSheet {
);
}
static String _line(String name, {String? longname, String? extra}) {
static String _line(String name, {String? longname}) {
final parts = <String>[if (name.isNotEmpty) name else '?'];
final ln = (longname ?? '').trim();
if (ln.isNotEmpty && ln != name) parts.add('($ln)');
final ex = (extra ?? '').trim();
if (ex.isNotEmpty) parts.add('· $ex');
return parts.join(' ');
}
@@ -116,12 +116,9 @@ class _OutsideDayColumn extends StatelessWidget {
static String _subtitleFor(Appointment a) {
if (isAllDayLike(a)) return 'Ganztägig';
return '${_hm(a.startTime)}${_hm(a.endTime)}';
return '${a.startTime.formatHm()}${a.endTime.formatHm()}';
}
static String _hm(DateTime t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
@override
Widget build(BuildContext context) {
if (appointments.isEmpty) return const SizedBox.shrink();
@@ -182,9 +179,7 @@ class _OutsideChip extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final allDay = isAllDayLike(appointment);
final timeLabel = allDay
? null
: '${appointment.startTime.hour.toString().padLeft(2, '0')}:${appointment.startTime.minute.toString().padLeft(2, '0')}';
final timeLabel = allDay ? null : appointment.startTime.formatHm();
// Past chips fade further, future/ongoing ones get a more saturated tint
// so the strip no longer reads as one uniform grey block.
@@ -289,14 +289,11 @@ class _DayColumn extends StatelessWidget {
}
static String _overflowSubtitle(Appointment apt) {
final time = '${_formatHm(apt.startTime)}${_formatHm(apt.endTime)}';
final time = '${apt.startTime.formatHm()}${apt.endTime.formatHm()}';
final loc = apt.location?.replaceAll('\n', ' · ');
return loc != null && loc.isNotEmpty ? '$time · $loc' : time;
}
static String _formatHm(DateTime t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);