From 401475731918a66a855e161c30077e89388a6482 Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Wed, 31 Dec 2025 16:44:29 +0100 Subject: [PATCH] Feat: Macht die Dashboard-Seite funktional --- .../recurring_transaction_controller.dart | 5 +- lib/Controller/transaction_controller.dart | 250 ++++++++++++++++++ lib/Pages/Dashboard/current_balance.dart | 142 ++++++++++ lib/Pages/Dashboard/dashboard.dart | 243 ++--------------- .../Dashboard/recent_transactions_list.dart | 90 +++++++ lib/Pages/Misc/floating_creation_button.dart | 22 +- lib/Pages/Misc/monthly_balance_chart.dart | 175 ++++++++++++ .../recurring_transacation_repository.dart | 10 +- lib/Repositories/transaction_repository.dart | 147 +++++++++- 9 files changed, 845 insertions(+), 239 deletions(-) create mode 100644 lib/Controller/transaction_controller.dart create mode 100644 lib/Pages/Dashboard/current_balance.dart create mode 100644 lib/Pages/Dashboard/recent_transactions_list.dart create mode 100644 lib/Pages/Misc/monthly_balance_chart.dart diff --git a/lib/Controller/recurring_transaction_controller.dart b/lib/Controller/recurring_transaction_controller.dart index 38eda00..294afea 100644 --- a/lib/Controller/recurring_transaction_controller.dart +++ b/lib/Controller/recurring_transaction_controller.dart @@ -116,7 +116,8 @@ class RecurringTransactionController { Account? _selectedAccount; - /// Gibt die gespeicherten wiederkehrenden Transaktionen als Liste zurück + /// Aktualisiert die gespeicherten wiederkehrenden Transaktionen + /// in der internen Liste. Future updateRecurringTransactions() async { if (_selectedAccount != null) { final List recurringTransactions = @@ -134,7 +135,7 @@ class RecurringTransactionController { unawaited(_newRecurringTransactionDialog?.show()); } - /// Startet den Prozess, um eine neue wiederkehrende Transaktion zu bearbeiten + /// Startet den Prozess, um eine wiederkehrende Transaktion zu bearbeiten Future editRecurringTransaction( final int recurringTransactionId, ) async { diff --git a/lib/Controller/transaction_controller.dart b/lib/Controller/transaction_controller.dart new file mode 100644 index 0000000..58c60fc --- /dev/null +++ b/lib/Controller/transaction_controller.dart @@ -0,0 +1,250 @@ +import 'dart:async'; + +import 'package:drift/drift.dart'; +import 'package:flutter/material.dart'; + +import '../Entities/drift_database.dart'; +import '../Pages/Dialog/dialog_action.dart'; +import '../Pages/Dialog/dialog_input_field.dart'; +import '../Pages/Dialog/dialog_input_field_type_enum.dart'; +import '../Pages/Dialog/dialog_type_enum.dart'; +import '../Pages/Dialog/dynamic_dialog.dart'; +import '../Repositories/transaction_repository.dart'; +import 'account_controller.dart'; + +/// Steuert die Interaktion mit den Transaktionen +class TransactionController { + /// Gibt die aktuell gültige Instanz der Klasse zurück + factory TransactionController() => _instance; + + /// Erstellt eine neue Instanz dieser Klasse + TransactionController._internal() { + _newTransactionDialog = DynamicDialog( + title: 'Neue Transaktion erstellen', + icon: Icons.swap_horiz, + inputFields: [ + const DialogInputField(id: 'name', label: 'Name', autoFocus: true), + DialogInputField( + id: 'date', + label: 'Transaktionsdatum', + keyboardType: TextInputType.datetime, + inputType: DialogInputFieldTypeEnum.date, + initialValue: DateTime.now(), + ), + const DialogInputField( + id: 'amount', + label: 'Betrag €', + keyboardType: TextInputType.number, + ), + ], + actions: [ + DialogAction(label: 'Abbruch'), + DialogAction( + label: 'Speichern', + isPrimary: true, + onPressed: _saveNewTransaction, + ), + ], + ); + + _errorTransactionValueEmptyDialog = DynamicDialog( + title: 'Fehler!', + icon: Icons.error, + content: const Text('Es wurden nicht alle Werte eingetragen!'), + dialogType: DialogTypeEnum.error, + ); + + _transactionCreatedDialog = DynamicDialog( + title: 'Transaktion erstellt!', + icon: Icons.check_circle, + content: const Text('Die Transaktion wurde erfolgreich erstellt!'), + dialogType: DialogTypeEnum.success, + ); + + _selectedAccount = _accountController.selected.value; + _accountController.selected.addListener(() { + _selectedAccount = _accountController.selected.value; + unawaited(updateTransactions()); + }); + + unawaited(updateTransactions()); + } + + static final TransactionController _instance = + TransactionController._internal(); + final TransactionRepository _transactionRepository = TransactionRepository(); + final AccountController _accountController = AccountController(); + + DynamicDialog? _newTransactionDialog; + DynamicDialog? _errorTransactionValueEmptyDialog; + DynamicDialog? _transactionCreatedDialog; + + final ValueNotifier> _transactions = + ValueNotifier>([]); + + /// Stellt die Liste der Transaktionen dar + ValueNotifier> get transactions { + if (_transactions.value == []) { + unawaited(updateTransactions()); + } + + return _transactions; + } + + set transactions(final List transactions) { + _transactions.value = transactions; + } + + Account? _selectedAccount; + + /// Aktualisiert die Transaktionen in der internen Liste + Future updateTransactions() async { + if (_selectedAccount != null) { + final List transactions = await _transactionRepository + .findBy(account: _selectedAccount, orderBy: 'dateDesc'); + + _transactions.value = transactions; + } + } + + /// Startet den Prozess, um eine neue Transaktion anzulegen + void newTransactionHandler() { + unawaited(_newTransactionDialog?.show()); + } + + /// Startet den Prozess, um eine Transaktion zu bearbeiten + Future editTransaction(final int transactionId) async { + final Transaction? transaction = await _transactionRepository.find( + transactionId, + ); + + if (transaction != null) { + final editTransactionDialog = DynamicDialog( + title: '${transaction.name} umbenennen', + icon: Icons.edit, + inputFields: [ + DialogInputField( + id: 'name', + label: 'Name', + autoFocus: true, + initialValue: transaction.name, + ), + DialogInputField( + id: 'date', + label: 'Transaktionsdatum', + keyboardType: TextInputType.datetime, + inputType: DialogInputFieldTypeEnum.date, + initialValue: transaction.date, + ), + DialogInputField( + id: 'amount', + label: 'Betrag €', + keyboardType: TextInputType.number, + initialValue: transaction.amount.toString(), + ), + ], + actions: [ + DialogAction(label: 'Abbruch'), + DialogAction( + label: 'Speichern', + isPrimary: true, + onPressed: _editTransaction, + ), + ], + hiddenValues: {'transaction': transaction}, + ); + unawaited(editTransactionDialog.show()); + } + } + + /// Startet den Prozess, um eine Transaktion zu löschen + Future deleteTransactionHandler(final int transactionId) async { + final Transaction? transaction = await _transactionRepository.find( + transactionId, + ); + + if (transaction != null) { + final deleteTransactionDialog = DynamicDialog( + dialogType: DialogTypeEnum.error, + title: '${transaction.name} löschen', + content: Text('Willst du ${transaction.name} wirklich löschen?'), + icon: Icons.delete_forever, + actions: [ + DialogAction(label: 'Abbruch', isPrimary: true), + DialogAction( + label: 'Transaktion löschen', + onPressed: _deleteTransaction, + ), + ], + hiddenValues: {'transaction': transaction}, + ); + unawaited(deleteTransactionDialog.show()); + } + } + + Future _saveNewTransaction(final Map values) async { + if (values['name'] == null || + values['name'] == '' || + values['date'] == null || + values['date'] == '' || + values['amount'] == null || + values['amount'] == '' || + _selectedAccount == null) { + await _errorTransactionValueEmptyDialog?.show(); + } else { + final String amount = values['amount']; + values['amount'] = double.tryParse(amount); + + if (values['amount'] == null || values['amount'] == 0) { + await _errorTransactionValueEmptyDialog?.show(); + } else { + final transaction = TransactionsCompanion( + name: Value(values['name']), + date: Value(values['date']), + amount: Value(values['amount']), + accountId: Value(_selectedAccount!.id), + ); + + await _transactionRepository.add(transaction); + await _transactionCreatedDialog?.show(); + + await updateTransactions(); + } + } + } + + Future _editTransaction(final Map values) async { + if (values['transaction'] != null && + values['transaction'] != null && + values['date'] != null && + values['date'] != '' && + values['amount'] != null && + values['amount'] != '') { + final String amount = values['amount']; + values['amount'] = double.tryParse(amount); + + if (values['amount'] != null && values['amount'] != 0) { + final Transaction transaction = values['transaction']; + final rtc = TransactionsCompanion( + id: Value(transaction.id), + name: Value(values['name']), + date: Value(values['date']), + amount: Value(values['amount']), + accountId: Value(transaction.accountId), + ); + + await _transactionRepository.update(rtc); + await updateTransactions(); + } + } + } + + Future _deleteTransaction(final Map values) async { + if (values['transaction'] != null) { + final Transaction transaction = values['transaction']; + await _transactionRepository.remove(transaction); + + await updateTransactions(); + } + } +} diff --git a/lib/Pages/Dashboard/current_balance.dart b/lib/Pages/Dashboard/current_balance.dart new file mode 100644 index 0000000..83ac8fb --- /dev/null +++ b/lib/Pages/Dashboard/current_balance.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; + +import '../../Controller/account_controller.dart'; +import '../../Controller/transaction_controller.dart'; +import '../../Repositories/transaction_repository.dart'; + +/// Gibt eine Übersicht über den aktuellen Kontostand +/// und der Veränderung zum Vormonat zurück +class CurrentBalance extends StatefulWidget { + /// Erstellt eine neue Instanz dieser Klasse + const CurrentBalance({super.key}); + + @override + State createState() => _CurrentBalanceState(); +} + +class _CurrentBalanceState extends State { + 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: _getBalance(), + builder: + ( + final BuildContext context, + final AsyncSnapshot<(double, double, double)> snapshot, + ) { + final ThemeData theme = Theme.of(context); + + Widget widget; + + if (snapshot.hasData) { + final double balanceOfLastMonth = snapshot.data!.$1; + final double balanceNow = snapshot.data!.$2; + final double balanceOfThisMonth = snapshot.data!.$3; + + final double diff = balanceOfThisMonth - balanceOfLastMonth; + + widget = Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Aktuell', style: theme.textTheme.bodyMedium), + const SizedBox(height: 8), + Text( + '${balanceNow.toStringAsFixed(2)} €', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'Differenz zum Vormonat', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + diff >= 0 ? Icons.arrow_upward : Icons.arrow_downward, + color: diff >= 0 ? Colors.green : Colors.red, + ), + const SizedBox(width: 4), + Text( + '${diff.abs().toStringAsFixed(2)} €', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: diff >= 0 ? Colors.green : Colors.red, + ), + ), + ], + ), + ], + ), + ], + ); + } else if (snapshot.hasError) { + widget = Column( + children: [ + Icon(Icons.error, color: theme.colorScheme.error), + const Text('Fehler beim holen der letzten Transaktionen!'), + ], + ); + } else { + widget = const Center(child: CircularProgressIndicator()); + } + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: widget, + ); + }, + ); + + Future<(double, double, double)> _getBalance() async { + final now = DateTime.now(); + final dateOfLastMonth = DateTime(now.year, now.month, 0); + final dateOfThisMonth = DateTime(now.year, now.month + 1, 0); + + final double balanceOfLastMonth = await _transactionRepository.balance( + account: _accountController.selected.value, + until: dateOfLastMonth, + ); + final double balanceNow = await _transactionRepository.balance( + account: _accountController.selected.value, + until: now, + ); + final double balanceOfThisMonth = await _transactionRepository.balance( + account: _accountController.selected.value, + until: dateOfThisMonth, + ); + + return (balanceOfLastMonth, balanceNow, balanceOfThisMonth); + } +} diff --git a/lib/Pages/Dashboard/dashboard.dart b/lib/Pages/Dashboard/dashboard.dart index 1799542..35457e0 100644 --- a/lib/Pages/Dashboard/dashboard.dart +++ b/lib/Pages/Dashboard/dashboard.dart @@ -1,6 +1,9 @@ -import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; +import '../Misc/monthly_balance_chart.dart'; +import 'current_balance.dart'; +import 'recent_transactions_list.dart'; + /// Eine Seite, die das Dashboard der App darstellt. /// /// Diese Seite zeigt eine Übersicht über den aktuellen Kontostand, @@ -13,227 +16,33 @@ class Dashboard extends StatelessWidget { /// Baut das Dashboard-Widget auf. /// [context] ist der Build-Kontext @override - Widget build(final BuildContext context) { - const currentBalance = 4820.75; - const double previousMonthBalance = 4300; + Widget build(final BuildContext context) => const Scaffold( + body: SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CurrentBalance(), + SizedBox(height: 32), - final monthlyBalance = { - 'Jan': 1200.0, - 'Feb': 900.0, - 'Mär': 1100.0, - 'Apr': 950.0, - 'Mai': 1300.0, - 'Jun': 1050.0, - }; - - final recentTransactions = >[ - {'name': 'Supermarkt', 'amount': -45.50}, - {'name': 'Gehalt', 'amount': 2500.00}, - {'name': 'Miete', 'amount': -900.00}, - {'name': 'Streaming', 'amount': -12.99}, - {'name': 'Kaffee', 'amount': -4.50}, - ]; - - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _currentBalance(currentBalance, previousMonthBalance, context), - const SizedBox(height: 32), - - const Text( - 'Kontostand pro Monat', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 12), - _monthlyBalance(monthlyBalance, context), - - const SizedBox(height: 32), - const Text( - 'Letzte Transaktionen', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 12), - ..._recentTransactions(recentTransactions), - ], - ), - ), - ), - ); - } - - /// Baut das Widget für den aktuellen Kontostand - /// und die Differenz zum Vormonat auf. - /// - /// [currentBalance] ist der aktuelle Kontostand - /// [previousBalance] ist der Kontostand des Vormonats - /// [context] ist der Build-Kontext - Widget _currentBalance( - final double currentBalance, - final double previousBalance, - final BuildContext context, - ) { - final ThemeData theme = Theme.of(context); - - final double diff = currentBalance - previousBalance; - - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(16), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Aktuell', style: theme.textTheme.bodyMedium), - const SizedBox(height: 8), - Text( - '${currentBalance.toStringAsFixed(2)} €', - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, + Text( + 'Kontostand pro Monat', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), - ), - ], - ), + SizedBox(height: 12), + MonthlyBalanceChart(), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - 'Differenz zum Vormonat', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface, - ), - ), - const SizedBox(height: 8), - Row( - children: [ - Icon( - diff >= 0 ? Icons.arrow_upward : Icons.arrow_downward, - color: diff >= 0 ? Colors.green : Colors.red, - ), - const SizedBox(width: 4), - Text( - '${diff.abs().toStringAsFixed(2)} €', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: diff >= 0 ? Colors.green : Colors.red, - ), - ), - ], - ), - ], - ), - ], - ), - ); - } - - /// Baut das Widget für die Entwicklung - /// des Kontostands der letzten Monate auf. - /// - /// [monthlyBalance] ist ein Map mit den Monaten als Schlüssel und - /// den dazugehörigen Kontoständen als Werte. - /// [context] ist der Build-Kontext. - Widget _monthlyBalance( - final Map monthlyBalance, - final BuildContext context, - ) { - final ThemeData theme = Theme.of(context); - final double maxY = - monthlyBalance.values.reduce( - (final double a, final double b) => a > b ? a : b, - ) + - 200; - - return SizedBox( - height: 180, - child: LineChart( - LineChartData( - minY: 0, - maxY: maxY, - 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()]); - } - return const Text(''); - }, - ), - ), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 50, - getTitlesWidget: (final double value, final TitleMeta meta) => - Text( - '${value.toInt()} €', - style: const TextStyle(fontSize: 12), - ), - ), - ), - ), - lineBarsData: [ - LineChartBarData( - spots: List.generate( - monthlyBalance.length, - (final int index) => FlSpot( - index.toDouble(), - monthlyBalance.values.elementAt(index), - ), - ), - isCurved: true, - barWidth: 3, - color: theme.colorScheme.primary, + SizedBox(height: 32), + Text( + 'Letzte Transaktionen', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), + SizedBox(height: 12), + RecentTransactionsList(), ], ), ), - ); - } - - /// Erstellt Widgets für die letzten Transaktionen. - /// - /// [recentTransactions] ist eine Liste von Transaktionen, - /// wobei jede Transaktion ein Map mit den Keys 'name' (String) - /// und 'amount' (double) ist. - /// Die Funktion gibt eine Liste von Widgets zurück, - /// die die Transaktionen anzeigen. - List _recentTransactions( - final List> recentTransactions, - ) => recentTransactions - .map( - (final Map tx) => Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: ListTile( - contentPadding: EdgeInsets.zero, - title: Text((tx['name'] ?? '') as String), - trailing: Text( - '${((tx['amount'] ?? 0) as double).abs().toStringAsFixed(2)} €', - style: TextStyle( - color: ((tx['amount'] ?? 0) as double) >= 0 - ? Colors.green - : Colors.red, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ) - .toList(); + ), + ); } diff --git a/lib/Pages/Dashboard/recent_transactions_list.dart b/lib/Pages/Dashboard/recent_transactions_list.dart new file mode 100644 index 0000000..e96882f --- /dev/null +++ b/lib/Pages/Dashboard/recent_transactions_list.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +import '../../Controller/account_controller.dart'; +import '../../Controller/transaction_controller.dart'; +import '../../Entities/drift_database.dart'; +import '../../Repositories/transaction_repository.dart'; + +/// Eine Liste mit den zuletzt getätigten Transaktionen +class RecentTransactionsList extends StatefulWidget { + /// Erstellt eine neue Instanz dieser Klasse + const RecentTransactionsList({super.key}); + + @override + State createState() => _RecentTransactionsListState(); +} + +class _RecentTransactionsListState extends State { + 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) { + final Future> recentTransactions = _transactionRepository + .findBy( + account: _accountController.selected.value, + limit: 5, + orderBy: 'dateDesc', + ); + + return FutureBuilder( + future: recentTransactions, + builder: + ( + final BuildContext context, + final AsyncSnapshot> snapshot, + ) { + final ThemeData theme = Theme.of(context); + + if (snapshot.hasData) { + final List? recentTransactionsWidgetList = snapshot.data + ?.map( + (final Transaction transaction) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + contentPadding: EdgeInsets.zero, + title: Text(transaction.name), + trailing: Text( + '${transaction.amount.abs().toStringAsFixed(2)} €', + style: TextStyle( + color: transaction.amount == 0 + ? null + : (transaction.amount < 0 + ? Colors.green + : Colors.red), + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ) + .toList(); + + return Column(children: [...?recentTransactionsWidgetList]); + } else if (snapshot.hasError) { + return Column( + children: [ + Icon(Icons.error, color: theme.colorScheme.error), + const Text('Fehler beim holen der letzten Transaktionen!'), + ], + ); + } else { + return const Center(child: CircularProgressIndicator()); + } + }, + ); + } +} diff --git a/lib/Pages/Misc/floating_creation_button.dart b/lib/Pages/Misc/floating_creation_button.dart index 714974f..55b9d8e 100644 --- a/lib/Pages/Misc/floating_creation_button.dart +++ b/lib/Pages/Misc/floating_creation_button.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_expandable_fab/flutter_expandable_fab.dart'; import '../../Controller/account_controller.dart'; +import '../../Controller/recurring_transaction_controller.dart'; +import '../../Controller/transaction_controller.dart'; /// Ein Floating Action Button, der beim Klicken ein expandierendes Menü öffnet, /// um neue Transaktionen oder Konten anzulegen. @@ -15,6 +17,10 @@ class FloatingCreationButton extends StatefulWidget { class _FloatingCreationButtonState extends State { final AccountController _accountController = AccountController(); + final RecurringTransactionController _recurringTransactionController = + RecurringTransactionController(); + final TransactionController _transactionController = TransactionController(); + final _key = GlobalKey(); @override @@ -27,16 +33,22 @@ class _FloatingCreationButtonState extends State { childrenAnimation: ExpandableFabAnimation.none, distance: 70, children: [ - _expandableButton( - label: 'Neue Transaktion', - icon: Icons.add, - onPressed: () {}, - ), _expandableButton( label: 'Neues Konto', icon: Icons.account_balance_wallet, onPressed: _accountController.newAccountHandler, ), + _expandableButton( + label: 'Neue Transaktion', + icon: Icons.swap_horiz, + onPressed: _transactionController.newTransactionHandler, + ), + _expandableButton( + label: 'Neue wiederkehrende Transaktion', + icon: Icons.repeat, + onPressed: + _recurringTransactionController.newRecurringTransactionHandler, + ), ], ); diff --git a/lib/Pages/Misc/monthly_balance_chart.dart b/lib/Pages/Misc/monthly_balance_chart.dart new file mode 100644 index 0000000..faf9697 --- /dev/null +++ b/lib/Pages/Misc/monthly_balance_chart.dart @@ -0,0 +1,175 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../Controller/account_controller.dart'; +import '../../Controller/transaction_controller.dart'; +import '../../Repositories/transaction_repository.dart'; + +/// Stellt einen Chart des Monats-Kontostands dar +class MonthlyBalanceChart extends StatefulWidget { + /// Erstellt eine neue Instanz dieser Klasse + const MonthlyBalanceChart({ + 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 createState() => _MonthlyBalanceChart(); +} + +class _MonthlyBalanceChart extends State { + final TransactionRepository _transactionRepository = TransactionRepository(); + + final AccountController _accountController = AccountController(); + final TransactionController _transactionController = TransactionController(); + + @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(() { + if (mounted) { + setState(() {}); + } + }); + } + + @override + Widget build(final BuildContext context) => FutureBuilder( + future: _transactionRepository.monthlyBalances( + account: _accountController.selected.value, + name: widget.name, + amountMin: widget.amountMin, + amountMax: widget.amountMax, + dateFrom: widget.dateFrom, + dateTo: widget.dateTo, + ), + builder: + ( + final BuildContext context, + final AsyncSnapshot>> snapshot, + ) { + final ThemeData theme = Theme.of(context); + + if (snapshot.hasData) { + final List> monthlyBalances = snapshot.data!; + + double maxBalance = 0; + double minBalance = 0; + + for (final value in monthlyBalances) { + if (maxBalance < value['balance']) { + maxBalance = value['balance']; + } + + if (minBalance > value['balance']) { + minBalance = value['balance']; + } + } + + return SizedBox( + height: 180, + child: LineChart( + LineChartData( + minY: minBalance, + maxY: maxBalance, + 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 = monthlyBalances.map(( + final value, + ) { + final DateTime date = value['date']; + final DateFormat format = DateFormat('MMMM'); + + return format.format(date); + }).toList(); + + if (value.toInt() >= 0 && + value.toInt() < months.length) { + return Text(months[value.toInt()]); + } + return const Text(''); + }, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 50, + getTitlesWidget: + (final double value, final TitleMeta meta) => Text( + '${value.toInt()} €', + style: const TextStyle(fontSize: 12), + ), + ), + ), + ), + lineBarsData: [ + LineChartBarData( + spots: List.generate( + monthlyBalances.length, + (final int index) => FlSpot( + index.toDouble(), + monthlyBalances[index]['balance'], + ), + ), + isCurved: true, + barWidth: 3, + color: theme.colorScheme.primary, + ), + ], + ), + ), + ); + } else if (snapshot.hasError) { + return Center( + child: Column( + children: [ + Icon(Icons.error, color: theme.colorScheme.error), + const Text('Fehler beim holen der Monatsübersicht!'), + ], + ), + ); + } else { + return const Center(child: CircularProgressIndicator()); + } + }, + ); +} diff --git a/lib/Repositories/recurring_transacation_repository.dart b/lib/Repositories/recurring_transacation_repository.dart index 5ff3153..c949b1c 100644 --- a/lib/Repositories/recurring_transacation_repository.dart +++ b/lib/Repositories/recurring_transacation_repository.dart @@ -18,7 +18,7 @@ class RecurringTransactionRepository { return find(id); } - /// Aktualisiert ein Konto in der Datenbank + /// Aktualisiert eine wiederkehrende Transaktion in der Datenbank Future update( final RecurringTransactionsCompanion recurringTransaction, ) => _db.update(_db.recurringTransactions).replace(recurringTransaction); @@ -42,8 +42,6 @@ class RecurringTransactionRepository { final DateTime? startDate, final DateTime? startDateBefore, final DateTime? startDateAfter, - final DateTime? startDateFrom, - final DateTime? startDateTo, final TimeFrameEnum? timeFrame, final double? amount, final double? amountMin, @@ -77,12 +75,6 @@ class RecurringTransactionRepository { query.where((final t) => t.startDate.isSmallerThanValue(startDateBefore)); } - if (startDateFrom != null && startDateTo != null) { - query.where( - (final t) => t.startDate.isBetweenValues(startDateFrom, startDateTo), - ); - } - if (timeFrame != null) { query.where((final t) => t.timeFrame.equals(timeFrame.index)); } diff --git a/lib/Repositories/transaction_repository.dart b/lib/Repositories/transaction_repository.dart index 44de2b5..a457569 100644 --- a/lib/Repositories/transaction_repository.dart +++ b/lib/Repositories/transaction_repository.dart @@ -13,6 +13,10 @@ class TransactionRepository { return find(id); } + /// Aktualisiert eine Transaktion in der Datenbank + Future update(final TransactionsCompanion transaction) => + _db.update(_db.transactions).replace(transaction); + /// Entfernt eine Transaktion aus der Datenbank Future remove(final Transaction transaction) => (_db.delete( _db.transactions, @@ -28,11 +32,14 @@ class TransactionRepository { final int? id, final String? name, final DateTime? date, - final DateTime? dateBefore, - final DateTime? dateAfter, + final DateTime? dateFrom, + final DateTime? dateTo, final double? amount, final double? amountMin, final double? amountMax, + final Account? account, + final int? limit, + final int? offset, final String? orderBy, }) { final SimpleSelectStatement<$TransactionsTable, Transaction> query = _db @@ -50,12 +57,12 @@ class TransactionRepository { query.where((final t) => t.date.equals(date)); } - if (dateBefore != null) { - query.where((final t) => t.date.isBiggerThanValue(dateBefore)); + if (dateFrom != null) { + query.where((final t) => t.date.isBiggerThanValue(dateFrom)); } - if (dateAfter != null) { - query.where((final t) => t.date.isSmallerThanValue(dateAfter)); + if (dateTo != null) { + query.where((final t) => t.date.isSmallerThanValue(dateTo)); } if (amount != null) { @@ -70,6 +77,14 @@ class TransactionRepository { query.where((final t) => t.amount.isSmallerThanValue(amountMax)); } + if (account != null) { + query.where((final t) => t.accountId.equals(account.id)); + } + + if (limit != null) { + query.limit(limit, offset: offset); + } + if (orderBy != null) { switch (orderBy) { case 'nameAsc': @@ -89,4 +104,124 @@ class TransactionRepository { return query.get(); } + + /// Gibt den Kontostand zurück + Future balance({ + final Account? account, + final DateTime? until, + }) async { + final JoinedSelectStatement<$TransactionsTable, Transaction> query = + _db.selectOnly(_db.transactions) + ..addColumns([_db.transactions.amount.sum()]); + + if (account != null) { + query.where(_db.transactions.accountId.equals(account.id)); + } + + if (until != null) { + query.where(_db.transactions.date.isSmallerOrEqualValue(until)); + } + + return (await query + .map((final row) => row.read(_db.transactions.amount.sum()) ?? 0) + .getSingle()) * + -1; + } + + /// Gibt den Kontostand der letzten 12 Monate zurück + Future>> monthlyBalances({ + final Account? account, + final String? name, + final double? amountMin, + final double? amountMax, + DateTime? dateFrom, + DateTime? dateTo, + }) async { + final now = DateTime.now(); + + final monthStart = DateTime(now.year, now.month - 12); + final monthEnd = DateTime(now.year, now.month + 1, 0); + + if (dateFrom == null || dateFrom.compareTo(monthStart) < 0) { + dateFrom = monthStart; + } + + if (dateTo == null || dateTo.compareTo(monthEnd) > 0) { + dateTo = monthEnd; + } + + final Expression yearExpr = _db.transactions.date.year; + final Expression monthExpr = _db.transactions.date.month; + final Expression sumExpr = _db.transactions.amount.sum(); + + final JoinedSelectStatement<$TransactionsTable, Transaction> query = + _db.selectOnly(_db.transactions) + ..addColumns([yearExpr, monthExpr, sumExpr]) + ..groupBy([yearExpr, monthExpr]) + ..orderBy([OrderingTerm.asc(yearExpr), OrderingTerm.asc(monthExpr)]) + ..where(_db.transactions.date.isBiggerOrEqualValue(dateFrom)) + ..where(_db.transactions.date.isSmallerOrEqualValue(dateTo)); + + if (account != null) { + query.where(_db.transactions.accountId.equals(account.id)); + } + + if (name != null && name.isNotEmpty) { + query.where(_db.transactions.name.like('%$name%')); + } + + if (amountMin != null) { + query.where(_db.transactions.amount.isBiggerOrEqualValue(amountMin)); + } + + if (amountMax != null) { + query.where(_db.transactions.amount.isSmallerOrEqualValue(amountMax)); + } + + final List> rows = (await query.get()).map((final row) { + final int year = row.read(yearExpr)!; + final int month = row.read(monthExpr)!; + + return { + 'date': DateTime(year, month), + 'balance': (row.read(sumExpr) ?? 0) * -1, + }; + }).toList(); + + double amount = await balance(account: account, until: dateFrom); + + DateTime dateTimeLoop = dateFrom; + int loop = 0; + + final List> result = []; + + while (dateTimeLoop.compareTo(monthEnd) < 0) { + Map? row; + for (final value in rows) { + final Object? rowDate = value['date']; + + if (rowDate is DateTime) { + if (dateTimeLoop.compareTo(rowDate) == 0) { + row = value; + } + } + } + + if (row == null) { + result.add({'date': dateTimeLoop, 'balance': 0.0}); + } else { + result.add(row); + } + + final double balance = double.parse(result[loop]['balance'].toString()); + amount = balance + amount; + + result[loop]['balance'] = amount; + + loop = loop + 1; + dateTimeLoop = DateTime(dateTimeLoop.year, dateTimeLoop.month + 1); + } + + return result; + } }