implemented absence report module with form validation, prefill support, and API integration

This commit is contained in:
2026-07-26 11:45:48 +02:00
parent 75080a2c49
commit b957189fd3
10 changed files with 523 additions and 0 deletions
@@ -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<AbsenceReportView> createState() => _AbsenceReportViewState();
}
class _AbsenceReportViewState extends State<AbsenceReportView> {
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<void> _init;
List<String> _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<void> _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<AbsencePrefillResponse?>(
(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<String> 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<void> _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<void> _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<void> _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<void>(
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<String>(
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),
],
),
),
);
}