365 lines
12 KiB
Dart
365 lines
12 KiB
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});
|
|
|
|
@override
|
|
State<Trend> createState() => _TrendState();
|
|
}
|
|
|
|
class _TrendState extends State<Trend> {
|
|
final Map<String, double> monthlyBalance = <String, double>{
|
|
for (int i in List<int>.generate(24, (final int i) => i + 1))
|
|
'M${i + 1}': Random().nextDouble() * 2000 + 500,
|
|
};
|
|
|
|
final List<Map<String, Object>> transactions =
|
|
List<Map<String, Object>>.generate(
|
|
50,
|
|
(final int i) => <String, Object>{
|
|
'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<String, String> 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<void> _pickDateRange() async {
|
|
final now = DateTime.now();
|
|
final DateTimeRange<DateTime>? 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 params = <String, String>{
|
|
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 names = <String>[
|
|
'',
|
|
'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<String, String> 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<Map<String, Object>> filteredTransactions =
|
|
transactions.where((final Map<String, Object> tx) {
|
|
final date = tx['date']! as DateTime;
|
|
|
|
var 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 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<String, Object> a, final Map<String, Object> b) =>
|
|
(b['date']! as DateTime).compareTo(a['date']! as DateTime),
|
|
);
|
|
|
|
final transactionsByMonth = <String, List<Map<String, Object>>>{};
|
|
for (final tx in filteredTransactions) {
|
|
final date = tx['date']! as DateTime;
|
|
final monthName = '${_monthName(date.month)} ${date.year}';
|
|
transactionsByMonth
|
|
.putIfAbsent(monthName, () => <Map<String, Object>>[])
|
|
.add(tx);
|
|
}
|
|
|
|
return Scaffold(
|
|
body: SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
_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: <Widget>[
|
|
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<String> 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>[
|
|
LineChartBarData(
|
|
spots: List<FlSpot>.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<String, List<Map<String, Object>>> transactionsByMonth,
|
|
) => Expanded(
|
|
child: CustomScrollView(
|
|
slivers: transactionsByMonth.entries
|
|
.map(
|
|
(final MapEntry<String, List<Map<String, Object>>> 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<String, Object> tx = entry.value[index];
|
|
final amount = (tx['amount'] ?? 0) as double;
|
|
final 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(),
|
|
),
|
|
);
|
|
}
|