Allow including other spendings in a budget

This commit is contained in:
2025-05-04 23:19:33 +02:00
parent abc44eddc2
commit 3cd6fd6759
8 changed files with 415 additions and 128 deletions

View File

@@ -50,6 +50,7 @@ class AddBudgetState extends State<AddBudgetPopup> {
final budget = Budget()
..name = _budgetNameEditController.text
..period = BudgetPeriod.month
..includeOtherSpendings = false
..income = double.parse(_budgetIncomeEditController.text)
..account.value = bloc.activeAccount!;
await upsertBudget(budget);

View File

@@ -33,7 +33,7 @@ class BudgetDetailsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
body: ListView(
children: [
if (isPage)
SizedBox(
@@ -113,20 +113,34 @@ class BudgetDetailsPage extends StatelessWidget {
);
}
final categories =
state.activeBudget!.items
.map((i) => i.expenseCategory.value!.name)
.toList();
final spending = <String, double>{};
for (final t in snapshot.data!) {
if (t.expenseCategory.value == null) {
continue;
String categoryName;
if (!categories.contains(t.expenseCategory.value?.name)) {
if (!state.activeBudget!.includeOtherSpendings) {
continue;
}
categoryName = "Other";
} else {
categoryName = t.expenseCategory.value!.name;
}
spending.update(
t.expenseCategory.value!.name,
categoryName,
(value) => value + t.amount,
ifAbsent: () => t.amount,
);
}
final totalSpent = spending.isEmpty ? 0 : spending.values.reduce((acc, val) => acc + val);
final totalSpent =
spending.isEmpty
? 0
: spending.values.reduce((acc, val) => acc + val);
final budgetTotal = state.activeBudget!.items
.map((i) => i.amount)
.reduce((acc, val) => acc + val);
@@ -179,24 +193,26 @@ class BudgetDetailsPage extends StatelessWidget {
child: Card(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.center,
CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Budget left",
textAlign: TextAlign.center,
style:
Theme.of(
context,
).textTheme.titleLarge,
Theme.of(
context,
).textTheme.titleLarge,
),
Text(
formatCurrency(budgetTotal + totalSpent),
formatCurrency(
budgetTotal + totalSpent,
),
textAlign: TextAlign.center,
style:
Theme.of(
context,
).textTheme.bodyLarge,
Theme.of(
context,
).textTheme.bodyLarge,
),
],
),
@@ -239,54 +255,97 @@ class BudgetDetailsPage extends StatelessWidget {
),
),
Padding(
padding: EdgeInsets.all(8),
child: SizedBox(
child: Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
"Budget breakdown",
style:
Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.all(16),
child: SizedBox(
width: 150,
height: 150,
child: AspectRatio(
aspectRatio: 1,
child: PieChart(
PieChartData(
borderData: FlBorderData(
show: false,
Wrap(
children: [
Padding(
padding: EdgeInsets.all(8),
child: SizedBox(
child: Card(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
"Budget breakdown",
style:
Theme.of(
context,
).textTheme.titleLarge,
textAlign: TextAlign.center,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.all(16),
child: SizedBox(
width: 150,
height: 150,
child: AspectRatio(
aspectRatio: 1,
child: PieChart(
PieChartData(
borderData: FlBorderData(
show: false,
),
sectionsSpace: 0,
centerSpaceRadius: 35,
sections:
state
.activeBudget!
.items
.map(
(
i,
) => PieChartSectionData(
value:
i.amount
.abs(),
title:
formatCurrency(
i.amount
.abs(),
),
titleStyle: TextStyle(
fontWeight:
FontWeight
.bold,
),
radius: 40,
color: colorHash(
i
.expenseCategory
.value!
.name,
),
),
)
.toList(),
),
),
),
sectionsSpace: 0,
centerSpaceRadius: 35,
sections:
),
),
Padding(
padding: EdgeInsets.symmetric(
horizontal: 8,
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children:
state.activeBudget!.items
.map(
(
i,
) => PieChartSectionData(
value: i.amount.abs(),
title: formatCurrency(
i.amount.abs(),
),
titleStyle: TextStyle(
fontWeight:
FontWeight.bold,
),
radius: 40,
(i) => LegendItem(
text:
i
.expenseCategory
.value!
.name,
color: colorHash(
i
.expenseCategory
@@ -298,44 +357,110 @@ class BudgetDetailsPage extends StatelessWidget {
.toList(),
),
),
],
),
],
),
),
),
),
Padding(
padding: EdgeInsets.all(8),
child: SizedBox(
child: Card(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
"Spending breakdown",
style:
Theme.of(
context,
).textTheme.titleLarge,
textAlign: TextAlign.center,
),
),
),
Padding(
padding: EdgeInsets.symmetric(
horizontal: 8,
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children:
state.activeBudget!.items
.map(
(i) => LegendItem(
text:
i
.expenseCategory
.value!
.name,
color: colorHash(
i
.expenseCategory
.value!
.name,
Row(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.all(16),
child: SizedBox(
width: 150,
height: 150,
child: AspectRatio(
aspectRatio: 1,
child: PieChart(
PieChartData(
borderData: FlBorderData(
show: false,
),
sectionsSpace: 0,
centerSpaceRadius: 35,
sections:
spending.entries
.map(
(
e,
) => PieChartSectionData(
value:
e.value
.abs(),
title:
formatCurrency(
e.value
.abs(),
),
titleStyle: TextStyle(
fontWeight:
FontWeight
.bold,
),
radius: 40,
color:
colorHash(
e.key,
),
),
)
.toList(),
),
)
.toList(),
),
),
),
),
Padding(
padding: EdgeInsets.symmetric(
horizontal: 8,
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children:
spending.keys
.map(
(k) => LegendItem(
text: k,
color: colorHash(k),
),
)
.toList(),
),
),
],
),
),
],
],
),
),
],
),
),
),
],
),
),
Padding(
padding: EdgeInsets.all(8),

View File

@@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:okane/screen.dart';
import 'package:okane/ui/pages/budgets/add_budget.dart';
import 'package:okane/ui/pages/budgets/edit_budget.dart';
import 'package:okane/ui/state/core.dart';
import 'package:okane/ui/utils.dart';
@@ -42,14 +43,30 @@ class BudgetListPage extends StatelessWidget {
(context, index) => ListTile(
title: Text(state.budgets[index].name),
selected: state.budgets[index] == state.activeBudget,
trailing: IconButton(
icon: Icon(
Icons.delete,
color: Colors.redAccent,
),
onPressed: () {
// TODO
},
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.edit),
onPressed: () {
showDialogOrModal(
context: context,
builder: (_) => EditBudgetPopup(
budget: state.activeBudget!,
onDone: () {
Navigator.of(context).pop();
},
),
);
},
),
IconButton(
icon: Icon(Icons.delete, color: Colors.redAccent),
onPressed: () {
// TODO
},
),
],
),
onTap: () {
GetIt.I.get<CoreCubit>().setActiveBudget(

View File

@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:okane/database/collections/budget.dart';
import 'package:okane/database/database.dart';
class EditBudgetPopup extends StatefulWidget {
final Budget budget;
final VoidCallback onDone;
const EditBudgetPopup({required this.budget, required this.onDone, super.key});
@override
EditBudgetState createState() => EditBudgetState();
}
class EditBudgetState extends State<EditBudgetPopup> {
final _budgetNameEditController = TextEditingController();
late bool _includeOtherSpendings;
@override
void initState() {
super.initState();
_budgetNameEditController.text = widget.budget.name;
_includeOtherSpendings = widget.budget.includeOtherSpendings;
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
decoration: InputDecoration(
hintText: "Name",
),
controller: _budgetNameEditController,
),
Row(
children: [
Text("Include other spendings"),
Switch(
value: _includeOtherSpendings,
onChanged: (value) {
setState(() => _includeOtherSpendings = value);
},
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: () async {
if (_budgetNameEditController.text.isEmpty) {
return;
}
if (_budgetNameEditController.text == widget.budget.name && _includeOtherSpendings == widget.budget.includeOtherSpendings) {
widget.onDone();
return;
}
widget.budget
..name = _budgetNameEditController.text
..includeOtherSpendings = _includeOtherSpendings;
await upsertBudget(widget.budget);
widget.onDone();
},
child: Text("Save"),
),
],
),
],
);
}
}

View File

@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
class AccountIndicator extends StatelessWidget {
final String accountName;
const AccountIndicator({super.key, required this.accountName});
final Widget? trailing;
const AccountIndicator({super.key, this.trailing, required this.accountName});
@override
Widget build(BuildContext context) {
@@ -12,12 +14,18 @@ class AccountIndicator extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
accountName,
style: Theme.of(context).textTheme.titleLarge,
Padding(
padding: EdgeInsets.all(8),
child: Text(
accountName,
style: Theme.of(context).textTheme.titleLarge,
),
),
const Spacer(),
if (trailing != null)
trailing!,
],
)
),
);
}
}
}