From 23480d80d27fff786782e5b8e9df0381bf7be4f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Wed, 22 Nov 2023 15:09:05 +0100 Subject: [PATCH 01/17] feat(graphs): :sparkles: render income graph next to expense graph #8 --- .vscode/settings.json | 3 +- lib/l10n/app_cs.arb | 4 +-- lib/l10n/app_en.arb | 4 +-- lib/util/graphs.dart | 67 +++++++++++++++++++++++++++++---------- lib/views/graph_view.dart | 47 +++++++++++++++++++-------- lib/views/home.dart | 6 ++-- 6 files changed, 92 insertions(+), 39 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 4397789..9aa5bb4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ "conventionalCommits.scopes": [ "ocr", "ui", - "translations" + "translations", + "graphs" ] } \ No newline at end of file diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index 3f32913..58a74db 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -59,8 +59,8 @@ "setupStartingBalance":"Počáteční zůstatek", "graphs":"Grafy", "createTestData":"Vytvořit vzorková data", - "spendingStats":"Statistiky utrácení", "yearly":"Roční", - "monthly":"Měsíční" + "monthly":"Měsíční", + "expenses":"Výdaje" } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3fd1003..1b49332 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -95,7 +95,7 @@ "setupStartingBalance":"Starting balance", "graphs":"Graphs", "createTestData":"Create test data", - "spendingStats":"Spending statistics", "yearly":"Yearly", - "monthly":"Monthly" + "monthly":"Monthly", + "expenses":"Expenses" } \ No newline at end of file diff --git a/lib/util/graphs.dart b/lib/util/graphs.dart index c91fa06..5227e20 100644 --- a/lib/util/graphs.dart +++ b/lib/util/graphs.dart @@ -1,47 +1,80 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:prasule/main.dart'; -/// Monthly/Yearly expenses [LineChart] +/// Monthly/Yearly expense/income [LineChart] class ExpensesChart extends StatelessWidget { const ExpensesChart( {super.key, required this.date, required this.locale, - this.data = const [], + this.expenseData = const [], + this.incomeData = const [], this.yearly = false}); final bool yearly; final DateTime date; final String locale; - final List data; - List get dataSorted { - var list = List.from(data); + final List expenseData; + List get expenseDataSorted { + var list = List.from(expenseData); list.sort((a, b) => a.compareTo(b)); return list; } + final List incomeData; + List get incomeDataSorted { + var list = List.from(incomeData); + list.sort((a, b) => a.compareTo(b)); + return list; + } + + double get maxY { + if (incomeData.isEmpty) return expenseDataSorted.last; + if (expenseData.isEmpty) return incomeDataSorted.last; + if (expenseDataSorted.last > incomeDataSorted.last) { + return expenseDataSorted.last; + } else { + return incomeDataSorted.last; + } + } + @override Widget build(BuildContext context) { return LineChart( LineChartData( maxX: (yearly) ? 12 : DateTime(date.year, date.month, 0).day.toDouble(), - maxY: dataSorted.last, + maxY: maxY, minX: 1, minY: 0, backgroundColor: Theme.of(context).colorScheme.background, lineBarsData: [ - LineChartBarData( - isCurved: true, - barWidth: 8, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - belowBarData: BarAreaData(show: false), - color: Theme.of(context).colorScheme.primary, - spots: List.generate( - (yearly) ? 12 : DateTime(date.year, date.month, 0).day, - (index) => FlSpot(index.toDouble() + 1, data[index]), + if (incomeData.isNotEmpty) + LineChartBarData( + isCurved: true, + barWidth: 8, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + color: Theme.of(context).colorScheme.primary, + spots: List.generate( + (yearly) ? 12 : DateTime(date.year, date.month, 0).day, + (index) => FlSpot(index.toDouble() + 1, incomeData[index]), + ), + ), + if (expenseData.isNotEmpty) + LineChartBarData( + isCurved: true, + barWidth: 8, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + color: Theme.of(context).colorScheme.error, + spots: List.generate( + (yearly) ? 12 : DateTime(date.year, date.month, 0).day, + (index) => FlSpot(index.toDouble() + 1, expenseData[index]), + ), ), - ), ], // actual data titlesData: FlTitlesData( rightTitles: const AxisTitles( diff --git a/lib/views/graph_view.dart b/lib/views/graph_view.dart index e03c7c6..ed69922 100644 --- a/lib/views/graph_view.dart +++ b/lib/views/graph_view.dart @@ -1,6 +1,7 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:prasule/api/category.dart'; import 'package:prasule/api/wallet.dart'; import 'package:prasule/api/walletmanager.dart'; import 'package:prasule/main.dart'; @@ -25,6 +26,7 @@ class _GraphViewState extends State { List wallets = []; String? locale; var yearlyBtnSet = {"monthly"}; + var graphTypeSet = {"expense", "income"}; bool get yearly => yearlyBtnSet.contains("yearly"); @override @@ -33,20 +35,22 @@ class _GraphViewState extends State { locale ??= Localizations.localeOf(context).languageCode; } - List generateChartData() { - if (selectedWallet == null) return [0]; + List generateChartData(EntryType type) { var data = List.filled( (yearly) ? 12 : DateTime(_selectedDate.year, _selectedDate.month, 0).day, 0.0); + if (selectedWallet == null) return []; for (var i = 0; i < data.length; i++) { - var entriesForRange = selectedWallet!.entries.where((element) => (!yearly) - ? element.date.month == _selectedDate.month && - element.date.year == _selectedDate.year && - element.date.day == i + 1 - : element.date.month == i + 1 && - element.date.year == _selectedDate.year); + var entriesForRange = selectedWallet!.entries.where((element) => + ((!yearly) + ? element.date.month == _selectedDate.month && + element.date.year == _selectedDate.year && + element.date.day == i + 1 + : element.date.month == i + 1 && + element.date.year == _selectedDate.year) && + element.type == type); var sum = 0.0; for (var e in entriesForRange) { sum += e.data.amount; @@ -148,12 +152,20 @@ class _GraphViewState extends State { SegmentedButton( segments: [ ButtonSegment( - value: "spending", - label: Text(AppLocalizations.of(context)!.spendingStats), - ) + value: "expense", + label: Text(AppLocalizations.of(context)!.expenses), + ), + ButtonSegment( + value: "income", + label: Text(AppLocalizations.of(context)!.income), + ), ], - selected: const {"spending"}, - // TODO: onSelectionChanged + selected: graphTypeSet, + multiSelectionEnabled: true, + onSelectionChanged: (selection) { + graphTypeSet = selection; + setState(() {}); + }, ), const SizedBox( height: 5, @@ -189,6 +201,8 @@ class _GraphViewState extends State { ..sort((a, b) => b.date.compareTo(a.date))) .first .date; + logger.i(firstDate); + logger.i(lastDate); var newDate = await showDatePicker( context: context, initialDate: _selectedDate, @@ -215,7 +229,12 @@ class _GraphViewState extends State { date: _selectedDate, locale: locale ?? "en", yearly: yearly, - data: generateChartData(), + expenseData: (graphTypeSet.contains("expense")) + ? generateChartData(EntryType.expense) + : [], + incomeData: (graphTypeSet.contains("income")) + ? generateChartData(EntryType.income) + : [], ), ) ], diff --git a/lib/views/home.dart b/lib/views/home.dart index 6d6e5ef..cf38e6f 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -1,6 +1,4 @@ import 'dart:math'; - -import 'package:currency_picker/currency_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; @@ -87,7 +85,9 @@ class _HomeViewState extends State { name: "Test Entry #${i + 1}", amount: random.nextInt(20000).toDouble(), ), - type: EntryType.expense, + type: (random.nextInt(3) > 0) + ? EntryType.expense + : EntryType.income, date: DateTime( 2023, random.nextInt(12) + 1, From a4163194ba9aef882a27a29550d5222485e48b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Mon, 25 Dec 2023 12:17:45 +0100 Subject: [PATCH 02/17] chore: upgrade flutter and deps --- .flutter | 2 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- pubspec.lock | 100 +++++++++--------- pubspec.yaml | 2 +- 4 files changed, 53 insertions(+), 53 deletions(-) diff --git a/.flutter b/.flutter index 0b591f2..fed06b3 160000 --- a/.flutter +++ b/.flutter @@ -1 +1 @@ -Subproject commit 0b591f2c82e9f59276ed68c7d4cbd63196f7c865 +Subproject commit fed06b31d938f7620ea7417295b8d8d19cf7cf1d diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 3c472b9..aa49780 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-all.zip diff --git a/pubspec.lock b/pubspec.lock index f6ae7d1..b4e2c3c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -77,18 +77,18 @@ packages: dependency: transitive description: name: build_resolvers - sha256: "64e12b0521812d1684b1917bc80945625391cb9bdd4312536b1d69dcb6133ed8" + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "10c6bcdbf9d049a0b666702cf1cee4ddfdc38f02a19d35ae392863b47519848b" + sha256: "67d591d602906ef9201caf93452495ad1812bea2074f04e25dbd7c133785821b" url: "https://pub.dev" source: hosted - version: "2.4.6" + version: "2.4.7" build_runner_core: dependency: transitive description: @@ -109,10 +109,10 @@ packages: dependency: transitive description: name: built_value - sha256: "723b4021e903217dfc445ec4cf5b42e27975aece1fc4ebbc1ca6329c2d9fb54e" + sha256: c9aabae0718ec394e5bc3c7272e6bb0dc0b32201a08fe185ec1d8401d3e39309 url: "https://pub.dev" source: hosted - version: "8.7.0" + version: "8.8.1" characters: dependency: transitive description: @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: cli_util - sha256: b8db3080e59b2503ca9e7922c3df2072cf13992354d5e944074ffa836fba43b7 + sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 url: "https://pub.dev" source: hosted - version: "0.4.0" + version: "0.4.1" clock: dependency: transitive description: @@ -149,10 +149,10 @@ packages: dependency: transitive description: name: code_builder - sha256: "1be9be30396d7e4c0db42c35ea6ccd7cc6a1e19916b5dc64d6ac216b5544d677" + sha256: feee43a5c05e7b3199bb375a86430b8ada1b04104f2923d0e03cc01ca87b6d84 url: "https://pub.dev" source: hosted - version: "4.7.0" + version: "4.9.0" collection: dependency: transitive description: @@ -173,18 +173,18 @@ packages: dependency: transitive description: name: coverage - sha256: ac86d3abab0f165e4b8f561280ff4e066bceaac83c424dd19f1ae2c2fcd12ca9 + sha256: "8acabb8306b57a409bf4c83522065672ee13179297a6bb0cb9ead73948df7c76" url: "https://pub.dev" source: hosted - version: "1.7.1" + version: "1.7.2" cross_file: dependency: transitive description: name: cross_file - sha256: "445db18de832dba8d851e287aff8ccf169bed30d2e94243cb54c7d2f1ed2142c" + sha256: fedaadfa3a6996f75211d835aaeb8fede285dae94262485698afd832371b9a5e url: "https://pub.dev" source: hosted - version: "0.3.3+6" + version: "0.3.3+8" crypto: dependency: transitive description: @@ -205,26 +205,26 @@ packages: dependency: "direct main" description: name: currency_picker - sha256: "5b87c259dbdb4e032c6b9abd22158782868505b5217b453c6c36445612a3d34c" + sha256: eb75deb7bc92e3f31e1b8ad4efacf71371e8e49d7a0eebd1c1a8e9fae58cc23d url: "https://pub.dev" source: hosted - version: "2.0.19" + version: "2.0.20" dart_style: dependency: transitive description: name: dart_style - sha256: abd7625e16f51f554ea244d090292945ec4d4be7bfbaf2ec8cccea568919d334 + sha256: "40ae61a5d43feea6d24bd22c0537a6629db858963b99b4bc1c3db80676f32368" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.3.4" dio: dependency: "direct main" description: name: dio - sha256: "417e2a6f9d83ab396ec38ff4ea5da6c254da71e4db765ad737a42af6930140b7" + sha256: "797e1e341c3dd2f69f2dad42564a6feff3bfb87187d05abb93b9609e6f1645c3" url: "https://pub.dev" source: hosted - version: "5.3.3" + version: "5.4.0" dots_indicator: dependency: transitive description: @@ -237,10 +237,10 @@ packages: dependency: "direct main" description: name: dynamic_color - sha256: "8b8bd1d798bd393e11eddeaa8ae95b12ff028bf7d5998fc5d003488cd5f4ce2f" + sha256: a866f1f8947bfdaf674d7928e769eac7230388a2e7a2542824fad4bb5b87be3b url: "https://pub.dev" source: hosted - version: "1.6.8" + version: "1.6.9" equatable: dependency: transitive description: @@ -317,10 +317,10 @@ packages: dependency: "direct main" description: name: fl_chart - sha256: "6b9eb2b3017241d05c482c01f668dd05cc909ec9a0114fdd49acd958ff2432fa" + sha256: "5a74434cc83bf64346efb562f1a06eefaf1bcb530dc3d96a104f631a1eff8d79" url: "https://pub.dev" source: hosted - version: "0.64.0" + version: "0.65.0" flutter: dependency: "direct main" description: flutter @@ -499,10 +499,10 @@ packages: dependency: transitive description: name: http - sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" + sha256: d4872660c46d929f6b8a9ef4e7a7eff7e49bbf0c4ec3f385ee32df5119175139 url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.2" http_multi_server: dependency: transitive description: @@ -531,18 +531,18 @@ packages: dependency: "direct main" description: name: image_picker - sha256: "7d7f2768df2a8b0a3cefa5ef4f84636121987d403130e70b17ef7e2cf650ba84" + sha256: fc712337719239b0b6e41316aa133350b078fa39b6cbd706b61f3fd421b03c77 url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5" image_picker_android: dependency: transitive description: name: image_picker_android - sha256: d6a6e78821086b0b737009b09363018309bbc6de3fd88cc5c26bc2bb44a4957f + sha256: ecdc963d2aa67af5195e723a40580f802d4392e31457a12a562b3e2bd6a396fe url: "https://pub.dev" source: hosted - version: "0.8.8+2" + version: "0.8.9+1" image_picker_for_web: dependency: transitive description: @@ -555,10 +555,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: "76ec722aeea419d03aa915c2c96bf5b47214b053899088c9abb4086ceecf97a7" + sha256: eac0a62104fa12feed213596df0321f57ce5a572562f72a68c4ff81e9e4caacf url: "https://pub.dev" source: hosted - version: "0.8.8+4" + version: "0.8.9" image_picker_linux: dependency: transitive description: @@ -648,10 +648,10 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7e108028e3d258667d079986da8c0bc32da4cb57431c2af03b1dc1038621a9dc" + sha256: "04be76c4a4bb50f14904e64749237e541e7c7bcf7ec0b196907322ab5d2fc739" url: "https://pub.dev" source: hosted - version: "9.0.13" + version: "9.0.16" leak_tracker_testing: dependency: transitive description: @@ -800,10 +800,10 @@ packages: dependency: transitive description: name: petitparser - sha256: eeb2d1428ee7f4170e2bd498827296a18d4e7fc462b71727d111c0ac7707cfa6 + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 url: "https://pub.dev" source: hosted - version: "6.0.1" + version: "6.0.2" platform: dependency: transitive description: @@ -816,10 +816,10 @@ packages: dependency: transitive description: name: plugin_platform_interface - sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d + sha256: f4f88d4a900933e7267e2b353594774fc0d07fb072b47eedcd5b54e1ea3269f8 url: "https://pub.dev" source: hosted - version: "2.1.6" + version: "2.1.7" pointycastle: dependency: transitive description: @@ -917,10 +917,10 @@ packages: dependency: transitive description: name: source_gen - sha256: fc0da689e5302edb6177fdd964efcb7f58912f43c28c2047a808f5bfff643d16 + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0" source_helper: dependency: transitive description: @@ -1069,18 +1069,18 @@ packages: dependency: transitive description: name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + sha256: edc8a9573dd8c5a83a183dae1af2b6fd4131377404706ca4e5420474784906fa url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "0.4.0" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + sha256: "045ec2137c27bf1a32e6ffa0e734d532a6677bf9016a0d1a406c54e499ff945b" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" webdriver: dependency: transitive description: @@ -1101,10 +1101,10 @@ packages: dependency: transitive description: name: win32 - sha256: "7c99c0e1e2fa190b48d25c81ca5e42036d5cac81430ef249027d97b0935c553f" + sha256: b0f37db61ba2f2e9b7a78a1caece0052564d1bc70668156cf3a29d676fe4e574 url: "https://pub.dev" source: hosted - version: "5.1.0" + version: "5.1.1" xdg_directories: dependency: transitive description: @@ -1117,10 +1117,10 @@ packages: dependency: transitive description: name: xml - sha256: af5e77e9b83f2f4adc5d3f0a4ece1c7f45a2467b695c2540381bac793e34e556 + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 url: "https://pub.dev" source: hosted - version: "6.4.2" + version: "6.5.0" yaml: dependency: transitive description: @@ -1130,5 +1130,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.2.0-194.0.dev <4.0.0" - flutter: ">=3.7.0" + dart: ">=3.2.0 <4.0.0" + flutter: ">=3.10.0" diff --git a/pubspec.yaml b/pubspec.yaml index 6a17388..8f0235b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,7 +38,7 @@ dependencies: flutter_slidable: ^3.0.0 flutter_localizations: sdk: flutter - fl_chart: ^0.64.0 + fl_chart: ^0.65.0 dev_dependencies: flutter_test: From 7830f7854ef510d026bac2b91c79c4502e655bf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Mon, 25 Dec 2023 15:38:19 +0100 Subject: [PATCH 03/17] chore: change up gradlew version for java --- .../app/FlutterMultiDexApplication.java | 25 +++++++++++++++++++ .../gradle/wrapper/gradle-wrapper.properties | 4 ++- 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java diff --git a/android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java b/android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java new file mode 100644 index 0000000..752fc18 --- /dev/null +++ b/android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java @@ -0,0 +1,25 @@ +// Generated file. +// +// If you wish to remove Flutter's multidex support, delete this entire file. +// +// Modifications to this file should be done in a copy under a different name +// as this file may be regenerated. + +package io.flutter.app; + +import android.app.Application; +import android.content.Context; +import androidx.annotation.CallSuper; +import androidx.multidex.MultiDex; + +/** + * Extension of {@link android.app.Application}, adding multidex support. + */ +public class FlutterMultiDexApplication extends Application { + @Override + @CallSuper + protected void attachBaseContext(Context base) { + super.attachBaseContext(base); + MultiDex.install(this); + } +} diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index aa49780..5c6f89d 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-all.zip From 1c4bc48e6fbd499efc7a4265b97ce59a83261038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Mon, 25 Dec 2023 19:03:52 +0100 Subject: [PATCH 04/17] feat: allow switching between bar nad line charts --- CHANGELOG.md | 3 + l10n.yaml | 3 +- lib/l10n/app_cs.arb | 19 +- lib/l10n/app_en.arb | 87 +++++++-- lib/util/drawer.dart | 4 +- lib/util/graphs.dart | 188 ++++++++++++++++++- lib/views/create_entry.dart | 20 +- lib/views/graph_view.dart | 261 ++++++++++++++++---------- lib/views/home.dart | 40 ++-- lib/views/settings/graph_type.dart | 156 +++++++++++++++ lib/views/settings/settings.dart | 31 ++- lib/views/settings/tessdata_list.dart | 18 +- lib/views/setup.dart | 44 ++--- pubspec.lock | 58 +++++- pubspec.yaml | 3 +- 15 files changed, 729 insertions(+), 206 deletions(-) create mode 100644 lib/views/settings/graph_type.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 5955607..a86776d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,5 @@ +# 1.0.0-alpha.2 +- Fixed localization issues +- Added graphs for expenses and income per month/year # 1.0.0-alpha - First public release \ No newline at end of file diff --git a/l10n.yaml b/l10n.yaml index 4e6692e..c114495 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -1,3 +1,4 @@ arb-dir: lib/l10n template-arb-file: app_en.arb -output-localization-file: app_localizations.dart \ No newline at end of file +output-localization-file: app_localizations.dart +nullable-getter: false \ No newline at end of file diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index 58a74db..566df2c 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -1,4 +1,5 @@ { + "@@locale": "cs", "categoryHealth": "Zdraví", "categoryCar": "Auto", "categoryFood": "Jídlo", @@ -48,9 +49,9 @@ "ocr": "OCR", "ocrData": "OCR Data", "downloaded": "Staženo", - "deleteOcr": "Opravdu chcete smazat '$lang' OCR data?\nJiž tato data nebudete moct použít při skenování obrázků.", - "langDownloadDialog": "Stahuji $lang, vyčkejte prosím...", - "langDownloadProgress": "Postup: $progress %", + "deleteOcr": "Opravdu chcete smazat '{lang}' OCR data?\nJiž tato data nebudete moct použít při skenování obrázků.", + "langDownloadDialog": "Stahuji {lang}, vyčkejte prosím...", + "langDownloadProgress": "Postup: {progress} %", "addingFromOcr": "Přidat skrz OCR", "license":"©️ 2023 Matyáš Caras\nVydáno pod licencí GNU AGPL license verze 3", "description":"Popis", @@ -61,6 +62,16 @@ "createTestData":"Vytvořit vzorková data", "yearly":"Roční", "monthly":"Měsíční", - "expenses":"Výdaje" + "expenses":"Výdaje", + "expensesForMonth": "Výdaje za {month}: {value}", + "incomeForMonth": "Příjmy za {month}: {value}", + "expensesForDay": "Výdaje: {value}", + "incomeForDay": "Příjmy: {value}", + "settingsAppearance":"Vzhled", + "graphType":"Druh grafu", + "graphTypeDesc":"Zvolte, zda-li použít sloupcový, nebo spojnicový graf, a kde", + "lineChart":"Spojnicový", + "barChart":"Sloupcový", + "selectType":"Zvolte typ" } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1b49332..e0ab041 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,4 +1,5 @@ { + "@@locale": "en", "categoryHealth": "Health", "categoryCar": "Car", "categoryFood": "Food", @@ -57,7 +58,7 @@ "ocr": "OCR", "ocrData": "OCR Data", "downloaded": "Downloaded", - "deleteOcr": "Do you really want to delete '$lang' OCR data?\nYou will not be able to use these language data when scanning pictures.", + "deleteOcr": "Do you really want to delete '{lang}' OCR data?\nYou will not be able to use these language data when scanning pictures.", "@deleteOcr": { "description": "Shown when a user wants to delete OCR data through settings", "placeholders": { @@ -67,7 +68,7 @@ } } }, - "langDownloadDialog": "Downloading $lang, please wait...", + "langDownloadDialog": "Downloading {lang}, please wait...", "@langDownloadDialog": { "description": "Shown as a title of a dialog while downloading new OCR data", "placeholders": { @@ -77,25 +78,79 @@ } } }, - "langDownloadProgress": "Download progress: $progress %", + "langDownloadProgress": "Download progress: {progress} %", "@langDownloadProgress": { "description": "Progress percentage shown while downloading OCR data", "placeholders": { - "progress":{ - "type":"num", - "example":"99.7" + "progress": { + "type": "num", + "example": "99.7" } } }, "addingFromOcr": "Add from OCR", - "license":"©️ 2023 Matyáš Caras\nReleased under the GNU AGPL license version 3", - "description":"Description", - "newWallet":"Add new wallet", - "walletExists":"A wallet with this name already exists!", - "setupStartingBalance":"Starting balance", - "graphs":"Graphs", - "createTestData":"Create test data", - "yearly":"Yearly", - "monthly":"Monthly", - "expenses":"Expenses" + "license": "©️ 2023 Matyáš Caras\nReleased under the GNU AGPL license version 3", + "description": "Description", + "newWallet": "Add new wallet", + "walletExists": "A wallet with this name already exists!", + "setupStartingBalance": "Starting balance", + "graphs": "Graphs", + "createTestData": "Create test data", + "yearly": "Yearly", + "monthly": "Monthly", + "expenses": "Expenses", + "expensesForMonth": "{month} expenses: {value}", + "@expensesForMonth": { + "description": "Shown as a tooltip when touching expense graph", + "placeholders": { + "month": { + "type": "String", + "example": "January" + }, + "value": { + "type": "String", + "example": "5000 Kč" + } + } + }, + "incomeForMonth": "{month} income: {value}", + "@incomeForMonth": { + "description": "Shown as a tooltip when touching income graph", + "placeholders": { + "month": { + "type": "String", + "example": "January" + }, + "value": { + "type": "String", + "example": "5000 Kč" + } + } + }, + "expensesForDay": "Expenses: {value}", + "@expensesForDay":{ + "description": "Shown as a tooltip when touching expense graph", + "placeholders": { + "value": { + "type": "String", + "example": "5000 Kč" + } + } + }, + "incomeForDay": "Income: {value}", + "@incomeForDay":{ + "description": "Shown as a tooltip when touching expense graph", + "placeholders": { + "value": { + "type": "String", + "example": "5000 Kč" + } + } + }, + "settingsAppearance":"Appearance", + "graphType":"Graph type", + "graphTypeDesc":"Choose whether to show line or bar chart and where", + "lineChart":"Line chart", + "barChart":"Bar chart", + "selectType":"Select type" } \ No newline at end of file diff --git a/lib/util/drawer.dart b/lib/util/drawer.dart index fbc3a20..c49d3ae 100644 --- a/lib/util/drawer.dart +++ b/lib/util/drawer.dart @@ -12,7 +12,7 @@ Drawer makeDrawer(BuildContext context, int page) => Drawer( ListTile( leading: const Icon(Icons.home), title: Text( - AppLocalizations.of(context)!.home, + AppLocalizations.of(context).home, ), selected: page == 1, onTap: () { @@ -27,7 +27,7 @@ Drawer makeDrawer(BuildContext context, int page) => Drawer( ListTile( leading: const Icon(Icons.bar_chart), title: Text( - AppLocalizations.of(context)!.graphs, + AppLocalizations.of(context).graphs, ), selected: page == 2, onTap: () { diff --git a/lib/util/graphs.dart b/lib/util/graphs.dart index 5227e20..06f0305 100644 --- a/lib/util/graphs.dart +++ b/lib/util/graphs.dart @@ -1,21 +1,24 @@ +import 'package:currency_picker/currency_picker.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:prasule/main.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; /// Monthly/Yearly expense/income [LineChart] -class ExpensesChart extends StatelessWidget { - const ExpensesChart( +class ExpensesLineChart extends StatelessWidget { + const ExpensesLineChart( {super.key, required this.date, required this.locale, - this.expenseData = const [], - this.incomeData = const [], + required this.expenseData, + required this.incomeData, + required this.currency, this.yearly = false}); final bool yearly; final DateTime date; final String locale; final List expenseData; + final Currency currency; List get expenseDataSorted { var list = List.from(expenseData); list.sort((a, b) => a.compareTo(b)); @@ -43,10 +46,36 @@ class ExpensesChart extends StatelessWidget { Widget build(BuildContext context) { return LineChart( LineChartData( - maxX: (yearly) ? 12 : DateTime(date.year, date.month, 0).day.toDouble(), + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipItems: (spots) => List.generate( + spots.length, + (index) => LineTooltipItem( + (yearly) + ? AppLocalizations.of(context).expensesForMonth( + DateFormat.MMMM(locale).format( + DateTime(date.year, spots[index].x.toInt() + 1, 1)), + NumberFormat.compactCurrency( + locale: locale, + symbol: currency.symbol, + name: currency.name) + .format(spots[index].y)) + : AppLocalizations.of(context).expensesForDay( + NumberFormat.compactCurrency( + locale: locale, + symbol: currency.symbol, + name: currency.name) + .format(spots[index].y), + ), + TextStyle(color: spots[index].bar.color), + ), + ), + ), + ), maxY: maxY, - minX: 1, + maxX: (yearly) ? 12 : DateTime(date.year, date.month, 0).day.toDouble(), minY: 0, + minX: 0, backgroundColor: Theme.of(context).colorScheme.background, lineBarsData: [ if (incomeData.isNotEmpty) @@ -59,7 +88,7 @@ class ExpensesChart extends StatelessWidget { color: Theme.of(context).colorScheme.primary, spots: List.generate( (yearly) ? 12 : DateTime(date.year, date.month, 0).day, - (index) => FlSpot(index.toDouble() + 1, incomeData[index]), + (index) => FlSpot(index.toDouble(), incomeData[index]), ), ), if (expenseData.isNotEmpty) @@ -85,15 +114,16 @@ class ExpensesChart extends StatelessWidget { ), bottomTitles: AxisTitles( sideTitles: SideTitles( + reservedSize: 30, showTitles: true, getTitlesWidget: (value, meta) { String text; if (yearly) { text = DateFormat.MMM(locale).format( - DateTime(date.year, value.toInt(), 1), + DateTime(date.year, value.toInt() + 1, 1), ); } else { - text = value.toInt().toString(); + text = (value.toInt() + 1).toString(); } return SideTitleWidget( axisSide: meta.axisSide, child: Text(text)); @@ -105,3 +135,141 @@ class ExpensesChart extends StatelessWidget { ); } } + +class ExpensesBarChart extends StatelessWidget { + const ExpensesBarChart( + {super.key, + required this.yearly, + required this.date, + required this.locale, + required this.expenseData, + required this.incomeData, + required this.currency}); + final bool yearly; + final DateTime date; + final String locale; + final List expenseData; + List get expenseDataSorted { + var list = List.from(expenseData); + list.sort((a, b) => a.compareTo(b)); + return list; + } + + final Currency currency; + + final List incomeData; + List get incomeDataSorted { + var list = List.from(incomeData); + list.sort((a, b) => a.compareTo(b)); + return list; + } + + double get maxY { + if (incomeData.isEmpty) return expenseDataSorted.last; + if (expenseData.isEmpty) return incomeDataSorted.last; + if (expenseDataSorted.last > incomeDataSorted.last) { + return expenseDataSorted.last; + } else { + return incomeDataSorted.last; + } + } + + @override + Widget build(BuildContext context) => BarChart( + BarChartData( + barTouchData: BarTouchData( + enabled: true, + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, groupIndex, rod, rodIndex) => + (yearly) // create custom tooltips for graph bars + ? BarTooltipItem( + (rodIndex == 1) + ? AppLocalizations.of(context).expensesForMonth( + DateFormat.MMMM(locale).format( + DateTime(date.year, groupIndex + 1, 1)), + NumberFormat.compactCurrency( + locale: locale, + symbol: currency.symbol, + name: currency.name) + .format(rod.toY), + ) + : AppLocalizations.of(context).incomeForMonth( + DateFormat.MMMM(locale).format( + DateTime(date.year, groupIndex + 1, 1)), + NumberFormat.compactCurrency( + locale: locale, + symbol: currency.symbol, + name: currency.name) + .format(rod.toY), + ), + TextStyle(color: rod.color), + ) + : BarTooltipItem( + (rodIndex == 1) + ? AppLocalizations.of(context).expensesForDay( + NumberFormat.compactCurrency( + locale: locale, + symbol: currency.symbol, + name: currency.name) + .format(rod.toY), + ) + : AppLocalizations.of(context).incomeForDay( + NumberFormat.compactCurrency( + locale: locale, + symbol: currency.symbol, + name: currency.name) + .format(rod.toY), + ), + TextStyle(color: rod.color), + ), + ), + ), + titlesData: FlTitlesData( + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30, + getTitlesWidget: (value, meta) { + String text; + if (yearly) { + text = DateFormat.MMM(locale).format( + DateTime(date.year, value.toInt() + 1, 1), + ); + } else { + text = (value.toInt() + 1).toString(); + } + return SideTitleWidget( + axisSide: meta.axisSide, child: Text(text)); + }, + ), + ), + ), // axis descriptions, + minY: 0, + maxY: maxY, + barGroups: List.generate( + (yearly) ? 12 : DateTime(date.year, date.month, 0).day, + (index) => BarChartGroupData( + x: index, + barRods: [ + if (incomeData.isNotEmpty) + BarChartRodData( + toY: incomeData[index], + color: Theme.of(context).colorScheme.primary, + ), + if (expenseData.isNotEmpty) + BarChartRodData( + toY: expenseData[index], + color: Theme.of(context).colorScheme.error, + ), + ], + ), + ), + ), + ); +} diff --git a/lib/views/create_entry.dart b/lib/views/create_entry.dart index 9a8298c..57f2541 100644 --- a/lib/views/create_entry.dart +++ b/lib/views/create_entry.dart @@ -40,7 +40,7 @@ class _CreateEntryViewState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text(AppLocalizations.of(context)!.createEntry), + title: Text(AppLocalizations.of(context).createEntry), ), body: SizedBox( width: MediaQuery.of(context).size.width, @@ -53,7 +53,7 @@ class _CreateEntryViewState extends State { SizedBox( width: MediaQuery.of(context).size.width * 0.8, child: PlatformField( - labelText: AppLocalizations.of(context)!.name, + labelText: AppLocalizations.of(context).name, controller: TextEditingController(text: newEntry.data.name), onChanged: (v) { newEntry.data.name = v; @@ -66,7 +66,7 @@ class _CreateEntryViewState extends State { SizedBox( width: MediaQuery.of(context).size.width * 0.8, child: PlatformField( - labelText: AppLocalizations.of(context)!.amount, + labelText: AppLocalizations.of(context).amount, controller: TextEditingController( text: newEntry.data.amount.toString()), keyboardType: @@ -83,7 +83,7 @@ class _CreateEntryViewState extends State { const SizedBox( height: 20, ), - Text(AppLocalizations.of(context)!.type), + Text(AppLocalizations.of(context).type), const SizedBox( height: 10, ), @@ -97,7 +97,7 @@ class _CreateEntryViewState extends State { child: SizedBox( width: MediaQuery.of(context).size.width * 0.8 - 24, child: Text( - AppLocalizations.of(context)!.expense, + AppLocalizations.of(context).expense, ), ), ), @@ -105,7 +105,7 @@ class _CreateEntryViewState extends State { value: EntryType.income, child: SizedBox( width: MediaQuery.of(context).size.width * 0.8 - 24, - child: Text(AppLocalizations.of(context)!.income), + child: Text(AppLocalizations.of(context).income), ), ), ], @@ -119,7 +119,7 @@ class _CreateEntryViewState extends State { const SizedBox( height: 20, ), - Text(AppLocalizations.of(context)!.category), + Text(AppLocalizations.of(context).category), const SizedBox( height: 10, ), @@ -151,7 +151,7 @@ class _CreateEntryViewState extends State { const SizedBox( height: 20, ), - Text(AppLocalizations.of(context)!.description), + Text(AppLocalizations.of(context).description), const SizedBox( height: 10, ), @@ -175,14 +175,14 @@ class _CreateEntryViewState extends State { height: 15, ), PlatformButton( - text: AppLocalizations.of(context)!.save, + text: AppLocalizations.of(context).save, onPressed: () { if (newEntry.data.name.isEmpty) { ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - AppLocalizations.of(context)!.errorEmptyName), + AppLocalizations.of(context).errorEmptyName), ), ); return; diff --git a/lib/views/graph_view.dart b/lib/views/graph_view.dart index ed69922..80ed028 100644 --- a/lib/views/graph_view.dart +++ b/lib/views/graph_view.dart @@ -1,4 +1,3 @@ -import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:prasule/api/category.dart'; @@ -12,6 +11,7 @@ import 'package:prasule/util/graphs.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:prasule/views/settings/settings.dart'; import 'package:prasule/views/setup.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class GraphView extends StatefulWidget { const GraphView({super.key}); @@ -71,10 +71,15 @@ class _GraphViewState extends State { setState(() {}); } + int? chartType; @override void initState() { super.initState(); loadWallet(); + SharedPreferences.getInstance().then((s) { + chartType = s.getInt("monthlygraph") ?? 2; + setState(() {}); + }); } @override @@ -95,7 +100,7 @@ class _GraphViewState extends State { ), DropdownMenuItem( value: -1, - child: Text(AppLocalizations.of(context)!.newWallet), + child: Text(AppLocalizations.of(context).newWallet), ) ], onChanged: (v) async { @@ -120,20 +125,20 @@ class _GraphViewState extends State { actions: [ PopupMenuButton( itemBuilder: (context) => [ - AppLocalizations.of(context)!.settings, - AppLocalizations.of(context)!.about + AppLocalizations.of(context).settings, + AppLocalizations.of(context).about ].map((e) => PopupMenuItem(value: e, child: Text(e))).toList(), onSelected: (value) { - if (value == AppLocalizations.of(context)!.settings) { + if (value == AppLocalizations.of(context).settings) { Navigator.of(context).push( MaterialPageRoute( builder: (context) => const SettingsView(), ), ); - } else if (value == AppLocalizations.of(context)!.about) { + } else if (value == AppLocalizations.of(context).about) { showAboutDialog( context: context, - applicationLegalese: AppLocalizations.of(context)!.license, + applicationLegalese: AppLocalizations.of(context).license, applicationName: "Prašule"); } }, @@ -143,103 +148,153 @@ class _GraphViewState extends State { drawer: makeDrawer(context, 2), body: SingleChildScrollView( child: Center( - child: SizedBox( - width: MediaQuery.of(context).size.width * 0.9, - height: MediaQuery.of(context).size.height, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SegmentedButton( - segments: [ - ButtonSegment( - value: "expense", - label: Text(AppLocalizations.of(context)!.expenses), - ), - ButtonSegment( - value: "income", - label: Text(AppLocalizations.of(context)!.income), - ), - ], - selected: graphTypeSet, - multiSelectionEnabled: true, - onSelectionChanged: (selection) { - graphTypeSet = selection; - setState(() {}); - }, - ), - const SizedBox( - height: 5, - ), - SegmentedButton( - segments: [ - ButtonSegment( - value: "yearly", - label: Text(AppLocalizations.of(context)!.yearly), - ), - ButtonSegment( - value: "monthly", - label: Text(AppLocalizations.of(context)!.monthly), - ), - ], - selected: yearlyBtnSet, - onSelectionChanged: (selection) { - yearlyBtnSet = selection; - setState(() {}); - }, - ), - const SizedBox(height: 5), - PlatformButton( - text: (yearly) - ? DateFormat.y(locale).format(_selectedDate) - : DateFormat.yMMMM(locale).format(_selectedDate), - onPressed: () async { - var firstDate = (selectedWallet!.entries - ..sort((a, b) => a.date.compareTo(b.date))) - .first - .date; - var lastDate = (selectedWallet!.entries - ..sort((a, b) => b.date.compareTo(a.date))) - .first - .date; - logger.i(firstDate); - logger.i(lastDate); - var newDate = await showDatePicker( - context: context, - initialDate: _selectedDate, - firstDate: firstDate, - lastDate: lastDate, - initialEntryMode: (yearly) - ? DatePickerEntryMode.input - : DatePickerEntryMode.calendar, - initialDatePickerMode: (yearly) - ? DatePickerMode.year - : DatePickerMode.day); - if (newDate == null) return; - _selectedDate = newDate; - setState(() {}); - }, - ), - const SizedBox( - height: 5, - ), - SizedBox( - width: MediaQuery.of(context).size.width * 0.9, - height: 300, - child: ExpensesChart( - date: _selectedDate, - locale: locale ?? "en", - yearly: yearly, - expenseData: (graphTypeSet.contains("expense")) - ? generateChartData(EntryType.expense) - : [], - incomeData: (graphTypeSet.contains("income")) - ? generateChartData(EntryType.income) - : [], - ), + child: (selectedWallet == null) + ? const CircularProgressIndicator( + strokeWidth: 5, ) - ], - ), - ), + : SizedBox( + width: MediaQuery.of(context).size.width * 0.9, + height: MediaQuery.of(context).size.height, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SegmentedButton( + segments: [ + ButtonSegment( + value: "expense", + label: Text(AppLocalizations.of(context).expenses), + ), + ButtonSegment( + value: "income", + label: Text(AppLocalizations.of(context).income), + ), + ], + selected: graphTypeSet, + multiSelectionEnabled: true, + onSelectionChanged: (selection) { + graphTypeSet = selection; + setState(() {}); + }, + ), + const SizedBox( + height: 5, + ), + SegmentedButton( + segments: [ + ButtonSegment( + value: "yearly", + label: Text(AppLocalizations.of(context).yearly), + ), + ButtonSegment( + value: "monthly", + label: Text(AppLocalizations.of(context).monthly), + ), + ], + selected: yearlyBtnSet, + onSelectionChanged: (selection) async { + yearlyBtnSet = selection; + var s = await SharedPreferences.getInstance(); + chartType = (yearly) + ? (s.getInt("yearlygraph") ?? 1) + : (s.getInt("monthlygraph") ?? 2); + setState(() {}); + }, + ), + const SizedBox(height: 5), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context) + .colorScheme + .secondaryContainer), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + PlatformButton( + text: (yearly) + ? DateFormat.y(locale).format(_selectedDate) + : DateFormat.yMMMM(locale) + .format(_selectedDate), + onPressed: () async { + var firstDate = (selectedWallet!.entries + ..sort( + (a, b) => a.date.compareTo(b.date))) + .first + .date; + var lastDate = (selectedWallet!.entries + ..sort( + (a, b) => b.date.compareTo(a.date))) + .first + .date; + logger.i(firstDate); + logger.i(lastDate); + var newDate = await showDatePicker( + context: context, + initialDate: DateTime(_selectedDate.year, + _selectedDate.month, 1), + firstDate: firstDate, + lastDate: lastDate, + initialEntryMode: (yearly) + ? DatePickerEntryMode.input + : DatePickerEntryMode.calendar, + initialDatePickerMode: (yearly) + ? DatePickerMode.year + : DatePickerMode.day); + if (newDate == null) return; + _selectedDate = newDate; + setState(() {}); + }, + ), + const SizedBox( + height: 5, + ), + SizedBox( + width: MediaQuery.of(context).size.width * 0.9, + height: 300, + child: (chartType == null) + ? const CircularProgressIndicator() + : (chartType == 1) + ? ExpensesBarChart( + currency: selectedWallet!.currency, + date: _selectedDate, + locale: locale ?? "en", + yearly: yearly, + expenseData: (graphTypeSet + .contains("expense")) + ? generateChartData( + EntryType.expense) + : [], + incomeData: (graphTypeSet + .contains("income")) + ? generateChartData( + EntryType.income) + : [], + ) + : ExpensesLineChart( + currency: selectedWallet!.currency, + date: _selectedDate, + locale: locale ?? "en", + yearly: yearly, + expenseData: (graphTypeSet + .contains("expense")) + ? generateChartData( + EntryType.expense) + : [], + incomeData: (graphTypeSet + .contains("income")) + ? generateChartData( + EntryType.income) + : [], + ), + ) + ], + ), + ), + ) + ], + ), + ), ), ), ); diff --git a/lib/views/home.dart b/lib/views/home.dart index cf38e6f..23b8dc7 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -72,7 +72,7 @@ class _HomeViewState extends State { if (kDebugMode) SpeedDialChild( child: const Icon(Icons.bug_report), - label: AppLocalizations.of(context)!.createTestData, + label: AppLocalizations.of(context).createTestData, onTap: () { // debug option to quickly fill a wallet with data if (selectedWallet == null) return; @@ -114,7 +114,7 @@ class _HomeViewState extends State { ), SpeedDialChild( child: const Icon(Icons.edit), - label: AppLocalizations.of(context)!.addNew, + label: AppLocalizations.of(context).addNew, onTap: () async { var sw = await Navigator.of(context).push( MaterialPageRoute( @@ -128,7 +128,7 @@ class _HomeViewState extends State { }), SpeedDialChild( child: const Icon(Icons.camera_alt), - label: AppLocalizations.of(context)!.addCamera, + label: AppLocalizations.of(context).addCamera, onTap: () async { final ImagePicker picker = ImagePicker(); final XFile? media = @@ -138,7 +138,7 @@ class _HomeViewState extends State { ), SpeedDialChild( child: const Icon(Icons.image), - label: AppLocalizations.of(context)!.addGallery, + label: AppLocalizations.of(context).addGallery, onTap: () { startOcr(ImageSource.gallery); }, @@ -160,7 +160,7 @@ class _HomeViewState extends State { ), DropdownMenuItem( value: -1, - child: Text(AppLocalizations.of(context)!.newWallet), + child: Text(AppLocalizations.of(context).newWallet), ) ], onChanged: (v) async { @@ -185,20 +185,20 @@ class _HomeViewState extends State { actions: [ PopupMenuButton( itemBuilder: (context) => [ - AppLocalizations.of(context)!.settings, - AppLocalizations.of(context)!.about + AppLocalizations.of(context).settings, + AppLocalizations.of(context).about ].map((e) => PopupMenuItem(value: e, child: Text(e))).toList(), onSelected: (value) { - if (value == AppLocalizations.of(context)!.settings) { + if (value == AppLocalizations.of(context).settings) { Navigator.of(context).push( MaterialPageRoute( builder: (context) => const SettingsView(), ), ); - } else if (value == AppLocalizations.of(context)!.about) { + } else if (value == AppLocalizations.of(context).about) { showAboutDialog( context: context, - applicationLegalese: AppLocalizations.of(context)!.license, + applicationLegalese: AppLocalizations.of(context).license, applicationName: "Prašule"); } }, @@ -223,14 +223,14 @@ class _HomeViewState extends State { ? Column( children: [ Text( - AppLocalizations.of(context)!.noEntries, + AppLocalizations.of(context).noEntries, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, ), ), Text( - AppLocalizations.of(context)!.noEntriesSub, + AppLocalizations.of(context).noEntriesSub, ) ], ) @@ -307,12 +307,12 @@ class _HomeViewState extends State { context: context, builder: (cx) => PlatformDialog( title: - AppLocalizations.of(context)!.sureDialog, + AppLocalizations.of(context).sureDialog, content: Text( - AppLocalizations.of(context)!.deleteSure), + AppLocalizations.of(context).deleteSure), actions: [ PlatformButton( - text: AppLocalizations.of(context)!.yes, + text: AppLocalizations.of(context).yes, onPressed: () { selectedWallet?.entries.removeWhere( (e) => e.id == element.id); @@ -323,7 +323,7 @@ class _HomeViewState extends State { }, ), PlatformButton( - text: AppLocalizations.of(context)!.no, + text: AppLocalizations.of(context).no, onPressed: () { Navigator.of(cx).pop(); }, @@ -365,9 +365,9 @@ class _HomeViewState extends State { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.missingOcr), + content: Text(AppLocalizations.of(context).missingOcr), action: SnackBarAction( - label: AppLocalizations.of(context)!.download, + label: AppLocalizations.of(context).download, onPressed: () { Navigator.of(context).push( MaterialPageRoute( @@ -408,7 +408,7 @@ class _HomeViewState extends State { showDialog( context: context, builder: (c) => PlatformDialog( - title: AppLocalizations.of(context)!.ocrLoading), + title: AppLocalizations.of(context).ocrLoading), barrierDismissible: false); var string = await FlutterTesseractOcr.extractText(media.path, language: selected, @@ -472,7 +472,7 @@ class _HomeViewState extends State { child: const Text("Cancel"), ), ], - title: AppLocalizations.of(context)!.ocrSelect, + title: AppLocalizations.of(context).ocrSelect, content: Column( children: [ ...List.generate( diff --git a/lib/views/settings/graph_type.dart b/lib/views/settings/graph_type.dart new file mode 100644 index 0000000..9b5eb5f --- /dev/null +++ b/lib/views/settings/graph_type.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:prasule/pw/platformdialog.dart'; +import 'package:settings_ui/settings_ui.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class GraphTypeSettingsView extends StatefulWidget { + const GraphTypeSettingsView({super.key}); + + @override + State createState() => _GraphTypeSettingsViewState(); +} + +class _GraphTypeSettingsViewState extends State { + var _yearly = 1; + var _monthly = 2; + @override + void initState() { + super.initState(); + SharedPreferences.getInstance().then((prefs) { + _yearly = prefs.getInt("yearlygraph") ?? 1; + _monthly = prefs.getInt("monthlygraph") ?? 2; + setState(() {}); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context).graphType), + ), + body: SettingsList( + applicationType: ApplicationType.both, + darkTheme: SettingsThemeData( + settingsListBackground: Theme.of(context).colorScheme.background, + titleTextColor: Theme.of(context).colorScheme.primary), + sections: [ + SettingsSection( + tiles: [ + SettingsTile.navigation( + title: Text(AppLocalizations.of(context).yearly), + value: Text(_yearly == 1 + ? AppLocalizations.of(context).barChart + : AppLocalizations.of(context).lineChart), + onPressed: (c) => showDialog( + context: c, + builder: (ctx) => PlatformDialog( + title: AppLocalizations.of(context).selectType, + content: Column( + children: [ + SizedBox( + width: MediaQuery.of(ctx).size.width, + child: InkWell( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text(AppLocalizations.of(context).barChart, + textAlign: TextAlign.center), + ), + onTap: () async { + var s = await SharedPreferences.getInstance(); + s.setInt("yearlygraph", 1); + _yearly = 1; + if (!mounted) return; + Navigator.of(ctx).pop(); + setState(() {}); + }, + ), + ), + SizedBox( + width: MediaQuery.of(context).size.width, + child: InkWell( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + AppLocalizations.of(context).lineChart, + textAlign: TextAlign.center, + ), + ), + onTap: () async { + var s = await SharedPreferences.getInstance(); + s.setInt("yearlygraph", 2); + _yearly = 2; + if (!mounted) return; + Navigator.of(ctx).pop(); + setState(() {}); + }, + ), + ), + ], + ), + ), + ), + ), + SettingsTile.navigation( + title: Text(AppLocalizations.of(context).monthly), + value: Text(_monthly == 1 + ? AppLocalizations.of(context).barChart + : AppLocalizations.of(context).lineChart), + onPressed: (c) => showDialog( + context: c, + builder: (ctx) => PlatformDialog( + title: AppLocalizations.of(context).selectType, + content: Column( + children: [ + SizedBox( + width: MediaQuery.of(ctx).size.width, + child: InkWell( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + AppLocalizations.of(context).barChart, + textAlign: TextAlign.center, + ), + ), + onTap: () async { + var s = await SharedPreferences.getInstance(); + s.setInt("monthlygraph", 1); + _monthly = 1; + if (!mounted) return; + Navigator.of(ctx).pop(); + setState(() {}); + }, + ), + ), + SizedBox( + width: MediaQuery.of(ctx).size.width, + child: InkWell( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + AppLocalizations.of(context).lineChart, + textAlign: TextAlign.center), + ), + onTap: () async { + var s = await SharedPreferences.getInstance(); + s.setInt("monthlygraph", 2); + _monthly = 2; + if (!mounted) return; + Navigator.of(ctx).pop(); + setState(() {}); + }, + ), + ), + ], + ), + ), + ), + ), + ], + ) + ], + ), + ); + } +} diff --git a/lib/views/settings/settings.dart b/lib/views/settings/settings.dart index 7bd4a31..6bd5e63 100644 --- a/lib/views/settings/settings.dart +++ b/lib/views/settings/settings.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:prasule/pw/platformroute.dart'; +import 'package:prasule/views/settings/graph_type.dart'; import 'package:prasule/views/settings/tessdata_list.dart'; import 'package:settings_ui/settings_ui.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -14,7 +16,7 @@ class _SettingsViewState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)), + appBar: AppBar(title: Text(AppLocalizations.of(context).settings)), body: SettingsList( applicationType: ApplicationType.both, darkTheme: SettingsThemeData( @@ -24,16 +26,31 @@ class _SettingsViewState extends State { SettingsSection( tiles: [ SettingsTile.navigation( - title: Text(AppLocalizations.of(context)!.downloadedOcr), + title: Text(AppLocalizations.of(context).downloadedOcr), description: - Text(AppLocalizations.of(context)!.downloadedOcrDesc), - onPressed: (context) => Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => const TessdataListView())), + Text(AppLocalizations.of(context).downloadedOcrDesc), + trailing: const Icon(Icons.keyboard_arrow_right), + onPressed: (context) => Navigator.of(context) + .push(platformRoute((c) => const TessdataListView())), ) ], - title: Text(AppLocalizations.of(context)!.ocr), + title: Text(AppLocalizations.of(context).ocr), ), + SettingsSection( + title: Text(AppLocalizations.of(context).settingsAppearance), + tiles: [ + SettingsTile.navigation( + title: Text(AppLocalizations.of(context).graphType), + description: Text(AppLocalizations.of(context).graphTypeDesc), + trailing: const Icon(Icons.keyboard_arrow_right), + onPressed: (c) => Navigator.of(c).push( + platformRoute( + (p0) => const GraphTypeSettingsView(), + ), + ), + ) + ], + ) ], ), ); diff --git a/lib/views/settings/tessdata_list.dart b/lib/views/settings/tessdata_list.dart index 472eec1..6fcee0c 100644 --- a/lib/views/settings/tessdata_list.dart +++ b/lib/views/settings/tessdata_list.dart @@ -29,7 +29,7 @@ class _TessdataListViewState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text(AppLocalizations.of(context)!.ocrData)), + appBar: AppBar(title: Text(AppLocalizations.of(context).ocrData)), body: Center( child: SizedBox( width: MediaQuery.of(context).size.width * 0.9, @@ -50,8 +50,8 @@ class _TessdataListViewState extends State { title: Text(_tessdata[i].keys.first), trailing: TextButton( child: Text(_tessdata[i][_tessdata[i].keys.first]! - ? AppLocalizations.of(context)!.downloaded - : AppLocalizations.of(context)!.download), + ? AppLocalizations.of(context).downloaded + : AppLocalizations.of(context).download), onPressed: () async { var lang = _tessdata[i].keys.first; if (_tessdata[i][lang]!) { @@ -59,12 +59,12 @@ class _TessdataListViewState extends State { showDialog( context: context, builder: (context) => PlatformDialog( - title: AppLocalizations.of(context)!.sureDialog, - content: Text(AppLocalizations.of(context)! + title: AppLocalizations.of(context).sureDialog, + content: Text(AppLocalizations.of(context) .deleteOcr(lang)), actions: [ PlatformButton( - text: AppLocalizations.of(context)!.yes, + text: AppLocalizations.of(context).yes, onPressed: () async { await TessdataApi.deleteData(lang); _tessdata[i][lang] = true; @@ -72,7 +72,7 @@ class _TessdataListViewState extends State { }, ), PlatformButton( - text: AppLocalizations.of(context)!.no, + text: AppLocalizations.of(context).no, onPressed: () { Navigator.of(context).pop(); }, @@ -91,7 +91,7 @@ class _TessdataListViewState extends State { showDialog( context: context, builder: (c) => PlatformDialog( - title: AppLocalizations.of(context)! + title: AppLocalizations.of(context) .langDownloadDialog(lang), content: StreamBuilder( builder: (context, snapshot) { @@ -102,7 +102,7 @@ class _TessdataListViewState extends State { if (snapshot.hasError) { return const Text("Error"); } - return Text(AppLocalizations.of(context)! + return Text(AppLocalizations.of(context) .langDownloadProgress(snapshot.data!)); }, stream: progressStream.stream, diff --git a/lib/views/setup.dart b/lib/views/setup.dart index 24dfbd7..9cebdbc 100644 --- a/lib/views/setup.dart +++ b/lib/views/setup.dart @@ -45,28 +45,28 @@ class _SetupViewState extends State { if (categories.isEmpty) { categories = [ WalletCategory( - name: AppLocalizations.of(context)!.categoryHealth, + name: AppLocalizations.of(context).categoryHealth, type: EntryType.expense, id: 1, icon: IconData(Icons.medical_information.codePoint, fontFamily: 'MaterialIcons'), ), WalletCategory( - name: AppLocalizations.of(context)!.categoryCar, + name: AppLocalizations.of(context).categoryCar, type: EntryType.expense, id: 2, icon: IconData(Icons.car_repair.codePoint, fontFamily: 'MaterialIcons'), ), WalletCategory( - name: AppLocalizations.of(context)!.categoryFood, + name: AppLocalizations.of(context).categoryFood, type: EntryType.expense, id: 3, icon: IconData(Icons.restaurant.codePoint, fontFamily: 'MaterialIcons'), ), WalletCategory( - name: AppLocalizations.of(context)!.categoryTravel, + name: AppLocalizations.of(context).categoryTravel, type: EntryType.expense, id: 4, icon: IconData(Icons.train.codePoint, fontFamily: 'MaterialIcons'), @@ -90,16 +90,16 @@ class _SetupViewState extends State { showNextButton: true, showBackButton: true, showDoneButton: true, - next: Text(AppLocalizations.of(context)!.next), - back: Text(AppLocalizations.of(context)!.back), - done: Text(AppLocalizations.of(context)!.finish), + next: Text(AppLocalizations.of(context).next), + back: Text(AppLocalizations.of(context).back), + done: Text(AppLocalizations.of(context).finish), onDone: () { if (name.isEmpty) { ScaffoldMessenger.of(context) .clearSnackBars(); // TODO: iOS replacement ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: - Text(AppLocalizations.of(context)!.errorEmptyName))); + Text(AppLocalizations.of(context).errorEmptyName))); return; } var wallet = Wallet( @@ -113,7 +113,7 @@ class _SetupViewState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: - Text(AppLocalizations.of(context)!.walletExists), + Text(AppLocalizations.of(context).walletExists), ), ); return; @@ -137,7 +137,7 @@ class _SetupViewState extends State { titleWidget: Padding( padding: const EdgeInsets.all(8), child: Text( - AppLocalizations.of(context)!.welcome, + AppLocalizations.of(context).welcome, style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold), textAlign: TextAlign.center, @@ -150,7 +150,7 @@ class _SetupViewState extends State { if (!widget.newWallet) Flexible( child: Text( - AppLocalizations.of(context)!.welcomeAboutPrasule), + AppLocalizations.of(context).welcomeAboutPrasule), ), if (!widget.newWallet) const SizedBox( @@ -158,7 +158,7 @@ class _SetupViewState extends State { ), Flexible( child: Text( - AppLocalizations.of(context)!.welcomeInstruction), + AppLocalizations.of(context).welcomeInstruction), ), ], ), @@ -169,7 +169,7 @@ class _SetupViewState extends State { titleWidget: Padding( padding: const EdgeInsets.all(8), child: Text( - AppLocalizations.of(context)!.setupWalletNameCurrency, + AppLocalizations.of(context).setupWalletNameCurrency, textAlign: TextAlign.center, style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold), @@ -182,7 +182,7 @@ class _SetupViewState extends State { width: MediaQuery.of(context).size.width * 0.7, child: PlatformField( labelText: - AppLocalizations.of(context)!.setupNamePlaceholder, + AppLocalizations.of(context).setupNamePlaceholder, onChanged: (t) { name = t; }, @@ -192,7 +192,7 @@ class _SetupViewState extends State { height: 5, ), PlatformButton( - text: AppLocalizations.of(context)! + text: AppLocalizations.of(context) .setupCurrency(_selectedCurrency.code), onPressed: () { showCurrencyPicker( @@ -211,7 +211,7 @@ class _SetupViewState extends State { width: MediaQuery.of(context).size.width * 0.7, child: PlatformField( labelText: - AppLocalizations.of(context)!.setupStartingBalance, + AppLocalizations.of(context).setupStartingBalance, keyboardType: const TextInputType.numberWithOptions( decimal: true), inputFormatters: [ @@ -233,7 +233,7 @@ class _SetupViewState extends State { titleWidget: Padding( padding: const EdgeInsets.all(8), child: Text( - AppLocalizations.of(context)!.setupCategoriesHeading, + AppLocalizations.of(context).setupCategoriesHeading, textAlign: TextAlign.center, style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold), @@ -243,7 +243,7 @@ class _SetupViewState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - AppLocalizations.of(context)!.setupCategoriesEditHint, + AppLocalizations.of(context).setupCategoriesEditHint, textAlign: TextAlign.center, ), SizedBox( @@ -296,17 +296,17 @@ class _SetupViewState extends State { Navigator.of(context).pop(); }, child: Text( - AppLocalizations.of(context)!.ok), + AppLocalizations.of(context).ok), ), TextButton( onPressed: () { Navigator.of(context).pop(); }, child: Text( - AppLocalizations.of(context)!.cancel), + AppLocalizations.of(context).cancel), ), ], - title: AppLocalizations.of(context)! + title: AppLocalizations.of(context) .setupCategoriesEditingName, content: SizedBox( width: 400, @@ -336,7 +336,7 @@ class _SetupViewState extends State { } categories.add( WalletCategory( - name: AppLocalizations.of(context)! + name: AppLocalizations.of(context) .setupWalletNamePlaceholder, type: EntryType.expense, id: id, diff --git a/pubspec.lock b/pubspec.lock index b4e2c3c..d8bb9d3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -876,6 +876,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "7bf53a9f2d007329ee6f3df7268fd498f8373602f943c975598bbb34649b62a7" + url: "https://pub.dev" + source: hosted + version: "2.3.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: d4ec5fc9ebb2f2e056c617112aa75dcf92fc2e4faaf2ae999caa297473f75d8a + url: "https://pub.dev" + source: hosted + version: "2.3.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: "7b15ffb9387ea3e237bb7a66b8a23d2147663d391cafc5c8f37b2e7b4bde5d21" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" + url: "https://pub.dev" + source: hosted + version: "2.3.2" shelf: dependency: transitive description: @@ -1131,4 +1187,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.2.0 <4.0.0" - flutter: ">=3.10.0" + flutter: ">=3.16.0" diff --git a/pubspec.yaml b/pubspec.yaml index 8f0235b..d642b16 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: prasule description: Open-source private expense tracker -version: 1.0.0-alpha2+2 +version: 1.0.0-alpha.2+2 environment: sdk: '>=3.1.0-262.2.beta <4.0.0' @@ -39,6 +39,7 @@ dependencies: flutter_localizations: sdk: flutter fl_chart: ^0.65.0 + shared_preferences: ^2.2.2 dev_dependencies: flutter_test: From 512de4deff1746868bfe657b675baa8fb5dbb8c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Mon, 25 Dec 2023 19:26:46 +0100 Subject: [PATCH 05/17] fix(graphs): correct tooltip text --- lib/util/graphs.dart | 49 +++++++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/lib/util/graphs.dart b/lib/util/graphs.dart index 06f0305..b4a2bef 100644 --- a/lib/util/graphs.dart +++ b/lib/util/graphs.dart @@ -51,22 +51,39 @@ class ExpensesLineChart extends StatelessWidget { getTooltipItems: (spots) => List.generate( spots.length, (index) => LineTooltipItem( - (yearly) - ? AppLocalizations.of(context).expensesForMonth( - DateFormat.MMMM(locale).format( - DateTime(date.year, spots[index].x.toInt() + 1, 1)), - NumberFormat.compactCurrency( - locale: locale, - symbol: currency.symbol, - name: currency.name) - .format(spots[index].y)) - : AppLocalizations.of(context).expensesForDay( - NumberFormat.compactCurrency( - locale: locale, - symbol: currency.symbol, - name: currency.name) - .format(spots[index].y), - ), + (spots[index].barIndex == 0) + ? (yearly + ? AppLocalizations.of(context).incomeForMonth( + DateFormat.MMMM(locale).format(DateTime( + date.year, spots[index].x.toInt() + 1, 1)), + NumberFormat.compactCurrency( + locale: locale, + symbol: currency.symbol, + name: currency.name) + .format(spots[index].y)) + : AppLocalizations.of(context).incomeForDay( + NumberFormat.compactCurrency( + locale: locale, + symbol: currency.symbol, + name: currency.name) + .format(spots[index].y), + )) + : (yearly + ? AppLocalizations.of(context).expensesForMonth( + DateFormat.MMMM(locale).format(DateTime( + date.year, spots[index].x.toInt() + 1, 1)), + NumberFormat.compactCurrency( + locale: locale, + symbol: currency.symbol, + name: currency.name) + .format(spots[index].y)) + : AppLocalizations.of(context).expensesForDay( + NumberFormat.compactCurrency( + locale: locale, + symbol: currency.symbol, + name: currency.name) + .format(spots[index].y), + )), TextStyle(color: spots[index].bar.color), ), ), From 9fa35660dfbf5688e50fa61b443aaa35bf071492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Mon, 25 Dec 2023 19:54:30 +0100 Subject: [PATCH 06/17] feat(graphs): change style --- lib/pw/platformbutton.dart | 10 +++++++--- lib/util/graphs.dart | 13 +++++++++---- lib/views/graph_view.dart | 9 ++++++++- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/lib/pw/platformbutton.dart b/lib/pw/platformbutton.dart index 901101d..aa49d08 100644 --- a/lib/pw/platformbutton.dart +++ b/lib/pw/platformbutton.dart @@ -5,12 +5,16 @@ import 'package:prasule/pw/platformwidget.dart'; class PlatformButton extends PlatformWidget { final String text; final void Function()? onPressed; + final ButtonStyle? style; const PlatformButton( - {super.key, required this.text, required this.onPressed}); + {super.key, required this.text, required this.onPressed, this.style}); @override - TextButton createAndroidWidget(BuildContext context) => - TextButton(onPressed: onPressed, child: Text(text)); + TextButton createAndroidWidget(BuildContext context) => TextButton( + onPressed: onPressed, + style: style, + child: Text(text), + ); @override CupertinoButton createIosWidget(BuildContext context) => diff --git a/lib/util/graphs.dart b/lib/util/graphs.dart index b4a2bef..5a21330 100644 --- a/lib/util/graphs.dart +++ b/lib/util/graphs.dart @@ -1,4 +1,5 @@ import 'package:currency_picker/currency_picker.dart'; +import 'package:dynamic_color/dynamic_color.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -102,7 +103,8 @@ class ExpensesLineChart extends StatelessWidget { isStrokeCapRound: true, dotData: const FlDotData(show: false), belowBarData: BarAreaData(show: false), - color: Theme.of(context).colorScheme.primary, + color: Colors.green + .harmonizeWith(Theme.of(context).colorScheme.secondary), spots: List.generate( (yearly) ? 12 : DateTime(date.year, date.month, 0).day, (index) => FlSpot(index.toDouble(), incomeData[index]), @@ -115,7 +117,8 @@ class ExpensesLineChart extends StatelessWidget { isStrokeCapRound: true, dotData: const FlDotData(show: false), belowBarData: BarAreaData(show: false), - color: Theme.of(context).colorScheme.error, + color: Colors.red + .harmonizeWith(Theme.of(context).colorScheme.secondary), spots: List.generate( (yearly) ? 12 : DateTime(date.year, date.month, 0).day, (index) => FlSpot(index.toDouble() + 1, expenseData[index]), @@ -277,12 +280,14 @@ class ExpensesBarChart extends StatelessWidget { if (incomeData.isNotEmpty) BarChartRodData( toY: incomeData[index], - color: Theme.of(context).colorScheme.primary, + color: Colors.green + .harmonizeWith(Theme.of(context).colorScheme.secondary), ), if (expenseData.isNotEmpty) BarChartRodData( toY: expenseData[index], - color: Theme.of(context).colorScheme.error, + color: Colors.red + .harmonizeWith(Theme.of(context).colorScheme.secondary), ), ], ), diff --git a/lib/views/graph_view.dart b/lib/views/graph_view.dart index 80ed028..188e231 100644 --- a/lib/views/graph_view.dart +++ b/lib/views/graph_view.dart @@ -153,7 +153,7 @@ class _GraphViewState extends State { strokeWidth: 5, ) : SizedBox( - width: MediaQuery.of(context).size.width * 0.9, + width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -212,6 +212,13 @@ class _GraphViewState extends State { child: Column( children: [ PlatformButton( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all( + Theme.of(context).colorScheme.primary), + foregroundColor: MaterialStateProperty.all( + Theme.of(context) + .colorScheme + .onPrimary)), text: (yearly) ? DateFormat.y(locale).format(_selectedDate) : DateFormat.yMMMM(locale) From 057954f6ce3c6c68ab4682059ce2d8dfb12309dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Mon, 25 Dec 2023 20:29:51 +0100 Subject: [PATCH 07/17] feat: allow disabling Material You --- lib/l10n/app_cs.arb | 4 ++- lib/l10n/app_en.arb | 4 ++- lib/main.dart | 51 ++++++++++++++++++++------------ lib/views/settings/settings.dart | 29 +++++++++++++++++- 4 files changed, 66 insertions(+), 22 deletions(-) diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index 566df2c..fc53ec4 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -72,6 +72,8 @@ "graphTypeDesc":"Zvolte, zda-li použít sloupcový, nebo spojnicový graf, a kde", "lineChart":"Spojnicový", "barChart":"Sloupcový", - "selectType":"Zvolte typ" + "selectType":"Zvolte typ", + "enableYou":"Povolit Material You (Může vyžadovat restart aplikace)", + "enableYouDesc":"Aplikace použije barevné schéma z vaší tapety" } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e0ab041..875aeaa 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -152,5 +152,7 @@ "graphTypeDesc":"Choose whether to show line or bar chart and where", "lineChart":"Line chart", "barChart":"Bar chart", - "selectType":"Select type" + "selectType":"Select type", + "enableYou":"Enable Material You (May require an app restart)", + "enableYouDesc":"The app will use a color scheme from your wallpaper" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 5ccc47b..a093132 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,8 +8,13 @@ import 'package:logger/logger.dart'; import 'package:prasule/util/color_schemes.g.dart'; import 'package:prasule/views/home.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:shared_preferences/shared_preferences.dart'; -void main() { +var _materialYou = false; +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + var s = await SharedPreferences.getInstance(); + _materialYou = s.getBool("useMaterialYou") ?? true; runApp(const MyApp()); } @@ -17,29 +22,37 @@ final logger = Logger(); class MyApp extends StatelessWidget { const MyApp({super.key}); - + static bool appliedYou = false; // This widget is the root of your application. @override Widget build(BuildContext context) { return (Platform.isAndroid) ? DynamicColorBuilder( - builder: (light, dark) => MaterialApp( - debugShowCheckedModeBanner: false, - localizationsDelegates: const [ - AppLocalizations.delegate, - ...GlobalMaterialLocalizations.delegates, - ...GlobalCupertinoLocalizations.delegates - ], - supportedLocales: AppLocalizations.supportedLocales, - title: 'Prašule', - theme: ThemeData( - colorScheme: light ?? lightColorScheme, - useMaterial3: true, - ), - darkTheme: ThemeData( - useMaterial3: true, colorScheme: dark ?? darkColorScheme), - home: const HomeView(), - ), + builder: (light, dark) { + appliedYou = light != null; + return MaterialApp( + debugShowCheckedModeBanner: false, + localizationsDelegates: const [ + AppLocalizations.delegate, + ...GlobalMaterialLocalizations.delegates, + ...GlobalCupertinoLocalizations.delegates + ], + supportedLocales: AppLocalizations.supportedLocales, + title: 'Prašule', + theme: ThemeData( + colorScheme: (_materialYou) + ? light ?? lightColorScheme + : lightColorScheme, + useMaterial3: true, + ), + darkTheme: ThemeData( + useMaterial3: true, + colorScheme: (_materialYou) + ? dark ?? darkColorScheme + : darkColorScheme), + home: const HomeView(), + ); + }, ) : Theme( data: ThemeData( diff --git a/lib/views/settings/settings.dart b/lib/views/settings/settings.dart index 6bd5e63..3ab16b7 100644 --- a/lib/views/settings/settings.dart +++ b/lib/views/settings/settings.dart @@ -1,9 +1,13 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:prasule/main.dart'; import 'package:prasule/pw/platformroute.dart'; import 'package:prasule/views/settings/graph_type.dart'; import 'package:prasule/views/settings/tessdata_list.dart'; import 'package:settings_ui/settings_ui.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class SettingsView extends StatefulWidget { const SettingsView({super.key}); @@ -13,6 +17,17 @@ class SettingsView extends StatefulWidget { } class _SettingsViewState extends State { + var _useMaterialYou = true; + final _supportsYou = MyApp.appliedYou; + @override + void initState() { + super.initState(); + SharedPreferences.getInstance().then((s) { + _useMaterialYou = s.getBool("useMaterialYou") ?? true; + setState(() {}); + }); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -48,7 +63,19 @@ class _SettingsViewState extends State { (p0) => const GraphTypeSettingsView(), ), ), - ) + ), + if (Platform.isAndroid && _supportsYou) + SettingsTile.switchTile( + initialValue: _useMaterialYou, + onToggle: (v) async { + var s = await SharedPreferences.getInstance(); + s.setBool("useMaterialYou", v); + _useMaterialYou = v; + setState(() {}); + }, + title: Text(AppLocalizations.of(context).enableYou), + description: Text(AppLocalizations.of(context).enableYouDesc), + ) ], ) ], From c2d98df7802bdc5de43814362580778ec984db93 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 25 Dec 2023 21:14:11 +0100 Subject: [PATCH 08/17] fix(deps): update dependency fl_chart to ^0.66.0 (#14) Reviewed-on: https://git.mnau.xyz/hernik/prasule/pulls/14 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- pubspec.lock | 6 +++--- pubspec.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index e4109b8..407235a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -317,10 +317,10 @@ packages: dependency: "direct main" description: name: fl_chart - sha256: "5a74434cc83bf64346efb562f1a06eefaf1bcb530dc3d96a104f631a1eff8d79" + sha256: fe6fec7d85975a99c73b9515a69a6e291364accfa0e4a5b3ce6de814d74b9a1c url: "https://pub.dev" source: hosted - version: "0.65.0" + version: "0.66.0" flutter: dependency: "direct main" description: flutter @@ -1131,4 +1131,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.2.0 <4.0.0" - flutter: ">=3.7.0" + flutter: ">=3.16.0" diff --git a/pubspec.yaml b/pubspec.yaml index 8f0235b..b5082ca 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,7 +38,7 @@ dependencies: flutter_slidable: ^3.0.0 flutter_localizations: sdk: flutter - fl_chart: ^0.65.0 + fl_chart: ^0.66.0 dev_dependencies: flutter_test: From 073806b8dd2ddfa2d098c244baa3da85e7c2feb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Fri, 29 Dec 2023 21:39:54 +0100 Subject: [PATCH 09/17] feat: edit per new lints, add editing categories --- CHANGELOG.md | 5 +- analysis_options.yaml | 4 +- lib/api/category.dart | 45 +++- lib/api/entry_data.dart | 17 +- lib/api/wallet.dart | 39 ++- lib/api/wallet.g.dart | 2 +- lib/api/walletentry.dart | 32 ++- lib/api/walletmanager.dart | 28 +- lib/l10n/app_cs.arb | 4 +- lib/l10n/app_en.arb | 4 +- lib/main.dart | 39 ++- lib/network/tessdata.dart | 54 ++-- lib/pw/platformbutton.dart | 11 +- lib/pw/platformdialog.dart | 7 +- lib/pw/platformfield.dart | 37 +-- lib/pw/platformroute.dart | 5 +- lib/pw/platformwidget.dart | 3 + lib/util/color_schemes.g.dart | 2 + lib/util/graphs.dart | 250 ++++++++++-------- lib/views/create_entry.dart | 44 ++-- lib/views/graph_view.dart | 148 ++++++----- lib/views/home.dart | 324 +++++++++++++----------- lib/views/settings/edit_categories.dart | 224 ++++++++++++++++ lib/views/settings/graph_type.dart | 62 +++-- lib/views/settings/settings.dart | 46 +++- lib/views/settings/tessdata_list.dart | 121 +++++---- lib/views/setup.dart | 126 +++++---- pubspec.lock | 12 +- pubspec.yaml | 58 ++--- 29 files changed, 1132 insertions(+), 621 deletions(-) create mode 100644 lib/views/settings/edit_categories.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index a86776d..2adbf2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ -# 1.0.0-alpha.2 +# 1.0.0-alpha+3 +- Add settings view for editing wallet categories +- Change code according to more aggressive linting +# 1.0.0-alpha+2 - Fixed localization issues - Added graphs for expenses and income per month/year # 1.0.0-alpha diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..6d940d0 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -7,7 +7,7 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml +include: package:very_good_analysis/analysis_options.yaml linter: # The lint rules applied to this project can be customized in the @@ -23,6 +23,8 @@ linter: rules: # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + prefer_single_quotes: false + flutter_style_todos: false # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/lib/api/category.dart b/lib/api/category.dart index 29e641f..0a2e16f 100644 --- a/lib/api/category.dart +++ b/lib/api/category.dart @@ -6,23 +6,32 @@ part 'category.g.dart'; /// Represents a category in a user's wallet class WalletCategory { - final EntryType type; - String name; - final int id; - @JsonKey(fromJson: _iconDataFromJson, toJson: _iconDataToJson) - IconData icon; - - WalletCategory( - {required this.name, - required this.type, - required this.id, - required this.icon}); + /// Represents a category in a user's wallet + WalletCategory({ + required this.name, + required this.type, + required this.id, + required this.icon, + }); /// Connect the generated [_$WalletEntry] function to the `fromJson` /// factory. factory WalletCategory.fromJson(Map json) => _$WalletCategoryFromJson(json); + /// Expense or income + final EntryType type; + + /// User-defined name + String name; + + /// Unique identificator of the category + final int id; + + /// Selected Icon for the category + @JsonKey(fromJson: _iconDataFromJson, toJson: _iconDataToJson) + IconData icon; + /// Connect the generated [_$PersonToJson] function to the `toJson` method. Map toJson() => _$WalletCategoryToJson(this); @@ -34,7 +43,15 @@ class WalletCategory { Map _iconDataToJson(IconData icon) => {'codepoint': icon.codePoint, 'family': icon.fontFamily}; -IconData _iconDataFromJson(Map data) => - IconData(data['codepoint'], fontFamily: data['family']); -enum EntryType { expense, income } +IconData _iconDataFromJson(Map data) => + IconData(data['codepoint'] as int, fontFamily: data['family'] as String?); + +/// Type of entry, either expense or income +enum EntryType { + /// Expense + expense, + + /// Income + income +} diff --git a/lib/api/entry_data.dart b/lib/api/entry_data.dart index fedcec4..21e8f59 100644 --- a/lib/api/entry_data.dart +++ b/lib/api/entry_data.dart @@ -1,16 +1,25 @@ import 'package:json_annotation/json_annotation.dart'; part 'entry_data.g.dart'; +/// Contains raw data @JsonSerializable() class EntryData { - String name; - String description; - double amount; - + /// Contains raw data EntryData({required this.name, required this.amount, this.description = ""}); + /// Connects generated fromJson function factory EntryData.fromJson(Map json) => _$EntryDataFromJson(json); + /// Name of entry + String name; + + /// Optional description, default is empty + String description; + + /// Amount for entry + double amount; + + /// Connects generated toJson function Map toJson() => _$EntryDataToJson(this); } diff --git a/lib/api/wallet.dart b/lib/api/wallet.dart index 05e2d0a..ed6526b 100644 --- a/lib/api/wallet.dart +++ b/lib/api/wallet.dart @@ -7,27 +7,41 @@ part 'wallet.g.dart'; Currency _currencyFromJson(Map data) => Currency.from(json: data); +/// Represents a single wallet +/// +/// A wallet stores [WalletSingleEntry]s categorized under [WalletCategory]s @JsonSerializable() class Wallet { - final String name; - final List categories; - final List entries; - double starterBalance; - @JsonKey(fromJson: _currencyFromJson) - final Currency currency; - + /// A wallet stores [WalletSingleEntry]s categorized under [WalletCategory]s Wallet( {required this.name, required this.currency, this.categories = const [], this.entries = const [], - this.starterBalance = 0}); + this.starterBalance = 0,}); - /// Connect the generated [_$WalletEntry] function to the `fromJson` - /// factory. + /// Connects generated fromJson function factory Wallet.fromJson(Map json) => _$WalletFromJson(json); - /// Connect the generated [_$PersonToJson] function to the `toJson` method. + /// Name of the wallet + final String name; + + /// A list of available categories + final List categories; + + /// List of saved entries + final List entries; + + /// The starting balance of the wallet + /// + /// Used to calculate current balance + double starterBalance; + + /// Selected currency + @JsonKey(fromJson: _currencyFromJson) + final Currency currency; + + /// Connects generated toJson function Map toJson() => _$WalletToJson(this); /// Getter for the next unused unique number ID in the wallet's entry list @@ -39,13 +53,14 @@ class Wallet { return id; } + /// Empty wallet used for placeholders static final Wallet empty = Wallet( name: "Empty", currency: Currency.from( json: { "code": "USD", "name": "United States Dollar", - "symbol": "\$", + "symbol": r"$", "flag": "USD", "decimal_digits": 2, "number": 840, diff --git a/lib/api/wallet.g.dart b/lib/api/wallet.g.dart index d2affe5..441b055 100644 --- a/lib/api/wallet.g.dart +++ b/lib/api/wallet.g.dart @@ -15,7 +15,7 @@ Wallet _$WalletFromJson(Map json) => Wallet( const [], entries: (json['entries'] as List?) ?.map( - (e) => WalletSingleEntry.fromJson(e as Map)) + (e) => WalletSingleEntry.fromJson(e as Map),) .toList() ?? const [], starterBalance: (json['starterBalance'] as num?)?.toDouble() ?? 0, diff --git a/lib/api/walletentry.dart b/lib/api/walletentry.dart index 48c6e0f..878c023 100644 --- a/lib/api/walletentry.dart +++ b/lib/api/walletentry.dart @@ -1,30 +1,40 @@ -import 'package:prasule/api/category.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'package:prasule/api/category.dart'; import 'package:prasule/api/entry_data.dart'; + part 'walletentry.g.dart'; @JsonSerializable() /// This is an entry containing a single item class WalletSingleEntry { - EntryType type; - EntryData data; - DateTime date; - WalletCategory category; - int id; - + /// This is an entry containing a single item WalletSingleEntry( {required this.data, required this.type, required this.date, required this.category, - required this.id}); + required this.id,}); - /// Connect the generated [_$WalletEntry] function to the `fromJson` - /// factory. + /// Connects generated fromJson function factory WalletSingleEntry.fromJson(Map json) => _$WalletSingleEntryFromJson(json); - /// Connect the generated [_$WalletEntryToJson] function to the `toJson` method. + /// Expense or income + EntryType type; + + /// Actual entry data + EntryData data; + + /// Date of entry creation + DateTime date; + + /// Selected category + WalletCategory category; + + /// Unique entry ID + int id; + + /// Connects generated toJson function Map toJson() => _$WalletSingleEntryToJson(this); } diff --git a/lib/api/walletmanager.dart b/lib/api/walletmanager.dart index c8f2823..0f00156 100644 --- a/lib/api/walletmanager.dart +++ b/lib/api/walletmanager.dart @@ -4,15 +4,18 @@ import 'dart:io'; import 'package:path_provider/path_provider.dart'; import 'package:prasule/api/wallet.dart'; +/// Used for [Wallet]-managing operations class WalletManager { + /// Returns a list of all [Wallet]s static Future> listWallets() async { - var path = + final path = Directory("${(await getApplicationDocumentsDirectory()).path}/wallets"); if (!path.existsSync()) { path.createSync(); } - var wallets = []; - for (var w in path.listSync().map((e) => e.path.split("/").last).toList()) { + final wallets = []; + for (final w + in path.listSync().map((e) => e.path.split("/").last).toList()) { try { wallets.add(await loadWallet(w)); } catch (e) { @@ -22,23 +25,26 @@ class WalletManager { return wallets; } + /// Loads and returns a single [Wallet] by name static Future loadWallet(String name) async { - var path = + final path = Directory("${(await getApplicationDocumentsDirectory()).path}/wallets"); - var wallet = File("${path.path}/$name"); + final wallet = File("${path.path}/$name"); if (!path.existsSync()) { path.createSync(); } if (!wallet.existsSync()) { return Future.error("Wallet does not exist"); } - return Wallet.fromJson(jsonDecode(wallet.readAsStringSync())); + return Wallet.fromJson( + jsonDecode(wallet.readAsStringSync()) as Map,); } + /// Converts [Wallet] to JSON and saves it to AppData static Future saveWallet(Wallet w) async { - var path = + final path = Directory("${(await getApplicationDocumentsDirectory()).path}/wallets"); - var wallet = File("${path.path}/${w.name}"); + final wallet = File("${path.path}/${w.name}"); if (!path.existsSync()) { path.createSync(); } @@ -47,10 +53,10 @@ class WalletManager { return true; } + /// Deletes the corresponding [Wallet] file static Future deleteWallet(Wallet w) async { - var path = + final path = Directory("${(await getApplicationDocumentsDirectory()).path}/wallets"); - var wallet = File("${path.path}/${w.name}"); - wallet.deleteSync(); + File("${path.path}/${w.name}").deleteSync(); } } diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index fc53ec4..13fc0a3 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -74,6 +74,8 @@ "barChart":"Sloupcový", "selectType":"Zvolte typ", "enableYou":"Povolit Material You (Může vyžadovat restart aplikace)", - "enableYouDesc":"Aplikace použije barevné schéma z vaší tapety" + "enableYouDesc":"Aplikace použije barevné schéma z vaší tapety", + "editCategories":"Upravit kategorie", + "editCategoriesDesc":"Přidat, upravit nebo odebrat kategorii z peněženky" } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 875aeaa..8aeea34 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -154,5 +154,7 @@ "barChart":"Bar chart", "selectType":"Select type", "enableYou":"Enable Material You (May require an app restart)", - "enableYouDesc":"The app will use a color scheme from your wallpaper" + "enableYouDesc":"The app will use a color scheme from your wallpaper", + "editCategories":"Edit categories", + "editCategoriesDesc":"Add, edit or remove categories from a wallet" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index a093132..19f6961 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,25 +3,37 @@ import 'dart:io'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:logger/logger.dart'; import 'package:prasule/util/color_schemes.g.dart'; import 'package:prasule/views/home.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:shared_preferences/shared_preferences.dart'; var _materialYou = false; void main() async { WidgetsFlutterBinding.ensureInitialized(); - var s = await SharedPreferences.getInstance(); + final s = await SharedPreferences.getInstance(); + + if (!Platform.isAndroid) { + await s.setBool("useMaterialYou", false); + } + _materialYou = s.getBool("useMaterialYou") ?? true; runApp(const MyApp()); } +/// Global logger for debugging final logger = Logger(); +/// The application itself class MyApp extends StatelessWidget { + /// The application itself const MyApp({super.key}); + + /// If Material You was applied + /// + /// Used to check if it is supported static bool appliedYou = false; // This widget is the root of your application. @override @@ -35,32 +47,33 @@ class MyApp extends StatelessWidget { localizationsDelegates: const [ AppLocalizations.delegate, ...GlobalMaterialLocalizations.delegates, - ...GlobalCupertinoLocalizations.delegates + ...GlobalCupertinoLocalizations.delegates, ], supportedLocales: AppLocalizations.supportedLocales, title: 'Prašule', theme: ThemeData( - colorScheme: (_materialYou) + colorScheme: _materialYou ? light ?? lightColorScheme : lightColorScheme, useMaterial3: true, ), darkTheme: ThemeData( - useMaterial3: true, - colorScheme: (_materialYou) - ? dark ?? darkColorScheme - : darkColorScheme), + useMaterial3: true, + colorScheme: + _materialYou ? dark ?? darkColorScheme : darkColorScheme, + ), home: const HomeView(), ); }, ) : Theme( data: ThemeData( - useMaterial3: true, - colorScheme: (MediaQuery.of(context).platformBrightness == - Brightness.dark) - ? darkColorScheme - : lightColorScheme), + useMaterial3: true, + colorScheme: + (MediaQuery.of(context).platformBrightness == Brightness.dark) + ? darkColorScheme + : lightColorScheme, + ), child: const CupertinoApp( title: 'Prašule', home: HomeView(), diff --git a/lib/network/tessdata.dart b/lib/network/tessdata.dart index 96b8e51..20e1c32 100644 --- a/lib/network/tessdata.dart +++ b/lib/network/tessdata.dart @@ -4,40 +4,48 @@ import 'package:dio/dio.dart'; import 'package:flutter_tesseract_ocr/flutter_tesseract_ocr.dart'; import 'package:prasule/main.dart'; +/// Used for communication with my repo mirror +/// +/// Downloads Tessdata for OCR class TessdataApi { static final Dio _client = Dio( BaseOptions( validateStatus: (status) => true, ), ); + + /// Gets available languages from the repo static Future> getAvailableData() async { - var res = await _client.get( - "https://git.mnau.xyz/api/v1/repos/hernik/tessdata_fast/contents", - options: Options(headers: {"Accept": "application/json"})); + final res = await _client.get>>( + "https://git.mnau.xyz/api/v1/repos/hernik/tessdata_fast/contents", + options: Options(headers: {"Accept": "application/json"}), + ); if ((res.statusCode ?? 500) > 399) { return Future.error("The server returned status code ${res.statusCode}"); } - var data = res.data; + final data = res.data; final dataFiles = []; - for (var file in data) { - if (!file["name"].endsWith(".traineddata")) continue; - dataFiles.add(file["name"].replaceAll(".traineddata", "")); + for (final file in data ?? >[]) { + if (!(file["name"] as String).endsWith(".traineddata")) continue; + dataFiles.add((file["name"] as String).replaceAll(".traineddata", "")); } return dataFiles; } + /// Deletes data from device static Future deleteData(String name) async { - var dataDir = Directory(await FlutterTesseractOcr.getTessdataPath()); + final dataDir = Directory(await FlutterTesseractOcr.getTessdataPath()); if (!dataDir.existsSync()) { dataDir.createSync(); } - var dataFile = File("${dataDir.path}/$name.traineddata"); + final dataFile = File("${dataDir.path}/$name.traineddata"); if (!dataFile.existsSync()) return; dataFile.deleteSync(); } + /// Finds existing data on the device static Future> getDownloadedData() async { - var tessDir = Directory(await FlutterTesseractOcr.getTessdataPath()); + final tessDir = Directory(await FlutterTesseractOcr.getTessdataPath()); if (!tessDir.existsSync()) { tessDir.createSync(); } @@ -48,25 +56,29 @@ class TessdataApi { .toList(); } - static Future downloadData(String isoCode, - {void Function(int, int)? callback}) async { - var tessDir = Directory(await FlutterTesseractOcr.getTessdataPath()); + /// Downloads data from the repo to the device + static Future downloadData( + String isoCode, { + void Function(int, int)? callback, + }) async { + final tessDir = Directory(await FlutterTesseractOcr.getTessdataPath()); if (!tessDir.existsSync()) { tessDir.createSync(); } - var file = File("${tessDir.path}/$isoCode.traineddata"); + final file = File("${tessDir.path}/$isoCode.traineddata"); if (file.existsSync()) return; // TODO: maybe ask to redownload? - var res = await _client.get( - "https://git.mnau.xyz/hernik/tessdata_fast/raw/branch/main/$isoCode.traineddata", - options: Options(responseType: ResponseType.bytes), - onReceiveProgress: callback); + final res = await _client.get>( + "https://git.mnau.xyz/hernik/tessdata_fast/raw/branch/main/$isoCode.traineddata", + options: Options(responseType: ResponseType.bytes), + onReceiveProgress: callback, + ); if ((res.statusCode ?? 500) > 399) { return Future.error("The server returned status code ${res.statusCode}"); } try { - var writefile = file.openSync(mode: FileMode.write); - writefile.writeFromSync(res.data); - writefile.closeSync(); + file.openSync(mode: FileMode.write) + ..writeFromSync(res.data!) + ..closeSync(); } catch (e) { logger.e(e); return Future.error("Could not complete writing file"); diff --git a/lib/pw/platformbutton.dart b/lib/pw/platformbutton.dart index aa49d08..c75af83 100644 --- a/lib/pw/platformbutton.dart +++ b/lib/pw/platformbutton.dart @@ -1,13 +1,20 @@ +// ignore_for_file: public_member_api_docs + import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:prasule/pw/platformwidget.dart'; +/// A [PlatformWidget] implementation of a text field class PlatformButton extends PlatformWidget { + const PlatformButton({ + required this.text, + required this.onPressed, + super.key, + this.style, + }); final String text; final void Function()? onPressed; final ButtonStyle? style; - const PlatformButton( - {super.key, required this.text, required this.onPressed, this.style}); @override TextButton createAndroidWidget(BuildContext context) => TextButton( diff --git a/lib/pw/platformdialog.dart b/lib/pw/platformdialog.dart index 96b7901..070d6f6 100644 --- a/lib/pw/platformdialog.dart +++ b/lib/pw/platformdialog.dart @@ -1,13 +1,16 @@ +// ignore_for_file: public_member_api_docs + import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:prasule/pw/platformwidget.dart'; +/// A [PlatformWidget] implementation of a dialog class PlatformDialog extends PlatformWidget { + const PlatformDialog( + {required this.title, super.key, this.content, this.actions = const [],}); final String title; final Widget? content; final List actions; - const PlatformDialog( - {super.key, required this.title, this.content, this.actions = const []}); @override AlertDialog createAndroidWidget(BuildContext context) => AlertDialog( diff --git a/lib/pw/platformfield.dart b/lib/pw/platformfield.dart index eed5515..331b8f8 100644 --- a/lib/pw/platformfield.dart +++ b/lib/pw/platformfield.dart @@ -1,9 +1,27 @@ +// ignore_for_file: public_member_api_docs + import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:prasule/pw/platformwidget.dart'; +/// A [PlatformWidget] implementation of a text field class PlatformField extends PlatformWidget { + const PlatformField({ + super.key, + this.controller, + this.enabled, + this.labelText, + this.obscureText = false, + this.autocorrect = false, + this.keyboardType, + this.inputFormatters = const [], + this.onChanged, + this.autofillHints, + this.textStyle, + this.textAlign = TextAlign.start, + this.maxLines = 1, + }); final TextEditingController? controller; final bool? enabled; final bool obscureText; @@ -16,20 +34,6 @@ class PlatformField extends PlatformWidget { final TextStyle? textStyle; final TextAlign textAlign; final int? maxLines; - const PlatformField( - {super.key, - this.controller, - this.enabled, - this.labelText, - this.obscureText = false, - this.autocorrect = false, - this.keyboardType, - this.inputFormatters = const [], - this.onChanged, - this.autofillHints, - this.textStyle, - this.textAlign = TextAlign.start, - this.maxLines = 1}); @override TextField createAndroidWidget(BuildContext context) => TextField( @@ -38,8 +42,9 @@ class PlatformField extends PlatformWidget { enabled: enabled, obscureText: obscureText, decoration: InputDecoration( - labelText: labelText, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(4))), + labelText: labelText, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(4)), + ), autocorrect: autocorrect, keyboardType: keyboardType, style: textStyle, diff --git a/lib/pw/platformroute.dart b/lib/pw/platformroute.dart index fbcd8b5..3e95ac3 100644 --- a/lib/pw/platformroute.dart +++ b/lib/pw/platformroute.dart @@ -3,7 +3,10 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -Route platformRoute(Widget Function(BuildContext) builder) => +/// Creates a PageRoute based on [Platform] +Route platformRoute( + Widget Function(BuildContext) builder, +) => (Platform.isIOS) ? CupertinoPageRoute(builder: builder) : MaterialPageRoute(builder: builder); diff --git a/lib/pw/platformwidget.dart b/lib/pw/platformwidget.dart index 5c3ba6b..98d9c89 100644 --- a/lib/pw/platformwidget.dart +++ b/lib/pw/platformwidget.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; /// Abstract class used to create widgets for the respective platform UI library abstract class PlatformWidget extends StatelessWidget { + /// Abstract class used to create widgets for the respective platform UI library const PlatformWidget({super.key}); @override @@ -16,7 +17,9 @@ abstract class PlatformWidget } } + /// The widget that will be shown on Android A createAndroidWidget(BuildContext context); + /// The widget that will be shown on iOS I createIosWidget(BuildContext context); } diff --git a/lib/util/color_schemes.g.dart b/lib/util/color_schemes.g.dart index e53598f..47200d1 100644 --- a/lib/util/color_schemes.g.dart +++ b/lib/util/color_schemes.g.dart @@ -1,3 +1,5 @@ +// ignore_for_file: public_member_api_docs + import 'package:flutter/material.dart'; const lightColorScheme = ColorScheme( diff --git a/lib/util/graphs.dart b/lib/util/graphs.dart index 5a21330..bb090f8 100644 --- a/lib/util/graphs.dart +++ b/lib/util/graphs.dart @@ -2,37 +2,55 @@ import 'package:currency_picker/currency_picker.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:intl/intl.dart'; /// Monthly/Yearly expense/income [LineChart] class ExpensesLineChart extends StatelessWidget { - const ExpensesLineChart( - {super.key, - required this.date, - required this.locale, - required this.expenseData, - required this.incomeData, - required this.currency, - this.yearly = false}); + /// Monthly/Yearly expense/income [LineChart] + const ExpensesLineChart({ + required this.date, + required this.locale, + required this.expenseData, + required this.incomeData, + required this.currency, + super.key, + this.yearly = false, + }); + + /// If the graph will be shown yearly final bool yearly; + + /// Selected date + /// + /// Used to get either month or year final DateTime date; + + /// Current locale + /// + /// Used mainly for formatting final String locale; + + /// The expense data used for the graph final List expenseData; + + /// Wallet currency + /// + /// Used to show currency symbol final Currency currency; - List get expenseDataSorted { - var list = List.from(expenseData); - list.sort((a, b) => a.compareTo(b)); - return list; - } + /// Expense data, but sorted + List get expenseDataSorted => + List.from(expenseData)..sort((a, b) => a.compareTo(b)); + + /// Income data used for the graph final List incomeData; - List get incomeDataSorted { - var list = List.from(incomeData); - list.sort((a, b) => a.compareTo(b)); - return list; - } + /// Income data, but sorted + List get incomeDataSorted => + List.from(incomeData)..sort((a, b) => a.compareTo(b)); + + /// Calculates maxY for the graph double get maxY { if (incomeData.isEmpty) return expenseDataSorted.last; if (expenseData.isEmpty) return incomeDataSorted.last; @@ -55,35 +73,45 @@ class ExpensesLineChart extends StatelessWidget { (spots[index].barIndex == 0) ? (yearly ? AppLocalizations.of(context).incomeForMonth( - DateFormat.MMMM(locale).format(DateTime( - date.year, spots[index].x.toInt() + 1, 1)), + DateFormat.MMMM(locale).format( + DateTime( + date.year, + spots[index].x.toInt() + 1, + ), + ), NumberFormat.compactCurrency( - locale: locale, - symbol: currency.symbol, - name: currency.name) - .format(spots[index].y)) + locale: locale, + symbol: currency.symbol, + name: currency.name, + ).format(spots[index].y), + ) : AppLocalizations.of(context).incomeForDay( NumberFormat.compactCurrency( - locale: locale, - symbol: currency.symbol, - name: currency.name) - .format(spots[index].y), + locale: locale, + symbol: currency.symbol, + name: currency.name, + ).format(spots[index].y), )) : (yearly ? AppLocalizations.of(context).expensesForMonth( - DateFormat.MMMM(locale).format(DateTime( - date.year, spots[index].x.toInt() + 1, 1)), + DateFormat.MMMM(locale).format( + DateTime( + date.year, + spots[index].x.toInt() + 1, + ), + ), NumberFormat.compactCurrency( - locale: locale, - symbol: currency.symbol, - name: currency.name) - .format(spots[index].y)) + locale: locale, + symbol: currency.symbol, + name: currency.name, + ).format(spots[index].y), + ) : AppLocalizations.of(context).expensesForDay( NumberFormat.compactCurrency( - locale: locale, - symbol: currency.symbol, - name: currency.name) - .format(spots[index].y), + locale: locale, + symbol: currency.symbol, + name: currency.name, + ).format(spots[index].y), )), TextStyle(color: spots[index].bar.color), ), @@ -91,7 +119,7 @@ class ExpensesLineChart extends StatelessWidget { ), ), maxY: maxY, - maxX: (yearly) ? 12 : DateTime(date.year, date.month, 0).day.toDouble(), + maxX: yearly ? 12 : DateTime(date.year, date.month, 0).day.toDouble(), minY: 0, minX: 0, backgroundColor: Theme.of(context).colorScheme.background, @@ -102,11 +130,11 @@ class ExpensesLineChart extends StatelessWidget { barWidth: 8, isStrokeCapRound: true, dotData: const FlDotData(show: false), - belowBarData: BarAreaData(show: false), + belowBarData: BarAreaData(), color: Colors.green .harmonizeWith(Theme.of(context).colorScheme.secondary), spots: List.generate( - (yearly) ? 12 : DateTime(date.year, date.month, 0).day, + yearly ? 12 : DateTime(date.year, date.month, 0).day, (index) => FlSpot(index.toDouble(), incomeData[index]), ), ), @@ -116,22 +144,18 @@ class ExpensesLineChart extends StatelessWidget { barWidth: 8, isStrokeCapRound: true, dotData: const FlDotData(show: false), - belowBarData: BarAreaData(show: false), + belowBarData: BarAreaData(), color: Colors.red .harmonizeWith(Theme.of(context).colorScheme.secondary), spots: List.generate( - (yearly) ? 12 : DateTime(date.year, date.month, 0).day, + yearly ? 12 : DateTime(date.year, date.month, 0).day, (index) => FlSpot(index.toDouble() + 1, expenseData[index]), ), ), ], // actual data titlesData: FlTitlesData( - rightTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), + rightTitles: const AxisTitles(), + topTitles: const AxisTitles(), bottomTitles: AxisTitles( sideTitles: SideTitles( reservedSize: 30, @@ -140,13 +164,15 @@ class ExpensesLineChart extends StatelessWidget { String text; if (yearly) { text = DateFormat.MMM(locale).format( - DateTime(date.year, value.toInt() + 1, 1), + DateTime(date.year, value.toInt() + 1), ); } else { text = (value.toInt() + 1).toString(); } return SideTitleWidget( - axisSide: meta.axisSide, child: Text(text)); + axisSide: meta.axisSide, + child: Text(text), + ); }, ), ), @@ -156,34 +182,52 @@ class ExpensesLineChart extends StatelessWidget { } } +/// Renders expenses/income as a [BarChart] class ExpensesBarChart extends StatelessWidget { - const ExpensesBarChart( - {super.key, - required this.yearly, - required this.date, - required this.locale, - required this.expenseData, - required this.incomeData, - required this.currency}); - final bool yearly; - final DateTime date; - final String locale; - final List expenseData; - List get expenseDataSorted { - var list = List.from(expenseData); - list.sort((a, b) => a.compareTo(b)); - return list; - } + /// Renders expenses/income as a [BarChart] + const ExpensesBarChart({ + required this.yearly, + required this.date, + required this.locale, + required this.expenseData, + required this.incomeData, + required this.currency, + super.key, + }); + /// If the graph will be shown yearly + final bool yearly; + + /// Selected date + /// + /// Used to get either month or year + final DateTime date; + + /// Current locale + /// + /// Used mainly for formatting + final String locale; + + /// The expense data used for the graph + final List expenseData; + + /// Wallet currency + /// + /// Used to show currency symbol final Currency currency; - final List incomeData; - List get incomeDataSorted { - var list = List.from(incomeData); - list.sort((a, b) => a.compareTo(b)); - return list; - } + /// Expense data, but sorted + List get expenseDataSorted => + List.from(expenseData)..sort((a, b) => a.compareTo(b)); + /// Income data used for the graph + final List incomeData; + + /// Income data, but sorted + List get incomeDataSorted => + List.from(incomeData)..sort((a, b) => a.compareTo(b)); + + /// Calculates maxY for the graph double get maxY { if (incomeData.isEmpty) return expenseDataSorted.last; if (expenseData.isEmpty) return incomeDataSorted.last; @@ -201,26 +245,28 @@ class ExpensesBarChart extends StatelessWidget { enabled: true, touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, groupIndex, rod, rodIndex) => - (yearly) // create custom tooltips for graph bars + yearly // create custom tooltips for graph bars ? BarTooltipItem( (rodIndex == 1) ? AppLocalizations.of(context).expensesForMonth( DateFormat.MMMM(locale).format( - DateTime(date.year, groupIndex + 1, 1)), + DateTime(date.year, groupIndex + 1), + ), NumberFormat.compactCurrency( - locale: locale, - symbol: currency.symbol, - name: currency.name) - .format(rod.toY), + locale: locale, + symbol: currency.symbol, + name: currency.name, + ).format(rod.toY), ) : AppLocalizations.of(context).incomeForMonth( DateFormat.MMMM(locale).format( - DateTime(date.year, groupIndex + 1, 1)), + DateTime(date.year, groupIndex + 1), + ), NumberFormat.compactCurrency( - locale: locale, - symbol: currency.symbol, - name: currency.name) - .format(rod.toY), + locale: locale, + symbol: currency.symbol, + name: currency.name, + ).format(rod.toY), ), TextStyle(color: rod.color), ) @@ -228,29 +274,25 @@ class ExpensesBarChart extends StatelessWidget { (rodIndex == 1) ? AppLocalizations.of(context).expensesForDay( NumberFormat.compactCurrency( - locale: locale, - symbol: currency.symbol, - name: currency.name) - .format(rod.toY), + locale: locale, + symbol: currency.symbol, + name: currency.name, + ).format(rod.toY), ) : AppLocalizations.of(context).incomeForDay( NumberFormat.compactCurrency( - locale: locale, - symbol: currency.symbol, - name: currency.name) - .format(rod.toY), + locale: locale, + symbol: currency.symbol, + name: currency.name, + ).format(rod.toY), ), TextStyle(color: rod.color), ), ), ), titlesData: FlTitlesData( - rightTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), + rightTitles: const AxisTitles(), + topTitles: const AxisTitles(), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, @@ -259,13 +301,15 @@ class ExpensesBarChart extends StatelessWidget { String text; if (yearly) { text = DateFormat.MMM(locale).format( - DateTime(date.year, value.toInt() + 1, 1), + DateTime(date.year, value.toInt() + 1), ); } else { text = (value.toInt() + 1).toString(); } return SideTitleWidget( - axisSide: meta.axisSide, child: Text(text)); + axisSide: meta.axisSide, + child: Text(text), + ); }, ), ), @@ -273,7 +317,7 @@ class ExpensesBarChart extends StatelessWidget { minY: 0, maxY: maxY, barGroups: List.generate( - (yearly) ? 12 : DateTime(date.year, date.month, 0).day, + yearly ? 12 : DateTime(date.year, date.month, 0).day, (index) => BarChartGroupData( x: index, barRods: [ diff --git a/lib/views/create_entry.dart b/lib/views/create_entry.dart index 57f2541..9ed1136 100644 --- a/lib/views/create_entry.dart +++ b/lib/views/create_entry.dart @@ -1,18 +1,26 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/entry_data.dart'; -import 'package:prasule/api/walletentry.dart'; import 'package:prasule/api/wallet.dart'; +import 'package:prasule/api/walletentry.dart'; import 'package:prasule/api/walletmanager.dart'; import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformfield.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +/// Used when user wants to add new entry class CreateEntryView extends StatefulWidget { + /// Used when user wants to add new entry + const CreateEntryView({required this.w, super.key, this.editEntry}); + + /// The wallet, where the entry will be saved to final Wallet w; + + /// Entry we want to edit + /// + /// Is null unless we are editing an existing entry final WalletSingleEntry? editEntry; - const CreateEntryView({super.key, required this.w, this.editEntry}); @override State createState() => _CreateEntryViewState(); @@ -27,11 +35,12 @@ class _CreateEntryViewState extends State { newEntry = widget.editEntry!; } else { newEntry = WalletSingleEntry( - id: widget.w.nextId, - data: EntryData(amount: 0, name: ""), - type: EntryType.expense, - date: DateTime.now(), - category: widget.w.categories.first); + id: widget.w.nextId, + data: EntryData(amount: 0, name: ""), + type: EntryType.expense, + date: DateTime.now(), + category: widget.w.categories.first, + ); } setState(() {}); } @@ -68,12 +77,14 @@ class _CreateEntryViewState extends State { child: PlatformField( labelText: AppLocalizations.of(context).amount, controller: TextEditingController( - text: newEntry.data.amount.toString()), + text: newEntry.data.amount.toString(), + ), keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ FilteringTextInputFormatter.allow( - RegExp(r'\d+[\.,]{0,1}\d{0,}')) + RegExp(r'\d+[\.,]{0,1}\d{0,}'), + ), ], onChanged: (v) { newEntry.data.amount = double.parse(v); @@ -157,9 +168,10 @@ class _CreateEntryViewState extends State { ), ConstrainedBox( constraints: BoxConstraints( - minWidth: MediaQuery.of(context).size.width * 0.8, - maxWidth: MediaQuery.of(context).size.width * 0.8, - maxHeight: 300), + minWidth: MediaQuery.of(context).size.width * 0.8, + maxWidth: MediaQuery.of(context).size.width * 0.8, + maxHeight: 300, + ), child: PlatformField( keyboardType: TextInputType.multiline, maxLines: null, @@ -181,8 +193,8 @@ class _CreateEntryViewState extends State { ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - AppLocalizations.of(context).errorEmptyName), + content: + Text(AppLocalizations.of(context).errorEmptyName), ), ); return; @@ -196,7 +208,7 @@ class _CreateEntryViewState extends State { (value) => Navigator.of(context).pop(widget.w), ); // TODO loading circle? }, - ) + ), ], ), ), diff --git a/lib/views/graph_view.dart b/lib/views/graph_view.dart index 188e231..0b1610c 100644 --- a/lib/views/graph_view.dart +++ b/lib/views/graph_view.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:intl/intl.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/wallet.dart'; @@ -8,12 +11,13 @@ import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformroute.dart'; import 'package:prasule/util/drawer.dart'; import 'package:prasule/util/graphs.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:prasule/views/settings/settings.dart'; import 'package:prasule/views/setup.dart'; import 'package:shared_preferences/shared_preferences.dart'; +/// Shows data from a [Wallet] in graphs class GraphView extends StatefulWidget { + /// Shows data from a [Wallet] in graphs const GraphView({super.key}); @override @@ -25,8 +29,8 @@ class _GraphViewState extends State { Wallet? selectedWallet; List wallets = []; String? locale; - var yearlyBtnSet = {"monthly"}; - var graphTypeSet = {"expense", "income"}; + Set yearlyBtnSet = {"monthly"}; + Set graphTypeSet = {"expense", "income"}; bool get yearly => yearlyBtnSet.contains("yearly"); @override @@ -36,23 +40,24 @@ class _GraphViewState extends State { } List generateChartData(EntryType type) { - var data = List.filled( - (yearly) - ? 12 - : DateTime(_selectedDate.year, _selectedDate.month, 0).day, - 0.0); + final data = List.filled( + yearly ? 12 : DateTime(_selectedDate.year, _selectedDate.month, 0).day, + 0, + ); if (selectedWallet == null) return []; for (var i = 0; i < data.length; i++) { - var entriesForRange = selectedWallet!.entries.where((element) => - ((!yearly) - ? element.date.month == _selectedDate.month && - element.date.year == _selectedDate.year && - element.date.day == i + 1 - : element.date.month == i + 1 && - element.date.year == _selectedDate.year) && - element.type == type); + final entriesForRange = selectedWallet!.entries.where( + (element) => + ((!yearly) + ? element.date.month == _selectedDate.month && + element.date.year == _selectedDate.year && + element.date.day == i + 1 + : element.date.month == i + 1 && + element.date.year == _selectedDate.year) && + element.type == type, + ); var sum = 0.0; - for (var e in entriesForRange) { + for (final e in entriesForRange) { sum += e.data.amount; } data[i] = sum; @@ -60,11 +65,13 @@ class _GraphViewState extends State { return data; } - void loadWallet() async { + Future loadWallet() async { wallets = await WalletManager.listWallets(); if (wallets.isEmpty && mounted) { - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (c) => const SetupView())); + unawaited( + Navigator.of(context) + .pushReplacement(platformRoute((c) => const SetupView())), + ); return; } selectedWallet = wallets.first; @@ -101,7 +108,7 @@ class _GraphViewState extends State { DropdownMenuItem( value: -1, child: Text(AppLocalizations.of(context).newWallet), - ) + ), ], onChanged: (v) async { if (v == null || v == -1) { @@ -126,23 +133,24 @@ class _GraphViewState extends State { PopupMenuButton( itemBuilder: (context) => [ AppLocalizations.of(context).settings, - AppLocalizations.of(context).about + AppLocalizations.of(context).about, ].map((e) => PopupMenuItem(value: e, child: Text(e))).toList(), onSelected: (value) { if (value == AppLocalizations.of(context).settings) { Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const SettingsView(), + platformRoute( + (context) => const SettingsView(), ), ); } else if (value == AppLocalizations.of(context).about) { showAboutDialog( - context: context, - applicationLegalese: AppLocalizations.of(context).license, - applicationName: "Prašule"); + context: context, + applicationLegalese: AppLocalizations.of(context).license, + applicationName: "Prašule", + ); } }, - ) + ), ], ), drawer: makeDrawer(context, 2), @@ -193,8 +201,8 @@ class _GraphViewState extends State { selected: yearlyBtnSet, onSelectionChanged: (selection) async { yearlyBtnSet = selection; - var s = await SharedPreferences.getInstance(); - chartType = (yearly) + final s = await SharedPreferences.getInstance(); + chartType = yearly ? (s.getInt("yearlygraph") ?? 1) : (s.getInt("monthlygraph") ?? 2); setState(() {}); @@ -203,51 +211,55 @@ class _GraphViewState extends State { const SizedBox(height: 5), Container( decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Theme.of(context) - .colorScheme - .secondaryContainer), + borderRadius: BorderRadius.circular(8), + color: + Theme.of(context).colorScheme.secondaryContainer, + ), child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Column( children: [ PlatformButton( style: ButtonStyle( - backgroundColor: MaterialStateProperty.all( - Theme.of(context).colorScheme.primary), - foregroundColor: MaterialStateProperty.all( - Theme.of(context) - .colorScheme - .onPrimary)), - text: (yearly) + backgroundColor: MaterialStateProperty.all( + Theme.of(context).colorScheme.primary, + ), + foregroundColor: MaterialStateProperty.all( + Theme.of(context).colorScheme.onPrimary, + ), + ), + text: yearly ? DateFormat.y(locale).format(_selectedDate) : DateFormat.yMMMM(locale) .format(_selectedDate), onPressed: () async { - var firstDate = (selectedWallet!.entries + final firstDate = (selectedWallet!.entries ..sort( - (a, b) => a.date.compareTo(b.date))) + (a, b) => a.date.compareTo(b.date), + )) .first .date; - var lastDate = (selectedWallet!.entries + final lastDate = (selectedWallet!.entries ..sort( - (a, b) => b.date.compareTo(a.date))) + (a, b) => b.date.compareTo(a.date), + )) .first .date; - logger.i(firstDate); - logger.i(lastDate); - var newDate = await showDatePicker( - context: context, - initialDate: DateTime(_selectedDate.year, - _selectedDate.month, 1), - firstDate: firstDate, - lastDate: lastDate, - initialEntryMode: (yearly) - ? DatePickerEntryMode.input - : DatePickerEntryMode.calendar, - initialDatePickerMode: (yearly) - ? DatePickerMode.year - : DatePickerMode.day); + final newDate = await showDatePicker( + context: context, + initialDate: DateTime( + _selectedDate.year, + _selectedDate.month, + ), + firstDate: firstDate, + lastDate: lastDate, + initialEntryMode: yearly + ? DatePickerEntryMode.input + : DatePickerEntryMode.calendar, + initialDatePickerMode: yearly + ? DatePickerMode.year + : DatePickerMode.day, + ); if (newDate == null) return; _selectedDate = newDate; setState(() {}); @@ -270,12 +282,14 @@ class _GraphViewState extends State { expenseData: (graphTypeSet .contains("expense")) ? generateChartData( - EntryType.expense) + EntryType.expense, + ) : [], incomeData: (graphTypeSet .contains("income")) ? generateChartData( - EntryType.income) + EntryType.income, + ) : [], ) : ExpensesLineChart( @@ -286,19 +300,21 @@ class _GraphViewState extends State { expenseData: (graphTypeSet .contains("expense")) ? generateChartData( - EntryType.expense) + EntryType.expense, + ) : [], incomeData: (graphTypeSet .contains("income")) ? generateChartData( - EntryType.income) + EntryType.income, + ) : [], ), - ) + ), ], ), ), - ) + ), ], ), ), diff --git a/lib/views/home.dart b/lib/views/home.dart index 23b8dc7..868cdae 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -1,6 +1,11 @@ +// ignore_for_file: inference_failure_on_function_invocation + +import 'dart:async'; import 'dart:math'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:flutter_speed_dial/flutter_speed_dial.dart'; import 'package:flutter_tesseract_ocr/flutter_tesseract_ocr.dart'; @@ -10,8 +15,8 @@ import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/entry_data.dart'; -import 'package:prasule/api/walletentry.dart'; import 'package:prasule/api/wallet.dart'; +import 'package:prasule/api/walletentry.dart'; import 'package:prasule/api/walletmanager.dart'; import 'package:prasule/main.dart'; import 'package:prasule/network/tessdata.dart'; @@ -23,9 +28,10 @@ import 'package:prasule/views/create_entry.dart'; import 'package:prasule/views/settings/settings.dart'; import 'package:prasule/views/settings/tessdata_list.dart'; import 'package:prasule/views/setup.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +/// Main view, shows entries class HomeView extends StatefulWidget { + /// Main view, shows entries const HomeView({super.key}); @override @@ -50,11 +56,13 @@ class _HomeViewState extends State { loadWallet(); } - void loadWallet() async { + Future loadWallet() async { wallets = await WalletManager.listWallets(); if (wallets.isEmpty && mounted) { - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (c) => const SetupView())); + unawaited( + Navigator.of(context) + .pushReplacement(platformRoute((c) => const SetupView())), + ); return; } selectedWallet = wallets.first; @@ -77,7 +85,7 @@ class _HomeViewState extends State { // debug option to quickly fill a wallet with data if (selectedWallet == null) return; selectedWallet!.entries.clear(); - var random = Random(); + final random = Random(); for (var i = 0; i < 30; i++) { selectedWallet!.entries.add( WalletSingleEntry( @@ -113,26 +121,26 @@ class _HomeViewState extends State { }, ), SpeedDialChild( - child: const Icon(Icons.edit), - label: AppLocalizations.of(context).addNew, - onTap: () async { - var sw = await Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => CreateEntryView(w: selectedWallet!), - ), - ); - if (sw != null) { - selectedWallet = sw; - } - setState(() {}); - }), + child: const Icon(Icons.edit), + label: AppLocalizations.of(context).addNew, + onTap: () async { + final sw = await Navigator.of(context).push( + MaterialPageRoute( + builder: (c) => CreateEntryView(w: selectedWallet!), + ), + ); + if (sw != null) { + selectedWallet = sw; + } + setState(() {}); + }, + ), SpeedDialChild( child: const Icon(Icons.camera_alt), label: AppLocalizations.of(context).addCamera, onTap: () async { - final ImagePicker picker = ImagePicker(); - final XFile? media = - await picker.pickImage(source: ImageSource.camera); + final picker = ImagePicker(); + final media = await picker.pickImage(source: ImageSource.camera); logger.i(media?.name); }, ), @@ -161,7 +169,7 @@ class _HomeViewState extends State { DropdownMenuItem( value: -1, child: Text(AppLocalizations.of(context).newWallet), - ) + ), ], onChanged: (v) async { if (v == null || v == -1) { @@ -186,23 +194,24 @@ class _HomeViewState extends State { PopupMenuButton( itemBuilder: (context) => [ AppLocalizations.of(context).settings, - AppLocalizations.of(context).about + AppLocalizations.of(context).about, ].map((e) => PopupMenuItem(value: e, child: Text(e))).toList(), onSelected: (value) { if (value == AppLocalizations.of(context).settings) { Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const SettingsView(), + platformRoute( + (context) => const SettingsView(), ), ); } else if (value == AppLocalizations.of(context).about) { showAboutDialog( - context: context, - applicationLegalese: AppLocalizations.of(context).license, - applicationName: "Prašule"); + context: context, + applicationLegalese: AppLocalizations.of(context).license, + applicationName: "Prašule", + ); } }, - ) + ), ], ), body: Center( @@ -216,7 +225,7 @@ class _HomeViewState extends State { width: 40, height: 40, child: CircularProgressIndicator(), - ) + ), ], ) : (selectedWallet!.entries.isEmpty) @@ -231,116 +240,123 @@ class _HomeViewState extends State { ), Text( AppLocalizations.of(context).noEntriesSub, - ) + ), ], ) : GroupedListView( groupHeaderBuilder: (element) => Text( DateFormat.yMMMM(locale).format(element.date), style: TextStyle( - color: Theme.of(context).colorScheme.primary), + color: Theme.of(context).colorScheme.primary, + ), ), elements: selectedWallet!.entries, itemComparator: (a, b) => b.date.compareTo(a.date), groupBy: (e) => DateFormat.yMMMM(locale).format(e.date), groupComparator: (a, b) { // TODO: better sorting algorithm lol - var yearA = RegExp(r'\d+').firstMatch(a); + final yearA = RegExp(r'\d+').firstMatch(a); if (yearA == null) return 0; - var yearB = RegExp(r'\d+').firstMatch(b); + final yearB = RegExp(r'\d+').firstMatch(b); if (yearB == null) return 0; - var compareYears = int.parse(yearA.group(0)!) + final compareYears = int.parse(yearA.group(0)!) .compareTo(int.parse(yearB.group(0)!)); if (compareYears != 0) return compareYears; - var months = List.generate( + final months = List.generate( 12, (index) => DateFormat.MMMM(locale).format( DateTime(2023, index + 1), ), ); - var monthA = RegExp(r'[^0-9 ]+').firstMatch(a); + final monthA = RegExp('[^0-9 ]+').firstMatch(a); if (monthA == null) return 0; - var monthB = RegExp(r'[^0-9 ]+').firstMatch(b); + final monthB = RegExp('[^0-9 ]+').firstMatch(b); if (monthB == null) return 0; return months.indexOf(monthB.group(0)!).compareTo( months.indexOf(monthA.group(0)!), ); }, itemBuilder: (context, element) => Slidable( - endActionPane: - ActionPane(motion: const ScrollMotion(), children: [ - SlidableAction( - onPressed: (c) { - Navigator.of(context) - .push( - MaterialPageRoute( - builder: (c) => CreateEntryView( - w: selectedWallet!, - editEntry: element, + endActionPane: ActionPane( + motion: const ScrollMotion(), + children: [ + SlidableAction( + onPressed: (c) { + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (c) => CreateEntryView( + w: selectedWallet!, + editEntry: element, + ), ), - ), - ) - .then( - (editedEntry) { - if (editedEntry == null) return; - selectedWallet!.entries.remove(element); - selectedWallet!.entries.add(editedEntry); - WalletManager.saveWallet(selectedWallet!); - setState(() {}); - }, - ); - }, - backgroundColor: - Theme.of(context).colorScheme.secondary, - foregroundColor: - Theme.of(context).colorScheme.onSecondary, - icon: Icons.edit, - ), - SlidableAction( - backgroundColor: - Theme.of(context).colorScheme.error, - foregroundColor: - Theme.of(context).colorScheme.onError, - icon: Icons.delete, - onPressed: (c) { - showDialog( - context: context, - builder: (cx) => PlatformDialog( - title: - AppLocalizations.of(context).sureDialog, - content: Text( - AppLocalizations.of(context).deleteSure), - actions: [ - PlatformButton( - text: AppLocalizations.of(context).yes, - onPressed: () { - selectedWallet?.entries.removeWhere( - (e) => e.id == element.id); - WalletManager.saveWallet( - selectedWallet!); - Navigator.of(cx).pop(); - setState(() {}); - }, + ) + .then( + (editedEntry) { + if (editedEntry == null) return; + selectedWallet!.entries.remove(element); + selectedWallet!.entries.add(editedEntry); + WalletManager.saveWallet(selectedWallet!); + setState(() {}); + }, + ); + }, + backgroundColor: + Theme.of(context).colorScheme.secondary, + foregroundColor: + Theme.of(context).colorScheme.onSecondary, + icon: Icons.edit, + ), + SlidableAction( + backgroundColor: + Theme.of(context).colorScheme.error, + foregroundColor: + Theme.of(context).colorScheme.onError, + icon: Icons.delete, + onPressed: (c) { + showDialog( + context: context, + builder: (cx) => PlatformDialog( + title: + AppLocalizations.of(context).sureDialog, + content: Text( + AppLocalizations.of(context).deleteSure, ), - PlatformButton( - text: AppLocalizations.of(context).no, - onPressed: () { - Navigator.of(cx).pop(); - }, - ), - ], - ), - ); - }, - ), - ]), + actions: [ + PlatformButton( + text: AppLocalizations.of(context).yes, + onPressed: () { + selectedWallet?.entries.removeWhere( + (e) => e.id == element.id, + ); + WalletManager.saveWallet( + selectedWallet!, + ); + Navigator.of(cx).pop(); + setState(() {}); + }, + ), + PlatformButton( + text: AppLocalizations.of(context).no, + onPressed: () { + Navigator.of(cx).pop(); + }, + ), + ], + ), + ); + }, + ), + ], + ), child: ListTile( leading: Container( decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Theme.of(context).colorScheme.secondary), + borderRadius: BorderRadius.circular(16), + color: Theme.of(context).colorScheme.secondary, + ), child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Icon( element.category.icon, color: @@ -350,7 +366,8 @@ class _HomeViewState extends State { ), title: Text(element.data.name), subtitle: Text( - "${element.data.amount} ${selectedWallet!.currency.symbol}"), + "${element.data.amount} ${selectedWallet!.currency.symbol}", + ), ), ), ), @@ -360,7 +377,7 @@ class _HomeViewState extends State { } Future startOcr(ImageSource imgSrc) async { - var availableLanguages = await TessdataApi.getDownloadedData(); + final availableLanguages = await TessdataApi.getDownloadedData(); if (availableLanguages.isEmpty) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -370,8 +387,8 @@ class _HomeViewState extends State { label: AppLocalizations.of(context).download, onPressed: () { Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => const TessdataListView(), + platformRoute( + (c) => const TessdataListView(), ), ); }, @@ -381,70 +398,82 @@ class _HomeViewState extends State { return; } if (!mounted) return; - var selectedLanguages = List.filled(availableLanguages.length, false); + final selectedLanguages = + List.filled(availableLanguages.length, false); selectedLanguages[0] = true; - showDialog( + await showDialog( context: context, builder: (c) => StatefulBuilder( builder: (ctx, setState) => PlatformDialog( actions: [ TextButton( onPressed: () async { - final ImagePicker picker = ImagePicker(); - final XFile? media = await picker.pickImage(source: imgSrc); + final picker = ImagePicker(); + final media = await picker.pickImage(source: imgSrc); if (media == null) { if (mounted) Navigator.of(context).pop(); return; } // get selected languages - var selected = availableLanguages - .where((element) => - selectedLanguages[availableLanguages.indexOf(element)]) + final selected = availableLanguages + .where( + (element) => selectedLanguages[ + availableLanguages.indexOf(element)], + ) .join("+") .replaceAll(".traineddata", ""); logger.i(selected); if (!mounted) return; - showDialog( + unawaited( + showDialog( context: context, builder: (c) => PlatformDialog( - title: AppLocalizations.of(context).ocrLoading), - barrierDismissible: false); - var string = await FlutterTesseractOcr.extractText(media.path, - language: selected, - args: { - "psm": "4", - "preserve_interword_spaces": "1", - }); + title: AppLocalizations.of(context).ocrLoading, + ), + barrierDismissible: false, + ), + ); + final string = await FlutterTesseractOcr.extractText( + media.path, + language: selected, + args: { + "psm": "4", + "preserve_interword_spaces": "1", + }, + ); if (!mounted) return; Navigator.of(context).pop(); logger.i(string); if (!mounted) return; - var lines = string.split("\n") + final lines = string.split("\n") ..removeWhere((element) { element.trim(); return element.isEmpty; }); var price = 0.0; - var description = ""; - for (var line in lines) { + final description = StringBuffer(); + for (final line in lines) { // find numbered prices on each line - var regex = RegExp(r'\d+(?:(?:\.|,) {0,}\d{0,})+'); - for (var match in regex.allMatches(line)) { + final regex = RegExp(r'\d+(?:(?:\.|,) {0,}\d{0,})+'); + for (final match in regex.allMatches(line)) { price += double.tryParse(match.group(0).toString()) ?? 0; } - description += "${line.replaceAll(regex, "")}\n"; + description.write("${line.replaceAll(regex, "")}\n"); } Navigator.of(ctx).pop(); // show edit - Navigator.of(context) - .push( + final newEntry = + await Navigator.of(context).push( platformRoute( (c) => CreateEntryView( w: selectedWallet!, editEntry: WalletSingleEntry( data: EntryData( - name: "", amount: price, description: description), + name: "", + amount: price, + description: description.toString(), + ), type: EntryType.expense, date: DateTime.now(), category: selectedWallet!.categories.first, @@ -452,16 +481,11 @@ class _HomeViewState extends State { ), ), ), - ) - .then( - (newEntry) { - // save entry if we didn't return empty - if (newEntry == null) return; - selectedWallet!.entries.add(newEntry); - WalletManager.saveWallet(selectedWallet!); - setState(() {}); - }, ); + if (newEntry == null) return; + selectedWallet!.entries.add(newEntry); + await WalletManager.saveWallet(selectedWallet!); + setState(() {}); }, child: const Text("Ok"), ), @@ -495,10 +519,10 @@ class _HomeViewState extends State { const SizedBox( width: 10, ), - Text(availableLanguages[index].split(".").first) + Text(availableLanguages[index].split(".").first), ], ), - ) + ), ], ), ), @@ -507,12 +531,12 @@ class _HomeViewState extends State { } Future getLostData() async { - final ImagePicker picker = ImagePicker(); - final LostDataResponse response = await picker.retrieveLostData(); + final picker = ImagePicker(); + final response = await picker.retrieveLostData(); if (response.isEmpty) { return; } - final List? files = response.files; + final files = response.files; if (files != null) { logger.i("Found lost files"); _handleLostFiles(files); diff --git a/lib/views/settings/edit_categories.dart b/lib/views/settings/edit_categories.dart new file mode 100644 index 0000000..85a8d2e --- /dev/null +++ b/lib/views/settings/edit_categories.dart @@ -0,0 +1,224 @@ +// ignore_for_file: inference_failure_on_function_invocation + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_iconpicker/flutter_iconpicker.dart'; +import 'package:prasule/api/category.dart'; +import 'package:prasule/api/wallet.dart'; +import 'package:prasule/api/walletmanager.dart'; +import 'package:prasule/main.dart'; +import 'package:prasule/pw/platformdialog.dart'; +import 'package:prasule/pw/platformfield.dart'; +import 'package:prasule/pw/platformroute.dart'; +import 'package:prasule/views/settings/settings.dart'; +import 'package:prasule/views/setup.dart'; + +/// Allows adding, editing or removing [WalletCategory]s +class EditCategoriesView extends StatefulWidget { + /// Allows adding, editing or removing [WalletCategory]s + const EditCategoriesView({super.key}); + + @override + State createState() => _EditCategoriesViewState(); +} + +class _EditCategoriesViewState extends State { + Wallet? selectedWallet; + List wallets = []; + List categories = []; + + @override + void initState() { + super.initState(); + loadWallet(); + } + + Future loadWallet() async { + wallets = await WalletManager.listWallets(); + if (wallets.isEmpty && mounted) { + unawaited( + Navigator.of(context) + .pushReplacement(platformRoute((c) => const SetupView())), + ); + return; + } + selectedWallet = wallets.first; + categories = selectedWallet!.categories; + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: DropdownButton( + value: + (selectedWallet == null) ? -1 : wallets.indexOf(selectedWallet!), + items: [ + ...wallets.map( + (e) => DropdownMenuItem( + value: wallets.indexOf( + e, + ), + child: Text(e.name), + ), + ), + DropdownMenuItem( + value: -1, + child: Text(AppLocalizations.of(context).newWallet), + ), + ], + onChanged: (v) async { + if (v == null || v == -1) { + await Navigator.of(context).push( + platformRoute( + (c) => const SetupView( + newWallet: true, + ), + ), + ); + wallets = await WalletManager.listWallets(); + logger.i(wallets.length); + selectedWallet = wallets.last; + setState(() {}); + return; + } + selectedWallet = wallets[v]; + setState(() {}); + }, + ), + actions: [ + PopupMenuButton( + itemBuilder: (context) => [ + AppLocalizations.of(context).settings, + AppLocalizations.of(context).about, + ].map((e) => PopupMenuItem(value: e, child: Text(e))).toList(), + onSelected: (value) { + if (value == AppLocalizations.of(context).settings) { + Navigator.of(context).push( + platformRoute( + (context) => const SettingsView(), + ), + ); + } else if (value == AppLocalizations.of(context).about) { + showAboutDialog( + context: context, + applicationLegalese: AppLocalizations.of(context).license, + applicationName: "Prašule", + ); + } + }, + ), + ], + ), + body: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + AppLocalizations.of(context).setupCategoriesEditHint, + textAlign: TextAlign.center, + ), + SizedBox( + height: MediaQuery.of(context).size.height * 0.64, + child: ListView.builder( + shrinkWrap: true, + itemBuilder: (context, i) => ListTile( + leading: GestureDetector( + onTap: () async { + final icon = + await FlutterIconPicker.showIconPicker(context); + if (icon == null) return; + categories[i].icon = icon; + setState(() {}); + }, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Theme.of(context).colorScheme.secondary, + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + categories[i].icon, + color: Theme.of(context).colorScheme.onSecondary, + ), + ), + ), + ), + trailing: IconButton( + icon: const Icon(Icons.cancel), + onPressed: () { + categories.removeAt(i); + setState(() {}); + }, + ), + title: GestureDetector( + onTap: () { + final controller = + TextEditingController(text: categories[i].name); + showDialog( + context: context, + builder: (c) => PlatformDialog( + actions: [ + TextButton( + onPressed: () { + if (controller.text.isEmpty) return; + categories[i].name = controller.text; + Navigator.of(context).pop(); + }, + child: Text(AppLocalizations.of(context).ok), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(AppLocalizations.of(context).cancel), + ), + ], + title: AppLocalizations.of(context) + .setupCategoriesEditingName, + content: SizedBox( + width: 400, + child: PlatformField(controller: controller), + ), + ), + ); + }, + child: Text( + categories[i].name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + itemCount: categories.length, + ), + ), + IconButton( + onPressed: () { + var id = 1; + while ( + categories.where((element) => element.id == id).isNotEmpty) { + id++; // create unique ID + } + categories.add( + WalletCategory( + name: AppLocalizations.of(context).setupWalletNamePlaceholder, + type: EntryType.expense, + id: id, + icon: IconData( + Icons.question_mark.codePoint, + fontFamily: 'MaterialIcons', + ), + ), + ); + setState(() {}); + }, + icon: const Icon(Icons.add), + ), + ], + ), + ); + } +} diff --git a/lib/views/settings/graph_type.dart b/lib/views/settings/graph_type.dart index 9b5eb5f..cc5d57e 100644 --- a/lib/views/settings/graph_type.dart +++ b/lib/views/settings/graph_type.dart @@ -1,10 +1,14 @@ +// ignore_for_file: inference_failure_on_function_invocation + import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:prasule/pw/platformdialog.dart'; import 'package:settings_ui/settings_ui.dart'; import 'package:shared_preferences/shared_preferences.dart'; +/// Allows setting the type of graph for certain data class GraphTypeSettingsView extends StatefulWidget { + /// Allows setting the type of graph for certain data const GraphTypeSettingsView({super.key}); @override @@ -33,16 +37,19 @@ class _GraphTypeSettingsViewState extends State { body: SettingsList( applicationType: ApplicationType.both, darkTheme: SettingsThemeData( - settingsListBackground: Theme.of(context).colorScheme.background, - titleTextColor: Theme.of(context).colorScheme.primary), + settingsListBackground: Theme.of(context).colorScheme.background, + titleTextColor: Theme.of(context).colorScheme.primary, + ), sections: [ SettingsSection( tiles: [ SettingsTile.navigation( title: Text(AppLocalizations.of(context).yearly), - value: Text(_yearly == 1 - ? AppLocalizations.of(context).barChart - : AppLocalizations.of(context).lineChart), + value: Text( + _yearly == 1 + ? AppLocalizations.of(context).barChart + : AppLocalizations.of(context).lineChart, + ), onPressed: (c) => showDialog( context: c, builder: (ctx) => PlatformDialog( @@ -53,13 +60,15 @@ class _GraphTypeSettingsViewState extends State { width: MediaQuery.of(ctx).size.width, child: InkWell( child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text(AppLocalizations.of(context).barChart, - textAlign: TextAlign.center), + padding: const EdgeInsets.all(8), + child: Text( + AppLocalizations.of(context).barChart, + textAlign: TextAlign.center, + ), ), onTap: () async { - var s = await SharedPreferences.getInstance(); - s.setInt("yearlygraph", 1); + final s = await SharedPreferences.getInstance(); + await s.setInt("yearlygraph", 1); _yearly = 1; if (!mounted) return; Navigator.of(ctx).pop(); @@ -71,15 +80,15 @@ class _GraphTypeSettingsViewState extends State { width: MediaQuery.of(context).size.width, child: InkWell( child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text( AppLocalizations.of(context).lineChart, textAlign: TextAlign.center, ), ), onTap: () async { - var s = await SharedPreferences.getInstance(); - s.setInt("yearlygraph", 2); + final s = await SharedPreferences.getInstance(); + await s.setInt("yearlygraph", 2); _yearly = 2; if (!mounted) return; Navigator.of(ctx).pop(); @@ -94,9 +103,11 @@ class _GraphTypeSettingsViewState extends State { ), SettingsTile.navigation( title: Text(AppLocalizations.of(context).monthly), - value: Text(_monthly == 1 - ? AppLocalizations.of(context).barChart - : AppLocalizations.of(context).lineChart), + value: Text( + _monthly == 1 + ? AppLocalizations.of(context).barChart + : AppLocalizations.of(context).lineChart, + ), onPressed: (c) => showDialog( context: c, builder: (ctx) => PlatformDialog( @@ -107,15 +118,15 @@ class _GraphTypeSettingsViewState extends State { width: MediaQuery.of(ctx).size.width, child: InkWell( child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text( AppLocalizations.of(context).barChart, textAlign: TextAlign.center, ), ), onTap: () async { - var s = await SharedPreferences.getInstance(); - s.setInt("monthlygraph", 1); + final s = await SharedPreferences.getInstance(); + await s.setInt("monthlygraph", 1); _monthly = 1; if (!mounted) return; Navigator.of(ctx).pop(); @@ -127,14 +138,15 @@ class _GraphTypeSettingsViewState extends State { width: MediaQuery.of(ctx).size.width, child: InkWell( child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text( - AppLocalizations.of(context).lineChart, - textAlign: TextAlign.center), + AppLocalizations.of(context).lineChart, + textAlign: TextAlign.center, + ), ), onTap: () async { - var s = await SharedPreferences.getInstance(); - s.setInt("monthlygraph", 2); + final s = await SharedPreferences.getInstance(); + await s.setInt("monthlygraph", 2); _monthly = 2; if (!mounted) return; Navigator.of(ctx).pop(); @@ -148,7 +160,7 @@ class _GraphTypeSettingsViewState extends State { ), ), ], - ) + ), ], ), ); diff --git a/lib/views/settings/settings.dart b/lib/views/settings/settings.dart index 3ab16b7..c1e63e9 100644 --- a/lib/views/settings/settings.dart +++ b/lib/views/settings/settings.dart @@ -1,15 +1,18 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:prasule/main.dart'; import 'package:prasule/pw/platformroute.dart'; +import 'package:prasule/views/settings/edit_categories.dart'; import 'package:prasule/views/settings/graph_type.dart'; import 'package:prasule/views/settings/tessdata_list.dart'; import 'package:settings_ui/settings_ui.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:shared_preferences/shared_preferences.dart'; +/// Shows settings categories class SettingsView extends StatefulWidget { + /// Shows settings categories const SettingsView({super.key}); @override @@ -35,9 +38,25 @@ class _SettingsViewState extends State { body: SettingsList( applicationType: ApplicationType.both, darkTheme: SettingsThemeData( - settingsListBackground: Theme.of(context).colorScheme.background, - titleTextColor: Theme.of(context).colorScheme.primary), + settingsListBackground: Theme.of(context).colorScheme.background, + titleTextColor: Theme.of(context).colorScheme.primary, + ), sections: [ + SettingsSection( + tiles: [ + SettingsTile.navigation( + title: Text(AppLocalizations.of(context).editCategories), + description: + Text(AppLocalizations.of(context).editCategoriesDesc), + trailing: const Icon(Icons.keyboard_arrow_right), + onPressed: (context) => Navigator.of(context).push( + platformRoute( + (c) => const EditCategoriesView(), + ), + ), + ), + ], + ), SettingsSection( tiles: [ SettingsTile.navigation( @@ -45,9 +64,12 @@ class _SettingsViewState extends State { description: Text(AppLocalizations.of(context).downloadedOcrDesc), trailing: const Icon(Icons.keyboard_arrow_right), - onPressed: (context) => Navigator.of(context) - .push(platformRoute((c) => const TessdataListView())), - ) + onPressed: (context) => Navigator.of(context).push( + platformRoute( + (c) => const TessdataListView(), + ), + ), + ), ], title: Text(AppLocalizations.of(context).ocr), ), @@ -68,16 +90,18 @@ class _SettingsViewState extends State { SettingsTile.switchTile( initialValue: _useMaterialYou, onToggle: (v) async { - var s = await SharedPreferences.getInstance(); - s.setBool("useMaterialYou", v); + final s = await SharedPreferences.getInstance(); + await s.setBool("useMaterialYou", v); _useMaterialYou = v; setState(() {}); }, title: Text(AppLocalizations.of(context).enableYou), - description: Text(AppLocalizations.of(context).enableYouDesc), - ) + description: Text( + AppLocalizations.of(context).enableYouDesc, + ), + ), ], - ) + ), ], ), ); diff --git a/lib/views/settings/tessdata_list.dart b/lib/views/settings/tessdata_list.dart index 6fcee0c..a0648a5 100644 --- a/lib/views/settings/tessdata_list.dart +++ b/lib/views/settings/tessdata_list.dart @@ -1,15 +1,19 @@ +// ignore_for_file: inference_failure_on_function_invocation + import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_tesseract_ocr/flutter_tesseract_ocr.dart'; import 'package:prasule/main.dart'; import 'package:prasule/network/tessdata.dart'; import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformdialog.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +/// Used to manage downloaded Tessdata for OCR class TessdataListView extends StatefulWidget { + /// Used to manage downloaded Tessdata for OCR const TessdataListView({super.key}); @override @@ -18,7 +22,7 @@ class TessdataListView extends StatefulWidget { class _TessdataListViewState extends State { final _tessdata = [ - {"eng": true} + {"eng": true}, ]; @override void didChangeDependencies() { @@ -49,19 +53,22 @@ class _TessdataListViewState extends State { itemBuilder: (context, i) => ListTile( title: Text(_tessdata[i].keys.first), trailing: TextButton( - child: Text(_tessdata[i][_tessdata[i].keys.first]! - ? AppLocalizations.of(context).downloaded - : AppLocalizations.of(context).download), + child: Text( + _tessdata[i][_tessdata[i].keys.first]! + ? AppLocalizations.of(context).downloaded + : AppLocalizations.of(context).download, + ), onPressed: () async { - var lang = _tessdata[i].keys.first; + final lang = _tessdata[i].keys.first; if (_tessdata[i][lang]!) { // deleting data - showDialog( + await showDialog( context: context, builder: (context) => PlatformDialog( title: AppLocalizations.of(context).sureDialog, - content: Text(AppLocalizations.of(context) - .deleteOcr(lang)), + content: Text( + AppLocalizations.of(context).deleteOcr(lang), + ), actions: [ PlatformButton( text: AppLocalizations.of(context).yes, @@ -86,42 +93,49 @@ class _TessdataListViewState extends State { // TODO: handle wifi errors //* downloading traineddata - var progressStream = StreamController(); + final progressStream = StreamController(); - showDialog( - context: context, - builder: (c) => PlatformDialog( - title: AppLocalizations.of(context) - .langDownloadDialog(lang), - content: StreamBuilder( - builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState.waiting) { - return const CircularProgressIndicator(); - } - if (snapshot.hasError) { - return const Text("Error"); - } - return Text(AppLocalizations.of(context) - .langDownloadProgress(snapshot.data!)); - }, - stream: progressStream.stream, + unawaited( + showDialog( + context: context, + builder: (c) => PlatformDialog( + title: AppLocalizations.of(context) + .langDownloadDialog(lang), + content: StreamBuilder( + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.waiting) { + return const CircularProgressIndicator(); + } + if (snapshot.hasError) { + return const Text("Error"); + } + return Text( + AppLocalizations.of(context) + .langDownloadProgress(snapshot.data!), + ); + }, + stream: progressStream.stream, + ), ), ), ); - await TessdataApi.downloadData(lang, callback: (a, b) { - if (progressStream.isClosed) return; - var p = a / b * 1000; - progressStream.add(p.roundToDouble() / 10); - if (p / 10 >= 100) { - logger.i("Done"); - Navigator.of(context, rootNavigator: true) - .pop("dialog"); - _tessdata[i][lang] = true; - progressStream.close(); - } - setState(() {}); - }); + await TessdataApi.downloadData( + lang, + callback: (a, b) { + if (progressStream.isClosed) return; + final p = a / b * 1000; + progressStream.add(p.roundToDouble() / 10); + if (p / 10 >= 100) { + logger.i("Done"); + Navigator.of(context, rootNavigator: true) + .pop("dialog"); + _tessdata[i][lang] = true; + progressStream.close(); + } + setState(() {}); + }, + ); }, ), ), @@ -134,25 +148,26 @@ class _TessdataListViewState extends State { /// Used to find which `.traineddata` is already downloaded and which not /// so we can show it to the user - void loadAllTessdata() async { - var tessDir = Directory(await FlutterTesseractOcr.getTessdataPath()); - var d = await TessdataApi.getAvailableData(); - var dataStatus = >[]; - for (var data in d) { - var e = {}; + Future loadAllTessdata() async { + final tessDir = Directory(await FlutterTesseractOcr.getTessdataPath()); + final d = await TessdataApi.getAvailableData(); + final dataStatus = >[]; + for (final data in d) { + final e = {}; e[data] = false; dataStatus.add(e); } - var appDir = tessDir.listSync(); - for (var file in appDir) { + final appDir = tessDir.listSync(); + for (final file in appDir) { if (file is! File || !file.path.endsWith("traineddata") || file.path.endsWith("eng.traineddata")) continue; logger.i(file.path); - var filename = file.path.split("/").last; - dataStatus[dataStatus.indexWhere((element) => - element.keys.first == filename.replaceAll(".traineddata", ""))] - [filename.replaceAll(".traineddata", "")] = true; + final filename = file.path.split("/").last; + dataStatus[dataStatus.indexWhere( + (element) => + element.keys.first == filename.replaceAll(".traineddata", ""), + )][filename.replaceAll(".traineddata", "")] = true; } _tessdata.addAll(dataStatus); setState(() {}); diff --git a/lib/views/setup.dart b/lib/views/setup.dart index 9cebdbc..c288f8f 100644 --- a/lib/views/setup.dart +++ b/lib/views/setup.dart @@ -1,6 +1,9 @@ +// ignore_for_file: inference_failure_on_function_invocation + import 'package:currency_picker/currency_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_iconpicker/flutter_iconpicker.dart'; import 'package:introduction_screen/introduction_screen.dart'; import 'package:prasule/api/category.dart'; @@ -9,10 +12,12 @@ import 'package:prasule/api/walletmanager.dart'; import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformdialog.dart'; import 'package:prasule/pw/platformfield.dart'; +import 'package:prasule/pw/platformroute.dart'; import 'package:prasule/views/home.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +/// View that shows on first-time setup class SetupView extends StatefulWidget { + /// View that shows on first-time setup const SetupView({super.key, this.newWallet = false}); /// We are only creating a new wallet, no first-time setup @@ -22,22 +27,24 @@ class SetupView extends StatefulWidget { } class _SetupViewState extends State { - var _selectedCurrency = Currency.from(json: { - "code": "USD", - "name": "United States Dollar", - "symbol": "\$", - "flag": "USD", - "decimal_digits": 2, - "number": 840, - "name_plural": "US dollars", - "thousands_separator": ",", - "decimal_separator": ".", - "space_between_amount_and_symbol": false, - "symbol_on_left": true, - }); - var categories = []; - var name = ""; - var balance = 0.0; + var _selectedCurrency = Currency.from( + json: { + "code": "USD", + "name": "United States Dollar", + "symbol": r"$", + "flag": "USD", + "decimal_digits": 2, + "number": 840, + "name_plural": "US dollars", + "thousands_separator": ",", + "decimal_separator": ".", + "space_between_amount_and_symbol": false, + "symbol_on_left": true, + }, + ); + List categories = []; + String name = ""; + double balance = 0; @override void didChangeDependencies() { @@ -48,8 +55,10 @@ class _SetupViewState extends State { name: AppLocalizations.of(context).categoryHealth, type: EntryType.expense, id: 1, - icon: IconData(Icons.medical_information.codePoint, - fontFamily: 'MaterialIcons'), + icon: IconData( + Icons.medical_information.codePoint, + fontFamily: 'MaterialIcons', + ), ), WalletCategory( name: AppLocalizations.of(context).categoryCar, @@ -87,9 +96,7 @@ class _SetupViewState extends State { dotsDecorator: DotsDecorator( activeColor: Theme.of(context).colorScheme.primary, ), - showNextButton: true, showBackButton: true, - showDoneButton: true, next: Text(AppLocalizations.of(context).next), back: Text(AppLocalizations.of(context).back), done: Text(AppLocalizations.of(context).finish), @@ -97,15 +104,18 @@ class _SetupViewState extends State { if (name.isEmpty) { ScaffoldMessenger.of(context) .clearSnackBars(); // TODO: iOS replacement - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: - Text(AppLocalizations.of(context).errorEmptyName))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context).errorEmptyName), + ), + ); return; } - var wallet = Wallet( - name: name, - currency: _selectedCurrency, - categories: categories); + final wallet = Wallet( + name: name, + currency: _selectedCurrency, + categories: categories, + ); WalletManager.saveWallet(wallet).then( (value) { if (!value) { @@ -123,8 +133,8 @@ class _SetupViewState extends State { return; } Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (c) => const HomeView(), + platformRoute( + (c) => const HomeView(), ), ); }, @@ -139,7 +149,9 @@ class _SetupViewState extends State { child: Text( AppLocalizations.of(context).welcome, style: const TextStyle( - fontSize: 24, fontWeight: FontWeight.bold), + fontSize: 24, + fontWeight: FontWeight.bold, + ), textAlign: TextAlign.center, ), ), @@ -150,7 +162,8 @@ class _SetupViewState extends State { if (!widget.newWallet) Flexible( child: Text( - AppLocalizations.of(context).welcomeAboutPrasule), + AppLocalizations.of(context).welcomeAboutPrasule, + ), ), if (!widget.newWallet) const SizedBox( @@ -158,7 +171,8 @@ class _SetupViewState extends State { ), Flexible( child: Text( - AppLocalizations.of(context).welcomeInstruction), + AppLocalizations.of(context).welcomeInstruction, + ), ), ], ), @@ -172,7 +186,9 @@ class _SetupViewState extends State { AppLocalizations.of(context).setupWalletNameCurrency, textAlign: TextAlign.center, style: const TextStyle( - fontSize: 24, fontWeight: FontWeight.bold), + fontSize: 24, + fontWeight: FontWeight.bold, + ), ), ), bodyWidget: Column( @@ -213,11 +229,12 @@ class _SetupViewState extends State { labelText: AppLocalizations.of(context).setupStartingBalance, keyboardType: const TextInputType.numberWithOptions( - decimal: true), + decimal: true, + ), inputFormatters: [ FilteringTextInputFormatter.allow( RegExp(r'\d+[\.,]{0,1}\d{0,}'), - ) + ), ], onChanged: (t) { balance = double.parse(t); @@ -236,7 +253,9 @@ class _SetupViewState extends State { AppLocalizations.of(context).setupCategoriesHeading, textAlign: TextAlign.center, style: const TextStyle( - fontSize: 24, fontWeight: FontWeight.bold), + fontSize: 24, + fontWeight: FontWeight.bold, + ), ), ), bodyWidget: Column( @@ -253,19 +272,21 @@ class _SetupViewState extends State { itemBuilder: (context, i) => ListTile( leading: GestureDetector( onTap: () async { - var icon = await FlutterIconPicker.showIconPicker( - context); + final icon = + await FlutterIconPicker.showIconPicker( + context, + ); if (icon == null) return; categories[i].icon = icon; setState(() {}); }, child: Container( decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: - Theme.of(context).colorScheme.secondary), + borderRadius: BorderRadius.circular(16), + color: Theme.of(context).colorScheme.secondary, + ), child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Icon( categories[i].icon, color: @@ -283,8 +304,9 @@ class _SetupViewState extends State { ), title: GestureDetector( onTap: () { - var controller = TextEditingController( - text: categories[i].name); + final controller = TextEditingController( + text: categories[i].name, + ); showDialog( context: context, builder: (c) => PlatformDialog( @@ -296,14 +318,16 @@ class _SetupViewState extends State { Navigator.of(context).pop(); }, child: Text( - AppLocalizations.of(context).ok), + AppLocalizations.of(context).ok, + ), ), TextButton( onPressed: () { Navigator.of(context).pop(); }, child: Text( - AppLocalizations.of(context).cancel), + AppLocalizations.of(context).cancel, + ), ), ], title: AppLocalizations.of(context) @@ -340,14 +364,16 @@ class _SetupViewState extends State { .setupWalletNamePlaceholder, type: EntryType.expense, id: id, - icon: IconData(Icons.question_mark.codePoint, - fontFamily: 'MaterialIcons'), + icon: IconData( + Icons.question_mark.codePoint, + fontFamily: 'MaterialIcons', + ), ), ); setState(() {}); }, icon: const Icon(Icons.add), - ) + ), ], ), ), diff --git a/pubspec.lock b/pubspec.lock index d8bb9d3..77b846c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -317,10 +317,10 @@ packages: dependency: "direct main" description: name: fl_chart - sha256: "5a74434cc83bf64346efb562f1a06eefaf1bcb530dc3d96a104f631a1eff8d79" + sha256: fe6fec7d85975a99c73b9515a69a6e291364accfa0e4a5b3ce6de814d74b9a1c url: "https://pub.dev" source: hosted - version: "0.65.0" + version: "0.66.0" flutter: dependency: "direct main" description: flutter @@ -1105,6 +1105,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + very_good_analysis: + dependency: "direct dev" + description: + name: very_good_analysis + sha256: "9ae7f3a3bd5764fb021b335ca28a34f040cd0ab6eec00a1b213b445dae58a4b8" + url: "https://pub.dev" + source: hosted + version: "5.1.0" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d642b16..e87beb4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: prasule description: Open-source private expense tracker -version: 1.0.0-alpha.2+2 +version: 1.0.0-alpha+3 environment: sdk: '>=3.1.0-262.2.beta <4.0.0' @@ -13,37 +13,33 @@ environment: # the latest version available on pub.dev. To see which dependencies have newer # versions available, run `flutter pub outdated`. dependencies: + cupertino_icons: ^1.0.2 + currency_picker: ^2.0.16 + dio: ^5.3.0 + dynamic_color: ^1.6.6 + fl_chart: ^0.66.0 flutter: sdk: flutter - - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.2 - path_provider: ^2.0.15 - dio: ^5.3.0 - logger: ^2.0.0 - settings_ui: ^2.0.2 - currency_picker: ^2.0.16 - json_serializable: ^6.7.1 - json_annotation: ^4.8.1 flutter_iconpicker: ^3.2.4 - dynamic_color: ^1.6.6 - introduction_screen: ^3.1.11 - intl: any - grouped_list: ^5.1.2 - flutter_speed_dial: ^7.0.0 - image_picker: ^1.0.1 - flutter_tesseract_ocr: ^0.4.23 - flutter_slidable: ^3.0.0 flutter_localizations: sdk: flutter - fl_chart: ^0.65.0 + flutter_slidable: ^3.0.0 + flutter_speed_dial: ^7.0.0 + flutter_tesseract_ocr: ^0.4.23 + grouped_list: ^5.1.2 + image_picker: ^1.0.1 + intl: any + introduction_screen: ^3.1.11 + json_annotation: ^4.8.1 + json_serializable: ^6.7.1 + logger: ^2.0.0 + path_provider: ^2.0.15 + settings_ui: ^2.0.2 shared_preferences: ^2.2.2 dev_dependencies: - flutter_test: - sdk: flutter + build_runner: ^2.4.6 + flutter_launcher_icons: ^0.13.1 # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is @@ -51,11 +47,12 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^3.0.0 - build_runner: ^2.4.6 - test: ^1.24.6 + flutter_test: + sdk: flutter integration_test: sdk: flutter - flutter_launcher_icons: ^0.13.1 + test: ^1.24.6 + very_good_analysis: ^5.1.0 flutter_launcher_icons: android: true @@ -66,7 +63,7 @@ flutter_launcher_icons: adaptive_icon_foreground: "assets/icon/dynamic_foreground.png" remove_alpha_ios: true # web: - # generate: true + # generate: true # background_color: "#2fe288" # theme_color: "#2fe288" # windows: @@ -77,7 +74,6 @@ flutter_launcher_icons: # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec - # The following section is specific to Flutter packages. flutter: generate: true @@ -88,18 +84,14 @@ flutter: assets: - assets/ - assets/tessdata/ - # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg - # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware - # For details regarding adding assets from package dependencies, see # https://flutter.dev/assets-and-images/#from-packages - # To add custom fonts to your application, add a fonts section here, # in this "flutter" section. Each entry in this list should have a # "family" key with the font family name, and a "fonts" key with a From 480c4e253812a2b67e116a132364a0f845d36930 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Sun, 31 Dec 2023 11:42:27 +0100 Subject: [PATCH 10/17] fix: wrong type cast --- lib/api/wallet.g.dart | 2 +- lib/api/walletmanager.dart | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/api/wallet.g.dart b/lib/api/wallet.g.dart index 441b055..d2affe5 100644 --- a/lib/api/wallet.g.dart +++ b/lib/api/wallet.g.dart @@ -15,7 +15,7 @@ Wallet _$WalletFromJson(Map json) => Wallet( const [], entries: (json['entries'] as List?) ?.map( - (e) => WalletSingleEntry.fromJson(e as Map),) + (e) => WalletSingleEntry.fromJson(e as Map)) .toList() ?? const [], starterBalance: (json['starterBalance'] as num?)?.toDouble() ?? 0, diff --git a/lib/api/walletmanager.dart b/lib/api/walletmanager.dart index 0f00156..d816b46 100644 --- a/lib/api/walletmanager.dart +++ b/lib/api/walletmanager.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:path_provider/path_provider.dart'; import 'package:prasule/api/wallet.dart'; +import 'package:prasule/main.dart'; /// Used for [Wallet]-managing operations class WalletManager { @@ -19,9 +20,11 @@ class WalletManager { try { wallets.add(await loadWallet(w)); } catch (e) { + logger.e(e); // TODO: do something with unreadable wallets } } + logger.i(wallets.length); return wallets; } @@ -37,7 +40,8 @@ class WalletManager { return Future.error("Wallet does not exist"); } return Wallet.fromJson( - jsonDecode(wallet.readAsStringSync()) as Map,); + jsonDecode(wallet.readAsStringSync()) as Map, + ); } /// Converts [Wallet] to JSON and saves it to AppData @@ -50,6 +54,7 @@ class WalletManager { } // if (!wallet.existsSync()) return false; wallet.writeAsStringSync(jsonEncode(w.toJson())); + logger.i(wallet.existsSync()); return true; } From 238caf92032d61e5c0aa390007aebd8df2a34925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Sun, 31 Dec 2023 12:41:10 +0100 Subject: [PATCH 11/17] fix: make edit_categories actually edit categories Also make sure home loads the wallet again after exiting settings. Also removed 'type' from Category, because I don't know what it was supposed to do there. --- CHANGELOG.md | 1 + lib/api/category.dart | 9 +- lib/api/category.g.dart | 7 - lib/api/entry_data.dart | 4 +- lib/api/wallet.dart | 47 +++++- lib/api/walletentry.dart | 17 +- lib/api/walletmanager.dart | 1 - lib/l10n/app_cs.arb | 4 +- lib/l10n/app_en.arb | 4 +- lib/views/home.dart | 9 +- lib/views/settings/edit_categories.dart | 216 +++++++++++++----------- lib/views/settings/settings.dart | 1 + lib/views/setup.dart | 171 ++++++++++--------- 13 files changed, 275 insertions(+), 216 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2adbf2b..2f52790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # 1.0.0-alpha+3 - Add settings view for editing wallet categories - Change code according to more aggressive linting +- Create a default "no category" category, mainly to store entries with removed categories # 1.0.0-alpha+2 - Fixed localization issues - Added graphs for expenses and income per month/year diff --git a/lib/api/category.dart b/lib/api/category.dart index 0a2e16f..30424f7 100644 --- a/lib/api/category.dart +++ b/lib/api/category.dart @@ -9,19 +9,14 @@ class WalletCategory { /// Represents a category in a user's wallet WalletCategory({ required this.name, - required this.type, required this.id, required this.icon, }); - /// Connect the generated [_$WalletEntry] function to the `fromJson` - /// factory. + /// Connects generated fromJson method factory WalletCategory.fromJson(Map json) => _$WalletCategoryFromJson(json); - /// Expense or income - final EntryType type; - /// User-defined name String name; @@ -32,7 +27,7 @@ class WalletCategory { @JsonKey(fromJson: _iconDataFromJson, toJson: _iconDataToJson) IconData icon; - /// Connect the generated [_$PersonToJson] function to the `toJson` method. + /// Connects generated toJson method Map toJson() => _$WalletCategoryToJson(this); @override diff --git a/lib/api/category.g.dart b/lib/api/category.g.dart index 00f6a92..f70475f 100644 --- a/lib/api/category.g.dart +++ b/lib/api/category.g.dart @@ -9,20 +9,13 @@ part of 'category.dart'; WalletCategory _$WalletCategoryFromJson(Map json) => WalletCategory( name: json['name'] as String, - type: $enumDecode(_$EntryTypeEnumMap, json['type']), id: json['id'] as int, icon: _iconDataFromJson(json['icon'] as Map), ); Map _$WalletCategoryToJson(WalletCategory instance) => { - 'type': _$EntryTypeEnumMap[instance.type]!, 'name': instance.name, 'id': instance.id, 'icon': _iconDataToJson(instance.icon), }; - -const _$EntryTypeEnumMap = { - EntryType.expense: 'expense', - EntryType.income: 'income', -}; diff --git a/lib/api/entry_data.dart b/lib/api/entry_data.dart index 21e8f59..23281f2 100644 --- a/lib/api/entry_data.dart +++ b/lib/api/entry_data.dart @@ -7,7 +7,7 @@ class EntryData { /// Contains raw data EntryData({required this.name, required this.amount, this.description = ""}); - /// Connects generated fromJson function + /// Connects generated fromJson method factory EntryData.fromJson(Map json) => _$EntryDataFromJson(json); @@ -20,6 +20,6 @@ class EntryData { /// Amount for entry double amount; - /// Connects generated toJson function + /// Connects generated toJson method Map toJson() => _$EntryDataToJson(this); } diff --git a/lib/api/wallet.dart b/lib/api/wallet.dart index ed6526b..e38b844 100644 --- a/lib/api/wallet.dart +++ b/lib/api/wallet.dart @@ -2,6 +2,7 @@ import 'package:currency_picker/currency_picker.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/walletentry.dart'; +import 'package:prasule/api/walletmanager.dart'; part 'wallet.g.dart'; Currency _currencyFromJson(Map data) => @@ -13,14 +14,15 @@ Currency _currencyFromJson(Map data) => @JsonSerializable() class Wallet { /// A wallet stores [WalletSingleEntry]s categorized under [WalletCategory]s - Wallet( - {required this.name, - required this.currency, - this.categories = const [], - this.entries = const [], - this.starterBalance = 0,}); + Wallet({ + required this.name, + required this.currency, + this.categories = const [], + this.entries = const [], + this.starterBalance = 0, + }); - /// Connects generated fromJson function + /// Connects generated fromJson method factory Wallet.fromJson(Map json) => _$WalletFromJson(json); /// Name of the wallet @@ -41,10 +43,10 @@ class Wallet { @JsonKey(fromJson: _currencyFromJson) final Currency currency; - /// Connects generated toJson function + /// Connects generated toJson method Map toJson() => _$WalletToJson(this); - /// Getter for the next unused unique number ID in the wallet's entry list + /// Getter for the next unused unique number ID in the wallet's **entry** list int get nextId { var id = 1; while (entries.where((element) => element.id == id).isNotEmpty) { @@ -53,6 +55,33 @@ class Wallet { return id; } + /// Getter for the next unused unique number ID in the wallet's **category** + /// list + int get nextCategoryId { + var id = 0; + while (categories.where((element) => element.id == id).isNotEmpty) { + id++; // create unique ID + } + return id; + } + + /// Removes the specified category. + /// + /// All [WalletSingleEntry]s will have their category reassigned + /// to the default *No category* + Future removeCategory(WalletCategory category) async { + // First remove the category from existing entries + for (final entryToChange + in entries.where((element) => element.category.id == category.id)) { + entryToChange.category = + categories.where((element) => element.id == 0).first; + } + // Remove the category + categories.removeWhere((element) => element.id == category.id); + // Save + await WalletManager.saveWallet(this); + } + /// Empty wallet used for placeholders static final Wallet empty = Wallet( name: "Empty", diff --git a/lib/api/walletentry.dart b/lib/api/walletentry.dart index 878c023..a515fc5 100644 --- a/lib/api/walletentry.dart +++ b/lib/api/walletentry.dart @@ -9,14 +9,15 @@ part 'walletentry.g.dart'; /// This is an entry containing a single item class WalletSingleEntry { /// This is an entry containing a single item - WalletSingleEntry( - {required this.data, - required this.type, - required this.date, - required this.category, - required this.id,}); + WalletSingleEntry({ + required this.data, + required this.type, + required this.date, + required this.category, + required this.id, + }); - /// Connects generated fromJson function + /// Connects generated fromJson method factory WalletSingleEntry.fromJson(Map json) => _$WalletSingleEntryFromJson(json); @@ -35,6 +36,6 @@ class WalletSingleEntry { /// Unique entry ID int id; - /// Connects generated toJson function + /// Connects generated toJson method Map toJson() => _$WalletSingleEntryToJson(this); } diff --git a/lib/api/walletmanager.dart b/lib/api/walletmanager.dart index d816b46..e2c66ab 100644 --- a/lib/api/walletmanager.dart +++ b/lib/api/walletmanager.dart @@ -54,7 +54,6 @@ class WalletManager { } // if (!wallet.existsSync()) return false; wallet.writeAsStringSync(jsonEncode(w.toJson())); - logger.i(wallet.existsSync()); return true; } diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index 13fc0a3..c3065c1 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -76,6 +76,8 @@ "enableYou":"Povolit Material You (Může vyžadovat restart aplikace)", "enableYouDesc":"Aplikace použije barevné schéma z vaší tapety", "editCategories":"Upravit kategorie", - "editCategoriesDesc":"Přidat, upravit nebo odebrat kategorii z peněženky" + "editCategoriesDesc":"Přidat, upravit nebo odebrat kategorii z peněženky", + "wallet":"Peněženka", + "noCategory":"Žádná kategorie" } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8aeea34..eb1bf55 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -156,5 +156,7 @@ "enableYou":"Enable Material You (May require an app restart)", "enableYouDesc":"The app will use a color scheme from your wallpaper", "editCategories":"Edit categories", - "editCategoriesDesc":"Add, edit or remove categories from a wallet" + "editCategoriesDesc":"Add, edit or remove categories from a wallet", + "wallet":"Wallet", + "noCategory":"No category" } \ No newline at end of file diff --git a/lib/views/home.dart b/lib/views/home.dart index 868cdae..430ccb1 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -198,11 +198,16 @@ class _HomeViewState extends State { ].map((e) => PopupMenuItem(value: e, child: Text(e))).toList(), onSelected: (value) { if (value == AppLocalizations.of(context).settings) { - Navigator.of(context).push( + Navigator.of(context) + .push( platformRoute( (context) => const SettingsView(), ), - ); + ) + .then((value) async { + selectedWallet = + await WalletManager.loadWallet(selectedWallet!.name); + }); } else if (value == AppLocalizations.of(context).about) { showAboutDialog( context: context, diff --git a/lib/views/settings/edit_categories.dart b/lib/views/settings/edit_categories.dart index 85a8d2e..b97afc0 100644 --- a/lib/views/settings/edit_categories.dart +++ b/lib/views/settings/edit_categories.dart @@ -27,7 +27,6 @@ class EditCategoriesView extends StatefulWidget { class _EditCategoriesViewState extends State { Wallet? selectedWallet; List wallets = []; - List categories = []; @override void initState() { @@ -45,7 +44,7 @@ class _EditCategoriesViewState extends State { return; } selectedWallet = wallets.first; - categories = selectedWallet!.categories; + logger.i(selectedWallet!.categories); setState(() {}); } @@ -115,109 +114,130 @@ class _EditCategoriesViewState extends State { ), body: Column( mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - AppLocalizations.of(context).setupCategoriesEditHint, - textAlign: TextAlign.center, - ), - SizedBox( - height: MediaQuery.of(context).size.height * 0.64, - child: ListView.builder( - shrinkWrap: true, - itemBuilder: (context, i) => ListTile( - leading: GestureDetector( - onTap: () async { - final icon = - await FlutterIconPicker.showIconPicker(context); - if (icon == null) return; - categories[i].icon = icon; - setState(() {}); - }, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Theme.of(context).colorScheme.secondary, - ), - child: Padding( - padding: const EdgeInsets.all(8), - child: Icon( - categories[i].icon, - color: Theme.of(context).colorScheme.onSecondary, - ), - ), + children: selectedWallet == null + ? [const CircularProgressIndicator()] + : [ + Text( + AppLocalizations.of(context).setupCategoriesEditHint, + textAlign: TextAlign.center, + ), + SizedBox( + height: MediaQuery.of(context).size.height * 0.64, + child: ListView.builder( + shrinkWrap: true, + itemBuilder: (context, i) => (i == 0) + ? const SizedBox() + : ListTile( + leading: GestureDetector( + onTap: () async { + final icon = + await FlutterIconPicker.showIconPicker( + context, + ); + if (icon == null) return; + selectedWallet!.categories[i].icon = icon; + await WalletManager.saveWallet(selectedWallet!); + setState(() {}); + }, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: + Theme.of(context).colorScheme.secondary, + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + selectedWallet!.categories[i].icon, + color: Theme.of(context) + .colorScheme + .onSecondary, + ), + ), + ), + ), + trailing: IconButton( + icon: const Icon(Icons.cancel), + onPressed: () async { + await selectedWallet!.removeCategory( + selectedWallet!.categories[i], + ); + setState(() {}); + }, + ), + title: GestureDetector( + onTap: () { + final controller = TextEditingController( + text: selectedWallet!.categories[i].name, + ); + showDialog( + context: context, + builder: (c) => PlatformDialog( + actions: [ + TextButton( + onPressed: () async { + if (controller.text.isEmpty) return; + selectedWallet!.categories[i].name = + controller.text; + await WalletManager.saveWallet( + selectedWallet!, + ); + if (!mounted) return; + Navigator.of(context).pop(); + }, + child: Text( + AppLocalizations.of(context).ok, + ), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text( + AppLocalizations.of(context).cancel, + ), + ), + ], + title: AppLocalizations.of(context) + .setupCategoriesEditingName, + content: SizedBox( + width: 400, + child: + PlatformField(controller: controller), + ), + ), + ); + }, + child: Text( + selectedWallet!.categories[i].name, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ), + ), + itemCount: selectedWallet!.categories.length, ), ), - trailing: IconButton( - icon: const Icon(Icons.cancel), - onPressed: () { - categories.removeAt(i); - setState(() {}); - }, - ), - title: GestureDetector( - onTap: () { - final controller = - TextEditingController(text: categories[i].name); - showDialog( - context: context, - builder: (c) => PlatformDialog( - actions: [ - TextButton( - onPressed: () { - if (controller.text.isEmpty) return; - categories[i].name = controller.text; - Navigator.of(context).pop(); - }, - child: Text(AppLocalizations.of(context).ok), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(AppLocalizations.of(context).cancel), - ), - ], - title: AppLocalizations.of(context) - .setupCategoriesEditingName, - content: SizedBox( - width: 400, - child: PlatformField(controller: controller), + IconButton( + onPressed: () async { + selectedWallet!.categories.add( + WalletCategory( + name: AppLocalizations.of(context) + .setupWalletNamePlaceholder, + id: selectedWallet!.nextCategoryId, + icon: IconData( + Icons.question_mark.codePoint, + fontFamily: 'MaterialIcons', ), ), ); + await WalletManager.saveWallet(selectedWallet!); + setState(() {}); }, - child: Text( - categories[i].name, - style: const TextStyle(fontWeight: FontWeight.bold), - ), + icon: const Icon(Icons.add), ), - ), - itemCount: categories.length, - ), - ), - IconButton( - onPressed: () { - var id = 1; - while ( - categories.where((element) => element.id == id).isNotEmpty) { - id++; // create unique ID - } - categories.add( - WalletCategory( - name: AppLocalizations.of(context).setupWalletNamePlaceholder, - type: EntryType.expense, - id: id, - icon: IconData( - Icons.question_mark.codePoint, - fontFamily: 'MaterialIcons', - ), - ), - ); - setState(() {}); - }, - icon: const Icon(Icons.add), - ), - ], + ], ), ); } diff --git a/lib/views/settings/settings.dart b/lib/views/settings/settings.dart index c1e63e9..0cce304 100644 --- a/lib/views/settings/settings.dart +++ b/lib/views/settings/settings.dart @@ -43,6 +43,7 @@ class _SettingsViewState extends State { ), sections: [ SettingsSection( + title: Text(AppLocalizations.of(context).wallet), tiles: [ SettingsTile.navigation( title: Text(AppLocalizations.of(context).editCategories), diff --git a/lib/views/setup.dart b/lib/views/setup.dart index c288f8f..567fd0a 100644 --- a/lib/views/setup.dart +++ b/lib/views/setup.dart @@ -51,9 +51,16 @@ class _SetupViewState extends State { super.didChangeDependencies(); if (categories.isEmpty) { categories = [ + WalletCategory( + name: AppLocalizations.of(context).noCategory, + id: 0, + icon: IconData( + Icons.payments.codePoint, + fontFamily: 'MaterialIcons', + ), + ), WalletCategory( name: AppLocalizations.of(context).categoryHealth, - type: EntryType.expense, id: 1, icon: IconData( Icons.medical_information.codePoint, @@ -62,21 +69,18 @@ class _SetupViewState extends State { ), WalletCategory( name: AppLocalizations.of(context).categoryCar, - type: EntryType.expense, id: 2, icon: IconData(Icons.car_repair.codePoint, fontFamily: 'MaterialIcons'), ), WalletCategory( name: AppLocalizations.of(context).categoryFood, - type: EntryType.expense, id: 3, icon: IconData(Icons.restaurant.codePoint, fontFamily: 'MaterialIcons'), ), WalletCategory( name: AppLocalizations.of(context).categoryTravel, - type: EntryType.expense, id: 4, icon: IconData(Icons.train.codePoint, fontFamily: 'MaterialIcons'), ), @@ -269,90 +273,98 @@ class _SetupViewState extends State { height: MediaQuery.of(context).size.height * 0.64, child: ListView.builder( shrinkWrap: true, - itemBuilder: (context, i) => ListTile( - leading: GestureDetector( - onTap: () async { - final icon = - await FlutterIconPicker.showIconPicker( - context, - ); - if (icon == null) return; - categories[i].icon = icon; - setState(() {}); - }, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Theme.of(context).colorScheme.secondary, - ), - child: Padding( - padding: const EdgeInsets.all(8), - child: Icon( - categories[i].icon, - color: - Theme.of(context).colorScheme.onSecondary, - ), - ), - ), - ), - trailing: IconButton( - icon: const Icon(Icons.cancel), - onPressed: () { - categories.removeAt(i); - setState(() {}); - }, - ), - title: GestureDetector( - onTap: () { - final controller = TextEditingController( - text: categories[i].name, - ); - showDialog( - context: context, - builder: (c) => PlatformDialog( - actions: [ - TextButton( - onPressed: () { - if (controller.text.isEmpty) return; - categories[i].name = controller.text; - Navigator.of(context).pop(); - }, - child: Text( - AppLocalizations.of(context).ok, + itemBuilder: (context, i) => (i == 0) + ? const SizedBox() + : ListTile( + leading: GestureDetector( + onTap: () async { + final icon = + await FlutterIconPicker.showIconPicker( + context, + ); + if (icon == null) return; + categories[i].icon = icon; + setState(() {}); + }, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Theme.of(context) + .colorScheme + .secondary, + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + categories[i].icon, + color: Theme.of(context) + .colorScheme + .onSecondary, ), ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text( - AppLocalizations.of(context).cancel, - ), - ), - ], - title: AppLocalizations.of(context) - .setupCategoriesEditingName, - content: SizedBox( - width: 400, - child: - PlatformField(controller: controller), ), ), - ); - }, - child: Text( - categories[i].name, - style: - const TextStyle(fontWeight: FontWeight.bold), - ), - ), - ), + trailing: IconButton( + icon: const Icon(Icons.cancel), + onPressed: () { + categories.removeAt(i); + setState(() {}); + }, + ), + title: GestureDetector( + onTap: () { + final controller = TextEditingController( + text: categories[i].name, + ); + showDialog( + context: context, + builder: (c) => PlatformDialog( + actions: [ + TextButton( + onPressed: () { + if (controller.text.isEmpty) + return; + categories[i].name = + controller.text; + Navigator.of(context).pop(); + }, + child: Text( + AppLocalizations.of(context).ok, + ), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text( + AppLocalizations.of(context) + .cancel, + ), + ), + ], + title: AppLocalizations.of(context) + .setupCategoriesEditingName, + content: SizedBox( + width: 400, + child: PlatformField( + controller: controller), + ), + ), + ); + }, + child: Text( + categories[i].name, + style: const TextStyle( + fontWeight: FontWeight.bold), + ), + ), + ), itemCount: categories.length, ), ), IconButton( onPressed: () { - var id = 1; + var id = 0; while (categories .where((element) => element.id == id) .isNotEmpty) { @@ -362,7 +374,6 @@ class _SetupViewState extends State { WalletCategory( name: AppLocalizations.of(context) .setupWalletNamePlaceholder, - type: EntryType.expense, id: id, icon: IconData( Icons.question_mark.codePoint, From 6163a95849d30812659dd5d63e27798ad3501652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Mon, 8 Jan 2024 15:38:31 +0100 Subject: [PATCH 12/17] feat(graphs): add pie chart and category colors (#19) Reviewed-on: https://git.mnau.xyz/hernik/prasule/pulls/19 --- CHANGELOG.md | 2 + lib/api/category.dart | 8 + lib/api/category.g.dart | 2 + lib/l10n/app_cs.arb | 6 +- lib/l10n/app_en.arb | 5 +- lib/pw/platformwidget.dart | 3 +- lib/util/get_last_date.dart | 8 + lib/util/graphs.dart | 231 ++++++++++++++++++++++-- lib/util/text_color.dart | 11 ++ lib/views/graph_view.dart | 166 ++++++++++------- lib/views/home.dart | 5 +- lib/views/settings/edit_categories.dart | 62 ++++++- lib/views/setup.dart | 78 ++++++-- pubspec.lock | 52 ++++-- pubspec.yaml | 1 + 15 files changed, 515 insertions(+), 125 deletions(-) create mode 100644 lib/util/get_last_date.dart create mode 100644 lib/util/text_color.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f52790..4097a41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ - Add settings view for editing wallet categories - Change code according to more aggressive linting - Create a default "no category" category, mainly to store entries with removed categories +- Categories now have changeable colors assigned to them +- Added pie chart for expense/income data per category # 1.0.0-alpha+2 - Fixed localization issues - Added graphs for expenses and income per month/year diff --git a/lib/api/category.dart b/lib/api/category.dart index 30424f7..7088b9a 100644 --- a/lib/api/category.dart +++ b/lib/api/category.dart @@ -11,6 +11,7 @@ class WalletCategory { required this.name, required this.id, required this.icon, + required this.color, }); /// Connects generated fromJson method @@ -27,6 +28,10 @@ class WalletCategory { @JsonKey(fromJson: _iconDataFromJson, toJson: _iconDataToJson) IconData icon; + /// The color that will be displayed with entry + @JsonKey(fromJson: _colorFromJson, toJson: _colorToJson) + Color color; + /// Connects generated toJson method Map toJson() => _$WalletCategoryToJson(this); @@ -42,6 +47,9 @@ Map _iconDataToJson(IconData icon) => IconData _iconDataFromJson(Map data) => IconData(data['codepoint'] as int, fontFamily: data['family'] as String?); +int _colorToJson(Color color) => color.value; +Color _colorFromJson(int input) => Color(input); + /// Type of entry, either expense or income enum EntryType { /// Expense diff --git a/lib/api/category.g.dart b/lib/api/category.g.dart index f70475f..9d013a6 100644 --- a/lib/api/category.g.dart +++ b/lib/api/category.g.dart @@ -11,6 +11,7 @@ WalletCategory _$WalletCategoryFromJson(Map json) => name: json['name'] as String, id: json['id'] as int, icon: _iconDataFromJson(json['icon'] as Map), + color: _colorFromJson(json['color'] as int), ); Map _$WalletCategoryToJson(WalletCategory instance) => @@ -18,4 +19,5 @@ Map _$WalletCategoryToJson(WalletCategory instance) => 'name': instance.name, 'id': instance.id, 'icon': _iconDataToJson(instance.icon), + 'color': _colorToJson(instance.color), }; diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index c3065c1..0c9bc09 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -78,6 +78,8 @@ "editCategories":"Upravit kategorie", "editCategoriesDesc":"Přidat, upravit nebo odebrat kategorii z peněženky", "wallet":"Peněženka", - "noCategory":"Žádná kategorie" - + "noCategory":"Žádná kategorie", + "done":"Hotovo", + "pickColor":"Zvolte barvu", + "changeDate":"Změnit ze kterého měsíce/roku brát data" } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index eb1bf55..b96bd6f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -158,5 +158,8 @@ "editCategories":"Edit categories", "editCategoriesDesc":"Add, edit or remove categories from a wallet", "wallet":"Wallet", - "noCategory":"No category" + "noCategory":"No category", + "done":"Done", + "pickColor":"Pick a color", + "changeDate":"Change what month/year to pick data from" } \ No newline at end of file diff --git a/lib/pw/platformwidget.dart b/lib/pw/platformwidget.dart index 98d9c89..b48c0b0 100644 --- a/lib/pw/platformwidget.dart +++ b/lib/pw/platformwidget.dart @@ -5,7 +5,8 @@ import 'package:flutter/material.dart'; /// Abstract class used to create widgets for the respective platform UI library abstract class PlatformWidget extends StatelessWidget { - /// Abstract class used to create widgets for the respective platform UI library + /// Abstract class used to create widgets + /// for the respective platform UI library const PlatformWidget({super.key}); @override diff --git a/lib/util/get_last_date.dart b/lib/util/get_last_date.dart new file mode 100644 index 0000000..27b52ad --- /dev/null +++ b/lib/util/get_last_date.dart @@ -0,0 +1,8 @@ +/// Extension to get last day of the month +extension LastDay on DateTime { + /// Returns the last day of the month as [int] + int get lastDay { + final d = add(const Duration(days: 31)); + return DateTime(d.year, d.month, 0).day; + } +} diff --git a/lib/util/graphs.dart b/lib/util/graphs.dart index bb090f8..2ef91fe 100644 --- a/lib/util/graphs.dart +++ b/lib/util/graphs.dart @@ -4,6 +4,11 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:intl/intl.dart'; +import 'package:prasule/api/category.dart'; +import 'package:prasule/api/walletentry.dart'; +import 'package:prasule/main.dart'; +import 'package:prasule/util/get_last_date.dart'; +import 'package:prasule/util/text_color.dart'; /// Monthly/Yearly expense/income [LineChart] class ExpensesLineChart extends StatelessWidget { @@ -70,7 +75,9 @@ class ExpensesLineChart extends StatelessWidget { getTooltipItems: (spots) => List.generate( spots.length, (index) => LineTooltipItem( - (spots[index].barIndex == 0) + // Changes what's rendered on the tooltip + // when clicked in the chart + (spots[index].barIndex == 0) // income chart ? (yearly ? AppLocalizations.of(context).incomeForMonth( DateFormat.MMMM(locale).format( @@ -92,7 +99,7 @@ class ExpensesLineChart extends StatelessWidget { name: currency.name, ).format(spots[index].y), )) - : (yearly + : (yearly // expense chart ? AppLocalizations.of(context).expensesForMonth( DateFormat.MMMM(locale).format( DateTime( @@ -114,12 +121,25 @@ class ExpensesLineChart extends StatelessWidget { ).format(spots[index].y), )), TextStyle(color: spots[index].bar.color), + children: [ + TextSpan( + text: "\n${yearly ? DateFormat.MMMM(locale).format( + DateTime( + date.year, + index + 1, + ), + ) : DateFormat.yMMMMd(locale).format(DateTime(date.year, date.month, spots[index].spotIndex + 1))}", + ), + ], ), ), ), ), maxY: maxY, - maxX: yearly ? 12 : DateTime(date.year, date.month, 0).day.toDouble(), + maxX: yearly + ? 12 + : date.lastDay.toDouble() - + 1, // remove 1 because we are indexing from 0 minY: 0, minX: 0, backgroundColor: Theme.of(context).colorScheme.background, @@ -131,10 +151,13 @@ class ExpensesLineChart extends StatelessWidget { isStrokeCapRound: true, dotData: const FlDotData(show: false), belowBarData: BarAreaData(), - color: Colors.green - .harmonizeWith(Theme.of(context).colorScheme.secondary), + color: + (MediaQuery.of(context).platformBrightness == Brightness.dark) + ? Colors.green.shade300 + : Colors.green + .harmonizeWith(Theme.of(context).colorScheme.primary), spots: List.generate( - yearly ? 12 : DateTime(date.year, date.month, 0).day, + yearly ? 12 : date.lastDay, (index) => FlSpot(index.toDouble(), incomeData[index]), ), ), @@ -145,17 +168,37 @@ class ExpensesLineChart extends StatelessWidget { isStrokeCapRound: true, dotData: const FlDotData(show: false), belowBarData: BarAreaData(), - color: Colors.red - .harmonizeWith(Theme.of(context).colorScheme.secondary), + color: + (MediaQuery.of(context).platformBrightness == Brightness.dark) + ? Colors.red.shade300 + : Colors.red + .harmonizeWith(Theme.of(context).colorScheme.primary), spots: List.generate( - yearly ? 12 : DateTime(date.year, date.month, 0).day, - (index) => FlSpot(index.toDouble() + 1, expenseData[index]), + yearly + ? 12 + : date.lastDay, // no -1 because it's the length, not index + (index) => FlSpot(index.toDouble(), expenseData[index]), ), ), ], // actual data titlesData: FlTitlesData( rightTitles: const AxisTitles(), topTitles: const AxisTitles(), + leftTitles: AxisTitles( + sideTitles: SideTitles( + reservedSize: (NumberFormat.compact() + .format(expenseDataSorted.last) + .length >= + 5 || + NumberFormat.compact() + .format(incomeDataSorted.last) + .length >= + 5) + ? 50 + : 25, + showTitles: true, + ), + ), bottomTitles: AxisTitles( sideTitles: SideTitles( reservedSize: 30, @@ -317,7 +360,7 @@ class ExpensesBarChart extends StatelessWidget { minY: 0, maxY: maxY, barGroups: List.generate( - yearly ? 12 : DateTime(date.year, date.month, 0).day, + yearly ? 12 : date.lastDay - 1, (index) => BarChartGroupData( x: index, barRods: [ @@ -325,13 +368,13 @@ class ExpensesBarChart extends StatelessWidget { BarChartRodData( toY: incomeData[index], color: Colors.green - .harmonizeWith(Theme.of(context).colorScheme.secondary), + .harmonizeWith(Theme.of(context).colorScheme.primary), ), if (expenseData.isNotEmpty) BarChartRodData( toY: expenseData[index], color: Colors.red - .harmonizeWith(Theme.of(context).colorScheme.secondary), + .harmonizeWith(Theme.of(context).colorScheme.primary), ), ], ), @@ -339,3 +382,165 @@ class ExpensesBarChart extends StatelessWidget { ), ); } + +/// [PieChart] used to display expenses/income visualized +/// under their respective category +class CategoriesPieChart extends StatefulWidget { + /// [PieChart] used to display expenses/income visualized + /// under their respective category + const CategoriesPieChart({ + required this.entries, + required this.categories, + required this.symbol, + super.key, + }); + + /// Entries to be used + final List entries; + + /// Categories to be displayed + final List categories; + + /// Currency symbol displayed on the chart + final String symbol; + + @override + State createState() => _CategoriesPieChartState(); +} + +class _CategoriesPieChartState extends State { + int touchedIndex = -1; + + @override + Widget build(BuildContext context) => Column( + children: [ + SizedBox( + width: MediaQuery.of(context).size.width, + child: Wrap( + alignment: WrapAlignment.center, + spacing: 4, + children: List.generate( + widget.categories.length, + (index) => Padding( + padding: const EdgeInsets.all(8), + child: Indicator( + size: touchedIndex == index ? 18 : 16, + color: widget.categories[index].color, + text: widget.categories[index].name, + textStyle: TextStyle( + fontWeight: (touchedIndex == index) + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ), + ), + ), + ), + const SizedBox( + height: 5, + ), + Expanded( + child: PieChart( + PieChartData( + centerSpaceRadius: double.infinity, + pieTouchData: PieTouchData( + touchCallback: (event, response) { + // Set touchedIndex so we can highlight + // the corresponding indicator + setState(() { + if (!event.isInterestedForInteractions || + response == null || + response.touchedSection == null) { + touchedIndex = -1; + return; + } + touchedIndex = + response.touchedSection!.touchedSectionIndex; + }); + }, + ), + sections: List.generate( + widget.categories.length, + (index) => PieChartSectionData( + title: NumberFormat.compactCurrency(symbol: widget.symbol) + .format( + widget.entries + .where( + (element) => + element.category.id == + widget.categories[index].id, + ) + .fold( + 0, + (previousValue, element) => + previousValue + element.data.amount, + ), + ), + titleStyle: TextStyle( + color: + widget.categories[index].color.calculateTextColor(), + fontWeight: FontWeight.bold, + ), + color: widget.categories[index].color, + value: widget.entries + .where( + (element) => + element.category.id == + widget.categories[index].id, + ) + .fold( + 0, + (previousValue, element) => + previousValue + element.data.amount, + ), + ), + ), + ), + ), + ), + ], + ); +} + +/// Used to indicate which part of a chart is for what +class Indicator extends StatelessWidget { + /// Used to indicate which part of a chart is for what + const Indicator({ + required this.size, + required this.color, + required this.text, + this.textStyle = const TextStyle(), + super.key, + }); + + /// Size of the indicator circle + final double size; + + /// Color of the indicator circle + final Color color; + + /// Text shown next to the indicator circle + final String text; + + final TextStyle textStyle; + + @override + Widget build(BuildContext context) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + ), + ), + const SizedBox( + width: 4, + ), + Text(text, style: textStyle), + ], + ); +} diff --git a/lib/util/text_color.dart b/lib/util/text_color.dart new file mode 100644 index 0000000..2c9788a --- /dev/null +++ b/lib/util/text_color.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +/// Used to add [calculateTextColor] to the [Color] class +extension TextColor on Color { + /// Returns if foreground should be white or dark on this [Color] + Color calculateTextColor() { + return ThemeData.estimateBrightnessForColor(this) == Brightness.light + ? Colors.black + : Colors.white; + } +} diff --git a/lib/views/graph_view.dart b/lib/views/graph_view.dart index 0b1610c..37d6cb6 100644 --- a/lib/views/graph_view.dart +++ b/lib/views/graph_view.dart @@ -40,8 +40,9 @@ class _GraphViewState extends State { } List generateChartData(EntryType type) { + final d = _selectedDate.add(const Duration(days: 31)); final data = List.filled( - yearly ? 12 : DateTime(_selectedDate.year, _selectedDate.month, 0).day, + yearly ? 12 : DateTime(d.year, d.month, 0).day, 0, ); if (selectedWallet == null) return []; @@ -92,6 +93,47 @@ class _GraphViewState extends State { @override Widget build(BuildContext context) { return Scaffold( + floatingActionButton: Tooltip( + message: AppLocalizations.of(context).changeDate, + child: PlatformButton( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all( + Theme.of(context).colorScheme.primary, + ), + foregroundColor: MaterialStateProperty.all( + Theme.of(context).colorScheme.onPrimary, + ), + ), + text: yearly + ? DateFormat.y(locale).format(_selectedDate) + : DateFormat.yMMMM(locale).format(_selectedDate), + onPressed: () async { + final firstDate = (selectedWallet!.entries + ..sort( + (a, b) => a.date.compareTo(b.date), + )) + .first + .date; + final newDate = await showDatePicker( + context: context, + initialDate: DateTime( + _selectedDate.year, + _selectedDate.month, + ), + firstDate: firstDate, + lastDate: DateTime.now(), + initialEntryMode: yearly + ? DatePickerEntryMode.input + : DatePickerEntryMode.calendar, + initialDatePickerMode: + yearly ? DatePickerMode.year : DatePickerMode.day, + ); + if (newDate == null) return; + _selectedDate = newDate; + setState(() {}); + }, + ), + ), appBar: AppBar( title: DropdownButton( value: @@ -137,11 +179,19 @@ class _GraphViewState extends State { ].map((e) => PopupMenuItem(value: e, child: Text(e))).toList(), onSelected: (value) { if (value == AppLocalizations.of(context).settings) { - Navigator.of(context).push( + Navigator.of(context) + .push( platformRoute( (context) => const SettingsView(), ), - ); + ) + .then((value) async { + selectedWallet = + await WalletManager.loadWallet(selectedWallet!.name); + final s = await SharedPreferences.getInstance(); + chartType = s.getInt("monthlygraph") ?? 2; + setState(() {}); + }); } else if (value == AppLocalizations.of(context).about) { showAboutDialog( context: context, @@ -219,58 +269,10 @@ class _GraphViewState extends State { padding: const EdgeInsets.all(8), child: Column( children: [ - PlatformButton( - style: ButtonStyle( - backgroundColor: MaterialStateProperty.all( - Theme.of(context).colorScheme.primary, - ), - foregroundColor: MaterialStateProperty.all( - Theme.of(context).colorScheme.onPrimary, - ), - ), - text: yearly - ? DateFormat.y(locale).format(_selectedDate) - : DateFormat.yMMMM(locale) - .format(_selectedDate), - onPressed: () async { - final firstDate = (selectedWallet!.entries - ..sort( - (a, b) => a.date.compareTo(b.date), - )) - .first - .date; - final lastDate = (selectedWallet!.entries - ..sort( - (a, b) => b.date.compareTo(a.date), - )) - .first - .date; - final newDate = await showDatePicker( - context: context, - initialDate: DateTime( - _selectedDate.year, - _selectedDate.month, - ), - firstDate: firstDate, - lastDate: lastDate, - initialEntryMode: yearly - ? DatePickerEntryMode.input - : DatePickerEntryMode.calendar, - initialDatePickerMode: yearly - ? DatePickerMode.year - : DatePickerMode.day, - ); - if (newDate == null) return; - _selectedDate = newDate; - setState(() {}); - }, - ), - const SizedBox( - height: 5, - ), SizedBox( width: MediaQuery.of(context).size.width * 0.9, - height: 300, + height: + MediaQuery.of(context).size.height * 0.35, child: (chartType == null) ? const CircularProgressIndicator() : (chartType == 1) @@ -292,29 +294,53 @@ class _GraphViewState extends State { ) : [], ) - : ExpensesLineChart( - currency: selectedWallet!.currency, - date: _selectedDate, - locale: locale ?? "en", - yearly: yearly, - expenseData: (graphTypeSet - .contains("expense")) - ? generateChartData( - EntryType.expense, - ) - : [], - incomeData: (graphTypeSet - .contains("income")) - ? generateChartData( - EntryType.income, - ) - : [], + : Padding( + padding: const EdgeInsets.all(8), + child: ExpensesLineChart( + currency: + selectedWallet!.currency, + date: _selectedDate, + locale: locale ?? "en", + yearly: yearly, + expenseData: (graphTypeSet + .contains("expense")) + ? generateChartData( + EntryType.expense, + ) + : [], + incomeData: (graphTypeSet + .contains("income")) + ? generateChartData( + EntryType.income, + ) + : [], + ), ), ), ], ), ), ), + const SizedBox( + height: 25, + ), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: + Theme.of(context).colorScheme.secondaryContainer, + ), + width: MediaQuery.of(context).size.width * 0.95, + height: MediaQuery.of(context).size.height * 0.35, + child: Padding( + padding: const EdgeInsets.all(8), + child: CategoriesPieChart( + symbol: selectedWallet!.currency.symbol, + entries: selectedWallet!.entries, + categories: selectedWallet!.categories, + ), + ), + ), ], ), ), diff --git a/lib/views/home.dart b/lib/views/home.dart index 430ccb1..a1edb1e 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -24,6 +24,7 @@ import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformdialog.dart'; import 'package:prasule/pw/platformroute.dart'; import 'package:prasule/util/drawer.dart'; +import 'package:prasule/util/text_color.dart'; import 'package:prasule/views/create_entry.dart'; import 'package:prasule/views/settings/settings.dart'; import 'package:prasule/views/settings/tessdata_list.dart'; @@ -358,14 +359,14 @@ class _HomeViewState extends State { leading: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), - color: Theme.of(context).colorScheme.secondary, + color: element.category.color, ), child: Padding( padding: const EdgeInsets.all(8), child: Icon( element.category.icon, color: - Theme.of(context).colorScheme.onSecondary, + element.category.color.calculateTextColor(), ), ), ), diff --git a/lib/views/settings/edit_categories.dart b/lib/views/settings/edit_categories.dart index b97afc0..98fe706 100644 --- a/lib/views/settings/edit_categories.dart +++ b/lib/views/settings/edit_categories.dart @@ -2,6 +2,8 @@ import 'dart:async'; +import 'package:dynamic_color/dynamic_color.dart'; +import 'package:flex_color_picker/flex_color_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_iconpicker/flutter_iconpicker.dart'; @@ -9,11 +11,14 @@ import 'package:prasule/api/category.dart'; import 'package:prasule/api/wallet.dart'; import 'package:prasule/api/walletmanager.dart'; import 'package:prasule/main.dart'; +import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformdialog.dart'; import 'package:prasule/pw/platformfield.dart'; import 'package:prasule/pw/platformroute.dart'; +import 'package:prasule/util/text_color.dart'; import 'package:prasule/views/settings/settings.dart'; import 'package:prasule/views/setup.dart'; +import 'package:shared_preferences/shared_preferences.dart'; /// Allows adding, editing or removing [WalletCategory]s class EditCategoriesView extends StatefulWidget { @@ -134,24 +139,64 @@ class _EditCategoriesViewState extends State { await FlutterIconPicker.showIconPicker( context, ); - if (icon == null) return; - selectedWallet!.categories[i].icon = icon; + if (icon != null) { + selectedWallet!.categories[i].icon = icon; + } + final materialEnabled = + (await SharedPreferences.getInstance()) + .getBool("useMaterialYou") ?? + false; + if (!mounted) return; + await showDialog( + context: context, + builder: (c) => PlatformDialog( + actions: [ + PlatformButton( + text: AppLocalizations.of(context).done, + onPressed: () { + Navigator.of(c).pop(); + }, + ), + ], + title: + AppLocalizations.of(context).pickColor, + content: Column( + children: [ + ColorPicker( + pickersEnabled: { + ColorPickerType.wheel: true, + ColorPickerType.primary: false, + ColorPickerType.custom: false, + ColorPickerType.bw: false, + ColorPickerType.accent: + materialEnabled, + }, + color: selectedWallet! + .categories[i].color, + onColorChanged: (color) { + selectedWallet! + .categories[i].color = color; + setState(() {}); + }, + ), + ], + ), + ), + ); await WalletManager.saveWallet(selectedWallet!); setState(() {}); }, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), - color: - Theme.of(context).colorScheme.secondary, + color: selectedWallet!.categories[i].color, ), child: Padding( padding: const EdgeInsets.all(8), child: Icon( selectedWallet!.categories[i].icon, - color: Theme.of(context) - .colorScheme - .onSecondary, + color: selectedWallet!.categories[i].color + .calculateTextColor(), ), ), ), @@ -230,6 +275,9 @@ class _EditCategoriesViewState extends State { Icons.question_mark.codePoint, fontFamily: 'MaterialIcons', ), + color: Colors.blueGrey.harmonizeWith( + Theme.of(context).colorScheme.primary, + ), ), ); await WalletManager.saveWallet(selectedWallet!); diff --git a/lib/views/setup.dart b/lib/views/setup.dart index 567fd0a..bc0b071 100644 --- a/lib/views/setup.dart +++ b/lib/views/setup.dart @@ -1,6 +1,8 @@ // ignore_for_file: inference_failure_on_function_invocation import 'package:currency_picker/currency_picker.dart'; +import 'package:dynamic_color/dynamic_color.dart'; +import 'package:flex_color_picker/flex_color_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -13,7 +15,9 @@ import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformdialog.dart'; import 'package:prasule/pw/platformfield.dart'; import 'package:prasule/pw/platformroute.dart'; +import 'package:prasule/util/text_color.dart'; import 'package:prasule/views/home.dart'; +import 'package:shared_preferences/shared_preferences.dart'; /// View that shows on first-time setup class SetupView extends StatefulWidget { @@ -58,6 +62,7 @@ class _SetupViewState extends State { Icons.payments.codePoint, fontFamily: 'MaterialIcons', ), + color: Theme.of(context).colorScheme.secondary, ), WalletCategory( name: AppLocalizations.of(context).categoryHealth, @@ -66,23 +71,31 @@ class _SetupViewState extends State { Icons.medical_information.codePoint, fontFamily: 'MaterialIcons', ), + color: Colors.red.shade700 + .harmonizeWith(Theme.of(context).colorScheme.primary), ), WalletCategory( name: AppLocalizations.of(context).categoryCar, id: 2, icon: IconData(Icons.car_repair.codePoint, fontFamily: 'MaterialIcons'), + color: Colors.purple + .harmonizeWith(Theme.of(context).colorScheme.primary), ), WalletCategory( name: AppLocalizations.of(context).categoryFood, id: 3, icon: IconData(Icons.restaurant.codePoint, fontFamily: 'MaterialIcons'), + color: Colors.green.shade700 + .harmonizeWith(Theme.of(context).colorScheme.primary), ), WalletCategory( name: AppLocalizations.of(context).categoryTravel, id: 4, icon: IconData(Icons.train.codePoint, fontFamily: 'MaterialIcons'), + color: Colors.orange.shade700 + .harmonizeWith(Theme.of(context).colorScheme.primary), ), ]; setState(() {}); @@ -282,24 +295,61 @@ class _SetupViewState extends State { await FlutterIconPicker.showIconPicker( context, ); - if (icon == null) return; - categories[i].icon = icon; + if (icon != null) categories[i].icon = icon; + final materialEnabled = + (await SharedPreferences.getInstance()) + .getBool("useMaterialYou") ?? + false; + if (!mounted) return; + await showDialog( + context: context, + builder: (c) => PlatformDialog( + actions: [ + PlatformButton( + text: AppLocalizations.of(context) + .done, + onPressed: () { + Navigator.of(c).pop(); + }, + ), + ], + title: AppLocalizations.of(context) + .pickColor, + content: Column( + children: [ + ColorPicker( + pickersEnabled: { + ColorPickerType.wheel: true, + ColorPickerType.primary: false, + ColorPickerType.custom: false, + ColorPickerType.bw: false, + ColorPickerType.accent: + materialEnabled, + }, + color: categories[i].color, + onColorChanged: (color) { + categories[i].color = color; + setState(() {}); + }, + ), + ], + ), + ), + ); setState(() {}); }, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), - color: Theme.of(context) - .colorScheme - .secondary, + color: categories[i].color, ), child: Padding( padding: const EdgeInsets.all(8), child: Icon( categories[i].icon, - color: Theme.of(context) - .colorScheme - .onSecondary, + color: categories[i] + .color + .calculateTextColor(), ), ), ), @@ -322,8 +372,9 @@ class _SetupViewState extends State { actions: [ TextButton( onPressed: () { - if (controller.text.isEmpty) + if (controller.text.isEmpty) { return; + } categories[i].name = controller.text; Navigator.of(context).pop(); @@ -347,7 +398,8 @@ class _SetupViewState extends State { content: SizedBox( width: 400, child: PlatformField( - controller: controller), + controller: controller, + ), ), ), ); @@ -355,7 +407,8 @@ class _SetupViewState extends State { child: Text( categories[i].name, style: const TextStyle( - fontWeight: FontWeight.bold), + fontWeight: FontWeight.bold, + ), ), ), ), @@ -379,6 +432,9 @@ class _SetupViewState extends State { Icons.question_mark.codePoint, fontFamily: 'MaterialIcons', ), + color: Colors.blueGrey.harmonizeWith( + Theme.of(context).colorScheme.primary, + ), ), ); setState(() {}); diff --git a/pubspec.lock b/pubspec.lock index 77b846c..5e8710d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: archive - sha256: "7b875fd4a20b165a3084bd2d210439b22ebc653f21cea4842729c0c30c82596b" + sha256: "22600aa1e926be775fa5fe7e6894e7fb3df9efda8891c73f70fb3262399a432d" url: "https://pub.dev" source: hosted - version: "3.4.9" + version: "3.4.10" args: dependency: transitive description: @@ -321,6 +321,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.66.0" + flex_color_picker: + dependency: "direct main" + description: + name: flex_color_picker + sha256: f37476ab3e80dcaca94e428e159944d465dd16312fda9ff41e07e86f04bfa51c + url: "https://pub.dev" + source: hosted + version: "3.3.0" + flex_seed_scheme: + dependency: transitive + description: + name: flex_seed_scheme + sha256: "29c12aba221eb8a368a119685371381f8035011d18de5ba277ad11d7dfb8657f" + url: "https://pub.dev" + source: hosted + version: "1.4.0" flutter: dependency: "direct main" description: flutter @@ -531,26 +547,26 @@ packages: dependency: "direct main" description: name: image_picker - sha256: fc712337719239b0b6e41316aa133350b078fa39b6cbd706b61f3fd421b03c77 + sha256: "340efe08645537d6b088a30620ee5752298b1630f23a829181172610b868262b" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.0.6" image_picker_android: dependency: transitive description: name: image_picker_android - sha256: ecdc963d2aa67af5195e723a40580f802d4392e31457a12a562b3e2bd6a396fe + sha256: "1a27bf4cc0330389cebe465bab08fe6dec97e44015b4899637344bb7297759ec" url: "https://pub.dev" source: hosted - version: "0.8.9+1" + version: "0.8.9+2" image_picker_for_web: dependency: transitive description: name: image_picker_for_web - sha256: "50bc9ae6a77eea3a8b11af5eb6c661eeb858fdd2f734c2a4fd17086922347ef7" + sha256: e2423c53a68b579a7c37a1eda967b8ae536c3d98518e5db95ca1fe5719a730a3 url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" image_picker_ios: dependency: transitive description: @@ -579,10 +595,10 @@ packages: dependency: transitive description: name: image_picker_platform_interface - sha256: ed9b00e63977c93b0d2d2b343685bed9c324534ba5abafbb3dfbd6a780b1b514 + sha256: "0e827c156e3a90edd3bbe7f6de048b39247b16e58173b08a835b7eb00aba239e" url: "https://pub.dev" source: hosted - version: "2.9.1" + version: "2.9.2" image_picker_windows: dependency: transitive description: @@ -760,10 +776,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: e595b98692943b4881b219f0a9e3945118d3c16bd7e2813f98ec6e532d905f72 + sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_foundation: dependency: transitive description: @@ -816,10 +832,10 @@ packages: dependency: transitive description: name: plugin_platform_interface - sha256: f4f88d4a900933e7267e2b353594774fc0d07fb072b47eedcd5b54e1ea3269f8 + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" url: "https://pub.dev" source: hosted - version: "2.1.7" + version: "2.1.8" pointycastle: dependency: transitive description: @@ -1165,18 +1181,18 @@ packages: dependency: transitive description: name: win32 - sha256: b0f37db61ba2f2e9b7a78a1caece0052564d1bc70668156cf3a29d676fe4e574 + sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" url: "https://pub.dev" source: hosted - version: "5.1.1" + version: "5.2.0" xdg_directories: dependency: transitive description: name: xdg_directories - sha256: "589ada45ba9e39405c198fe34eb0f607cddb2108527e658136120892beac46d2" + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" xml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e87beb4..38afb3c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: dio: ^5.3.0 dynamic_color: ^1.6.6 fl_chart: ^0.66.0 + flex_color_picker: ^3.3.0 flutter: sdk: flutter flutter_iconpicker: ^3.2.4 From 17a3a1ce200ab0a85bf10d9deb47f0476b8c985a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Mon, 8 Jan 2024 21:19:15 +0100 Subject: [PATCH 13/17] feat: add recurring entries (#20) Reviewed-on: https://git.mnau.xyz/hernik/prasule/pulls/20 --- CHANGELOG.md | 2 + lib/api/recurring_entry.dart | 52 +++ lib/api/recurring_entry.g.dart | 45 +++ lib/api/wallet.dart | 61 +++- lib/api/wallet.g.dart | 6 + .../{walletentry.dart => wallet_entry.dart} | 2 +- ...walletentry.g.dart => wallet_entry.g.dart} | 2 +- ...walletmanager.dart => wallet_manager.dart} | 0 lib/l10n/app_cs.arb | 8 +- lib/l10n/app_en.arb | 44 ++- lib/util/drawer.dart | 17 + lib/util/graphs.dart | 3 +- lib/views/create_entry.dart | 12 +- lib/views/create_recur_entry.dart | 342 ++++++++++++++++++ lib/views/graph_view.dart | 2 +- lib/views/home.dart | 57 ++- lib/views/recurring_view.dart | 288 +++++++++++++++ lib/views/settings/edit_categories.dart | 2 +- lib/views/setup.dart | 2 +- 19 files changed, 921 insertions(+), 26 deletions(-) create mode 100644 lib/api/recurring_entry.dart create mode 100644 lib/api/recurring_entry.g.dart rename lib/api/{walletentry.dart => wallet_entry.dart} (97%) rename lib/api/{walletentry.g.dart => wallet_entry.g.dart} (97%) rename lib/api/{walletmanager.dart => wallet_manager.dart} (100%) create mode 100644 lib/views/create_recur_entry.dart create mode 100644 lib/views/recurring_view.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 4097a41..5d8977c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Create a default "no category" category, mainly to store entries with removed categories - Categories now have changeable colors assigned to them - Added pie chart for expense/income data per category +- Added recurring entries +- Fixed wrong default sorting # 1.0.0-alpha+2 - Fixed localization issues - Added graphs for expenses and income per month/year diff --git a/lib/api/recurring_entry.dart b/lib/api/recurring_entry.dart new file mode 100644 index 0000000..f45aac8 --- /dev/null +++ b/lib/api/recurring_entry.dart @@ -0,0 +1,52 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:prasule/api/category.dart'; +import 'package:prasule/api/entry_data.dart'; +import 'package:prasule/api/wallet_entry.dart'; + +part 'recurring_entry.g.dart'; + +/// This is a [WalletSingleEntry] that is automatically recurring +@JsonSerializable() +class RecurringWalletEntry extends WalletSingleEntry { + /// This is a [WalletSingleEntry] that is automatically recurring + RecurringWalletEntry({ + required super.data, + required super.type, + required super.date, + required super.category, + required super.id, + required this.lastRunDate, + required this.recurType, + this.repeatAfter = 1, + }); + + /// Connects generated fromJson method + factory RecurringWalletEntry.fromJson(Map json) => + _$RecurringWalletEntryFromJson(json); + + /// Connects generated toJson method + @override + Map toJson() => _$RecurringWalletEntryToJson(this); + + /// Last date the recurring entry was added into the single entry list + DateTime lastRunDate; + + /// After how many {recurType} should the entry recur + int repeatAfter; + + /// What type of recurrence should happen + RecurType recurType; +} + +/// How a [RecurringWalletEntry] should recur +@JsonEnum() +enum RecurType { + /// Will recur every {repeatAfter} months + month, + + /// Will recur every {repeatAfter} years + year, + + /// Will recur every {repeatAfter} days + day +} diff --git a/lib/api/recurring_entry.g.dart b/lib/api/recurring_entry.g.dart new file mode 100644 index 0000000..75400d6 --- /dev/null +++ b/lib/api/recurring_entry.g.dart @@ -0,0 +1,45 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recurring_entry.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +RecurringWalletEntry _$RecurringWalletEntryFromJson( + Map json) => + RecurringWalletEntry( + data: EntryData.fromJson(json['data'] as Map), + type: $enumDecode(_$EntryTypeEnumMap, json['type']), + date: DateTime.parse(json['date'] as String), + category: + WalletCategory.fromJson(json['category'] as Map), + id: json['id'] as int, + lastRunDate: DateTime.parse(json['lastRunDate'] as String), + repeatAfter: json['repeatAfter'] as int, + recurType: $enumDecode(_$RecurTypeEnumMap, json['recurType']), + ); + +Map _$RecurringWalletEntryToJson( + RecurringWalletEntry instance) => + { + 'type': _$EntryTypeEnumMap[instance.type]!, + 'data': instance.data, + 'date': instance.date.toIso8601String(), + 'category': instance.category, + 'id': instance.id, + 'lastRunDate': instance.lastRunDate.toIso8601String(), + 'repeatAfter': instance.repeatAfter, + 'recurType': _$RecurTypeEnumMap[instance.recurType]!, + }; + +const _$EntryTypeEnumMap = { + EntryType.expense: 'expense', + EntryType.income: 'income', +}; + +const _$RecurTypeEnumMap = { + RecurType.month: 'month', + RecurType.year: 'year', + RecurType.day: 'day', +}; diff --git a/lib/api/wallet.dart b/lib/api/wallet.dart index e38b844..959add1 100644 --- a/lib/api/wallet.dart +++ b/lib/api/wallet.dart @@ -1,8 +1,11 @@ import 'package:currency_picker/currency_picker.dart'; +import 'package:intl/intl.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:prasule/api/category.dart'; -import 'package:prasule/api/walletentry.dart'; -import 'package:prasule/api/walletmanager.dart'; +import 'package:prasule/api/recurring_entry.dart'; +import 'package:prasule/api/wallet_entry.dart'; +import 'package:prasule/api/wallet_manager.dart'; +import 'package:prasule/main.dart'; part 'wallet.g.dart'; Currency _currencyFromJson(Map data) => @@ -19,12 +22,16 @@ class Wallet { required this.currency, this.categories = const [], this.entries = const [], + this.recurringEntries = const [], this.starterBalance = 0, }); /// Connects generated fromJson method factory Wallet.fromJson(Map json) => _$WalletFromJson(json); + /// A list of all [RecurringWalletEntry]s + final List recurringEntries; + /// Name of the wallet final String name; @@ -65,6 +72,56 @@ class Wallet { return id; } + /// Handles adding recurring entries to the entry list + void recurEntries() { + final n = DateTime.now(); + for (final ent in recurringEntries) { + var m = DateTime( + (ent.recurType == RecurType.year) + ? ent.lastRunDate.year + ent.repeatAfter + : ent.lastRunDate.year, + (ent.recurType == RecurType.month) + ? ent.lastRunDate.month + ent.repeatAfter + : ent.lastRunDate.month, + (ent.recurType == RecurType.day) + ? ent.lastRunDate.day + ent.repeatAfter + : ent.lastRunDate.day, + ); // create the date after which we should recur + + while (n.isAfter( + m, + )) { + logger.i("Adding recurring entry ${ent.data.name}"); + recurringEntries[recurringEntries.indexOf(ent)].lastRunDate = + m; // update date on recurring entry + logger.i(recurringEntries[recurringEntries.indexOf(ent)].lastRunDate); + final addedEntry = (recurringEntries[recurringEntries.indexOf(ent)] + as WalletSingleEntry) + ..date = DateTime.now() + ..id = nextId; // copy entry with today's date and unique ID + entries.add( + addedEntry, + ); // add it to entries + + m = DateTime( + (ent.recurType == RecurType.year) + ? ent.lastRunDate.year + ent.repeatAfter + : ent.lastRunDate.year, + (ent.recurType == RecurType.month) + ? ent.lastRunDate.month + ent.repeatAfter + : ent.lastRunDate.month, + (ent.recurType == RecurType.day) + ? ent.lastRunDate.day + ent.repeatAfter + : ent.lastRunDate.day, + ); // add tne variable again to check if we aren't missing any entries + logger.i( + "Last recurred date is now on ${DateFormat.yMMMMd().format(m)} (${n.isAfter(m)})"); + } + + WalletManager.saveWallet(this); // save wallet + } + } + /// Removes the specified category. /// /// All [WalletSingleEntry]s will have their category reassigned diff --git a/lib/api/wallet.g.dart b/lib/api/wallet.g.dart index d2affe5..561b831 100644 --- a/lib/api/wallet.g.dart +++ b/lib/api/wallet.g.dart @@ -18,10 +18,16 @@ Wallet _$WalletFromJson(Map json) => Wallet( (e) => WalletSingleEntry.fromJson(e as Map)) .toList() ?? const [], + recurringEntries: (json['recurringEntries'] as List?) + ?.map((e) => + RecurringWalletEntry.fromJson(e as Map)) + .toList() ?? + const [], starterBalance: (json['starterBalance'] as num?)?.toDouble() ?? 0, ); Map _$WalletToJson(Wallet instance) => { + 'recurringEntries': instance.recurringEntries, 'name': instance.name, 'categories': instance.categories, 'entries': instance.entries, diff --git a/lib/api/walletentry.dart b/lib/api/wallet_entry.dart similarity index 97% rename from lib/api/walletentry.dart rename to lib/api/wallet_entry.dart index a515fc5..e6f85da 100644 --- a/lib/api/walletentry.dart +++ b/lib/api/wallet_entry.dart @@ -2,7 +2,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/entry_data.dart'; -part 'walletentry.g.dart'; +part 'wallet_entry.g.dart'; @JsonSerializable() diff --git a/lib/api/walletentry.g.dart b/lib/api/wallet_entry.g.dart similarity index 97% rename from lib/api/walletentry.g.dart rename to lib/api/wallet_entry.g.dart index fcdd640..c994ecf 100644 --- a/lib/api/walletentry.g.dart +++ b/lib/api/wallet_entry.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'walletentry.dart'; +part of 'wallet_entry.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/walletmanager.dart b/lib/api/wallet_manager.dart similarity index 100% rename from lib/api/walletmanager.dart rename to lib/api/wallet_manager.dart diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index 0c9bc09..d1794ff 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -81,5 +81,11 @@ "noCategory":"Žádná kategorie", "done":"Hotovo", "pickColor":"Zvolte barvu", - "changeDate":"Změnit ze kterého měsíce/roku brát data" + "changeDate":"Změnit ze kterého měsíce/roku brát data", + "recurringPayments":"Opakující se platby", + "monthCounter": "{count, plural, =1{měsíc} few{měsíce} many{měsíců} other{měsíců} }", + "dayCounter":"{count, plural, =1{den} few{dny} many{dnů} other{dnů} }", + "yearCounter":"{count, plural, =1{rok} few{rok} many{let} other{let} }", + "recurEvery":"{count, plural, =1{Opakovat každý} few{Opakovat každé} many{Opakovat každých} other{Opakovat každých}}", + "startingWithDate": "počínaje datem" } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b96bd6f..c2fe305 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -161,5 +161,47 @@ "noCategory":"No category", "done":"Done", "pickColor":"Pick a color", - "changeDate":"Change what month/year to pick data from" + "changeDate":"Change what month/year to pick data from", + "recurringPayments":"Recurring payments", + "recurEvery":"{count, plural, other{Recur every}}", + "@recurEvery":{ + "description": "Shown when creating recurring entries, ex.: Recur every 2 months", + "placeholders": { + "count":{ + "description": "Specifies how many X are being counted", + "type": "int" + } + } + }, + "monthCounter":"{count, plural, =1{month} other{months} }", + "@monthCounter":{ + "placeholders": { + "count":{ + "description": "Specifies how many months are being counted", + "type": "int" + } + } + }, + "dayCounter":"{count, plural, =1{day} other{days} }", + "@dayCounter":{ + "placeholders": { + "count":{ + "description": "Specifies how many days are being counted", + "type": "int" + } + } + }, + "yearCounter":"{count, plural, =1{year} other{years} }", + "@yearCounter":{ + "placeholders": { + "count":{ + "description": "Specifies how many years are being counted", + "type": "int" + } + } + }, + "startingWithDate": "starting", + "@startingWithDate":{ + "description": "Shown after 'Recur every X Y', e.g. 'Recur every 2 month starting 20th June 2023'" + } } \ No newline at end of file diff --git a/lib/util/drawer.dart b/lib/util/drawer.dart index c49d3ae..76308db 100644 --- a/lib/util/drawer.dart +++ b/lib/util/drawer.dart @@ -3,6 +3,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:prasule/pw/platformroute.dart'; import 'package:prasule/views/graph_view.dart'; import 'package:prasule/views/home.dart'; +import 'package:prasule/views/recurring_view.dart'; /// Makes the drawer because I won't enter the same code in every view Drawer makeDrawer(BuildContext context, int page) => Drawer( @@ -39,6 +40,22 @@ Drawer makeDrawer(BuildContext context, int page) => Drawer( .pushReplacement(platformRoute((p0) => const GraphView())); }, ), + ListTile( + leading: const Icon(Icons.repeat), + title: Text( + AppLocalizations.of(context).recurringPayments, + ), + selected: page == 3, + onTap: () { + if (page == 3) { + Navigator.of(context).pop(); + return; + } + Navigator.of(context).pushReplacement( + platformRoute((p0) => const RecurringEntriesView()), + ); + }, + ), ], ), ); diff --git a/lib/util/graphs.dart b/lib/util/graphs.dart index 2ef91fe..2ccd5e5 100644 --- a/lib/util/graphs.dart +++ b/lib/util/graphs.dart @@ -5,8 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:intl/intl.dart'; import 'package:prasule/api/category.dart'; -import 'package:prasule/api/walletentry.dart'; -import 'package:prasule/main.dart'; +import 'package:prasule/api/wallet_entry.dart'; import 'package:prasule/util/get_last_date.dart'; import 'package:prasule/util/text_color.dart'; diff --git a/lib/views/create_entry.dart b/lib/views/create_entry.dart index 9ed1136..3640241 100644 --- a/lib/views/create_entry.dart +++ b/lib/views/create_entry.dart @@ -4,15 +4,15 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/entry_data.dart'; import 'package:prasule/api/wallet.dart'; -import 'package:prasule/api/walletentry.dart'; -import 'package:prasule/api/walletmanager.dart'; +import 'package:prasule/api/wallet_entry.dart'; +import 'package:prasule/api/wallet_manager.dart'; import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformfield.dart'; /// Used when user wants to add new entry -class CreateEntryView extends StatefulWidget { +class CreateSingleEntryView extends StatefulWidget { /// Used when user wants to add new entry - const CreateEntryView({required this.w, super.key, this.editEntry}); + const CreateSingleEntryView({required this.w, super.key, this.editEntry}); /// The wallet, where the entry will be saved to final Wallet w; @@ -23,10 +23,10 @@ class CreateEntryView extends StatefulWidget { final WalletSingleEntry? editEntry; @override - State createState() => _CreateEntryViewState(); + State createState() => _CreateSingleEntryViewState(); } -class _CreateEntryViewState extends State { +class _CreateSingleEntryViewState extends State { late WalletSingleEntry newEntry; @override void initState() { diff --git a/lib/views/create_recur_entry.dart b/lib/views/create_recur_entry.dart new file mode 100644 index 0000000..6047980 --- /dev/null +++ b/lib/views/create_recur_entry.dart @@ -0,0 +1,342 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:intl/intl.dart'; +import 'package:prasule/api/category.dart'; +import 'package:prasule/api/entry_data.dart'; +import 'package:prasule/api/recurring_entry.dart'; +import 'package:prasule/api/wallet.dart'; +import 'package:prasule/api/wallet_manager.dart'; +import 'package:prasule/main.dart'; +import 'package:prasule/pw/platformbutton.dart'; +import 'package:prasule/pw/platformfield.dart'; + +/// Used when user wants to add new entry +class CreateRecurringEntryView extends StatefulWidget { + /// Used when user wants to add new entry + const CreateRecurringEntryView({ + required this.w, + required this.locale, + super.key, + this.editEntry, + }); + + /// The wallet, where the entry will be saved to + final Wallet w; + + /// Entry we want to edit + /// + /// Is null unless we are editing an existing entry + final RecurringWalletEntry? editEntry; + + /// Selected locale + final String locale; + + @override + State createState() => _CreateRecurringEntryViewState(); +} + +class _CreateRecurringEntryViewState extends State { + late RecurringWalletEntry newEntry; + @override + void initState() { + super.initState(); + if (widget.editEntry != null) { + newEntry = widget.editEntry!; + } else { + newEntry = RecurringWalletEntry( + id: widget.w.nextId, + data: EntryData(amount: 0, name: ""), + type: EntryType.expense, + date: DateTime.now(), + category: widget.w.categories.first, + lastRunDate: DateTime.now(), + recurType: RecurType.month, + ); + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context).createEntry), + ), + body: SizedBox( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + child: Center( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: MediaQuery.of(context).size.width * 0.8, + child: PlatformField( + labelText: AppLocalizations.of(context).name, + controller: TextEditingController(text: newEntry.data.name), + onChanged: (v) { + newEntry.data.name = v; + }, + ), + ), + const SizedBox( + height: 15, + ), + SizedBox( + width: MediaQuery.of(context).size.width * 0.8, + child: PlatformField( + labelText: AppLocalizations.of(context).amount, + controller: TextEditingController( + text: newEntry.data.amount.toString(), + ), + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'\d+[\.,]{0,1}\d{0,}'), + ), + ], + onChanged: (v) { + logger.i(v); + newEntry.data.amount = double.parse(v); + }, + ), + ), + const SizedBox( + height: 20, + ), + Text(AppLocalizations.of(context).type), + const SizedBox( + height: 10, + ), + SizedBox( + width: MediaQuery.of(context).size.width * 0.8, + child: DropdownButton( + value: newEntry.type, + items: [ + DropdownMenuItem( + value: EntryType.expense, + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.8 - 24, + child: Text( + AppLocalizations.of(context).expense, + ), + ), + ), + DropdownMenuItem( + value: EntryType.income, + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.8 - 24, + child: Text(AppLocalizations.of(context).income), + ), + ), + ], + onChanged: (v) { + if (v == null) return; + newEntry.type = v; + setState(() {}); + }, + ), + ), + const SizedBox( + height: 20, + ), + Text(AppLocalizations.of(context).category), + const SizedBox( + height: 10, + ), + SizedBox( + width: MediaQuery.of(context).size.width * 0.8, + child: DropdownButton( + value: newEntry.category.id, + items: List.generate( + widget.w.categories.length, + (index) => DropdownMenuItem( + value: widget.w.categories[index].id, + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.8 - 24, + child: Text( + widget.w.categories[index].name, + ), + ), + ), + ), + onChanged: (v) { + if (v == null) return; + newEntry.category = widget.w.categories + .where((element) => element.id == v) + .first; + setState(() {}); + }, + ), + ), + const SizedBox( + height: 20, + ), + Text(AppLocalizations.of(context).description), + const SizedBox( + height: 10, + ), + ConstrainedBox( + constraints: BoxConstraints( + minWidth: MediaQuery.of(context).size.width * 0.8, + maxWidth: MediaQuery.of(context).size.width * 0.8, + maxHeight: 300, + ), + child: PlatformField( + keyboardType: TextInputType.multiline, + maxLines: null, + controller: TextEditingController( + text: newEntry.data.description, + ), + onChanged: (v) { + newEntry.data.description = v; + }, + ), + ), + const SizedBox( + height: 20, + ), + SizedBox( + width: MediaQuery.of(context).size.width * 0.9, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + AppLocalizations.of(context) + .recurEvery(newEntry.repeatAfter), + ), + const SizedBox( + width: 10, + ), + SizedBox( + width: 50, + child: PlatformField( + controller: TextEditingController( + text: newEntry.repeatAfter.toString(), + ), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + FilteringTextInputFormatter.deny( + RegExp(r"^0$"), + replacementString: "1", + ), + FilteringTextInputFormatter.deny( + r"\d+[\.,]{0,1}\d{0,}", + replacementString: "1", + ), + ], + onChanged: (s) { + final n = int.tryParse(s); + if (n == null) return; + newEntry.repeatAfter = n; + setState(() {}); + }, + ), + ), + ], + ), + ), + SizedBox( + width: 200, + child: DropdownButton( + value: newEntry.recurType, + items: [ + DropdownMenuItem( + value: RecurType.day, + child: SizedBox( + width: 176, + child: Text( + AppLocalizations.of(context) + .dayCounter(newEntry.repeatAfter), + ), + ), + ), + DropdownMenuItem( + value: RecurType.month, + child: SizedBox( + width: 176, + child: Text( + AppLocalizations.of(context) + .monthCounter(newEntry.repeatAfter), + ), + ), + ), + DropdownMenuItem( + value: RecurType.year, + child: SizedBox( + width: 176, + child: Text( + AppLocalizations.of(context) + .yearCounter(newEntry.repeatAfter), + ), + ), + ), + ], + onChanged: (v) { + if (v == null) return; + newEntry.recurType = v; + setState(() {}); + }, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(AppLocalizations.of(context).startingWithDate), + const SizedBox( + width: 10, + ), // TODO: maybe use sizedbox on row with spaceEvenly? + PlatformButton( + text: DateFormat.yMMMMd(widget.locale) + .format(newEntry.lastRunDate), + onPressed: () async { + final d = await showDatePicker( + context: context, + firstDate: DateTime.now(), + lastDate: + DateTime.now().add(const Duration(days: 365)), + ); + if (d == null) return; + newEntry.lastRunDate = d; + setState(() {}); + }, + ), + ], + ), + const SizedBox( + height: 15, + ), + PlatformButton( + text: AppLocalizations.of(context).save, + onPressed: () { + if (newEntry.data.name.isEmpty) { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text(AppLocalizations.of(context).errorEmptyName), + ), + ); + return; + } + if (widget.editEntry != null) { + Navigator.of(context).pop(newEntry); + return; + } + widget.w.recurringEntries.add(newEntry); + WalletManager.saveWallet(widget.w).then( + (value) => Navigator.of(context).pop(widget.w), + ); // TODO loading circle? + }, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/graph_view.dart b/lib/views/graph_view.dart index 37d6cb6..b2fc2d8 100644 --- a/lib/views/graph_view.dart +++ b/lib/views/graph_view.dart @@ -5,7 +5,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:intl/intl.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/wallet.dart'; -import 'package:prasule/api/walletmanager.dart'; +import 'package:prasule/api/wallet_manager.dart'; import 'package:prasule/main.dart'; import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformroute.dart'; diff --git a/lib/views/home.dart b/lib/views/home.dart index a1edb1e..950c459 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -15,9 +15,10 @@ import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/entry_data.dart'; +import 'package:prasule/api/recurring_entry.dart'; import 'package:prasule/api/wallet.dart'; -import 'package:prasule/api/walletentry.dart'; -import 'package:prasule/api/walletmanager.dart'; +import 'package:prasule/api/wallet_entry.dart'; +import 'package:prasule/api/wallet_manager.dart'; import 'package:prasule/main.dart'; import 'package:prasule/network/tessdata.dart'; import 'package:prasule/pw/platformbutton.dart'; @@ -67,6 +68,7 @@ class _HomeViewState extends State { return; } selectedWallet = wallets.first; + selectedWallet!.recurEntries(); setState(() {}); } @@ -86,6 +88,7 @@ class _HomeViewState extends State { // debug option to quickly fill a wallet with data if (selectedWallet == null) return; selectedWallet!.entries.clear(); + selectedWallet!.recurringEntries.clear(); final random = Random(); for (var i = 0; i < 30; i++) { selectedWallet!.entries.add( @@ -109,7 +112,41 @@ class _HomeViewState extends State { ); } - logger.i(selectedWallet!.entries.length); + logger.d( + "Created ${selectedWallet!.entries.length} regular entries", + ); + + for (var i = 0; i < 3; i++) { + final type = random.nextInt(3); + selectedWallet!.recurringEntries.add( + RecurringWalletEntry( + data: EntryData( + name: "Recurring Entry #${i + 1}", + amount: random.nextInt(20000).toDouble(), + ), + type: (random.nextInt(3) > 0) + ? EntryType.expense + : EntryType.income, + date: DateTime( + 2023, + random.nextInt(12) + 1, + random.nextInt(28) + 1, + ), + category: selectedWallet!.categories[ + random.nextInt(selectedWallet!.categories.length)], + id: selectedWallet!.nextId, + lastRunDate: DateTime.now().subtract( + Duration( + days: (type > 0) ? 3 : 3 * 31, + ), + ), + recurType: (type > 0) ? RecurType.day : RecurType.month, + ), + ); + } + + logger.d( + "Created ${selectedWallet!.recurringEntries.length} recurring entries"); // save and reload WalletManager.saveWallet(selectedWallet!).then((value) { @@ -127,7 +164,7 @@ class _HomeViewState extends State { onTap: () async { final sw = await Navigator.of(context).push( MaterialPageRoute( - builder: (c) => CreateEntryView(w: selectedWallet!), + builder: (c) => CreateSingleEntryView(w: selectedWallet!), ), ); if (sw != null) { @@ -265,8 +302,8 @@ class _HomeViewState extends State { if (yearA == null) return 0; final yearB = RegExp(r'\d+').firstMatch(b); if (yearB == null) return 0; - final compareYears = int.parse(yearA.group(0)!) - .compareTo(int.parse(yearB.group(0)!)); + final compareYears = int.parse(yearB.group(0)!) + .compareTo(int.parse(yearA.group(0)!)); if (compareYears != 0) return compareYears; final months = List.generate( 12, @@ -291,7 +328,7 @@ class _HomeViewState extends State { Navigator.of(context) .push( MaterialPageRoute( - builder: (c) => CreateEntryView( + builder: (c) => CreateSingleEntryView( w: selectedWallet!, editEntry: element, ), @@ -372,7 +409,9 @@ class _HomeViewState extends State { ), title: Text(element.data.name), subtitle: Text( - "${element.data.amount} ${selectedWallet!.currency.symbol}", + NumberFormat.currency( + symbol: selectedWallet!.currency.symbol, + ).format(element.data.amount), ), ), ), @@ -472,7 +511,7 @@ class _HomeViewState extends State { final newEntry = await Navigator.of(context).push( platformRoute( - (c) => CreateEntryView( + (c) => CreateSingleEntryView( w: selectedWallet!, editEntry: WalletSingleEntry( data: EntryData( diff --git a/lib/views/recurring_view.dart b/lib/views/recurring_view.dart new file mode 100644 index 0000000..49bc3e3 --- /dev/null +++ b/lib/views/recurring_view.dart @@ -0,0 +1,288 @@ +// ignore_for_file: inference_failure_on_function_invocation + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:intl/intl.dart'; +import 'package:prasule/api/recurring_entry.dart'; +import 'package:prasule/api/wallet.dart'; +import 'package:prasule/api/wallet_manager.dart'; +import 'package:prasule/pw/platformbutton.dart'; +import 'package:prasule/pw/platformdialog.dart'; +import 'package:prasule/pw/platformroute.dart'; +import 'package:prasule/util/drawer.dart'; +import 'package:prasule/util/text_color.dart'; +import 'package:prasule/views/create_recur_entry.dart'; +import 'package:prasule/views/settings/settings.dart'; +import 'package:prasule/views/setup.dart'; + +/// Used to set up recurring entries +class RecurringEntriesView extends StatefulWidget { + /// Used to set up recurring entries + const RecurringEntriesView({super.key}); + + @override + State createState() => _RecurringEntriesViewState(); +} + +class _RecurringEntriesViewState extends State { + Wallet? selectedWallet; + List wallets = []; + + late String locale; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + locale = Localizations.localeOf(context).languageCode; + initializeDateFormatting(Localizations.localeOf(context).languageCode); + } + + @override + void initState() { + super.initState(); + loadWallet(); + } + + Future loadWallet() async { + wallets = await WalletManager.listWallets(); + if (wallets.isEmpty && mounted) { + unawaited( + Navigator.of(context) + .pushReplacement(platformRoute((c) => const SetupView())), + ); + return; + } + selectedWallet = wallets.first; + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + drawer: makeDrawer(context, 3), + appBar: AppBar( + title: DropdownButton( + value: + (selectedWallet == null) ? -1 : wallets.indexOf(selectedWallet!), + items: [ + ...wallets.map( + (e) => DropdownMenuItem( + value: wallets.indexOf( + e, + ), + child: Text(e.name), + ), + ), + DropdownMenuItem( + value: -1, + child: Text(AppLocalizations.of(context).newWallet), + ), + ], + onChanged: (v) async { + if (v == null || v == -1) { + await Navigator.of(context).push( + platformRoute( + (c) => const SetupView( + newWallet: true, + ), + ), + ); + wallets = await WalletManager.listWallets(); + selectedWallet = wallets.last; + setState(() {}); + return; + } + selectedWallet = wallets[v]; + setState(() {}); + }, + ), + actions: [ + PopupMenuButton( + itemBuilder: (context) => [ + AppLocalizations.of(context).settings, + AppLocalizations.of(context).about, + ].map((e) => PopupMenuItem(value: e, child: Text(e))).toList(), + onSelected: (value) { + if (value == AppLocalizations.of(context).settings) { + Navigator.of(context) + .push( + platformRoute( + (context) => const SettingsView(), + ), + ) + .then((value) async { + selectedWallet = + await WalletManager.loadWallet(selectedWallet!.name); + }); + } else if (value == AppLocalizations.of(context).about) { + showAboutDialog( + context: context, + applicationLegalese: AppLocalizations.of(context).license, + applicationName: "Prašule", + ); + } + }, + ), + ], + ), + floatingActionButton: FloatingActionButton( + shape: const CircleBorder(), + child: const Icon(Icons.add), + onPressed: () { + Navigator.of(context).push( + platformRoute( + (p0) => CreateRecurringEntryView( + w: selectedWallet!, + locale: locale, + ), + ), + ); + }, + ), + body: Center( + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.9, + child: (selectedWallet == null) + ? const Column( + children: [ + SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator(), + ), + ], + ) + : (selectedWallet!.recurringEntries.isEmpty) + ? Column( + children: [ + Text( + AppLocalizations.of(context).noEntries, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + Text( + AppLocalizations.of(context).noEntriesSub, + ), + ], + ) + : ListView.builder( + itemBuilder: (c, i) => Slidable( + endActionPane: ActionPane( + motion: const ScrollMotion(), + children: [ + SlidableAction( + onPressed: (c) { + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (c) => CreateRecurringEntryView( + w: selectedWallet!, + locale: locale, + editEntry: + selectedWallet!.recurringEntries[i], + ), + ), + ) + .then( + (editedEntry) { + if (editedEntry == null) return; + selectedWallet!.entries.remove( + selectedWallet!.recurringEntries[i], + ); + selectedWallet!.entries.add(editedEntry); + WalletManager.saveWallet(selectedWallet!); + setState(() {}); + }, + ); + }, + backgroundColor: + Theme.of(context).colorScheme.secondary, + foregroundColor: + Theme.of(context).colorScheme.onSecondary, + icon: Icons.edit, + ), + SlidableAction( + backgroundColor: + Theme.of(context).colorScheme.error, + foregroundColor: + Theme.of(context).colorScheme.onError, + icon: Icons.delete, + onPressed: (c) { + showDialog( + context: context, + builder: (cx) => PlatformDialog( + title: + AppLocalizations.of(context).sureDialog, + content: Text( + AppLocalizations.of(context).deleteSure, + ), + actions: [ + PlatformButton( + text: AppLocalizations.of(context).yes, + onPressed: () { + selectedWallet!.recurringEntries + .remove( + selectedWallet!.recurringEntries[i], + ); + WalletManager.saveWallet( + selectedWallet!, + ); + Navigator.of(cx).pop(); + setState(() {}); + }, + ), + PlatformButton( + text: AppLocalizations.of(context).no, + onPressed: () { + Navigator.of(cx).pop(); + }, + ), + ], + ), + ); + }, + ), + ], + ), + child: ListTile( + leading: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: selectedWallet! + .recurringEntries[i].category.color, + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + selectedWallet! + .recurringEntries[i].category.icon, + color: selectedWallet! + .recurringEntries[i].category.color + .calculateTextColor(), + ), + ), + ), + title: Text( + selectedWallet!.recurringEntries[i].data.name, + ), + subtitle: Text( + NumberFormat.currency( + symbol: selectedWallet!.currency.symbol, + ).format( + selectedWallet!.recurringEntries[i].data.amount, + ), + ), + ), + ), + itemCount: selectedWallet!.recurringEntries.length, + ), + ), + ), + ); + } +} diff --git a/lib/views/settings/edit_categories.dart b/lib/views/settings/edit_categories.dart index 98fe706..832234d 100644 --- a/lib/views/settings/edit_categories.dart +++ b/lib/views/settings/edit_categories.dart @@ -9,7 +9,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_iconpicker/flutter_iconpicker.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/wallet.dart'; -import 'package:prasule/api/walletmanager.dart'; +import 'package:prasule/api/wallet_manager.dart'; import 'package:prasule/main.dart'; import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformdialog.dart'; diff --git a/lib/views/setup.dart b/lib/views/setup.dart index bc0b071..e273149 100644 --- a/lib/views/setup.dart +++ b/lib/views/setup.dart @@ -10,7 +10,7 @@ import 'package:flutter_iconpicker/flutter_iconpicker.dart'; import 'package:introduction_screen/introduction_screen.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/wallet.dart'; -import 'package:prasule/api/walletmanager.dart'; +import 'package:prasule/api/wallet_manager.dart'; import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformdialog.dart'; import 'package:prasule/pw/platformfield.dart'; From b72e811339af4076b5884bcf8b257e069db2a201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Mon, 8 Jan 2024 21:53:51 +0100 Subject: [PATCH 14/17] fix: differentiate expense/income with color --- CHANGELOG.md | 1 + lib/views/home.dart | 43 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d8977c..757b0a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Added pie chart for expense/income data per category - Added recurring entries - Fixed wrong default sorting +- Differentiate expense and income on home view # 1.0.0-alpha+2 - Fixed localization issues - Added graphs for expenses and income per month/year diff --git a/lib/views/home.dart b/lib/views/home.dart index 950c459..563821a 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'dart:math'; +import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -146,7 +147,8 @@ class _HomeViewState extends State { } logger.d( - "Created ${selectedWallet!.recurringEntries.length} recurring entries"); + "Created ${selectedWallet!.recurringEntries.length} recurring entries", + ); // save and reload WalletManager.saveWallet(selectedWallet!).then((value) { @@ -408,10 +410,41 @@ class _HomeViewState extends State { ), ), title: Text(element.data.name), - subtitle: Text( - NumberFormat.currency( - symbol: selectedWallet!.currency.symbol, - ).format(element.data.amount), + subtitle: RichText( + text: TextSpan( + children: [ + TextSpan( + text: NumberFormat.currency( + symbol: selectedWallet!.currency.symbol, + ).format(element.data.amount), + style: TextStyle( + color: (element.type == EntryType.income) + ? (MediaQuery.of(context) + .platformBrightness == + Brightness.dark) + ? Colors.green.shade300 + : Colors.green.harmonizeWith( + Theme.of(context) + .colorScheme + .primary, + ) + : (MediaQuery.of(context) + .platformBrightness == + Brightness.dark) + ? Colors.red.shade300 + : Colors.red.harmonizeWith( + Theme.of(context) + .colorScheme + .primary, + ), + ), + ), + TextSpan( + text: + " | ${DateFormat.MMMd(locale).format(element.date)}", + ), + ], + ), ), ), ), From e40f0491fc464ba8eb785428941a447bccfec4e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Mon, 8 Jan 2024 23:22:35 +0100 Subject: [PATCH 15/17] fix: actually make recurring entries work correctly --- lib/api/wallet.dart | 59 +++++++++++++++++++++++++++++++++------------ lib/views/home.dart | 1 + 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/lib/api/wallet.dart b/lib/api/wallet.dart index 959add1..16ac990 100644 --- a/lib/api/wallet.dart +++ b/lib/api/wallet.dart @@ -1,6 +1,7 @@ import 'package:currency_picker/currency_picker.dart'; import 'package:intl/intl.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'package:logger/logger.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/recurring_entry.dart'; import 'package:prasule/api/wallet_entry.dart'; @@ -56,9 +57,11 @@ class Wallet { /// Getter for the next unused unique number ID in the wallet's **entry** list int get nextId { var id = 1; + logger.d("Making ID after ${entries.length} entries"); while (entries.where((element) => element.id == id).isNotEmpty) { id++; // create unique ID } + logger.d("New ID: $id\nLast entry ID: ${entries.lastOrNull?.id}"); return id; } @@ -91,33 +94,57 @@ class Wallet { while (n.isAfter( m, )) { - logger.i("Adding recurring entry ${ent.data.name}"); + logger + ..i("Adding recurring entry ${ent.data.name}") + ..d("Current entry count: ${entries.length}"); recurringEntries[recurringEntries.indexOf(ent)].lastRunDate = m; // update date on recurring entry logger.i(recurringEntries[recurringEntries.indexOf(ent)].lastRunDate); - final addedEntry = (recurringEntries[recurringEntries.indexOf(ent)] - as WalletSingleEntry) - ..date = DateTime.now() - ..id = nextId; // copy entry with today's date and unique ID - entries.add( - addedEntry, - ); // add it to entries + + var id = 1; + logger.d("Making ID after ${entries.length} entries"); + while (entries.where((element) => element.id == id).isNotEmpty) { + id++; // create unique ID + } + logger.d("New ID: $id\nLast entry ID: ${entries.lastOrNull?.id}"); + + final addedEntry = WalletSingleEntry( + data: recurringEntries[recurringEntries.indexOf(ent)].data, + type: recurringEntries[recurringEntries.indexOf(ent)].type, + date: m, + category: recurringEntries[recurringEntries.indexOf(ent)].category, + id: id, + ); + + entries.add(addedEntry); m = DateTime( (ent.recurType == RecurType.year) - ? ent.lastRunDate.year + ent.repeatAfter - : ent.lastRunDate.year, + ? recurringEntries[recurringEntries.indexOf(ent)] + .lastRunDate + .year + + ent.repeatAfter + : recurringEntries[recurringEntries.indexOf(ent)] + .lastRunDate + .year, (ent.recurType == RecurType.month) - ? ent.lastRunDate.month + ent.repeatAfter - : ent.lastRunDate.month, + ? recurringEntries[recurringEntries.indexOf(ent)] + .lastRunDate + .month + + ent.repeatAfter + : recurringEntries[recurringEntries.indexOf(ent)] + .lastRunDate + .month, (ent.recurType == RecurType.day) - ? ent.lastRunDate.day + ent.repeatAfter - : ent.lastRunDate.day, - ); // add tne variable again to check if we aren't missing any entries + ? recurringEntries[recurringEntries.indexOf(ent)] + .lastRunDate + .day + + ent.repeatAfter + : recurringEntries[recurringEntries.indexOf(ent)].lastRunDate.day, + ); // add the variable again to check if we aren't missing any entries logger.i( "Last recurred date is now on ${DateFormat.yMMMMd().format(m)} (${n.isAfter(m)})"); } - WalletManager.saveWallet(this); // save wallet } } diff --git a/lib/views/home.dart b/lib/views/home.dart index 563821a..f0c24b3 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -374,6 +374,7 @@ class _HomeViewState extends State { selectedWallet?.entries.removeWhere( (e) => e.id == element.id, ); + WalletManager.saveWallet( selectedWallet!, ); From efc003a7190876cb2761642b655e69094c154ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Mon, 8 Jan 2024 23:24:17 +0100 Subject: [PATCH 16/17] chore: cleanup code --- lib/api/wallet.dart | 13 +------------ lib/views/home.dart | 1 - 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/lib/api/wallet.dart b/lib/api/wallet.dart index 16ac990..077cea5 100644 --- a/lib/api/wallet.dart +++ b/lib/api/wallet.dart @@ -1,7 +1,6 @@ import 'package:currency_picker/currency_picker.dart'; import 'package:intl/intl.dart'; import 'package:json_annotation/json_annotation.dart'; -import 'package:logger/logger.dart'; import 'package:prasule/api/category.dart'; import 'package:prasule/api/recurring_entry.dart'; import 'package:prasule/api/wallet_entry.dart'; @@ -57,11 +56,9 @@ class Wallet { /// Getter for the next unused unique number ID in the wallet's **entry** list int get nextId { var id = 1; - logger.d("Making ID after ${entries.length} entries"); while (entries.where((element) => element.id == id).isNotEmpty) { id++; // create unique ID } - logger.d("New ID: $id\nLast entry ID: ${entries.lastOrNull?.id}"); return id; } @@ -99,21 +96,13 @@ class Wallet { ..d("Current entry count: ${entries.length}"); recurringEntries[recurringEntries.indexOf(ent)].lastRunDate = m; // update date on recurring entry - logger.i(recurringEntries[recurringEntries.indexOf(ent)].lastRunDate); - - var id = 1; - logger.d("Making ID after ${entries.length} entries"); - while (entries.where((element) => element.id == id).isNotEmpty) { - id++; // create unique ID - } - logger.d("New ID: $id\nLast entry ID: ${entries.lastOrNull?.id}"); final addedEntry = WalletSingleEntry( data: recurringEntries[recurringEntries.indexOf(ent)].data, type: recurringEntries[recurringEntries.indexOf(ent)].type, date: m, category: recurringEntries[recurringEntries.indexOf(ent)].category, - id: id, + id: nextId, ); entries.add(addedEntry); diff --git a/lib/views/home.dart b/lib/views/home.dart index f0c24b3..f18722a 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -221,7 +221,6 @@ class _HomeViewState extends State { ), ); wallets = await WalletManager.listWallets(); - logger.i(wallets.length); selectedWallet = wallets.last; setState(() {}); return; From 0dbcedd0ca7b58451f9ae4ff13997808bd05240b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Tue, 9 Jan 2024 00:31:03 +0100 Subject: [PATCH 17/17] fix: iOS specific fixes (#21) Reviewed-on: https://git.mnau.xyz/hernik/prasule/pulls/21 --- lib/main.dart | 5 ++++ lib/pw/platformfield.dart | 2 +- lib/util/show_message.dart | 15 +++++++++++ lib/views/create_entry.dart | 10 +++----- lib/views/create_recur_entry.dart | 10 +++----- lib/views/home.dart | 42 +++++++++++++++++++++---------- lib/views/setup.dart | 23 ++++++++--------- pubspec.lock | 8 ++++++ pubspec.yaml | 1 + 9 files changed, 75 insertions(+), 41 deletions(-) create mode 100644 lib/util/show_message.dart diff --git a/lib/main.dart b/lib/main.dart index 19f6961..9a86716 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -75,6 +75,11 @@ class MyApp extends StatelessWidget { : lightColorScheme, ), child: const CupertinoApp( + localizationsDelegates: [ + AppLocalizations.delegate, + ...GlobalMaterialLocalizations.delegates, + ...GlobalCupertinoLocalizations.delegates, + ], title: 'Prašule', home: HomeView(), ), diff --git a/lib/pw/platformfield.dart b/lib/pw/platformfield.dart index 331b8f8..710e534 100644 --- a/lib/pw/platformfield.dart +++ b/lib/pw/platformfield.dart @@ -61,7 +61,7 @@ class PlatformField extends PlatformWidget { controller: controller, enabled: enabled ?? true, obscureText: obscureText, - prefix: (labelText == null) ? null : Text(labelText!), + placeholder: labelText, autocorrect: autocorrect, keyboardType: keyboardType, inputFormatters: inputFormatters, diff --git a/lib/util/show_message.dart b/lib/util/show_message.dart new file mode 100644 index 0000000..b705449 --- /dev/null +++ b/lib/util/show_message.dart @@ -0,0 +1,15 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; + +/// Shows either SnackBar on Android or Toast on iOS +Future showMessage(String message, BuildContext context) async { + if (Platform.isIOS) { + await Fluttertoast.showToast(msg: message, toastLength: Toast.LENGTH_LONG); + } else { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); + } +} diff --git a/lib/views/create_entry.dart b/lib/views/create_entry.dart index 3640241..1b27c8c 100644 --- a/lib/views/create_entry.dart +++ b/lib/views/create_entry.dart @@ -8,6 +8,7 @@ import 'package:prasule/api/wallet_entry.dart'; import 'package:prasule/api/wallet_manager.dart'; import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformfield.dart'; +import 'package:prasule/util/show_message.dart'; /// Used when user wants to add new entry class CreateSingleEntryView extends StatefulWidget { @@ -190,13 +191,8 @@ class _CreateSingleEntryViewState extends State { text: AppLocalizations.of(context).save, onPressed: () { if (newEntry.data.name.isEmpty) { - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: - Text(AppLocalizations.of(context).errorEmptyName), - ), - ); + showMessage( + AppLocalizations.of(context).errorEmptyName, context); return; } if (widget.editEntry != null) { diff --git a/lib/views/create_recur_entry.dart b/lib/views/create_recur_entry.dart index 6047980..1ad51a6 100644 --- a/lib/views/create_recur_entry.dart +++ b/lib/views/create_recur_entry.dart @@ -10,6 +10,7 @@ import 'package:prasule/api/wallet_manager.dart'; import 'package:prasule/main.dart'; import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformfield.dart'; +import 'package:prasule/util/show_message.dart'; /// Used when user wants to add new entry class CreateRecurringEntryView extends StatefulWidget { @@ -313,13 +314,8 @@ class _CreateRecurringEntryViewState extends State { text: AppLocalizations.of(context).save, onPressed: () { if (newEntry.data.name.isEmpty) { - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: - Text(AppLocalizations.of(context).errorEmptyName), - ), - ); + showMessage( + AppLocalizations.of(context).errorEmptyName, context); return; } if (widget.editEntry != null) { diff --git a/lib/views/home.dart b/lib/views/home.dart index f18722a..6b31dac 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -442,6 +442,12 @@ class _HomeViewState extends State { TextSpan( text: " | ${DateFormat.MMMd(locale).format(element.date)}", + style: TextStyle( + color: Theme.of(context) + .colorScheme + .background + .calculateTextColor(), + ), ), ], ), @@ -458,19 +464,29 @@ class _HomeViewState extends State { final availableLanguages = await TessdataApi.getDownloadedData(); if (availableLanguages.isEmpty) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context).missingOcr), - action: SnackBarAction( - label: AppLocalizations.of(context).download, - onPressed: () { - Navigator.of(context).push( - platformRoute( - (c) => const TessdataListView(), - ), - ); - }, - ), + await showDialog( + context: context, + builder: (c) => PlatformDialog( + title: AppLocalizations.of(context).missingOcr, + actions: [ + PlatformButton( + text: AppLocalizations.of(context).download, + onPressed: () { + Navigator.of(context).push( + platformRoute( + (c) => const TessdataListView(), + ), + ); + Navigator.of(c).pop(); + }, + ), + PlatformButton( + text: AppLocalizations.of(context).ok, + onPressed: () { + Navigator.of(c).pop(); + }, + ), + ], ), ); return; diff --git a/lib/views/setup.dart b/lib/views/setup.dart index e273149..e8749e3 100644 --- a/lib/views/setup.dart +++ b/lib/views/setup.dart @@ -15,6 +15,7 @@ import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformdialog.dart'; import 'package:prasule/pw/platformfield.dart'; import 'package:prasule/pw/platformroute.dart'; +import 'package:prasule/util/show_message.dart'; import 'package:prasule/util/text_color.dart'; import 'package:prasule/views/home.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -119,12 +120,9 @@ class _SetupViewState extends State { done: Text(AppLocalizations.of(context).finish), onDone: () { if (name.isEmpty) { - ScaffoldMessenger.of(context) - .clearSnackBars(); // TODO: iOS replacement - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context).errorEmptyName), - ), + showMessage( + AppLocalizations.of(context).errorEmptyName, + context, ); return; } @@ -136,12 +134,9 @@ class _SetupViewState extends State { WalletManager.saveWallet(wallet).then( (value) { if (!value) { - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: - Text(AppLocalizations.of(context).walletExists), - ), + showMessage( + AppLocalizations.of(context).walletExists, + context, ); return; } @@ -254,7 +249,9 @@ class _SetupViewState extends State { ), ], onChanged: (t) { - balance = double.parse(t); + final b = double.tryParse(t); + if (b == null) return; + balance = b; }, ), ), diff --git a/pubspec.lock b/pubspec.lock index 5e8710d..30c6e96 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -466,6 +466,14 @@ packages: description: flutter source: sdk version: "0.0.0" + fluttertoast: + dependency: "direct main" + description: + name: fluttertoast + sha256: dfdde255317af381bfc1c486ed968d5a43a2ded9c931e87cbecd88767d6a71c1 + url: "https://pub.dev" + source: hosted + version: "8.2.4" font_awesome_flutter: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 38afb3c..f79b64d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: flutter_slidable: ^3.0.0 flutter_speed_dial: ^7.0.0 flutter_tesseract_ocr: ^0.4.23 + fluttertoast: ^8.2.4 grouped_list: ^5.1.2 image_picker: ^1.0.1 intl: any