From ba67074188bff06d5644f36d3f7d0e3c4118acf4 Mon Sep 17 00:00:00 2001 From: Herwig Birke Date: Wed, 27 Aug 2025 23:03:23 +0200 Subject: [PATCH] initial commit --- lib/api/booking_api.dart | 7 +- lib/api/daily_working_api.dart | 66 +++++ lib/models/work_day.dart | 4 +- lib/screens/monthly_view.dart | 499 +++++++++++++++++++++----------- lib/utils/helpers.dart | 33 ++- lib/utils/input_formatters.dart | 3 +- 6 files changed, 434 insertions(+), 178 deletions(-) create mode 100644 lib/api/daily_working_api.dart diff --git a/lib/api/booking_api.dart b/lib/api/booking_api.dart index c2670f2..7502979 100644 --- a/lib/api/booking_api.dart +++ b/lib/api/booking_api.dart @@ -63,14 +63,15 @@ class BookingApi { addPair('come4', 'leave4'); addPair('come5', 'leave5'); - final absence = - (row['code']?.toString().trim().isEmpty ?? true) ? null : row['code'].toString(); + final code = (row['code']?.toString().trim().isEmpty ?? true) + ? null + : row['code'].toString().trim(); out.add(WorkDay( date: d, intervals: intervals, targetMinutes: target, - absenceType: absence, + code: code, )); } diff --git a/lib/api/daily_working_api.dart b/lib/api/daily_working_api.dart new file mode 100644 index 0000000..e02617f --- /dev/null +++ b/lib/api/daily_working_api.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +/// Lädt die tägliche Sollzeit (HH:MM:SS pro Wochentag) aus der API. +/// Gibt eine Map zurück: key = DateTime.weekday (1..7), value = Minuten. +class DailyWorkingApi { + final String host; + final String path; + final http.Client client; + + const DailyWorkingApi({ + required this.client, + this.host = 'api.windesign.at', + this.path = '/workinghours.php', + }); + + Future> getDailyMinutes() async { + final uri = Uri.https(host, path, { + 'module': 'dailyworking', + 'function': 'getList', + }); + + final res = await client + .get(uri, headers: {'Accept': 'application/json'}) + .timeout(const Duration(seconds: 15)); + + if (res.statusCode != 200) { + throw Exception('HTTP ${res.statusCode}: ${res.body}'); + } + + final map = jsonDecode(res.body) as Map; + final list = (map['entries'] as List?) ?? const []; + if (list.isEmpty) return _defaultPlan(); + + final row = (list.first as Map).cast(); + + int parseHMS(String? s) { + if (s == null) return 0; + final parts = s.split(':'); + if (parts.length != 3) return 0; + final h = int.tryParse(parts[0]) ?? 0; + final m = int.tryParse(parts[1]) ?? 0; + return h * 60 + m; // Sekunden ignoriert + } + + return { + DateTime.monday: parseHMS(row['monday'] as String?), + DateTime.tuesday: parseHMS(row['tuesday'] as String?), + DateTime.wednesday: parseHMS(row['wednesday'] as String?), + DateTime.thursday: parseHMS(row['thursday'] as String?), + DateTime.friday: parseHMS(row['friday'] as String?), + DateTime.saturday: parseHMS(row['saturday'] as String?), + DateTime.sunday: parseHMS(row['sunday'] as String?), + }; + } + + Map _defaultPlan() => { + DateTime.monday: 8 * 60, + DateTime.tuesday: 8 * 60, + DateTime.wednesday: 8 * 60, + DateTime.thursday: 8 * 60, + DateTime.friday: 8 * 60, + DateTime.saturday: 0, + DateTime.sunday: 0, + }; +} diff --git a/lib/models/work_day.dart b/lib/models/work_day.dart index db14f0b..f7e7a9b 100644 --- a/lib/models/work_day.dart +++ b/lib/models/work_day.dart @@ -4,14 +4,14 @@ import 'work_interval.dart'; class WorkDay { final DateTime date; final List intervals; // bis zu 5 - final String? absenceType; // Urlaub, Krank, … + final String? code; // GZ, K, U, SU, T oder null final int targetMinutes; // Soll const WorkDay({ required this.date, required this.intervals, required this.targetMinutes, - this.absenceType, + this.code, }); int get workedMinutes => intervals.fold(0, (sum, i) => sum + i.minutes); diff --git a/lib/screens/monthly_view.dart b/lib/screens/monthly_view.dart index 3bb8080..ed28c98 100644 --- a/lib/screens/monthly_view.dart +++ b/lib/screens/monthly_view.dart @@ -1,7 +1,9 @@ +import 'dart:ui' show FontFeature; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import '../api/booking_api.dart'; +import '../api/daily_working_api.dart'; import '../models/work_day.dart'; import '../models/work_interval.dart'; import '../utils/helpers.dart'; @@ -15,8 +17,14 @@ class MonthlyView extends StatefulWidget { } class _MonthlyViewState extends State { - int _uiVersion = 0; + // Spaltenbreiten (Header & Body identisch -> exakte Ausrichtung) + static const double _wDate = 120; + static const double _wHoliday = 160; + static const double _wTime = 84; + static const double _wCode = 110; + static const double _wNumber = 92; + // Edit/Fokus/Controller je Feld final Set _invalidCells = {}; final Map _focusNodes = {}; final Map _controllers = {}; @@ -25,13 +33,23 @@ class _MonthlyViewState extends State { _controllers.putIfAbsent(key, () => TextEditingController(text: initial)); late final http.Client _client; - late final BookingApi _api; + late final BookingApi _bookingApi; + late final DailyWorkingApi _dailyApi; late DateTime _monthStart; List _days = const []; bool _loading = false; String? _error; Map _holidays = const {}; + Map _dailyPlan = { + DateTime.monday: 8 * 60, + DateTime.tuesday: 8 * 60, + DateTime.wednesday: 8 * 60, + DateTime.thursday: 8 * 60, + DateTime.friday: 8 * 60, + DateTime.saturday: 0, + DateTime.sunday: 0, + }; late final ScrollController _hCtrl; late final ScrollController _vCtrl; @@ -44,7 +62,8 @@ class _MonthlyViewState extends State { _hCtrl = ScrollController(); _vCtrl = ScrollController(); _client = http.Client(); - _api = BookingApi(client: _client); + _bookingApi = BookingApi(client: _client); + _dailyApi = DailyWorkingApi(client: _client); _loadMonth(_monthStart); } @@ -69,15 +88,33 @@ class _MonthlyViewState extends State { _days = const []; }); try { - final apiDays = await _api.getBookingList(m); - final days = fillMonth(m, apiDays); + final results = await Future.wait([ + _bookingApi.getBookingList(m), + _dailyApi.getDailyMinutes(), + ]); + + final apiDays = results[0] as List; + final plan = results[1] as Map; + + final filled = fillMonth(m, apiDays); + + final withTargets = filled + .map((d) => WorkDay( + date: d.date, + intervals: d.intervals, + targetMinutes: plan[d.date.weekday] ?? d.targetMinutes, + code: d.code, + )) + .toList(); + setState(() { _monthStart = DateTime(m.year, m.month, 1); _holidays = buildHolidayMapAT(_monthStart.year); - _days = days; + _days = withTargets; + _dailyPlan = plan; _loading = false; - _uiVersion++; }); + _syncControllersWithDays(); } catch (e) { setState(() { @@ -87,38 +124,66 @@ class _MonthlyViewState extends State { } } + double get _tableMinWidth => + _wDate + _wHoliday + (10 * _wTime) + _wCode + (4 * _wNumber) + _wDate; + + // Override für Ist-Minuten nach Code + int _workedFor(WorkDay d) { + final c = d.code; + if (c == 'U' || c == 'SU' || c == 'K' || c == 'T') { + return d.targetMinutes; // Urlaub/Sonderurlaub/Krankenstand/Training -> Ist = Soll + } + return d.workedMinutes; // normal aus Intervallen + } + + Color? _rowColorFor(WorkDay d, {required Color? holidayBg, required Color? weekendBg}) { + switch (d.code) { + case 'GZ': return const Color(0xFFBFBFFF); + case 'U': return const Color(0xFF7F7FFF); + case 'SU': return const Color(0xFF7F7FFF); + case 'K': return Colors.yellow; + case 'T': return Colors.red; + } + // kein Code -> ggf. Feiertag/Wochenende + final isHoliday = _holidays.containsKey(ymd(d.date)); + final isWeekend = d.date.weekday == DateTime.saturday || d.date.weekday == DateTime.sunday; + if (isHoliday) return holidayBg; + if (isWeekend) return weekendBg; + return null; + } + @override Widget build(BuildContext context) { - final columns = _buildColumns(); + final headerColumns = _buildColumns(); + final bodyColumns = _buildColumns(); // gleiche Struktur (Body blendet Header aus) final theme = Theme.of(context); final holidayBg = theme.colorScheme.secondaryContainer.withOpacity(0.45); final weekendBg = Colors.grey.withOpacity(0.30); - // Effektive Tage: live aus Editfeldern gelesen + // Effektive Tage (inkl. Live-Edits) final effectiveDays = List.generate(_days.length, (i) => _effectiveDay(i, _days[i])); - // kumulative Differenz + // kumulative Differenz basierend auf Override- oder Intervall-Arbeitszeit + final diffs = []; + for (final d in effectiveDays) { + final worked = _workedFor(d); + diffs.add(worked - d.targetMinutes); + } final cumulative = []; int sum = 0; - for (final d in effectiveDays) { - sum += d.diffMinutes; + for (final diff in diffs) { + sum += diff; cumulative.add(sum); } final rows = List.generate(_days.length, (i) { final day = effectiveDays[i]; final run = cumulative[i]; - final isHoliday = _holidays.containsKey(ymd(day.date)); - final isWeekend = - day.date.weekday == DateTime.saturday || day.date.weekday == DateTime.sunday; return DataRow( - // MaterialStateProperty -> WidgetStateProperty (Deprecation) - color: WidgetStateProperty.resolveWith((_) { - if (isHoliday) return holidayBg; - if (isWeekend) return weekendBg; - return null; - }), + color: WidgetStateProperty.resolveWith( + (_) => _rowColorFor(day, holidayBg: holidayBg, weekendBg: weekendBg), + ), cells: _buildEditableCells(i, day, run), ); }); @@ -165,6 +230,34 @@ class _MonthlyViewState extends State { ], ), ), + + // FIXIERTE KOPFZEILE (nur horizontal scrollend) + Scrollbar( + controller: _hCtrl, + notificationPredicate: (n) => n.metrics.axis == Axis.horizontal, + thumbVisibility: true, + child: SingleChildScrollView( + controller: _hCtrl, + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: _tableMinWidth), + child: DataTableTheme( + data: const DataTableThemeData( + headingRowHeight: 42, + columnSpacing: 20, + headingTextStyle: TextStyle(fontWeight: FontWeight.w700), + ), + child: DataTable( + showCheckboxColumn: false, + columns: headerColumns, + rows: const [], // nur Header anzeigen + ), + ), + ), + ), + ), + + // BODY (horiz. & vert. Scroll, aber Header im Body ausgeblendet) Expanded( child: Scrollbar( controller: _hCtrl, @@ -175,7 +268,7 @@ class _MonthlyViewState extends State { padding: const EdgeInsets.only(bottom: 24), scrollDirection: Axis.horizontal, child: ConstrainedBox( - constraints: const BoxConstraints(minWidth: 1300), + constraints: BoxConstraints(minWidth: _tableMinWidth), child: Scrollbar( controller: _vCtrl, notificationPredicate: (n) => n.metrics.axis == Axis.vertical, @@ -184,15 +277,14 @@ class _MonthlyViewState extends State { controller: _vCtrl, child: DataTableTheme( data: const DataTableThemeData( - headingRowHeight: 42, + headingRowHeight: 0, // Header hier ausblenden dataRowMinHeight: 44, dataRowMaxHeight: 54, columnSpacing: 20, - headingTextStyle: TextStyle(fontWeight: FontWeight.w700), ), child: DataTable( showCheckboxColumn: false, - columns: columns, + columns: bodyColumns, rows: rows, ), ), @@ -206,43 +298,46 @@ class _MonthlyViewState extends State { } List _buildColumns() { - const center = TextAlign.center; - const right = TextAlign.right; - const left = TextAlign.left; - - DataColumn c(String label, {TextAlign align = center, double? width}) => DataColumn( + DataColumn c(String label, {Alignment align = Alignment.center, double? width}) => DataColumn( label: SizedBox( - width: width ?? (align == center ? null : 120), + width: width, child: Align( - alignment: align == right - ? Alignment.centerRight - : align == left - ? Alignment.centerLeft - : Alignment.center, - child: Text(label, textAlign: align), + alignment: align, + child: Text(label, textAlign: _toTextAlign(align)), ), ), ); return [ - // 0 - c('Datum', align: right), - // 1 - c('Feiertag', align: left, width: 160), - // 2..11 Zeitspalten - c('Start 1'), c('Ende 1'), - c('Start 2'), c('Ende 2'), - c('Start 3'), c('Ende 3'), - c('Start 4'), c('Ende 4'), - c('Start 5'), c('Ende 5'), - // 12..16 Kennzahlen - c('Abwesenheitstype', align: left, width: 160), - c('Soll', align: right), - c('Ist', align: right), - c('Differenz', align: right), - c('Differenz gesamt', align: right), - // 17 - c('Datum', align: left), + // 0 Datum (rechtsbündig) + c('Datum', align: Alignment.centerRight, width: _wDate), + + // 1 Feiertag (links) + c('Feiertag', align: Alignment.centerLeft, width: _wHoliday), + + // 2..11 Zeitspalten (zentriert) + c('Start 1', align: Alignment.center, width: _wTime), + c('Ende 1', align: Alignment.center, width: _wTime), + c('Start 2', align: Alignment.center, width: _wTime), + c('Ende 2', align: Alignment.center, width: _wTime), + c('Start 3', align: Alignment.center, width: _wTime), + c('Ende 3', align: Alignment.center, width: _wTime), + c('Start 4', align: Alignment.center, width: _wTime), + c('Ende 4', align: Alignment.center, width: _wTime), + c('Start 5', align: Alignment.center, width: _wTime), + c('Ende 5', align: Alignment.center, width: _wTime), + + // 12 Code (zentriert) + c('Code', align: Alignment.center, width: _wCode), + + // 13..16 Kennzahlen (rechtsbündig) + c('Soll', align: Alignment.centerRight, width: _wNumber), + c('Ist', align: Alignment.centerRight, width: _wNumber), + c('Differenz', align: Alignment.centerRight, width: _wNumber), + c('Differenz gesamt', align: Alignment.centerRight, width: _wNumber), + + // 17 Datum (linksbündig) + c('Datum', align: Alignment.centerLeft, width: _wDate), ]; } @@ -264,103 +359,174 @@ class _MonthlyViewState extends State { final cells = []; - // 0: Datum Wochentag (rechts) - cells.add(DataCell(Align(alignment: Alignment.centerRight, child: Text(leftLabel)))); + // 0: Datum (rechts) + cells.add(DataCell(SizedBox( + width: _wDate, + child: Align(alignment: Alignment.centerRight, child: Text(leftLabel)), + ))); // 1: Feiertag-Name - cells.add(DataCell(Text(hName))); + cells.add(DataCell(SizedBox( + width: _wHoliday, + child: Align(alignment: Alignment.centerLeft, child: Text(hName)), + ))); - // 2..11: editierbare Zeiten + // 2..11: editierbare Zeiten (zentriert) for (int slot = 0; slot < 5; slot++) { // Start final keyS = 't_${dayIndex}_${slot}_s'; final fnS = _nodeFor(keyS); final ctrlS = _controllerFor(keyS, slotText(slot, true)); - cells.add(DataCell( - _timeField( - key: ValueKey(keyS), - controller: ctrlS, - focusNode: fnS, - invalid: isInvalid(slot, true), - onChanged: (text) { - final k = _cellKey(dayIndex, slot, true); - final valid = text.isEmpty || _isValidHHMM(text); - setState(() { - if (valid) { - _invalidCells.remove(k); + cells.add(DataCell(SizedBox( + width: _wTime, + child: Align( + alignment: Alignment.center, + child: _timeField( + key: ValueKey(keyS), + controller: ctrlS, + focusNode: fnS, + invalid: isInvalid(slot, true), + onChanged: (text) { + final k = _cellKey(dayIndex, slot, true); + final valid = text.isEmpty || _isValidHHMM(text); + setState(() { + if (valid) { + _invalidCells.remove(k); + } else { + _invalidCells.add(k); + } + }); + if (valid && (text.isEmpty || text.length == 5)) { + _commitTime(dayIndex, slot, true, text); } else { - _invalidCells.add(k); + setState(() {}); // live neu berechnen } - }); - if (valid && (text.isEmpty || text.length == 5)) { - _commitTime(dayIndex, slot, true, text); - } else { - setState(() {}); // live neu berechnen, Fokus bleibt erhalten - } - }, - onSubmitted: (text) { - if (text.isEmpty || _isValidHHMM(text)) { - _commitTime(dayIndex, slot, true, text); - } - }, + }, + onSubmitted: (text) { + if (text.isEmpty || _isValidHHMM(text)) { + _commitTime(dayIndex, slot, true, text); + } + }, + ), ), - onTap: () => fnS.requestFocus(), - )); + ))); // Ende final keyE = 't_${dayIndex}_${slot}_e'; final fnE = _nodeFor(keyE); final ctrlE = _controllerFor(keyE, slotText(slot, false)); - cells.add(DataCell( - _timeField( - key: ValueKey(keyE), - controller: ctrlE, - focusNode: fnE, - invalid: isInvalid(slot, false), - onChanged: (text) { - final k = _cellKey(dayIndex, slot, false); - final valid = text.isEmpty || _isValidHHMM(text); - setState(() { - if (valid) { - _invalidCells.remove(k); + cells.add(DataCell(SizedBox( + width: _wTime, + child: Align( + alignment: Alignment.center, + child: _timeField( + key: ValueKey(keyE), + controller: ctrlE, + focusNode: fnE, + invalid: isInvalid(slot, false), + onChanged: (text) { + final k = _cellKey(dayIndex, slot, false); + final valid = text.isEmpty || _isValidHHMM(text); + setState(() { + if (valid) { + _invalidCells.remove(k); + } else { + _invalidCells.add(k); + } + }); + if (valid && (text.isEmpty || text.length == 5)) { + _commitTime(dayIndex, slot, false, text); } else { - _invalidCells.add(k); + setState(() {}); // live neu berechnen } - }); - if (valid && (text.isEmpty || text.length == 5)) { - _commitTime(dayIndex, slot, false, text); - } else { - setState(() => _uiVersion++); - } - }, - onSubmitted: (text) { - if (text.isEmpty || _isValidHHMM(text)) { - _commitTime(dayIndex, slot, false, text); - } - }, + }, + onSubmitted: (text) { + if (text.isEmpty || _isValidHHMM(text)) { + _commitTime(dayIndex, slot, false, text); + } + }, + ), ), - onTap: () => fnE.requestFocus(), - )); + ))); } - // 12..16: Kennzahlen + // 12: Code (Dropdown) — zentriert (Kurzname geschlossen, Langname in Liste) + cells.add(DataCell(SizedBox( + width: _wCode, + child: Align(alignment: Alignment.center, child: _codeDropdown(dayIndex, day)), + ))); + + // 13..16: Kennzahlen (rechts) – mit Override + final worked = _workedFor(day); final soll = minutesToHHMM(day.targetMinutes); - final ist = minutesToHHMM(day.workedMinutes); - final diff = minutesToSignedHHMM(day.diffMinutes); + final ist = minutesToHHMM(worked); + final diff = minutesToSignedHHMM(worked - day.targetMinutes); final diffSum = minutesToSignedHHMM(runningDiff); - cells.add(DataCell(Text(day.absenceType ?? '—'))); - cells.add(DataCell(Align(alignment: Alignment.centerRight, child: mono(soll)))); - cells.add(DataCell(Align(alignment: Alignment.centerRight, child: mono(ist)))); - cells.add(DataCell(Align(alignment: Alignment.centerRight, child: mono(diff)))); - cells.add(DataCell(Align(alignment: Alignment.centerRight, child: mono(diffSum)))); + cells.add(DataCell(SizedBox( + width: _wNumber, + child: Align(alignment: Alignment.centerRight, child: mono(soll)), + ))); + cells.add(DataCell(SizedBox( + width: _wNumber, + child: Align(alignment: Alignment.centerRight, child: mono(ist)), + ))); + cells.add(DataCell(SizedBox( + width: _wNumber, + child: Align(alignment: Alignment.centerRight, child: mono(diff)), + ))); + cells.add(DataCell(SizedBox( + width: _wNumber, + child: Align(alignment: Alignment.centerRight, child: mono(diffSum)), + ))); - // 17: Wochentag Datum (links) - cells.add(DataCell(Align(alignment: Alignment.centerLeft, child: Text(rightLabel)))); + // 17: Datum (links) + cells.add(DataCell(SizedBox( + width: _wDate, + child: Align(alignment: Alignment.centerLeft, child: Text(rightLabel)), + ))); assert(cells.length == 18, 'Row has ${cells.length} cells but expected 18.'); return cells; } + Widget _codeDropdown(int dayIndex, WorkDay day) { + final value = day.code; // null => kein Code (—) + final values = [null, ...kAbsenceCodes]; + + return DropdownButton( + isExpanded: true, + value: value, + // Langnamen in der aufgeklappten Liste: + items: values.map((v) { + final label = v == null ? '—' : codeLabel(v); + return DropdownMenuItem( + value: v, + child: Text(label, textAlign: TextAlign.center), + ); + }).toList(), + // Kurznamen (zentriert) in der geschlossenen Anzeige: + selectedItemBuilder: (context) { + return values.map((v) { + final shortText = v ?? '—'; + return Center(child: Text(shortText)); + }).toList(); + }, + onChanged: (newCode) { + final d = _days[dayIndex]; + setState(() { + final newDays = List.from(_days); + newDays[dayIndex] = WorkDay( + date: d.date, + intervals: d.intervals, + targetMinutes: d.targetMinutes, + code: newCode, + ); + _days = newDays; + }); + }, + ); + } + bool _isValidHHMM(String s) { if (s.length != 5 || s[2] != ':') return false; final h = int.tryParse(s.substring(0, 2)); @@ -402,8 +568,8 @@ class _MonthlyViewState extends State { newDays[dayIndex] = WorkDay( date: d.date, intervals: newIntervals, - targetMinutes: d.targetMinutes, - absenceType: d.absenceType, + targetMinutes: _dailyPlan[d.date.weekday] ?? d.targetMinutes, + code: d.code, ); _days = newDays; }); @@ -448,52 +614,59 @@ class _MonthlyViewState extends State { } } + final target = _dailyPlan[base.date.weekday] ?? base.targetMinutes; + return WorkDay( date: base.date, intervals: intervals, - targetMinutes: base.targetMinutes, - absenceType: base.absenceType, + targetMinutes: target, + code: base.code, ); } - // ---- fehlende Methode: gemeinsames Zeitfeld-Widget ---- -Widget _timeField({ - required Key key, - required TextEditingController controller, - required bool invalid, - required ValueChanged onChanged, - required ValueChanged onSubmitted, - FocusNode? focusNode, -}) { - final theme = Theme.of(context); - final errorFill = theme.colorScheme.errorContainer.withOpacity(0.25); + // rahmenlose Eingabefelder, nur dezente Füllung bei invalid + Widget _timeField({ + required Key key, + required TextEditingController controller, + required bool invalid, + required ValueChanged onChanged, + required ValueChanged onSubmitted, + FocusNode? focusNode, + }) { + final theme = Theme.of(context); + final errorFill = theme.colorScheme.errorContainer.withOpacity(0.25); - return SizedBox( - width: 84, - child: TextFormField( - key: key, - controller: controller, - focusNode: focusNode, - textAlign: TextAlign.center, - style: const TextStyle(fontFeatures: [FontFeature.tabularFigures()]), - keyboardType: TextInputType.datetime, - inputFormatters: const [HHmmInputFormatter()], - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - // dezente Hinterlegung nur wenn ungültig: - filled: invalid, - fillColor: invalid ? errorFill : null, - // kein hintText mehr + return SizedBox( + width: _wTime, + child: TextFormField( + key: key, + controller: controller, + focusNode: focusNode, + textAlign: TextAlign.center, + style: const TextStyle(fontFeatures: [FontFeature.tabularFigures()]), + keyboardType: TextInputType.datetime, + inputFormatters: const [HHmmInputFormatter()], + decoration: const InputDecoration( + isDense: true, + contentPadding: EdgeInsets.symmetric(horizontal: 6, vertical: 6), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + ).copyWith( + filled: invalid, + fillColor: invalid ? errorFill : null, + ), + onChanged: onChanged, + onFieldSubmitted: onSubmitted, ), - onChanged: onChanged, - onFieldSubmitted: onSubmitted, - ), - ); -} + ); + } + + TextAlign _toTextAlign(Alignment a) { + if (a == Alignment.centerRight) return TextAlign.right; + if (a == Alignment.centerLeft) return TextAlign.left; + return TextAlign.center; + } } class _MonthHeader extends StatelessWidget { diff --git a/lib/utils/helpers.dart b/lib/utils/helpers.dart index c8c57fd..099ee2a 100644 --- a/lib/utils/helpers.dart +++ b/lib/utils/helpers.dart @@ -36,15 +36,6 @@ Widget mono(String s) => String fmtTimeOfDay(TimeOfDay t) => '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; -int minutesBetween(TimeOfDay a, TimeOfDay b) { - final aMin = a.hour * 60 + a.minute; - final bMin = b.hour * 60 + b.minute; - final d = bMin - aMin; - if (d <= 0) return 0; - if (d >= 24 * 60) return 24 * 60; - return d; -} - String minutesToHHMM(int minutes) { final h = minutes ~/ 60; final m = minutes % 60; @@ -102,3 +93,27 @@ List fillMonth(DateTime monthStart, List existing) { } return out; } + +/// "HH:MM:SS" → Minuten (Sekunden ignoriert). Ungültig => null. +int? minutesFromHHMMSS(String? s) { + if (s == null) return null; + final parts = s.split(':'); + if (parts.length != 3) return null; + final h = int.tryParse(parts[0]) ?? 0; + final m = int.tryParse(parts[1]) ?? 0; + return h * 60 + m; +} + +// Abwesenheitscodes und Labels +const List kAbsenceCodes = ['GZ', 'K', 'U', 'SU', 'T']; + +String codeLabel(String? code) { + switch (code) { + case 'GZ': return 'Gleitzeit'; + case 'K': return 'Krankenstand'; + case 'U': return 'Urlaub'; + case 'SU': return 'Sonderurlaub'; + case 'T': return 'Training'; + default: return '—'; + } +} diff --git a/lib/utils/input_formatters.dart b/lib/utils/input_formatters.dart index 3e37205..a17f93a 100644 --- a/lib/utils/input_formatters.dart +++ b/lib/utils/input_formatters.dart @@ -1,5 +1,6 @@ import 'package:flutter/services.dart'; +/// Tippt „HHMM“ und formatiert live zu „HH:MM“ (nur Ziffern erlaubt). class HHmmInputFormatter extends TextInputFormatter { const HHmmInputFormatter(); @@ -17,7 +18,7 @@ class HHmmInputFormatter extends TextInputFormatter { } else { final hh = digits.substring(0, 2); final mm = digits.substring(2); - text = '$hh:$mm'; // ← Interpolation statt + ':' + + text = '$hh:$mm'; } final offset = text.length;