Feat: Macht die Trend-Seite funktional
This commit is contained in:
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../Services/navigation_service.dart';
|
import '../../Services/navigation_service.dart';
|
||||||
import '../../Services/theme_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_action.dart';
|
||||||
import 'dialog_input_field.dart';
|
import 'dialog_input_field.dart';
|
||||||
import 'dialog_input_field_select_item.dart';
|
import 'dialog_input_field_select_item.dart';
|
||||||
|
|||||||
111
lib/Pages/Misc/InputFields/date_range_picker.dart
Normal file
111
lib/Pages/Misc/InputFields/date_range_picker.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ class DynamicDateTimeField extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Der Initiale Wert des Input-Feldes
|
||||||
final DateTime? initialValue;
|
final DateTime? initialValue;
|
||||||
|
|
||||||
/// Der Modus des Datums-Feldes
|
/// Der Modus des Datums-Feldes
|
||||||
@@ -45,15 +45,6 @@ class _MonthlyBalanceChart extends State<MonthlyBalanceChart> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
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();
|
super.initState();
|
||||||
|
|
||||||
_transactionController.transactions.addListener(() {
|
_transactionController.transactions.addListener(() {
|
||||||
|
|||||||
159
lib/Pages/Trend/input_fields.dart
Normal file
159
lib/Pages/Trend/input_fields.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
156
lib/Pages/Trend/transaction_list.dart
Normal file
156
lib/Pages/Trend/transaction_list.dart
Normal 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());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:fl_chart/fl_chart.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_sticky_header/flutter_sticky_header.dart';
|
|
||||||
import 'package:routemaster/routemaster.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.
|
/// Eine Seite, die den Trend von Transaktionen zeigt.
|
||||||
///
|
///
|
||||||
/// Die Seite zeigt einen Liniendiagramm für den Kontostand über die Zeit,
|
/// 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> {
|
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
|
@override
|
||||||
Widget build(final BuildContext context) {
|
Widget build(final BuildContext context) {
|
||||||
final ThemeData theme = Theme.of(context);
|
|
||||||
|
|
||||||
final Map<String, String> query = Routemaster.of(
|
final Map<String, String> query = Routemaster.of(
|
||||||
context,
|
context,
|
||||||
).currentRoute.queryParameters;
|
).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 =
|
final String? name = query['name'];
|
||||||
transactions.where((final Map<String, Object> tx) {
|
final double? amountMin = double.tryParse(query['amountMin'] ?? '');
|
||||||
final date = tx['date']! as DateTime;
|
final double? amountMax = double.tryParse(query['amountMax'] ?? '');
|
||||||
|
final DateTime? dateFrom = DateTime.tryParse(query['dateFrom'] ?? '');
|
||||||
var rangeMatch = true;
|
final DateTime? dateTo = DateTime.tryParse(query['dateTo'] ?? '');
|
||||||
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(
|
return Scaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
@@ -174,10 +38,29 @@ class _TrendState extends State<Trend> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
_inputFields(theme),
|
InputFields(
|
||||||
|
name: name,
|
||||||
|
amountMin: amountMin,
|
||||||
|
amountMax: amountMax,
|
||||||
|
dateFrom: dateFrom,
|
||||||
|
dateTo: dateTo,
|
||||||
|
onChanged: () {
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
const SizedBox(height: 24),
|
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 SizedBox(height: 24),
|
||||||
|
|
||||||
const Text(
|
const Text(
|
||||||
@@ -185,180 +68,21 @@ class _TrendState extends State<Trend> {
|
|||||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user