Add a loan feature

This commit is contained in:
2025-05-12 21:02:51 +02:00
parent e0fba11f25
commit c5aa165424
15 changed files with 1383 additions and 5 deletions

View File

@@ -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<AccountListPage> {
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: UpcomingTransactionsCard(),
),
Padding(padding: EdgeInsets.all(8), child: BreakdownCard()),
Padding(padding: EdgeInsets.all(8), child: TotalLoanCard()),
],
),
],

View File

@@ -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<CoreCubit, CoreState>(
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,
);
},
),
),
);
},
);
}
}

View File

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

View File

@@ -68,7 +68,6 @@ class BudgetListPage extends StatelessWidget {
);
},
),
Positioned(
right: 16,
bottom: 16,

View File

@@ -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<AddLoanPopup> {
final TextEditingController _beneficiaryTextController =
TextEditingController();
SearchFieldListItem<Beneficiary>? _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<CoreCubit, CoreState>(
builder:
(context, state) => SearchField<Beneficiary>(
suggestions:
state.beneficiaries
.where((el) {
final bloc = GetIt.I.get<CoreCubit>();
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<CoreCubit>();
final loan =
Loan()..beneficiary.value = _selectedBeneficiary!.item;
await upsertLoan(loan);
widget.onDone();
},
child: Text(t.modals.add),
),
],
),
],
);
}
}

View File

@@ -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<AddLoanChangePopup> {
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),
),
),
],
);
}
}

View File

@@ -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<CoreCubit, CoreState>(
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!);
},
),
);
},
),
],
);
},
),
],
);
}
}

View File

@@ -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<CoreCubit, CoreState>(
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<CoreCubit>().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();
},
),
);
},
),
),
],
);
},
);
}
}