diff --git a/README.md b/README.md index 4e5ca097..98b54319 100644 --- a/README.md +++ b/README.md @@ -8,21 +8,22 @@ The app design is heavily influenced by [Bluecoins](https://play.google.com/stor The app is still pretty much work in progress, but basic features already do work: +- General + - Dark Mode switch according to system settings - Dashboard - Multiple charts for the current balance & recent history - - Budget overview for last 30 days. + - Budget overview for last 30 days - Transactions - - List transactions by date, including (basic) filters. + - List transactions by date, including (basic) filters - Add & edit transactions with autocomplete, including attachments, split transactions & multi currency support - Balance Sheet - List invididual balances ### Planned Features -- Piggy Banks -- Dark Mode - Detailed Accounts page - Settings page - Notification Listener +- More filter options - ... and many more. ## Screenshots diff --git a/lib/pages/home_piggybank.dart b/lib/pages/home_piggybank.dart index a72dc436..1b054f40 100644 --- a/lib/pages/home_piggybank.dart +++ b/lib/pages/home_piggybank.dart @@ -1,11 +1,16 @@ import 'dart:ui'; +import 'package:chopper/chopper.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:waterflyiii/auth.dart'; import 'package:waterflyiii/swagger_fireflyiii_api/firefly_iii.swagger.dart'; +import 'package:community_charts_flutter/community_charts_flutter.dart' + as charts; + class HomePiggybank extends StatefulWidget { const HomePiggybank({ super.key, @@ -58,12 +63,16 @@ class _HomePiggybankState extends State children: [ ...snapshot.data!.data.map( (PiggyBankRead piggy) { - final double balance = - double.parse(piggy.attributes.currentAmount ?? ""); - final String balanceString = balance.abs().toStringAsFixed( - piggy.attributes.currencyDecimalPlaces ?? 2); + final double currentAmount = + double.tryParse(piggy.attributes.currentAmount ?? "") ?? + 0; + final String currentString = currentAmount + .abs() + .toStringAsFixed( + piggy.attributes.currencyDecimalPlaces ?? 2); final double targetAmount = - double.parse(piggy.attributes.targetAmount ?? ""); + double.tryParse(piggy.attributes.targetAmount ?? "") ?? + 0; final String targetString = targetAmount .abs() .toStringAsFixed( @@ -92,12 +101,12 @@ class _HomePiggybankState extends State children: [ TextSpan( text: - "${(balance < 0) ? '-' : ''}${piggy.attributes.currencySymbol}$balanceString", + "${(currentAmount < 0) ? '-' : ''}${piggy.attributes.currencySymbol}$currentString", style: Theme.of(context) .textTheme .titleMedium! .copyWith( - color: (balance < 0) + color: (currentAmount < 0) ? Colors.red : Colors.green, fontWeight: FontWeight.bold, @@ -112,24 +121,17 @@ class _HomePiggybankState extends State targetAmount != 0 ? TextSpan( text: - "${(piggy.attributes.percentage ?? 0).round()}% of ${piggy.attributes.currencySymbol}$targetAmount") + "${(piggy.attributes.percentage ?? 0).round()}% of ${piggy.attributes.currencySymbol}$targetString") : const TextSpan(), ], ), ), onTap: () { - // Piggybank Chart Dialog! - /*showDialog( - context: context, - builder: (BuildContext context) => Dialog.fullscreen( - child: Scaffold( - appBar: AppBar( - title: Text(piggy.attributes.name), - ), - body: :TODO:, - ), - ), - );*/ + showDialog( + context: context, + builder: (BuildContext context) => + PiggyDetails(piggy: piggy), + ); }, ), piggy.attributes.percentage != null @@ -158,3 +160,204 @@ class _HomePiggybankState extends State ); } } + +class PiggyDetails extends StatefulWidget { + const PiggyDetails({ + super.key, + required this.piggy, + }); + + final PiggyBankRead piggy; + + @override + State createState() => _PiggyDetailsState(); +} + +class TimeSeriesChart { + final DateTime time; + final double value; + + TimeSeriesChart(this.time, this.value); +} + +class _PiggyDetailsState extends State { + Future> _fetchChart() async { + final api = FireflyProvider.of(context).api; + if (api == null) { + throw Exception("API unavailable"); + } + + final resp = await api.apiV1PiggyBanksIdEventsGet(id: widget.piggy.id); + if (!resp.isSuccessful || resp.body == null || resp.body!.data.isEmpty) { + throw Exception("Invalid Response from API"); + } + + return resp.body!.data.sortedBy((PiggyBankEventRead e) => + e.attributes.createdAt ?? e.attributes.updatedAt ?? DateTime.now()); + } + + @override + Widget build(BuildContext context) { + final double currentAmount = + double.tryParse(widget.piggy.attributes.currentAmount ?? "") ?? 0; + final String currentString = currentAmount + .abs() + .toStringAsFixed(widget.piggy.attributes.currencyDecimalPlaces ?? 2); + final double targetAmount = + double.tryParse(widget.piggy.attributes.targetAmount ?? "") ?? 0; + final String targetString = targetAmount + .abs() + .toStringAsFixed(widget.piggy.attributes.currencyDecimalPlaces ?? 2); + final double leftAmount = + double.tryParse(widget.piggy.attributes.leftToSave ?? "") ?? 0; + final String leftString = leftAmount + .abs() + .toStringAsFixed(widget.piggy.attributes.currencyDecimalPlaces ?? 2); + final DateTime? startDate = widget.piggy.attributes.startDate?.toLocal(); + final DateTime? targetDate = widget.piggy.attributes.targetDate?.toLocal(); + + String infoText = ""; + + if (targetAmount != 0) { + infoText += + "Target amount: ${widget.piggy.attributes.currencySymbol}$targetString\n"; + } + infoText += + "Saved so far: ${widget.piggy.attributes.currencySymbol}$currentString\n"; + if (leftAmount != 0) { + infoText += + "Left to save: ${widget.piggy.attributes.currencySymbol}$leftString\n"; + } + if (startDate != null) { + infoText += + "Start date: ${DateFormat(DateFormat.YEAR_MONTH_DAY).format(startDate)}\n"; + } + if (targetDate != null) { + infoText += + "Target date: ${DateFormat(DateFormat.YEAR_MONTH_DAY).format(targetDate)}\n"; + } + + return SimpleDialog( + title: Text(widget.piggy.attributes.name), + clipBehavior: Clip.hardEdge, + children: [ + widget.piggy.attributes.percentage != null + ? LinearProgressIndicator( + value: widget.piggy.attributes.percentage! / 100, + ) + : const Divider(height: 0), + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 0), + child: Text(infoText.strip()), + ), + FutureBuilder( + future: _fetchChart(), + builder: (BuildContext context, + AsyncSnapshot> snapshot) { + if (snapshot.connectionState == ConnectionState.done && + snapshot.hasData) { + final List> chartData = + []; + final List> ticks = []; + final List data = []; + + double total = 0; + + if (widget.piggy.attributes.startDate != null) { + data.add(TimeSeriesChart( + widget.piggy.attributes.startDate!, + 0, + )); + ticks.add(charts.TickSpec( + widget.piggy.attributes.startDate!.toLocal())); + } + + for (PiggyBankEventRead e in snapshot.data!) { + final DateTime? date = + e.attributes.createdAt ?? e.attributes.updatedAt; + final amount = + double.tryParse(e.attributes.amount ?? "") ?? 0; + if (date == null || amount == 0) { + continue; + } + total += amount; + data.add(TimeSeriesChart(date, total)); + ticks.add(charts.TickSpec(date.toLocal())); + } + chartData.add( + charts.Series( + id: widget.piggy.id, + domainFn: (TimeSeriesChart d, _) => d.time.toLocal(), + measureFn: (TimeSeriesChart d, _) => d.value, + data: data, + ), + ); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: SizedBox( + height: 300, + child: charts.TimeSeriesChart( + chartData, + animate: true, + primaryMeasureAxis: charts.NumericAxisSpec( + tickProviderSpec: + const charts.BasicNumericTickProviderSpec( + //desiredTickCount: 6, + desiredMaxTickCount: 6, + desiredMinTickCount: 4, + zeroBound: true, + ), + renderSpec: charts.SmallTickRendererSpec( + labelStyle: charts.TextStyleSpec( + color: charts.ColorUtil.fromDartColor( + Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + ), + domainAxis: charts.DateTimeAxisSpec( + tickFormatterSpec: charts.BasicDateTimeTickFormatterSpec + .fromDateFormat( + DateFormat(DateFormat.ABBR_MONTH_DAY)), + tickProviderSpec: + const charts.AutoDateTimeTickProviderSpec( + includeTime: false), + renderSpec: charts.SmallTickRendererSpec( + labelStyle: charts.TextStyleSpec( + color: charts.ColorUtil.fromDartColor( + Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + ), + ), + ), + ); + } else if (snapshot.hasError) { + print("has error ${snapshot.error}, popping view"); + Navigator.of(context).pop(); + return const SizedBox(); + } else { + return const Padding( + padding: EdgeInsets.all(8), + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + }), + OverflowBar( + alignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + }, + child: const Text("Close"), + ), + const SizedBox(width: 12), + ], + ), + ], + ); + } +} diff --git a/lib/pages/home_transactions.dart b/lib/pages/home_transactions.dart index aca5f967..78f64932 100644 --- a/lib/pages/home_transactions.dart +++ b/lib/pages/home_transactions.dart @@ -59,6 +59,7 @@ class _HomeTransactionsState extends State widget.key!, [ IconButton( + // :TODO: turn blue when filter is set.. if feasible icon: const Icon(Icons.tune), tooltip: 'Filter List', onPressed: () async {