54736fa680
Herwig Birke 5 months ago
parent f212f9feb3
commit 1e90222d36

@ -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<List<WorkDay>> 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<String, dynamic>;
final List items = map['bookings'] ?? [];
final out = <WorkDay>[];
if (map['error'] == true) {
throw Exception('booking/getList error: ${map['errmsg']}');
}
final list = (map['bookings'] as List?) ?? const [];
final items = <WorkDay>[];
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<String, dynamic>;
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 = <String?>[
row['come1'] as String?, row['come2'] as String?,
row['come3'] as String?, row['come4'] as String?, row['come5'] as String?,
];
final ends = <String?>[
row['leave1'] as String?, row['leave2'] as String?,
row['leave3'] as String?, row['leave4'] as String?, row['leave5'] as String?,
];
final intervals = <WorkInterval>[];
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<MonthStart> 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<String, dynamic>;
if (map['error'] == true) {
throw Exception('monthlybooking/getList error: ${map['errmsg']}');
}
return MonthStart.fromJson(map);
}
}

@ -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<int,int> 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<Map<int, int>> getDailyMinutes() async {
final uri = Uri.https(host, path, {
'module': 'dailyworking',
'function': 'getList',
});
final res = await client
.get(uri, headers: {'Accept': 'application/json'})
.timeout(const Duration(seconds: 15));
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<String, dynamic>;
final list = (map['entries'] as List?) ?? const [];
if (list.isEmpty) return _defaultPlan();
final row = (list.first as Map).cast<String, dynamic>();
int parseHMS(String? s) {
if (s == null) return 0;
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<String, dynamic>;
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<int, int> _defaultPlan() => {
DateTime.monday: 8 * 60,
DateTime.tuesday: 8 * 60,
DateTime.wednesday: 8 * 60,
DateTime.thursday: 8 * 60,
DateTime.friday: 8 * 60,
DateTime.saturday: 0,
DateTime.sunday: 0,
};
}

@ -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,
);
}
}

@ -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<String, dynamic> json) {
final bookings = (json['bookings'] as List?) ?? const [];
final row = bookings.isNotEmpty ? (bookings.first as Map<String, dynamic>) : const <String, dynamic>{};
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']),
);
}
}

@ -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<WorkInterval> intervals; // bis zu 5
final String? code; // GZ, K, U, SU, T oder null
final int targetMinutes; // Soll
final List<WorkInterval> 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);
}

@ -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;
}
}

@ -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 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<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)
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<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(),
);
}
}

@ -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;
}

@ -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);
}

@ -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),
);
}
}

@ -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;
}
Loading…
Cancel
Save