initial commit

This commit is contained in:
2025-08-27 13:42:07 +02:00
commit 36ae378cb9
136 changed files with 5696 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
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 '../utils/helpers.dart';
class BookingApi {
final String host;
final String path;
final http.Client client;
const BookingApi({
required this.client,
this.host = 'api.windesign.at',
this.path = '/workinghours.php',
});
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)');
}
if (res.statusCode != 200) {
throw Exception('HTTP ${res.statusCode}: ${res.body}');
}
final map = jsonDecode(res.body) as Map<String, dynamic>;
final List items = map['bookings'] ?? [];
final out = <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;
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));
}
addPair('come1', 'leave1');
addPair('come2', 'leave2');
addPair('come3', 'leave3');
addPair('come4', 'leave4');
addPair('come5', 'leave5');
final absence =
(row['code']?.toString().trim().isEmpty ?? true) ? null : row['code'].toString();
out.add(WorkDay(
date: d,
intervals: intervals,
targetMinutes: target,
absenceType: absence,
));
}
out.sort((a, b) => a.date.compareTo(b.date));
return out;
}
}
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'screens/home.dart';
void main() => runApp(const ZeitschreibungApp());
class ZeitschreibungApp extends StatelessWidget {
const ZeitschreibungApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Zeitschreibung',
theme: ThemeData(
colorSchemeSeed: const Color(0xFF3B82F6),
useMaterial3: true,
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
isDense: true,
),
),
home: const HomeScreen(),
debugShowCheckedModeBanner: false,
);
}
}
+19
View File
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'work_interval.dart';
class WorkDay {
final DateTime date;
final List<WorkInterval> intervals; // bis zu 5
final String? absenceType; // Urlaub, Krank, …
final int targetMinutes; // Soll
const WorkDay({
required this.date,
required this.intervals,
required this.targetMinutes,
this.absenceType,
});
int get workedMinutes => intervals.fold(0, (sum, i) => sum + i.minutes);
int get diffMinutes => workedMinutes - targetMinutes;
}
+17
View File
@@ -0,0 +1,17 @@
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;
}
}
+32
View File
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'monthly_view.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: const Text('Zeitschreibung'),
bottom: const TabBar(
tabs: [
Tab(text: 'Monat'),
Tab(text: 'Woche'),
Tab(text: 'Projekte'),
],
),
),
body: const TabBarView(
children: [
MonthlyView(),
Center(child: Text('Wochensicht (in Arbeit)')),
Center(child: Text('Projekte (in Arbeit)')),
],
),
),
);
}
}
+537
View File
@@ -0,0 +1,537 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../api/booking_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<MonthlyView> createState() => _MonthlyViewState();
}
class _MonthlyViewState extends State<MonthlyView> {
int _uiVersion = 0;
final Set<String> _invalidCells = <String>{};
final Map<String, FocusNode> _focusNodes = {};
final Map<String, TextEditingController> _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 _api;
late DateTime _monthStart;
List<WorkDay> _days = const [];
bool _loading = false;
String? _error;
Map<String, String> _holidays = const {};
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();
_api = BookingApi(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<void> _loadMonth(DateTime m) async {
setState(() {
_loading = true;
_error = null;
_days = const [];
});
try {
final apiDays = await _api.getBookingList(m);
final days = fillMonth(m, apiDays);
setState(() {
_monthStart = DateTime(m.year, m.month, 1);
_holidays = buildHolidayMapAT(_monthStart.year);
_days = days;
_loading = false;
_uiVersion++;
});
_syncControllersWithDays();
} catch (e) {
setState(() {
_error = e.toString();
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
final columns = _buildColumns();
final theme = Theme.of(context);
final holidayBg = theme.colorScheme.secondaryContainer.withOpacity(0.45);
final weekendBg = Colors.grey.withOpacity(0.30);
// Effektive Tage: live aus Editfeldern gelesen
final effectiveDays = List<WorkDay>.generate(_days.length, (i) => _effectiveDay(i, _days[i]));
// kumulative Differenz
final cumulative = <int>[];
int sum = 0;
for (final d in effectiveDays) {
sum += d.diffMinutes;
cumulative.add(sum);
}
final rows = List<DataRow>.generate(_days.length, (i) {
final day = effectiveDays[i];
final run = cumulative[i];
final isHoliday = _holidays.containsKey(ymd(day.date));
final isWeekend =
day.date.weekday == DateTime.saturday || day.date.weekday == DateTime.sunday;
return DataRow(
// MaterialStateProperty -> WidgetStateProperty (Deprecation)
color: WidgetStateProperty.resolveWith<Color?>((_) {
if (isHoliday) return holidayBg;
if (isWeekend) return weekendBg;
return null;
}),
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: <Widget>[
const Text('Fehler beim Laden:'),
SelectableText(_error ?? ''),
],
),
actions: <Widget>[
TextButton(
onPressed: () => _loadMonth(_monthStart),
child: const Text('Erneut versuchen'),
),
],
),
),
Expanded(
child: Scrollbar(
controller: _hCtrl,
notificationPredicate: (n) => n.metrics.axis == Axis.horizontal,
thumbVisibility: true,
child: SingleChildScrollView(
controller: _hCtrl,
padding: const EdgeInsets.only(bottom: 24),
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 1300),
child: Scrollbar(
controller: _vCtrl,
notificationPredicate: (n) => n.metrics.axis == Axis.vertical,
thumbVisibility: true,
child: SingleChildScrollView(
controller: _vCtrl,
child: DataTableTheme(
data: const DataTableThemeData(
headingRowHeight: 42,
dataRowMinHeight: 44,
dataRowMaxHeight: 54,
columnSpacing: 20,
headingTextStyle: TextStyle(fontWeight: FontWeight.w700),
),
child: DataTable(
showCheckboxColumn: false,
columns: columns,
rows: rows,
),
),
),
),
),
),
),
),
]);
}
List<DataColumn> _buildColumns() {
const center = TextAlign.center;
const right = TextAlign.right;
const left = TextAlign.left;
DataColumn c(String label, {TextAlign align = center, double? width}) => DataColumn(
label: SizedBox(
width: width ?? (align == center ? null : 120),
child: Align(
alignment: align == right
? Alignment.centerRight
: align == left
? Alignment.centerLeft
: Alignment.center,
child: Text(label, textAlign: align),
),
),
);
return [
// 0
c('Datum', align: right),
// 1
c('Feiertag', align: left, width: 160),
// 2..11 Zeitspalten
c('Start 1'), c('Ende 1'),
c('Start 2'), c('Ende 2'),
c('Start 3'), c('Ende 3'),
c('Start 4'), c('Ende 4'),
c('Start 5'), c('Ende 5'),
// 12..16 Kennzahlen
c('Abwesenheitstype', align: left, width: 160),
c('Soll', align: right),
c('Ist', align: right),
c('Differenz', align: right),
c('Differenz gesamt', align: right),
// 17
c('Datum', align: left),
];
}
List<DataCell> _buildEditableCells(int dayIndex, WorkDay day, int runningDiff) {
final leftLabel = leftDayLabel(day.date);
final rightLabel = rightDayLabel(day.date);
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 = <DataCell>[];
// 0: Datum Wochentag (rechts)
cells.add(DataCell(Align(alignment: Alignment.centerRight, child: Text(leftLabel))));
// 1: Feiertag-Name
cells.add(DataCell(Text(hName)));
// 2..11: editierbare 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(
_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(() {}); // live neu berechnen, Fokus bleibt erhalten
}
},
onSubmitted: (text) {
if (text.isEmpty || _isValidHHMM(text)) {
_commitTime(dayIndex, slot, true, text);
}
},
),
onTap: () => fnS.requestFocus(),
));
// Ende
final keyE = 't_${dayIndex}_${slot}_e';
final fnE = _nodeFor(keyE);
final ctrlE = _controllerFor(keyE, slotText(slot, false));
cells.add(DataCell(
_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(() => _uiVersion++);
}
},
onSubmitted: (text) {
if (text.isEmpty || _isValidHHMM(text)) {
_commitTime(dayIndex, slot, false, text);
}
},
),
onTap: () => fnE.requestFocus(),
));
}
// 12..16: Kennzahlen
final soll = minutesToHHMM(day.targetMinutes);
final ist = minutesToHHMM(day.workedMinutes);
final diff = minutesToSignedHHMM(day.diffMinutes);
final diffSum = minutesToSignedHHMM(runningDiff);
cells.add(DataCell(Text(day.absenceType ?? '')));
cells.add(DataCell(Align(alignment: Alignment.centerRight, child: mono(soll))));
cells.add(DataCell(Align(alignment: Alignment.centerRight, child: mono(ist))));
cells.add(DataCell(Align(alignment: Alignment.centerRight, child: mono(diff))));
cells.add(DataCell(Align(alignment: Alignment.centerRight, child: mono(diffSum))));
// 17: Wochentag Datum (links)
cells.add(DataCell(Align(alignment: Alignment.centerLeft, child: Text(rightLabel))));
assert(cells.length == 18, 'Row has ${cells.length} cells but expected 18.');
return cells;
}
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<TimeOfDay?>.filled(5, null);
final ends = List<TimeOfDay?>.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 = <WorkInterval>[];
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<WorkDay>.from(_days);
newDays[dayIndex] = WorkDay(
date: d.date,
intervals: newIntervals,
targetMinutes: d.targetMinutes,
absenceType: d.absenceType,
);
_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 = <WorkInterval>[];
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));
}
}
return WorkDay(
date: base.date,
intervals: intervals,
targetMinutes: base.targetMinutes,
absenceType: base.absenceType,
);
}
// ---- fehlende Methode: gemeinsames Zeitfeld-Widget ----
Widget _timeField({
required Key key,
required TextEditingController controller,
required bool invalid,
required ValueChanged<String> onChanged,
required ValueChanged<String> onSubmitted,
FocusNode? focusNode,
}) {
final theme = Theme.of(context);
final errorFill = theme.colorScheme.errorContainer.withOpacity(0.25);
return SizedBox(
width: 84,
child: TextFormField(
key: key,
controller: controller,
focusNode: focusNode,
textAlign: TextAlign.center,
style: const TextStyle(fontFeatures: [FontFeature.tabularFigures()]),
keyboardType: TextInputType.datetime,
inputFormatters: const [HHmmInputFormatter()],
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
// dezente Hinterlegung nur wenn ungültig:
filled: invalid,
fillColor: invalid ? errorFill : null,
// kein hintText mehr
),
onChanged: onChanged,
onFieldSubmitted: onSubmitted,
),
);
}
}
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),
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)),
]),
);
}
}
+104
View File
@@ -0,0 +1,104 @@
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}';
}
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 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')}';
int minutesBetween(TimeOfDay a, TimeOfDay b) {
final aMin = a.hour * 60 + a.minute;
final bMin = b.hour * 60 + b.minute;
final d = bMin - aMin;
if (d <= 0) return 0;
if (d >= 24 * 60) return 24 * 60;
return d;
}
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(':');
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);
}
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 (h == null || m == null) return null;
if (h < 0 || h > 23 || m < 0 || m > 59) return null;
return TimeOfDay(hour: h, minute: m);
}
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);
} 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));
}
}
return out;
}
+42
View File
@@ -0,0 +1,42 @@
import '../utils/helpers.dart';
Map<String, String> buildHolidayMapAT(int year) {
final Map<String, String> m = {};
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(DateTime(year, 5, 1), 'Staatsfeiertag');
add(DateTime(year, 8, 15), 'Mariä Himmelfahrt');
add(DateTime(year, 10, 26), 'Nationalfeiertag');
add(DateTime(year, 11, 1), 'Allerheiligen');
add(DateTime(year, 12, 8), 'Mariä Empfängnis');
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;
}
DateTime _easterSunday(int year) {
final a = year % 19;
final b = year ~/ 100;
final c = year % 100;
final d = b ~/ 4;
final e = b % 4;
final f = (b + 8) ~/ 25;
final g = (b - f + 1) ~/ 3;
final h = (19 * a + b - d - g + 15) % 30;
final i = c ~/ 4;
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 day = ((h + l - 7 * m + 114) % 31) + 1;
return DateTime(year, month, day);
}
+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter/services.dart';
class HHmmInputFormatter extends TextInputFormatter {
const HHmmInputFormatter();
@override
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
String digits = newValue.text.replaceAll(RegExp(r'[^0-9]'), '');
if (digits.isEmpty) {
return const TextEditingValue(text: '', selection: TextSelection.collapsed(offset: 0));
}
if (digits.length > 4) digits = digits.substring(0, 4);
String text;
if (digits.length <= 2) {
text = digits;
} else {
final hh = digits.substring(0, 2);
final mm = digits.substring(2);
text = '$hh:$mm'; // ← Interpolation statt + ':' +
}
final offset = text.length;
return TextEditingValue(text: text, selection: TextSelection.collapsed(offset: offset));
}
}