import 'dart:math'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_sticky_header/flutter_sticky_header.dart'; import 'package:routemaster/routemaster.dart'; /// Eine Seite, die den Trend von Transaktionen zeigt. /// /// Die Seite zeigt einen Liniendiagramm für den Kontostand über die Zeit, /// sowie eine Liste aller Transaktionen, die nach Monaten gruppiert sind. /// Die Transaktionen können nach Name, Betrag und Zeitraum gefiltert werden. class Trend extends StatefulWidget { /// Erstellt eine neue Instanz der Trend-Seite. const Trend({super.key}); @override State createState() => _TrendState(); } class _TrendState extends State { final Map monthlyBalance = { for (int i in List.generate(24, (final int i) => i + 1)) 'M${i + 1}': Random().nextDouble() * 2000 + 500, }; final List> transactions = List>.generate( 50, (final int i) => { 'date': DateTime.now().subtract(Duration(days: i)), 'name': 'Transaction ${i + 1}', 'amount': (Random().nextDouble() * 2000 - 1000), }, ); late TextEditingController nameController; late TextEditingController minController; late TextEditingController maxController; DateTimeRange? selectedRange; bool _initialized = false; @override void didChangeDependencies() { super.didChangeDependencies(); if (!_initialized) { final Map query = Routemaster.of( context, ).currentRoute.queryParameters; nameController = TextEditingController(text: query['name'] ?? ''); minController = TextEditingController(text: query['min'] ?? ''); maxController = TextEditingController(text: query['max'] ?? ''); if (query['start'] != null && query['end'] != null) { final DateTime? start = DateTime.tryParse(query['start']!); final DateTime? end = DateTime.tryParse(query['end']!); if (start != null && end != null) { selectedRange = DateTimeRange(start: start, end: end); } } else { selectedRange = null; } _initialized = true; } } @override void dispose() { nameController.dispose(); minController.dispose(); maxController.dispose(); super.dispose(); } Future _pickDateRange() async { final DateTime now = DateTime.now(); final DateTimeRange? picked = await showDateRangePicker( context: context, firstDate: DateTime(now.year - 5), lastDate: DateTime(now.year + 1), initialDateRange: selectedRange, ); if (picked != null) { setState(() => selectedRange = picked); _updateUrl(); } } void _updateUrl() { final Map params = { if (nameController.text.isNotEmpty) 'name': nameController.text, if (minController.text.isNotEmpty) 'min': minController.text, if (maxController.text.isNotEmpty) 'max': maxController.text, if (selectedRange != null) 'start': selectedRange!.start.toIso8601String(), if (selectedRange != null) 'end': selectedRange!.end.toIso8601String(), }; Routemaster.of(context).replace('/trend', queryParameters: params); setState(() {}); } String _monthName(final int month) { const List names = [ '', 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', ]; return names[month]; } @override Widget build(final BuildContext context) { final ThemeData theme = Theme.of(context); final Map query = Routemaster.of( context, ).currentRoute.queryParameters; final String searchName = query['name'] ?? ''; final double? minAmount = double.tryParse(query['min'] ?? ''); final double? maxAmount = double.tryParse(query['max'] ?? ''); final List> filteredTransactions = transactions.where((final Map tx) { final DateTime date = tx['date']! as DateTime; bool rangeMatch = true; if (selectedRange != null) { rangeMatch = !date.isBefore(selectedRange!.start) && !date.isAfter(selectedRange!.end); } final bool nameMatch = searchName.isEmpty || ((tx['name'] ?? '') as String).contains(searchName); final double amount = (tx['amount'] ?? 0) as double; final bool amountMatch = (minAmount == null || amount >= minAmount) && (maxAmount == null || amount <= maxAmount); return rangeMatch && nameMatch && amountMatch; }).toList()..sort( (final Map a, final Map b) => (b['date']! as DateTime).compareTo(a['date']! as DateTime), ); final Map>> transactionsByMonth = >>{}; for (final Map tx in filteredTransactions) { final DateTime date = tx['date']! as DateTime; final String monthName = '${_monthName(date.month)} ${date.year}'; transactionsByMonth .putIfAbsent(monthName, () => >[]) .add(tx); } return Scaffold( body: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _inputFields(theme), const SizedBox(height: 24), _lineChart(theme), const SizedBox(height: 24), const Text( 'Transaktionen', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), const SizedBox(height: 12), _transactions(theme, transactionsByMonth), ], ), ), ), ); } Widget _inputFields(final ThemeData theme) => Row( children: [ Expanded( child: TextField( controller: nameController, decoration: const InputDecoration( labelText: 'Name', border: OutlineInputBorder(), ), onChanged: (_) => _updateUrl(), ), ), const SizedBox(width: 8), Expanded( child: TextField( controller: minController, decoration: const InputDecoration( labelText: 'Min Betrag', border: OutlineInputBorder(), ), keyboardType: const TextInputType.numberWithOptions(decimal: true), onChanged: (_) => _updateUrl(), ), ), const SizedBox(width: 8), Expanded( child: TextField( controller: maxController, decoration: const InputDecoration( labelText: 'Max Betrag', border: OutlineInputBorder(), ), keyboardType: const TextInputType.numberWithOptions(decimal: true), onChanged: (_) => _updateUrl(), ), ), const SizedBox(width: 8), Expanded( child: TextField( readOnly: true, controller: TextEditingController( text: selectedRange != null ? '${selectedRange!.start.day}.${selectedRange!.start.month}.${selectedRange!.start.year} - ${selectedRange!.end.day}.${selectedRange!.end.month}.${selectedRange!.end.year}' : '', ), decoration: InputDecoration( labelText: 'Zeitraum', border: const OutlineInputBorder(), suffixIcon: Icon( Icons.calendar_month, color: theme.colorScheme.onSurface, ), ), onTap: _pickDateRange, ), ), ], ); Widget _lineChart(final ThemeData theme) => SizedBox( height: 180, child: LineChart( LineChartData( minY: 0, maxY: monthlyBalance.values.reduce( (final double a, final double b) => a > b ? a : b, ) + 500, titlesData: FlTitlesData( topTitles: const AxisTitles(), rightTitles: const AxisTitles(), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 28, interval: 1, getTitlesWidget: (final double value, final TitleMeta meta) { final List months = monthlyBalance.keys.toList(); if (value.toInt() >= 0 && value.toInt() < months.length) { return Text( months[value.toInt()], style: theme.textTheme.bodySmall, ); } return const Text(''); }, ), ), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 50, interval: 500, getTitlesWidget: (final double value, final TitleMeta meta) => Text('${value.toInt()} €', style: theme.textTheme.bodySmall), ), ), ), lineBarsData: [ LineChartBarData( spots: List.generate( monthlyBalance.length, (final int i) => FlSpot(i.toDouble(), monthlyBalance.values.elementAt(i)), ), isCurved: true, barWidth: 3, color: theme.colorScheme.primary, ), ], ), ), ); Widget _transactions( final ThemeData theme, final Map>> transactionsByMonth, ) => Expanded( child: CustomScrollView( slivers: transactionsByMonth.entries .map( (final MapEntry>> entry) => SliverStickyHeader( header: Container( color: theme.colorScheme.surface, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), alignment: Alignment.centerLeft, child: Text( entry.key, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, ), ), ), sliver: SliverList( delegate: SliverChildBuilderDelegate(( final BuildContext context, final int index, ) { final Map tx = entry.value[index]; final double amount = (tx['amount'] ?? 0) as double; final DateTime date = tx['date']! as DateTime; return ListTile( contentPadding: EdgeInsets.zero, title: Text((tx['name'] ?? '') as String), subtitle: Text( '${date.day}.${date.month}.${date.year}', ), trailing: Text( '${amount.toStringAsFixed(2)} €', style: TextStyle( color: amount >= 0 ? Colors.green : Colors.red, fontWeight: FontWeight.bold, ), ), ); }, childCount: entry.value.length), ), ), ) .toList(), ), ); }