diff --git a/README.md b/README.md index 817e0b0..0515c86 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ - - + + @@ -26,37 +26,12 @@ - Double Entry Accounting - Easy export to Google Drive or WhatsApp/Telegram +- [Encryption with AES/CBC/PKCS7](https://github.com/Donnie/Finease/wiki/Encryption) +- [Foreign currency transactions with automated retranslation.](https://github.com/Donnie/Finease/wiki/Foreign-Currency-Retranslation-%E2%80%90-Gains-and-losses-in-foreign-currency) - Totally offline*! 100% privacy commitment. Cultivate discipline, enjoy ease of use, and control your financial data. *\* needs internet only if you have multi currency accounts, to look up exchange rates from ECB.* -#### Encryption Feature - -Finease now includes an advanced encryption feature to enhance data security. This feature ensures that all your financial data within the app is now encrypted during exporting it to Google Drive or other places, providing an additional layer of protection against unauthorized access. - -**Technical Details**: - -- Encryption Algorithm: The feature leverages the AES (Advanced Encryption Standard) algorithm, renowned for its balance between strong security and efficient performance. AES operates in CBC mode (Cipher Block Chaining), which ensures that each block of data is encrypted differently. -- Key Management: Encryption keys are generated using a secure, random process and are managed using a combination of hashing and salting techniques. This approach ensures that keys are unique and cannot be easily predicted or replicated. -- Data Integrity: Alongside encryption, the application implements HMAC (Hash-Based Message Authentication Code) to verify the integrity and authenticity of the data. This ensures that any tampering with the encrypted data can be detected. - -**How It Works:** - -- You have to turn on `Enable Encryption` under `Database` section from the Settings. -- The encryption feature automatically encrypts your database file during exports and uses the same password to decrypt it during importing. - -**Tips:** - -- Remember, encryption is only as strong as your password. Use a strong, unique password for your account. -- If you want to decrypt the DB file on your computer read the [directions provided here](decrypt.dart). - -**Warnings:** - -- Due to the nature of latest available encryption methods, your data cannot be encrypted while being in use by the App. Therefore, if your phone is compromised, your data and password shall be available to third party. -- If you provide the unencrypted database file to anyone they would get to know your DB password. -- If you forget your password, it might be impossible to recover your encrypted data. Always keep a backup of your password in a secure place. -- Avoid sharing your password or storing it in an insecure location. - > Made with ♥ in Berlin diff --git a/lib/db/accounts.dart b/lib/db/accounts.dart index f4f8938..5e017ae 100644 --- a/lib/db/accounts.dart +++ b/lib/db/accounts.dart @@ -38,6 +38,38 @@ class AccountService { return account; } + Future createForexRetransAccIfNotExist({ + name = "Forex Retranslation", + }) async { + final dbClient = await _databaseHelper.db; + + // Check if the account already exists + final String currency = await SettingService().getSetting(Setting.prefCurrency); + final List> existingAccounts = await dbClient.query( + Accounts, + where: 'currency = ? AND name = ?', + whereArgs: [currency, name], + ); + + if (existingAccounts.isNotEmpty) { + // Account already exists, return the existing account + return Account.fromJson(existingAccounts.first); + } else { + // Account does not exist, create a new one + Account newAccount = Account( + balance: 0, + currency: currency, + name: name, + hidden: true, + type: AccountType.asset, + liquid: false, + ); + + // Persist the new account and return it + return await createAccount(newAccount); + } + } + Future createForexAccountIfNotExist( String currency, { double balance = 0, @@ -92,10 +124,33 @@ class AccountService { return null; } - Future> getAllAccounts(bool hidden) async { + Future> getAllAccounts({ + bool hidden = true, + bool liquid = false, + String? currency, + AccountType? type, + }) async { final dbClient = await _databaseHelper.db; - var whereClause = hidden ? '' : 'WHERE hidden = 0'; + List conditions = []; + if (liquid) { + conditions.add('liquid = 1'); + } + if (type != null) { + conditions.add("type = '${type.name}'"); + } + if (currency != null) { + conditions.add("currency = '$currency'"); + } + if (!hidden) { + conditions.add('hidden = 0'); + } + + String whereClause = ''; + if (conditions.isNotEmpty) { + whereClause = 'WHERE '; + whereClause += conditions.join(' AND '); + } String rawQuery = ''' SELECT @@ -149,7 +204,6 @@ class AccountService { // updates rates table await currencyBoxService.init(); await currencyBoxService.updateRatesTable(); - currencyBoxService.close(); } List conditions = ["type NOT IN ('income', 'expense')"]; @@ -159,6 +213,7 @@ class AccountService { if (type != null) { conditions.add("type = '${type.name}'"); } + conditions.add('hidden = 0'); String whereClause = conditions.join(' AND '); String sql = ''' WITH CurrencyGroups AS ( diff --git a/lib/db/currency.dart b/lib/db/currency.dart index 30ed7f2..cec9463 100644 --- a/lib/db/currency.dart +++ b/lib/db/currency.dart @@ -98,10 +98,6 @@ class CurrencyBoxService { rethrow; } } - - void close() { - _box.close(); - } } // ignore: non_constant_identifier_names diff --git a/lib/db/entries.dart b/lib/db/entries.dart index 092fbc4..5d78e11 100644 --- a/lib/db/entries.dart +++ b/lib/db/entries.dart @@ -1,6 +1,7 @@ import 'package:finease/db/accounts.dart'; import 'package:finease/db/currency.dart'; import 'package:finease/db/db.dart'; +import 'package:finease/db/settings.dart'; class EntryService { final DatabaseHelper _databaseHelper; @@ -97,27 +98,36 @@ class EntryService { Future> getAllEntries({ DateTime? startDate, DateTime? endDate, + int? accountId, }) async { final dbClient = await _databaseHelper.db; - String whereClause = ''; + List conditions = []; List whereArguments = []; + // If account id is provided, add it to the where clause + if (accountId != null) { + conditions.add('debit_account_id = ? OR credit_account_id = ?'); + whereArguments.add(accountId); + whereArguments.add(accountId); + } // If startDate is provided, add it to the where clause if (startDate != null) { - whereClause += 'date >= ?'; + conditions.add('date >= ?'); whereArguments.add(startDate.toIso8601String()); } // If endDate is provided, add it to the where clause if (endDate != null) { - if (whereClause.isNotEmpty) { - whereClause += ' AND '; - } - whereClause += 'date <= ?'; + conditions.add('date <= ?'); whereArguments.add(endDate.toIso8601String()); } + String whereClause = ''; + if (conditions.isNotEmpty) { + whereClause += conditions.join(' AND '); + } + // Fetch all entries according to the provided start and end dates final List> entriesData = await dbClient.query( 'Entries', @@ -125,8 +135,7 @@ class EntryService { whereArgs: whereArguments.isEmpty ? null : whereArguments, ); - final List allAccounts = - await AccountService().getAllAccounts(true); + final List allAccounts = await AccountService().getAllAccounts(); // Create a map for quick account lookup by ID var accountsMap = {for (var account in allAccounts) account.id: account}; @@ -161,7 +170,7 @@ class EntryService { ); } - Future adjustFirstBalance( + Future adjustFirstBalance( int toAccountId, int fromAccountId, double balance) async { if (balance == 0) { return; @@ -178,7 +187,7 @@ class EntryService { await dbClient.insert('Entries', entry.toJson()); } - Future adjustFirstForexBalance( + Future adjustFirstForexBalance( int toAccountId, int fromAccountId, double balance) async { if (balance == 0) { return; @@ -193,6 +202,28 @@ class EntryService { await createForexEntry(entry); } + + Future addCurrencyRetranslation( + double amount, + ) async { + Account forexReTrans = + await AccountService().createForexRetransAccIfNotExist(); + + String? capG = await SettingService().getSetting(Setting.capitalGains); + int? capGains = int.tryParse(capG); + if (capGains == null) { + throw AccountLinkingException("Capital Gains account not linked"); + } + + Entry entry = Entry( + debitAccountId: capGains, + creditAccountId: forexReTrans.id!, + amount: amount, + notes: "Foreign Currency Retranslation", + ); + + await createEntry(entry); + } } class Entry { @@ -245,3 +276,11 @@ class Entry { }; } } + +class AccountLinkingException implements Exception { + final String message; + AccountLinkingException(this.message); + + @override + String toString() => message; +} diff --git a/lib/db/migrations/b_add_indices.dart b/lib/db/migrations/b_add_indices.dart index 9729ac5..76ec7e7 100644 --- a/lib/db/migrations/b_add_indices.dart +++ b/lib/db/migrations/b_add_indices.dart @@ -18,8 +18,5 @@ Future bAddIndices(Database db) async { CREATE INDEX idx_entries_notes ON Entries(notes); CREATE INDEX idx_settings_key ON Settings(key); - - CREATE INDEX idx_migrations_id ON Migrations(id); '''); } - diff --git a/lib/db/months.dart b/lib/db/months.dart index ecdc654..a758960 100644 --- a/lib/db/months.dart +++ b/lib/db/months.dart @@ -19,7 +19,6 @@ class MonthService { // updates rates table await currencyBoxService.init(); await currencyBoxService.updateRatesTable(); - currencyBoxService.close(); } final dbClient = await _databaseHelper.db; diff --git a/lib/db/settings.dart b/lib/db/settings.dart index 5b37fe7..c896e57 100644 --- a/lib/db/settings.dart +++ b/lib/db/settings.dart @@ -2,14 +2,15 @@ import 'package:finease/db/db.dart'; import 'package:sqflite/sqflite.dart'; enum Setting { - introDone, - userName, accountSetup, + capitalGains, // account.id int + dbPassword, + introDone, onboarded, pastAccount, prefCurrency, useEncryption, - dbPassword, + userName, } typedef Settings = Map; diff --git a/lib/pages/add_account/account_body.dart b/lib/pages/add_account/account_body.dart index 61099e3..5e47a71 100644 --- a/lib/pages/add_account/account_body.dart +++ b/lib/pages/add_account/account_body.dart @@ -119,7 +119,7 @@ class AddAccountBodyState extends State { ), ), const SizedBox(height: 16), - GestureDetector( + InkWell( onTap: () => showCurrencyPicker( context: context, currencyFilter: SupportedCurrency.keys.toList(), diff --git a/lib/pages/add_entry/entry_body.dart b/lib/pages/add_entry/entry_body.dart index 4b2ff5c..357715b 100644 --- a/lib/pages/add_entry/entry_body.dart +++ b/lib/pages/add_entry/entry_body.dart @@ -76,7 +76,6 @@ class AddEntryBodyState extends State { _showError(e); val = false; } finally { - if (!val) _currencyBoxService.close(); setState(() { _useECBrate = val; }); diff --git a/lib/pages/add_entry/main.dart b/lib/pages/add_entry/main.dart index 026b228..7413f1a 100644 --- a/lib/pages/add_entry/main.dart +++ b/lib/pages/add_entry/main.dart @@ -42,7 +42,7 @@ class AddEntryScreenState extends State { } Future _fetchAccounts() async { - final accounts = await _accountService.getAllAccounts(false); + final accounts = await _accountService.getAllAccounts(hidden: false); accounts.sort((a, b) => a.name.compareTo(b.name)); final curr = await _settingService.getSetting(Setting.prefCurrency); diff --git a/lib/pages/add_info/main.dart b/lib/pages/add_info/main.dart index 969b360..ecbf63e 100644 --- a/lib/pages/add_info/main.dart +++ b/lib/pages/add_info/main.dart @@ -46,6 +46,7 @@ class _AddInfoPageState extends State { if (_formState.currentState!.validate()) { context.go(RoutesName.setupAccounts.path); await _settingService.setSetting(Setting.userName, _name.text); + Account account = await _accountService.createAccount(Account( balance: 0, currency: _currency.text, @@ -91,6 +92,7 @@ class _AddInfoPageState extends State { // next page needs the currency await _settingService.setSetting( Setting.prefCurrency, _currency.text); + saveForm(); }, extendedPadding: const EdgeInsets.symmetric(horizontal: 24), diff --git a/lib/pages/add_info/widget.dart b/lib/pages/add_info/widget.dart index 990cec4..293ed24 100644 --- a/lib/pages/add_info/widget.dart +++ b/lib/pages/add_info/widget.dart @@ -68,7 +68,7 @@ class AddInfoBody extends StatelessWidget { }, ), const SizedBox(height: 32), - GestureDetector( + InkWell( onTap: () => showCurrencyPicker( context: context, currencyFilter: SupportedCurrency.keys.toList(), diff --git a/lib/pages/edit_account/account_body.dart b/lib/pages/edit_account/account_body.dart index 23e0367..476452d 100644 --- a/lib/pages/edit_account/account_body.dart +++ b/lib/pages/edit_account/account_body.dart @@ -78,7 +78,7 @@ class EditAccountBody extends StatelessWidget { Visibility( visible: account.deletable, child: Column(children: [ - GestureDetector( + InkWell( onTap: () => showCurrencyPicker( context: context, currencyFilter: SupportedCurrency.keys.toList(), diff --git a/lib/pages/home/accounts/account_card.dart b/lib/pages/home/accounts/account_card.dart index 4903d5e..8ab1912 100644 --- a/lib/pages/home/accounts/account_card.dart +++ b/lib/pages/home/accounts/account_card.dart @@ -52,11 +52,15 @@ class BankAccounts extends StatelessWidget { ...mainAccounts.map( (a) => BankAccountCardClickable( account: a, - onTap: () => context.pushNamed( + onLongPress: () => context.pushNamed( RoutesName.editAccount.name, pathParameters: {'id': a.id.toString()}, extra: onEdit, ), + onTap: () => context.pushNamed( + RoutesName.transactionsByAccount.name, + extra: {'account_id': a.id.toString()}, + ), ), ), const Divider(), @@ -79,11 +83,15 @@ class BankAccounts extends StatelessWidget { ...extAccounts.map( (a) => BankAccountChipClickable( account: a, - onTap: () => context.pushNamed( + onLongPress: () => context.pushNamed( RoutesName.editAccount.name, pathParameters: {'id': a.id.toString()}, extra: onEdit, ), + onTap: () => context.pushNamed( + RoutesName.transactionsByAccount.name, + extra: {'account_id': a.id.toString()}, + ), ), ), ], @@ -103,11 +111,15 @@ class BankAccounts extends StatelessWidget { ...hiddenAccounts.map( (a) => BankAccountChipClickable( account: a, - onTap: () => context.pushNamed( + onLongPress: () => context.pushNamed( RoutesName.editAccount.name, pathParameters: {'id': a.id.toString()}, extra: onEdit, ), + onTap: () => context.pushNamed( + RoutesName.transactionsByAccount.name, + extra: {'account_id': a.id.toString()}, + ), ), ), ], @@ -121,17 +133,20 @@ class BankAccounts extends StatelessWidget { class BankAccountCardClickable extends StatelessWidget { final Account account; final VoidCallback? onTap; + final VoidCallback? onLongPress; const BankAccountCardClickable({ super.key, required this.account, - required this.onTap, + this.onTap, + this.onLongPress, }); @override Widget build(BuildContext context) { return InkWell( onTap: onTap, + onLongPress: onLongPress, child: BankAccountCard(account: account), ); } @@ -221,17 +236,20 @@ class BankAccountCard extends StatelessWidget { class BankAccountChipClickable extends StatelessWidget { final Account account; final VoidCallback? onTap; + final VoidCallback? onLongPress; const BankAccountChipClickable({ super.key, required this.account, required this.onTap, + this.onLongPress, }); @override Widget build(BuildContext context) { return InkWell( onTap: onTap, + onLongPress: onLongPress, child: BankAccountChip(account: account), ); } diff --git a/lib/pages/home/accounts/main.dart b/lib/pages/home/accounts/main.dart index 47bf1e1..d9b7ab4 100644 --- a/lib/pages/home/accounts/main.dart +++ b/lib/pages/home/accounts/main.dart @@ -24,7 +24,7 @@ class _AccountsPageState extends State { } Future loadAccounts() async { - List accountsList = await AccountService().getAllAccounts(true); + List accountsList = await AccountService().getAllAccounts(); accountsList.sort((a, b) => a.name.compareTo(b.name)); setState(() { @@ -50,7 +50,8 @@ class _AccountsPageState extends State { return Scaffold( key: scaffoldStateKey, - appBar: appBar(context, "accounts"), + appBar: infoBar(context, "accounts", + "Click to see transactions,\nand long press to edit the account."), body: RefreshIndicator( onRefresh: loadAccounts, child: BankAccounts( diff --git a/lib/pages/home/entries/main.dart b/lib/pages/home/entries/main.dart index c69b34d..5e4b4a8 100644 --- a/lib/pages/home/entries/main.dart +++ b/lib/pages/home/entries/main.dart @@ -34,6 +34,7 @@ class EntriesPageState extends State { List entriesList = await _entryService.getAllEntries( startDate: widget.startDate, endDate: widget.endDate, + accountId: widget.accountID, ); entriesList.sort((a, b) => (b.date!.compareTo(a.date!))); diff --git a/lib/pages/home/months/main.dart b/lib/pages/home/months/main.dart index 7d0d847..8eb729c 100644 --- a/lib/pages/home/months/main.dart +++ b/lib/pages/home/months/main.dart @@ -63,6 +63,7 @@ class MonthsPageState extends State { isLoading: isLoading, months: months, networth: networth, + onChange: loadMonths, ), ), drawer: AppDrawer( diff --git a/lib/pages/home/months/month_card.dart b/lib/pages/home/months/month_card.dart index 3307f63..8d72d15 100644 --- a/lib/pages/home/months/month_card.dart +++ b/lib/pages/home/months/month_card.dart @@ -1,26 +1,57 @@ import 'package:finease/db/currency.dart'; +import 'package:finease/db/entries.dart'; import 'package:finease/db/months.dart'; -import 'package:finease/parts/card.dart'; -import 'package:finease/routes/routes_name.dart'; +import 'package:finease/parts/export.dart'; import 'package:flutter/material.dart'; -import 'package:finease/core/export.dart'; -import 'package:go_router/go_router.dart'; -import 'package:intl/intl.dart'; - -DateFormat formatter = DateFormat('MMMM yyyy'); class MonthCards extends StatelessWidget { final List months; final bool isLoading; final double networth; + final Future Function() onChange; const MonthCards({ super.key, required this.months, this.isLoading = false, required this.networth, + required this.onChange, }); + Future _showConfirmationDialog( + BuildContext context, + double unrealised, + ) async { + bool? confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Foreign Currency Retranslation'), + content: const Text( + 'Do you want to adjust the unrealised amount as Capital Gains?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Confirm'), + ), + ], + ), + ); + + if (confirmed ?? false) { + try { + await EntryService().addCurrencyRetranslation(unrealised); + onChange(); + } catch (e) { + // ignore: use_build_context_synchronously + await showErrorDialog('Error: $e', context); + } + } + } + @override Widget build(BuildContext context) { if (isLoading) { @@ -36,7 +67,7 @@ class MonthCards extends StatelessWidget { } String currency = SupportedCurrency[months[0].currency!]!; - double unrealised = (networth- (months[0].networth ?? 0)); + double unrealised = (networth - (months[0].networth ?? 0)); bool showUnrealised = unrealised.round().abs() > 0; String gains = (unrealised > 0) ? "gains" : "losses"; @@ -46,8 +77,11 @@ class MonthCards extends StatelessWidget { Visibility( visible: showUnrealised, child: Center( - child: Text( - 'Unrealised $gains: $currency${unrealised.toStringAsFixed(2)}', + child: UnrealisedAlert( + gains: gains, + currency: currency, + unrealised: unrealised, + onTap: () => _showConfirmationDialog(context, unrealised), ), ), ), @@ -64,127 +98,3 @@ class MonthCards extends StatelessWidget { ); } } - -class MonthCard extends StatelessWidget { - const MonthCard({ - super.key, - required this.month, - }); - - final Month month; - - @override - Widget build(BuildContext context) { - 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: () { - context.pushNamed( - RoutesName.transactionsByDate.name, - extra: { - 'startDate': startDate.toIso8601String(), - 'endDate': endDate.toIso8601String(), - }, - ); - }, - child: AppCard( - elevation: 4, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Flex( - direction: Axis.horizontal, - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Text( - formatter.format(month.date!), - style: context.titleSmall, - ) - ], - ), - 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: networth, - ), - ), - const SizedBox(width: 8), - Expanded( - child: MonthWidget( - title: "Effect", - content: effect, - ), - ), - ], - ), - const SizedBox(height: 6), - Row( - children: [ - Expanded( - child: MonthWidget( - title: "Income", - content: income, - ), - ), - const SizedBox(width: 8), - Expanded( - child: MonthWidget( - title: "Expense", - content: expense, - ), - ), - ], - ), - ], - ), - ), - ), - ); - } -} - -class MonthWidget extends StatelessWidget { - const MonthWidget({ - super.key, - required this.title, - required this.content, - }); - - final String content; - final String title; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title), - Text( - content, - style: context.titleLarge, - ), - ], - ); - } -} diff --git a/lib/pages/settings/capital_gains.dart b/lib/pages/settings/capital_gains.dart new file mode 100644 index 0000000..66c410f --- /dev/null +++ b/lib/pages/settings/capital_gains.dart @@ -0,0 +1,85 @@ +import 'package:finease/db/accounts.dart'; +import 'package:finease/db/settings.dart'; +import 'package:finease/parts/account_search.dart'; +import 'package:flutter/material.dart'; + +class CapGainsSelectorWidget extends StatefulWidget { + const CapGainsSelectorWidget({ + super.key, + }); + + @override + CapGainsSelectorWidgetState createState() => CapGainsSelectorWidgetState(); +} + +class CapGainsSelectorWidgetState extends State { + final SettingService _settingService = SettingService(); + final AccountService _accountService = AccountService(); + Account? _account; + String? subtitle; + + @override + void initState() { + super.initState(); + onLoad(); + } + + Future onLoad() async { + String settingId = await _settingService.getSetting(Setting.capitalGains); + int? accountId = int.tryParse(settingId); + if (accountId != null) { + Account? account = await _accountService.getAccount(accountId); + setState(() { + _account = account; + subtitle = "${account!.name} - ${account.currency}"; + }); + } + } + + Future search(List accounts) async { + Account? selectedAccount = await showSearch( + context: context, + delegate: AccountSearchDelegate( + accounts: accounts, + selected: _account, + ), + ); + return selectedAccount; + } + + void _showCapGainsPicker() async { + final String currency = await _settingService.getSetting(Setting.prefCurrency); + final List accounts = await AccountService().getAllAccounts( + hidden: false, + type: AccountType.income, + currency: currency, + ); + Account? selectedAccount = await search(accounts); + + if (selectedAccount != null) { + await _settingService.setSetting( + Setting.capitalGains, "${selectedAccount.id}"); + setState(() { + _account = selectedAccount; + subtitle = "${selectedAccount.name} - ${selectedAccount.currency}"; + }); + return; + } + + await _settingService.deleteSetting(Setting.capitalGains); + setState(() { + _account = null; + subtitle = null; + }); + } + + @override + Widget build(BuildContext context) { + return ListTile( + title: const Text("Capital Gains Account"), + subtitle: Text(subtitle ?? 'Unlinked'), + leading: const Icon(Icons.moving), + onTap: () => _showCapGainsPicker(), + ); + } +} diff --git a/lib/pages/settings/main.dart b/lib/pages/settings/main.dart index 1cf0fde..3f70644 100644 --- a/lib/pages/settings/main.dart +++ b/lib/pages/settings/main.dart @@ -1,5 +1,6 @@ import 'package:finease/pages/export.dart'; import 'package:finease/pages/settings/about.dart'; +import 'package:finease/pages/settings/capital_gains.dart'; import 'package:finease/pages/settings/currency.dart'; import 'package:finease/pages/settings/toggle_encryption.dart'; import 'package:finease/parts/export.dart'; @@ -26,6 +27,7 @@ class SettingsPage extends StatelessWidget { title: "Personalise", options: [ CurrencySelectorWidget(onChange: onFormSubmitted), + const CapGainsSelectorWidget(), ], ), SettingsGroup( diff --git a/lib/pages/setup_accounts/default_account.dart b/lib/pages/setup_accounts/default_account.dart index 8f242c7..d7e8f20 100644 --- a/lib/pages/setup_accounts/default_account.dart +++ b/lib/pages/setup_accounts/default_account.dart @@ -1,5 +1,6 @@ import 'package:finease/db/accounts.dart'; +const String capitalGains = 'Capital Gains'; List defaultAccountsData( String prefCurrency, ) { @@ -12,7 +13,7 @@ List defaultAccountsData( type: AccountType.income, ), Account( - name: 'Capital Gains', + name: capitalGains, balance: 0, currency: prefCurrency, liquid: false, diff --git a/lib/pages/setup_accounts/setup_accounts.dart b/lib/pages/setup_accounts/setup_accounts.dart index cfe459b..c20d66d 100644 --- a/lib/pages/setup_accounts/setup_accounts.dart +++ b/lib/pages/setup_accounts/setup_accounts.dart @@ -39,7 +39,7 @@ class SetupAccountsWidgetState extends State } Future _fetchAccounts() async { - final accounts = await _accountService.getAllAccounts(false); + final accounts = await _accountService.getAllAccounts(hidden: false); setState(() { selectedAccounts = accounts; accountsNotifier.value = [...selectedAccounts]; @@ -55,6 +55,9 @@ class SetupAccountsWidgetState extends State void selectAccount(Account model) async { var account = await _accountService.createAccount(model); + if (account.name == capitalGains) { + await _settingService.setSetting(Setting.capitalGains, "${account.id}"); + } setState(() { egAccounts.remove(model); selectedAccounts.add(account); @@ -64,6 +67,9 @@ class SetupAccountsWidgetState extends State void deselectAccount(Account model) async { await _accountService.deleteAccount(model.id!); + if (model.name == capitalGains) { + await _settingService.deleteSetting(Setting.capitalGains); + } model.id = null; setState(() { egAccounts.add(model); @@ -114,7 +120,7 @@ class SetupAccountsWidgetState extends State runSpacing: 12.0, children: [ ...egAccounts.map( - (model) => _buildAccountChip(model, isEGAccount: true)), + (model) => _buildAccountChip(model, isEgAccount: true)), AddNewAccount( onSelected: (val) => context.pushNamed( RoutesName.addAccount.name, @@ -163,7 +169,7 @@ class SetupAccountsWidgetState extends State ); } - Widget _buildAccountChip(Account model, {required bool isEGAccount}) { + Widget _buildAccountChip(Account model, {required bool isEgAccount}) { return AccountChip( avatar: Icon( Icons.add_rounded, @@ -171,7 +177,7 @@ class SetupAccountsWidgetState extends State ), label: Text(model.name), onSelected: (val) => - isEGAccount ? selectAccount(model) : deselectAccount(model), + isEgAccount ? selectAccount(model) : deselectAccount(model), ); } } diff --git a/lib/parts/account_search.dart b/lib/parts/account_search.dart new file mode 100644 index 0000000..a3b54ec --- /dev/null +++ b/lib/parts/account_search.dart @@ -0,0 +1,75 @@ +import 'package:finease/db/accounts.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class AccountSearchDelegate extends SearchDelegate { + List accounts; + Account? selected; + bool once = false; + AccountSearchDelegate({required this.accounts, this.selected}); + + @override + List buildActions(BuildContext context) { + return [ + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + selected = null; + query = ''; + }, + ), + ]; + } + + @override + Widget buildLeading(BuildContext context) { + return IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => close(context, selected), + ); + } + + @override + Widget buildResults(BuildContext context) { + if (!once && selected != null && selected!.name.isNotEmpty) { + query = selected?.name ?? ''; + once = true; + } + + List filteredList = accounts.where((account) { + return account.name.toLowerCase().contains(query.toLowerCase()); + }).toList(); + + return SingleChildScrollView( + child: Column( + children: [ + ListTile( + title: const Text( + 'Unlink', + style: TextStyle(fontStyle: FontStyle.italic), + ), + onTap: context.pop, + ), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: filteredList.length, + itemBuilder: (context, index) { + return ListTile( + title: Text(filteredList[index].name), + onTap: () { + close(context, filteredList[index]); + }, + ); + }, + ), + ], + ), + ); + } + + @override + Widget buildSuggestions(BuildContext context) { + return buildResults(context); + } +} diff --git a/lib/parts/app_top_bar.dart b/lib/parts/app_top_bar.dart index 2a1c0db..ba8c7d5 100644 --- a/lib/parts/app_top_bar.dart +++ b/lib/parts/app_top_bar.dart @@ -35,6 +35,59 @@ PreferredSize appBar( ), ); +PreferredSize infoBar( + BuildContext context, + String title, + String info, +) => + PreferredSize( + preferredSize: const Size.fromHeight(toolbarHeight), + child: SafeArea( + top: true, + child: Container( + margin: const EdgeInsets.only(top: 8, bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: ClipRRect( + borderRadius: BorderRadius.circular(32), + clipBehavior: Clip.antiAlias, + child: AppBar( + leading: Navigator.of(context).canPop() + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + ) + : null, + automaticallyImplyLeading: true, + backgroundColor: context.secondaryContainer.withOpacity(0.5), + scrolledUnderElevation: 0, + title: Text(title, style: context.titleMedium), + actions: [ + IconButton( + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text('Information'), + content: Text(info), + actions: [ + TextButton( + child: const Text('OK'), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ); + }, + icon: const Icon(Icons.info_outlined), + ), + const SizedBox(width: 8), + ], + ), + ), + ), + ), + ); + class TopBar extends StatelessWidget { const TopBar({ super.key, diff --git a/lib/parts/export.dart b/lib/parts/export.dart index dec054f..539cbbf 100644 --- a/lib/parts/export.dart +++ b/lib/parts/export.dart @@ -5,4 +5,5 @@ export 'app_top_bar.dart'; export 'button.dart'; export 'error_dialog.dart'; export 'icon_title.dart'; +export 'month_parts.dart'; export 'variable_fab_size.dart'; diff --git a/lib/parts/month_parts.dart b/lib/parts/month_parts.dart new file mode 100644 index 0000000..83cbfb0 --- /dev/null +++ b/lib/parts/month_parts.dart @@ -0,0 +1,180 @@ +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'; +import 'package:flutter/material.dart'; +import 'package:finease/core/export.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +DateFormat formatter = DateFormat('MMMM yyyy'); + +class UnrealisedAlert extends StatelessWidget { + const UnrealisedAlert({ + super.key, + required this.gains, + required this.currency, + required this.unrealised, + this.onTap, + }); + + final String gains; + final String currency; + final double unrealised; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: InkWell( + onTap: onTap, + child: Chip( + label: IntrinsicWidth( + child: Row( + children: [ + const Icon(Icons.info_outline_rounded), + const SizedBox(width: 4), + Text( + 'Unrealised $gains: $currency${unrealised.toStringAsFixed(2)}', + ) + ], + ), + ), + backgroundColor: context.secondaryContainer.withOpacity(0.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + side: BorderSide( + width: 1, + color: context.primary, + ), + ), + ), + ), + ); + } +} + +class MonthCard extends StatelessWidget { + const MonthCard({ + super.key, + required this.month, + }); + + final Month month; + + @override + Widget build(BuildContext context) { + 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: () { + context.pushNamed( + RoutesName.transactionsByDate.name, + extra: { + 'startDate': startDate.toIso8601String(), + 'endDate': endDate.toIso8601String(), + }, + ); + }, + child: AppCard( + elevation: 4, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flex( + direction: Axis.horizontal, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Text( + formatter.format(month.date!), + style: context.titleSmall, + ) + ], + ), + 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: networth, + ), + ), + const SizedBox(width: 8), + Expanded( + child: MonthWidget( + title: "Effect", + content: effect, + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: MonthWidget( + title: "Income", + content: income, + ), + ), + const SizedBox(width: 8), + Expanded( + child: MonthWidget( + title: "Expense", + content: expense, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class MonthWidget extends StatelessWidget { + const MonthWidget({ + super.key, + required this.title, + required this.content, + }); + + final String content; + final String title; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title), + Text( + content, + style: context.titleLarge, + ), + ], + ); + } +} diff --git a/lib/parts/pill_chip.dart b/lib/parts/pill_chip.dart index 3a71665..ce28d01 100644 --- a/lib/parts/pill_chip.dart +++ b/lib/parts/pill_chip.dart @@ -23,7 +23,7 @@ class AppPillChip extends StatelessWidget { return Row( children: [ - GestureDetector( + InkWell( onTap: onPressed, child: AnimatedContainer( duration: const Duration(milliseconds: 300), diff --git a/lib/parts/user_widget.dart b/lib/parts/user_widget.dart index 518d7f9..3a03fa8 100644 --- a/lib/parts/user_widget.dart +++ b/lib/parts/user_widget.dart @@ -6,7 +6,7 @@ class AppUserWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( + return InkWell( onTap: () => {}, child: ClipOval( child: Container( diff --git a/lib/routes/routes.dart b/lib/routes/routes.dart index 9bf9330..f146fd6 100644 --- a/lib/routes/routes.dart +++ b/lib/routes/routes.dart @@ -62,6 +62,15 @@ final GoRouter goRouter = GoRouter( return EntriesPage(startDate: startDate, endDate: endDate); }, ), + GoRoute( + name: RoutesName.transactionsByAccount.name, + path: RoutesName.transactionsByAccount.path, + builder: (BuildContext context, GoRouterState state) { + Map range = state.extra as Map; + final int accountID = int.tryParse(range['account_id']!) ?? 0; + return EntriesPage(accountID: accountID); + }, + ), GoRoute( name: RoutesName.setupAccounts.name, path: RoutesName.setupAccounts.path, diff --git a/lib/routes/routes_name.dart b/lib/routes/routes_name.dart index 4263a22..0b7cdc6 100644 --- a/lib/routes/routes_name.dart +++ b/lib/routes/routes_name.dart @@ -11,6 +11,7 @@ enum RoutesName { setupAccounts, transactions, transactionsByDate, + transactionsByAccount, } extension RoutesNameHelper on RoutesName { diff --git a/pubspec.yaml b/pubspec.yaml index f964ff1..f7120cf 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.24 +version: 1.0.25 environment: sdk: '>=3.2.3 <4.0.0'