Feat: Macht die Trend-Seite funktional

This commit is contained in:
2026-01-01 17:05:10 +01:00
parent fcdc820c74
commit d3804865d8
7 changed files with 469 additions and 327 deletions

View File

@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import '../../Services/navigation_service.dart';
import '../../Services/theme_service.dart';
import '../Misc/dynamic_date_time_field.dart';
import '../Misc/InputFields/dynamic_date_time_field.dart';
import 'dialog_action.dart';
import 'dialog_input_field.dart';
import 'dialog_input_field_select_item.dart';

View File

@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import '../../Dialog/dialog_action.dart';
import '../../Dialog/dialog_input_field.dart';
import '../../Dialog/dialog_input_field_type_enum.dart';
import '../../Dialog/dynamic_dialog.dart';
/// Stellt einen DateRange-Picker als Input-Feld dar
class DateRangePicker extends StatefulWidget {
/// Initialisiert eine neue Instanz dieser Klasse
const DateRangePicker({
this.autofocus = false,
super.key,
this.decoration,
this.initialValue,
this.onChanged,
});
/// Der Initiale Wert des Input-Feldes
final DateTimeRange? initialValue;
/// Ob das Feld automatisch ausgewählt werden soll
final bool autofocus;
/// Die Funktion, die bei Veränderung des Wertes aufgerufen wird
final Function(DateTimeRange?)? onChanged;
/// Die Dekoration, wie das Feld aussehen soll
final InputDecoration? decoration;
@override
State<StatefulWidget> createState() => _DateRangePicker();
}
class _DateRangePicker extends State<DateRangePicker> {
final ValueNotifier<DateTimeRange?> _value = ValueNotifier(null);
@override
void initState() {
super.initState();
_value.value = widget.initialValue;
_value.addListener(() {
widget.onChanged?.call(_value.value);
});
}
@override
Widget build(final BuildContext context) => TextField(
readOnly: true,
controller: TextEditingController(
text: widget.initialValue != null
? '${widget.initialValue!.start.day}'
'.${widget.initialValue!.start.month}'
'.${widget.initialValue!.start.year}'
' - '
'${widget.initialValue!.end.day}'
'.${widget.initialValue!.end.month}'
'.${widget.initialValue!.end.year}'
: null,
),
decoration: widget.decoration,
onTap: _pickDateRange,
);
Future<void> _pickDateRange() async {
await DynamicDialog(
title: 'Zeitraum auswählen',
inputFields: [
DialogInputField(
id: 'dateFrom',
label: 'Startdatum',
keyboardType: TextInputType.datetime,
inputType: DialogInputFieldTypeEnum.date,
initialValue: _value.value?.start,
),
DialogInputField(
id: 'dateTo',
label: 'Enddatum',
keyboardType: TextInputType.datetime,
inputType: DialogInputFieldTypeEnum.date,
initialValue: _value.value?.end,
),
],
actions: [
DialogAction(label: 'Abbrechen'),
DialogAction(
label: 'Leeren',
onPressed: (_) {
_value.value = null;
},
),
DialogAction(
label: 'Auswählen',
isPrimary: true,
onPressed: (final Map<String, dynamic> values) {
if (values['dateFrom'] != null &&
values['dateFrom'] is DateTime &&
values['dateTo'] != null &&
values['dateTo'] is DateTime) {
_value.value = DateTimeRange(
start: values['dateFrom'],
end: values['dateTo'],
);
}
},
),
],
).show();
}
}

View File

@@ -14,6 +14,7 @@ class DynamicDateTimeField extends StatefulWidget {
super.key,
});
/// Der Initiale Wert des Input-Feldes
final DateTime? initialValue;
/// Der Modus des Datums-Feldes

View File

@@ -45,15 +45,6 @@ class _MonthlyBalanceChart extends State<MonthlyBalanceChart> {
@override
void initState() {
_transactionRepository.monthlyBalances(
account: _accountController.selected.value,
name: widget.name,
amountMin: widget.amountMin,
amountMax: widget.amountMax,
dateFrom: widget.dateFrom,
dateTo: widget.dateTo,
);
super.initState();
_transactionController.transactions.addListener(() {

View File

@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import 'package:routemaster/routemaster.dart';
import '../Misc/InputFields/date_range_picker.dart';
/// Stellt die Inputfelder für die Verlaufsübersicht dar
class InputFields extends StatefulWidget {
/// Erstellt eine neue Instanz dieser Klasse
const InputFields({
super.key,
this.amountMax,
this.amountMin,
this.dateFrom,
this.dateTo,
this.name,
this.onChanged,
});
/// Der Name der Transaktion, nach der gesucht werden soll
final String? name;
/// Der Mindestbetrag der Transaktion, nach der gesucht werden soll
final double? amountMin;
/// Der Maximalbetrag der Transaktion, nach der gesucht werden soll
final double? amountMax;
/// Das Datum der Transaktionen, ab dem gestartet wurde
final DateTime? dateFrom;
///Das Datum der Transaktionen, bis zu welchen beendet wurde
final DateTime? dateTo;
/// Die Funktion, die bei Veränderung eines Wertes aufgerufen wird
final Function()? onChanged;
@override
State<StatefulWidget> createState() => _InputFields();
}
class _InputFields extends State<InputFields> {
String? _name;
double? _amountMin;
double? _amountMax;
DateTimeRange? _dateTimeRange;
@override
void initState() {
super.initState();
_name = widget.name;
_amountMin = widget.amountMin;
_amountMax = widget.amountMax;
if (widget.dateFrom != null && widget.dateTo != null) {
_dateTimeRange = DateTimeRange(
start: widget.dateFrom!,
end: widget.dateTo!,
);
}
}
@override
Widget build(final BuildContext context) {
final ThemeData theme = Theme.of(context);
return Row(
children: <Widget>[
Expanded(
child: TextField(
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
),
onChanged: (final String value) {
_name = value;
_updateUrl();
},
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
decoration: const InputDecoration(
labelText: 'Min Betrag €',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (final String value) {
_amountMin = double.tryParse(value);
_updateUrl();
},
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
decoration: const InputDecoration(
labelText: 'Max Betrag €',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (final String value) {
_amountMax = double.tryParse(value);
_updateUrl();
},
),
),
const SizedBox(width: 8),
Expanded(
child: DateRangePicker(
initialValue: _dateTimeRange,
onChanged: (final DateTimeRange? value) {
_dateTimeRange = value;
_updateUrl();
},
decoration: InputDecoration(
labelText: 'Zeitraum',
border: const OutlineInputBorder(),
suffixIcon: Icon(
Icons.calendar_month,
color: theme.colorScheme.onSurface,
),
),
),
),
const SizedBox(width: 8),
IconButton(
onPressed: () {
_name = null;
_amountMin = null;
_amountMax = null;
_dateTimeRange = null;
_updateUrl();
},
icon: const Icon(Icons.clear),
),
],
);
}
void _updateUrl() {
final params = <String, String>{
if (_name != null && _name != '') 'name': _name!,
if (_amountMin != null) 'amountMin': _amountMin!.toString(),
if (_amountMax != null) 'amountMax': _amountMax!.toString(),
};
if (_dateTimeRange != null) {
params['dateFrom'] = _dateTimeRange!.start.toIso8601String();
params['dateTo'] = _dateTimeRange!.end.toIso8601String();
}
Routemaster.of(context).replace('/trend', queryParameters: params);
widget.onChanged?.call();
}
}

View File

@@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
import 'package:flutter_sticky_header/flutter_sticky_header.dart';
import 'package:intl/intl.dart';
import '../../Controller/account_controller.dart';
import '../../Controller/transaction_controller.dart';
import '../../Entities/drift_database.dart';
import '../../Repositories/transaction_repository.dart';
/// Stellt eine filterbare Liste der Transaktionen dar
class TransactionList extends StatefulWidget {
/// Erstellt eine neue Instanz dieser Klasse
const TransactionList({
super.key,
this.amountMax,
this.amountMin,
this.dateFrom,
this.dateTo,
this.name,
});
/// Der Name der Transaktion, nach der gesucht werden soll
final String? name;
/// Der Mindestbetrag der Transaktion, nach der gesucht werden soll
final double? amountMin;
/// Der Maximalbetrag der Transaktion, nach der gesucht werden soll
final double? amountMax;
/// Das Datum der Transaktionen, ab dem gestartet wurde
final DateTime? dateFrom;
///Das Datum der Transaktionen, bis zu welchen beendet wurde
final DateTime? dateTo;
@override
State<StatefulWidget> createState() => _TransactionListState();
}
class _TransactionListState extends State<TransactionList> {
final TransactionController _transactionController = TransactionController();
final AccountController _accountController = AccountController();
final TransactionRepository _transactionRepository = TransactionRepository();
@override
void initState() {
super.initState();
_transactionController.transactions.addListener(() {
if (mounted) {
setState(() {});
}
});
}
@override
Widget build(final BuildContext context) => FutureBuilder(
future: _transactionRepository.findBy(
name: widget.name,
amountMin: widget.amountMin,
amountMax: widget.amountMax,
dateFrom: widget.dateFrom,
dateTo: widget.dateTo,
account: _accountController.selected.value,
),
builder:
(
final BuildContext context,
final AsyncSnapshot<List<Transaction>> snapshot,
) {
final ThemeData theme = Theme.of(context);
if (snapshot.hasData) {
final transactionsByMonth = <String, List<Transaction>>{};
for (final Transaction transaction in snapshot.data!) {
final DateTime date = transaction.date!;
final DateFormat format = DateFormat('MMMM');
final monthName = '${format.format(date)} ${date.year}';
transactionsByMonth
.putIfAbsent(monthName, () => [])
.add(transaction);
}
return Expanded(
child: CustomScrollView(
slivers: transactionsByMonth.entries
.map(
(
final MapEntry<String, List<Transaction>> 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 Transaction transaction = entry.value[index];
return ListTile(
contentPadding: EdgeInsets.zero,
title: Text(transaction.name),
subtitle: Text(
'${transaction.date?.day}'
'.${transaction.date?.month}'
'.${transaction.date?.year}',
),
trailing: Text(
'${transaction.amount.abs().toStringAsFixed(2)}'
'',
style: TextStyle(
color: transaction.amount >= 0
? Colors.red
: Colors.green,
fontWeight: FontWeight.bold,
),
),
);
}, childCount: entry.value.length),
),
),
)
.toList(),
),
);
} else if (snapshot.hasError) {
return Center(
child: Column(
children: [
Icon(Icons.error, color: theme.colorScheme.error),
const Text('Fehler beim holen der Transaktionen!'),
],
),
);
} else {
return const Center(child: CircularProgressIndicator());
}
},
);
}

View File

@@ -1,10 +1,10 @@
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';
import '../Misc/monthly_balance_chart.dart';
import 'input_fields.dart';
import 'transaction_list.dart';
/// Eine Seite, die den Trend von Transaktionen zeigt.
///
/// Die Seite zeigt einen Liniendiagramm für den Kontostand über die Zeit,
@@ -19,153 +19,17 @@ class Trend extends StatefulWidget {
}
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);
}
final String? name = query['name'];
final double? amountMin = double.tryParse(query['amountMin'] ?? '');
final double? amountMax = double.tryParse(query['amountMax'] ?? '');
final DateTime? dateFrom = DateTime.tryParse(query['dateFrom'] ?? '');
final DateTime? dateTo = DateTime.tryParse(query['dateTo'] ?? '');
return Scaffold(
body: SafeArea(
@@ -174,10 +38,29 @@ class _TrendState extends State<Trend> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_inputFields(theme),
InputFields(
name: name,
amountMin: amountMin,
amountMax: amountMax,
dateFrom: dateFrom,
dateTo: dateTo,
onChanged: () {
setState(() {});
},
),
const SizedBox(height: 24),
_lineChart(theme),
MonthlyBalanceChart(
name: name,
amountMin: amountMin,
amountMax: amountMax,
dateFrom: (dateFrom != null)
? DateTime(dateFrom.year, dateFrom.month, dateFrom.day - 1)
: null,
dateTo: (dateTo != null)
? DateTime(dateTo.year, dateTo.month, dateTo.day + 1)
: null,
),
const SizedBox(height: 24),
const Text(
@@ -185,180 +68,21 @@ class _TrendState extends State<Trend> {
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
_transactions(theme, transactionsByMonth),
TransactionList(
name: name,
amountMin: amountMin,
amountMax: amountMax,
dateFrom: (dateFrom != null)
? DateTime(dateFrom.year, dateFrom.month, dateFrom.day - 1)
: null,
dateTo: (dateTo != null)
? DateTime(dateTo.year, dateTo.month, dateTo.day + 1)
: null,
),
],
),
),
),
);
}
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(),
),
);
}