Allow including other spendings in a budget

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

View File

@ -29,6 +29,8 @@ class Budget {
late double income; late double income;
late bool includeOtherSpendings;
final account = IsarLink<Account>(); final account = IsarLink<Account>();
final items = IsarLinks<BudgetItem>(); final items = IsarLinks<BudgetItem>();

View File

@ -394,18 +394,23 @@ const BudgetSchema = CollectionSchema(
name: r'Budget', name: r'Budget',
id: -3383598594604670326, id: -3383598594604670326,
properties: { properties: {
r'income': PropertySchema( r'includeOtherSpendings': PropertySchema(
id: 0, id: 0,
name: r'includeOtherSpendings',
type: IsarType.bool,
),
r'income': PropertySchema(
id: 1,
name: r'income', name: r'income',
type: IsarType.double, type: IsarType.double,
), ),
r'name': PropertySchema( r'name': PropertySchema(
id: 1, id: 2,
name: r'name', name: r'name',
type: IsarType.string, type: IsarType.string,
), ),
r'period': PropertySchema( r'period': PropertySchema(
id: 2, id: 3,
name: r'period', name: r'period',
type: IsarType.byte, type: IsarType.byte,
enumMap: _BudgetperiodEnumValueMap, enumMap: _BudgetperiodEnumValueMap,
@ -454,9 +459,10 @@ void _budgetSerialize(
List<int> offsets, List<int> offsets,
Map<Type, List<int>> allOffsets, Map<Type, List<int>> allOffsets,
) { ) {
writer.writeDouble(offsets[0], object.income); writer.writeBool(offsets[0], object.includeOtherSpendings);
writer.writeString(offsets[1], object.name); writer.writeDouble(offsets[1], object.income);
writer.writeByte(offsets[2], object.period.index); writer.writeString(offsets[2], object.name);
writer.writeByte(offsets[3], object.period.index);
} }
Budget _budgetDeserialize( Budget _budgetDeserialize(
@ -467,10 +473,11 @@ Budget _budgetDeserialize(
) { ) {
final object = Budget(); final object = Budget();
object.id = id; object.id = id;
object.income = reader.readDouble(offsets[0]); object.includeOtherSpendings = reader.readBool(offsets[0]);
object.name = reader.readString(offsets[1]); object.income = reader.readDouble(offsets[1]);
object.name = reader.readString(offsets[2]);
object.period = object.period =
_BudgetperiodValueEnumMap[reader.readByteOrNull(offsets[2])] ?? _BudgetperiodValueEnumMap[reader.readByteOrNull(offsets[3])] ??
BudgetPeriod.month; BudgetPeriod.month;
return object; return object;
} }
@ -483,10 +490,12 @@ P _budgetDeserializeProp<P>(
) { ) {
switch (propertyId) { switch (propertyId) {
case 0: case 0:
return (reader.readDouble(offset)) as P; return (reader.readBool(offset)) as P;
case 1: case 1:
return (reader.readString(offset)) as P; return (reader.readDouble(offset)) as P;
case 2: case 2:
return (reader.readString(offset)) as P;
case 3:
return (_BudgetperiodValueEnumMap[reader.readByteOrNull(offset)] ?? return (_BudgetperiodValueEnumMap[reader.readByteOrNull(offset)] ??
BudgetPeriod.month) as P; BudgetPeriod.month) as P;
default: default:
@ -643,6 +652,16 @@ extension BudgetQueryFilter on QueryBuilder<Budget, Budget, QFilterCondition> {
}); });
} }
QueryBuilder<Budget, Budget, QAfterFilterCondition>
includeOtherSpendingsEqualTo(bool value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'includeOtherSpendings',
value: value,
));
});
}
QueryBuilder<Budget, Budget, QAfterFilterCondition> incomeEqualTo( QueryBuilder<Budget, Budget, QAfterFilterCondition> incomeEqualTo(
double value, { double value, {
double epsilon = Query.epsilon, double epsilon = Query.epsilon,
@ -962,6 +981,18 @@ extension BudgetQueryLinks on QueryBuilder<Budget, Budget, QFilterCondition> {
} }
extension BudgetQuerySortBy on QueryBuilder<Budget, Budget, QSortBy> { extension BudgetQuerySortBy on QueryBuilder<Budget, Budget, QSortBy> {
QueryBuilder<Budget, Budget, QAfterSortBy> sortByIncludeOtherSpendings() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'includeOtherSpendings', Sort.asc);
});
}
QueryBuilder<Budget, Budget, QAfterSortBy> sortByIncludeOtherSpendingsDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'includeOtherSpendings', Sort.desc);
});
}
QueryBuilder<Budget, Budget, QAfterSortBy> sortByIncome() { QueryBuilder<Budget, Budget, QAfterSortBy> sortByIncome() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'income', Sort.asc); return query.addSortBy(r'income', Sort.asc);
@ -1012,6 +1043,18 @@ extension BudgetQuerySortThenBy on QueryBuilder<Budget, Budget, QSortThenBy> {
}); });
} }
QueryBuilder<Budget, Budget, QAfterSortBy> thenByIncludeOtherSpendings() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'includeOtherSpendings', Sort.asc);
});
}
QueryBuilder<Budget, Budget, QAfterSortBy> thenByIncludeOtherSpendingsDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'includeOtherSpendings', Sort.desc);
});
}
QueryBuilder<Budget, Budget, QAfterSortBy> thenByIncome() { QueryBuilder<Budget, Budget, QAfterSortBy> thenByIncome() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'income', Sort.asc); return query.addSortBy(r'income', Sort.asc);
@ -1050,6 +1093,12 @@ extension BudgetQuerySortThenBy on QueryBuilder<Budget, Budget, QSortThenBy> {
} }
extension BudgetQueryWhereDistinct on QueryBuilder<Budget, Budget, QDistinct> { extension BudgetQueryWhereDistinct on QueryBuilder<Budget, Budget, QDistinct> {
QueryBuilder<Budget, Budget, QDistinct> distinctByIncludeOtherSpendings() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'includeOtherSpendings');
});
}
QueryBuilder<Budget, Budget, QDistinct> distinctByIncome() { QueryBuilder<Budget, Budget, QDistinct> distinctByIncome() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'income'); return query.addDistinctBy(r'income');
@ -1077,6 +1126,12 @@ extension BudgetQueryProperty on QueryBuilder<Budget, Budget, QQueryProperty> {
}); });
} }
QueryBuilder<Budget, bool, QQueryOperations> includeOtherSpendingsProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'includeOtherSpendings');
});
}
QueryBuilder<Budget, double, QQueryOperations> incomeProperty() { QueryBuilder<Budget, double, QQueryOperations> incomeProperty() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'income'); return query.addPropertyName(r'income');

View File

@ -27,22 +27,24 @@ class MyApp extends StatelessWidget {
// This widget is the root of your application. // This widget is the root of your application.
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiBlocProvider( return SafeArea(
providers: [ child: MultiBlocProvider(
BlocProvider<CoreCubit>(create: (_) => GetIt.I.get<CoreCubit>()), providers: [
], BlocProvider<CoreCubit>(create: (_) => GetIt.I.get<CoreCubit>()),
child: MaterialApp( ],
title: 'Flutter Demo', child: MaterialApp(
theme: ThemeData( title: 'Flutter Demo',
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
home: const MyHomePage(),
onGenerateRoute:
(settings) => switch (settings.name) {
"/transactions/details" => TransactionDetailsPage.mobileRoute,
"/budgets/details" => BudgetDetailsPage.mobileRoute,
_ => MaterialPageRoute<void>(builder: (_) => Text("Unknown!!")),
},
), ),
home: const MyHomePage(),
onGenerateRoute:
(settings) => switch (settings.name) {
"/transactions/details" => TransactionDetailsPage.mobileRoute,
"/budgets/details" => BudgetDetailsPage.mobileRoute,
_ => MaterialPageRoute<void>(builder: (_) => Text("Unknown!!")),
},
), ),
); );
} }

View File

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

View File

@ -33,7 +33,7 @@ class BudgetDetailsPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: Column( body: ListView(
children: [ children: [
if (isPage) if (isPage)
SizedBox( 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>{}; final spending = <String, double>{};
for (final t in snapshot.data!) { for (final t in snapshot.data!) {
if (t.expenseCategory.value == null) { String categoryName;
continue; if (!categories.contains(t.expenseCategory.value?.name)) {
if (!state.activeBudget!.includeOtherSpendings) {
continue;
}
categoryName = "Other";
} else {
categoryName = t.expenseCategory.value!.name;
} }
spending.update( spending.update(
t.expenseCategory.value!.name, categoryName,
(value) => value + t.amount, (value) => value + t.amount,
ifAbsent: () => 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 final budgetTotal = state.activeBudget!.items
.map((i) => i.amount) .map((i) => i.amount)
.reduce((acc, val) => acc + val); .reduce((acc, val) => acc + val);
@ -179,24 +193,26 @@ class BudgetDetailsPage extends StatelessWidget {
child: Card( child: Card(
child: Column( child: Column(
crossAxisAlignment: crossAxisAlignment:
CrossAxisAlignment.center, CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( Text(
"Budget left", "Budget left",
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: style:
Theme.of( Theme.of(
context, context,
).textTheme.titleLarge, ).textTheme.titleLarge,
), ),
Text( Text(
formatCurrency(budgetTotal + totalSpent), formatCurrency(
budgetTotal + totalSpent,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: style:
Theme.of( Theme.of(
context, context,
).textTheme.bodyLarge, ).textTheme.bodyLarge,
), ),
], ],
), ),
@ -239,54 +255,97 @@ class BudgetDetailsPage extends StatelessWidget {
), ),
), ),
Padding( Wrap(
padding: EdgeInsets.all(8), children: [
child: SizedBox( Padding(
child: Card( padding: EdgeInsets.all(8),
child: Column( child: SizedBox(
crossAxisAlignment: CrossAxisAlignment.center, child: Card(
children: [ child: Column(
Padding( crossAxisAlignment:
padding: EdgeInsets.only(top: 8), CrossAxisAlignment.center,
child: Text( children: [
"Budget breakdown", Padding(
style: padding: EdgeInsets.only(top: 8),
Theme.of(context).textTheme.titleLarge, child: Text(
textAlign: TextAlign.center, "Budget breakdown",
), style:
), Theme.of(
Row( context,
mainAxisSize: MainAxisSize.min, ).textTheme.titleLarge,
children: [ textAlign: TextAlign.center,
Padding( ),
padding: EdgeInsets.all(16), ),
child: SizedBox( Row(
width: 150, mainAxisSize: MainAxisSize.min,
height: 150, children: [
child: AspectRatio( Padding(
aspectRatio: 1, padding: EdgeInsets.all(16),
child: PieChart( child: SizedBox(
PieChartData( width: 150,
borderData: FlBorderData( height: 150,
show: false, 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 state.activeBudget!.items
.map( .map(
( (i) => LegendItem(
i, text:
) => PieChartSectionData( i
value: i.amount.abs(), .expenseCategory
title: formatCurrency( .value!
i.amount.abs(), .name,
),
titleStyle: TextStyle(
fontWeight:
FontWeight.bold,
),
radius: 40,
color: colorHash( color: colorHash(
i i
.expenseCategory .expenseCategory
@ -298,44 +357,110 @@ class BudgetDetailsPage extends StatelessWidget {
.toList(), .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,
), ),
), ),
), Row(
mainAxisSize: MainAxisSize.min,
Padding( children: [
padding: EdgeInsets.symmetric( Padding(
horizontal: 8, padding: EdgeInsets.all(16),
), child: SizedBox(
child: Column( width: 150,
crossAxisAlignment: height: 150,
CrossAxisAlignment.start, child: AspectRatio(
children: aspectRatio: 1,
state.activeBudget!.items child: PieChart(
.map( PieChartData(
(i) => LegendItem( borderData: FlBorderData(
text: show: false,
i
.expenseCategory
.value!
.name,
color: colorHash(
i
.expenseCategory
.value!
.name,
), ),
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(
padding: EdgeInsets.all(8), 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:get_it/get_it.dart';
import 'package:okane/screen.dart'; import 'package:okane/screen.dart';
import 'package:okane/ui/pages/budgets/add_budget.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/state/core.dart';
import 'package:okane/ui/utils.dart'; import 'package:okane/ui/utils.dart';
@ -42,14 +43,30 @@ class BudgetListPage extends StatelessWidget {
(context, index) => ListTile( (context, index) => ListTile(
title: Text(state.budgets[index].name), title: Text(state.budgets[index].name),
selected: state.budgets[index] == state.activeBudget, selected: state.budgets[index] == state.activeBudget,
trailing: IconButton( trailing: Row(
icon: Icon( mainAxisSize: MainAxisSize.min,
Icons.delete, children: [
color: Colors.redAccent, IconButton(
), icon: Icon(Icons.edit),
onPressed: () { onPressed: () {
// TODO showDialogOrModal(
}, context: context,
builder: (_) => EditBudgetPopup(
budget: state.activeBudget!,
onDone: () {
Navigator.of(context).pop();
},
),
);
},
),
IconButton(
icon: Icon(Icons.delete, color: Colors.redAccent),
onPressed: () {
// TODO
},
),
],
), ),
onTap: () { onTap: () {
GetIt.I.get<CoreCubit>().setActiveBudget( 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 { class AccountIndicator extends StatelessWidget {
final String accountName; final String accountName;
const AccountIndicator({super.key, required this.accountName}); final Widget? trailing;
const AccountIndicator({super.key, this.trailing, required this.accountName});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -12,12 +14,18 @@ class AccountIndicator extends StatelessWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
Text( Padding(
accountName, padding: EdgeInsets.all(8),
style: Theme.of(context).textTheme.titleLarge, child: Text(
accountName,
style: Theme.of(context).textTheme.titleLarge,
),
), ),
const Spacer(),
if (trailing != null)
trailing!,
], ],
) ),
); );
} }
} }