Compare commits
2 Commits
624c5512a6
...
32be67426c
| Author | SHA1 | Date | |
|---|---|---|---|
| 32be67426c | |||
| b957189fd3 |
@@ -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',
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user