implemented absence report module with form validation, prefill support, and API integration
This commit is contained in:
@@ -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_breaker.dart';
|
||||||
import 'data/demo_holidays.dart';
|
import 'data/demo_holidays.dart';
|
||||||
import 'data/demo_timetable.dart';
|
import 'data/demo_timetable.dart';
|
||||||
@@ -39,6 +40,10 @@ class DemoMarianumConnect {
|
|||||||
.toList();
|
.toList();
|
||||||
case 'breaker':
|
case 'breaker':
|
||||||
return DemoBreaker.none().toJson();
|
return DemoBreaker.none().toJson();
|
||||||
|
case 'absence/classes':
|
||||||
|
return DemoAbsence.classes();
|
||||||
|
case 'absence/prefill':
|
||||||
|
return DemoAbsence.prefill().toJson();
|
||||||
case 'timetable/elements/teachers':
|
case 'timetable/elements/teachers':
|
||||||
case 'timetable/elements/students':
|
case 'timetable/elements/students':
|
||||||
case 'timetable/elements/classes':
|
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 '../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
|
||||||
import '../../../routing/app_routes.dart';
|
import '../../../routing/app_routes.dart';
|
||||||
import '../../../storage/modules_settings.dart';
|
import '../../../storage/modules_settings.dart';
|
||||||
|
import '../../../view/pages/absence_report/absence_report_view.dart';
|
||||||
import '../../../view/pages/files/files.dart';
|
import '../../../view/pages/files/files.dart';
|
||||||
import '../../../view/pages/grade_averages/grade_averages_view.dart';
|
import '../../../view/pages/grade_averages/grade_averages_view.dart';
|
||||||
import '../../../view/pages/holidays/holidays_view.dart';
|
import '../../../view/pages/holidays/holidays_view.dart';
|
||||||
@@ -137,6 +138,12 @@ class AppModule {
|
|||||||
breakerArea: BreakerArea.dates,
|
breakerArea: BreakerArea.dates,
|
||||||
create: MarianumDatesView.new,
|
create: MarianumDatesView.new,
|
||||||
),
|
),
|
||||||
|
Modules.absenceReport: AppModule(
|
||||||
|
Modules.absenceReport,
|
||||||
|
name: 'Krankmeldung',
|
||||||
|
icon: () => Icon(Icons.sick_outlined),
|
||||||
|
create: AbsenceReportView.new,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!showFiltered) {
|
if (!showFiltered) {
|
||||||
@@ -286,4 +293,5 @@ enum Modules {
|
|||||||
gradeAveragesCalculator,
|
gradeAveragesCalculator,
|
||||||
holidays,
|
holidays,
|
||||||
marianumDates,
|
marianumDates,
|
||||||
|
absenceReport,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,4 +39,5 @@ const _$ModulesEnumMap = {
|
|||||||
Modules.gradeAveragesCalculator: 'gradeAveragesCalculator',
|
Modules.gradeAveragesCalculator: 'gradeAveragesCalculator',
|
||||||
Modules.holidays: 'holidays',
|
Modules.holidays: 'holidays',
|
||||||
Modules.marianumDates: 'marianumDates',
|
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