diff --git a/CHANGELOG.md b/CHANGELOG.md index 352ee50..757b0a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ +# 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 +- Categories now have changeable colors assigned to them +- 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 -# 1.0.0-alpha+1 +# 1.0.0-alpha - First public release \ No newline at end of file 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..7088b9a 100644 --- a/lib/api/category.dart +++ b/lib/api/category.dart @@ -6,24 +6,33 @@ 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; + /// Represents a category in a user's wallet + WalletCategory({ + required this.name, + required this.id, + required this.icon, + required this.color, + }); - 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); - /// Connect the generated [_$PersonToJson] function to the `toJson` method. + /// 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; + + /// The color that will be displayed with entry + @JsonKey(fromJson: _colorFromJson, toJson: _colorToJson) + Color color; + + /// Connects generated toJson method Map toJson() => _$WalletCategoryToJson(this); @override @@ -34,7 +43,18 @@ 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?); + +int _colorToJson(Color color) => color.value; +Color _colorFromJson(int input) => Color(input); + +/// Type of entry, either expense or income +enum EntryType { + /// Expense + expense, + + /// Income + income +} diff --git a/lib/api/category.g.dart b/lib/api/category.g.dart index 00f6a92..9d013a6 100644 --- a/lib/api/category.g.dart +++ b/lib/api/category.g.dart @@ -9,20 +9,15 @@ 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), + color: _colorFromJson(json['color'] as int), ); Map _$WalletCategoryToJson(WalletCategory instance) => { - 'type': _$EntryTypeEnumMap[instance.type]!, 'name': instance.name, 'id': instance.id, 'icon': _iconDataToJson(instance.icon), + 'color': _colorToJson(instance.color), }; - -const _$EntryTypeEnumMap = { - EntryType.expense: 'expense', - EntryType.income: 'income', -}; diff --git a/lib/api/entry_data.dart b/lib/api/entry_data.dart index fedcec4..23281f2 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 method 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 method Map toJson() => _$EntryDataToJson(this); } 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 05e2d0a..077cea5 100644 --- a/lib/api/wallet.dart +++ b/lib/api/wallet.dart @@ -1,36 +1,59 @@ 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/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) => Currency.from(json: data); +/// Represents a single wallet +/// +/// A wallet stores [WalletSingleEntry]s categorized under [WalletCategory]s @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.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; + + /// 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; - Wallet( - {required this.name, - required this.currency, - this.categories = const [], - this.entries = const [], - this.starterBalance = 0}); - - /// Connect the generated [_$WalletEntry] function to the `fromJson` - /// factory. - factory Wallet.fromJson(Map json) => _$WalletFromJson(json); - - /// Connect the generated [_$PersonToJson] function to the `toJson` method. + /// 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) { @@ -39,13 +62,107 @@ 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; + } + + /// 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}") + ..d("Current entry count: ${entries.length}"); + recurringEntries[recurringEntries.indexOf(ent)].lastRunDate = + m; // update date on recurring entry + + final addedEntry = WalletSingleEntry( + data: recurringEntries[recurringEntries.indexOf(ent)].data, + type: recurringEntries[recurringEntries.indexOf(ent)].type, + date: m, + category: recurringEntries[recurringEntries.indexOf(ent)].category, + id: nextId, + ); + + entries.add(addedEntry); + + m = DateTime( + (ent.recurType == RecurType.year) + ? recurringEntries[recurringEntries.indexOf(ent)] + .lastRunDate + .year + + ent.repeatAfter + : recurringEntries[recurringEntries.indexOf(ent)] + .lastRunDate + .year, + (ent.recurType == RecurType.month) + ? recurringEntries[recurringEntries.indexOf(ent)] + .lastRunDate + .month + + ent.repeatAfter + : recurringEntries[recurringEntries.indexOf(ent)] + .lastRunDate + .month, + (ent.recurType == RecurType.day) + ? 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 + } + } + + /// 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", 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..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 54% rename from lib/api/walletentry.dart rename to lib/api/wallet_entry.dart index 48c6e0f..e6f85da 100644 --- a/lib/api/walletentry.dart +++ b/lib/api/wallet_entry.dart @@ -1,30 +1,41 @@ -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'; + +part 'wallet_entry.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, + }); - WalletSingleEntry( - {required this.data, - required this.type, - required this.date, - required this.category, - required this.id}); - - /// Connect the generated [_$WalletEntry] function to the `fromJson` - /// factory. + /// Connects generated fromJson method 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 method Map toJson() => _$WalletSingleEntryToJson(this); } 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 62% rename from lib/api/walletmanager.dart rename to lib/api/wallet_manager.dart index c8f2823..e2c66ab 100644 --- a/lib/api/walletmanager.dart +++ b/lib/api/wallet_manager.dart @@ -3,42 +3,52 @@ 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 { + /// 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) { + logger.e(e); // TODO: do something with unreadable wallets } } + logger.i(wallets.length); 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 +57,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..d1794ff 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -74,6 +74,18 @@ "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", + "wallet":"Peněženka", + "noCategory":"Žádná kategorie", + "done":"Hotovo", + "pickColor":"Zvolte barvu", + "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 875aeaa..c2fe305 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -154,5 +154,54 @@ "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", + "wallet":"Wallet", + "noCategory":"No category", + "done":"Done", + "pickColor":"Pick a color", + "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/main.dart b/lib/main.dart index a093132..9a86716 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,33 +47,39 @@ 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( + localizationsDelegates: [ + AppLocalizations.delegate, + ...GlobalMaterialLocalizations.delegates, + ...GlobalCupertinoLocalizations.delegates, + ], 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..710e534 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, @@ -56,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/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..b48c0b0 100644 --- a/lib/pw/platformwidget.dart +++ b/lib/pw/platformwidget.dart @@ -5,6 +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 const PlatformWidget({super.key}); @override @@ -16,7 +18,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/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/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 5a21330..e1d20f4 100644 --- a/lib/util/graphs.dart +++ b/lib/util/graphs.dart @@ -2,28 +2,67 @@ 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'; +import 'package:prasule/api/category.dart'; +import 'package:prasule/api/wallet_entry.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 { - 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; + + /// 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; + if (expenseDataSorted.last > incomeDataSorted.last) { + return expenseDataSorted.last; + } else { + return incomeDataSorted.last; + } } final List incomeData; @@ -52,46 +91,71 @@ 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(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 + : (yearly // expense chart ? 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), + 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, @@ -102,11 +166,14 @@ class ExpensesLineChart extends StatelessWidget { barWidth: 8, isStrokeCapRound: true, dotData: const FlDotData(show: false), - belowBarData: BarAreaData(show: false), - color: Colors.green - .harmonizeWith(Theme.of(context).colorScheme.secondary), + belowBarData: BarAreaData(), + 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]), ), ), @@ -116,21 +183,37 @@ class ExpensesLineChart extends StatelessWidget { barWidth: 8, isStrokeCapRound: true, dotData: const FlDotData(show: false), - belowBarData: BarAreaData(show: false), - color: Colors.red - .harmonizeWith(Theme.of(context).colorScheme.secondary), + belowBarData: BarAreaData(), + 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( - sideTitles: SideTitles(showTitles: false), - ), - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), + 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( @@ -140,13 +223,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 +241,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 +304,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 +333,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 +360,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 +376,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: [ @@ -281,13 +384,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), ), ], ), @@ -295,3 +398,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/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/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/create_entry.dart b/lib/views/create_entry.dart index 57f2541..1b27c8c 100644 --- a/lib/views/create_entry.dart +++ b/lib/views/create_entry.dart @@ -1,24 +1,33 @@ 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/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'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:prasule/util/show_message.dart'; -class CreateEntryView extends StatefulWidget { +/// Used when user wants to add new entry +class CreateSingleEntryView extends StatefulWidget { + /// Used when user wants to add new entry + const CreateSingleEntryView({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(); + State createState() => _CreateSingleEntryViewState(); } -class _CreateEntryViewState extends State { +class _CreateSingleEntryViewState extends State { late WalletSingleEntry newEntry; @override void initState() { @@ -27,11 +36,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 +78,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 +169,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, @@ -178,13 +191,8 @@ class _CreateEntryViewState 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) { @@ -196,7 +204,7 @@ class _CreateEntryViewState extends State { (value) => Navigator.of(context).pop(widget.w), ); // TODO loading circle? }, - ) + ), ], ), ), diff --git a/lib/views/create_recur_entry.dart b/lib/views/create_recur_entry.dart new file mode 100644 index 0000000..1ad51a6 --- /dev/null +++ b/lib/views/create_recur_entry.dart @@ -0,0 +1,338 @@ +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'; +import 'package:prasule/util/show_message.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) { + showMessage( + AppLocalizations.of(context).errorEmptyName, context); + 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 188e231..b2fc2d8 100644 --- a/lib/views/graph_view.dart +++ b/lib/views/graph_view.dart @@ -1,19 +1,23 @@ +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'; -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'; 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,25 @@ class _GraphViewState extends State { } List generateChartData(EntryType type) { - var data = List.filled( - (yearly) - ? 12 - : DateTime(_selectedDate.year, _selectedDate.month, 0).day, - 0.0); + final d = _selectedDate.add(const Duration(days: 31)); + final data = List.filled( + yearly ? 12 : DateTime(d.year, d.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 +66,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; @@ -85,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: @@ -101,7 +150,7 @@ class _GraphViewState extends State { DropdownMenuItem( value: -1, child: Text(AppLocalizations.of(context).newWallet), - ) + ), ], onChanged: (v) async { if (v == null || v == -1) { @@ -126,23 +175,32 @@ 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(), + 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, - applicationLegalese: AppLocalizations.of(context).license, - applicationName: "Prašule"); + context: context, + applicationLegalese: AppLocalizations.of(context).license, + applicationName: "Prašule", + ); } }, - ) + ), ], ), drawer: makeDrawer(context, 2), @@ -193,8 +251,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,62 +261,18 @@ 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) - ? 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, + height: + MediaQuery.of(context).size.height * 0.35, child: (chartType == null) ? const CircularProgressIndicator() : (chartType == 1) @@ -270,35 +284,63 @@ class _GraphViewState extends State { expenseData: (graphTypeSet .contains("expense")) ? generateChartData( - EntryType.expense) + EntryType.expense, + ) : [], incomeData: (graphTypeSet .contains("income")) ? generateChartData( - EntryType.income) + 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) - : [], + : 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 23b8dc7..6b31dac 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -1,6 +1,12 @@ +// ignore_for_file: inference_failure_on_function_invocation + +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'; 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,22 +16,25 @@ 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/recurring_entry.dart'; import 'package:prasule/api/wallet.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'; 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'; 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,14 +59,17 @@ 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; + selectedWallet!.recurEntries(); setState(() {}); } @@ -77,7 +89,8 @@ class _HomeViewState extends State { // debug option to quickly fill a wallet with data if (selectedWallet == null) return; selectedWallet!.entries.clear(); - var random = Random(); + selectedWallet!.recurringEntries.clear(); + final random = Random(); for (var i = 0; i < 30; i++) { selectedWallet!.entries.add( WalletSingleEntry( @@ -100,7 +113,42 @@ 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) { @@ -113,26 +161,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) => CreateSingleEntryView(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 +209,7 @@ class _HomeViewState extends State { DropdownMenuItem( value: -1, child: Text(AppLocalizations.of(context).newWallet), - ) + ), ], onChanged: (v) async { if (v == null || v == -1) { @@ -173,7 +221,6 @@ class _HomeViewState extends State { ), ); wallets = await WalletManager.listWallets(); - logger.i(wallets.length); selectedWallet = wallets.last; setState(() {}); return; @@ -186,23 +233,29 @@ 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(), + 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"); + context: context, + applicationLegalese: AppLocalizations.of(context).license, + applicationName: "Prašule", + ); } }, - ) + ), ], ), body: Center( @@ -216,7 +269,7 @@ class _HomeViewState extends State { width: 40, height: 40, child: CircularProgressIndicator(), - ) + ), ], ) : (selectedWallet!.entries.isEmpty) @@ -231,126 +284,174 @@ 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)!) - .compareTo(int.parse(yearB.group(0)!)); + final compareYears = int.parse(yearB.group(0)!) + .compareTo(int.parse(yearA.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) => CreateSingleEntryView( + 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: element.category.color, + ), child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Icon( element.category.icon, color: - Theme.of(context).colorScheme.onSecondary, + element.category.color.calculateTextColor(), ), ), ), title: Text(element.data.name), - subtitle: Text( - "${element.data.amount} ${selectedWallet!.currency.symbol}"), + 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)}", + style: TextStyle( + color: Theme.of(context) + .colorScheme + .background + .calculateTextColor(), + ), + ), + ], + ), + ), ), ), ), @@ -360,91 +461,113 @@ 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( - SnackBar( - content: Text(AppLocalizations.of(context).missingOcr), - action: SnackBarAction( - label: AppLocalizations.of(context).download, - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (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; } 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( + (c) => CreateSingleEntryView( 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 +575,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 +613,10 @@ class _HomeViewState extends State { const SizedBox( width: 10, ), - Text(availableLanguages[index].split(".").first) + Text(availableLanguages[index].split(".").first), ], ), - ) + ), ], ), ), @@ -507,12 +625,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/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 new file mode 100644 index 0000000..832234d --- /dev/null +++ b/lib/views/settings/edit_categories.dart @@ -0,0 +1,292 @@ +// ignore_for_file: inference_failure_on_function_invocation + +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'; +import 'package:prasule/api/category.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/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 { + /// Allows adding, editing or removing [WalletCategory]s + const EditCategoriesView({super.key}); + + @override + State createState() => _EditCategoriesViewState(); +} + +class _EditCategoriesViewState extends State { + Wallet? selectedWallet; + List wallets = []; + + @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; + logger.i(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: 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) { + 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: selectedWallet!.categories[i].color, + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + selectedWallet!.categories[i].icon, + color: selectedWallet!.categories[i].color + .calculateTextColor(), + ), + ), + ), + ), + 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, + ), + ), + IconButton( + onPressed: () async { + selectedWallet!.categories.add( + WalletCategory( + name: AppLocalizations.of(context) + .setupWalletNamePlaceholder, + id: selectedWallet!.nextCategoryId, + icon: IconData( + Icons.question_mark.codePoint, + fontFamily: 'MaterialIcons', + ), + color: Colors.blueGrey.harmonizeWith( + Theme.of(context).colorScheme.primary, + ), + ), + ); + await WalletManager.saveWallet(selectedWallet!); + 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..0cce304 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,26 @@ 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( + title: Text(AppLocalizations.of(context).wallet), + 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 +65,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 +91,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..e8749e3 100644 --- a/lib/views/setup.dart +++ b/lib/views/setup.dart @@ -1,18 +1,28 @@ +// 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'; 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'; +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:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:shared_preferences/shared_preferences.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,54 +32,71 @@ 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() { super.didChangeDependencies(); if (categories.isEmpty) { categories = [ + WalletCategory( + name: AppLocalizations.of(context).noCategory, + id: 0, + icon: IconData( + Icons.payments.codePoint, + fontFamily: 'MaterialIcons', + ), + color: Theme.of(context).colorScheme.secondary, + ), WalletCategory( 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', + ), + color: Colors.red.shade700 + .harmonizeWith(Theme.of(context).colorScheme.primary), ), WalletCategory( name: AppLocalizations.of(context).categoryCar, - type: EntryType.expense, 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, - type: EntryType.expense, 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, - type: EntryType.expense, id: 4, icon: IconData(Icons.train.codePoint, fontFamily: 'MaterialIcons'), + color: Colors.orange.shade700 + .harmonizeWith(Theme.of(context).colorScheme.primary), ), ]; setState(() {}); @@ -87,34 +114,29 @@ 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), 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; } - 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) { - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: - Text(AppLocalizations.of(context).walletExists), - ), + showMessage( + AppLocalizations.of(context).walletExists, + context, ); return; } @@ -123,8 +145,8 @@ class _SetupViewState extends State { return; } Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (c) => const HomeView(), + platformRoute( + (c) => const HomeView(), ), ); }, @@ -139,7 +161,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 +174,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 +183,8 @@ class _SetupViewState extends State { ), Flexible( child: Text( - AppLocalizations.of(context).welcomeInstruction), + AppLocalizations.of(context).welcomeInstruction, + ), ), ], ), @@ -172,7 +198,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,14 +241,17 @@ 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); + final b = double.tryParse(t); + if (b == null) return; + balance = b; }, ), ), @@ -236,7 +267,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( @@ -250,85 +283,138 @@ 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 { - var 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.0), - 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: () { - var 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) 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: categories[i].color, ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text( - AppLocalizations.of(context).cancel), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + categories[i].icon, + color: categories[i] + .color + .calculateTextColor(), + ), ), - ], - 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) { @@ -338,16 +424,20 @@ class _SetupViewState extends State { WalletCategory( name: AppLocalizations.of(context) .setupWalletNamePlaceholder, - type: EntryType.expense, id: id, - icon: IconData(Icons.question_mark.codePoint, - fontFamily: 'MaterialIcons'), + icon: IconData( + Icons.question_mark.codePoint, + fontFamily: 'MaterialIcons', + ), + color: Colors.blueGrey.harmonizeWith( + Theme.of(context).colorScheme.primary, + ), ), ); setState(() {}); }, icon: const Icon(Icons.add), - ) + ), ], ), ), diff --git a/pubspec.lock b/pubspec.lock index d8bb9d3..30c6e96 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: @@ -317,10 +317,26 @@ 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" + 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 @@ -450,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: @@ -531,26 +555,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 +603,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 +784,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 +840,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: @@ -1105,6 +1129,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: @@ -1157,18 +1189,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 3173f3d..f79b64d 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 +version: 1.0.0-alpha+3 environment: sdk: '>=3.1.0-262.2.beta <4.0.0' @@ -13,37 +13,35 @@ 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 + flex_color_picker: ^3.3.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 + fluttertoast: ^8.2.4 + 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 +49,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 +65,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 +76,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 +86,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