2 Commits

10 changed files with 523 additions and 0 deletions
+17
View File
@@ -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<String> classes() => const ['5a', '6b', '7c', '9d', '10a', 'Q1'];
static AbsencePrefillResponse prefill() => AbsencePrefillResponse(
firstName: 'Max',
lastName: 'Mustermann',
className: '10a',
phone: '0123 456789',
);
}
+5
View File
@@ -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':
@@ -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<List<String>> run() => guard(() async {
final response = await dio.get<List<dynamic>>(endpoint('absence/classes'));
return response.data!.cast<String>();
});
}
@@ -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<AbsencePrefillResponse> run() =>
getObject('absence/prefill', AbsencePrefillResponse.fromJson);
}
@@ -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<String, dynamic> json) =>
_$AbsencePrefillResponseFromJson(json);
Map<String, dynamic> toJson() => _$AbsencePrefillResponseToJson(this);
}
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'absence_prefill_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AbsencePrefillResponse _$AbsencePrefillResponseFromJson(
Map<String, dynamic> json,
) => AbsencePrefillResponse(
firstName: json['firstName'] as String? ?? '',
lastName: json['lastName'] as String? ?? '',
className: json['className'] as String? ?? '',
phone: json['phone'] as String? ?? '',
);
Map<String, dynamic> _$AbsencePrefillResponseToJson(
AbsencePrefillResponse instance,
) => <String, dynamic>{
'firstName': instance.firstName,
'lastName': instance.lastName,
'className': instance.className,
'phone': instance.phone,
};
@@ -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<void> 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<void>(
endpoint('absence'),
data: {
'firstName': firstName,
'lastName': lastName,
'className': className,
'absentFrom': isoDate(absentFrom),
'absentUntil': isoDate(absentUntil),
'phone': phone,
'note': note,
},
);
});
}
+8
View File
@@ -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,
}
+1
View File
@@ -39,4 +39,5 @@ const _$ModulesEnumMap = {
Modules.gradeAveragesCalculator: 'gradeAveragesCalculator',
Modules.holidays: 'holidays',
Modules.marianumDates: 'marianumDates',
Modules.absenceReport: 'absenceReport',
};
@@ -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),
],
),
),
);
}