import 'dart:developer'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:timetracker/src/rust/api.dart'; import 'package:timetracker/time_tracking_service.dart'; int dateTimeToUnixSeconds(DateTime dt) => dt.toUtc().millisecondsSinceEpoch ~/ 1000; DateTime unixSecondsToDateTime(int ts) => DateTime.fromMillisecondsSinceEpoch(ts * 1000, isUtc: true).toLocal(); extension TimeEntryFormatting on TimeEntry { DateTime get startDateTime => DateTime.fromMillisecondsSinceEpoch( startTime.toInt() * 1000, isUtc: true, ).toLocal(); DateTime? get endDateTime => endTime == null ? null : DateTime.fromMillisecondsSinceEpoch( endTime!.toInt() * 1000, isUtc: true, ).toLocal(); String get durationFormatted { final durationInSeconds = durationSecs?.toInt(); if (durationInSeconds == null || durationInSeconds < 0) return '--:--:--'; final duration = Duration(seconds: durationInSeconds); String twoDigits(int n) => n.toString().padLeft(2, "0"); final hours = twoDigits(duration.inHours); final minutes = twoDigits(duration.inMinutes.remainder(60)); final seconds = twoDigits(duration.inSeconds.remainder(60)); return "$hours:$minutes:$seconds"; } } extension ReportDataFormatting on ReportData { String get totalDurationFormatted { final durationInSeconds = totalDurationSecs.toInt(); if (durationInSeconds < 0) return '--:--:--'; final duration = Duration(seconds: durationInSeconds); String twoDigits(int n) => n.toString().padLeft(2, "0"); final hours = twoDigits(duration.inHours); final minutes = twoDigits(duration.inMinutes.remainder(60)); final seconds = twoDigits(duration.inSeconds.remainder(60)); return "$hours:$minutes:$seconds"; } } enum ReportPeriod { day, week, month, year, custom } class ReportScreen extends StatefulWidget { const ReportScreen({super.key}); @override State createState() => _ReportScreenState(); } class _ReportScreenState extends State { final DateTime _selectedDate = DateTime.now(); ReportPeriod _selectedPeriod = ReportPeriod.day; DateTime _customStartDate = DateTime.now(); DateTime _customEndDate = DateTime.now(); Tag? _selectedTag; ReportData? _reportData; bool _isLoadingReport = false; TimeEntry? _editingEntry; String _formatDuration(Duration duration) { String twoDigits(int n) => n.toString().padLeft(2, "0"); if (duration.isNegative) return "--:--:--"; final hours = twoDigits(duration.inHours); final minutes = twoDigits(duration.inMinutes.remainder(60)); final seconds = twoDigits(duration.inSeconds.remainder(60)); return "$hours:$minutes:$seconds"; } @override void initState() { super.initState(); final now = DateTime.now(); _customStartDate = DateTime(now.year, now.month, now.day); _customEndDate = DateTime(now.year, now.month, now.day); _generateReport(); } Map> _aggregateDataForBarChart( List entries, ) { final Map> dailyTagDurations = {}; for (final entry in entries) { if (entry.durationSecs == null || entry.durationSecs!.toInt() <= 0) { continue; } final entryDay = DateTime( entry.startDateTime.year, entry.startDateTime.month, entry.startDateTime.day, ); final tagName = entry.tagName ?? 'Ohne Tag'; final duration = Duration(seconds: entry.durationSecs!.toInt()); final dailyMap = dailyTagDurations.putIfAbsent(entryDay, () => {}); final currentDuration = dailyMap.putIfAbsent( tagName, () => Duration.zero, ); dailyMap[tagName] = currentDuration + duration; } return dailyTagDurations; } Widget _buildBarChart( ReportData reportData, DateTime startDate, DateTime endDate, ) { final aggregatedData = _aggregateDataForBarChart(reportData.entries); if (aggregatedData.isEmpty) { return Center( child: PlatformText("Keine Daten für Balkendiagramm vorhanden."), ); } final uniqueTags = {}; for (var dailyMap in aggregatedData.values) { uniqueTags.addAll(dailyMap.keys); } final sortedTags = uniqueTags.toList()..sort(); final List predefinedColors = [ Colors.blue, Colors.red, Colors.green, Colors.orange, Colors.purple, Colors.teal, Colors.pink, Colors.amber, Colors.indigo, Colors.cyan, ]; final tagColors = {}; for (int i = 0; i < sortedTags.length; i++) { tagColors[sortedTags[i]] = predefinedColors[i % predefinedColors.length]; } final sortedDays = aggregatedData.keys.toList()..sort(); double maxY = 0; for (var day in sortedDays) { double dailyTotalHours = 0; for (var duration in aggregatedData[day]!.values) { dailyTotalHours += duration.inMinutes / 60.0; } if (dailyTotalHours > maxY) { maxY = dailyTotalHours; } } maxY = (maxY * 1.1).ceilToDouble(); if (maxY < 1) maxY = 1; final List barGroups = []; final double barWidth = 12; final double spaceBetweenBars = 2; final double groupWidth = (barWidth + spaceBetweenBars) * sortedTags.length; for (int dayIndex = 0; dayIndex < sortedDays.length; dayIndex++) { final day = sortedDays[dayIndex]; final dailyMap = aggregatedData[day]!; final List barRods = []; for (int tagIndex = 0; tagIndex < sortedTags.length; tagIndex++) { final tagName = sortedTags[tagIndex]; final duration = dailyMap[tagName] ?? Duration.zero; final hours = duration.inMinutes / 60.0; barRods.add( BarChartRodData( toY: hours, color: tagColors[tagName], width: barWidth, borderRadius: BorderRadius.zero, ), ); } barGroups.add( BarChartGroupData( x: dayIndex, barsSpace: spaceBetweenBars, barRods: barRods, ), ); } Widget bottomTitleWidgets(double value, TitleMeta meta) { final index = value.toInt(); final showEvery = (sortedDays.length / 7).ceil(); if (index < 0 || index >= sortedDays.length || index % showEvery != 0) { return Container(); } final day = sortedDays[index]; String text; if (_selectedPeriod == ReportPeriod.day || _selectedPeriod == ReportPeriod.week) { text = DateFormat('E', 'de_DE').format(day); } else { text = DateFormat('dd.MM').format(day); } return SideTitleWidget( space: 4, meta: TitleMeta( min: 0.0, max: 10.0, parentAxisSize: 10, axisPosition: 0.0, appliedInterval: 10, sideTitles: SideTitles(), formattedValue: '', axisSide: AxisSide.left, rotationQuarterTurns: 0, ), child: Text(text, style: const TextStyle(fontSize: 10)), ); } Widget leftTitleWidgets(double value, TitleMeta meta) { if (value == 0 || value == meta.max) { return Container(); } if (value % 1 == 0) { return SideTitleWidget( space: 4, meta: TitleMeta( min: 0.0, max: 10.0, parentAxisSize: 10, axisPosition: 0.0, appliedInterval: 10, sideTitles: SideTitles(), formattedValue: '', axisSide: AxisSide.left, rotationQuarterTurns: 0, ), child: Text( '${value.toInt()}h', style: const TextStyle(fontSize: 10), ), ); } return Container(); } return Padding( padding: const EdgeInsets.all(16.0), child: BarChart( BarChartData( maxY: maxY, barGroups: barGroups, groupsSpace: groupWidth + 10, titlesData: FlTitlesData( show: true, rightTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), topTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, getTitlesWidget: bottomTitleWidgets, reservedSize: 20, ), ), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 30, getTitlesWidget: leftTitleWidgets, ), ), ), gridData: FlGridData( show: true, drawVerticalLine: false, getDrawingHorizontalLine: (value) { return FlLine( color: Colors.grey.withOpacity(0.3), strokeWidth: 1, ); }, ), borderData: FlBorderData(show: false), barTouchData: BarTouchData( touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, groupIndex, rod, rodIndex) { final tagName = sortedTags[rodIndex]; final duration = Duration(minutes: (rod.toY * 60).round()); return BarTooltipItem( '$tagName\n', const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, ), children: [ TextSpan( text: _formatDuration(duration), style: const TextStyle( color: Colors.white, fontWeight: FontWeight.normal, ), ), ], ); }, ), ), ), ), ); } (DateTime, DateTime) _calculateDateRange(ReportPeriod period) { final nowLocal = DateTime.now().toLocal(); final startOfDay = DateTime(nowLocal.year, nowLocal.month, nowLocal.day); switch (_selectedPeriod) { case ReportPeriod.day: final endOfDay = startOfDay.add(const Duration(days: 1)); return (startOfDay, endOfDay); case ReportPeriod.week: final startOfWeek = startOfDay.subtract( Duration(days: startOfDay.weekday - 1), ); final endOfWeek = startOfWeek.add(const Duration(days: 7)); return (startOfWeek, endOfWeek); case ReportPeriod.month: final startOfMonth = DateTime(nowLocal.year, nowLocal.month, 1); final endOfMonth = DateTime(nowLocal.year, nowLocal.month + 1, 1); return (startOfMonth, endOfMonth); case ReportPeriod.year: final startOfYear = DateTime(nowLocal.year, 1, 1); final endOfYear = DateTime(nowLocal.year + 1, 1, 1); return (startOfYear, endOfYear); case ReportPeriod.custom: final endExclusive = DateTime( _customEndDate.year, _customEndDate.month, _customEndDate.day, ).add(const Duration(days: 1)); return (_customStartDate, endExclusive); } } Future _generateReport() async { if (!mounted) return; setState(() { _isLoadingReport = true; _reportData = null; }); final timeService = context.read(); final tagId = _selectedTag?.id; late DateTime reportStartDate; late DateTime reportEndDateExclusive; if (_selectedPeriod == ReportPeriod.custom) { reportStartDate = _customStartDate; reportEndDateExclusive = DateTime( _customEndDate.year, _customEndDate.month, _customEndDate.day, ).add(const Duration(days: 1)); } else { final (start, end) = _calculateDateRange(_selectedPeriod); reportStartDate = start; reportEndDateExclusive = end; } log( "Generating report for Period: $_selectedPeriod, TagID: $tagId, Start: $reportStartDate, End (Exclusive): $reportEndDateExclusive", ); final data = await timeService.flGetReport( tagId, reportStartDate, reportEndDateExclusive, ); if (mounted) { setState(() { _reportData = data; _isLoadingReport = false; }); } } // Future _generateReport() async { // if (!mounted) return; // setState(() { // _isLoadingReport = true; // _reportData = null; // }); // final timeService = context.read(); // final (start, end) = _calculateDateRange(); // final tagId = _selectedTag?.id; // log("Generating report for TagID: $tagId, Start: $start, End: $end"); // final data = await timeService.flGetReport(tagId, start, end); // if (mounted) { // setState(() { // _reportData = data; // _isLoadingReport = false; // }); // } // } @override Widget build(BuildContext context) { final tags = context.watch().tags; final List tagOptions = [null, ...tags]; return PlatformScaffold( appBar: PlatformAppBar(title: PlatformText('Reports')), body: Column( children: [ _buildFilterControls(tagOptions), Expanded( child: RefreshIndicator.adaptive( onRefresh: _generateReport, child: _isLoadingReport ? Center(child: PlatformCircularProgressIndicator()) : _reportData == null ? ListView( children: [ SizedBox( height: MediaQuery.of(context).size.height * 0.5, child: Text( 'Keine Reportdaten gefunden oder Fehler.', ), ), ], ) : _buildReportView(_reportData!), ), ), ], ), ); } Widget _buildFilterControls(List tagOptions) { final DateFormat formatter = DateFormat('dd.MM.yyyy'); final String dateRangeButtonText = '${formatter.format(_customStartDate)} - ${formatter.format(_customEndDate)}'; return Padding( padding: const EdgeInsets.all(8.0), child: Wrap( spacing: 8.0, runSpacing: 4.0, alignment: WrapAlignment.start, crossAxisAlignment: WrapCrossAlignment.center, children: [ PlatformWidget( material: (_, __) => DropdownButton( value: _selectedPeriod, items: [ ...ReportPeriod.values.map((period) { String text; switch (period) { case ReportPeriod.day: text = 'Tag'; break; case ReportPeriod.week: text = 'Woche'; break; case ReportPeriod.month: text = 'Monat'; break; case ReportPeriod.year: text = 'Jahr'; break; case ReportPeriod.custom: text = 'Benutzerdefiniert'; break; } return DropdownMenuItem( value: period, child: PlatformText(text), ); }), ], onChanged: (ReportPeriod? newValue) { if (newValue != null && newValue != _selectedPeriod) { setState(() { _selectedPeriod = newValue; if (_selectedPeriod != ReportPeriod.custom) { _generateReport(); } else { // Optional: Direkt den Picker öffnen, wenn "Custom" gewählt wird? // WidgetsBinding.instance.addPostFrameCallback((_) => _selectDateRange()); } }); } }, ), // TODO: Cupertino Dropdown Alternative (komplexer, oft wird Button+Picker genutzt) // Fürs Erste verwenden wir auch auf iOS das Material Dropdown cupertino: (_, __) => CupertinoButton( padding: EdgeInsets.zero, child: Text(_selectedPeriod.toString().split('.').last), onPressed: () { /* Hier müsste ein Picker geöffnet werden */ }, ), ), DropdownButton( value: _selectedTag, hint: PlatformText("Alle Tags"), onChanged: (Tag? newValue) { setState(() { _selectedTag = newValue; }); _generateReport(); }, items: tagOptions.map((Tag? tag) { return DropdownMenuItem( value: tag, child: PlatformText(tag?.name ?? "Alle Tags"), ); }).toList(), ), if (_selectedPeriod == ReportPeriod.custom) PlatformElevatedButton( onPressed: _selectDateRange, child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(FontAwesomeIcons.calendar), // PlatformIcons(context)), const SizedBox(width: 8), PlatformText(dateRangeButtonText), ], ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), ), ], ), ); } // Widget _buildFilterControls(List tagOptions) { // final DateFormat formatter = DateFormat('dd.MM.yyyy'); // final (start, end) = _calculateDateRange(); // return Padding( // padding: const EdgeInsets.all(8.0), // child: Wrap( // spacing: 8.0, // runSpacing: 4.0, // alignment: WrapAlignment.center, // children: [ // DropdownButton( // value: _selectedPeriod, // onChanged: (ReportPeriod? newValue) { // if (newValue != null) { // setState(() { // _selectedPeriod = newValue; // }); // _generateReport(); // } // }, // items: // ReportPeriod.values.map((ReportPeriod period) { // return DropdownMenuItem( // value: period, // child: PlatformText( // period.toString().split('.').last.toUpperCase(), // ), // ); // }).toList(), // ), // DropdownButton( // value: _selectedTag, // hint: PlatformText("Alle Tags"), // onChanged: (Tag? newValue) { // setState(() { // _selectedTag = newValue; // }); // _generateReport(); // }, // items: // tagOptions.map((Tag? tag) { // return DropdownMenuItem( // value: tag, // child: PlatformText(tag?.name ?? "Alle Tags"), // ); // }).toList(), // ), // Chip( // label: PlatformText( // '${formatter.format(start)} - ${formatter.format(end.subtract(const Duration(seconds: 1)))}', // ), // ), // ], // ), // ); // } Future _selectDateRange() async { final DateTimeRange initialRange = DateTimeRange( start: _customStartDate, end: _customEndDate, ); final DateTimeRange? picked = await showDateRangePicker( context: context, locale: const Locale('de', 'DE'), initialDateRange: initialRange, firstDate: DateTime(2020), lastDate: DateTime.now().add(const Duration(days: 365)), helpText: 'Zeitraum auswählen', cancelText: 'Abbrechen', confirmText: 'Ok', saveText: 'Speichern', ); if (picked != null) { if (picked.start != _customStartDate || picked.end != _customEndDate || _selectedPeriod != ReportPeriod.custom) { setState(() { _customStartDate = DateTime( picked.start.year, picked.start.month, picked.start.day, ); _customEndDate = DateTime( picked.end.year, picked.end.month, picked.end.day, ); _selectedPeriod = ReportPeriod.custom; }); _generateReport(); } } } Widget _buildReportView(ReportData reportData) { if (_reportData == null) { return Center( child: PlatformText('Keine Reportdaten geladen oder Fehler.'), ); } final (startDate, endDate) = _calculateDateRange( _selectedPeriod == ReportPeriod.custom ? ReportPeriod.custom : _selectedPeriod, ); return ListView( children: [ Padding( padding: const EdgeInsets.all(16.0), child: PlatformText( 'Gesamtdauer: ${_reportData!.totalDurationFormatted}', style: platformThemeData( context, material: (data) => data.textTheme.titleLarge, cupertino: (data) => data.textTheme.navTitleTextStyle, ), ), ), SizedBox( height: 250, child: _buildBarChart(_reportData!, startDate, endDate), ), const Divider(), Padding( padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), child: PlatformText( "Einträge (zum Löschen wischen)", style: platformThemeData( context, material: (data) => data.textTheme.titleMedium, cupertino: (data) => data.textTheme.navTitleTextStyle.copyWith(fontSize: 18), ), ), ), SizedBox(height: 600, child: _buildReportList(_reportData!.entries)), ], ); } void _confirmAndDeleteEntry(TimeEntry entry, BuildContext context) { showPlatformDialog( context: context, builder: (_) => PlatformAlertDialog( title: Text('Eintrag löschen?'), actions: [ PlatformDialogAction( child: PlatformText('Abbrechen'), onPressed: () => Navigator.pop(context), ), PlatformDialogAction( child: PlatformText('Löschen'), onPressed: () async { Navigator.pop(context); await _deleteEntry(entry); }, ), ], ), ); } Future _deleteEntry(TimeEntry entry) async { final service = Provider.of(context, listen: false); try { await service.flDeleteTimeEntry(entry.id); if (mounted) { setState(() => _reportData!.entries.remove(entry)); } await _generateReport(); } catch (e) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Fehler beim Löschen: $e'))); } } Widget _buildReportList(List entries) { return ListView.builder( // shrinkWrap: true, // physics: const NeverScrollableScrollPhysics(), itemCount: entries.length, itemBuilder: (context, index) { final entry = entries[index]; return Builder( builder: (slidableContext) => Slidable( endActionPane: ActionPane( motion: const ScrollMotion(), children: [ SlidableAction( onPressed: (_) => _confirmAndDeleteEntry(entry, slidableContext), backgroundColor: Colors.red, icon: Icons.delete, label: 'Löschen', ), SlidableAction( onPressed: (context) => _showEditEntryDialog(context, entry), backgroundColor: Colors.blue, icon: Icons.edit, label: 'Bearbeiten', ), ], ), child: ListTile( title: Text(entry.tagName ?? 'Ohne Tag'), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Start: ${DateFormat('HH:mm').format(entry.startDateTime)}', ), if (entry.endDateTime != null) Text( 'Ende: ${DateFormat('HH:mm').format(entry.endDateTime!)}', ), Text('Dauer: ${entry.durationFormatted}'), ], ), trailing: const Icon(Icons.swipe_left), ), ), ); }, ); } Future _showEditEntryDialog( BuildContext context, TimeEntry entry, ) async { DateTime? startTime = entry.startDateTime; DateTime? endTime = entry.endDateTime; await showPlatformDialog( context: context, builder: (BuildContext context) { return PlatformAlertDialog( title: Text('Eintrag bearbeiten'), content: StatefulBuilder( builder: (BuildContext context, StateSetter setState) { return Column( mainAxisSize: MainAxisSize.min, children: [ PlatformText('Startzeit:'), PlatformElevatedButton( child: Text( DateFormat('yyyy-MM-dd HH:mm').format(startTime!), ), onPressed: () async { DateTime? pickedDate = await showPlatformDatePicker( context: context, initialDate: startTime!, firstDate: DateTime(2000), lastDate: DateTime(2101), ); if (pickedDate != null) { TimeOfDay? pickedTime = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(startTime!), ); if (pickedTime != null) { setState(() { startTime = DateTime( pickedDate.year, pickedDate.month, pickedDate.day, pickedTime.hour, pickedTime.minute, ); }); } } }, ), PlatformText('Endzeit:'), PlatformElevatedButton( child: Text( endTime != null ? DateFormat('yyyy-MM-dd HH:mm').format(endTime!) : 'Keine Endzeit', ), onPressed: () async { DateTime? pickedDate = await showPlatformDatePicker( context: context, initialDate: endTime ?? DateTime.now(), firstDate: DateTime(2000), lastDate: DateTime(2101), ); if (pickedDate != null) { TimeOfDay? pickedTime = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime( endTime ?? DateTime.now(), ), ); if (pickedTime != null) { setState(() { endTime = DateTime( pickedDate.year, pickedDate.month, pickedDate.day, pickedTime.hour, pickedTime.minute, ); }); } } }, ), ], ); }, ), actions: [ PlatformDialogAction( child: PlatformText('Abbrechen'), onPressed: () { Navigator.of(context).pop(); }, ), PlatformDialogAction( child: PlatformText('Speichern'), onPressed: () async { // Hier die Logik zum Speichern der bearbeiteten Daten Navigator.of(context).pop(); _updateTimeEntry(entry, startTime, endTime); }, ), ], ); }, ); } Future _updateTimeEntry( TimeEntry entry, DateTime? startTime, DateTime? endTime, ) async { final timeService = Provider.of( context, listen: false, ); if (startTime == null || endTime == null) { log("Start- oder Endzeit darf nicht null sein."); return; } bool success = await timeService.flUpdateTimeEntry( entryId: entry.id, tagId: entry.tagId, startTime: startTime, endTime: endTime, ); if (success) { _generateReport(); } else { _showPlatformFeedbackDialog( context, 'Error', 'Fehler beim Aktualisieren des TimeEntry.', ); } } void _showPlatformFeedbackDialog( BuildContext context, String title, String message, ) { showPlatformDialog( context: context, useRootNavigator: true, builder: (dialogContext) => PlatformAlertDialog( title: PlatformText(title), content: PlatformText(message), actions: [ PlatformDialogAction( child: PlatformText('OK'), onPressed: () => Navigator.pop(dialogContext), ), ], ), ); } }