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'; import '../utils/holidays_at.dart'; import '../utils/input_formatters.dart'; class MonthlyView extends StatefulWidget { const MonthlyView({super.key}); @override State createState() => _MonthlyViewState(); } class _MonthlyViewState extends State { // Kompaktere Layout-Parameter static const double _wDate = 96; static const double _wHoliday = 120; static const double _wTime = 60; static const double _wCode = 70; static const double _wNumber = 76; static const double _fontSize = 12; // Edit/Fokus/Controller je Feld final Set _invalidCells = {}; final Map _focusNodes = {}; final Map _controllers = {}; FocusNode _nodeFor(String key) => _focusNodes.putIfAbsent(key, () => FocusNode()); TextEditingController _controllerFor(String key, String initial) => _controllers.putIfAbsent(key, () => TextEditingController(text: initial)); late final http.Client _client; 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; @override void initState() { super.initState(); final now = DateTime.now(); _monthStart = DateTime(now.year, now.month, 1); _hCtrl = ScrollController(); _vCtrl = ScrollController(); _client = http.Client(); _bookingApi = BookingApi(client: _client); _dailyApi = DailyWorkingApi(client: _client); _loadMonth(_monthStart); } @override void dispose() { for (final n in _focusNodes.values) { n.dispose(); } for (final c in _controllers.values) { c.dispose(); } _hCtrl.dispose(); _vCtrl.dispose(); _client.close(); super.dispose(); } Future _loadMonth(DateTime m) async { setState(() { _loading = true; _error = null; _days = const []; }); try { 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 = withTargets; _dailyPlan = plan; _loading = false; }); _syncControllersWithDays(); } catch (e) { setState(() { _error = e.toString(); _loading = false; }); } } double get _tableMinWidth => _wDate + _wHoliday + (10 * _wTime) + _wCode + (4 * _wNumber) + _wDate; // 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; } return d.workedMinutes; } Color? _rowColorFor(WorkDay d, {required Color? holidayBg, required Color? weekendBg}) { switch (d.code) { case 'GZ': return const Color(0xFFBFBFFF); // 0xBFBFFF mit vollem Alpha case 'U': return const Color(0xFF7F7FFF); // 0x7F7FFF mit vollem Alpha case 'SU': return const Color(0xFF7F7FFF); case 'K': return Colors.yellow; case 'T': return Colors.red; } 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 headerColumns = _buildColumns(); final bodyColumns = _buildColumns(); final theme = Theme.of(context); 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])); // diffs & kumuliert (mit Override) final diffs = []; for (final d in effectiveDays) { final worked = _workedFor(d); diffs.add(worked - d.targetMinutes); } final cumulative = []; int sum = 0; 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]; return DataRow( color: WidgetStateProperty.resolveWith( (_) => _rowColorFor(day, holidayBg: holidayBg, weekendBg: weekendBg), ), cells: _buildEditableCells(i, day, run), ); }); return Column(children: [ _MonthHeader( month: _monthStart, loading: _loading, 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, initialDate: _monthStart, firstDate: DateTime(2000, 1, 1), lastDate: DateTime(2100, 12, 31), helpText: 'Monat wählen', initialEntryMode: DatePickerEntryMode.calendarOnly, ); if (picked != null) { _loadMonth(DateTime(picked.year, picked.month, 1)); } }, onReload: () => _loadMonth(_monthStart), ), if (_loading) const LinearProgressIndicator(minHeight: 2), if (_error != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: MaterialBanner( content: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ 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)), ), ], ), ), // FIXIERTE KOPFZEILE 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: DataTableThemeData( headingRowHeight: 30, columnSpacing: 10, headingTextStyle: const TextStyle(fontWeight: FontWeight.w700).copyWith( fontSize: _fontSize, ), ), child: DataTable( showCheckboxColumn: false, columns: headerColumns, rows: const [], ), ), ), ), ), // BODY Expanded( child: Scrollbar( controller: _hCtrl, notificationPredicate: (n) => n.metrics.axis == Axis.horizontal, thumbVisibility: true, child: SingleChildScrollView( controller: _hCtrl, padding: const EdgeInsets.only(bottom: 16), scrollDirection: Axis.horizontal, child: ConstrainedBox( constraints: BoxConstraints(minWidth: _tableMinWidth), child: Scrollbar( controller: _vCtrl, notificationPredicate: (n) => n.metrics.axis == Axis.vertical, thumbVisibility: true, child: SingleChildScrollView( controller: _vCtrl, child: DataTableTheme( data: const DataTableThemeData( headingRowHeight: 0, dataRowMinHeight: 30, dataRowMaxHeight: 34, columnSpacing: 10, ), child: DataTable( showCheckboxColumn: false, columns: bodyColumns, rows: rows, ), ), ), ), ), ), ), ), ]); } List _buildColumns() { DataColumn c(String label, {Alignment align = Alignment.center, double? width}) => DataColumn( label: SizedBox( width: width, child: Align( alignment: align, child: Text( label, textAlign: _toTextAlign(align), style: const TextStyle(fontSize: _fontSize), ), ), ), ); return [ c('Datum', align: Alignment.centerRight, width: _wDate), c('Feiertag', align: Alignment.centerLeft, width: _wHoliday), 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), c('Code', align: Alignment.center, width: _wCode), 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), c('Datum', align: Alignment.centerLeft, width: _wDate), ]; } 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 final hName = _holidays[ymd(day.date)] ?? ''; String slotText(int slot, bool isStart) { if (slot < day.intervals.length) { final p = day.intervals[slot]; return fmtTimeOfDay(isStart ? p.start : p.end); } return ''; } bool isInvalid(int slot, bool isStart) => _invalidCells.contains(_cellKey(dayIndex, slot, isStart)); final cells = []; // 0: Datum (rechts) cells.add(DataCell(SizedBox( width: _wDate, child: Align( alignment: Alignment.centerRight, child: Text(leftLabel, style: const TextStyle(fontSize: _fontSize)), ), ))); // 1: Feiertag cells.add(DataCell(SizedBox( width: _wHoliday, child: Align( alignment: Alignment.centerLeft, child: Text(hName, style: const TextStyle(fontSize: _fontSize)), ), ))); // 2..11: Zeiten 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(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 { setState(() {}); } }, onSubmitted: (text) { if (text.isEmpty || _isValidHHMM(text)) { _commitTime(dayIndex, slot, true, text); } }, ), ), ))); // Ende final keyE = 't_${dayIndex}_${slot}_e'; final fnE = _nodeFor(keyE); final ctrlE = _controllerFor(keyE, slotText(slot, false)); 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 { setState(() {}); } }, onSubmitted: (text) { if (text.isEmpty || _isValidHHMM(text)) { _commitTime(dayIndex, slot, false, text); } }, ), ), ))); } // 12: Code cells.add(DataCell(SizedBox( width: _wCode, child: Align(alignment: Alignment.center, child: _codeDropdown(dayIndex, day)), ))); // 13..16: Kennzahlen (mit Override) final worked = _workedFor(day); final soll = minutesToHHMM(day.targetMinutes); final ist = minutesToHHMM(worked); final diff = minutesToSignedHHMM(worked - day.targetMinutes); final diffSum = minutesToSignedHHMM(runningDiff); cells.add(DataCell(SizedBox( width: _wNumber, child: Align(alignment: Alignment.centerRight, child: _monoSmall(soll)), ))); cells.add(DataCell(SizedBox( width: _wNumber, child: Align(alignment: Alignment.centerRight, child: _monoSmall(ist)), ))); cells.add(DataCell(SizedBox( width: _wNumber, child: Align(alignment: Alignment.centerRight, child: _monoSmall(diff)), ))); cells.add(DataCell(SizedBox( width: _wNumber, child: Align(alignment: Alignment.centerRight, child: _monoSmall(diffSum)), ))); // 17: Datum (links) cells.add(DataCell(SizedBox( width: _wDate, child: Align( alignment: Alignment.centerLeft, child: Text(rightLabel, style: const TextStyle(fontSize: _fontSize)), ), ))); assert(cells.length == 18, 'Row has ${cells.length} cells but expected 18.'); return cells; } Widget _codeDropdown(int dayIndex, WorkDay day) { final value = day.code; final values = [null, ...kAbsenceCodes]; return DropdownButton( isExpanded: true, value: value, items: values.map((v) { final label = v == null ? '—' : codeLabel(v); return DropdownMenuItem( value: v, 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))); }).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)); final m = int.tryParse(s.substring(3, 5)); if (h == null || m == null) return false; return h >= 0 && h <= 23 && m >= 0 && m <= 59; } String _cellKey(int dayIndex, int slot, bool isStart) => 'd${dayIndex}_s${slot}_${isStart ? 'b' : 'e'}'; void _commitTime(int dayIndex, int slot, bool isStart, String text) { final t = text.trim().isEmpty ? null : parseTextHHMM(text); final d = _days[dayIndex]; final starts = List.filled(5, null); final ends = List.filled(5, null); for (int i = 0; i < d.intervals.length && i < 5; i++) { starts[i] = d.intervals[i].start; ends[i] = d.intervals[i].end; } if (isStart) { starts[slot] = t; } else { ends[slot] = t; } final newIntervals = []; for (int i = 0; i < 5; i++) { final s = starts[i]; final e = ends[i]; if (s != null && e != null) newIntervals.add(WorkInterval(s, e)); } setState(() { final newDays = List.from(_days); newDays[dayIndex] = WorkDay( date: d.date, intervals: newIntervals, targetMinutes: _dailyPlan[d.date.weekday] ?? d.targetMinutes, code: d.code, ); _days = newDays; }); } void _syncControllersWithDays() { for (int i = 0; i < _days.length; i++) { for (int slot = 0; slot < 5; slot++) { final sKey = 't_${i}_${slot}_s'; final eKey = 't_${i}_${slot}_e'; final sText = (slot < _days[i].intervals.length) ? fmtTimeOfDay(_days[i].intervals[slot].start) : ''; final eText = (slot < _days[i].intervals.length) ? fmtTimeOfDay(_days[i].intervals[slot].end) : ''; 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; } } } WorkDay _effectiveDay(int row, WorkDay base) { TimeOfDay? baseSlot(WorkDay d, int slot, bool isStart) { if (slot >= d.intervals.length) return null; final iv = d.intervals[slot]; return isStart ? iv.start : iv.end; } final intervals = []; for (int slot = 0; slot < 5; slot++) { final sKey = 't_${row}_${slot}_s'; final eKey = 't_${row}_${slot}_e'; 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); 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, code: base.code, ); } // kompakte, rahmenlose Eingabefelder 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: _wTime, child: TextFormField( key: key, controller: controller, focusNode: focusNode, textAlign: TextAlign.center, style: const TextStyle( fontFeatures: [FontFeature.tabularFigures()], fontSize: _fontSize, ), keyboardType: TextInputType.datetime, inputFormatters: const [HHmmInputFormatter()], decoration: const InputDecoration( isDense: true, contentPadding: EdgeInsets.symmetric(horizontal: 4, vertical: 2), border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, ).copyWith( filled: invalid, fillColor: invalid ? errorFill : null, ), onChanged: onChanged, onFieldSubmitted: onSubmitted, ), ); } Widget _monoSmall(String s) => Text( s, style: const TextStyle( fontSize: _fontSize, fontFeatures: [FontFeature.tabularFigures()], ), ); 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 { final DateTime month; final VoidCallback onPrev; final VoidCallback onNext; final VoidCallback onPickMonth; final VoidCallback onReload; final bool loading; const _MonthHeader({ required this.month, required this.onPrev, required this.onNext, required this.onPickMonth, required this.onReload, this.loading = false, super.key, }); @override Widget build(BuildContext context) { final title = monthTitle(month); return Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), child: Row(children: [ 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, ), ), ), IconButton(onPressed: loading ? null : onReload, icon: const Icon(Icons.refresh)), IconButton(onPressed: loading ? null : onNext, icon: const Icon(Icons.chevron_right)), ]), ); } }