diff --git a/lib/api/booking_api.dart b/lib/api/booking_api.dart index 7502979..50217e9 100644 --- a/lib/api/booking_api.dart +++ b/lib/api/booking_api.dart @@ -1,81 +1,86 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import 'package:flutter/material.dart'; - import '../models/work_day.dart'; import '../models/work_interval.dart'; +import '../models/month_start.dart'; import '../utils/helpers.dart'; class BookingApi { - final String host; - final String path; final http.Client client; + BookingApi({required this.client}); - const BookingApi({ - required this.client, - this.host = 'api.windesign.at', - this.path = '/workinghours.php', - }); - + /// Monatliche Buchungen holen (für YYYY-MM) Future> getBookingList(DateTime monthStart) async { final y = monthStart.year.toString().padLeft(4, '0'); final m = monthStart.month.toString().padLeft(2, '0'); - final uri = Uri.https(host, path, { - 'module': 'booking', - 'function': 'getList', - 'date': '$y-$m', - }); - - http.Response res; - try { - res = await client - .get(uri, headers: {'Accept': 'application/json'}) - .timeout(const Duration(seconds: 15)); - } on http.ClientException catch (e) { - throw Exception('ClientException: ${e.message} (uri=$uri)'); - } on Object catch (e) { - throw Exception('Network error: $e (uri=$uri)'); - } + final uri = Uri.parse( + 'https://api.windesign.at/workinghours.php?module=booking&function=getList&date=$y-$m', + ); + final res = await client.get(uri); if (res.statusCode != 200) { - throw Exception('HTTP ${res.statusCode}: ${res.body}'); + throw Exception('booking/getList failed: ${res.statusCode} ${res.body}'); } - final map = jsonDecode(res.body) as Map; - final List items = map['bookings'] ?? []; - final out = []; + if (map['error'] == true) { + throw Exception('booking/getList error: ${map['errmsg']}'); + } + final list = (map['bookings'] as List?) ?? const []; + final items = []; - for (final row in items) { - final d = DateTime.parse(row['bookingDay'] as String); - final isWeekend = d.weekday == DateTime.saturday || d.weekday == DateTime.sunday; - final target = isWeekend ? 0 : 8 * 60; + for (final e in list) { + final row = e as Map; + final dayStr = (row['bookingDay'] as String?) ?? ''; + final date = DateTime.tryParse(dayStr); + if (date == null) continue; + String? code = (row['code'] as String?)?.trim(); + if (code != null && code.isEmpty) code = null; + + final starts = [ + row['come1'] as String?, row['come2'] as String?, + row['come3'] as String?, row['come4'] as String?, row['come5'] as String?, + ]; + final ends = [ + row['leave1'] as String?, row['leave2'] as String?, + row['leave3'] as String?, row['leave4'] as String?, row['leave5'] as String?, + ]; final intervals = []; - TimeOfDay? p(dynamic v) => parseHHMM(v); - void addPair(String a, String b) { - final s = p(row[a]); - final e = p(row[b]); - if (s != null && e != null) intervals.add(WorkInterval(s, e)); + for (int i = 0; i < 5; i++) { + final s = parseDbTime(starts[i]); + final e2 = parseDbTime(ends[i]); + if (s != null && e2 != null) { + intervals.add(WorkInterval(s, e2)); + } } - addPair('come1', 'leave1'); - addPair('come2', 'leave2'); - addPair('come3', 'leave3'); - addPair('come4', 'leave4'); - addPair('come5', 'leave5'); - - final code = (row['code']?.toString().trim().isEmpty ?? true) - ? null - : row['code'].toString().trim(); - out.add(WorkDay( - date: d, + items.add(WorkDay( + date: DateTime(date.year, date.month, date.day), intervals: intervals, - targetMinutes: target, + targetMinutes: 0, // wird im UI mit dem Tagesplan ersetzt code: code, )); } - out.sort((a, b) => a.date.compareTo(b.date)); - return out; + return items; + } + + /// Monatliche Startdaten (Startsaldo, Vacation, Overtime, Correction) + Future getMonthStart(DateTime monthStart) async { + final y = monthStart.year.toString().padLeft(4, '0'); + final m = monthStart.month.toString().padLeft(2, '0'); + final uri = Uri.parse( + 'https://api.windesign.at/workinghours.php' + '?module=monthlybooking&function=getList&date=$y-$m-01', + ); + final res = await client.get(uri); + if (res.statusCode != 200) { + throw Exception('monthlybooking/getList failed: ${res.statusCode} ${res.body}'); + } + final map = jsonDecode(res.body) as Map; + if (map['error'] == true) { + throw Exception('monthlybooking/getList error: ${map['errmsg']}'); + } + return MonthStart.fromJson(map); } } diff --git a/lib/api/daily_working_api.dart b/lib/api/daily_working_api.dart index e02617f..687cf0f 100644 --- a/lib/api/daily_working_api.dart +++ b/lib/api/daily_working_api.dart @@ -1,66 +1,47 @@ 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; + DailyWorkingApi({required this.client}); - const DailyWorkingApi({ - required this.client, - this.host = 'api.windesign.at', - this.path = '/workinghours.php', - }); - + /// Liefert Map weekday(1..7) -> Minuten (int) 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)); - + final uri = Uri.parse( + 'https://api.windesign.at/workinghours.php?module=dailyworking&function=getList', + ); + final res = await client.get(uri); if (res.statusCode != 200) { - throw Exception('HTTP ${res.statusCode}: ${res.body}'); + throw Exception('dailyworking/getList failed: ${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; + if (map['error'] == true) { + throw Exception('dailyworking/getList error: ${map['errmsg']}'); + } + final entries = (map['entries'] as List?) ?? const []; + if (entries.isEmpty) { + return { + 1: 8 * 60, 2: 8 * 60, 3: 8 * 60, 4: 8 * 60, 5: 8 * 60, 6: 0, 7: 0, + }; + } + final row = entries.first as Map; + int parseHHMMSS(String? s) { + if (s == null || s.isEmpty) return 0; final parts = s.split(':'); - if (parts.length != 3) return 0; + if (parts.length < 2) return 0; final h = int.tryParse(parts[0]) ?? 0; final m = int.tryParse(parts[1]) ?? 0; - return h * 60 + m; // Sekunden ignoriert + return h * 60 + m; } 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?), + 1: parseHHMMSS(row['monday'] as String?), + 2: parseHHMMSS(row['tuesday'] as String?), + 3: parseHHMMSS(row['wednesday'] as String?), + 4: parseHHMMSS(row['thursday'] as String?), + 5: parseHHMMSS(row['friday'] as String?), + 6: parseHHMMSS(row['saturday'] as String?), + 7: parseHHMMSS(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/main.dart b/lib/main.dart index 58205e3..2436e54 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,25 +1,45 @@ import 'package:flutter/material.dart'; -import 'screens/home.dart'; +import 'screens/monthly_view.dart'; -void main() => runApp(const ZeitschreibungApp()); +void main() { + runApp(const WorkingHoursApp()); +} -class ZeitschreibungApp extends StatelessWidget { - const ZeitschreibungApp({super.key}); +class WorkingHoursApp extends StatelessWidget { + const WorkingHoursApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( - title: 'Zeitschreibung', + title: 'Working Hours', theme: ThemeData( - colorSchemeSeed: const Color(0xFF3B82F6), useMaterial3: true, - inputDecorationTheme: const InputDecorationTheme( - border: OutlineInputBorder(), - isDense: true, + colorSchemeSeed: const Color(0xFF4B7BE5), + visualDensity: VisualDensity.compact, + ), + home: const _Home(), + ); + } +} + +class _Home extends StatelessWidget { + const _Home(); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 1, + child: Scaffold( + appBar: AppBar( + title: const Text('Zeitschreibung'), + bottom: const TabBar(tabs: [ + Tab(text: 'Monat'), + ]), ), + body: const TabBarView(children: [ + MonthlyView(), + ]), ), - home: const HomeScreen(), - debugShowCheckedModeBanner: false, ); } } diff --git a/lib/models/month_start.dart b/lib/models/month_start.dart new file mode 100644 index 0000000..d225ba9 --- /dev/null +++ b/lib/models/month_start.dart @@ -0,0 +1,35 @@ +class MonthStart { + final DateTime month; // YYYY-MM-01 + final int startMinutes; // starthours (in Minuten) + final int startVacationUnits; // startvacation (z.B. Tage) + final int overtimeMinutes; // overtime (Minuten) + final int correctionMinutes; // correction (Minuten) + + const MonthStart({ + required this.month, + required this.startMinutes, + required this.startVacationUnits, + required this.overtimeMinutes, + required this.correctionMinutes, + }); + + /// Basis für kumulierte Differenz + int get carryBaseMinutes => startMinutes + overtimeMinutes + correctionMinutes; + + static MonthStart fromJson(Map json) { + final bookings = (json['bookings'] as List?) ?? const []; + final row = bookings.isNotEmpty ? (bookings.first as Map) : const {}; + final dateStr = (json['date'] as String?) ?? '1970-01-01'; + final dt = DateTime.tryParse(dateStr) ?? DateTime(1970, 1, 1); + + int _toInt(dynamic v) => v is num ? v.toInt() : int.tryParse('$v') ?? 0; + + return MonthStart( + month: DateTime(dt.year, dt.month, 1), + startMinutes: _toInt(row['starthours']), + startVacationUnits: _toInt(row['startvacation']), + overtimeMinutes: _toInt(row['overtime']), + correctionMinutes: _toInt(row['correction']), + ); + } +} diff --git a/lib/models/work_day.dart b/lib/models/work_day.dart index f7e7a9b..c7acc9c 100644 --- a/lib/models/work_day.dart +++ b/lib/models/work_day.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; +import '../utils/work_time.dart'; import 'work_interval.dart'; class WorkDay { final DateTime date; - final List intervals; // bis zu 5 - final String? code; // GZ, K, U, SU, T oder null - final int targetMinutes; // Soll + final List intervals; + final int targetMinutes; + final String? code; // 'G','U','SU','K','T' oder null const WorkDay({ required this.date, @@ -14,6 +15,6 @@ class WorkDay { this.code, }); - int get workedMinutes => intervals.fold(0, (sum, i) => sum + i.minutes); - int get diffMinutes => workedMinutes - targetMinutes; + /// Effektive Ist-Zeit mit 30-Minuten-Pausenregel (>6h) und Lückenanrechnung + int get workedMinutes => effectiveWorkedMinutes(intervals); } diff --git a/lib/models/work_interval.dart b/lib/models/work_interval.dart index 74071ac..13701f4 100644 --- a/lib/models/work_interval.dart +++ b/lib/models/work_interval.dart @@ -3,15 +3,5 @@ import 'package:flutter/material.dart'; class WorkInterval { final TimeOfDay start; final TimeOfDay end; - const WorkInterval(this.start, this.end); - - int get minutes { - final aMin = start.hour * 60 + start.minute; - final bMin = end.hour * 60 + end.minute; - final d = bMin - aMin; - if (d <= 0) return 0; - if (d >= 24 * 60) return 24 * 60; - return d; - } } diff --git a/lib/screens/monthly_view.dart b/lib/screens/monthly_view.dart index 28421cb..56702b1 100644 --- a/lib/screens/monthly_view.dart +++ b/lib/screens/monthly_view.dart @@ -6,6 +6,7 @@ import '../api/booking_api.dart'; import '../api/daily_working_api.dart'; import '../models/work_day.dart'; import '../models/work_interval.dart'; +import '../models/month_start.dart'; import '../utils/helpers.dart'; import '../utils/holidays_at.dart'; import '../utils/input_formatters.dart'; @@ -17,7 +18,7 @@ class MonthlyView extends StatefulWidget { } class _MonthlyViewState extends State { - // Kompaktere Layout-Parameter + // Layout static const double _wDate = 96; static const double _wHoliday = 120; static const double _wTime = 60; @@ -25,17 +26,19 @@ class _MonthlyViewState extends State { static const double _wNumber = 76; static const double _fontSize = 12; - // Codes, die Zeiten sperren/ leeren + // Codes, die Zeiten leeren/sperren static const Set _lockCodes = {'G', 'K', 'U', 'SU'}; // Edit/Fokus/Controller je Feld final Set _invalidCells = {}; final Map _focusNodes = {}; final Map _controllers = {}; - FocusNode _nodeFor(String key) => _focusNodes.putIfAbsent(key, () => FocusNode()); + FocusNode _nodeFor(String key) => + _focusNodes.putIfAbsent(key, () => FocusNode()); TextEditingController _controllerFor(String key, String initial) => _controllers.putIfAbsent(key, () => TextEditingController(text: initial)); + // API / State late final http.Client _client; late final BookingApi _bookingApi; late final DailyWorkingApi _dailyApi; @@ -45,7 +48,7 @@ class _MonthlyViewState extends State { bool _loading = false; String? _error; Map _holidays = const {}; - Map _dailyPlan = { + Map _dailyPlan = const { DateTime.monday: 8 * 60, DateTime.tuesday: 8 * 60, DateTime.wednesday: 8 * 60, @@ -55,10 +58,14 @@ class _MonthlyViewState extends State { DateTime.sunday: 0, }; - // Scroll-Controller - late final ScrollController _hHeaderCtrl; // Header (nur mitgeführt) - late final ScrollController _hBodyCtrl; // Body (führend) - late final ScrollController _vCtrl; // Body vertikal + // Monatliche Startdaten (für kumulierte Differenz & Footer) + MonthStart? _monthStartInfo; + int _carryBaseMinutes = 0; + + // Scroll-Controller (Header/Body synchron) + late final ScrollController _hHeaderCtrl; + late final ScrollController _hBodyCtrl; + late final ScrollController _vCtrl; bool _syncingH = false; @override @@ -71,7 +78,6 @@ class _MonthlyViewState extends State { _hBodyCtrl = ScrollController(); _vCtrl = ScrollController(); - // Body scrollt -> Header folgt _hBodyCtrl.addListener(() { if (_syncingH) return; _syncingH = true; @@ -112,27 +118,35 @@ class _MonthlyViewState extends State { final results = await Future.wait([ _bookingApi.getBookingList(m), _dailyApi.getDailyMinutes(), + _bookingApi.getMonthStart(m), ]); final apiDays = results[0] as List; final plan = results[1] as Map; + final mStart = results[2] as MonthStart; - final filled = fillMonth(m, apiDays); + final holidayMap = buildHolidayMapAT(m.year); // << neu - final withTargets = filled - .map((d) => WorkDay( - date: d.date, - intervals: d.intervals, - targetMinutes: plan[d.date.weekday] ?? d.targetMinutes, - code: d.code, - )) - .toList(); + final filled = fillMonth(m, apiDays); + final withTargets = filled.map((d) { + final isHoliday = holidayMap.containsKey(ymd(d.date)); // << neu + final target = + isHoliday ? 0 : (plan[d.date.weekday] ?? d.targetMinutes); + return WorkDay( + date: d.date, + intervals: d.intervals, + targetMinutes: target, + code: d.code, + ); + }).toList(); setState(() { _monthStart = DateTime(m.year, m.month, 1); - _holidays = buildHolidayMapAT(_monthStart.year); + _holidays = holidayMap; // << aus holidayMap setzen _days = withTargets; _dailyPlan = plan; + _monthStartInfo = mStart; + _carryBaseMinutes = mStart.carryBaseMinutes; _loading = false; }); @@ -150,14 +164,20 @@ class _MonthlyViewState extends State { // Ist-Override für U/SU/K/T int _workedFor(WorkDay d) { - final c = d.code; - if (c == 'U' || c == 'SU' || c == 'K' || c == 'T') { - return d.targetMinutes; + switch (d.code) { + case 'U': + case 'SU': + case 'K': // neu: Krank => IST = 0 + return 0; + case 'T': + return d.targetMinutes; + default: + return d.workedMinutes; } - return d.workedMinutes; } - Color? _rowColorFor(WorkDay d, {required Color? holidayBg, required Color? weekendBg}) { + Color? _rowColorFor(WorkDay d, + {required Color? holidayBg, required Color? weekendBg}) { switch (d.code) { case 'G': return const Color(0xFFBFBFFF); // Gleitzeit @@ -166,12 +186,13 @@ class _MonthlyViewState extends State { case 'SU': return const Color(0xFF7F7FFF); // Sonderurlaub case 'K': - return Colors.yellow; // Krankenstand + return Colors.yellow; // Krankenstand case 'T': - return Colors.red; // Training + return Colors.red; // Training } final isHoliday = _holidays.containsKey(ymd(d.date)); - final isWeekend = d.date.weekday == DateTime.saturday || d.date.weekday == DateTime.sunday; + final isWeekend = d.date.weekday == DateTime.saturday || + d.date.weekday == DateTime.sunday; if (isHoliday) return holidayBg; if (isWeekend) return weekendBg; return null; @@ -186,21 +207,45 @@ class _MonthlyViewState extends State { final holidayBg = theme.colorScheme.secondaryContainer.withOpacity(0.45); final weekendBg = Colors.grey.withOpacity(0.30); - final effectiveDays = List.generate(_days.length, (i) => _effectiveDay(i, _days[i])); + // Live-„Effective“-Tage (inkl. Eingabetexte + Sperrlogik) + final effectiveDays = + List.generate(_days.length, (i) => _effectiveDay(i, _days[i])); - // diffs & kumuliert (mit Override) + // Tagesdifferenzen & kumuliert (Start mit Monatssaldo aus API) final diffs = []; + int sollTotal = 0; + int istTotal = 0; for (final d in effectiveDays) { final worked = _workedFor(d); diffs.add(worked - d.targetMinutes); + sollTotal += d.targetMinutes; + istTotal += worked; } final cumulative = []; - int sum = 0; + int sum = _carryBaseMinutes; for (final diff in diffs) { sum += diff; cumulative.add(sum); } + // Footer-Werte berechnen + final monthLabel = monthTitle(_monthStart); + final startVacation = _monthStartInfo?.startVacationUnits ?? 0; + final usedVacation = _days.where((d) => d.code == 'U').length; + final vacationCarry = startVacation - usedVacation; + + final codeCount = { + 'G': _days.where((d) => d.code == 'G').length, + 'K': _days.where((d) => d.code == 'K').length, + 'U': usedVacation, + 'SU': _days.where((d) => d.code == 'SU').length, + 'T': _days.where((d) => d.code == 'T').length, + }; + + final correctionMin = _monthStartInfo?.correctionMinutes ?? 0; + final nextCarryMin = + cumulative.isNotEmpty ? cumulative.last : _carryBaseMinutes; + final rows = List.generate(_days.length, (i) { final day = effectiveDays[i]; final run = cumulative[i]; @@ -216,8 +261,10 @@ class _MonthlyViewState extends State { _MonthHeader( month: _monthStart, loading: _loading, - onPrev: () => _loadMonth(DateTime(_monthStart.year, _monthStart.month - 1, 1)), - onNext: () => _loadMonth(DateTime(_monthStart.year, _monthStart.month + 1, 1)), + onPrev: () => + _loadMonth(DateTime(_monthStart.year, _monthStart.month - 1, 1)), + onNext: () => + _loadMonth(DateTime(_monthStart.year, _monthStart.month + 1, 1)), onPickMonth: () async { final picked = await showDatePicker( context: context, @@ -242,20 +289,22 @@ class _MonthlyViewState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text('Fehler beim Laden:', style: TextStyle(fontSize: _fontSize)), - SelectableText(_error ?? '', style: const TextStyle(fontSize: _fontSize)), + Text('Fehler beim Laden:', + style: TextStyle(fontSize: _fontSize)), + SelectableText(_error ?? '', + style: const TextStyle(fontSize: _fontSize)), ], ), actions: [ TextButton( onPressed: () => _loadMonth(_monthStart), - child: Text('Erneut versuchen', style: const TextStyle(fontSize: _fontSize)), + child: const Text('Erneut versuchen'), ), ], ), ), - // FIXIERTE KOPFZEILE (mitgeführt, nicht interaktiv) + // FIXIERTE KOPFZEILE (mitgeführt) SingleChildScrollView( controller: _hHeaderCtrl, physics: const NeverScrollableScrollPhysics(), @@ -266,16 +315,15 @@ class _MonthlyViewState extends State { data: DataTableThemeData( headingRowHeight: 30, columnSpacing: 10, - headingTextStyle: const TextStyle(fontWeight: FontWeight.w700).copyWith( - fontSize: _fontSize, - ), + headingTextStyle: const TextStyle( + fontWeight: FontWeight.w700, fontSize: _fontSize), ), child: const _HeaderOnlyDataTable(), ), ), ), - // BODY (horiz. & vert. Scroll; horizontale Scrollbar hier) + // BODY (scrollbar horiz/vert) Expanded( child: Scrollbar( controller: _hBodyCtrl, @@ -295,7 +343,7 @@ class _MonthlyViewState extends State { controller: _vCtrl, child: DataTableTheme( data: const DataTableThemeData( - headingRowHeight: 0, // Header im Body ausblenden + headingRowHeight: 0, dataRowMinHeight: 30, dataRowMaxHeight: 34, columnSpacing: 10, @@ -312,11 +360,38 @@ class _MonthlyViewState extends State { ), ), ), + + // Footer + const Divider(height: 1), + _MonthlySummaryFooter( + month: _monthStart, + fontSize: _fontSize, + // Linke Spalte + uebertragStartText: minutesToSignedHHMM(_carryBaseMinutes), + sollText: minutesToHHMM(sollTotal), + istText: minutesToHHMM(istTotal), + correctionText: minutesToSignedHHMM(correctionMin), + saldoText: minutesToSignedHHMM(istTotal - sollTotal), + paidOvertimeText: '—', // folgt später + uebertragNextText: minutesToSignedHHMM(nextCarryMin), + restUrlaubText: '$startVacation', + urlaubUebertragText: '$vacationCarry', + // Rechte Spalte (Counts) + countGleitzeit: codeCount['G'] ?? 0, + countKrank: codeCount['K'] ?? 0, + countUrlaub: codeCount['U'] ?? 0, + countSonderurlaub: codeCount['SU'] ?? 0, + countTraining: codeCount['T'] ?? 0, + monthLabel: monthLabel, + ), + const SizedBox(height: 8), ]); } List _buildColumns() { - DataColumn c(String label, {Alignment align = Alignment.center, double? width}) => DataColumn( + DataColumn c(String label, + {Alignment align = Alignment.center, double? width}) => + DataColumn( label: SizedBox( width: width, child: Align( @@ -352,18 +427,16 @@ class _MonthlyViewState extends State { ]; } - List _buildEditableCells(int dayIndex, WorkDay day, int runningDiff) { - // Gewünscht: links Wochentag zuerst, rechts Datum zuerst. - final leftLabel = rightDayLabel(day.date); // Wochentag Datum - final rightLabel = leftDayLabel(day.date); // Datum Wochentag + List _buildEditableCells( + int dayIndex, WorkDay day, int runningDiff) { + final leftLabel = rightDayLabel(day.date); // "Mo 01.09." + final rightLabel = leftDayLabel(day.date); // "01.09. Mo" final hName = _holidays[ymd(day.date)] ?? ''; - // Sperre Zeiten, wenn Code in _lockCodes final bool lockTimes = day.code != null && _lockCodes.contains(day.code); - - // Ist Wochenende/Feiertag => Code nicht änderbar final bool isHoliday = _holidays.containsKey(ymd(day.date)); - final bool isWeekend = day.date.weekday == DateTime.saturday || day.date.weekday == DateTime.sunday; + final bool isWeekend = day.date.weekday == DateTime.saturday || + day.date.weekday == DateTime.sunday; final bool codeDisabled = isHoliday || isWeekend; String slotText(int slot, bool isStart) { @@ -380,7 +453,7 @@ class _MonthlyViewState extends State { final cells = []; - // 0: Datum (rechts) + // 0: Datum (rechtsbündig) cells.add(DataCell(SizedBox( width: _wDate, child: Align( @@ -397,7 +470,7 @@ class _MonthlyViewState extends State { ), ))); - // 2..11: Zeiten (evtl. gesperrt & geleert) + // 2..11: Zeiten for (int slot = 0; slot < 5; slot++) { // Start final keyS = 't_${dayIndex}_${slot}_s'; @@ -484,7 +557,7 @@ class _MonthlyViewState extends State { ))); } - // 12: Code (Dropdown) — zentriert (disable bei Sa/So/Feiertag) + // 12: Code (Dropdown, am Wochenende/Feiertag gesperrt) cells.add(DataCell(SizedBox( width: _wCode, child: Align( @@ -493,7 +566,7 @@ class _MonthlyViewState extends State { ), ))); - // 13..16: Kennzahlen (mit Override) + // 13..16: Kennzahlen final worked = _workedFor(day); final soll = minutesToHHMM(day.targetMinutes); final ist = minutesToHHMM(worked); @@ -514,10 +587,11 @@ class _MonthlyViewState extends State { ))); cells.add(DataCell(SizedBox( width: _wNumber, - child: Align(alignment: Alignment.centerRight, child: _monoSmall(diffSum)), + child: + Align(alignment: Alignment.centerRight, child: _monoSmall(diffSum)), ))); - // 17: Datum (links) + // 17: Datum (linksbündig) cells.add(DataCell(SizedBox( width: _wDate, child: Align( @@ -526,7 +600,8 @@ class _MonthlyViewState extends State { ), ))); - assert(cells.length == 18, 'Row has ${cells.length} cells but expected 18.'); + assert( + cells.length == 18, 'Row has ${cells.length} cells but expected 18.'); return cells; } @@ -550,21 +625,25 @@ class _MonthlyViewState extends State { final label = v == null ? '—' : codeLabel(v); return DropdownMenuItem( value: v, - child: Text(label, textAlign: TextAlign.center, style: const TextStyle(fontSize: _fontSize)), + child: Text(label, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: _fontSize)), ); }).toList(), selectedItemBuilder: (context) { return values.map((v) { final shortText = v ?? '—'; - return Center(child: Text(shortText, style: const TextStyle(fontSize: _fontSize))); + return Center( + child: + Text(shortText, style: const TextStyle(fontSize: _fontSize))); }).toList(); }, onChanged: disabled ? null : (newCode) { final d = _days[dayIndex]; - // Wenn neuer Code ein Lock-Code ist: Zeiten löschen & sperren - final bool willLock = newCode != null && _lockCodes.contains(newCode); + final bool willLock = + newCode != null && _lockCodes.contains(newCode); setState(() { final newDays = List.from(_days); newDays[dayIndex] = WorkDay( @@ -576,14 +655,11 @@ class _MonthlyViewState extends State { _days = newDays; if (willLock) { - // Controller leeren & Invalid-Flags löschen for (int slot = 0; slot < 5; slot++) { final sKey = 't_${dayIndex}_${slot}_s'; final eKey = 't_${dayIndex}_${slot}_e'; - final cs = _controllers[sKey]; - final ce = _controllers[eKey]; - if (cs != null && cs.text.isNotEmpty) cs.text = ''; - if (ce != null && ce.text.isNotEmpty) ce.text = ''; + _controllers[sKey]?.text = ''; + _controllers[eKey]?.text = ''; _invalidCells.remove(_cellKey(dayIndex, slot, true)); _invalidCells.remove(_cellKey(dayIndex, slot, false)); } @@ -608,7 +684,6 @@ class _MonthlyViewState extends State { final t = text.trim().isEmpty ? null : parseTextHHMM(text); final d = _days[dayIndex]; - // Wenn Code sperrt: ignorieren (Sicherheit) if (d.code != null && _lockCodes.contains(d.code)) return; final starts = List.filled(5, null); @@ -662,19 +737,42 @@ class _MonthlyViewState extends State { : ''; final cs = _controllers[sKey]; final ce = _controllers[eKey]; - if (cs != null && cs.text != sText && !(_focusNodes[sKey]?.hasFocus ?? false)) cs.text = sText; - if (ce != null && ce.text != eText && !(_focusNodes[eKey]?.hasFocus ?? false)) ce.text = eText; + if (cs != null && + cs.text != sText && + !(_focusNodes[sKey]?.hasFocus ?? false)) cs.text = sText; + if (ce != null && + ce.text != eText && + !(_focusNodes[eKey]?.hasFocus ?? false)) ce.text = eText; } } } WorkDay _effectiveDay(int row, WorkDay base) { - // Wenn Code sperrt, sind die Intervalle leer (unabhängig von Controller-Text) + final isHoliday = _holidays.containsKey(ymd(base.date)); + + // Basisziel (Tagesplan) + final baseTarget = _dailyPlan[base.date.weekday] ?? base.targetMinutes; + + // Zielzeit bestimmen: Feiertag -> 0, sonst bei U/SU -> 0, sonst Tagesplan + int targetFor(WorkDay b) { + if (isHoliday) return 0; + if (b.code == 'U' || b.code == 'SU' || b.code == 'K') { + return 0; // <- neu: Soll = 0 bei U/SU + } + return baseTarget; + } + + // „Lock“-Pfad (G/K/U/SU: keine Zeiten) if (base.code != null && _lockCodes.contains(base.code)) { - final target = _dailyPlan[base.date.weekday] ?? base.targetMinutes; - return WorkDay(date: base.date, intervals: const [], targetMinutes: target, code: base.code); + return WorkDay( + date: base.date, + intervals: const [], + targetMinutes: targetFor(base), + code: base.code, + ); } + // Eingabetexte berücksichtigen TimeOfDay? baseSlot(WorkDay d, int slot, bool isStart) { if (slot >= d.intervals.length) return null; final iv = d.intervals[slot]; @@ -688,20 +786,20 @@ class _MonthlyViewState extends State { final sText = _controllers[sKey]?.text; final eText = _controllers[eKey]?.text; - final s = (sText != null && sText.isNotEmpty) ? parseTextHHMM(sText) : baseSlot(base, slot, true); - final e = (eText != null && eText.isNotEmpty) ? parseTextHHMM(eText) : baseSlot(base, slot, false); + final s = (sText != null && sText.isNotEmpty) + ? parseTextHHMM(sText) + : baseSlot(base, slot, true); + final e = (eText != null && eText.isNotEmpty) + ? parseTextHHMM(eText) + : baseSlot(base, slot, false); - if (s != null && e != null) { - intervals.add(WorkInterval(s, e)); - } + if (s != null && e != null) intervals.add(WorkInterval(s, e)); } - final target = _dailyPlan[base.date.weekday] ?? base.targetMinutes; - return WorkDay( date: base.date, intervals: intervals, - targetMinutes: target, + targetMinutes: targetFor(base), // <- neu angewendet code: base.code, ); } @@ -757,8 +855,7 @@ class _MonthlyViewState extends State { } } -/// Minimaler Header-Table, damit die Kopfzeile fixiert bleiben kann. -/// Nutzt die gleichen Spalten wie der Body, aber ohne Rows. +/// Kopfzeile als separate DataTable (fixiert) class _HeaderOnlyDataTable extends StatelessWidget { const _HeaderOnlyDataTable(); @@ -796,20 +893,252 @@ class _MonthHeader extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), child: Row(children: [ - IconButton(onPressed: loading ? null : onPrev, icon: const Icon(Icons.chevron_left)), + IconButton( + onPressed: loading ? null : onPrev, + icon: const Icon(Icons.chevron_left)), Expanded( child: TextButton.icon( onPressed: loading ? null : onPickMonth, icon: const Icon(Icons.calendar_month), - label: Text(title, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontSize: 16)), - style: TextButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.onSurface, - ), + label: Text(title, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontSize: 16)), ), ), - IconButton(onPressed: loading ? null : onReload, icon: const Icon(Icons.refresh)), - IconButton(onPressed: loading ? null : onNext, icon: const Icon(Icons.chevron_right)), + IconButton( + onPressed: loading ? null : onReload, + icon: const Icon(Icons.refresh)), + IconButton( + onPressed: loading ? null : onNext, + icon: const Icon(Icons.chevron_right)), ]), ); } } + +/// Footer mit 2 Spalten und berechneten Werten (zentriert, responsiv) +class _MonthlySummaryFooter extends StatelessWidget { + final DateTime month; + final double fontSize; + + // Linke Spalte (Strings bereits formatiert) + final String uebertragStartText; // Startsaldo (Minuten → HH:MM) + final String sollText; // Summe Soll (HH:MM) + final String istText; // Summe Ist (HH:MM) + final String correctionText; // Correction (HH:MM, ±) + final String saldoText; // IST - SOLL (±HH:MM) + final String paidOvertimeText; // folgt später (—) + final String uebertragNextText; // letzter „Differenz gesamt“ (±HH:MM) + final String restUrlaubText; // startvacation (Zahl) + final String urlaubUebertragText; // startvacation - used + + // Rechte Spalte (Counts) + final int countGleitzeit; + final int countKrank; + final int countUrlaub; + final int countSonderurlaub; + final int countTraining; + + // Label für aktuellen Monat (z. B. "September 2025") + final String monthLabel; + + const _MonthlySummaryFooter({ + required this.month, + required this.fontSize, + required this.uebertragStartText, + required this.sollText, + required this.istText, + required this.correctionText, + required this.saldoText, + required this.paidOvertimeText, + required this.uebertragNextText, + required this.restUrlaubText, + required this.urlaubUebertragText, + required this.countGleitzeit, + required this.countKrank, + required this.countUrlaub, + required this.countSonderurlaub, + required this.countTraining, + required this.monthLabel, + super.key, + }); + + @override + Widget build(BuildContext context) { + // Monatslabels: Vormonat für Überträge/Resturlaub, aktueller Monat für Soll/Ist + final prev = DateTime(month.year, month.month - 1, 1); + final prevLabel = monthTitle(prev); + final curLabel = monthLabel; + + // Layout-Konstanten + const double leftValueWidth = 120; // Werte-Breite links (rechtsbündig) + const double rightValueWidth = 80; // Werte-Breite rechts (rechtsbündig) + const double colGap = 24; // Abstand zwischen Spalten + const double rowGap = 2; // Zeilenabstand + const double footerMaxWidth = 720; // maximale Footer-Breite + + final labelStyle = TextStyle( + fontSize: fontSize, color: Theme.of(context).colorScheme.onSurface); + final valueStyle = TextStyle( + fontSize: fontSize, + fontFeatures: const [FontFeature.tabularFigures()], + color: Theme.of(context).colorScheme.onSurface, + ); + + // Linke Spalte: Feldname rechtsbündig, Wert rechtsbündig (fixe Breite) + final leftItems = <(String label, String value)>[ + ('Übertrag $prevLabel', uebertragStartText), + ('SOLL Arbeitszeit ($curLabel)', sollText), + ('IST Arbeitszeit ($curLabel)', istText), + ('Correction', correctionText), + ('Saldo', saldoText), + ('ausbezahlte Überstunden', paidOvertimeText), + ('Übertrag nächster Monat', uebertragNextText), + ('Resturlaub $prevLabel', restUrlaubText), + ('Übertrag Urlaub', urlaubUebertragText), + ]; + + // Rechte Spalte: links der Wert (rechtsbündig), rechts der Feldname + final rightItems = <(String value, String label)>[ + ('${countGleitzeit}', 'Gleitzeit'), + ('${countKrank}', 'Krank'), + ('${countUrlaub}', 'Urlaub'), + ('${countSonderurlaub}', 'Sonderurlaub'), + ('${countTraining}', 'Training'), + ]; + + Widget leftRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: rowGap), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + // Label nimmt restliche Breite ein, rechtsbündig + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: Text(label, + style: labelStyle, + overflow: TextOverflow.fade, + softWrap: false, + maxLines: 1), + ), + ), + const SizedBox(width: 12), + // Wert mit fixer Breite, rechtsbündig + SizedBox( + width: leftValueWidth, + child: Align( + alignment: Alignment.centerRight, + child: + Text(value, style: valueStyle, textAlign: TextAlign.right), + ), + ), + ], + ), + ); + } + + Widget rightRow(String value, String label) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: rowGap), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + // Wert (rechtsbündig) mit fixer Breite + SizedBox( + width: rightValueWidth, + child: Align( + alignment: Alignment.centerRight, + child: + Text(value, style: valueStyle, textAlign: TextAlign.right), + ), + ), + const SizedBox(width: 12), + // Feldname links – nimmt restliche Breite + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Text(label, + style: labelStyle, + overflow: TextOverflow.fade, + softWrap: false, + maxLines: 1), + ), + ), + ], + ), + ); + } + + // Zentrierter Footer-Container + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: footerMaxWidth), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Linke Spalte (flexibel) + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: leftItems.map((e) => leftRow(e.$1, e.$2)).toList(), + ), + ), + SizedBox(width: colGap), + // Rechte Spalte (flexibel) + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: + rightItems.map((e) => rightRow(e.$1, e.$2)).toList(), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _Metric { + final String label; + final String value; + const _Metric(this.label, this.value); +} + +class _MetricList extends StatelessWidget { + final List<_Metric> items; + final TextStyle labelStyle; + final TextStyle valueStyle; + const _MetricList({ + required this.items, + required this.labelStyle, + required this.valueStyle, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: items + .map((m) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Expanded(child: Text(m.label, style: labelStyle)), + const SizedBox(width: 12), + Text(m.value, style: valueStyle), + ], + ), + )) + .toList(), + ); + } +} diff --git a/lib/utils/helpers.dart b/lib/utils/helpers.dart index 9b2d79c..324146d 100644 --- a/lib/utils/helpers.dart +++ b/lib/utils/helpers.dart @@ -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 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 fillMonth(DateTime monthStart, List existing) { - final map = {for (final w in existing) ymd(w.date): w}; - final nextMonth = DateTime(monthStart.year, monthStart.month + 1, 1); - final out = []; - 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 fillMonth(DateTime monthStart, List fromApi) { + final m0 = DateTime(monthStart.year, monthStart.month, 1); + final m1 = DateTime(monthStart.year, monthStart.month + 1, 1); + final byDay = {for (final d in fromApi) ymd(d.date): d}; + + final days = []; + 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 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; } diff --git a/lib/utils/holidays_at.dart b/lib/utils/holidays_at.dart index 4d7c566..53f466b 100644 --- a/lib/utils/holidays_at.dart +++ b/lib/utils/holidays_at.dart @@ -1,12 +1,25 @@ import '../utils/helpers.dart'; +/// Sehr vereinfachte österreichische Feiertage (bundesweit). +/// Key = 'YYYY-MM-DD', Value = Name. Map buildHolidayMapAT(int year) { final Map 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 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); } diff --git a/lib/utils/input_formatters.dart b/lib/utils/input_formatters.dart index a17f93a..84b4125 100644 --- a/lib/utils/input_formatters.dart +++ b/lib/utils/input_formatters.dart @@ -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)); + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + var text = newValue.text; + + // 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, ':'); + } } - if (digits.length > 4) digits = digits.substring(0, 4); - String text; - if (digits.length <= 2) { - text = digits; - } else { - final hh = digits.substring(0, 2); - final mm = digits.substring(2); - text = '$hh:$mm'; + // 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; + } } - final offset = text.length; - return TextEditingValue(text: text, selection: TextSelection.collapsed(offset: offset)); + // Cursor ans Ende + return TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); } } diff --git a/lib/utils/work_time.dart b/lib/utils/work_time.dart new file mode 100644 index 0000000..4e33ae0 --- /dev/null +++ b/lib/utils/work_time.dart @@ -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 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; +}