implemented dynamic module settings and configurable bottom bar, added all-day event support to timetable, and overhauled marianum dates UI with month grouping and search

This commit is contained in:
2026-05-06 22:37:41 +02:00
parent 86d12884fc
commit 95ef29fb09
19 changed files with 1114 additions and 253 deletions
@@ -9,8 +9,8 @@ import 'package:time_range_picker/time_range_picker.dart';
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../../../extensions/date_time.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/focus_behaviour.dart';
import '../../../../widget/info_dialog.dart';
import 'custom_event_colors.dart';
class CustomEventEditDialog extends StatefulWidget {
@@ -34,15 +34,18 @@ class CustomEventEditDialog extends StatefulWidget {
}
class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
// Visible window of the timetable / time picker (matches `_pickTimeRange`'s
// `disabledTime`). Pre-filled times from outside this window are clamped in.
// Selectable window for non-all-day events. Times outside this range are
// clamped in. For events outside school hours, use the all-day toggle.
static const TimeOfDay _windowStart = TimeOfDay(hour: 8, minute: 0);
static const TimeOfDay _windowEnd = TimeOfDay(hour: 16, minute: 30);
static const TimeOfDay _defaultStart = _windowStart;
static const TimeOfDay _defaultEnd = TimeOfDay(hour: 9, minute: 30);
static const int _minDurationMinutes = 15;
late DateTime _date = widget.existingEvent?.startDate ?? widget.initialStart ?? DateTime.now();
late TimeOfDay _startTime;
late TimeOfDay _endTime;
late bool _isAllDay;
late final TextEditingController _name = TextEditingController(
text: widget.existingEvent?.title ?? widget.initialTitle,
);
@@ -61,12 +64,22 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
void initState() {
super.initState();
if (_isEditing) {
_startTime = widget.existingEvent!.startDate.toTimeOfDay();
_endTime = widget.existingEvent!.endDate.toTimeOfDay();
final s = widget.existingEvent!.startDate;
final e = widget.existingEvent!.endDate;
_isAllDay = isAllDayConvention(s, e);
if (_isAllDay) {
_startTime = _defaultStart;
_endTime = _defaultEnd;
} else {
final clamped = _clampToVisibleWindow(s.toTimeOfDay(), e.toTimeOfDay());
_startTime = clamped.$1;
_endTime = clamped.$2;
}
return;
}
final rawStart = widget.initialStart?.toTimeOfDay() ?? _windowStart;
final rawEnd = widget.initialEnd?.toTimeOfDay() ?? const TimeOfDay(hour: 9, minute: 30);
_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;
@@ -88,17 +101,38 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
return (fromMin(start), fromMin(end));
}
bool _validate() => _name.text.isNotEmpty;
/// All-day convention shared with [TimetableAppointmentFactory]: a custom
/// event is treated as all-day when its start and end both land on midnight
/// of the same day. We piggyback on this so we don't need a backend schema
/// change.
static bool isAllDayConvention(DateTime start, DateTime end) =>
start.year == end.year &&
start.month == end.month &&
start.day == end.day &&
start.hour == 0 &&
start.minute == 0 &&
start.second == 0 &&
end.hour == 0 &&
end.minute == 0 &&
end.second == 0;
void _save() {
if (!_validate()) return;
Future<void> _save() async {
if (_name.text.trim().isEmpty) {
throw Exception('Bitte einen Terminnamen eingeben.');
}
// All-day convention: store start and end as midnight of the chosen day.
// The factory recognises this on read.
final midnight = DateTime(_date.year, _date.month, _date.day);
final startDate = _isAllDay ? midnight : _date.withTime(_startTime);
final endDate = _isAllDay ? midnight : _date.withTime(_endTime);
final edited = CustomTimetableEvent(
id: widget.existingEvent?.id ?? '',
title: _name.text,
description: _description.text,
startDate: _date.withTime(_startTime),
endDate: _date.withTime(_endTime),
startDate: startDate,
endDate: endDate,
color: _color.name,
rrule: _rrule,
createdAt: DateTime.now(),
@@ -106,17 +140,11 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
);
final bloc = context.read<TimetableBloc>();
final future = _isEditing
? bloc.updateCustomEvent(widget.existingEvent!.id, edited)
: bloc.addCustomEvent(edited);
future.then((_) {
if (!mounted) return;
Navigator.of(context).pop();
}).catchError((Object error) {
if (!mounted) return;
InfoDialog.show(context, error.toString(), copyable: true, title: 'Fehler');
});
if (_isEditing) {
await bloc.updateCustomEvent(widget.existingEvent!.id, edited);
} else {
await bloc.addCustomEvent(edited);
}
}
Future<void> _pickDate() async {
@@ -138,8 +166,8 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
start: _startTime,
end: _endTime,
disabledTime: TimeRange(
startTime: const TimeOfDay(hour: 16, minute: 30),
endTime: const TimeOfDay(hour: 8, minute: 0),
startTime: _windowEnd,
endTime: _windowStart,
),
disabledColor: Colors.grey,
paintingStyle: PaintingStyle.fill,
@@ -147,7 +175,7 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
fromText: 'Beginnend',
toText: 'Endend',
strokeColor: Theme.of(context).colorScheme.secondary,
minDuration: const Duration(minutes: 15),
minDuration: Duration(minutes: _minDurationMinutes),
selectedColor: Theme.of(context).primaryColor,
ticks: 24,
);
@@ -191,12 +219,19 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
subtitle: const Text('Datum'),
onTap: _pickDate,
),
ListTile(
leading: const Icon(Icons.access_time_outlined),
title: Text('${_startTime.format(context)} - ${_endTime.format(context)}'),
subtitle: const Text('Zeitraum'),
onTap: _pickTimeRange,
SwitchListTile(
secondary: const Icon(Icons.today_outlined),
title: const Text('Ganztägig'),
value: _isAllDay,
onChanged: (v) => setState(() => _isAllDay = v),
),
if (!_isAllDay)
ListTile(
leading: const Icon(Icons.access_time_outlined),
title: Text('${_startTime.format(context)} - ${_endTime.format(context)}'),
subtitle: const Text('Zeitraum'),
onTap: _pickTimeRange,
),
const Divider(),
ListTile(
leading: const Icon(Icons.color_lens_outlined),
@@ -246,8 +281,10 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')),
TextButton(onPressed: _save, child: Text(_isEditing ? 'Speichern' : 'Erstellen')),
AsyncDialogAction(
confirmLabel: _isEditing ? 'Speichern' : 'Erstellen',
onConfirm: _save,
),
],
);
}