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
+419 -90
View File
@@ -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<MonthlyView> {
// 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<MonthlyView> {
static const double _wNumber = 76;
static const double _fontSize = 12;
// Codes, die Zeiten sperren/ leeren
// Codes, die Zeiten leeren/sperren
static const Set<String> _lockCodes = {'G', 'K', 'U', 'SU'};
// Edit/Fokus/Controller je Feld
final Set<String> _invalidCells = <String>{};
final Map<String, FocusNode> _focusNodes = {};
final Map<String, TextEditingController> _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<MonthlyView> {
bool _loading = false;
String? _error;
Map<String, String> _holidays = const {};
Map<int, int> _dailyPlan = {
Map<int, int> _dailyPlan = const {
DateTime.monday: 8 * 60,
DateTime.tuesday: 8 * 60,
DateTime.wednesday: 8 * 60,
@@ -55,10 +58,14 @@ class _MonthlyViewState extends State<MonthlyView> {
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<MonthlyView> {
_hBodyCtrl = ScrollController();
_vCtrl = ScrollController();
// Body scrollt -> Header folgt
_hBodyCtrl.addListener(() {
if (_syncingH) return;
_syncingH = true;
@@ -112,27 +118,35 @@ class _MonthlyViewState extends State<MonthlyView> {
final results = await Future.wait([
_bookingApi.getBookingList(m),
_dailyApi.getDailyMinutes(),
_bookingApi.getMonthStart(m),
]);
final apiDays = results[0] as List<WorkDay>;
final plan = results[1] as Map<int, int>;
final mStart = results[2] as MonthStart;
final holidayMap = buildHolidayMapAT(m.year); // << neu
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();
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<MonthlyView> {
// 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<MonthlyView> {
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<MonthlyView> {
final holidayBg = theme.colorScheme.secondaryContainer.withOpacity(0.45);
final weekendBg = Colors.grey.withOpacity(0.30);
final effectiveDays = List<WorkDay>.generate(_days.length, (i) => _effectiveDay(i, _days[i]));
// Live-„Effective“-Tage (inkl. Eingabetexte + Sperrlogik)
final effectiveDays =
List<WorkDay>.generate(_days.length, (i) => _effectiveDay(i, _days[i]));
// diffs & kumuliert (mit Override)
// Tagesdifferenzen & kumuliert (Start mit Monatssaldo aus API)
final diffs = <int>[];
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>[];
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 = <String, int>{
'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<DataRow>.generate(_days.length, (i) {
final day = effectiveDays[i];
final run = cumulative[i];
@@ -216,8 +261,10 @@ class _MonthlyViewState extends State<MonthlyView> {
_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<MonthlyView> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
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: <Widget>[
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<MonthlyView> {
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<MonthlyView> {
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<MonthlyView> {
),
),
),
// 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<DataColumn> _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<MonthlyView> {
];
}
List<DataCell> _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<DataCell> _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<MonthlyView> {
final cells = <DataCell>[];
// 0: Datum (rechts)
// 0: Datum (rechtsbündig)
cells.add(DataCell(SizedBox(
width: _wDate,
child: Align(
@@ -397,7 +470,7 @@ class _MonthlyViewState extends State<MonthlyView> {
),
)));
// 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<MonthlyView> {
)));
}
// 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<MonthlyView> {
),
)));
// 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<MonthlyView> {
)));
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<MonthlyView> {
),
)));
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<MonthlyView> {
final label = v == null ? '' : codeLabel(v);
return DropdownMenuItem<String?>(
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<WorkDay>.from(_days);
newDays[dayIndex] = WorkDay(
@@ -576,14 +655,11 @@ class _MonthlyViewState extends State<MonthlyView> {
_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<MonthlyView> {
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<TimeOfDay?>.filled(5, null);
@@ -662,19 +737,42 @@ class _MonthlyViewState extends State<MonthlyView> {
: '';
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)
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);
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)) {
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<MonthlyView> {
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<MonthlyView> {
}
}
/// 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(),
);
}
}