From c5aa1654241551ef6ba847018c259badb096a2a0 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Mon, 12 May 2025 21:02:51 +0200 Subject: [PATCH] Add a loan feature --- lib/database/collections/loan.dart | 23 + lib/database/collections/loan.g.dart | 813 +++++++++++++++++++ lib/database/database.dart | 53 ++ lib/ui/navigation.dart | 11 + lib/ui/pages/account/account.dart | 4 + lib/ui/pages/account/loan_card.dart | 36 + lib/ui/pages/account/total_balance_card.dart | 3 +- lib/ui/pages/budgets/budgets.dart | 1 - lib/ui/pages/loans/add_loan.dart | 94 +++ lib/ui/pages/loans/add_loan_change.dart | 102 +++ lib/ui/pages/loans/loan_details.dart | 97 +++ lib/ui/pages/loans/loan_list.dart | 74 ++ lib/ui/state/core.dart | 15 + lib/ui/state/core.freezed.dart | 60 +- pubspec.yaml | 2 +- 15 files changed, 1383 insertions(+), 5 deletions(-) create mode 100644 lib/database/collections/loan.dart create mode 100644 lib/database/collections/loan.g.dart create mode 100644 lib/ui/pages/account/loan_card.dart create mode 100644 lib/ui/pages/loans/add_loan.dart create mode 100644 lib/ui/pages/loans/add_loan_change.dart create mode 100644 lib/ui/pages/loans/loan_details.dart create mode 100644 lib/ui/pages/loans/loan_list.dart diff --git a/lib/database/collections/loan.dart b/lib/database/collections/loan.dart new file mode 100644 index 0000000..9f8cdcf --- /dev/null +++ b/lib/database/collections/loan.dart @@ -0,0 +1,23 @@ +import 'package:isar/isar.dart'; +import 'package:okane/database/collections/account.dart'; +import 'package:okane/database/collections/beneficiary.dart'; + +part 'loan.g.dart'; + +@collection +class Loan { + Id id = Isar.autoIncrement; + + final beneficiary = IsarLink(); + + final changes = IsarLinks(); +} + +@collection +class LoanChange { + Id id = Isar.autoIncrement; + + late double amount; + + late DateTime date; +} diff --git a/lib/database/collections/loan.g.dart b/lib/database/collections/loan.g.dart new file mode 100644 index 0000000..a670e49 --- /dev/null +++ b/lib/database/collections/loan.g.dart @@ -0,0 +1,813 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'loan.dart'; + +// ************************************************************************** +// IsarCollectionGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetLoanCollection on Isar { + IsarCollection get loans => this.collection(); +} + +const LoanSchema = CollectionSchema( + name: r'Loan', + id: 3165146227223573679, + properties: {}, + estimateSize: _loanEstimateSize, + serialize: _loanSerialize, + deserialize: _loanDeserialize, + deserializeProp: _loanDeserializeProp, + idName: r'id', + indexes: {}, + links: { + r'beneficiary': LinkSchema( + id: -4362685136363706814, + name: r'beneficiary', + target: r'Beneficiary', + single: true, + ), + r'changes': LinkSchema( + id: -2646664619562347284, + name: r'changes', + target: r'LoanChange', + single: false, + ), + }, + embeddedSchemas: {}, + getId: _loanGetId, + getLinks: _loanGetLinks, + attach: _loanAttach, + version: '3.1.0+1', +); + +int _loanEstimateSize( + Loan object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + return bytesCount; +} + +void _loanSerialize( + Loan object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) {} +Loan _loanDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = Loan(); + object.id = id; + return object; +} + +P _loanDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _loanGetId(Loan object) { + return object.id; +} + +List> _loanGetLinks(Loan object) { + return [object.beneficiary, object.changes]; +} + +void _loanAttach(IsarCollection col, Id id, Loan object) { + object.id = id; + object.beneficiary.attach( + col, + col.isar.collection(), + r'beneficiary', + id, + ); + object.changes.attach(col, col.isar.collection(), r'changes', id); +} + +extension LoanQueryWhereSort on QueryBuilder { + QueryBuilder anyId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension LoanQueryWhere on QueryBuilder { + QueryBuilder idEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between(lower: id, upper: id)); + }); + } + + QueryBuilder idNotEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ); + } + }); + } + + QueryBuilder idGreaterThan( + Id id, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: include), + ); + }); + } + + QueryBuilder idLessThan( + Id id, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: include), + ); + }); + } + + QueryBuilder idBetween( + Id lowerId, + Id upperId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.between( + lower: lowerId, + includeLower: includeLower, + upper: upperId, + includeUpper: includeUpper, + ), + ); + }); + } +} + +extension LoanQueryFilter on QueryBuilder { + QueryBuilder idEqualTo(Id value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'id', value: value), + ); + }); + } + + QueryBuilder idGreaterThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'id', + value: value, + ), + ); + }); + } + + QueryBuilder idLessThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'id', + value: value, + ), + ); + }); + } + + QueryBuilder idBetween( + Id lower, + Id upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'id', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } +} + +extension LoanQueryObject on QueryBuilder {} + +extension LoanQueryLinks on QueryBuilder { + QueryBuilder beneficiary( + FilterQuery q, + ) { + return QueryBuilder.apply(this, (query) { + return query.link(q, r'beneficiary'); + }); + } + + QueryBuilder beneficiaryIsNull() { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'beneficiary', 0, true, 0, true); + }); + } + + QueryBuilder changes( + FilterQuery q, + ) { + return QueryBuilder.apply(this, (query) { + return query.link(q, r'changes'); + }); + } + + QueryBuilder changesLengthEqualTo( + int length, + ) { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'changes', length, true, length, true); + }); + } + + QueryBuilder changesIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'changes', 0, true, 0, true); + }); + } + + QueryBuilder changesIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'changes', 0, false, 999999, true); + }); + } + + QueryBuilder changesLengthLessThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'changes', 0, true, length, include); + }); + } + + QueryBuilder changesLengthGreaterThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'changes', length, include, 999999, true); + }); + } + + QueryBuilder changesLengthBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.linkLength( + r'changes', + lower, + includeLower, + upper, + includeUpper, + ); + }); + } +} + +extension LoanQuerySortBy on QueryBuilder {} + +extension LoanQuerySortThenBy on QueryBuilder { + QueryBuilder thenById() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.asc); + }); + } + + QueryBuilder thenByIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.desc); + }); + } +} + +extension LoanQueryWhereDistinct on QueryBuilder {} + +extension LoanQueryProperty on QueryBuilder { + QueryBuilder idProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'id'); + }); + } +} + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetLoanChangeCollection on Isar { + IsarCollection get loanChanges => this.collection(); +} + +const LoanChangeSchema = CollectionSchema( + name: r'LoanChange', + id: 998721626271124002, + properties: { + r'amount': PropertySchema(id: 0, name: r'amount', type: IsarType.double), + r'date': PropertySchema(id: 1, name: r'date', type: IsarType.dateTime), + }, + estimateSize: _loanChangeEstimateSize, + serialize: _loanChangeSerialize, + deserialize: _loanChangeDeserialize, + deserializeProp: _loanChangeDeserializeProp, + idName: r'id', + indexes: {}, + links: {}, + embeddedSchemas: {}, + getId: _loanChangeGetId, + getLinks: _loanChangeGetLinks, + attach: _loanChangeAttach, + version: '3.1.0+1', +); + +int _loanChangeEstimateSize( + LoanChange object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + return bytesCount; +} + +void _loanChangeSerialize( + LoanChange object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeDouble(offsets[0], object.amount); + writer.writeDateTime(offsets[1], object.date); +} + +LoanChange _loanChangeDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = LoanChange(); + object.amount = reader.readDouble(offsets[0]); + object.date = reader.readDateTime(offsets[1]); + object.id = id; + return object; +} + +P _loanChangeDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readDouble(offset)) as P; + case 1: + return (reader.readDateTime(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _loanChangeGetId(LoanChange object) { + return object.id; +} + +List> _loanChangeGetLinks(LoanChange object) { + return []; +} + +void _loanChangeAttach(IsarCollection col, Id id, LoanChange object) { + object.id = id; +} + +extension LoanChangeQueryWhereSort + on QueryBuilder { + QueryBuilder anyId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension LoanChangeQueryWhere + on QueryBuilder { + QueryBuilder idEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between(lower: id, upper: id)); + }); + } + + QueryBuilder idNotEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ); + } + }); + } + + QueryBuilder idGreaterThan( + Id id, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: include), + ); + }); + } + + QueryBuilder idLessThan( + Id id, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: include), + ); + }); + } + + QueryBuilder idBetween( + Id lowerId, + Id upperId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.between( + lower: lowerId, + includeLower: includeLower, + upper: upperId, + includeUpper: includeUpper, + ), + ); + }); + } +} + +extension LoanChangeQueryFilter + on QueryBuilder { + QueryBuilder amountEqualTo( + double value, { + double epsilon = Query.epsilon, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'amount', + value: value, + epsilon: epsilon, + ), + ); + }); + } + + QueryBuilder amountGreaterThan( + double value, { + bool include = false, + double epsilon = Query.epsilon, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'amount', + value: value, + epsilon: epsilon, + ), + ); + }); + } + + QueryBuilder amountLessThan( + double value, { + bool include = false, + double epsilon = Query.epsilon, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'amount', + value: value, + epsilon: epsilon, + ), + ); + }); + } + + QueryBuilder amountBetween( + double lower, + double upper, { + bool includeLower = true, + bool includeUpper = true, + double epsilon = Query.epsilon, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'amount', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + epsilon: epsilon, + ), + ); + }); + } + + QueryBuilder dateEqualTo( + DateTime value, + ) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'date', value: value), + ); + }); + } + + QueryBuilder dateGreaterThan( + DateTime value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'date', + value: value, + ), + ); + }); + } + + QueryBuilder dateLessThan( + DateTime value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'date', + value: value, + ), + ); + }); + } + + QueryBuilder dateBetween( + DateTime lower, + DateTime upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'date', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder idEqualTo( + Id value, + ) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'id', value: value), + ); + }); + } + + QueryBuilder idGreaterThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'id', + value: value, + ), + ); + }); + } + + QueryBuilder idLessThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'id', + value: value, + ), + ); + }); + } + + QueryBuilder idBetween( + Id lower, + Id upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'id', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } +} + +extension LoanChangeQueryObject + on QueryBuilder {} + +extension LoanChangeQueryLinks + on QueryBuilder {} + +extension LoanChangeQuerySortBy + on QueryBuilder { + QueryBuilder sortByAmount() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'amount', Sort.asc); + }); + } + + QueryBuilder sortByAmountDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'amount', Sort.desc); + }); + } + + QueryBuilder sortByDate() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'date', Sort.asc); + }); + } + + QueryBuilder sortByDateDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'date', Sort.desc); + }); + } +} + +extension LoanChangeQuerySortThenBy + on QueryBuilder { + QueryBuilder thenByAmount() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'amount', Sort.asc); + }); + } + + QueryBuilder thenByAmountDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'amount', Sort.desc); + }); + } + + QueryBuilder thenByDate() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'date', Sort.asc); + }); + } + + QueryBuilder thenByDateDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'date', Sort.desc); + }); + } + + QueryBuilder thenById() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.asc); + }); + } + + QueryBuilder thenByIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.desc); + }); + } +} + +extension LoanChangeQueryWhereDistinct + on QueryBuilder { + QueryBuilder distinctByAmount() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'amount'); + }); + } + + QueryBuilder distinctByDate() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'date'); + }); + } +} + +extension LoanChangeQueryProperty + on QueryBuilder { + QueryBuilder idProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'id'); + }); + } + + QueryBuilder amountProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'amount'); + }); + } + + QueryBuilder dateProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'date'); + }); + } +} diff --git a/lib/database/database.dart b/lib/database/database.dart index b3d967c..c3cfec1 100644 --- a/lib/database/database.dart +++ b/lib/database/database.dart @@ -6,6 +6,7 @@ import 'package:more/collection.dart'; import 'package:okane/database/collections/account.dart'; import 'package:okane/database/collections/beneficiary.dart'; import 'package:okane/database/collections/expense_category.dart'; +import 'package:okane/database/collections/loan.dart'; import 'package:okane/database/collections/recurrent.dart'; import 'package:okane/database/collections/template.dart'; import 'package:okane/database/collections/transaction.dart'; @@ -26,6 +27,8 @@ Future openDatabase() async { ExpenseCategorySchema, BudgetSchema, BudgetItemSchema, + LoanSchema, + LoanChangeSchema, ], directory: dir.path); } @@ -288,6 +291,56 @@ Future> getTransactionsInTimeframe( .findAll(); } +Future upsertLoan(Loan loan) { + final db = GetIt.I.get(); + return db.writeTxn(() async { + await db.loans.put(loan); + await loan.beneficiary.save(); + await loan.changes.save(); + }); +} + +Future> getLoans() { + return GetIt.I.get().loans.where().findAll(); +} + +Stream watchLoans() { + return GetIt.I.get().loans.where().watchLazy(fireImmediately: true); +} + +Future deleteLoan(Loan loan) async { + final db = GetIt.I.get(); + final loanChangeIds = loan.changes.map((c) => c.id).toList(); + return db.writeTxn(() async { + await db.loans.delete(loan.id); + await db.loanChanges.deleteAll(loanChangeIds); + }); +} + +Future upsertLoanChange(LoanChange loanChange) { + final db = GetIt.I.get(); + return db.writeTxn(() async { + await db.loanChanges.put(loanChange); + }); +} + +Future deleteLoanChange(LoanChange loanChange) { + final db = GetIt.I.get(); + return db.writeTxn(() async { + await db.loanChanges.delete(loanChange.id); + }); +} + +Future getTotalLoanSum() async { + final loans = await getLoans(); + return loans + .map( + (loan) => + loan.changes.map((l) => l.amount).reduce((acc, val) => acc + val), + ) + .reduce((acc, val) => acc + val); +} + Future deleteAccount(Account account) async { final db = GetIt.I.get(); final affectedBudgets = diff --git a/lib/ui/navigation.dart b/lib/ui/navigation.dart index c667702..3801744 100644 --- a/lib/ui/navigation.dart +++ b/lib/ui/navigation.dart @@ -5,6 +5,8 @@ import 'package:okane/ui/pages/account/account.dart'; import 'package:okane/ui/pages/beneficiary_list.dart'; import 'package:okane/ui/pages/budgets/budget_details.dart'; import 'package:okane/ui/pages/budgets/budgets.dart'; +import 'package:okane/ui/pages/loans/loan_details.dart'; +import 'package:okane/ui/pages/loans/loan_list.dart'; import 'package:okane/ui/pages/settings.dart'; import 'package:okane/ui/pages/template_list.dart'; import 'package:okane/ui/pages/transaction_details.dart'; @@ -18,6 +20,7 @@ enum OkanePage { beneficiaries, templates, budgets, + loans, settings, } @@ -104,6 +107,14 @@ final _pages = [ (_) => BudgetDetailsPage(), true, ), + OkanePageItem( + OkanePage.loans, + Icons.money_outlined, + "Loans", + LoanListPage(), + (_) => LoanDetailsPage(), + false, + ), OkanePageItem( OkanePage.settings, Icons.settings, diff --git a/lib/ui/pages/account/account.dart b/lib/ui/pages/account/account.dart index d41a3ff..09701dd 100644 --- a/lib/ui/pages/account/account.dart +++ b/lib/ui/pages/account/account.dart @@ -6,6 +6,7 @@ import 'package:okane/database/collections/beneficiary.dart'; import 'package:okane/database/database.dart'; import 'package:okane/ui/pages/account/breakdown_card.dart'; import 'package:okane/ui/pages/account/delete_account.dart'; +import 'package:okane/ui/pages/account/loan_card.dart'; import 'package:okane/ui/pages/account/total_balance_card.dart'; import 'package:okane/ui/pages/account/upcoming_transactions_card.dart'; import 'package:okane/ui/state/core.dart'; @@ -184,7 +185,10 @@ class AccountListPageState extends State { padding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), child: UpcomingTransactionsCard(), ), + Padding(padding: EdgeInsets.all(8), child: BreakdownCard()), + + Padding(padding: EdgeInsets.all(8), child: TotalLoanCard()), ], ), ], diff --git a/lib/ui/pages/account/loan_card.dart b/lib/ui/pages/account/loan_card.dart new file mode 100644 index 0000000..d8eebc4 --- /dev/null +++ b/lib/ui/pages/account/loan_card.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:okane/database/database.dart'; +import 'package:okane/i18n/strings.g.dart'; +import 'package:okane/ui/state/core.dart'; +import 'package:okane/ui/utils.dart'; +import 'package:okane/ui/widgets/piechart_card.dart'; + +class TotalLoanCard extends StatelessWidget { + const TotalLoanCard({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return ResponsiveCard( + titleText: "Loan Sum", + child: Padding( + padding: EdgeInsets.all(16), + child: FutureBuilder( + future: getTotalLoanSum(), + builder: (context, snapshot) { + return Text( + snapshot.hasData + ? formatCurrency(snapshot.data!) + : t.pages.accounts.totalBalance.loading, + style: Theme.of(context).textTheme.bodyLarge, + ); + }, + ), + ), + ); + }, + ); + } +} diff --git a/lib/ui/pages/account/total_balance_card.dart b/lib/ui/pages/account/total_balance_card.dart index cec4ce9..9675d69 100644 --- a/lib/ui/pages/account/total_balance_card.dart +++ b/lib/ui/pages/account/total_balance_card.dart @@ -16,8 +16,9 @@ class TotalBalanceCard extends StatelessWidget { } final results = await Future.wait(accounts.map(getTotalBalance).toList()); + final loanSum = await getTotalLoanSum(); - return results.reduce((acc, val) => acc + val); + return results.reduce((acc, val) => acc + val) + loanSum; } @override diff --git a/lib/ui/pages/budgets/budgets.dart b/lib/ui/pages/budgets/budgets.dart index 392026d..896079a 100644 --- a/lib/ui/pages/budgets/budgets.dart +++ b/lib/ui/pages/budgets/budgets.dart @@ -68,7 +68,6 @@ class BudgetListPage extends StatelessWidget { ); }, ), - Positioned( right: 16, bottom: 16, diff --git a/lib/ui/pages/loans/add_loan.dart b/lib/ui/pages/loans/add_loan.dart new file mode 100644 index 0000000..5e85f34 --- /dev/null +++ b/lib/ui/pages/loans/add_loan.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; +import 'package:okane/database/collections/beneficiary.dart'; +import 'package:okane/database/collections/budget.dart'; +import 'package:okane/database/collections/loan.dart'; +import 'package:okane/database/database.dart'; +import 'package:okane/i18n/strings.g.dart'; +import 'package:okane/ui/state/core.dart'; +import 'package:searchfield/searchfield.dart'; + +class AddLoanPopup extends StatefulWidget { + final VoidCallback onDone; + + const AddLoanPopup({super.key, required this.onDone}); + + @override + AddBudgetState createState() => AddBudgetState(); +} + +class AddBudgetState extends State { + final TextEditingController _beneficiaryTextController = + TextEditingController(); + SearchFieldListItem? _selectedBeneficiary; + + String getBeneficiaryName(Beneficiary item) { + return switch (item.type) { + BeneficiaryType.account => t.common.beneficiary.nameWithAccount( + name: item.name, + ), + BeneficiaryType.other => item.name, + }; + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: BlocBuilder( + builder: + (context, state) => SearchField( + suggestions: + state.beneficiaries + .where((el) { + final bloc = GetIt.I.get(); + if (el.type == BeneficiaryType.account) { + return el.account.value?.id.toInt() == + bloc.activeAccount?.id.toInt(); + } + return true; + }) + .map((el) { + return SearchFieldListItem( + getBeneficiaryName(el), + item: el, + ); + }) + .toList(), + hint: "Beneficiary", + controller: _beneficiaryTextController, + selectedValue: _selectedBeneficiary, + onSuggestionTap: (beneficiary) { + setState(() => _selectedBeneficiary = beneficiary); + }, + ), + ), + ), + + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton( + onPressed: () async { + if (_beneficiaryTextController.text.isEmpty) { + return; + } + + final bloc = GetIt.I.get(); + final loan = + Loan()..beneficiary.value = _selectedBeneficiary!.item; + await upsertLoan(loan); + widget.onDone(); + }, + child: Text(t.modals.add), + ), + ], + ), + ], + ); + } +} diff --git a/lib/ui/pages/loans/add_loan_change.dart b/lib/ui/pages/loans/add_loan_change.dart new file mode 100644 index 0000000..882472b --- /dev/null +++ b/lib/ui/pages/loans/add_loan_change.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:okane/database/collections/loan.dart'; +import 'package:okane/database/database.dart'; +import 'package:okane/i18n/strings.g.dart'; +import 'package:okane/ui/utils.dart'; + +enum LoanChangeType { owe, loan } + +class AddLoanChangePopup extends StatefulWidget { + final VoidCallback onDone; + + final Loan loan; + + const AddLoanChangePopup({ + super.key, + required this.onDone, + required this.loan, + }); + + @override + AddLoanPopupState createState() => AddLoanPopupState(); +} + +class AddLoanPopupState extends State { + LoanChangeType _loanChangeType = LoanChangeType.loan; + final TextEditingController _amountController = TextEditingController( + text: "0.00", + ); + + DateTime _selectedDate = DateTime.now(); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SegmentedButton( + segments: [ + ButtonSegment(value: LoanChangeType.loan, label: Text("Loan")), + ButtonSegment(value: LoanChangeType.owe, label: Text("Owe")), + ], + selected: {_loanChangeType}, + onSelectionChanged: (values) { + setState(() { + _loanChangeType = values.first; + }); + }, + ), + TextField( + decoration: InputDecoration( + icon: Icon(Icons.euro), + hintText: "Amount", + ), + controller: _amountController, + keyboardType: TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), + ), + Row( + children: [ + Text("Date"), + OutlinedButton( + onPressed: () async { + final dt = await showDatePicker( + context: context, + initialDate: _selectedDate, + firstDate: DateTime(1), + lastDate: DateTime(9999), + ); + if (dt == null) return; + + setState(() => _selectedDate = dt); + }, + child: Text(formatDateTime(_selectedDate)), + ), + ], + ), + Align( + alignment: Alignment.centerRight, + child: OutlinedButton( + onPressed: () async { + final sign = switch (_loanChangeType) { + LoanChangeType.owe => -1, + LoanChangeType.loan => 1, + }; + final loanChange = + LoanChange() + ..amount = sign * double.parse(_amountController.text).abs() + ..date = DateTime.now(); + await upsertLoanChange(loanChange); + widget.loan.changes.add(loanChange); + await upsertLoan(widget.loan); + widget.onDone(); + }, + child: Text(t.modals.add), + ), + ), + ], + ); + } +} diff --git a/lib/ui/pages/loans/loan_details.dart b/lib/ui/pages/loans/loan_details.dart new file mode 100644 index 0000000..38d2df2 --- /dev/null +++ b/lib/ui/pages/loans/loan_details.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:okane/database/database.dart'; +import 'package:okane/ui/pages/loans/add_loan_change.dart'; +import 'package:okane/ui/state/core.dart'; +import 'package:okane/ui/utils.dart'; +import 'package:okane/ui/widgets/image_wrapper.dart'; + +class LoanDetailsPage extends StatelessWidget { + const LoanDetailsPage({super.key}); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + BlocBuilder( + builder: (context, state) { + if (state.activeLoan == null) { + return Text("No loan selected"); + } + + final loans = state.activeLoan!.changes.toList(); + final loanSum = loans + .map((c) => c.amount) + .reduce((acc, val) => acc + val); + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Row( + children: [ + ImageWrapper( + title: state.activeLoan!.beneficiary.value!.name, + path: state.activeLoan!.beneficiary.value!.imagePath, + ), + Text(state.activeLoan!.beneficiary.value!.name), + ], + ), + ), + + SliverToBoxAdapter( + child: Text("Total: ${formatCurrency(loanSum)}"), + ), + + SliverToBoxAdapter( + child: Row( + children: [ + Text("Loan Transactions"), + + IconButton( + onPressed: () { + showDialogOrModal( + context: context, + builder: + (_) => AddLoanChangePopup( + loan: state.activeLoan!, + onDone: () { + Navigator.of(context).pop(); + }, + ), + ); + }, + icon: Icon(Icons.add), + ), + ], + ), + ), + + SliverList.builder( + itemCount: loans.length, + itemBuilder: (context, index) { + final item = loans[index]; + + return ListTile( + leading: + item.amount > 0 + ? Icon(Icons.add, color: Colors.green) + : Icon(Icons.remove, color: Colors.red), + title: Text(formatCurrency(item.amount)), + trailing: IconButton( + icon: Icon(Icons.delete, color: Colors.red), + onPressed: () async { + state.activeLoan!.changes.remove(item); + await deleteLoanChange(item); + await upsertLoan(state.activeLoan!); + }, + ), + ); + }, + ), + ], + ); + }, + ), + ], + ); + } +} diff --git a/lib/ui/pages/loans/loan_list.dart b/lib/ui/pages/loans/loan_list.dart new file mode 100644 index 0000000..3723d6e --- /dev/null +++ b/lib/ui/pages/loans/loan_list.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; +import 'package:okane/database/database.dart'; +import 'package:okane/ui/pages/loans/add_loan.dart'; +import 'package:okane/ui/state/core.dart'; +import 'package:okane/ui/utils.dart'; +import 'package:okane/ui/widgets/image_wrapper.dart'; + +class LoanListPage extends StatelessWidget { + const LoanListPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Stack( + children: [ + ListView.builder( + itemCount: state.loans.length, + itemBuilder: (context, index) { + final item = state.loans[index]; + final beneficiary = item.beneficiary.value!; + return ListTile( + leading: ImageWrapper( + title: beneficiary.name, + path: beneficiary.imagePath, + ), + onTap: () { + GetIt.I.get().setActiveLoan(item); + }, + trailing: IconButton( + onPressed: () async { + final result = await confirm( + context, + "Delete Loan", + "Are you sure you want to delete the loan?", + ); + if (!result) { + return; + } + + await deleteLoan(item); + }, + icon: Icon(Icons.delete, color: Colors.red), + ), + title: Text(beneficiary.name), + ); + }, + ), + Positioned( + right: 16, + bottom: 16, + child: FloatingActionButton( + child: Icon(Icons.add), + onPressed: () { + showDialogOrModal( + context: context, + builder: + (_) => AddLoanPopup( + onDone: () { + Navigator.of(context).pop(); + }, + ), + ); + }, + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/ui/state/core.dart b/lib/ui/state/core.dart index 1f777ee..2b94bbd 100644 --- a/lib/ui/state/core.dart +++ b/lib/ui/state/core.dart @@ -5,6 +5,7 @@ import 'package:okane/database/collections/account.dart'; import 'package:okane/database/collections/beneficiary.dart'; import 'package:okane/database/collections/budget.dart'; import 'package:okane/database/collections/expense_category.dart'; +import 'package:okane/database/collections/loan.dart'; import 'package:okane/database/collections/recurrent.dart'; import 'package:okane/database/collections/template.dart'; import 'package:okane/database/collections/transaction.dart'; @@ -27,6 +28,8 @@ abstract class CoreState with _$CoreState { @Default([]) List expenseCategories, @Default([]) List budgets, @Default(null) Budget? activeBudget, + @Default([]) List loans, + @Default(null) Loan? activeLoan, @Default(false) bool isDeletingAccount, }) = _CoreState; } @@ -41,6 +44,7 @@ class CoreCubit extends Cubit { StreamSubscription? _beneficiariesStreamSubscription; StreamSubscription? _expenseCategoryStreamSubscription; StreamSubscription? _budgetsStreamSubscription; + StreamSubscription? _loanStreamSubscription; void setupAccountStream() { _accountsStreamSubscription?.cancel(); @@ -115,6 +119,10 @@ class CoreCubit extends Cubit { ) async { emit(state.copyWith(budgets: await db.getBudgets(activeAccount!))); }); + _loanStreamSubscription?.cancel(); + _loanStreamSubscription = db.watchLoans().listen((_) async { + emit(state.copyWith(loans: await db.getLoans())); + }); } void cancelStreams() { @@ -125,6 +133,7 @@ class CoreCubit extends Cubit { _expenseCategoryStreamSubscription?.cancel(); _transactionsStreamSubscription?.cancel(); _transactionTemplatesStreamSubcription?.cancel(); + _loanStreamSubscription?.cancel(); } Future init() async { @@ -140,6 +149,7 @@ class CoreCubit extends Cubit { recurringTransactions: await db.getRecurringTransactions(account), expenseCategories: await db.getExpenseCategories(), budgets: await db.getBudgets(account), + loans: await db.getLoans(), ), ); @@ -167,6 +177,7 @@ class CoreCubit extends Cubit { budgets: await db.getBudgets(account), activeBudget: null, activeTransaction: null, + activeLoan: null, ), ); setupStreams(account); @@ -199,6 +210,10 @@ class CoreCubit extends Cubit { await init(); } + void setActiveLoan(Loan loan) { + emit(state.copyWith(activeLoan: loan)); + } + Account? get activeAccount => state.activeAccountIndex == null ? null diff --git a/lib/ui/state/core.freezed.dart b/lib/ui/state/core.freezed.dart index 765897a..6b19976 100644 --- a/lib/ui/state/core.freezed.dart +++ b/lib/ui/state/core.freezed.dart @@ -31,6 +31,8 @@ mixin _$CoreState { throw _privateConstructorUsedError; List get budgets => throw _privateConstructorUsedError; Budget? get activeBudget => throw _privateConstructorUsedError; + List get loans => throw _privateConstructorUsedError; + Loan? get activeLoan => throw _privateConstructorUsedError; bool get isDeletingAccount => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -55,6 +57,8 @@ abstract class $CoreStateCopyWith<$Res> { List expenseCategories, List budgets, Budget? activeBudget, + List loans, + Loan? activeLoan, bool isDeletingAccount, }); } @@ -83,6 +87,8 @@ class _$CoreStateCopyWithImpl<$Res, $Val extends CoreState> Object? expenseCategories = null, Object? budgets = null, Object? activeBudget = freezed, + Object? loans = null, + Object? activeLoan = freezed, Object? isDeletingAccount = null, }) { return _then( @@ -142,6 +148,16 @@ class _$CoreStateCopyWithImpl<$Res, $Val extends CoreState> ? _value.activeBudget : activeBudget // ignore: cast_nullable_to_non_nullable as Budget?, + loans: + null == loans + ? _value.loans + : loans // ignore: cast_nullable_to_non_nullable + as List, + activeLoan: + freezed == activeLoan + ? _value.activeLoan + : activeLoan // ignore: cast_nullable_to_non_nullable + as Loan?, isDeletingAccount: null == isDeletingAccount ? _value.isDeletingAccount @@ -174,6 +190,8 @@ abstract class _$$CoreStateImplCopyWith<$Res> List expenseCategories, List budgets, Budget? activeBudget, + List loans, + Loan? activeLoan, bool isDeletingAccount, }); } @@ -201,6 +219,8 @@ class __$$CoreStateImplCopyWithImpl<$Res> Object? expenseCategories = null, Object? budgets = null, Object? activeBudget = freezed, + Object? loans = null, + Object? activeLoan = freezed, Object? isDeletingAccount = null, }) { return _then( @@ -260,6 +280,16 @@ class __$$CoreStateImplCopyWithImpl<$Res> ? _value.activeBudget : activeBudget // ignore: cast_nullable_to_non_nullable as Budget?, + loans: + null == loans + ? _value._loans + : loans // ignore: cast_nullable_to_non_nullable + as List, + activeLoan: + freezed == activeLoan + ? _value.activeLoan + : activeLoan // ignore: cast_nullable_to_non_nullable + as Loan?, isDeletingAccount: null == isDeletingAccount ? _value.isDeletingAccount @@ -285,6 +315,8 @@ class _$CoreStateImpl implements _CoreState { final List expenseCategories = const [], final List budgets = const [], this.activeBudget = null, + final List loans = const [], + this.activeLoan = null, this.isDeletingAccount = false, }) : _accounts = accounts, _recurringTransactions = recurringTransactions, @@ -292,7 +324,8 @@ class _$CoreStateImpl implements _CoreState { _transactionTemplates = transactionTemplates, _beneficiaries = beneficiaries, _expenseCategories = expenseCategories, - _budgets = budgets; + _budgets = budgets, + _loans = loans; @override @JsonKey() @@ -371,13 +404,25 @@ class _$CoreStateImpl implements _CoreState { @override @JsonKey() final Budget? activeBudget; + final List _loans; + @override + @JsonKey() + List get loans { + if (_loans is EqualUnmodifiableListView) return _loans; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_loans); + } + + @override + @JsonKey() + final Loan? activeLoan; @override @JsonKey() final bool isDeletingAccount; @override String toString() { - return 'CoreState(activePage: $activePage, activeAccountIndex: $activeAccountIndex, activeTransaction: $activeTransaction, accounts: $accounts, recurringTransactions: $recurringTransactions, transactions: $transactions, transactionTemplates: $transactionTemplates, beneficiaries: $beneficiaries, expenseCategories: $expenseCategories, budgets: $budgets, activeBudget: $activeBudget, isDeletingAccount: $isDeletingAccount)'; + return 'CoreState(activePage: $activePage, activeAccountIndex: $activeAccountIndex, activeTransaction: $activeTransaction, accounts: $accounts, recurringTransactions: $recurringTransactions, transactions: $transactions, transactionTemplates: $transactionTemplates, beneficiaries: $beneficiaries, expenseCategories: $expenseCategories, budgets: $budgets, activeBudget: $activeBudget, loans: $loans, activeLoan: $activeLoan, isDeletingAccount: $isDeletingAccount)'; } @override @@ -415,6 +460,9 @@ class _$CoreStateImpl implements _CoreState { const DeepCollectionEquality().equals(other._budgets, _budgets) && (identical(other.activeBudget, activeBudget) || other.activeBudget == activeBudget) && + const DeepCollectionEquality().equals(other._loans, _loans) && + (identical(other.activeLoan, activeLoan) || + other.activeLoan == activeLoan) && (identical(other.isDeletingAccount, isDeletingAccount) || other.isDeletingAccount == isDeletingAccount)); } @@ -433,6 +481,8 @@ class _$CoreStateImpl implements _CoreState { const DeepCollectionEquality().hash(_expenseCategories), const DeepCollectionEquality().hash(_budgets), activeBudget, + const DeepCollectionEquality().hash(_loans), + activeLoan, isDeletingAccount, ); @@ -456,6 +506,8 @@ abstract class _CoreState implements CoreState { final List expenseCategories, final List budgets, final Budget? activeBudget, + final List loans, + final Loan? activeLoan, final bool isDeletingAccount, }) = _$CoreStateImpl; @@ -482,6 +534,10 @@ abstract class _CoreState implements CoreState { @override Budget? get activeBudget; @override + List get loans; + @override + Loan? get activeLoan; + @override bool get isDeletingAccount; @override @JsonKey(ignore: true) diff --git a/pubspec.yaml b/pubspec.yaml index 9086d92..d993ee5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: okane description: "A cross-platform finance tracker." publish_to: 'none' -version: 1.0.0+1 +version: 1.0.0+2 environment: sdk: ^3.7.0