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] 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