initial commit

This commit is contained in:
2025-08-27 23:03:23 +02:00
parent 36ae378cb9
commit ba67074188
6 changed files with 434 additions and 178 deletions
+4 -3
View File
@@ -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,
));
}
+66
View File
@@ -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<int,int> 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<Map<int, int>> 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<String, dynamic>;
final list = (map['entries'] as List?) ?? const [];
if (list.isEmpty) return _defaultPlan();
final row = (list.first as Map).cast<String, dynamic>();
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<int, int> _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,
};
}
+2 -2
View File
@@ -4,14 +4,14 @@ import 'work_interval.dart';
class WorkDay {
final DateTime date;
final List<WorkInterval> 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);
+336 -163
View File
@@ -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<MonthlyView> {
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<String> _invalidCells = <String>{};
final Map<String, FocusNode> _focusNodes = {};
final Map<String, TextEditingController> _controllers = {};
@@ -25,13 +33,23 @@ class _MonthlyViewState extends State<MonthlyView> {
_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<WorkDay> _days = const [];
bool _loading = false;
String? _error;
Map<String, String> _holidays = const {};
Map<int, int> _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<MonthlyView> {
_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<MonthlyView> {
_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<WorkDay>;
final plan = results[1] as Map<int, int>;
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<MonthlyView> {
}
}
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<WorkDay>.generate(_days.length, (i) => _effectiveDay(i, _days[i]));
// kumulative Differenz
// kumulative Differenz basierend auf Override- oder Intervall-Arbeitszeit
final diffs = <int>[];
for (final d in effectiveDays) {
final worked = _workedFor(d);
diffs.add(worked - d.targetMinutes);
}
final cumulative = <int>[];
int sum = 0;
for (final d in effectiveDays) {
sum += d.diffMinutes;
for (final diff in diffs) {
sum += diff;
cumulative.add(sum);
}
final rows = List<DataRow>.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<Color?>((_) {
if (isHoliday) return holidayBg;
if (isWeekend) return weekendBg;
return null;
}),
color: WidgetStateProperty.resolveWith<Color?>(
(_) => _rowColorFor(day, holidayBg: holidayBg, weekendBg: weekendBg),
),
cells: _buildEditableCells(i, day, run),
);
});
@@ -165,6 +230,34 @@ class _MonthlyViewState extends State<MonthlyView> {
],
),
),
// 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 <DataRow>[], // 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<MonthlyView> {
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<MonthlyView> {
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<MonthlyView> {
}
List<DataColumn> _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<MonthlyView> {
final cells = <DataCell>[];
// 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 = <String?>[null, ...kAbsenceCodes];
return DropdownButton<String?>(
isExpanded: true,
value: value,
// Langnamen in der aufgeklappten Liste:
items: values.map((v) {
final label = v == null ? '' : codeLabel(v);
return DropdownMenuItem<String?>(
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<WorkDay>.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<MonthlyView> {
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<MonthlyView> {
}
}
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<String> onChanged,
required ValueChanged<String> 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<String> onChanged,
required ValueChanged<String> 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 {
+24 -9
View File
@@ -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<WorkDay> fillMonth(DateTime monthStart, List<WorkDay> 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<String> 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 '';
}
}
+2 -1
View File
@@ -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;