diff --git a/README.md b/README.md index 52e4724..6ea5f46 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ - - + + diff --git a/lib/core/currency.dart b/lib/core/currency.dart index 61c5670..b02f5e9 100644 --- a/lib/core/currency.dart +++ b/lib/core/currency.dart @@ -6,20 +6,25 @@ class ExchangeService { 'https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml'; Future> getExchangeRateMap(String baseCurrency) async { - final response = await http.get(Uri.parse(url)); - if (response.statusCode != 200) { - throw Exception('Failed to load currency data'); - } + try { + final response = + await http.get(Uri.parse(url)).timeout(const Duration(seconds: 5)); + if (response.statusCode != 200) { + throw InternetUnavailableError('Failed to load currency data'); + } - final document = XmlDocument.parse(response.body); - final rates = _parseRates(document); + final document = XmlDocument.parse(response.body); + final rates = _parseRates(document); - if (!rates.containsKey(baseCurrency)) { - throw Exception('Base currency not found'); - } + if (!rates.containsKey(baseCurrency)) { + throw Exception('Base currency not found'); + } - final baseRate = rates[baseCurrency]!; - return rates.map((currency, rate) => MapEntry(currency, rate / baseRate)); + final baseRate = rates[baseCurrency]!; + return rates.map((currency, rate) => MapEntry(currency, rate / baseRate)); + } catch (e) { + throw InternetUnavailableError('Failed to load currency data'); + } } Map _parseRates(XmlDocument document) { @@ -41,3 +46,12 @@ class ExchangeService { return rates; } } + +class InternetUnavailableError implements Exception { + final String message; + + InternetUnavailableError(this.message); + + @override + String toString() => 'InternetUnavailableError: $message'; +} diff --git a/lib/db/accounts.dart b/lib/db/accounts.dart index 22ce40a..7128cc4 100644 --- a/lib/db/accounts.dart +++ b/lib/db/accounts.dart @@ -165,35 +165,39 @@ class AccountService { // Execute the query List> result = await dbClient.rawQuery(sql); - int totalBalance = 0; + // result is like: + // [{total_balance: 9880057, currency: EUR}] + int totalBalance = 0; String prefCurrency = await _settingService.getSetting(Setting.prefCurrency); + // Determine if conversion is needed and initialize currencyBoxService once if true + bool needsConversion = result.any((row) => row['currency'] != prefCurrency); + if (needsConversion) { + await currencyBoxService.init(); + } + // Loop through each currency and convert the balance to preferred currency - await currencyBoxService.init(); for (var row in result) { String currency = row['currency']; int balance = row['total_balance']; - // Convert balance to preferred currency - double convertedBalance = - await _convertCurrency(currency, balance, prefCurrency); - totalBalance += convertedBalance.round(); + // If the currency is not the preferred currency, convert it + if (currency != prefCurrency) { + double rate = await currencyBoxService.getSingleRate( + currency, + prefCurrency, + ); + totalBalance += (balance * rate).round(); + } else { + // Already in preferred currency, no conversion needed + totalBalance += balance; + } } return totalBalance / 100; } - - Future _convertCurrency( - String fromCurrency, - int balance, - String toCurrency, - ) async { - double rate = - await currencyBoxService.getSingleRate(fromCurrency, toCurrency); - return balance * rate; - } } enum AccountType { diff --git a/lib/db/currency.dart b/lib/db/currency.dart index def70b4..9a49ca3 100644 --- a/lib/db/currency.dart +++ b/lib/db/currency.dart @@ -1,59 +1,64 @@ import 'package:finease/core/export.dart'; +import 'package:finease/db/settings.dart'; import 'package:hive/hive.dart'; class CurrencyBoxService { static const String _boxName = 'currencies'; late Box _box; - final ExchangeService _currencyService = ExchangeService(); + late String prefCurrency; + final ExchangeService _exchangeService = ExchangeService(); Future init() async { _box = await Hive.openBox(_boxName); - } - - Future> getLatestRates(String baseCurrency) async { final currentDate = DateTime.now(); final lastUpdate = _box.get('lastUpdate') as DateTime?; + prefCurrency = await SettingService().getSetting(Setting.prefCurrency); - if (lastUpdate == null || lastUpdate.day != currentDate.day) { - await _updateRates(baseCurrency); + // Check if data is older than a day or if no data is available (lastUpdate is null) + if (lastUpdate == null || + lastUpdate.difference(currentDate).inDays.abs() >= 1) { + try { + await _updateRates(prefCurrency); + } on InternetUnavailableError { + if (lastUpdate == null) { + // If no data available and there is an InternetUnavailableError, then rethrow it + rethrow; + } + // else, ignore the error because we have data (even if it is outdated) + } catch (e) { + // Handle all other exceptions or rethrow as needed + rethrow; + } } - - return Map.from(_box.toMap()) - ..remove('lastUpdate'); // Exclude the 'lastUpdate' key } Future getSingleRate( String baseCurrency, String targetCurrency, ) async { - final currentDate = DateTime.now(); - final lastUpdate = _box.get('lastUpdate') as DateTime?; - - if (lastUpdate == null || lastUpdate.day != currentDate.day) { - await _updateRates(baseCurrency); + double baseRate = 1.0; + double targetRate = 1.0; + // Assuming data is always available and up-to-date. + // Retrieve the rates directly from the box. + if (baseCurrency != prefCurrency) { + baseRate = _box.get(baseCurrency) ?? 0; } - - // Retrieve the rate for the targetCurrency and the baseCurrency - final targetRate = _box.get(targetCurrency) as double?; - final baseRate = _box.get(baseCurrency) as double?; - - // Check if either rate is null - if (targetRate == null) { - throw Exception('Rate for $targetCurrency not found'); - } - if (baseRate == null) { - throw Exception('Rate for $baseCurrency not found'); + if (targetCurrency != prefCurrency) { + targetRate = _box.get(targetCurrency) ?? 0; } - // Calculate the combined rate - final combinedRate = targetRate / baseRate; + // rates must be available; if not, throw an exception. + if (targetRate == 0 || baseRate == 0) { + throw Exception('Unable to find rate for $baseCurrency/$targetCurrency'); + } - return combinedRate; + // Calculate and return the combined rate. + return targetRate / baseRate; } Future _updateRates(String baseCurrency) async { try { - final rates = await _currencyService.getExchangeRateMap(baseCurrency); + final rates = await _exchangeService.getExchangeRateMap(baseCurrency); await _box.putAll(rates); await _box.put('lastUpdate', DateTime.now()); } catch (e) { diff --git a/lib/db/months.dart b/lib/db/months.dart index 664cd9d..813c2c7 100644 --- a/lib/db/months.dart +++ b/lib/db/months.dart @@ -47,7 +47,8 @@ class MonthService { AND e.date BETWEEN months.startDate AND months.endDate AND ac.currency = '$prefCurrency' AND ad.currency = '$prefCurrency' - ), 0) AS expense + ), 0) AS expense, + '$prefCurrency' AS currency FROM ( SELECT REPLACE(DATETIME(monthDate, 'start of month'), ' ', 'T') || 'Z' as startDate, @@ -61,7 +62,8 @@ class MonthService { income, expense, (income - expense) as effect, - SUM(income - expense) OVER (ORDER BY startDate ASC) as networth + SUM(income - expense) OVER (ORDER BY startDate ASC) as networth, + currency FROM MonthlyTotals ) SELECT @@ -69,7 +71,8 @@ class MonthService { effect, expense, income, - networth + networth, + currency FROM CumulativeTotals; '''); @@ -87,6 +90,7 @@ class Month { num? expense; num? income; num? networth; + String? currency; Month({ this.date, @@ -94,6 +98,7 @@ class Month { this.expense, this.income, this.networth, + this.currency, }); factory Month.fromJson(Map json) { @@ -103,6 +108,25 @@ class Month { expense: json['expense'] / 100, income: json['income'] / 100, networth: json['networth'] / 100, + currency: json['currency'], ); } + + // Calculate the factor based on the relationship between income and expense + double get factor { + if (income == null || expense == null) { + return 0; // or some default value or handle error + } + // Ensure that neither income nor expense is zero to avoid division by zero + if (income == 0 && expense == 0) { + return 0.5; // When both are zero, we can define factor as 0.5 + } + + // Bias towards extremes for visibility + num incomeSquared = income! * income!; + num expenseSquared = expense! * expense!; + return (incomeSquared / (incomeSquared + expenseSquared)); + } + + bool get good => factor > 0.5; } diff --git a/lib/pages/add_account/main.dart b/lib/pages/add_account/main.dart index bba5654..b5b6bcc 100644 --- a/lib/pages/add_account/main.dart +++ b/lib/pages/add_account/main.dart @@ -1,6 +1,7 @@ import 'package:finease/core/export.dart'; import 'package:finease/db/accounts.dart'; import 'package:finease/pages/export.dart'; +import 'package:finease/parts/error_dialog.dart'; import 'package:finease/parts/export.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -79,8 +80,14 @@ class AddAccountScreenState extends State { liquid: accountLiquid, type: accountType, ); - account = await _accountService.createAccount(account); - widget.onFormSubmitted(account); + try { + account = await _accountService.createAccount(account); + widget.onFormSubmitted(account); + } catch (e) { + _showError(e); + } } } + + Future _showError(e) async => showErrorDialog(e.toString(), context); } diff --git a/lib/pages/add_entry/main.dart b/lib/pages/add_entry/main.dart index 4ec1198..cce288b 100644 --- a/lib/pages/add_entry/main.dart +++ b/lib/pages/add_entry/main.dart @@ -1,6 +1,7 @@ import 'package:finease/db/accounts.dart'; import 'package:finease/db/entries.dart'; import 'package:finease/pages/export.dart'; +import 'package:finease/parts/error_dialog.dart'; import 'package:finease/parts/export.dart'; import 'package:finease/routes/routes_name.dart'; import 'package:flutter/material.dart'; @@ -104,12 +105,18 @@ class AddEntryScreenState extends State { notes: entryNotes, date: _dateTime, ); - if (_debitAccount!.currency != _creditAccount!.currency) { - await _entryService.createForexEntry(entry); - } else { - await _entryService.createEntry(entry); + try { + if (_debitAccount!.currency != _creditAccount!.currency) { + await _entryService.createForexEntry(entry); + } else { + await _entryService.createEntry(entry); + } + widget.onFormSubmitted(); + } catch (e) { + _showError(e); } - widget.onFormSubmitted(); } } + + Future _showError(e) async => showErrorDialog(e.toString(), context); } diff --git a/lib/pages/export.dart b/lib/pages/export.dart index 14fc2f1..2364073 100644 --- a/lib/pages/export.dart +++ b/lib/pages/export.dart @@ -15,8 +15,8 @@ export "home/frame/destinations.dart"; export "home/frame/main.dart"; export "home/frame/mobile.dart"; export "home/frame/tablet.dart"; -export 'home/months/screen/main.dart'; -export 'home/months/screen/month_card.dart'; +export 'home/months/main.dart'; +export 'home/months/month_card.dart'; export 'home/summary/main.dart'; export 'home/summary/widgets.dart'; export "intro/intro_big.dart"; diff --git a/lib/pages/home/frame/mobile.dart b/lib/pages/home/frame/mobile.dart index 81ea9b5..bcb4eb0 100644 --- a/lib/pages/home/frame/mobile.dart +++ b/lib/pages/home/frame/mobile.dart @@ -1,4 +1,5 @@ import 'package:finease/pages/export.dart'; +import 'package:finease/parts/error_dialog.dart'; import 'package:finease/parts/export.dart'; import 'package:finease/routes/routes_name.dart'; import 'package:flutter/material.dart'; @@ -32,22 +33,28 @@ class SummaryPageState extends State { } Future _fetchNetWorth() async { - String prefCurrency = - await _settingService.getSetting(Setting.prefCurrency); - double asset = - await _accountService.getTotalBalance(type: AccountType.asset); - double liabilities = - await _accountService.getTotalBalance(type: AccountType.liability); - double liquid = await _accountService.getTotalBalance(liquid: true); - setState(() { - currency = prefCurrency; - networthAmount = asset + liabilities; - liabilitiesAmount = liabilities; - assetAmount = asset; - liquidAmount = liquid; - }); + try { + String prefCurrency = + await _settingService.getSetting(Setting.prefCurrency); + double asset = + await _accountService.getTotalBalance(type: AccountType.asset); + double liabilities = + await _accountService.getTotalBalance(type: AccountType.liability); + double liquid = await _accountService.getTotalBalance(liquid: true); + setState(() { + currency = prefCurrency; + networthAmount = asset + liabilities; + liabilitiesAmount = liabilities; + assetAmount = asset; + liquidAmount = liquid; + }); + } catch (e) { + _showError(e); + } } + Future _showError(e) async => showErrorDialog(e.toString(), context); + void _updateBody(int index) { setState(() { destIndex = index; @@ -74,12 +81,18 @@ class SummaryPageState extends State { destinations: destinations, onDestinationSelected: _updateBody, ), - body: SummaryBody( - networthAmount: networthAmount, - assetAmount: assetAmount, - liabilitiesAmount: liabilitiesAmount, - liquidAmount: liquidAmount, - currency: currency, + body: RefreshIndicator( + onRefresh: _fetchNetWorth, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: SummaryBody( + networthAmount: networthAmount, + assetAmount: assetAmount, + liabilitiesAmount: liabilitiesAmount, + liquidAmount: liquidAmount, + currency: currency, + ), + ), ), floatingActionButton: VariableFABSize( onPressed: () => diff --git a/lib/pages/home/months/screen/main.dart b/lib/pages/home/months/main.dart similarity index 100% rename from lib/pages/home/months/screen/main.dart rename to lib/pages/home/months/main.dart diff --git a/lib/pages/home/months/screen/month_card.dart b/lib/pages/home/months/month_card.dart similarity index 77% rename from lib/pages/home/months/screen/month_card.dart rename to lib/pages/home/months/month_card.dart index 48bf2a4..b57d2c9 100644 --- a/lib/pages/home/months/screen/month_card.dart +++ b/lib/pages/home/months/month_card.dart @@ -1,3 +1,4 @@ +import 'package:finease/db/currency.dart'; import 'package:finease/db/months.dart'; import 'package:finease/parts/card.dart'; import 'package:finease/routes/routes_name.dart'; @@ -40,6 +41,11 @@ class MonthCard extends StatelessWidget { DateTime startDate = month.date!; DateTime endDate = DateTime(month.date!.year, month.date!.month + 1, 1) .subtract(const Duration(seconds: 1)); + String currency = SupportedCurrency[month.currency!]!; + String networth = '$currency${month.networth!.toStringAsFixed(2)}'; + String effect = '$currency${month.effect!.toStringAsFixed(2)}'; + String income = '$currency${month.income!.toStringAsFixed(2)}'; + String expense = '$currency${month.expense!.toStringAsFixed(2)}'; return InkWell( onTap: () { @@ -68,20 +74,29 @@ class MonthCard extends StatelessWidget { ) ], ), - const Divider(), + const SizedBox(height: 4), + LinearProgressIndicator( + value: month.factor, + minHeight: 2.0, + backgroundColor: Colors.grey[300], + valueColor: AlwaysStoppedAnimation( + month.good ? Colors.green : Colors.red, + ), + ), + const SizedBox(height: 8), Row( children: [ Expanded( child: MonthWidget( title: "Net Worth", - content: month.networth!.toStringAsFixed(2), + content: networth, ), ), const SizedBox(width: 8), Expanded( child: MonthWidget( title: "Effect", - content: month.effect!.toStringAsFixed(2), + content: effect, ), ), ], @@ -92,14 +107,14 @@ class MonthCard extends StatelessWidget { Expanded( child: MonthWidget( title: "Income", - content: month.income!.toStringAsFixed(2), + content: income, ), ), const SizedBox(width: 8), Expanded( child: MonthWidget( title: "Expense", - content: month.expense!.toStringAsFixed(2), + content: expense, ), ), ], diff --git a/lib/pages/settings/export_db.dart b/lib/pages/settings/export_db.dart index 68c514e..74353cb 100644 --- a/lib/pages/settings/export_db.dart +++ b/lib/pages/settings/export_db.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:finease/core/export.dart'; import 'package:finease/db/db.dart'; import 'package:finease/db/settings.dart'; +import 'package:finease/parts/error_dialog.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; @@ -45,7 +46,7 @@ class ExportDatabaseWidgetState extends State { await Share.shareXFiles([xFile], text: 'Here is my database file.'); } catch (e) { if (mounted) { - _showErrorDialog('Error sharing database: $e'); + await showErrorDialog('Error sharing database: $e', context); } } finally { if (newPath.isNotEmpty) { @@ -57,24 +58,6 @@ class ExportDatabaseWidgetState extends State { } } - void _showErrorDialog(String message) { - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('Error'), - content: Text(message), - actions: [ - TextButton( - child: const Text('OK'), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ); - }, - ); - } - @override Widget build(BuildContext context) { return ListTile( diff --git a/lib/pages/settings/import_db.dart b/lib/pages/settings/import_db.dart index 94ee4de..3dd6d3d 100644 --- a/lib/pages/settings/import_db.dart +++ b/lib/pages/settings/import_db.dart @@ -1,6 +1,7 @@ import 'package:finease/core/export.dart'; import 'package:finease/db/db.dart'; import 'package:finease/db/settings.dart'; +import 'package:finease/parts/error_dialog.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; @@ -100,42 +101,16 @@ class ImportDatabaseWidgetState extends State { } } - Future _showEncryptionAlert() async { - await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text("Enable Encryption"), - content: const Text( - "Please Enable Encryption in the Settings before importing an encrypted database."), - actions: [ - TextButton( - child: const Text("OK"), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ); - }, - ); - } + Future _showEncryptionAlert() async => showErrorDialog( + 'Please Enable Encryption in the Settings' + ' before importing an encrypted database.', + context, + ); - Future _showPaddingErrorAlert() async { - await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text("Password error"), - content: const Text("The database file could not be recovered!"), - actions: [ - TextButton( - child: const Text("OK"), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ); - }, - ); - } + Future _showPaddingErrorAlert() async => showErrorDialog( + 'The database file could not be recovered!', + context, + ); @override Widget build(BuildContext context) { diff --git a/lib/parts/error_dialog.dart b/lib/parts/error_dialog.dart new file mode 100644 index 0000000..6994a17 --- /dev/null +++ b/lib/parts/error_dialog.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +Future showErrorDialog(String message, BuildContext context) async { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Error'), + content: Text(message), + actions: [ + TextButton( + child: const Text('OK'), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ); + }, + ); +} diff --git a/pubspec.yaml b/pubspec.yaml index b9bde9c..9a90128 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: finease description: "A full stack mobile app to keep track of financial transactions" publish_to: 'none' -version: 1.0.19 +version: 1.0.20 environment: sdk: '>=3.2.3 <4.0.0'