diff --git a/lib/Pages/Trend/trend.dart b/lib/Pages/Trend/trend.dart index 338f925..419a224 100644 --- a/lib/Pages/Trend/trend.dart +++ b/lib/Pages/Trend/trend.dart @@ -1,6 +1,365 @@ -import 'package:flutter/material.dart'; +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}); -class Trend extends StatelessWidget { @override - Widget build(final BuildContext context) => const Text('TREND'); + 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(), + ), + ); }