initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user