From b957189fd3c0e97227431a95e744a54e73ba9749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 26 Jul 2026 11:45:48 +0200 Subject: [PATCH] implemented absence report module with form validation, prefill support, and API integration --- lib/api/demo/data/demo_absence.dart | 17 + lib/api/demo/demo_marianumconnect.dart | 5 + .../queries/absence/absence_classes.dart | 13 + .../queries/absence/absence_prefill.dart | 10 + .../absence/absence_prefill_response.dart | 28 ++ .../absence/absence_prefill_response.g.dart | 25 ++ .../queries/absence/absence_submit.dart | 32 ++ lib/state/app/modules/app_modules.dart | 8 + lib/storage/modules_settings.g.dart | 1 + .../absence_report/absence_report_view.dart | 384 ++++++++++++++++++ 10 files changed, 523 insertions(+) create mode 100644 lib/api/demo/data/demo_absence.dart create mode 100644 lib/api/marianumconnect/queries/absence/absence_classes.dart create mode 100644 lib/api/marianumconnect/queries/absence/absence_prefill.dart create mode 100644 lib/api/marianumconnect/queries/absence/absence_prefill_response.dart create mode 100644 lib/api/marianumconnect/queries/absence/absence_prefill_response.g.dart create mode 100644 lib/api/marianumconnect/queries/absence/absence_submit.dart create mode 100644 lib/view/pages/absence_report/absence_report_view.dart diff --git a/lib/api/demo/data/demo_absence.dart b/lib/api/demo/data/demo_absence.dart new file mode 100644 index 0000000..a1f650c --- /dev/null +++ b/lib/api/demo/data/demo_absence.dart @@ -0,0 +1,17 @@ +import '../../marianumconnect/queries/absence/absence_prefill_response.dart'; + +/// Demo fixtures for the absence-report form: the class dropdown and the +/// identity/phone prefill. Typed (compile-checked) so a field rename can't +/// silently drift the demo shape away from what the queries parse. +class DemoAbsence { + const DemoAbsence._(); + + static List classes() => const ['5a', '6b', '7c', '9d', '10a', 'Q1']; + + static AbsencePrefillResponse prefill() => AbsencePrefillResponse( + firstName: 'Max', + lastName: 'Mustermann', + className: '10a', + phone: '0123 456789', + ); +} diff --git a/lib/api/demo/demo_marianumconnect.dart b/lib/api/demo/demo_marianumconnect.dart index b925e7c..ce3faaf 100644 --- a/lib/api/demo/demo_marianumconnect.dart +++ b/lib/api/demo/demo_marianumconnect.dart @@ -1,3 +1,4 @@ +import 'data/demo_absence.dart'; import 'data/demo_breaker.dart'; import 'data/demo_holidays.dart'; import 'data/demo_timetable.dart'; @@ -39,6 +40,10 @@ class DemoMarianumConnect { .toList(); case 'breaker': return DemoBreaker.none().toJson(); + case 'absence/classes': + return DemoAbsence.classes(); + case 'absence/prefill': + return DemoAbsence.prefill().toJson(); case 'timetable/elements/teachers': case 'timetable/elements/students': case 'timetable/elements/classes': diff --git a/lib/api/marianumconnect/queries/absence/absence_classes.dart b/lib/api/marianumconnect/queries/absence/absence_classes.dart new file mode 100644 index 0000000..c692323 --- /dev/null +++ b/lib/api/marianumconnect/queries/absence/absence_classes.dart @@ -0,0 +1,13 @@ +import '../../marianumconnect_query.dart'; + +/// GETs the selectable classes for the absence form (`absence/classes`). The +/// body is a bare JSON string array, so [getList] (which maps objects) does not +/// fit — read the raw list and cast. +class AbsenceClasses extends MarianumConnectQuery { + AbsenceClasses({super.dio}); + + Future> run() => guard(() async { + final response = await dio.get>(endpoint('absence/classes')); + return response.data!.cast(); + }); +} diff --git a/lib/api/marianumconnect/queries/absence/absence_prefill.dart b/lib/api/marianumconnect/queries/absence/absence_prefill.dart new file mode 100644 index 0000000..0d7fb61 --- /dev/null +++ b/lib/api/marianumconnect/queries/absence/absence_prefill.dart @@ -0,0 +1,10 @@ +import '../../marianumconnect_query.dart'; +import 'absence_prefill_response.dart'; + +/// GETs the absence-report form prefill (`absence/prefill`, bearer-auth). +class AbsencePrefill extends MarianumConnectQuery { + AbsencePrefill({super.dio}); + + Future run() => + getObject('absence/prefill', AbsencePrefillResponse.fromJson); +} diff --git a/lib/api/marianumconnect/queries/absence/absence_prefill_response.dart b/lib/api/marianumconnect/queries/absence/absence_prefill_response.dart new file mode 100644 index 0000000..b262c00 --- /dev/null +++ b/lib/api/marianumconnect/queries/absence/absence_prefill_response.dart @@ -0,0 +1,28 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'absence_prefill_response.g.dart'; + +/// Prefill for the absence-report form: identity from LDAP plus the phone +/// number from the user's last report (empty strings when unknown). +@JsonSerializable() +class AbsencePrefillResponse { + @JsonKey(defaultValue: '') + final String firstName; + @JsonKey(defaultValue: '') + final String lastName; + @JsonKey(defaultValue: '') + final String className; + @JsonKey(defaultValue: '') + final String phone; + + AbsencePrefillResponse({ + required this.firstName, + required this.lastName, + required this.className, + required this.phone, + }); + + factory AbsencePrefillResponse.fromJson(Map json) => + _$AbsencePrefillResponseFromJson(json); + Map toJson() => _$AbsencePrefillResponseToJson(this); +} diff --git a/lib/api/marianumconnect/queries/absence/absence_prefill_response.g.dart b/lib/api/marianumconnect/queries/absence/absence_prefill_response.g.dart new file mode 100644 index 0000000..e8f5425 --- /dev/null +++ b/lib/api/marianumconnect/queries/absence/absence_prefill_response.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'absence_prefill_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +AbsencePrefillResponse _$AbsencePrefillResponseFromJson( + Map json, +) => AbsencePrefillResponse( + firstName: json['firstName'] as String? ?? '', + lastName: json['lastName'] as String? ?? '', + className: json['className'] as String? ?? '', + phone: json['phone'] as String? ?? '', +); + +Map _$AbsencePrefillResponseToJson( + AbsencePrefillResponse instance, +) => { + 'firstName': instance.firstName, + 'lastName': instance.lastName, + 'className': instance.className, + 'phone': instance.phone, +}; diff --git a/lib/api/marianumconnect/queries/absence/absence_submit.dart b/lib/api/marianumconnect/queries/absence/absence_submit.dart new file mode 100644 index 0000000..8f8c874 --- /dev/null +++ b/lib/api/marianumconnect/queries/absence/absence_submit.dart @@ -0,0 +1,32 @@ +import '../../marianumconnect_query.dart'; + +/// Submits an absence report (`POST absence`, bearer-auth, `source=mobile`). +/// Empty identity fields are backfilled from LDAP server-side; validation +/// (all fields required, class must exist, no past start date, end >= start) +/// also runs server-side and mirrors the client checks. +class AbsenceSubmit extends MarianumConnectQuery { + AbsenceSubmit({super.dio}); + + Future run({ + required String firstName, + required String lastName, + required String className, + required DateTime absentFrom, + required DateTime absentUntil, + required String phone, + required String note, + }) => guard(() async { + await dio.post( + endpoint('absence'), + data: { + 'firstName': firstName, + 'lastName': lastName, + 'className': className, + 'absentFrom': isoDate(absentFrom), + 'absentUntil': isoDate(absentUntil), + 'phone': phone, + 'note': note, + }, + ); + }); +} diff --git a/lib/state/app/modules/app_modules.dart b/lib/state/app/modules/app_modules.dart index 0aa7433..19c6d9c 100644 --- a/lib/state/app/modules/app_modules.dart +++ b/lib/state/app/modules/app_modules.dart @@ -6,6 +6,7 @@ import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import '../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import '../../../routing/app_routes.dart'; import '../../../storage/modules_settings.dart'; +import '../../../view/pages/absence_report/absence_report_view.dart'; import '../../../view/pages/files/files.dart'; import '../../../view/pages/grade_averages/grade_averages_view.dart'; import '../../../view/pages/holidays/holidays_view.dart'; @@ -137,6 +138,12 @@ class AppModule { breakerArea: BreakerArea.dates, create: MarianumDatesView.new, ), + Modules.absenceReport: AppModule( + Modules.absenceReport, + name: 'Krankmeldung', + icon: () => Icon(Icons.sick_outlined), + create: AbsenceReportView.new, + ), }; if (!showFiltered) { @@ -286,4 +293,5 @@ enum Modules { gradeAveragesCalculator, holidays, marianumDates, + absenceReport, } diff --git a/lib/storage/modules_settings.g.dart b/lib/storage/modules_settings.g.dart index 9267e99..20ea811 100644 --- a/lib/storage/modules_settings.g.dart +++ b/lib/storage/modules_settings.g.dart @@ -39,4 +39,5 @@ const _$ModulesEnumMap = { Modules.gradeAveragesCalculator: 'gradeAveragesCalculator', Modules.holidays: 'holidays', Modules.marianumDates: 'marianumDates', + Modules.absenceReport: 'absenceReport', }; diff --git a/lib/view/pages/absence_report/absence_report_view.dart b/lib/view/pages/absence_report/absence_report_view.dart new file mode 100644 index 0000000..0ddb332 --- /dev/null +++ b/lib/view/pages/absence_report/absence_report_view.dart @@ -0,0 +1,384 @@ +import 'package:flutter/material.dart'; + +import '../../../api/errors/error_mapper.dart'; +import '../../../api/marianumconnect/queries/absence/absence_classes.dart'; +import '../../../api/marianumconnect/queries/absence/absence_prefill.dart'; +import '../../../api/marianumconnect/queries/absence/absence_prefill_response.dart'; +import '../../../api/marianumconnect/queries/absence/absence_submit.dart'; +import '../../../extensions/date_time.dart'; +import '../../../widget/app_progress_indicator.dart'; +import '../../../widget/async_action_button.dart'; +import '../../../widget/demo_restricted.dart'; +import '../../../widget/focus_behaviour.dart'; +import '../../../widget/placeholder_view.dart'; + +/// Mobile mirror of the public absence-report form: submit-only (no history — +/// that lives on the web). Identity/class/phone are prefilled from the backend +/// but stay editable; the class list matches the submit validation source. +/// User-facing texts mirror the public web form (`AbsencePublicForm.svelte`). +class AbsenceReportView extends StatefulWidget { + const AbsenceReportView({super.key}); + + @override + State createState() => _AbsenceReportViewState(); +} + +class _AbsenceReportViewState extends State { + static const String _required = 'Dieses Feld ist erforderlich.'; + + final TextEditingController _firstName = TextEditingController(); + final TextEditingController _lastName = TextEditingController(); + final TextEditingController _phone = TextEditingController(); + final TextEditingController _note = TextEditingController(); + final AsyncActionController _submitController = AsyncActionController(); + + late Future _init; + List _classes = const []; + String? _selectedClass; + late DateTime _absentFrom; + late DateTime _absentUntil; + bool _submitted = false; + bool _done = false; + + @override + void initState() { + super.initState(); + final today = DateUtils.dateOnly(DateTime.now()); + _absentFrom = today; + _absentUntil = today; + for (final c in [_firstName, _lastName, _phone, _note]) { + c.addListener(_onFieldChanged); + } + _init = _load(); + } + + @override + void dispose() { + for (final c in [_firstName, _lastName, _phone, _note]) { + c.dispose(); + } + _submitController.dispose(); + super.dispose(); + } + + // Once a submit surfaced errors, re-render on every keystroke so the inline + // field errors clear as soon as the offending field is filled. + void _onFieldChanged() { + if (_submitted) setState(() {}); + } + + Future _load() async { + // Both GETs are independent — fire them together. Prefill is best-effort + // (mapped to null on failure), so a classes error still propagates while a + // prefill failure never surfaces as an unhandled async error. + final classesFuture = AbsenceClasses().run(); + final prefillFuture = AbsencePrefill().run().then( + (p) => p, + onError: (_) => null, + ); + final classes = await classesFuture; + final prefill = await prefillFuture; + if (prefill != null) _applyPrefill(prefill, classes); + _classes = classes; + } + + void _applyPrefill(AbsencePrefillResponse p, List classes) { + if (_firstName.text.isEmpty) _firstName.text = p.firstName; + if (_lastName.text.isEmpty) _lastName.text = p.lastName; + if (_phone.text.isEmpty) _phone.text = p.phone; + if (_selectedClass == null && classes.contains(p.className)) { + _selectedClass = p.className; + } + } + + bool get _startInPast => + _absentFrom.isBefore(DateUtils.dateOnly(DateTime.now())); + + bool get _endBeforeStart => _absentUntil.isBefore(_absentFrom); + + Future _pickFrom() async { + final today = DateUtils.dateOnly(DateTime.now()); + final picked = await showDatePicker( + context: context, + initialDate: _absentFrom.isBefore(today) ? today : _absentFrom, + firstDate: today, + lastDate: DateTime(today.year + 1, today.month, today.day), + ); + if (picked == null) return; + setState(() { + _absentFrom = picked; + if (_endBeforeStart) _absentUntil = _absentFrom; + }); + } + + Future _pickUntil() async { + final picked = await showDatePicker( + context: context, + initialDate: _endBeforeStart ? _absentFrom : _absentUntil, + firstDate: _absentFrom, + lastDate: DateTime( + _absentFrom.year + 1, + _absentFrom.month, + _absentFrom.day, + ), + ); + if (picked == null) return; + setState(() => _absentUntil = picked); + } + + Future _submit() async { + if (guardDemoAction(context)) return; + final valid = + _firstName.text.trim().isNotEmpty && + _lastName.text.trim().isNotEmpty && + _selectedClass != null && + _phone.text.trim().isNotEmpty && + _note.text.trim().isNotEmpty && + !_startInPast && + !_endBeforeStart; + if (!valid) { + setState(() => _submitted = true); + return; + } + await AbsenceSubmit().run( + firstName: _firstName.text.trim(), + lastName: _lastName.text.trim(), + className: _selectedClass!, + absentFrom: _absentFrom, + absentUntil: _absentUntil, + phone: _phone.text.trim(), + note: _note.text.trim(), + ); + if (!mounted) return; + // Replace the whole form with a terminal success screen. There is + // deliberately no way back to the form here — to file another report the + // user leaves the module and re-enters (which builds a fresh form). + setState(() => _done = true); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('Krankmeldung')), + body: _done ? const _SubmittedView() : _buildBody(context), + ); + + Widget _buildBody(BuildContext context) => FutureBuilder( + future: _init, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: AppProgressIndicator.large()); + } + if (snapshot.hasError) { + return PlaceholderView( + icon: Icons.error_outline, + text: errorToUserMessage(snapshot.error), + button: ElevatedButton.icon( + onPressed: () => setState(() => _init = _load()), + icon: const Icon(Icons.refresh), + label: const Text('Erneut versuchen'), + ), + ); + } + return _buildForm(context); + }, + ); + + Widget _buildForm(BuildContext context) { + final theme = Theme.of(context); + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Bitte tragen Sie hier die voraussichtliche Abwesenheit ein. ' + 'Bitte denken Sie auch an die schriftliche Entschuldigung.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + TextField( + controller: _firstName, + textCapitalization: TextCapitalization.words, + decoration: _decoration( + 'Vorname', + error: _requiredError(_firstName), + ), + onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context), + ), + const SizedBox(height: 16), + TextField( + controller: _lastName, + textCapitalization: TextCapitalization.words, + decoration: _decoration( + 'Nachname', + error: _requiredError(_lastName), + ), + onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _selectedClass, + isExpanded: true, + hint: const Text('— bitte wählen —'), + decoration: _decoration( + 'Klasse', + error: _submitted && _selectedClass == null ? _required : null, + ), + items: _classes + .map((c) => DropdownMenuItem(value: c, child: Text(c))) + .toList(), + onChanged: (value) => setState(() => _selectedClass = value), + ), + const SizedBox(height: 16), + _DateField( + label: 'Fehlt ab', + value: _absentFrom.formatDate(), + error: _submitted && _startInPast + ? 'Das Startdatum darf nicht in der Vergangenheit liegen.' + : null, + onTap: _pickFrom, + ), + const SizedBox(height: 16), + _DateField( + label: 'bis', + value: _absentUntil.formatDate(), + error: _submitted && _endBeforeStart + ? 'Das Enddatum darf nicht vor dem Startdatum liegen.' + : null, + onTap: _pickUntil, + ), + const SizedBox(height: 16), + TextField( + controller: _phone, + keyboardType: TextInputType.phone, + decoration: _decoration( + 'Telefonnummer für Rückfragen', + error: _requiredError(_phone), + ), + onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context), + ), + const SizedBox(height: 16), + TextField( + controller: _note, + minLines: 3, + maxLines: 6, + textCapitalization: TextCapitalization.sentences, + decoration: _decoration( + 'Bemerkung / Grund', + error: _requiredError(_note), + ), + onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context), + ), + const SizedBox(height: 24), + AsyncActionButton( + controller: _submitController, + onPressed: _submit, + child: const Text('Abwesenheit melden'), + ), + ], + ), + ); + } + + String? _requiredError(TextEditingController controller) => + _submitted && controller.text.trim().isEmpty ? _required : null; + + InputDecoration _decoration(String label, {String? error, String? hint}) => + InputDecoration( + border: const OutlineInputBorder(), + labelText: label, + hintText: hint, + errorText: error, + ); +} + +/// Terminal success screen shown in place of the form after a report was +/// submitted. Mirrors the public web form's done page and intentionally offers +/// no path back to the form — filing another report means re-entering the +/// module. +class _SubmittedView extends StatelessWidget { + const _SubmittedView(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Icon( + Icons.check_circle_outline, + size: 60, + color: theme.colorScheme.primary, + ), + ), + Text( + 'Ihre Abwesenheit wurde erfolgreich übermittelt!', + textAlign: TextAlign.center, + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 12), + Text( + 'Vielen Dank und bei Krankheit gute Besserung!', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Text( + 'Bei Fragen wenden Sie sich bitte an unser Sekretariat. ' + 'Tel: 0661-969120', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} + +class _DateField extends StatelessWidget { + final String label; + final String value; + final String? error; + final VoidCallback onTap; + + const _DateField({ + required this.label, + required this.value, + required this.error, + required this.onTap, + }); + + @override + Widget build(BuildContext context) => InkWell( + onTap: onTap, + child: InputDecorator( + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: label, + errorText: error, + ), + child: Row( + children: [ + Icon( + Icons.date_range_outlined, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Text(value, style: Theme.of(context).textTheme.bodyLarge), + ], + ), + ), + ); +}