From 2fddbb0e4314cc4763ee993a5d818cb756464dee Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Fri, 23 Feb 2024 22:58:31 +0800 Subject: [PATCH 01/32] Fix #81 --- lib/objectbox/actions.dart | 2 +- lib/widgets/home/transactions_date_header.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index 942086a..f132cb2 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -127,7 +127,7 @@ extension TransactionActions on Transaction { } extension TransactionListActions on Iterable { - Iterable get nonTransactions => + Iterable get nonTransfers => where((transaction) => !transaction.isTransfer); double get incomeSum => where((transaction) => transaction.amount >= 0) diff --git a/lib/widgets/home/transactions_date_header.dart b/lib/widgets/home/transactions_date_header.dart index 78c8373..6ecfe7a 100644 --- a/lib/widgets/home/transactions_date_header.dart +++ b/lib/widgets/home/transactions_date_header.dart @@ -17,7 +17,7 @@ class TransactionListDateHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final double flow = transactions.sum; + final double flow = transactions.nonTransfers.sum; final int count = transactions.length; return Column( From 181235c5ebe040600eae01fbc06412eab25bccf3 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Fri, 23 Feb 2024 23:43:35 +0800 Subject: [PATCH 02/32] Refactor actions, rethink #81 --- lib/widgets/account_card.dart | 4 ++-- lib/widgets/home/transactions_date_header.dart | 8 ++++++-- lib/widgets/transaction_list_tile.dart | 7 +++++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/lib/widgets/account_card.dart b/lib/widgets/account_card.dart index 9bc8e24..b14418a 100644 --- a/lib/widgets/account_card.dart +++ b/lib/widgets/account_card.dart @@ -32,10 +32,10 @@ class AccountCard extends StatelessWidget { @override Widget build(BuildContext context) { final double incomeSum = excludeTransfersInTotal - ? account.transactions.nonTransactions.incomeSum + ? account.transactions.nonTransfers.incomeSum : account.transactions.incomeSum; final double expenseSum = excludeTransfersInTotal - ? account.transactions.nonTransactions.expenseSum + ? account.transactions.nonTransfers.expenseSum : account.transactions.expenseSum; final child = Surface( diff --git a/lib/widgets/home/transactions_date_header.dart b/lib/widgets/home/transactions_date_header.dart index 6ecfe7a..d1672b4 100644 --- a/lib/widgets/home/transactions_date_header.dart +++ b/lib/widgets/home/transactions_date_header.dart @@ -1,6 +1,7 @@ import 'package:flow/entity/transaction.dart'; import 'package:flow/l10n/extensions.dart'; import 'package:flow/objectbox/actions.dart'; +import 'package:flow/prefs.dart'; import 'package:flow/theme/theme.dart'; import 'package:flutter/widgets.dart'; import 'package:moment_dart/moment_dart.dart'; @@ -17,8 +18,11 @@ class TransactionListDateHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final double flow = transactions.nonTransfers.sum; - final int count = transactions.length; + final double flow = transactions.sum; + final int count = transactions.length - + (LocalPreferences().combineTransferTransactions.get() + ? transactions.transfers.length ~/ 2 + : 0); return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/widgets/transaction_list_tile.dart b/lib/widgets/transaction_list_tile.dart index f169f16..7b835e9 100644 --- a/lib/widgets/transaction_list_tile.dart +++ b/lib/widgets/transaction_list_tile.dart @@ -6,6 +6,7 @@ import 'package:flow/entity/transaction.dart'; import 'package:flow/entity/transaction/extensions/default/transfer.dart'; import 'package:flow/l10n/extensions.dart'; import 'package:flow/objectbox/actions.dart'; +import 'package:flow/prefs.dart'; import 'package:flow/theme/theme.dart'; import 'package:flow/widgets/general/flow_icon.dart'; import 'package:flutter/cupertino.dart'; @@ -36,6 +37,12 @@ class TransactionListTile extends StatelessWidget { @override Widget build(BuildContext context) { + if (LocalPreferences().combineTransferTransactions.get() && + transaction.isTransfer && + transaction.amount.isNegative) { + return Container(); + } + final bool missingTitle = transaction.title == null; final Transfer? transfer = From 74d969f20f5512607e9db8736fb886d43f3fef66 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 26 Feb 2024 19:41:52 +0800 Subject: [PATCH 03/32] feat: home page stats --- assets/l10n/en_US.json | 2 + assets/l10n/mn_MN.json | 2 + lib/data/money_flow.dart | 43 +++++++ lib/entity/account.dart | 2 + lib/objectbox/actions.dart | 120 ++++++++++++------ lib/routes/home/home_tab.dart | 116 ++++++++++++++++- lib/routes/home/stats_tab.dart | 58 ++++++++- lib/widgets/home/home/analytics_card.dart | 24 ++++ .../home/home/flow_separate_line_chart.dart | 108 ++++++++++++++++ pubspec.lock | 8 ++ pubspec.yaml | 1 + 11 files changed, 440 insertions(+), 44 deletions(-) create mode 100644 lib/data/money_flow.dart create mode 100644 lib/widgets/home/home/analytics_card.dart create mode 100644 lib/widgets/home/home/flow_separate_line_chart.dart diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index 38eaa48..6594c57 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -128,6 +128,8 @@ "tabs.home.noTransactions.allTime": "You don't have any transactions", "tabs.home.noTransactions.last7Days": "No transactions for the last 7 days", "tabs.home.noTransactions.addSome": "Click on (+) button below to add a new transaction", + "tabs.home.totalBalance": "Total balance", + "tabs.home.flowToday": "Flow today", "tabs.stats": "Stats", "tabs.accounts": "Accounts", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index cf21dea..7837eb2 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -128,6 +128,8 @@ "tabs.home.noTransactions.allTime": "Танд одоогоор гүйлгээ алга байна", "tabs.home.noTransactions.last7Days": "Сүүлийн долоо хоногт хийгдсэн гүйлгээ алга байна", "tabs.home.noTransactions.addSome": "Доор байрлах (+) товч дээр дарж гүйлгээ нэмээрэй", + "tabs.home.totalBalance": "Нийт үлдэгдэл", + "tabs.home.flowToday": "Өнөөдөр", "tabs.stats": "Тоо", "tabs.accounts": "Данснууд", diff --git a/lib/data/money_flow.dart b/lib/data/money_flow.dart new file mode 100644 index 0000000..31fc8aa --- /dev/null +++ b/lib/data/money_flow.dart @@ -0,0 +1,43 @@ +class MoneyFlow implements Comparable { + double totalExpense; + double totalIncome; + + double get flow => totalExpense + totalIncome; + + MoneyFlow({ + this.totalExpense = 0.0, + this.totalIncome = 0.0, + }); + + @override + int compareTo(MoneyFlow other) { + return flow.compareTo(other.flow); + } + + void addExpense(double expense) => totalExpense += expense; + void addIncome(double income) => totalIncome += income; + void add(double amount) => + amount.isNegative ? addExpense(amount) : addIncome(amount); + void addAll(Iterable amounts) => amounts.forEach(add); + + operator +(MoneyFlow other) { + return MoneyFlow( + totalExpense: totalExpense + other.totalExpense, + totalIncome: totalIncome + other.totalIncome, + ); + } + + operator -(MoneyFlow other) { + return MoneyFlow( + totalExpense: totalExpense - other.totalExpense, + totalIncome: totalIncome - other.totalIncome, + ); + } + + operator -() { + return MoneyFlow( + totalExpense: -totalExpense, + totalIncome: -totalIncome, + ); + } +} diff --git a/lib/entity/account.dart b/lib/entity/account.dart index 0b2f6a6..22d2706 100644 --- a/lib/entity/account.dart +++ b/lib/entity/account.dart @@ -50,6 +50,8 @@ class Account implements EntityBase { } /// Returns current balance. This is calculated by summing up every single transaction + /// + /// TODO should this be cached? @Transient() @JsonKey(includeFromJson: false, includeToJson: false) double get balance { diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index f132cb2..a607f60 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -1,5 +1,6 @@ import 'dart:developer'; +import 'package:flow/data/money_flow.dart'; import 'package:flow/data/prefs/frecency_group.dart'; import 'package:flow/entity/account.dart'; import 'package:flow/entity/category.dart'; @@ -14,6 +15,18 @@ import 'package:moment_dart/moment_dart.dart'; import 'package:uuid/uuid.dart'; extension MainActions on ObjectBox { + double getTotalBalance() { + final Query accountsQuery = box() + .query(Account_.excludeFromTotalBalance.equals(false)) + .build(); + + final List accounts = accountsQuery.find(); + + return accounts + .map((e) => e.balance) + .fold(0, (previousValue, element) => previousValue + element); + } + List getAccounts([bool sortByFrecency = true]) { final List accounts = box().getAll(); @@ -67,6 +80,63 @@ extension MainActions on ObjectBox { await ObjectBox().box().putManyAsync(accounts); } + + /// Returns a map of category uuid -> [MoneyFlow] + Future> flowByCategories({ + required DateTime from, + required DateTime to, + }) async { + final Query transactionsQuery = ObjectBox() + .box() + .query(Transaction_.transactionDate.betweenDate(from, to)) + .build(); + + final List transactions = await transactionsQuery.findAsync(); + + transactionsQuery.close(); + + final Map flow = {}; + + for (final transaction in transactions) { + final String categoryUuid = + transaction.category.target?.uuid ?? Uuid.NAMESPACE_NIL; + + flow[categoryUuid] ??= MoneyFlow(); + flow[categoryUuid]!.add(transaction.amount); + } + + return flow; + } + + /// Returns a map of category uuid -> [MoneyFlow] + Future> flowByAccounts({ + required DateTime from, + required DateTime to, + }) async { + final Query transactionsQuery = ObjectBox() + .box() + .query(Transaction_.transactionDate.betweenDate(from, to)) + .build(); + + final List transactions = await transactionsQuery.findAsync(); + + transactionsQuery.close(); + + final Map flow = {}; + + for (final transaction in transactions) { + final String accountUuid = + transaction.account.target?.uuid ?? Uuid.NAMESPACE_NIL; + + flow[accountUuid] ??= MoneyFlow(); + flow[accountUuid]!.add(transaction.amount); + } + + assert(!flow.containsKey(Uuid.NAMESPACE_NIL), + "There is no way you've managed to make a transaction without an account"); + + return flow; + } } extension TransactionActions on Transaction { @@ -129,49 +199,25 @@ extension TransactionActions on Transaction { extension TransactionListActions on Iterable { Iterable get nonTransfers => where((transaction) => !transaction.isTransfer); - - double get incomeSum => where((transaction) => transaction.amount >= 0) - .map((transaction) => transaction.amount) - .fold(0, (value, element) => value + element); - double get expenseSum => where((transaction) => transaction.amount < 0) - .map((transaction) => transaction.amount) - .fold(0, (value, element) => value + element); - double get sum => - map((transaction) => transaction.amount).fold(0, (a, b) => a + b); + Iterable get transfers => + where((transaction) => transaction.isTransfer); + Iterable get expenses => + where((transaction) => transaction.amount.isNegative); + Iterable get incomes => + where((transaction) => transaction.amount > 0); + + double get incomeSum => + incomes.fold(0, (value, element) => value + element.amount); + double get expenseSum => + expenses.fold(0, (value, element) => value + element.amount); + double get sum => fold(0, (value, element) => value + element.amount); Map> groupByDate() { final Map> value = {}; - int? lastTransferIndex; - Transaction? lastTransferFrom; - - for (final (index, transaction) in indexed) { + for (final transaction in this) { final date = transaction.transactionDate.toLocal().startOfDay(); - if (LocalPreferences().combineTransferTransactions.get() && - transaction.isTransfer) { - if (lastTransferIndex == null) { - lastTransferIndex = index; - lastTransferFrom = transaction; - continue; - } - - value[date] ??= []; - value[date]!.add( - lastTransferFrom! - ..title ??= "transaction.transfer.fromToTitle".tr( - { - "from": lastTransferFrom.account.target!.name, - "to": transaction.account.target!.name - }, - ), - ); - - lastTransferIndex = null; - lastTransferFrom = null; - continue; - } - value[date] ??= []; value[date]!.add(transaction); } diff --git a/lib/routes/home/home_tab.dart b/lib/routes/home/home_tab.dart index d4b6172..6d0e9be 100644 --- a/lib/routes/home/home_tab.dart +++ b/lib/routes/home/home_tab.dart @@ -1,10 +1,16 @@ import 'package:flow/entity/transaction.dart'; +import 'package:flow/l10n/flow_localizations.dart'; import 'package:flow/objectbox.dart'; +import 'package:flow/objectbox/actions.dart'; import 'package:flow/objectbox/objectbox.g.dart'; +import 'package:flow/theme/theme.dart'; +import 'package:flow/widgets/home/home/analytics_card.dart'; +import 'package:flow/widgets/home/home/flow_separate_line_chart.dart'; import 'package:flow/widgets/home/home/no_transactions.dart'; import 'package:flow/widgets/home/greetings_bar.dart'; import 'package:flow/widgets/grouped_transaction_list.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:moment_dart/moment_dart.dart'; class HomeTab extends StatefulWidget { @@ -17,15 +23,15 @@ class HomeTab extends StatefulWidget { } class _HomeTabState extends State with AutomaticKeepAliveClientMixin { - // Query for today's transaction, newest to oldest + final DateTime startDate = + Moment.now().subtract(const Duration(days: 6)).startOfDay(); + + // Last 7 days, and planned payments, newest to oldest QueryBuilder qb() => ObjectBox() .box() .query( Transaction_.transactionDate.greaterOrEqual( - Moment.now() - .subtract(const Duration(days: 6)) - .startOfDay() - .millisecondsSinceEpoch, + startDate.millisecondsSinceEpoch, ), ) .order(Transaction_.transactionDate, flags: Order.descending); @@ -50,12 +56,112 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { .where((element) => element.transactionDate <= Moment.now()) .toList(); + final bool showAnalytics = + !noTransactionsAtAll && transactions?.isNotEmpty == true; + return Column( children: [ const Padding( padding: EdgeInsets.all(16.0), child: GreetingsBar(), ), + if (showAnalytics) ...[ + Container( + height: 200.0, + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + Expanded( + child: Column( + children: [ + Expanded( + child: AnalyticsCard( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 12.0, + ), + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "tabs.home.totalBalance".t(context), + style: context.textTheme.bodyLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Flexible( + child: Text( + ObjectBox() + .getTotalBalance() + .moneyCompact, + style: context.textTheme.displaySmall, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 16.0), + Expanded( + child: AnalyticsCard( + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 12.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "tabs.home.flowToday".t(context), + style: context.textTheme.bodyLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Flexible( + child: Text( + (transactions ?? []) + .where((element) => + element.transactionDate >= + DateTime.now().startOfDay() && + element.transactionDate <= + DateTime.now()) + .sum + .moneyCompact, + style: context.textTheme.displaySmall, + ), + ), + ], + ), + ), + ), + ), + ], + )), + const SizedBox(width: 16.0), + Expanded( + child: AnalyticsCard( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: FlowSeparateLineChart( + transactions: transactions ?? [], + startDate: startDate, + endDate: DateTime.now(), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16.0), + ], switch ((transactions?.length ?? 0, snapshot.hasData)) { (0, true) => Expanded( child: NoTransactions( diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index 98b55c2..1816e00 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -1,4 +1,11 @@ +import 'dart:convert'; + +import 'package:flow/data/money_flow.dart'; +import 'package:flow/objectbox.dart'; +import 'package:flow/objectbox/actions.dart'; +import 'package:flow/widgets/general/spinner.dart'; import 'package:flutter/material.dart'; +import 'package:moment_dart/moment_dart.dart'; class StatsTab extends StatefulWidget { const StatsTab({super.key}); @@ -7,9 +14,56 @@ class StatsTab extends StatefulWidget { State createState() => _StatsTabState(); } -class _StatsTabState extends State { +class _StatsTabState extends State + with AutomaticKeepAliveClientMixin { + final DateTime from = DateTime.fromMillisecondsSinceEpoch(0); + final DateTime to = + DateTime.now().add(const Duration(days: 7)).startOfLocalWeek(); + // final DateTime from = DateTime.now().startOfLocalWeek(); + // final DateTime to = + // DateTime.now().add(const Duration(days: 7)).startOfLocalWeek(); + + late final Future> categoriesFlow = + ObjectBox().flowByCategories(from: from, to: to); + late final Future> accountsFlow = + ObjectBox().flowByAccounts(from: from, to: to); + @override Widget build(BuildContext context) { - return const Placeholder(); + super.build(context); + + return SingleChildScrollView( + child: Column( + children: [ + FutureBuilder( + future: categoriesFlow, + builder: (context, snapshot) => snapshot.data == null + ? const Spinner() + : Text( + jsonEncode( + snapshot.data!.map( + (key, value) => MapEntry(key, value.flow), + ), + ), + ), + ), + FutureBuilder( + future: accountsFlow, + builder: (context, snapshot) => snapshot.data == null + ? const Spinner() + : Text( + jsonEncode( + snapshot.data!.map( + (key, value) => MapEntry(key, value.flow), + ), + ), + ), + ), + ], + ), + ); } + + @override + bool get wantKeepAlive => true; } diff --git a/lib/widgets/home/home/analytics_card.dart b/lib/widgets/home/home/analytics_card.dart new file mode 100644 index 0000000..e6007dc --- /dev/null +++ b/lib/widgets/home/home/analytics_card.dart @@ -0,0 +1,24 @@ +import 'package:flow/widgets/general/surface.dart'; +import 'package:flutter/material.dart'; + +class AnalyticsCard extends StatelessWidget { + final Widget child; + + static const borderRadius = BorderRadius.all( + Radius.circular(24.0), + ); + + const AnalyticsCard({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + return Surface( + elevation: 0.0, + builder: (context) => ClipRRect( + borderRadius: borderRadius, + child: child, + ), + shape: const RoundedRectangleBorder(borderRadius: borderRadius), + ); + } +} diff --git a/lib/widgets/home/home/flow_separate_line_chart.dart b/lib/widgets/home/home/flow_separate_line_chart.dart new file mode 100644 index 0000000..d79b2f4 --- /dev/null +++ b/lib/widgets/home/home/flow_separate_line_chart.dart @@ -0,0 +1,108 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flow/entity/transaction.dart'; +import 'package:flow/objectbox/actions.dart'; +import 'package:flow/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:moment_dart/moment_dart.dart'; + +class FlowSeparateLineChart extends StatefulWidget { + final DateTime startDate; + final DateTime endDate; + + final List transactions; + + const FlowSeparateLineChart({ + super.key, + required this.transactions, + required this.startDate, + required this.endDate, + }); + + @override + State createState() => _FlowSeparateLineChartState(); +} + +class _FlowSeparateLineChartState extends State { + late List transactions; + late LineChartData data; + + @override + void initState() { + super.initState(); + + transactions = widget.transactions; + } + + @override + void didChangeDependencies() { + updateData(); + + super.didChangeDependencies(); + } + + @override + void didUpdateWidget(FlowSeparateLineChart oldWidget) { + transactions = widget.transactions; + + updateData(); + + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return LineChart( + data, + curve: Curves.easeOut, + ); + } + + void updateData() { + data = LineChartData( + titlesData: const FlTitlesData(show: false), + borderData: FlBorderData(show: false), + clipData: const FlClipData.all(), + gridData: FlGridData( + verticalInterval: const Duration(days: 1).inMicroseconds.toDouble(), + ), + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + tooltipBgColor: context.colorScheme.background, + tooltipPadding: const EdgeInsets.all(4.0), + fitInsideHorizontally: true, + fitInsideVertically: true, + ), + ), + minX: widget.startDate.startOfDay().microsecondsSinceEpoch.toDouble(), + maxX: widget.endDate.endOfDay().microsecondsSinceEpoch.toDouble() + 1, + lineBarsData: [ + LineChartBarData( + color: context.flowColors.expense, + spots: transactions.expenses + .map( + (e) => FlSpot( + e.transactionDate.microsecondsSinceEpoch.toDouble(), + e.amount.abs(), + ), + ) + .toList(), + isStrokeCapRound: true, + isStrokeJoinRound: true, + ), + LineChartBarData( + color: context.flowColors.income, + spots: transactions.incomes + .map( + (e) => FlSpot( + e.transactionDate.microsecondsSinceEpoch.toDouble(), + e.amount, + ), + ) + .toList(), + isStrokeCapRound: true, + isStrokeJoinRound: true, + ), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 0d8e1ed..496ff4c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -329,6 +329,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "00b74ae680df6b1135bdbea00a7d1fc072a9180b7c3f3702e4b19a9943f5ed7d" + url: "https://pub.dev" + source: hosted + version: "0.66.2" flat_buffers: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index dce14d2..1a0ffdd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: desktop_drop: ^0.4.4 file_picker: ^6.1.1 file_saver: ^0.2.9 + fl_chart: ^0.66.2 flutter: sdk: flutter flutter_floating_bottom_bar: ^1.2.0 From bd35e352daa429b39ffd8c2711b62a4e755cfebd Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 26 Feb 2024 19:42:13 +0800 Subject: [PATCH 04/32] chore: bump ver to 0.2.4+27 for testing --- lib/constants.dart | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/constants.dart b/lib/constants.dart index a5283f3..905723c 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; -const appVersion = "0.2.3+26"; +const appVersion = "0.2.4+27"; const debugBuild = true; bool get flowDebugMode => kDebugMode || debugBuild; diff --git a/pubspec.yaml b/pubspec.yaml index 1a0ffdd..5732ad0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: A personal finance managing app publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: "0.2.3+26" +version: "0.2.4+27" environment: sdk: ">=3.1.3 <4.0.0" From 33a54008f4ee3f103db3b21b8db44ac1e67d5371 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Thu, 29 Feb 2024 17:35:26 +0800 Subject: [PATCH 05/32] refactor grouped transactions widget, improve line graph a little --- assets/l10n/en_US.json | 1 + assets/l10n/mn_MN.json | 1 + lib/routes/home/home_tab.dart | 176 +++++++----------- lib/routes/transactions_page.dart | 12 +- lib/widgets/grouped_transaction_list.dart | 26 +-- lib/widgets/home/home/flow_graph.dart | 43 +++++ .../home/home/flow_separate_line_chart.dart | 69 +++++-- lib/widgets/home/home/flow_today_card.dart | 52 ++++++ lib/widgets/home/home/total_balance_card.dart | 40 ++++ 9 files changed, 279 insertions(+), 141 deletions(-) create mode 100644 lib/widgets/home/home/flow_graph.dart create mode 100644 lib/widgets/home/home/flow_today_card.dart create mode 100644 lib/widgets/home/home/total_balance_card.dart diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index 6594c57..248d983 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -128,6 +128,7 @@ "tabs.home.noTransactions.allTime": "You don't have any transactions", "tabs.home.noTransactions.last7Days": "No transactions for the last 7 days", "tabs.home.noTransactions.addSome": "Click on (+) button below to add a new transaction", + "tabs.home.last7days": "Last 7 days", "tabs.home.totalBalance": "Total balance", "tabs.home.flowToday": "Flow today", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 7837eb2..423600f 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -128,6 +128,7 @@ "tabs.home.noTransactions.allTime": "Танд одоогоор гүйлгээ алга байна", "tabs.home.noTransactions.last7Days": "Сүүлийн долоо хоногт хийгдсэн гүйлгээ алга байна", "tabs.home.noTransactions.addSome": "Доор байрлах (+) товч дээр дарж гүйлгээ нэмээрэй", + "tabs.home.last7days": "Сүүлийн 7 хоног", "tabs.home.totalBalance": "Нийт үлдэгдэл", "tabs.home.flowToday": "Өнөөдөр", diff --git a/lib/routes/home/home_tab.dart b/lib/routes/home/home_tab.dart index 6d0e9be..2edfc97 100644 --- a/lib/routes/home/home_tab.dart +++ b/lib/routes/home/home_tab.dart @@ -6,11 +6,13 @@ import 'package:flow/objectbox/objectbox.g.dart'; import 'package:flow/theme/theme.dart'; import 'package:flow/widgets/home/home/analytics_card.dart'; import 'package:flow/widgets/home/home/flow_separate_line_chart.dart'; +import 'package:flow/widgets/home/home/flow_today_card.dart'; import 'package:flow/widgets/home/home/no_transactions.dart'; import 'package:flow/widgets/home/greetings_bar.dart'; import 'package:flow/widgets/grouped_transaction_list.dart'; +import 'package:flow/widgets/home/home/total_balance_card.dart'; +import 'package:flow/widgets/home/transactions_date_header.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; import 'package:moment_dart/moment_dart.dart'; class HomeTab extends StatefulWidget { @@ -56,112 +58,12 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { .where((element) => element.transactionDate <= Moment.now()) .toList(); - final bool showAnalytics = - !noTransactionsAtAll && transactions?.isNotEmpty == true; - return Column( children: [ const Padding( padding: EdgeInsets.all(16.0), child: GreetingsBar(), ), - if (showAnalytics) ...[ - Container( - height: 200.0, - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Row( - children: [ - Expanded( - child: Column( - children: [ - Expanded( - child: AnalyticsCard( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 12.0, - ), - width: double.infinity, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "tabs.home.totalBalance".t(context), - style: context.textTheme.bodyLarge, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Flexible( - child: Text( - ObjectBox() - .getTotalBalance() - .moneyCompact, - style: context.textTheme.displaySmall, - ), - ), - ], - ), - ), - ), - ), - const SizedBox(height: 16.0), - Expanded( - child: AnalyticsCard( - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 12.0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - "tabs.home.flowToday".t(context), - style: context.textTheme.bodyLarge, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Flexible( - child: Text( - (transactions ?? []) - .where((element) => - element.transactionDate >= - DateTime.now().startOfDay() && - element.transactionDate <= - DateTime.now()) - .sum - .moneyCompact, - style: context.textTheme.displaySmall, - ), - ), - ], - ), - ), - ), - ), - ], - )), - const SizedBox(width: 16.0), - Expanded( - child: AnalyticsCard( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: FlowSeparateLineChart( - transactions: transactions ?? [], - startDate: startDate, - endDate: DateTime.now(), - ), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 16.0), - ], switch ((transactions?.length ?? 0, snapshot.hasData)) { (0, true) => Expanded( child: NoTransactions( @@ -169,14 +71,7 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { ), ), (_, true) => Expanded( - child: GroupedTransactionList( - controller: widget.scrollController, - transactions: transactions!, - listPadding: const EdgeInsets.only( - top: 16.0, - bottom: 80.0, - ), - ), + child: buildGroupedList(context, transactions ?? []), ), (_, false) => const Expanded( child: Center( @@ -190,6 +85,69 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { ); } + Widget buildGroupedList( + BuildContext context, List transactions) { + final Map> grouped = transactions.groupByDate(); + final List headers = grouped.keys + .map((date) => + TransactionListDateHeader(transactions: grouped[date]!, date: date)) + .toList(); + + return GroupedTransactionList( + controller: widget.scrollController, + header: SizedBox( + height: 200.0, + width: double.infinity, + child: Row( + children: [ + Expanded( + child: Column( + children: [ + const Expanded( + child: TotalBalanceCard(), + ), + const SizedBox(height: 16.0), + Expanded( + child: FlowTodayCard(transactions: transactions), + ), + ], + )), + const SizedBox(width: 16.0), + Expanded( + child: AnalyticsCard( + child: Padding( + padding: const EdgeInsets.all(16.0).copyWith(top: 12.0), + child: Column( + children: [ + Text( + "tabs.home.last7days".t(context), + style: context.textTheme.headlineSmall, + ), + const SizedBox(height: 8.0), + Expanded( + child: FlowSeparateLineChart( + transactions: transactions, + startDate: startDate, + endDate: DateTime.now(), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + transactions: grouped.values.toList(), + headers: headers, + listPadding: const EdgeInsets.only( + top: 0, + bottom: 80.0, + ), + ); + } + @override bool get wantKeepAlive => true; } diff --git a/lib/routes/transactions_page.dart b/lib/routes/transactions_page.dart index f4a99f3..e2b7d06 100644 --- a/lib/routes/transactions_page.dart +++ b/lib/routes/transactions_page.dart @@ -1,8 +1,10 @@ import 'package:flow/entity/transaction.dart'; import 'package:flow/objectbox.dart'; +import 'package:flow/objectbox/actions.dart'; import 'package:flow/objectbox/objectbox.g.dart'; import 'package:flow/widgets/general/spinner.dart'; import 'package:flow/widgets/grouped_transaction_list.dart'; +import 'package:flow/widgets/home/transactions_date_header.dart'; import 'package:flutter/material.dart'; class TransactionsPage extends StatefulWidget { @@ -44,8 +46,16 @@ class _TransactionsPageState extends State { return const Spinner.center(); } + final grouped = snapshot.data!.find().groupByDate(); + final headers = grouped.keys + .map((date) => TransactionListDateHeader( + transactions: grouped[date]!, date: date)) + .toList(); + return GroupedTransactionList( - transactions: snapshot.data!.find()); + transactions: grouped.values.toList(), + headers: headers, + ); }, ), )); diff --git a/lib/widgets/grouped_transaction_list.dart b/lib/widgets/grouped_transaction_list.dart index dc75fc3..6783edf 100644 --- a/lib/widgets/grouped_transaction_list.dart +++ b/lib/widgets/grouped_transaction_list.dart @@ -3,51 +3,51 @@ import 'package:flow/l10n/extensions.dart'; import 'package:flow/objectbox/actions.dart'; import 'package:flow/prefs.dart'; import 'package:flow/utils/utils.dart'; -import 'package:flow/widgets/home/transactions_date_header.dart'; import 'package:flow/widgets/transaction_list_tile.dart'; import 'package:flutter/widgets.dart'; class GroupedTransactionList extends StatelessWidget { final EdgeInsets listPadding; final EdgeInsets itemPadding; - final List transactions; + final List> transactions; + final List headers; final ScrollController? controller; + final Widget? header; + const GroupedTransactionList({ super.key, required this.transactions, + required this.headers, this.listPadding = const EdgeInsets.symmetric(vertical: 16.0), this.itemPadding = const EdgeInsets.symmetric( horizontal: 16.0, vertical: 4.0, ), this.controller, - }); + this.header, + }) : assert(headers.length == transactions.length); @override Widget build(BuildContext context) { final bool combineTransfers = LocalPreferences().combineTransferTransactions.get(); - final Map> grouped = transactions.groupByDate(); final List flattened = [ - for (final date in grouped.keys) ...[ - date, - ...grouped[date]!, - ], + if (header != null) header!, + ...List.generate(transactions.length, + (index) => [headers[index], ...transactions[index]]) + .expand((element) => element), ]; return ListView.builder( controller: controller, padding: listPadding.copyWith(bottom: listPadding.bottom), itemBuilder: (context, index) => switch (flattened[index]) { - (DateTime date) => Padding( + (Widget header) => Padding( padding: itemPadding.copyWith(top: index == 0 ? 8.0 : 24.0), - child: TransactionListDateHeader( - transactions: grouped[date]!, - date: date, - ), + child: header, ), (Transaction transaction) => TransactionListTile( combineTransfers: combineTransfers, diff --git a/lib/widgets/home/home/flow_graph.dart b/lib/widgets/home/home/flow_graph.dart new file mode 100644 index 0000000..17b8aa4 --- /dev/null +++ b/lib/widgets/home/home/flow_graph.dart @@ -0,0 +1,43 @@ +import 'package:flow/entity/transaction.dart'; +import 'package:flow/l10n/flow_localizations.dart'; +import 'package:flow/theme/theme.dart'; +import 'package:flow/widgets/home/home/analytics_card.dart'; +import 'package:flow/widgets/home/home/flow_separate_line_chart.dart'; +import 'package:flutter/material.dart'; + +class FlowGraph extends StatelessWidget { + final DateTime startDate; + + final List? transactions; + + const FlowGraph({ + super.key, + this.transactions, + required this.startDate, + }); + + @override + Widget build(BuildContext context) { + return AnalyticsCard( + child: Padding( + padding: const EdgeInsets.all(16.0).copyWith(top: 12.0), + child: Column( + children: [ + Text( + "tabs.home.last7days".t(context), + style: context.textTheme.headlineSmall, + ), + const SizedBox(height: 8.0), + Expanded( + child: FlowSeparateLineChart( + transactions: transactions ?? [], + startDate: startDate, + endDate: DateTime.now(), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/home/home/flow_separate_line_chart.dart b/lib/widgets/home/home/flow_separate_line_chart.dart index d79b2f4..fcd4304 100644 --- a/lib/widgets/home/home/flow_separate_line_chart.dart +++ b/lib/widgets/home/home/flow_separate_line_chart.dart @@ -54,14 +54,51 @@ class _FlowSeparateLineChartState extends State { return LineChart( data, curve: Curves.easeOut, + duration: const Duration(milliseconds: 250), ); } void updateData() { + final List incomeSpots = transactions.incomes + .map( + (e) => FlSpot( + e.transactionDate.microsecondsSinceEpoch.toDouble(), + e.amount, + ), + ) + .toList(); + + final List expenseSpots = transactions.expenses + .map( + (e) => FlSpot( + e.transactionDate.microsecondsSinceEpoch.toDouble(), + e.amount.abs(), + ), + ) + .toList(); + data = LineChartData( - titlesData: const FlTitlesData(show: false), + titlesData: FlTitlesData( + leftTitles: const AxisTitles(), + rightTitles: const AxisTitles(), + topTitles: const AxisTitles(), + bottomTitles: AxisTitles( + drawBelowEverything: false, + sideTitles: SideTitles( + reservedSize: 18.0, + showTitles: true, + interval: const Duration(days: 1).inMicroseconds.toDouble(), + getTitlesWidget: (value, meta) => Text( + value % const Duration(days: 1).inMicroseconds == 0 + ? Moment.fromMicrosecondsSinceEpoch(value.toInt()).format('D') + : '', + style: context.textTheme.labelSmall?.semi(context), + ), + ), + ), + ), borderData: FlBorderData(show: false), - clipData: const FlClipData.all(), + clipData: const FlClipData.none(), gridData: FlGridData( verticalInterval: const Duration(days: 1).inMicroseconds.toDouble(), ), @@ -77,28 +114,24 @@ class _FlowSeparateLineChartState extends State { maxX: widget.endDate.endOfDay().microsecondsSinceEpoch.toDouble() + 1, lineBarsData: [ LineChartBarData( + isCurved: true, + preventCurveOverShooting: true, + curveSmoothness: 0.25, + barWidth: 4.0, + dotData: const FlDotData(show: false), color: context.flowColors.expense, - spots: transactions.expenses - .map( - (e) => FlSpot( - e.transactionDate.microsecondsSinceEpoch.toDouble(), - e.amount.abs(), - ), - ) - .toList(), + spots: expenseSpots, isStrokeCapRound: true, isStrokeJoinRound: true, ), LineChartBarData( + isCurved: true, + preventCurveOverShooting: true, + curveSmoothness: 0.25, + barWidth: 4.0, + dotData: const FlDotData(show: false), color: context.flowColors.income, - spots: transactions.incomes - .map( - (e) => FlSpot( - e.transactionDate.microsecondsSinceEpoch.toDouble(), - e.amount, - ), - ) - .toList(), + spots: incomeSpots, isStrokeCapRound: true, isStrokeJoinRound: true, ), diff --git a/lib/widgets/home/home/flow_today_card.dart b/lib/widgets/home/home/flow_today_card.dart new file mode 100644 index 0000000..1ddba16 --- /dev/null +++ b/lib/widgets/home/home/flow_today_card.dart @@ -0,0 +1,52 @@ +import 'package:flow/entity/transaction.dart'; +import 'package:flow/l10n/flow_localizations.dart'; +import 'package:flow/objectbox/actions.dart'; +import 'package:flow/theme/theme.dart'; +import 'package:flow/widgets/home/home/analytics_card.dart'; +import 'package:flutter/material.dart'; +import 'package:moment_dart/moment_dart.dart'; + +class FlowTodayCard extends StatelessWidget { + final List? transactions; + + const FlowTodayCard({super.key, this.transactions}); + + @override + Widget build(BuildContext context) { + final double flow = transactions == null + ? 0 + : transactions! + .where((element) => + element.transactionDate >= DateTime.now().startOfDay() && + element.transactionDate <= DateTime.now()) + .sum; + + return AnalyticsCard( + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 12.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "tabs.home.flowToday".t(context), + style: context.textTheme.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Flexible( + child: Text( + flow.moneyCompact, + style: context.textTheme.displaySmall, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/home/home/total_balance_card.dart b/lib/widgets/home/home/total_balance_card.dart new file mode 100644 index 0000000..25aaeb6 --- /dev/null +++ b/lib/widgets/home/home/total_balance_card.dart @@ -0,0 +1,40 @@ +import 'package:flow/l10n/flow_localizations.dart'; +import 'package:flow/objectbox.dart'; +import 'package:flow/objectbox/actions.dart'; +import 'package:flow/theme/theme.dart'; +import 'package:flow/widgets/home/home/analytics_card.dart'; +import 'package:flutter/material.dart'; + +class TotalBalanceCard extends StatelessWidget { + const TotalBalanceCard({super.key}); + + @override + Widget build(BuildContext context) { + return AnalyticsCard( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 12.0, + ), + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "tabs.home.totalBalance".t(context), + style: context.textTheme.bodyLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Flexible( + child: Text( + ObjectBox().getTotalBalance().moneyCompact, + style: context.textTheme.displaySmall, + ), + ), + ], + ), + ), + ); + } +} From 69b1367d3dd023a2af1df1a30cba23ae13b92352 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Thu, 29 Feb 2024 19:50:42 +0800 Subject: [PATCH 06/32] chore: temporarily remove home tab stats --- lib/routes/home/home_tab.dart | 52 +---------------------------------- 1 file changed, 1 insertion(+), 51 deletions(-) diff --git a/lib/routes/home/home_tab.dart b/lib/routes/home/home_tab.dart index 2edfc97..0f6321a 100644 --- a/lib/routes/home/home_tab.dart +++ b/lib/routes/home/home_tab.dart @@ -1,16 +1,10 @@ import 'package:flow/entity/transaction.dart'; -import 'package:flow/l10n/flow_localizations.dart'; import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/actions.dart'; import 'package:flow/objectbox/objectbox.g.dart'; -import 'package:flow/theme/theme.dart'; -import 'package:flow/widgets/home/home/analytics_card.dart'; -import 'package:flow/widgets/home/home/flow_separate_line_chart.dart'; -import 'package:flow/widgets/home/home/flow_today_card.dart'; import 'package:flow/widgets/home/home/no_transactions.dart'; import 'package:flow/widgets/home/greetings_bar.dart'; import 'package:flow/widgets/grouped_transaction_list.dart'; -import 'package:flow/widgets/home/home/total_balance_card.dart'; import 'package:flow/widgets/home/transactions_date_header.dart'; import 'package:flutter/material.dart'; import 'package:moment_dart/moment_dart.dart'; @@ -26,7 +20,7 @@ class HomeTab extends StatefulWidget { class _HomeTabState extends State with AutomaticKeepAliveClientMixin { final DateTime startDate = - Moment.now().subtract(const Duration(days: 6)).startOfDay(); + Moment.now().subtract(const Duration(days: 29)).startOfDay(); // Last 7 days, and planned payments, newest to oldest QueryBuilder qb() => ObjectBox() @@ -95,50 +89,6 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { return GroupedTransactionList( controller: widget.scrollController, - header: SizedBox( - height: 200.0, - width: double.infinity, - child: Row( - children: [ - Expanded( - child: Column( - children: [ - const Expanded( - child: TotalBalanceCard(), - ), - const SizedBox(height: 16.0), - Expanded( - child: FlowTodayCard(transactions: transactions), - ), - ], - )), - const SizedBox(width: 16.0), - Expanded( - child: AnalyticsCard( - child: Padding( - padding: const EdgeInsets.all(16.0).copyWith(top: 12.0), - child: Column( - children: [ - Text( - "tabs.home.last7days".t(context), - style: context.textTheme.headlineSmall, - ), - const SizedBox(height: 8.0), - Expanded( - child: FlowSeparateLineChart( - transactions: transactions, - startDate: startDate, - endDate: DateTime.now(), - ), - ), - ], - ), - ), - ), - ), - ], - ), - ), transactions: grouped.values.toList(), headers: headers, listPadding: const EdgeInsets.only( From fd1274d740c44bd287655051151cdecbe3bfb8d4 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Thu, 29 Feb 2024 19:50:51 +0800 Subject: [PATCH 07/32] chore: add time range util --- lib/utils/time_range.dart | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 lib/utils/time_range.dart diff --git a/lib/utils/time_range.dart b/lib/utils/time_range.dart new file mode 100644 index 0000000..009a374 --- /dev/null +++ b/lib/utils/time_range.dart @@ -0,0 +1,19 @@ +import 'package:moment_dart/moment_dart.dart'; + +typedef TimeRange = ({DateTime from, DateTime to}); + +TimeRange thisWeek([DateTime? anchor]) { + final now = anchor ?? DateTime.now(); + final from = now.startOfLocalWeek(); + final to = now.endOfLocalWeek(); + + return (from: from, to: to); +} + +TimeRange thisMonth([DateTime? anchor]) { + final now = anchor ?? DateTime.now(); + final from = now.startOfMonth(); + final to = now.endOfMonth(); + + return (from: from, to: to); +} From 6a388c6fb66c1e779f182a489a4b1f37a6bf9972 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Thu, 29 Feb 2024 19:51:10 +0800 Subject: [PATCH 08/32] refactor: analytics data class --- lib/data/flow_analytics.dart | 16 ++++++++++++++++ lib/objectbox/actions.dart | 33 +++++++++++++++++++++------------ 2 files changed, 37 insertions(+), 12 deletions(-) create mode 100644 lib/data/flow_analytics.dart diff --git a/lib/data/flow_analytics.dart b/lib/data/flow_analytics.dart new file mode 100644 index 0000000..446069f --- /dev/null +++ b/lib/data/flow_analytics.dart @@ -0,0 +1,16 @@ +import 'package:flow/data/money_flow.dart'; + +class FlowAnalytics { + final DateTime from; + final DateTime to; + + final Map flow; + final Map groupData; + + const FlowAnalytics({ + required this.from, + required this.to, + required this.flow, + required this.groupData, + }); +} diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index a607f60..6794bd5 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -1,5 +1,6 @@ import 'dart:developer'; +import 'package:flow/data/flow_analytics.dart'; import 'package:flow/data/money_flow.dart'; import 'package:flow/data/prefs/frecency_group.dart'; import 'package:flow/entity/account.dart'; @@ -82,20 +83,22 @@ extension MainActions on ObjectBox { } /// Returns a map of category uuid -> [MoneyFlow] - Future> flowByCategories({ + Future> flowByCategories({ required DateTime from, required DateTime to, }) async { - final Query transactionsQuery = ObjectBox() - .box() - .query(Transaction_.transactionDate.betweenDate(from, to)) - .build(); + final Condition dateFilter = + Transaction_.transactionDate.betweenDate(from, to); + + final Query transactionsQuery = + ObjectBox().box().query(dateFilter).build(); final List transactions = await transactionsQuery.findAsync(); transactionsQuery.close(); final Map flow = {}; + final Map categories = {}; for (final transaction in transactions) { final String categoryUuid = @@ -103,26 +106,30 @@ extension MainActions on ObjectBox { flow[categoryUuid] ??= MoneyFlow(); flow[categoryUuid]!.add(transaction.amount); + + categories[categoryUuid] ??= transaction.category.target; } - return flow; + return FlowAnalytics(flow: flow, groupData: categories, from: from, to: to); } /// Returns a map of category uuid -> [MoneyFlow] - Future> flowByAccounts({ + Future> flowByAccounts({ required DateTime from, required DateTime to, }) async { - final Query transactionsQuery = ObjectBox() - .box() - .query(Transaction_.transactionDate.betweenDate(from, to)) - .build(); + final Condition dateFilter = + Transaction_.transactionDate.betweenDate(from, to); + + final Query transactionsQuery = + ObjectBox().box().query(dateFilter).build(); final List transactions = await transactionsQuery.findAsync(); transactionsQuery.close(); final Map flow = {}; + final Map accounts = {}; for (final transaction in transactions) { final String accountUuid = @@ -130,12 +137,14 @@ extension MainActions on ObjectBox { flow[accountUuid] ??= MoneyFlow(); flow[accountUuid]!.add(transaction.amount); + + accounts[accountUuid] ??= transaction.account.target; } assert(!flow.containsKey(Uuid.NAMESPACE_NIL), "There is no way you've managed to make a transaction without an account"); - return flow; + return FlowAnalytics(from: from, to: to, flow: flow, groupData: accounts); } } From e72be926920eadc7ff03886852090b0aff387074 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Thu, 29 Feb 2024 19:51:22 +0800 Subject: [PATCH 09/32] feat: month selector bar widget --- lib/widgets/month_selector_bar.dart | 90 +++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 lib/widgets/month_selector_bar.dart diff --git a/lib/widgets/month_selector_bar.dart b/lib/widgets/month_selector_bar.dart new file mode 100644 index 0000000..735a76a --- /dev/null +++ b/lib/widgets/month_selector_bar.dart @@ -0,0 +1,90 @@ +import 'package:flow/widgets/button.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:moment_dart/moment_dart.dart'; +import 'package:month_picker_dialog/month_picker_dialog.dart'; + +class MonthSelectorBar extends StatelessWidget { + /// If specified, used instead of `DateTime.now` + final DateTime? anchor; + + final int year; + final int month; + + final Function(int year, int month)? onUpdate; + + const MonthSelectorBar({ + super.key, + required this.year, + required this.month, + this.onUpdate, + this.anchor, + }); + MonthSelectorBar.fromDate({ + super.key, + required DateTime value, + this.onUpdate, + this.anchor, + }) : year = value.year, + month = value.month; + + @override + Widget build(BuildContext context) { + final bool showYear = (anchor ?? DateTime.now()).year != year; + final String monthName = DateTime(year, month, 1) + .format(payload: showYear ? 'MMMM YYYY' : "MMMM"); + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + IconButton( + onPressed: prev, icon: const Icon(Symbols.chevron_left_rounded)), + const SizedBox(width: 8.0), + Expanded( + child: Button( + child: Text( + monthName, + textAlign: TextAlign.center, + ), + onTap: () => select(context), + ), + ), + // InkWell( + // onTap: () => select(context), + // child: Text(monthName), + // ), + const SizedBox(width: 8.0), + IconButton( + onPressed: next, icon: const Icon(Symbols.chevron_right_rounded)), + ], + ); + } + + void next() { + if (month == 12) { + onUpdate?.call(year + 1, 1); + } else { + onUpdate?.call(year, month + 1); + } + } + + void prev() { + if (month == 1) { + onUpdate?.call(year - 1, 12); + } else { + onUpdate?.call(year, month - 1); + } + } + + void select(BuildContext context) async { + final DateTime? value = await showMonthPicker( + context: context, + initialDate: DateTime(year, month), + ); + + if (value == null || onUpdate == null || !context.mounted) return; + + onUpdate!(value.year, value.month); + } +} From 4b4a15f5ea4e7177ddf17d14662ab25870aac99b Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Thu, 29 Feb 2024 19:51:34 +0800 Subject: [PATCH 10/32] chore: add month selector dialog as dep --- pubspec.lock | 24 ++++++++++++++++++++++++ pubspec.yaml | 1 + 2 files changed, 25 insertions(+) diff --git a/pubspec.lock b/pubspec.lock index 496ff4c..5dea289 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -701,6 +701,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + month_picker_dialog: + dependency: "direct main" + description: + name: month_picker_dialog + sha256: "480a19aac8db72bd89e3a34a651ea2e9e2551424e7a22ec52a79f7ffa927c622" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" objectbox: dependency: "direct main" description: @@ -846,6 +862,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + provider: + dependency: transitive + description: + name: provider + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + url: "https://pub.dev" + source: hosted + version: "6.1.2" pub_semver: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5732ad0..1a070e4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: mask_text_input_formatter: ^2.8.0 material_symbols_icons: ^4.2719.1 moment_dart: ^1.1.1 + month_picker_dialog: ^2.11.0 objectbox: ^2.4.0 objectbox_flutter_libs: ^2.4.0 path: ^1.8.3 From 5c789152a3e2a1316d761bc139812fb117f27267 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Thu, 29 Feb 2024 19:51:56 +0800 Subject: [PATCH 11/32] chore: stats tab, pie chart draft --- lib/routes/home/stats_tab.dart | 100 ++++++++++++-------- lib/widgets/home/stats/group_pie_chart.dart | 55 +++++++++++ 2 files changed, 114 insertions(+), 41 deletions(-) create mode 100644 lib/widgets/home/stats/group_pie_chart.dart diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index 1816e00..6db6a23 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -1,9 +1,9 @@ -import 'dart:convert'; - -import 'package:flow/data/money_flow.dart'; +import 'package:flow/data/flow_analytics.dart'; import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/actions.dart'; import 'package:flow/widgets/general/spinner.dart'; +import 'package:flow/widgets/home/stats/group_pie_chart.dart'; +import 'package:flow/widgets/month_selector_bar.dart'; import 'package:flutter/material.dart'; import 'package:moment_dart/moment_dart.dart'; @@ -16,54 +16,72 @@ class StatsTab extends StatefulWidget { class _StatsTabState extends State with AutomaticKeepAliveClientMixin { - final DateTime from = DateTime.fromMillisecondsSinceEpoch(0); - final DateTime to = - DateTime.now().add(const Duration(days: 7)).startOfLocalWeek(); - // final DateTime from = DateTime.now().startOfLocalWeek(); - // final DateTime to = - // DateTime.now().add(const Duration(days: 7)).startOfLocalWeek(); + DateTime from = DateTime.now().startOfMonth(); + DateTime to = DateTime.now().endOfMonth(); + + FlowAnalytics? analytics; + + bool busy = false; - late final Future> categoriesFlow = - ObjectBox().flowByCategories(from: from, to: to); - late final Future> accountsFlow = - ObjectBox().flowByAccounts(from: from, to: to); + @override + void initState() { + super.initState(); + + fetch(true); + } @override Widget build(BuildContext context) { super.build(context); - return SingleChildScrollView( - child: Column( - children: [ - FutureBuilder( - future: categoriesFlow, - builder: (context, snapshot) => snapshot.data == null - ? const Spinner() - : Text( - jsonEncode( - snapshot.data!.map( - (key, value) => MapEntry(key, value.flow), - ), - ), - ), + return Column( + children: [ + Container( + padding: const EdgeInsets.all(16.0), + width: double.infinity, + child: MonthSelectorBar( + year: from.year, + month: from.month, + onUpdate: (year, month) => setState(() { + from = DateTime(year, month).startOfMonth(); + to = DateTime(year, month).endOfMonth(); + fetch(true); + }), ), - FutureBuilder( - future: accountsFlow, - builder: (context, snapshot) => snapshot.data == null - ? const Spinner() - : Text( - jsonEncode( - snapshot.data!.map( - (key, value) => MapEntry(key, value.flow), - ), - ), - ), - ), - ], - ), + ), + busy + ? const Spinner() + : AspectRatio( + aspectRatio: 1.0, + child: GroupPieChart( + data: analytics!.groupData, + flow: analytics!.flow, + ), + ), + ], ); } + Future fetch(bool byCategory) async { + if (busy) return; + + setState(() { + busy = true; + }); + + try { + analytics = byCategory + ? await ObjectBox().flowByCategories(from: from, to: to) + : await ObjectBox().flowByAccounts(from: from, to: to); + } finally { + busy = false; + + if (mounted) { + setState(() {}); + } + } + } + @override bool get wantKeepAlive => true; } diff --git a/lib/widgets/home/stats/group_pie_chart.dart b/lib/widgets/home/stats/group_pie_chart.dart new file mode 100644 index 0000000..e4d6113 --- /dev/null +++ b/lib/widgets/home/stats/group_pie_chart.dart @@ -0,0 +1,55 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flow/data/money_flow.dart'; +import 'package:flow/entity/account.dart'; +import 'package:flow/entity/category.dart'; +import 'package:flow/theme/theme.dart'; +import 'package:flow/widgets/general/flow_icon.dart'; +import 'package:flutter/material.dart'; + +class GroupPieChart extends StatelessWidget { + final Map flow; + final Map data; + + const GroupPieChart({super.key, required this.flow, required this.data}); + + @override + Widget build(BuildContext context) { + return PieChart( + PieChartData( + sectionsSpace: 8.0, + centerSpaceRadius: 48.0, + startDegreeOffset: -90, + sections: flow.entries + .map( + (e) => PieChartSectionData( + color: context.colorScheme.secondary, + radius: 80.0, + value: e.value.totalExpense.abs(), + title: resolveName(data[e.key]), + showTitle: false, + badgeWidget: resolveBadgeWidget(data[e.key]), + ), + ) + .toList(), + ), + ); + } + + String resolveName(Object? entity) => switch (entity) { + Category category => category.name, + Account account => account.name, + _ => "???" + }; + + Widget? resolveBadgeWidget(Object? entity) => switch (entity) { + Category category => FlowIcon( + category.icon, + plated: true, + ), + Account account => FlowIcon( + account.icon, + plated: true, + ), + _ => null, + }; +} From 1b349d0e0baa60ca39eade96f2992e4d4e61c63f Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Thu, 29 Feb 2024 19:55:55 +0800 Subject: [PATCH 12/32] fix: backup save button having repeated type --- assets/l10n/en_US.json | 5 +++-- assets/l10n/mn_MN.json | 3 ++- lib/routes/export_page.dart | 2 +- lib/sync/export/history/backup_entry_card.dart | 5 ++++- lib/widgets/export/export_success.dart | 2 +- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index 248d983..8735c45 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -180,7 +180,6 @@ "sync.import.emergencyBackup.successful": "Previous data was backed up. You can save the backup file from Backup > Backup history", "sync.import.start": "Start importing", "sync.import.success": "Import successful!", - "sync.export.fileDeleted": "File not found", "sync.export": "Export", "sync.export.type": "Export ({type})", @@ -197,7 +196,9 @@ "sync.export.success": "Export successful!", "sync.export.success.filePath[0]": "Saved to ", "sync.export.success.filePath[1]": "", - "sync.export.share": "Flow backup ({type}, {date})", + "sync.export.save": "Save backup", + "sync.export.save.shareTitle": "Flow backup ({type}, {date})", + "sync.export.fileDeleted": "File not found", "enum.TransactionSubtype": "Type", "enum.TransactionSubtype#null": "Default", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 423600f..dffd1dc 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -196,7 +196,8 @@ "sync.export.success": "Амжилттай өгөгдлийг нөөцөллөө!", "sync.export.success.filePath[0]": "Файлыг ", "sync.export.success.filePath[1]": "-т хадгаллаа", - "sync.export.share": "Flow нөөц ({type}, {date})", + "sync.export.save": "Нөөц хадгалах", + "sync.export.save.shareTitle": "Flow нөөц ({type}, {date})", "sync.export.fileDeleted": "Файл олдсонгүй", "enum.TransactionSubtype": "Төрөл", diff --git a/lib/routes/export_page.dart b/lib/routes/export_page.dart index dc3b915..272ca69 100644 --- a/lib/routes/export_page.dart +++ b/lib/routes/export_page.dart @@ -95,7 +95,7 @@ class _ExportPageState extends State { await Share.shareXFiles( [XFile(filePath!)], sharePositionOrigin: origin, - subject: "sync.export.share".t(context, { + subject: "sync.export.save .shareTitle".t(context, { "type": widget.mode.name, "date": Moment.now().lll, }), diff --git a/lib/sync/export/history/backup_entry_card.dart b/lib/sync/export/history/backup_entry_card.dart index 18b4dd8..92eee47 100644 --- a/lib/sync/export/history/backup_entry_card.dart +++ b/lib/sync/export/history/backup_entry_card.dart @@ -94,6 +94,9 @@ class BackupEntryCard extends StatelessWidget { await Share.shareXFiles([XFile(entry.filePath)], sharePositionOrigin: origin, - subject: "sync.export.share".t(context, entry.fileExt)); + subject: "sync.export.save.shareTitle".t(context, { + "type": entry.fileExt, + "date": entry.createdDate.toMoment().lll, + })); } } diff --git a/lib/widgets/export/export_success.dart b/lib/widgets/export/export_success.dart index 56132d5..2fe30dc 100644 --- a/lib/widgets/export/export_success.dart +++ b/lib/widgets/export/export_success.dart @@ -81,7 +81,7 @@ class ExportSuccess extends StatelessWidget { onTap: shareFn, leading: const Icon(Symbols.save_alt_rounded), child: Text( - "sync.export.share".t(context, mode.name), + "sync.export.save".t(context, mode.name), ), ) ], From 6b136d42eec569cf080c7591506cfb03c48d8a64 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Thu, 29 Feb 2024 19:56:16 +0800 Subject: [PATCH 13/32] chore: bump ver to 0.2.5+28 for testing --- lib/constants.dart | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/constants.dart b/lib/constants.dart index 905723c..a73eda4 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; -const appVersion = "0.2.4+27"; +const appVersion = "0.2.5+28"; const debugBuild = true; bool get flowDebugMode => kDebugMode || debugBuild; diff --git a/pubspec.yaml b/pubspec.yaml index 1a070e4..984b441 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: A personal finance managing app publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: "0.2.4+27" +version: "0.2.5+28" environment: sdk: ">=3.1.3 <4.0.0" From 746e7c25d493228e9fd0d8bd5722f24149d7c34f Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 3 Mar 2024 13:50:19 +0800 Subject: [PATCH 14/32] refactor: move widgets to general --- lib/routes/error_page.dart | 2 +- lib/routes/home/profile_tab.dart | 2 +- lib/routes/new_transaction/select_account_sheet.dart | 2 +- lib/routes/profile_page.dart | 2 +- lib/routes/setup/setup_accounts_page.dart | 4 ++-- lib/routes/setup/setup_categories_page.dart | 4 ++-- lib/routes/setup/setup_currency_page.dart | 2 +- lib/routes/setup/setup_profile_page.dart | 2 +- lib/routes/setup/setup_profile_picture_page.dart | 4 ++-- lib/routes/setup_page.dart | 2 +- lib/routes/support_page.dart | 2 +- lib/utils/utils.dart | 2 +- lib/widgets/categories/no_categories.dart | 2 +- lib/widgets/export/export_success.dart | 4 ++-- lib/widgets/{ => general}/button.dart | 0 lib/widgets/{ => general}/info_text.dart | 0 lib/widgets/{ => general}/profile_picture.dart | 0 lib/widgets/home/greetings_bar.dart | 2 +- lib/widgets/home/home/account/no_accounts.dart | 2 +- lib/widgets/home/prefs/profile_card.dart | 2 +- lib/widgets/import_wizard/import_success.dart | 2 +- lib/widgets/import_wizard/v1/backup_info.dart | 4 ++-- lib/widgets/month_selector_bar.dart | 2 +- 23 files changed, 25 insertions(+), 25 deletions(-) rename lib/widgets/{ => general}/button.dart (100%) rename lib/widgets/{ => general}/info_text.dart (100%) rename lib/widgets/{ => general}/profile_picture.dart (100%) diff --git a/lib/routes/error_page.dart b/lib/routes/error_page.dart index aa5bc8e..ea9625b 100644 --- a/lib/routes/error_page.dart +++ b/lib/routes/error_page.dart @@ -1,7 +1,7 @@ import 'package:flow/data/flow_icon.dart'; import 'package:flow/l10n/extensions.dart'; import 'package:flow/theme/theme.dart'; -import 'package:flow/widgets/button.dart'; +import 'package:flow/widgets/general/button.dart'; import 'package:flow/widgets/general/flow_icon.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/routes/home/profile_tab.dart b/lib/routes/home/profile_tab.dart index 00423d6..cff47f8 100644 --- a/lib/routes/home/profile_tab.dart +++ b/lib/routes/home/profile_tab.dart @@ -5,7 +5,7 @@ import 'package:flow/sync/import.dart'; import 'package:flow/theme/theme.dart'; import 'package:flow/utils/toast.dart'; import 'package:flow/utils/utils.dart'; -import 'package:flow/widgets/button.dart'; +import 'package:flow/widgets/general/button.dart'; import 'package:flow/widgets/general/list_header.dart'; import 'package:flow/widgets/home/prefs/profile_card.dart'; import 'package:flutter/material.dart'; diff --git a/lib/routes/new_transaction/select_account_sheet.dart b/lib/routes/new_transaction/select_account_sheet.dart index f9c6792..d925a20 100644 --- a/lib/routes/new_transaction/select_account_sheet.dart +++ b/lib/routes/new_transaction/select_account_sheet.dart @@ -1,6 +1,6 @@ import 'package:flow/entity/account.dart'; import 'package:flow/l10n/extensions.dart'; -import 'package:flow/widgets/button.dart'; +import 'package:flow/widgets/general/button.dart'; import 'package:flow/widgets/general/flow_icon.dart'; import 'package:flow/widgets/general/modal_sheet.dart'; import 'package:flutter/material.dart'; diff --git a/lib/routes/profile_page.dart b/lib/routes/profile_page.dart index 8059a9d..47c5c08 100644 --- a/lib/routes/profile_page.dart +++ b/lib/routes/profile_page.dart @@ -10,7 +10,7 @@ import 'package:flow/entity/profile.dart'; import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/objectbox.g.dart'; import 'package:flow/utils/utils.dart'; -import 'package:flow/widgets/profile_picture.dart'; +import 'package:flow/widgets/general/profile_picture.dart'; import 'package:flutter/material.dart'; class ProfilePage extends StatefulWidget { diff --git a/lib/routes/setup/setup_accounts_page.dart b/lib/routes/setup/setup_accounts_page.dart index 0ddad4e..49a7d18 100644 --- a/lib/routes/setup/setup_accounts_page.dart +++ b/lib/routes/setup/setup_accounts_page.dart @@ -4,8 +4,8 @@ import 'package:flow/l10n/extensions.dart'; import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/objectbox.g.dart'; import 'package:flow/prefs.dart'; -import 'package:flow/widgets/button.dart'; -import 'package:flow/widgets/info_text.dart'; +import 'package:flow/widgets/general/button.dart'; +import 'package:flow/widgets/general/info_text.dart'; import 'package:flow/widgets/setup/accounts/account_preset_card.dart'; import 'package:flow/widgets/setup/accounts/add_account_card.dart'; import 'package:flow/widgets/setup/setup_header.dart'; diff --git a/lib/routes/setup/setup_categories_page.dart b/lib/routes/setup/setup_categories_page.dart index 432c39c..9db84c2 100644 --- a/lib/routes/setup/setup_categories_page.dart +++ b/lib/routes/setup/setup_categories_page.dart @@ -6,9 +6,9 @@ import 'package:flow/objectbox/objectbox.g.dart'; import 'package:flow/utils/utils.dart'; import 'package:flow/utils/value_or.dart'; import 'package:flow/widgets/add_category_card.dart'; -import 'package:flow/widgets/button.dart'; +import 'package:flow/widgets/general/button.dart'; import 'package:flow/widgets/category_card.dart'; -import 'package:flow/widgets/info_text.dart'; +import 'package:flow/widgets/general/info_text.dart'; import 'package:flow/widgets/setup/categories/category_preset_card.dart'; import 'package:flow/widgets/setup/setup_header.dart'; diff --git a/lib/routes/setup/setup_currency_page.dart b/lib/routes/setup/setup_currency_page.dart index 6445bdc..01c4386 100644 --- a/lib/routes/setup/setup_currency_page.dart +++ b/lib/routes/setup/setup_currency_page.dart @@ -1,7 +1,7 @@ import 'package:flow/l10n/extensions.dart'; import 'package:flow/prefs.dart'; import 'package:flow/theme/theme.dart'; -import 'package:flow/widgets/button.dart'; +import 'package:flow/widgets/general/button.dart'; import 'package:flow/widgets/select_currency_sheet.dart'; import 'package:flow/widgets/setup/setup_header.dart'; import 'package:flutter/material.dart'; diff --git a/lib/routes/setup/setup_profile_page.dart b/lib/routes/setup/setup_profile_page.dart index 8a6f2f8..0e0f4be 100644 --- a/lib/routes/setup/setup_profile_page.dart +++ b/lib/routes/setup/setup_profile_page.dart @@ -3,7 +3,7 @@ import 'package:flow/form_validators.dart'; import 'package:flow/l10n/extensions.dart'; import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/objectbox.g.dart'; -import 'package:flow/widgets/button.dart'; +import 'package:flow/widgets/general/button.dart'; import 'package:flow/widgets/setup/setup_header.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/routes/setup/setup_profile_picture_page.dart b/lib/routes/setup/setup_profile_picture_page.dart index a76551e..5c543a3 100644 --- a/lib/routes/setup/setup_profile_picture_page.dart +++ b/lib/routes/setup/setup_profile_picture_page.dart @@ -5,8 +5,8 @@ import 'dart:ui' as ui; import 'package:flow/l10n/extensions.dart'; import 'package:flow/objectbox.dart'; import 'package:flow/utils/utils.dart'; -import 'package:flow/widgets/button.dart'; -import 'package:flow/widgets/profile_picture.dart'; +import 'package:flow/widgets/general/button.dart'; +import 'package:flow/widgets/general/profile_picture.dart'; import 'package:flow/widgets/setup/setup_header.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/routes/setup_page.dart b/lib/routes/setup_page.dart index 96cc95b..10732e3 100644 --- a/lib/routes/setup_page.dart +++ b/lib/routes/setup_page.dart @@ -1,6 +1,6 @@ import 'package:flow/l10n/extensions.dart'; import 'package:flow/theme/theme.dart'; -import 'package:flow/widgets/button.dart'; +import 'package:flow/widgets/general/button.dart'; import 'package:flow/widgets/setup/foss_slide.dart'; import 'package:flow/widgets/setup/offline_slide.dart'; import 'package:flow/widgets/setup/welcome_slide.dart'; diff --git a/lib/routes/support_page.dart b/lib/routes/support_page.dart index 5880afc..8fa1ebc 100644 --- a/lib/routes/support_page.dart +++ b/lib/routes/support_page.dart @@ -3,7 +3,7 @@ import 'package:flow/data/flow_icon.dart'; import 'package:flow/l10n/extensions.dart'; import 'package:flow/theme/theme.dart'; import 'package:flow/utils/utils.dart'; -import 'package:flow/widgets/button.dart'; +import 'package:flow/widgets/general/button.dart'; import 'package:flow/widgets/general/flow_icon.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 3a70302..478c202 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -7,7 +7,7 @@ import 'package:flow/l10n/extensions.dart'; import 'package:flow/routes/utils/crop_square_image_page.dart'; import 'package:flow/theme/theme.dart'; import 'package:flow/utils/toast.dart'; -import 'package:flow/widgets/button.dart'; +import 'package:flow/widgets/general/button.dart'; import 'package:flow/widgets/general/modal_sheet.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; diff --git a/lib/widgets/categories/no_categories.dart b/lib/widgets/categories/no_categories.dart index 1c9f1e6..d6adacb 100644 --- a/lib/widgets/categories/no_categories.dart +++ b/lib/widgets/categories/no_categories.dart @@ -1,7 +1,7 @@ import 'package:flow/data/flow_icon.dart'; import 'package:flow/l10n/extensions.dart'; import 'package:flow/theme/theme.dart'; -import 'package:flow/widgets/button.dart'; +import 'package:flow/widgets/general/button.dart'; import 'package:flow/widgets/general/flow_icon.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/widgets/export/export_success.dart b/lib/widgets/export/export_success.dart index 2fe30dc..b3895a0 100644 --- a/lib/widgets/export/export_success.dart +++ b/lib/widgets/export/export_success.dart @@ -5,9 +5,9 @@ import 'package:flow/sync/export/mode.dart'; import 'package:flow/theme/theme.dart'; import 'package:flow/utils/toast.dart'; import 'package:flow/utils/utils.dart'; -import 'package:flow/widgets/button.dart'; +import 'package:flow/widgets/general/button.dart'; import 'package:flow/widgets/general/flow_icon.dart'; -import 'package:flow/widgets/info_text.dart'; +import 'package:flow/widgets/general/info_text.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; diff --git a/lib/widgets/button.dart b/lib/widgets/general/button.dart similarity index 100% rename from lib/widgets/button.dart rename to lib/widgets/general/button.dart diff --git a/lib/widgets/info_text.dart b/lib/widgets/general/info_text.dart similarity index 100% rename from lib/widgets/info_text.dart rename to lib/widgets/general/info_text.dart diff --git a/lib/widgets/profile_picture.dart b/lib/widgets/general/profile_picture.dart similarity index 100% rename from lib/widgets/profile_picture.dart rename to lib/widgets/general/profile_picture.dart diff --git a/lib/widgets/home/greetings_bar.dart b/lib/widgets/home/greetings_bar.dart index a30dea0..93c02d4 100644 --- a/lib/widgets/home/greetings_bar.dart +++ b/lib/widgets/home/greetings_bar.dart @@ -2,7 +2,7 @@ import 'package:flow/entity/profile.dart'; import 'package:flow/l10n/extensions.dart'; import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/objectbox.g.dart'; -import 'package:flow/widgets/profile_picture.dart'; +import 'package:flow/widgets/general/profile_picture.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/widgets/home/home/account/no_accounts.dart b/lib/widgets/home/home/account/no_accounts.dart index d39941a..4a9692b 100644 --- a/lib/widgets/home/home/account/no_accounts.dart +++ b/lib/widgets/home/home/account/no_accounts.dart @@ -1,7 +1,7 @@ import 'package:flow/data/flow_icon.dart'; import 'package:flow/l10n/extensions.dart'; import 'package:flow/theme/theme.dart'; -import 'package:flow/widgets/button.dart'; +import 'package:flow/widgets/general/button.dart'; import 'package:flow/widgets/general/flow_icon.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/widgets/home/prefs/profile_card.dart b/lib/widgets/home/prefs/profile_card.dart index cd37ca7..b48ebee 100644 --- a/lib/widgets/home/prefs/profile_card.dart +++ b/lib/widgets/home/prefs/profile_card.dart @@ -1,7 +1,7 @@ import 'package:flow/entity/profile.dart'; import 'package:flow/objectbox.dart'; import 'package:flow/theme/theme.dart'; -import 'package:flow/widgets/profile_picture.dart'; +import 'package:flow/widgets/general/profile_picture.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:objectbox/objectbox.dart'; diff --git a/lib/widgets/import_wizard/import_success.dart b/lib/widgets/import_wizard/import_success.dart index 6e3d140..e5239f7 100644 --- a/lib/widgets/import_wizard/import_success.dart +++ b/lib/widgets/import_wizard/import_success.dart @@ -1,7 +1,7 @@ import 'package:flow/data/flow_icon.dart'; import 'package:flow/l10n/extensions.dart'; import 'package:flow/theme/theme.dart'; -import 'package:flow/widgets/button.dart'; +import 'package:flow/widgets/general/button.dart'; import 'package:flow/widgets/general/flow_icon.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/widgets/import_wizard/v1/backup_info.dart b/lib/widgets/import_wizard/v1/backup_info.dart index 3b453b2..f13c961 100644 --- a/lib/widgets/import_wizard/v1/backup_info.dart +++ b/lib/widgets/import_wizard/v1/backup_info.dart @@ -1,11 +1,11 @@ import 'package:flow/data/flow_icon.dart'; import 'package:flow/l10n/extensions.dart'; import 'package:flow/sync/import/import_v1.dart'; -import 'package:flow/widgets/button.dart'; +import 'package:flow/widgets/general/button.dart'; import 'package:flow/widgets/general/flow_icon.dart'; import 'package:flow/widgets/general/list_header.dart'; import 'package:flow/widgets/import_wizard/import_item_list_tile.dart'; -import 'package:flow/widgets/info_text.dart'; +import 'package:flow/widgets/general/info_text.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; diff --git a/lib/widgets/month_selector_bar.dart b/lib/widgets/month_selector_bar.dart index 735a76a..858ca6a 100644 --- a/lib/widgets/month_selector_bar.dart +++ b/lib/widgets/month_selector_bar.dart @@ -1,4 +1,4 @@ -import 'package:flow/widgets/button.dart'; +import 'package:flow/widgets/general/button.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:moment_dart/moment_dart.dart'; From 775e5c8447e03bf987f604f13723512278d7fb87 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 3 Mar 2024 18:21:01 +0800 Subject: [PATCH 15/32] range selector draft --- assets/l10n/en_US.json | 10 + assets/l10n/mn_MN.json | 10 + lib/routes/home/stats_tab.dart | 34 ++-- lib/utils/time_range.dart | 19 -- lib/widgets/select_time_range_mode_sheet.dart | 87 +++++++++ lib/widgets/time_range_selector.dart | 183 ++++++++++++++++++ pubspec.lock | 4 +- pubspec.yaml | 2 +- 8 files changed, 314 insertions(+), 35 deletions(-) delete mode 100644 lib/utils/time_range.dart create mode 100644 lib/widgets/select_time_range_mode_sheet.dart create mode 100644 lib/widgets/time_range_selector.dart diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index 8735c45..2d070ac 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -133,6 +133,16 @@ "tabs.home.flowToday": "Flow today", "tabs.stats": "Stats", + "tabs.stats.timeRange.select": "Select range", + "tabs.stats.timeRange.changeMode": "More options", + "tabs.stats.timeRange.presets": "Common options", + "tabs.stats.timeRange.thisWeek": "This week", + "tabs.stats.timeRange.thisMonth": "This month", + "tabs.stats.timeRange.thisYear": "This year", + "tabs.stats.timeRange.mode.custom": "Select custom range", + "tabs.stats.timeRange.mode.byWeek": "By week", + "tabs.stats.timeRange.mode.byMonth": "By month", + "tabs.stats.timeRange.mode.byYear": "By year", "tabs.accounts": "Accounts", "tabs.accounts.reorder": "Reorder accounts", "tabs.accounts.reorder.guide": "Long press and drag", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index dffd1dc..16cee53 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -133,6 +133,16 @@ "tabs.home.flowToday": "Өнөөдөр", "tabs.stats": "Тоо", + "tabs.stats.timeRange.select": "Хугацаа сонгох", + "tabs.stats.timeRange.changeMode": "Өөр сонголтууд", + "tabs.stats.timeRange.presets": "Түгээмэл сонголтууд", + "tabs.stats.timeRange.thisWeek": "Энэ долоо хоног", + "tabs.stats.timeRange.thisMonth": "Энэ сар", + "tabs.stats.timeRange.thisYear": "Энэ жил", + "tabs.stats.timeRange.mode.custom": "Эхлэх дуусах огноогоор", + "tabs.stats.timeRange.mode.byWeek": "Долоо хоногоор", + "tabs.stats.timeRange.mode.byMonth": "Сараар", + "tabs.stats.timeRange.mode.byYear": "Жилээр", "tabs.accounts": "Данснууд", "tabs.accounts.reorder": "Дараалал өөрчлөх", "tabs.accounts.reorder.guide": "Удаан дарж чирнэ үү", diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index 6db6a23..ccd7cd6 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -3,7 +3,7 @@ import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/actions.dart'; import 'package:flow/widgets/general/spinner.dart'; import 'package:flow/widgets/home/stats/group_pie_chart.dart'; -import 'package:flow/widgets/month_selector_bar.dart'; +import 'package:flow/widgets/time_range_selector.dart'; import 'package:flutter/material.dart'; import 'package:moment_dart/moment_dart.dart'; @@ -16,8 +16,7 @@ class StatsTab extends StatefulWidget { class _StatsTabState extends State with AutomaticKeepAliveClientMixin { - DateTime from = DateTime.now().startOfMonth(); - DateTime to = DateTime.now().endOfMonth(); + TimeRange range = TimeRange.thisMonth(); FlowAnalytics? analytics; @@ -39,14 +38,9 @@ class _StatsTabState extends State Container( padding: const EdgeInsets.all(16.0), width: double.infinity, - child: MonthSelectorBar( - year: from.year, - month: from.month, - onUpdate: (year, month) => setState(() { - from = DateTime(year, month).startOfMonth(); - to = DateTime(year, month).endOfMonth(); - fetch(true); - }), + child: TimeRangeSelector( + initialValue: range, + onChanged: updateRange, ), ), busy @@ -62,6 +56,14 @@ class _StatsTabState extends State ); } + void updateRange(TimeRange newRange) { + setState(() { + range = newRange; + }); + + fetch(true); + } + Future fetch(bool byCategory) async { if (busy) return; @@ -71,8 +73,8 @@ class _StatsTabState extends State try { analytics = byCategory - ? await ObjectBox().flowByCategories(from: from, to: to) - : await ObjectBox().flowByAccounts(from: from, to: to); + ? await ObjectBox().flowByCategories(from: range.from, to: range.to) + : await ObjectBox().flowByAccounts(from: range.from, to: range.to); } finally { busy = false; @@ -85,3 +87,9 @@ class _StatsTabState extends State @override bool get wantKeepAlive => true; } + +enum StatsTabTimeRangeMode { + thisWeek, + thisMonth, + custom, +} diff --git a/lib/utils/time_range.dart b/lib/utils/time_range.dart deleted file mode 100644 index 009a374..0000000 --- a/lib/utils/time_range.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:moment_dart/moment_dart.dart'; - -typedef TimeRange = ({DateTime from, DateTime to}); - -TimeRange thisWeek([DateTime? anchor]) { - final now = anchor ?? DateTime.now(); - final from = now.startOfLocalWeek(); - final to = now.endOfLocalWeek(); - - return (from: from, to: to); -} - -TimeRange thisMonth([DateTime? anchor]) { - final now = anchor ?? DateTime.now(); - final from = now.startOfMonth(); - final to = now.endOfMonth(); - - return (from: from, to: to); -} diff --git a/lib/widgets/select_time_range_mode_sheet.dart b/lib/widgets/select_time_range_mode_sheet.dart new file mode 100644 index 0000000..8063431 --- /dev/null +++ b/lib/widgets/select_time_range_mode_sheet.dart @@ -0,0 +1,87 @@ +import 'package:flow/l10n/flow_localizations.dart'; +import 'package:flow/theme/theme.dart'; +import 'package:flow/widgets/general/modal_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +enum TimeRangeMode { + thisWeek, + thisMonth, + thisYear, + byMonth, + byYear, + custom, +} + +class SelectTimeRangeModeSheet extends StatelessWidget { + const SelectTimeRangeModeSheet({super.key}); + + @override + Widget build(BuildContext context) { + final double scrollableContentMaxHeight = + MediaQuery.of(context).size.height * 0.4; + + return ModalSheet.scrollable( + scrollableContentMaxHeight: scrollableContentMaxHeight, + title: Text("tabs.stats.timeRange.select".t(context)), + trailing: ButtonBar( + children: [ + TextButton.icon( + onPressed: () => context.pop(null), + icon: const Icon(Symbols.close_rounded), + label: Text("general.cancel".t(context)), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Text( + "tabs.stats.timeRange.presets".t(context), + style: context.textTheme.labelMedium, + ), + ), + const SizedBox(height: 8.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Wrap( + spacing: 12.0, + runSpacing: 8.0, + children: [ + ActionChip( + label: Text("tabs.stats.timeRange.thisWeek".t(context)), + onPressed: () => context.pop(TimeRangeMode.thisWeek), + ), + ActionChip( + label: Text("tabs.stats.timeRange.thisMonth".t(context)), + onPressed: () => context.pop(TimeRangeMode.thisMonth), + ), + ActionChip( + label: Text("tabs.stats.timeRange.thisYear".t(context)), + onPressed: () => context.pop(TimeRangeMode.thisYear), + ), + ], + ), + ), + const SizedBox(height: 8.0), + ListTile( + title: Text("tabs.stats.timeRange.mode.byMonth".t(context)), + onTap: () => context.pop(TimeRangeMode.byMonth), + ), + ListTile( + title: Text("tabs.stats.timeRange.mode.byYear".t(context)), + onTap: () => context.pop(TimeRangeMode.byYear), + ), + ListTile( + title: Text("tabs.stats.timeRange.mode.custom".t(context)), + onTap: () => context.pop(TimeRangeMode.custom), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/time_range_selector.dart b/lib/widgets/time_range_selector.dart new file mode 100644 index 0000000..e70d88d --- /dev/null +++ b/lib/widgets/time_range_selector.dart @@ -0,0 +1,183 @@ +import 'package:flow/l10n/flow_localizations.dart'; +import 'package:flow/widgets/general/button.dart'; +import 'package:flow/widgets/select_time_range_mode_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:moment_dart/moment_dart.dart'; + +/// Defaults to the current month +class TimeRangeSelector extends StatefulWidget { + final TimeRange initialValue; + + final Function(TimeRange) onChanged; + + const TimeRangeSelector({ + super.key, + required this.initialValue, + required this.onChanged, + }); + + @override + State createState() => _TimeRangeSelectorState(); +} + +class _TimeRangeSelectorState extends State { + late TimeRange _timeRange; + + @override + void initState() { + super.initState(); + + _timeRange = widget.initialValue; + } + + @override + void didUpdateWidget(TimeRangeSelector oldWidget) { + if (widget.initialValue != oldWidget.initialValue) { + setState(() { + _timeRange = widget.initialValue; + }); + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + final bool buildNextPrev = _timeRange is PageableRange; + + final String modeLabel = switch (_timeRange) { + LocalWeekTimeRange() => "tabs.stats.timeRange.mode.byWeek", + MonthTimeRange() => "tabs.stats.timeRange.mode.byMonth", + YearTimeRange() => "tabs.stats.timeRange.mode.byYear", + _ => "tabs.stats.timeRange.mode.custom", + } + .t(context); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + if (buildNextPrev) ...[ + IconButton( + icon: const Icon(Symbols.chevron_left), + onPressed: prev, + ), + const SizedBox(width: 12.0), + ], + Expanded( + child: switch (_timeRange) { + LocalWeekTimeRange localWeekTimeRange => Button( + onTap: selectRange, + child: Text( + "${localWeekTimeRange.from.toMoment().ll} -> ${localWeekTimeRange.to.toMoment().ll}", + textAlign: TextAlign.center, + ), + ), + MonthTimeRange monthTimeRange => Button( + child: Text( + monthTimeRange.from.format( + payload: + monthTimeRange.from.isAtSameYearAs(DateTime.now()) + ? "MMMM" + : "MMMM YYYY", + ), + textAlign: TextAlign.center, + ), + ), + YearTimeRange yearTimeRange => Button( + onTap: selectRange, + child: Text( + yearTimeRange.year.toString(), + textAlign: TextAlign.center, + ), + ), + _ => Button( + onTap: selectRange, + child: Text( + "${_timeRange.from.toMoment().ll} -> ${_timeRange.to.toMoment().ll}", + textAlign: TextAlign.center, + ), + ), + }, + ), + if (buildNextPrev) ...[ + const SizedBox(width: 12.0), + IconButton( + icon: const Icon(Symbols.chevron_right), + onPressed: next, + ), + ], + ], + ), + const SizedBox(height: 8.0), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(modeLabel), + TextButton( + onPressed: changeMode, + child: Text("tabs.stats.timeRange.changeMode".t(context)), + ), + ], + ), + ], + ); + } + + void update(TimeRange newValue) { + setState(() { + _timeRange = newValue; + }); + widget.onChanged(_timeRange); + } + + Future selectRange() async { + final range = await showDateRangePicker( + context: context, + firstDate: DateTime.fromMicrosecondsSinceEpoch(0), + lastDate: DateTime.now().startOfNextYear(), + initialDateRange: _timeRange is CustomTimeRange + ? DateTimeRange( + start: (_timeRange as CustomTimeRange).from, + end: (_timeRange as CustomTimeRange).to) + : null, + ); + + if (range != null) { + return CustomTimeRange(range.start, range.end); + } + + return null; + } + + Future changeMode() async { + final TimeRangeMode? mode = await showModalBottomSheet( + context: context, + builder: (BuildContext context) => const SelectTimeRangeModeSheet(), + ); + + if (mode == null) return; + + final TimeRange? newRange = switch (mode) { + TimeRangeMode.thisWeek => TimeRange.thisLocalWeek(), + TimeRangeMode.thisMonth => TimeRange.thisMonth(), + TimeRangeMode.thisYear => TimeRange.thisYear(), + TimeRangeMode.byYear => await selectRange(), + TimeRangeMode.byMonth => await selectRange(), + TimeRangeMode.custom => await selectRange(), + }; + + if (newRange == null) return; + if (!mounted) return; + + update(newRange); + } + + /// Assumes [_timeRange] is pageable + void next() => update((_timeRange as PageableRange).next); + + /// Assumes [_timeRange] is pageable + void prev() => update((_timeRange as PageableRange).last); +} diff --git a/pubspec.lock b/pubspec.lock index 5dea289..9749dab 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -697,10 +697,10 @@ packages: dependency: "direct main" description: name: moment_dart - sha256: aa2b9ac396a16681b8f4a73e48268d8b59dfe33202ae1f531c3a9c91096d66fc + sha256: "252e06594ad7d1f1815c18a9112b76451fcee05743667c0566e24b83a078b91d" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "2.0.2" month_picker_dialog: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 984b441..0c35cb2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,7 +33,7 @@ dependencies: local_settings: ^0.3.1 mask_text_input_formatter: ^2.8.0 material_symbols_icons: ^4.2719.1 - moment_dart: ^1.1.1 + moment_dart: ^2.0.2 month_picker_dialog: ^2.11.0 objectbox: ^2.4.0 objectbox_flutter_libs: ^2.4.0 From 5af35a401aab35158c566b5dbd3df8cdf357839d Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 3 Mar 2024 18:26:19 +0800 Subject: [PATCH 16/32] fix export share subject --- lib/routes/export_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/routes/export_page.dart b/lib/routes/export_page.dart index 272ca69..20250ba 100644 --- a/lib/routes/export_page.dart +++ b/lib/routes/export_page.dart @@ -95,7 +95,7 @@ class _ExportPageState extends State { await Share.shareXFiles( [XFile(filePath!)], sharePositionOrigin: origin, - subject: "sync.export.save .shareTitle".t(context, { + subject: "sync.export.save.shareTitle".t(context, { "type": widget.mode.name, "date": Moment.now().lll, }), From 18d595cf7f1b8e6a79c76a7322309911f8fa0cbd Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 3 Mar 2024 19:43:42 +0800 Subject: [PATCH 17/32] remove transfer/zeros, add time range selector draft --- lib/data/flow_analytics.dart | 4 +- lib/data/money_flow.dart | 7 +- lib/objectbox/actions.dart | 34 ++++-- lib/routes/home/stats_tab.dart | 25 ++-- lib/widgets/home/stats/group_pie_chart.dart | 113 +++++++++++++++---- lib/widgets/home/stats/legend_list_tile.dart | 31 +++++ 6 files changed, 170 insertions(+), 44 deletions(-) create mode 100644 lib/widgets/home/stats/legend_list_tile.dart diff --git a/lib/data/flow_analytics.dart b/lib/data/flow_analytics.dart index 446069f..8335941 100644 --- a/lib/data/flow_analytics.dart +++ b/lib/data/flow_analytics.dart @@ -4,13 +4,11 @@ class FlowAnalytics { final DateTime from; final DateTime to; - final Map flow; - final Map groupData; + final Map> flow; const FlowAnalytics({ required this.from, required this.to, required this.flow, - required this.groupData, }); } diff --git a/lib/data/money_flow.dart b/lib/data/money_flow.dart index 31fc8aa..74f904f 100644 --- a/lib/data/money_flow.dart +++ b/lib/data/money_flow.dart @@ -1,10 +1,15 @@ -class MoneyFlow implements Comparable { +class MoneyFlow implements Comparable { + final T? associatedData; + double totalExpense; double totalIncome; double get flow => totalExpense + totalIncome; + bool get isEmpty => totalExpense.abs() == 0.0 && totalIncome.abs() == 0.0; + MoneyFlow({ + this.associatedData, this.totalExpense = 0.0, this.totalIncome = 0.0, }); diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index 6794bd5..58f0e89 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -86,6 +86,8 @@ extension MainActions on ObjectBox { Future> flowByCategories({ required DateTime from, required DateTime to, + bool ignoreTransfers = true, + bool omitZeroes = true, }) async { final Condition dateFilter = Transaction_.transactionDate.betweenDate(from, to); @@ -97,26 +99,32 @@ extension MainActions on ObjectBox { transactionsQuery.close(); - final Map flow = {}; - final Map categories = {}; + final Map> flow = {}; for (final transaction in transactions) { + if (ignoreTransfers && transaction.isTransfer) continue; + final String categoryUuid = transaction.category.target?.uuid ?? Uuid.NAMESPACE_NIL; - flow[categoryUuid] ??= MoneyFlow(); + flow[categoryUuid] ??= + MoneyFlow(associatedData: transaction.category.target); flow[categoryUuid]!.add(transaction.amount); + } - categories[categoryUuid] ??= transaction.category.target; + if (omitZeroes) { + flow.removeWhere((key, value) => value.isEmpty); } - return FlowAnalytics(flow: flow, groupData: categories, from: from, to: to); + return FlowAnalytics(flow: flow, from: from, to: to); } /// Returns a map of category uuid -> [MoneyFlow] Future> flowByAccounts({ required DateTime from, required DateTime to, + bool ignoreTransfers = true, + bool omitZeroes = true, }) async { final Condition dateFilter = Transaction_.transactionDate.betweenDate(from, to); @@ -128,23 +136,27 @@ extension MainActions on ObjectBox { transactionsQuery.close(); - final Map flow = {}; - final Map accounts = {}; + final Map> flow = {}; for (final transaction in transactions) { + if (ignoreTransfers && transaction.isTransfer) continue; + final String accountUuid = transaction.account.target?.uuid ?? Uuid.NAMESPACE_NIL; - flow[accountUuid] ??= MoneyFlow(); + flow[accountUuid] ??= + MoneyFlow(associatedData: transaction.account.target); flow[accountUuid]!.add(transaction.amount); - - accounts[accountUuid] ??= transaction.account.target; } assert(!flow.containsKey(Uuid.NAMESPACE_NIL), "There is no way you've managed to make a transaction without an account"); - return FlowAnalytics(from: from, to: to, flow: flow, groupData: accounts); + if (omitZeroes) { + flow.removeWhere((key, value) => value.isEmpty); + } + + return FlowAnalytics(from: from, to: to, flow: flow); } } diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index ccd7cd6..03057d4 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -1,4 +1,5 @@ import 'package:flow/data/flow_analytics.dart'; +import 'package:flow/data/money_flow.dart'; import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/actions.dart'; import 'package:flow/widgets/general/spinner.dart'; @@ -33,6 +34,13 @@ class _StatsTabState extends State Widget build(BuildContext context) { super.build(context); + final Map data = analytics == null + ? {} + : Map.fromEntries( + analytics!.flow.entries + .where((element) => element.value.totalExpense < 0), + ); + return Column( children: [ Container( @@ -45,13 +53,16 @@ class _StatsTabState extends State ), busy ? const Spinner() - : AspectRatio( - aspectRatio: 1.0, - child: GroupPieChart( - data: analytics!.groupData, - flow: analytics!.flow, - ), - ), + : (data.isEmpty + ? const Text("mty") + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + GroupPieChart( + data: data, + ), + ], + )), ], ); } diff --git a/lib/widgets/home/stats/group_pie_chart.dart b/lib/widgets/home/stats/group_pie_chart.dart index e4d6113..e5a877a 100644 --- a/lib/widgets/home/stats/group_pie_chart.dart +++ b/lib/widgets/home/stats/group_pie_chart.dart @@ -1,37 +1,103 @@ import 'package:fl_chart/fl_chart.dart'; +import 'package:flow/data/flow_icon.dart'; import 'package:flow/data/money_flow.dart'; import 'package:flow/entity/account.dart'; import 'package:flow/entity/category.dart'; +import 'package:flow/l10n/flow_localizations.dart'; import 'package:flow/theme/theme.dart'; import 'package:flow/widgets/general/flow_icon.dart'; +import 'package:flow/widgets/home/stats/legend_list_tile.dart'; import 'package:flutter/material.dart'; -class GroupPieChart extends StatelessWidget { - final Map flow; - final Map data; +class GroupPieChart extends StatefulWidget { + final Map> data; - const GroupPieChart({super.key, required this.flow, required this.data}); + const GroupPieChart({super.key, required this.data}); + + @override + State> createState() => _GroupPieChartState(); +} + +class _GroupPieChartState extends State> { + late Map> data; + + bool expense = true; + + String? selectedKey; + + @override + void initState() { + super.initState(); + + data = widget.data; + } + + @override + void didUpdateWidget(GroupPieChart oldWidget) { + data = widget.data; + + super.didUpdateWidget(oldWidget); + } @override Widget build(BuildContext context) { - return PieChart( - PieChartData( - sectionsSpace: 8.0, - centerSpaceRadius: 48.0, - startDegreeOffset: -90, - sections: flow.entries - .map( - (e) => PieChartSectionData( - color: context.colorScheme.secondary, - radius: 80.0, - value: e.value.totalExpense.abs(), - title: resolveName(data[e.key]), - showTitle: false, - badgeWidget: resolveBadgeWidget(data[e.key]), + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + AspectRatio( + aspectRatio: 1.0, + child: PieChart( + PieChartData( + pieTouchData: PieTouchData(touchCallback: (event, response) { + if (!event.isInterestedForInteractions || + response == null || + response.touchedSection == null) { + setState(() { + selectedKey = null; + }); + return; + } + + final int index = response.touchedSection!.touchedSectionIndex; + + if (index > -1) { + selectedKey = data.entries.elementAt(index).key; + setState(() {}); + } + }), + sectionsSpace: 2.0, + centerSpaceRadius: 48.0, + sections: data.entries + .map((e) => sectionData(data[e.key]!, + showBadge: e.key == selectedKey)) + .toList(), + ), + ), + ), + ...data.entries.map((e) => LegendListTile( + key: ValueKey(e.key), + leading: resolveBadgeWidget(e.value.associatedData), + title: Text(resolveName(e.value.associatedData)), + trailing: Text( + e.value.totalExpense.moneyCompact, + style: context.textTheme.bodyLarge, ), - ) - .toList(), - ), + selected: e.key == selectedKey, + onTap: () => setState(() => selectedKey = e.key), + )) + ], + ); + } + + PieChartSectionData sectionData(MoneyFlow flow, {bool showBadge = false}) { + return PieChartSectionData( + color: context.colorScheme.secondary, + radius: 80.0, + value: flow.totalExpense.abs(), + title: resolveName(flow.associatedData), + showTitle: false, + badgeWidget: showBadge ? resolveBadgeWidget(flow.associatedData) : null, + badgePositionPercentageOffset: 1.0, ); } @@ -50,6 +116,9 @@ class GroupPieChart extends StatelessWidget { account.icon, plated: true, ), - _ => null, + _ => FlowIcon( + FlowIconData.emoji("?"), + plated: true, + ), }; } diff --git a/lib/widgets/home/stats/legend_list_tile.dart b/lib/widgets/home/stats/legend_list_tile.dart new file mode 100644 index 0000000..3c3e3db --- /dev/null +++ b/lib/widgets/home/stats/legend_list_tile.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class LegendListTile extends StatelessWidget { + final bool selected; + + final Widget? leading; + final Widget? title; + final Widget? trailing; + + final VoidCallback? onTap; + + const LegendListTile({ + super.key, + this.leading, + this.title, + this.trailing, + this.onTap, + this.selected = false, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + onTap: onTap, + leading: leading, + title: title, + trailing: trailing, + selected: selected, + ); + } +} From 42c38eb8ab3246766da0193cf99635f8cbfe6b67 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 3 Mar 2024 19:44:05 +0800 Subject: [PATCH 18/32] chore: bump ver 0.2.6+29 --- lib/constants.dart | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/constants.dart b/lib/constants.dart index a73eda4..84a243f 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; -const appVersion = "0.2.5+28"; +const appVersion = "0.2.6+29"; const debugBuild = true; bool get flowDebugMode => kDebugMode || debugBuild; diff --git a/pubspec.yaml b/pubspec.yaml index 0c35cb2..89245fc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: A personal finance managing app publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: "0.2.5+28" +version: "0.2.6+29" environment: sdk: ">=3.1.3 <4.0.0" From 6941352ac9957b066395a92eb497d55e3d0167d4 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Mon, 4 Mar 2024 17:38:07 +0800 Subject: [PATCH 19/32] fix ko-fi link --- lib/routes/support_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/routes/support_page.dart b/lib/routes/support_page.dart index 8fa1ebc..49c32b4 100644 --- a/lib/routes/support_page.dart +++ b/lib/routes/support_page.dart @@ -139,7 +139,7 @@ class SupportPage extends StatelessWidget { "support.donateDeveloper.action".t(context), ), ), - onTap: () => openUrl(flowGitHubRepoLink), + onTap: () => openUrl(maintainerKoFiLink), ) ], ), From c479e46fc45a15936fe6cfb2328f960db1e3b566 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Tue, 5 Mar 2024 18:35:00 +0800 Subject: [PATCH 20/32] chore: update table --- lib/objectbox/objectbox-model.json | 6 ++++-- lib/objectbox/objectbox.g.dart | 8 +++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/objectbox/objectbox-model.json b/lib/objectbox/objectbox-model.json index d155a02..040cedf 100644 --- a/lib/objectbox/objectbox-model.json +++ b/lib/objectbox/objectbox-model.json @@ -58,7 +58,7 @@ }, { "id": "2:649350347514211469", - "lastPropertyId": "6:7989789340130049283", + "lastPropertyId": "8:2832069050067036408", "name": "Category", "properties": [ { @@ -283,7 +283,9 @@ 8163231449257835161, 8413544260372569748, 5446772239174299489, - 3056128952161562633 + 3056128952161562633, + 2675470948342446870, + 2832069050067036408 ], "retiredRelationUids": [], "version": 1 diff --git a/lib/objectbox/objectbox.g.dart b/lib/objectbox/objectbox.g.dart index c609a9e..0614c6c 100644 --- a/lib/objectbox/objectbox.g.dart +++ b/lib/objectbox/objectbox.g.dart @@ -81,7 +81,7 @@ final _entities = [ obx_int.ModelEntity( id: const obx_int.IdUid(2, 649350347514211469), name: 'Category', - lastPropertyId: const obx_int.IdUid(6, 7989789340130049283), + lastPropertyId: const obx_int.IdUid(8, 2832069050067036408), flags: 0, properties: [ obx_int.ModelProperty( @@ -335,7 +335,9 @@ obx_int.ModelDefinition getObjectBoxModel() { 8163231449257835161, 8413544260372569748, 5446772239174299489, - 3056128952161562633 + 3056128952161562633, + 2675470948342446870, + 2832069050067036408 ], retiredRelationUids: const [], modelVersion: 5, @@ -422,7 +424,7 @@ obx_int.ModelDefinition getObjectBoxModel() { final uuidOffset = fbb.writeString(object.uuid); final nameOffset = fbb.writeString(object.name); final iconCodeOffset = fbb.writeString(object.iconCode); - fbb.startTable(7); + fbb.startTable(9); fbb.addInt64(0, object.id); fbb.addOffset(1, uuidOffset); fbb.addInt64(2, object.createdDate.millisecondsSinceEpoch); From 921e1308080d03c75cab28bae745f77ef1551cce Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Tue, 5 Mar 2024 18:35:14 +0800 Subject: [PATCH 21/32] chore: add color to legend list tile --- lib/widgets/home/stats/legend_list_tile.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/widgets/home/stats/legend_list_tile.dart b/lib/widgets/home/stats/legend_list_tile.dart index 3c3e3db..0e2e384 100644 --- a/lib/widgets/home/stats/legend_list_tile.dart +++ b/lib/widgets/home/stats/legend_list_tile.dart @@ -7,6 +7,8 @@ class LegendListTile extends StatelessWidget { final Widget? title; final Widget? trailing; + final Color? color; + final VoidCallback? onTap; const LegendListTile({ @@ -15,6 +17,7 @@ class LegendListTile extends StatelessWidget { this.title, this.trailing, this.onTap, + this.color, this.selected = false, }); @@ -26,6 +29,7 @@ class LegendListTile extends StatelessWidget { title: title, trailing: trailing, selected: selected, + iconColor: color, ); } } From e87cc8d369ac06962675462cacbb377f2bacaf2f Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Tue, 5 Mar 2024 18:35:31 +0800 Subject: [PATCH 22/32] chore: add colors to tab --- lib/theme/colors.py | 91 ++++++++++++++++++++ lib/theme/primary_colors.dart | 41 +++++++++ lib/widgets/home/stats/group_pie_chart.dart | 92 ++++++++++++++++----- 3 files changed, 202 insertions(+), 22 deletions(-) create mode 100644 lib/theme/colors.py create mode 100644 lib/theme/primary_colors.dart diff --git a/lib/theme/colors.py b/lib/theme/colors.py new file mode 100644 index 0000000..2393a77 --- /dev/null +++ b/lib/theme/colors.py @@ -0,0 +1,91 @@ +import colorsys +import math + + +def calculate_luminace(normalized_value): + index = normalized_value + + if index < 0.03928: + return index / 12.92 + else: + return ((index + 0.055) / 1.055) ** 2.4 + + +def calculate_relative_luminance(r, g, b): + return 0.2126 * calculate_luminace(r) + 0.7152 * calculate_luminace(g) + 0.0722 * calculate_luminace(b) + + +def calculate_contrast_ratio(color: tuple[float, float, float], otherColor: tuple[float, float, float]): + l1 = calculate_relative_luminance(*color) + l2 = calculate_relative_luminance(*otherColor) + return (l1 + 0.05) / (l2 + 0.05) if l1 > l2 else (l2 + 0.05) / (l1 + 0.05) + + +def rad(degrees: float): + return degrees / 180 * math.pi + + +def rgbToHex(r: float, g: float, b: float): + rs = hex(math.floor(r * 255))[2:].zfill(2) + gs = hex(math.floor(g * 255))[2:].zfill(2) + bs = hex(math.floor(b * 255))[2:].zfill(2) + + return rs + gs + bs + + +def generateColors(backgroundColor: tuple[float, float, float], targetContrast: float, hueOffset=0, numberOfColors=16, saturation=0.2, initialBrightness=1.0, brightnessStep=-.05, totalTrials=5): + colorContrastList: list[tuple[tuple[float, float, float], float]] = list() + + unitHue = 1 / numberOfColors + + for i in range(numberOfColors): + hue = (hueOffset + unitHue * i) % 1.0 + + r, g, b = colorsys.hsv_to_rgb(hue, saturation, initialBrightness) + + trials = totalTrials + contrast = calculate_contrast_ratio(backgroundColor, (r, g, b)) + + while contrast < targetContrast and trials > 0: + trials -= 1 + r, g, b = colorsys.hsv_to_rgb( + hue, saturation, initialBrightness + (brightnessStep * (totalTrials - trials))) + contrast = calculate_contrast_ratio(backgroundColor, (r, g, b)) + + colorContrastList.append(((r, g, b), contrast)) + + return colorContrastList + + +with open("primary_colors.dart", "w") as output_file: + print("// Auto-generated by colors.py", file=output_file) + print("", file=output_file) + print("import 'dart:ui';", file=output_file) + print("", file=output_file) + + accentColors: list[tuple[tuple[float, float, float], float]] = list() + primaryColors: list[tuple[tuple[float, float, float], float]] = list() + + bg = 0xf5 / 255.0, 0xf6 / 255.0, 0xfa / 255.0 + + for [color, contrast] in generateColors(bg, 1.0, saturation=0.2, initialBrightness=1.0, totalTrials=0, hueOffset=0.80196078431): + hexColor = rgbToHex(*color) + accentColors.append([color, 0.0]) + [colorH, colorS, colorV] = colorsys.rgb_to_hsv(*color) + [secondaryColor, secondaryContrast] = generateColors( + color, 5.0, saturation=1.0, initialBrightness=0.65, hueOffset=colorH, brightnessStep=-0.033)[0] + primaryColors.append([secondaryColor, secondaryContrast]) + + print("const accentColors = [", file=output_file) + for [accentColor, constrast] in accentColors: + contrastText = "" if constrast == 0 else f" // contrast ratio {contrast}" + print( + f" Color(0xff{rgbToHex(*accentColor)}),{contrastText}", file=output_file) + print("];", file=output_file) + print("", file=output_file) + print("const primaryColors = [", file=output_file) + for [primaryColor, pContrast] in primaryColors: + pcontrastText = "" if pContrast == 0 else f" // contrast ratio {pContrast}" + print( + f" Color(0xff{rgbToHex(*primaryColor)}),{pcontrastText}", file=output_file) + print("];", file=output_file) diff --git a/lib/theme/primary_colors.dart b/lib/theme/primary_colors.dart new file mode 100644 index 0000000..974abd2 --- /dev/null +++ b/lib/theme/primary_colors.dart @@ -0,0 +1,41 @@ +// Auto-generated by colors.py + +import 'dart:ui'; + +const accentColors = [ + Color(0xfff5ccff), + Color(0xffffccf5), + Color(0xffffcce2), + Color(0xffffcccf), + Color(0xffffdbcc), + Color(0xffffefcc), + Color(0xfffbffcc), + Color(0xffe8ffcc), + Color(0xffd5ffcc), + Color(0xffccffd5), + Color(0xffccffe8), + Color(0xffccfffb), + Color(0xffccefff), + Color(0xffccdbff), + Color(0xffcfccff), + Color(0xffe2ccff), +]; + +const primaryColors = [ + Color(0xff8600a5), // contrast ratio 5.82456471142964 + Color(0xffa50086), // contrast ratio 5.131551048194216 + Color(0xffa50048), // contrast ratio 5.529257614713291 + Color(0xffa5000a), // contrast ratio 5.644652409710161 + Color(0xffa53300), // contrast ratio 5.272019425653915 + Color(0xff845a00), // contrast ratio 5.3249027529793045 + Color(0xff747b00), // contrast ratio 4.4056160670194595 + Color(0xff457b00), // contrast ratio 4.759500600587007 + Color(0xff177b00), // contrast ratio 4.872864221074725 + Color(0xff007b17), // contrast ratio 4.856003928836115 + Color(0xff007b45), // contrast ratio 4.816518785663914 + Color(0xff007b73), // contrast ratio 4.6801442121694015 + Color(0xff006694), // contrast ratio 5.179720075231227 + Color(0xff0033a5), // contrast ratio 7.478914206842744 + Color(0xff0a00a5), // contrast ratio 8.795416386639062 + Color(0xff4800a5), // contrast ratio 7.838426705632426 +]; diff --git a/lib/widgets/home/stats/group_pie_chart.dart b/lib/widgets/home/stats/group_pie_chart.dart index e5a877a..9bc1b5d 100644 --- a/lib/widgets/home/stats/group_pie_chart.dart +++ b/lib/widgets/home/stats/group_pie_chart.dart @@ -4,6 +4,7 @@ import 'package:flow/data/money_flow.dart'; import 'package:flow/entity/account.dart'; import 'package:flow/entity/category.dart'; import 'package:flow/l10n/flow_localizations.dart'; +import 'package:flow/theme/primary_colors.dart'; import 'package:flow/theme/theme.dart'; import 'package:flow/widgets/general/flow_icon.dart'; import 'package:flow/widgets/home/stats/legend_list_tile.dart'; @@ -52,9 +53,9 @@ class _GroupPieChartState extends State> { if (!event.isInterestedForInteractions || response == null || response.touchedSection == null) { - setState(() { - selectedKey = null; - }); + // setState(() { + // selectedKey = null; + // }); return; } @@ -67,37 +68,76 @@ class _GroupPieChartState extends State> { }), sectionsSpace: 2.0, centerSpaceRadius: 48.0, - sections: data.entries - .map((e) => sectionData(data[e.key]!, - showBadge: e.key == selectedKey)) + startDegreeOffset: -90.0, + sections: data.entries.indexed + .map( + (e) => sectionData( + data[e.$2.key]!, + selected: e.$2.key == selectedKey, + index: e.$1, + ), + ) .toList(), ), ), ), - ...data.entries.map((e) => LegendListTile( - key: ValueKey(e.key), - leading: resolveBadgeWidget(e.value.associatedData), - title: Text(resolveName(e.value.associatedData)), - trailing: Text( - e.value.totalExpense.moneyCompact, - style: context.textTheme.bodyLarge, - ), - selected: e.key == selectedKey, - onTap: () => setState(() => selectedKey = e.key), - )) + ...(data.entries.toList().indexed.toList() + ..sort((a, b) => + a.$2.value.totalExpense.compareTo(b.$2.value.totalExpense))) + .map((e) { + final color = primaryColors[e.$1 % primaryColors.length]; + final backgroundColor = accentColors[e.$1 % primaryColors.length]; + + return LegendListTile( + key: ValueKey(e.$2.key), + color: color, + leading: resolveBadgeWidget( + e.$2.value.associatedData, + color: color, + backgroundColor: backgroundColor, + ), + title: Text(resolveName(e.$2.value.associatedData)), + trailing: Text( + e.$2.value.totalExpense.moneyCompact, + style: context.textTheme.bodyLarge, + ), + selected: e.$2.key == selectedKey, + onTap: () => setState(() => selectedKey = e.$2.key), + ); + }) ], ); } - PieChartSectionData sectionData(MoneyFlow flow, {bool showBadge = false}) { + PieChartSectionData sectionData( + MoneyFlow flow, { + bool selected = false, + int index = 0, + }) { + final color = primaryColors[index % primaryColors.length]; + final backgroundColor = accentColors[index % primaryColors.length]; + return PieChartSectionData( - color: context.colorScheme.secondary, + color: color, radius: 80.0, value: flow.totalExpense.abs(), title: resolveName(flow.associatedData), showTitle: false, - badgeWidget: showBadge ? resolveBadgeWidget(flow.associatedData) : null, - badgePositionPercentageOffset: 1.0, + badgeWidget: selected + ? resolveBadgeWidget( + flow.associatedData, + color: color, + backgroundColor: backgroundColor, + ) + : null, + badgePositionPercentageOffset: 1.1, + borderSide: selected + ? BorderSide( + color: context.colorScheme.primary, + width: 2.0, + strokeAlign: BorderSide.strokeAlignInside, + ) + : BorderSide.none, ); } @@ -107,18 +147,26 @@ class _GroupPieChartState extends State> { _ => "???" }; - Widget? resolveBadgeWidget(Object? entity) => switch (entity) { + Widget? resolveBadgeWidget(Object? entity, + {Color? color, Color? backgroundColor}) => + switch (entity) { Category category => FlowIcon( category.icon, plated: true, + color: color, + plateColor: backgroundColor ?? color?.withAlpha(0x40), ), Account account => FlowIcon( account.icon, plated: true, + color: color, + plateColor: backgroundColor ?? color?.withAlpha(0x40), ), _ => FlowIcon( FlowIconData.emoji("?"), plated: true, + color: color, + plateColor: backgroundColor ?? color?.withAlpha(0x40), ), }; } From b786b6d2f17d12cda8c463454f83cd3b5c1ea9aa Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Tue, 5 Mar 2024 18:35:38 +0800 Subject: [PATCH 23/32] chore: scroll stats tab --- lib/routes/home/stats_tab.dart | 53 +++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index 03057d4..cc231d0 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -38,32 +38,39 @@ class _StatsTabState extends State ? {} : Map.fromEntries( analytics!.flow.entries - .where((element) => element.value.totalExpense < 0), + .where((element) => element.value.totalExpense < 0) + .toList() + ..sort( + (a, b) => b.value.totalExpense.compareTo(a.value.totalExpense), + ), ); - return Column( - children: [ - Container( - padding: const EdgeInsets.all(16.0), - width: double.infinity, - child: TimeRangeSelector( - initialValue: range, - onChanged: updateRange, + return SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 80.0), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16.0), + width: double.infinity, + child: TimeRangeSelector( + initialValue: range, + onChanged: updateRange, + ), ), - ), - busy - ? const Spinner() - : (data.isEmpty - ? const Text("mty") - : Column( - mainAxisSize: MainAxisSize.min, - children: [ - GroupPieChart( - data: data, - ), - ], - )), - ], + busy + ? const Spinner() + : (data.isEmpty + ? const Text("mty") + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + GroupPieChart( + data: data, + ), + ], + )), + ], + ), ); } From 3b3cabd887534629079ed6f78157a1c68faeff0c Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Tue, 5 Mar 2024 18:36:50 +0800 Subject: [PATCH 24/32] chore: bump ver to 0.2.7+30 for testing --- lib/constants.dart | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/constants.dart b/lib/constants.dart index 84a243f..26951d5 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; -const appVersion = "0.2.6+29"; +const appVersion = "0.2.7+30"; const debugBuild = true; bool get flowDebugMode => kDebugMode || debugBuild; diff --git a/pubspec.yaml b/pubspec.yaml index 89245fc..39b6ad8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: A personal finance managing app publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: "0.2.6+29" +version: "0.2.7+30" environment: sdk: ">=3.1.3 <4.0.0" From d3c8e88b5f684414dac2da1b22f2c00b7d285520 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Tue, 5 Mar 2024 18:49:22 +0800 Subject: [PATCH 25/32] Transfers missing from transactions Fixes #83 --- lib/routes/home/home_tab.dart | 1 + lib/widgets/grouped_transaction_list.dart | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/routes/home/home_tab.dart b/lib/routes/home/home_tab.dart index 0f6321a..8c09d48 100644 --- a/lib/routes/home/home_tab.dart +++ b/lib/routes/home/home_tab.dart @@ -90,6 +90,7 @@ class _HomeTabState extends State with AutomaticKeepAliveClientMixin { return GroupedTransactionList( controller: widget.scrollController, transactions: grouped.values.toList(), + shouldCombineTransferIfNeeded: true, headers: headers, listPadding: const EdgeInsets.only( top: 0, diff --git a/lib/widgets/grouped_transaction_list.dart b/lib/widgets/grouped_transaction_list.dart index 6783edf..cc12e7f 100644 --- a/lib/widgets/grouped_transaction_list.dart +++ b/lib/widgets/grouped_transaction_list.dart @@ -12,6 +12,9 @@ class GroupedTransactionList extends StatelessWidget { final List> transactions; final List headers; + /// When set to true, displays one side of transfer transactions as empty [Container]s + final bool shouldCombineTransferIfNeeded; + final ScrollController? controller; final Widget? header; @@ -20,18 +23,19 @@ class GroupedTransactionList extends StatelessWidget { super.key, required this.transactions, required this.headers, + this.controller, + this.header, this.listPadding = const EdgeInsets.symmetric(vertical: 16.0), this.itemPadding = const EdgeInsets.symmetric( horizontal: 16.0, vertical: 4.0, ), - this.controller, - this.header, + this.shouldCombineTransferIfNeeded = false, }) : assert(headers.length == transactions.length); @override Widget build(BuildContext context) { - final bool combineTransfers = + final bool combineTransfers = shouldCombineTransferIfNeeded && LocalPreferences().combineTransferTransactions.get(); final List flattened = [ From 86f0832ba3b1c766eec0ffa3bb3005c97bc474e7 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Tue, 5 Mar 2024 20:06:40 +0800 Subject: [PATCH 26/32] chore: enhance group pie chart and stats tab --- assets/l10n/en_US.json | 3 + assets/l10n/mn_MN.json | 3 + lib/routes/home/stats_tab.dart | 52 +++-- lib/widgets/home/stats/group_pie_chart.dart | 238 ++++++++++++++------ 4 files changed, 206 insertions(+), 90 deletions(-) diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index 2d070ac..7f139f4 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -95,6 +95,7 @@ "category.delete": "Delete category", "category.delete.warning": "Deleting this category will leave {transactionCount} transactions with no category. This action is irreversible!", "category.skip": "No category", + "category.none": "No category", "categories": "Categories", "categories.noCategories": "You don't have any categories", @@ -143,6 +144,8 @@ "tabs.stats.timeRange.mode.byWeek": "By week", "tabs.stats.timeRange.mode.byMonth": "By month", "tabs.stats.timeRange.mode.byYear": "By year", + "tabs.stats.chart.noData": "No data to show for the selected filters", + "tabs.stats.chart.select.clickToSelect": "Click to select", "tabs.accounts": "Accounts", "tabs.accounts.reorder": "Reorder accounts", "tabs.accounts.reorder.guide": "Long press and drag", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 16cee53..635e955 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -95,6 +95,7 @@ "category.delete": "Ангилалыг устгах", "category.delete.warning": "Энэ ангиллыг устгавал холбоотой {transactionCount} гүйлгээг ангилалгүй болохыг анхаарна уу. Энэ үйлдлийг буцаах боломжгүй юм!", "category.skip": "Ангилалгүй", + "category.none": "Ангилалгүй", "categories": "Ангиллууд", "categories.noCategories": "Танд үүсгэсэн ангилал алга байна", @@ -143,6 +144,8 @@ "tabs.stats.timeRange.mode.byWeek": "Долоо хоногоор", "tabs.stats.timeRange.mode.byMonth": "Сараар", "tabs.stats.timeRange.mode.byYear": "Жилээр", + "tabs.stats.chart.noData": "Сонгосон шүүлтүүрт тохирох хангалттай өгөгдөл байхгүй байна", + "tabs.stats.chart.select.clickToSelect": "Товшиж сонгоно уу", "tabs.accounts": "Данснууд", "tabs.accounts.reorder": "Дараалал өөрчлөх", "tabs.accounts.reorder.guide": "Удаан дарж чирнэ үү", diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index cc231d0..23194ea 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -1,5 +1,6 @@ import 'package:flow/data/flow_analytics.dart'; import 'package:flow/data/money_flow.dart'; +import 'package:flow/l10n/flow_localizations.dart'; import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/actions.dart'; import 'package:flow/widgets/general/spinner.dart'; @@ -45,32 +46,33 @@ class _StatsTabState extends State ), ); - return SingleChildScrollView( - padding: const EdgeInsets.only(bottom: 80.0), - child: Column( - children: [ - Container( - padding: const EdgeInsets.all(16.0), - width: double.infinity, - child: TimeRangeSelector( - initialValue: range, - onChanged: updateRange, - ), + return Column( + children: [ + Container( + padding: const EdgeInsets.all(16.0), + width: double.infinity, + child: TimeRangeSelector( + initialValue: range, + onChanged: updateRange, ), - busy - ? const Spinner() - : (data.isEmpty - ? const Text("mty") - : Column( - mainAxisSize: MainAxisSize.min, - children: [ - GroupPieChart( - data: data, - ), - ], - )), - ], - ), + ), + busy + ? const Spinner() + : (data.isEmpty + ? Center( + child: Text( + "tabs.stats.chart.noData".t(context), + ), + ) + : Expanded( + child: GroupPieChart( + data: data, + scrollLegendWithin: true, + scrollPadding: const EdgeInsets.only(bottom: 96.0), + unresolvedDataTitle: "category.none".t(context), + ), + )), + ], ); } diff --git a/lib/widgets/home/stats/group_pie_chart.dart b/lib/widgets/home/stats/group_pie_chart.dart index 9bc1b5d..b02bed2 100644 --- a/lib/widgets/home/stats/group_pie_chart.dart +++ b/lib/widgets/home/stats/group_pie_chart.dart @@ -1,19 +1,44 @@ +import 'dart:math' as math; + import 'package:fl_chart/fl_chart.dart'; import 'package:flow/data/flow_icon.dart'; import 'package:flow/data/money_flow.dart'; import 'package:flow/entity/account.dart'; import 'package:flow/entity/category.dart'; import 'package:flow/l10n/flow_localizations.dart'; +import 'package:flow/main.dart'; import 'package:flow/theme/primary_colors.dart'; import 'package:flow/theme/theme.dart'; import 'package:flow/widgets/general/flow_icon.dart'; import 'package:flow/widgets/home/stats/legend_list_tile.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide Flow; class GroupPieChart extends StatefulWidget { + final EdgeInsets chartPadding; + + final bool showSelectedSection; + + final bool showLegend; + final bool sortLegend; + + final bool scrollLegendWithin; + final EdgeInsets scrollPadding; + final Map> data; - const GroupPieChart({super.key, required this.data}); + final String? unresolvedDataTitle; + + const GroupPieChart({ + super.key, + required this.data, + this.chartPadding = const EdgeInsets.all(24.0), + this.scrollPadding = EdgeInsets.zero, + this.showLegend = true, + this.scrollLegendWithin = false, + this.showSelectedSection = true, + this.sortLegend = true, + this.unresolvedDataTitle, + }); @override State> createState() => _GroupPieChartState(); @@ -42,84 +67,167 @@ class _GroupPieChartState extends State> { @override Widget build(BuildContext context) { + final MoneyFlow? selectedSection = + selectedKey == null ? null : data[selectedKey!]; + + final double selectedSectionProc = selectedSection == null + ? 0.0 + : (selectedSection.totalExpense / + data.values.fold( + 0, + (previousValue, element) => + previousValue + element.totalExpense)); + return Column( mainAxisSize: MainAxisSize.min, children: [ - AspectRatio( - aspectRatio: 1.0, - child: PieChart( - PieChartData( - pieTouchData: PieTouchData(touchCallback: (event, response) { - if (!event.isInterestedForInteractions || - response == null || - response.touchedSection == null) { - // setState(() { - // selectedKey = null; - // }); - return; - } - - final int index = response.touchedSection!.touchedSectionIndex; - - if (index > -1) { - selectedKey = data.entries.elementAt(index).key; - setState(() {}); - } - }), - sectionsSpace: 2.0, - centerSpaceRadius: 48.0, - startDegreeOffset: -90.0, - sections: data.entries.indexed - .map( - (e) => sectionData( - data[e.$2.key]!, - selected: e.$2.key == selectedKey, - index: e.$1, - ), - ) - .toList(), - ), + if (widget.showSelectedSection) ...[ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + selectedSection == null + ? "tabs.stats.chart.select.clickToSelect".t(context) + : resolveName(selectedSection.associatedData), + style: context.textTheme.headlineSmall, + ), + Text( + "${selectedSection?.totalExpense.abs().money ?? "-"} • ${(100 * selectedSectionProc).toStringAsFixed(1)}%"), + ], + ), + const SizedBox(height: 8.0), + ], + Padding( + padding: widget.chartPadding, + child: AspectRatio( + aspectRatio: 1.0, + child: LayoutBuilder(builder: (context, constraints) { + final double size = constraints.maxWidth; + + final double centerHoleDiameter = math.min(96.0, size * 0.25); + final double radius = (size - centerHoleDiameter) * 0.5; + + return PieChart( + PieChartData( + pieTouchData: PieTouchData(touchCallback: (event, response) { + if (!event.isInterestedForInteractions || + response == null || + response.touchedSection == null) { + // setState(() { + // selectedKey = null; + // }); + return; + } + + final int index = + response.touchedSection!.touchedSectionIndex; + + if (index > -1) { + selectedKey = data.entries.elementAt(index).key; + setState(() {}); + } + }), + sectionsSpace: 2.0, + centerSpaceRadius: centerHoleDiameter / 2, + startDegreeOffset: -90.0, + sections: data.entries.indexed + .map( + (e) => sectionData( + data[e.$2.key]!, + selected: e.$2.key == selectedKey, + index: e.$1, + radius: radius, + ), + ) + .toList(), + ), + ); + }), ), ), - ...(data.entries.toList().indexed.toList() - ..sort((a, b) => - a.$2.value.totalExpense.compareTo(b.$2.value.totalExpense))) - .map((e) { - final color = primaryColors[e.$1 % primaryColors.length]; - final backgroundColor = accentColors[e.$1 % primaryColors.length]; - - return LegendListTile( - key: ValueKey(e.$2.key), - color: color, - leading: resolveBadgeWidget( - e.$2.value.associatedData, - color: color, - backgroundColor: backgroundColor, - ), - title: Text(resolveName(e.$2.value.associatedData)), - trailing: Text( - e.$2.value.totalExpense.moneyCompact, - style: context.textTheme.bodyLarge, - ), - selected: e.$2.key == selectedKey, - onTap: () => setState(() => selectedKey = e.$2.key), - ); - }) + if (widget.showLegend) buildLegend(context), ], ); } + Widget buildLegendItem( + BuildContext context, + int index, + MapEntry> entry, + ) { + final bool usingDarkTheme = Flow.of(context).useDarkTheme; + + final Color color = (usingDarkTheme + ? accentColors + : primaryColors)[index % primaryColors.length]; + final Color backgroundColor = (usingDarkTheme + ? primaryColors + : accentColors)[index % primaryColors.length]; + + return LegendListTile( + key: ValueKey(entry.key), + color: color, + leading: resolveBadgeWidget( + entry.value.associatedData, + color: color, + backgroundColor: backgroundColor, + ), + title: Text(resolveName(entry.value.associatedData)), + trailing: Text( + entry.value.totalExpense.moneyCompact, + style: context.textTheme.bodyLarge, + ), + selected: entry.key == selectedKey, + onTap: () => setState(() => selectedKey = entry.key), + ); + } + + Widget buildLegend(BuildContext context) { + final indexed = data.entries.toList().indexed.toList(); + if (widget.sortLegend) { + indexed.sort( + (a, b) => a.$2.value.totalExpense.compareTo( + b.$2.value.totalExpense, + ), + ); + } + + if (widget.scrollLegendWithin) { + return Expanded( + child: ListView.builder( + itemBuilder: (context, index) => + buildLegendItem(context, indexed[index].$1, indexed[index].$2), + itemCount: indexed.length, + padding: widget.scrollPadding, + ), + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: + indexed.map((e) => buildLegendItem(context, e.$1, e.$2)).toList(), + ); + } + PieChartSectionData sectionData( MoneyFlow flow, { + required double radius, bool selected = false, int index = 0, }) { - final color = primaryColors[index % primaryColors.length]; - final backgroundColor = accentColors[index % primaryColors.length]; + final bool usingDarkTheme = Flow.of(context).useDarkTheme; + + final Color color = (usingDarkTheme + ? accentColors + : primaryColors)[index % primaryColors.length]; + final Color backgroundColor = (usingDarkTheme + ? primaryColors + : accentColors)[index % primaryColors.length]; return PieChartSectionData( color: color, - radius: 80.0, + radius: radius, value: flow.totalExpense.abs(), title: resolveName(flow.associatedData), showTitle: false, @@ -130,7 +238,7 @@ class _GroupPieChartState extends State> { backgroundColor: backgroundColor, ) : null, - badgePositionPercentageOffset: 1.1, + badgePositionPercentageOffset: 0.8, borderSide: selected ? BorderSide( color: context.colorScheme.primary, @@ -144,7 +252,7 @@ class _GroupPieChartState extends State> { String resolveName(Object? entity) => switch (entity) { Category category => category.name, Account account => account.name, - _ => "???" + _ => widget.unresolvedDataTitle ?? "???" }; Widget? resolveBadgeWidget(Object? entity, From 9604c90e78996ffa1ea17dbbc25a8ef241f6bfce Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Tue, 5 Mar 2024 20:17:58 +0800 Subject: [PATCH 27/32] temporarily remove unimplemented range options --- lib/widgets/select_time_range_mode_sheet.dart | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/widgets/select_time_range_mode_sheet.dart b/lib/widgets/select_time_range_mode_sheet.dart index 8063431..98bb390 100644 --- a/lib/widgets/select_time_range_mode_sheet.dart +++ b/lib/widgets/select_time_range_mode_sheet.dart @@ -68,14 +68,14 @@ class SelectTimeRangeModeSheet extends StatelessWidget { ), ), const SizedBox(height: 8.0), - ListTile( - title: Text("tabs.stats.timeRange.mode.byMonth".t(context)), - onTap: () => context.pop(TimeRangeMode.byMonth), - ), - ListTile( - title: Text("tabs.stats.timeRange.mode.byYear".t(context)), - onTap: () => context.pop(TimeRangeMode.byYear), - ), + // ListTile( + // title: Text("tabs.stats.timeRange.mode.byMonth".t(context)), + // onTap: () => context.pop(TimeRangeMode.byMonth), + // ), + // ListTile( + // title: Text("tabs.stats.timeRange.mode.byYear".t(context)), + // onTap: () => context.pop(TimeRangeMode.byYear), + // ), ListTile( title: Text("tabs.stats.timeRange.mode.custom".t(context)), onTap: () => context.pop(TimeRangeMode.custom), From 5acf12930881c2981ab960b5da346f78ab5366ea Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Tue, 5 Mar 2024 20:18:14 +0800 Subject: [PATCH 28/32] chore: optimize no data screen for stats --- assets/l10n/en_US.json | 2 +- assets/l10n/mn_MN.json | 2 +- lib/routes/home/stats_tab.dart | 56 ++++++++++++++++++++++++++--- lib/widgets/home/stats/no_data.dart | 47 ++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 7 deletions(-) create mode 100644 lib/widgets/home/stats/no_data.dart diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index 7f139f4..78c4ae8 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -144,7 +144,7 @@ "tabs.stats.timeRange.mode.byWeek": "By week", "tabs.stats.timeRange.mode.byMonth": "By month", "tabs.stats.timeRange.mode.byYear": "By year", - "tabs.stats.chart.noData": "No data to show for the selected filters", + "tabs.stats.chart.noData": "No data to show", "tabs.stats.chart.select.clickToSelect": "Click to select", "tabs.accounts": "Accounts", "tabs.accounts.reorder": "Reorder accounts", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 635e955..b9ad515 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -144,7 +144,7 @@ "tabs.stats.timeRange.mode.byWeek": "Долоо хоногоор", "tabs.stats.timeRange.mode.byMonth": "Сараар", "tabs.stats.timeRange.mode.byYear": "Жилээр", - "tabs.stats.chart.noData": "Сонгосон шүүлтүүрт тохирох хангалттай өгөгдөл байхгүй байна", + "tabs.stats.chart.noData": "Харуулах өгөгдөл байхгүй байна", "tabs.stats.chart.select.clickToSelect": "Товшиж сонгоно уу", "tabs.accounts": "Данснууд", "tabs.accounts.reorder": "Дараалал өөрчлөх", diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index 23194ea..ef15f46 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -5,6 +5,8 @@ import 'package:flow/objectbox.dart'; import 'package:flow/objectbox/actions.dart'; import 'package:flow/widgets/general/spinner.dart'; import 'package:flow/widgets/home/stats/group_pie_chart.dart'; +import 'package:flow/widgets/home/stats/no_data.dart'; +import 'package:flow/widgets/select_time_range_mode_sheet.dart'; import 'package:flow/widgets/time_range_selector.dart'; import 'package:flutter/material.dart'; import 'package:moment_dart/moment_dart.dart'; @@ -59,11 +61,10 @@ class _StatsTabState extends State busy ? const Spinner() : (data.isEmpty - ? Center( - child: Text( - "tabs.stats.chart.noData".t(context), - ), - ) + ? Expanded( + child: NoData( + onTap: changeMode, + )) : Expanded( child: GroupPieChart( data: data, @@ -104,6 +105,51 @@ class _StatsTabState extends State } } + // TODO remove time range related code + // to avoid duplicating what's in [TimeRangeSelector] + + Future selectRange() async { + final newRange = await showDateRangePicker( + context: context, + firstDate: DateTime.fromMicrosecondsSinceEpoch(0), + lastDate: DateTime.now().startOfNextYear(), + initialDateRange: range is CustomTimeRange + ? DateTimeRange( + start: (range as CustomTimeRange).from, + end: (range as CustomTimeRange).to) + : null, + ); + + if (newRange != null) { + return CustomTimeRange(newRange.start, newRange.end); + } + + return null; + } + + Future changeMode() async { + final TimeRangeMode? mode = await showModalBottomSheet( + context: context, + builder: (BuildContext context) => const SelectTimeRangeModeSheet(), + ); + + if (mode == null) return; + + final TimeRange? newRange = switch (mode) { + TimeRangeMode.thisWeek => TimeRange.thisLocalWeek(), + TimeRangeMode.thisMonth => TimeRange.thisMonth(), + TimeRangeMode.thisYear => TimeRange.thisYear(), + TimeRangeMode.byYear => await selectRange(), + TimeRangeMode.byMonth => await selectRange(), + TimeRangeMode.custom => await selectRange(), + }; + + if (newRange == null) return; + if (!mounted) return; + + updateRange(newRange); + } + @override bool get wantKeepAlive => true; } diff --git a/lib/widgets/home/stats/no_data.dart b/lib/widgets/home/stats/no_data.dart new file mode 100644 index 0000000..6d43fb1 --- /dev/null +++ b/lib/widgets/home/stats/no_data.dart @@ -0,0 +1,47 @@ +import 'package:flow/data/flow_icon.dart'; +import 'package:flow/l10n/extensions.dart'; +import 'package:flow/theme/theme.dart'; +import 'package:flow/widgets/general/button.dart'; +import 'package:flow/widgets/general/flow_icon.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class NoData extends StatelessWidget { + final VoidCallback onTap; + + const NoData({super.key, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(24.0), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "tabs.stats.chart.noData".t(context), + textAlign: TextAlign.center, + style: context.textTheme.headlineMedium, + ), + const SizedBox(height: 8.0), + FlowIcon( + FlowIconData.icon(Symbols.query_stats_rounded), + size: 128.0, + color: context.colorScheme.primary, + ), + const SizedBox(height: 8.0), + Button( + trailing: const Icon( + Symbols.history_rounded, + weight: 600.0, + ), + onTap: onTap, + child: Text("tabs.stats.timeRange.select".t(context)), + ), + ], + ), + ), + ); + } +} From 9fcc338c014b021fd3e2dfa3869dc684ec939386 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Tue, 5 Mar 2024 20:20:33 +0800 Subject: [PATCH 29/32] chore: add/remove comments --- lib/objectbox/actions.dart | 2 +- lib/prefs.dart | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/objectbox/actions.dart b/lib/objectbox/actions.dart index 58f0e89..fcb9312 100644 --- a/lib/objectbox/actions.dart +++ b/lib/objectbox/actions.dart @@ -253,7 +253,7 @@ extension AccountActions on Account { /// This is probably better of as Singleton somewhere with memoization as /// account names won't get changed that frequently (I hope) /// - /// TODO + /// TODO refactor this to be more efficient static String nameByUuid(String uuid) { final query = ObjectBox().box().query(Account_.uuid.equals(uuid)).build(); diff --git a/lib/prefs.dart b/lib/prefs.dart index 27e5ff4..9accdde 100644 --- a/lib/prefs.dart +++ b/lib/prefs.dart @@ -173,8 +173,7 @@ class LocalPreferences { categoryTransactionsQuery.close(); - // TODO do I have to use `await` here? - // doesn't seem necessary... + // Future setFrecencyData( "category", category.uuid, @@ -214,8 +213,7 @@ class LocalPreferences { accountTransactionsQuery.close(); - // TODO do I have to use `await` here? - // doesn't seem necessary... + // Future setFrecencyData( "account", account.uuid, From 425aa9237cf9dec70b3c898e9714988450997e2c Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Tue, 5 Mar 2024 20:21:02 +0800 Subject: [PATCH 30/32] chore: bump ver to 0.2.8+31 for testing --- lib/constants.dart | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/constants.dart b/lib/constants.dart index 26951d5..104fd63 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; -const appVersion = "0.2.7+30"; +const appVersion = "0.2.8+31"; const debugBuild = true; bool get flowDebugMode => kDebugMode || debugBuild; diff --git a/pubspec.yaml b/pubspec.yaml index 39b6ad8..302366c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: A personal finance managing app publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: "0.2.7+30" +version: "0.2.8+31" environment: sdk: ">=3.1.3 <4.0.0" From 19f4569d27ada052d90aa745d9471b728031cad4 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Wed, 6 Mar 2024 00:43:03 +0800 Subject: [PATCH 31/32] really fix #83 --- lib/widgets/transaction_list_tile.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/widgets/transaction_list_tile.dart b/lib/widgets/transaction_list_tile.dart index 7b835e9..95f9724 100644 --- a/lib/widgets/transaction_list_tile.dart +++ b/lib/widgets/transaction_list_tile.dart @@ -6,7 +6,6 @@ import 'package:flow/entity/transaction.dart'; import 'package:flow/entity/transaction/extensions/default/transfer.dart'; import 'package:flow/l10n/extensions.dart'; import 'package:flow/objectbox/actions.dart'; -import 'package:flow/prefs.dart'; import 'package:flow/theme/theme.dart'; import 'package:flow/widgets/general/flow_icon.dart'; import 'package:flutter/cupertino.dart'; @@ -37,7 +36,7 @@ class TransactionListTile extends StatelessWidget { @override Widget build(BuildContext context) { - if (LocalPreferences().combineTransferTransactions.get() && + if (combineTransfers && transaction.isTransfer && transaction.amount.isNegative) { return Container(); From dbe7ba2417b0867f2fd196229899135b5d0fed4e Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Wed, 6 Mar 2024 01:46:00 +0800 Subject: [PATCH 32/32] fix #27 --- lib/routes/home/stats_tab.dart | 11 ++++++----- lib/widgets/time_range_selector.dart | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/routes/home/stats_tab.dart b/lib/routes/home/stats_tab.dart index ef15f46..448e093 100644 --- a/lib/routes/home/stats_tab.dart +++ b/lib/routes/home/stats_tab.dart @@ -66,11 +66,12 @@ class _StatsTabState extends State onTap: changeMode, )) : Expanded( - child: GroupPieChart( - data: data, - scrollLegendWithin: true, - scrollPadding: const EdgeInsets.only(bottom: 96.0), - unresolvedDataTitle: "category.none".t(context), + child: SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 96.0), + child: GroupPieChart( + data: data, + unresolvedDataTitle: "category.none".t(context), + ), ), )), ], diff --git a/lib/widgets/time_range_selector.dart b/lib/widgets/time_range_selector.dart index e70d88d..03de478 100644 --- a/lib/widgets/time_range_selector.dart +++ b/lib/widgets/time_range_selector.dart @@ -110,7 +110,7 @@ class _TimeRangeSelectorState extends State { ], ], ), - const SizedBox(height: 8.0), + const SizedBox(height: 4.0), Row( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.spaceBetween,