This commit is contained in:
2025-09-19 13:53:50 +02:00
parent f212f9feb3
commit 1e90222d36
11 changed files with 751 additions and 333 deletions
+73 -93
View File
@@ -1,119 +1,99 @@
import 'dart:ui' show FontFeature;
import 'package:flutter/material.dart';
import '../models/work_day.dart';
String monthTitle(DateTime m) {
const months = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
];
return '${months[m.month - 1]} ${m.year}';
}
/// Kurzcode-Liste & Label
const List<String> kAbsenceCodes = ['G', 'K', 'U', 'SU', 'T'];
String leftDayLabel(DateTime d) => '${ddmm(d)} ${weekdayShort(d)}';
String rightDayLabel(DateTime d) => '${weekdayShort(d)} ${ddmm(d)}';
String weekdayShort(DateTime d) {
switch (d.weekday) {
case DateTime.monday: return 'Mo';
case DateTime.tuesday: return 'Di';
case DateTime.wednesday: return 'Mi';
case DateTime.thursday: return 'Do';
case DateTime.friday: return 'Fr';
case DateTime.saturday: return 'Sa';
case DateTime.sunday: return 'So';
default: return '';
String codeLabel(String code) {
switch (code) {
case 'G': return 'Gleitzeit';
case 'K': return 'Krankenstand';
case 'U': return 'Urlaub';
case 'SU': return 'Sonderurlaub';
case 'T': return 'Training';
default: return code;
}
}
String ddmm(DateTime d) =>
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}';
Widget mono(String s) =>
Text(s, style: const TextStyle(fontFeatures: [FontFeature.tabularFigures()]));
String fmtTimeOfDay(TimeOfDay t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
String minutesToHHMM(int minutes) {
final h = minutes ~/ 60;
final m = minutes % 60;
return '${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}';
}
String minutesToSignedHHMM(int minutes) {
final sign = minutes < 0 ? '-' : '+';
final absMin = minutes.abs();
final h = absMin ~/ 60;
final m = absMin % 60;
return '$sign${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}';
}
TimeOfDay? parseHHMM(dynamic v) {
if (v == null) return null;
final s = v.toString().trim();
if (s.isEmpty) return null;
final parts = s.split(':');
/// Zeit-Helfer
TimeOfDay? parseDbTime(String? hhmmss) {
if (hhmmss == null || hhmmss.isEmpty) return null;
final parts = hhmmss.split(':');
if (parts.length < 2) return null;
final h = int.tryParse(parts[0]) ?? 0;
final m = int.tryParse(parts[1]) ?? 0;
if (h < 0 || h > 23 || m < 0 || m > 59) return null;
return TimeOfDay(hour: h, minute: m);
}
String fmtTimeOfDay(TimeOfDay? t) {
if (t == null) return '';
final hh = t.hour.toString().padLeft(2, '0');
final mm = t.minute.toString().padLeft(2, '0');
return '$hh:$mm';
}
TimeOfDay? parseTextHHMM(String s) {
final t = s.trim();
if (t.length != 5 || t[2] != ':') return null;
final h = int.tryParse(t.substring(0, 2));
final m = int.tryParse(t.substring(3, 5));
if (s.length != 5 || s[2] != ':') return null;
final h = int.tryParse(s.substring(0, 2));
final m = int.tryParse(s.substring(3, 5));
if (h == null || m == null) return null;
if (h < 0 || h > 23 || m < 0 || m > 59) return null;
return TimeOfDay(hour: h, minute: m);
}
String minutesToHHMM(int minutes) {
final sign = minutes < 0 ? '-' : '';
final m = minutes.abs();
final hh = (m ~/ 60).toString().padLeft(2, '0');
final mm = (m % 60).toString().padLeft(2, '0');
return '$sign$hh:$mm';
}
String minutesToSignedHHMM(int minutes) => minutesToHHMM(minutes);
/// Datums-Formatierungen
String ymd(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
// Füllt alle Tage des Monats auf
List<WorkDay> fillMonth(DateTime monthStart, List<WorkDay> existing) {
final map = <String, WorkDay>{for (final w in existing) ymd(w.date): w};
final nextMonth = DateTime(monthStart.year, monthStart.month + 1, 1);
final out = <WorkDay>[];
for (DateTime d = monthStart; d.isBefore(nextMonth); d = d.add(const Duration(days: 1))) {
final key = ymd(d);
final wd = map[key];
if (wd != null) {
out.add(wd);
const _weekdayShort = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
String _shortWeekday(DateTime d) => _weekdayShort[(d.weekday - 1) % 7];
String _shortDate(DateTime d) =>
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.';
/// Links in der Tabelle (Tag vor Datum)
String rightDayLabel(DateTime d) => '${_shortWeekday(d)} ${_shortDate(d)}';
/// Rechts in der Tabelle (Datum vor Tag)
String leftDayLabel(DateTime d) => '${_shortDate(d)} ${_shortWeekday(d)}';
const _monthNames = [
'Jänner', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
];
String monthTitle(DateTime m) => '${_monthNames[m.month - 1]} ${m.year}';
/// Füllt den kompletten Monat mit leeren Tagen, ersetzt vorhandene Einträge
List<WorkDay> fillMonth(DateTime monthStart, List<WorkDay> fromApi) {
final m0 = DateTime(monthStart.year, monthStart.month, 1);
final m1 = DateTime(monthStart.year, monthStart.month + 1, 1);
final byDay = <String, WorkDay>{for (final d in fromApi) ymd(d.date): d};
final days = <WorkDay>[];
for (DateTime d = m0; d.isBefore(m1); d = d.add(const Duration(days: 1))) {
final k = ymd(d);
if (byDay.containsKey(k)) {
final v = byDay[k]!;
days.add(WorkDay(
date: d,
intervals: v.intervals,
targetMinutes: v.targetMinutes,
code: v.code,
));
} else {
final isWeekend = d.weekday == DateTime.saturday || d.weekday == DateTime.sunday;
final target = isWeekend ? 0 : 8 * 60;
out.add(WorkDay(date: d, intervals: const [], targetMinutes: target));
days.add(WorkDay(date: d, intervals: const [], targetMinutes: 0, code: null));
}
}
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 = ['G', 'K', 'U', 'SU', 'T'];
String codeLabel(String? code) {
switch (code) {
case 'G': return 'Gleitzeit';
case 'K': return 'Krankenstand';
case 'U': return 'Urlaub';
case 'SU': return 'Sonderurlaub';
case 'T': return 'Training';
default: return '';
}
return days;
}
+15 -7
View File
@@ -1,12 +1,25 @@
import '../utils/helpers.dart';
/// Sehr vereinfachte österreichische Feiertage (bundesweit).
/// Key = 'YYYY-MM-DD', Value = Name.
Map<String, String> buildHolidayMapAT(int year) {
final Map<String, String> m = {};
DateTime easter = _easterSunday(year);
DateTime easterMon = easter.add(const Duration(days: 1));
DateTime ascension = easter.add(const Duration(days: 39));
DateTime whitMon = easter.add(const Duration(days: 50));
DateTime corpusChristi = easter.add(const Duration(days: 60));
void add(DateTime d, String name) => m[ymd(d)] = name;
add(DateTime(year, 1, 1), 'Neujahr');
add(DateTime(year, 1, 6), 'Heilige Drei Könige');
add(easterMon, 'Ostermontag');
add(DateTime(year, 5, 1), 'Staatsfeiertag');
add(ascension, 'Christi Himmelfahrt');
add(whitMon, 'Pfingstmontag');
add(corpusChristi, 'Fronleichnam');
add(DateTime(year, 8, 15), 'Mariä Himmelfahrt');
add(DateTime(year, 10, 26), 'Nationalfeiertag');
add(DateTime(year, 11, 1), 'Allerheiligen');
@@ -14,15 +27,10 @@ Map<String, String> buildHolidayMapAT(int year) {
add(DateTime(year, 12, 25), 'Christtag');
add(DateTime(year, 12, 26), 'Stefanitag');
final easter = _easterSunday(year);
add(easter.add(const Duration(days: 1)), 'Ostermontag');
add(easter.add(const Duration(days: 39)), 'Christi Himmelfahrt');
add(easter.add(const Duration(days: 50)), 'Pfingstmontag');
add(easter.add(const Duration(days: 60)), 'Fronleichnam');
return m;
}
/// Gaußsche Osterformel (Gregorianisch)
DateTime _easterSunday(int year) {
final a = year % 19;
final b = year ~/ 100;
@@ -36,7 +44,7 @@ DateTime _easterSunday(int year) {
final k = c % 4;
final l = (32 + 2 * e + 2 * i - h - k) % 7;
final m = (a + 11 * h + 22 * l) ~/ 451;
final month = (h + l - 7 * m + 114) ~/ 31; // 3=March, 4=April
final month = (h + l - 7 * m + 114) ~/ 31; // 3=March, 4=April
final day = ((h + l - 7 * m + 114) % 31) + 1;
return DateTime(year, month, day);
}
+46 -16
View File
@@ -1,27 +1,57 @@
import 'package:flutter/services.dart';
/// Tippt „HHMM“ und formatiert live zu „HH:MM“ (nur Ziffern erlaubt).
/// Formatiert Eingaben als HH:mm (max. 5 Zeichen),
/// lässt nur Ziffern und optional ":" an Pos 2 zu.
/// Fügt bei Bedarf ":" automatisch ein.
class HHmmInputFormatter extends TextInputFormatter {
const HHmmInputFormatter();
@override
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
String digits = newValue.text.replaceAll(RegExp(r'[^0-9]'), '');
if (digits.isEmpty) {
return const TextEditingValue(text: '', selection: TextSelection.collapsed(offset: 0));
}
if (digits.length > 4) digits = digits.substring(0, 4);
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
var text = newValue.text;
String text;
if (digits.length <= 2) {
text = digits;
} else {
final hh = digits.substring(0, 2);
final mm = digits.substring(2);
text = '$hh:$mm';
// nur Ziffern und ":" erlauben
final filtered = StringBuffer();
for (int i = 0; i < text.length; i++) {
final c = text[i];
if ((c.codeUnitAt(0) >= 48 && c.codeUnitAt(0) <= 57) || c == ':') {
filtered.write(c);
}
}
text = filtered.toString();
// Länge begrenzen
if (text.length > 5) text = text.substring(0, 5);
// ":" automatisch einfügen
if (text.length >= 3) {
if (text[2] != ':') {
text = text.replaceRange(2, 2, ':');
}
}
final offset = text.length;
return TextEditingValue(text: text, selection: TextSelection.collapsed(offset: offset));
// nur ein ":" erlauben, und nur an Stelle 2
if (text.contains(':')) {
final idx = text.indexOf(':');
if (idx != 2) {
text = text.replaceAll(':', '');
if (text.length >= 2) {
text = '${text.substring(0, 2)}:${text.substring(2)}';
}
} else {
// weitere ":" entfernen
final rest = text.substring(3).replaceAll(':', '');
text = text.substring(0, 3) + rest;
}
}
// Cursor ans Ende
return TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
);
}
}
+39
View File
@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import '../models/work_interval.dart';
int _toMinutes(TimeOfDay t) => t.hour * 60 + t.minute;
/// Effektive Arbeitsminuten eines Tages nach Pausenregel:
/// - <= 6h Arbeit: keine Pflichtpause
/// - > 6h Arbeit: 30 Min Pflichtpause, reduziert um die Summe der Lücken
/// zwischen Intervallen (maximal bis 0 reduzierbar).
int effectiveWorkedMinutes(
List<WorkInterval> intervals, {
int requiredBreakOver6h = 30,
}) {
if (intervals.isEmpty) return 0;
// sortieren
final sorted = [...intervals]
..sort((a, b) => _toMinutes(a.start).compareTo(_toMinutes(b.start)));
// Arbeitszeit summieren
int worked = 0;
for (final iv in sorted) {
final w = _toMinutes(iv.end) - _toMinutes(iv.start);
if (w > 0) worked += w;
}
if (worked <= 6 * 60) return worked;
// Lücken summieren
int gaps = 0;
for (var i = 0; i < sorted.length - 1; i++) {
final gap = _toMinutes(sorted[i + 1].start) - _toMinutes(sorted[i].end);
if (gap > 0) gaps += gap;
}
final extraBreak = (requiredBreakOver6h - gaps).clamp(0, requiredBreakOver6h);
final effective = worked - extraBreak;
return effective < 0 ? 0 : effective;
}