Add budgets

This commit is contained in:
2025-05-04 19:26:11 +02:00
parent 5cc0bba09a
commit eced6e6a6d
23 changed files with 3211 additions and 1507 deletions

View File

@@ -111,20 +111,6 @@ class AccountListPageState extends State<AccountListPage> {
child: UpcomingTransactionsCard(),
),
/*
BlocBuilder<CoreCubit, CoreState>(
builder:
(context, state) => Row(
children: [
Expanded(
child: Padding(
padding: EdgeInsets.all(16),
child: AccountBalanceGraphCard(),
),
),
],
),
),*/
Row(
children: [
Padding(padding: EdgeInsets.all(16), child: BreakdownCard()),

View File

@@ -28,15 +28,17 @@ class TotalBalanceCard extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("Total balance"),
Text(
"Total balance",
style: Theme.of(context).textTheme.titleLarge,
),
FutureBuilder(
future: _getTotalBalance(state.accounts),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Text("...");
}
return Text(formatCurrency(snapshot.data!));
return Text(
snapshot.hasData ? formatCurrency(snapshot.data!) : "...",
style: Theme.of(context).textTheme.bodyLarge,
);
},
),
],

View File

@@ -1,6 +1,4 @@
import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:okane/database/collections/recurrent.dart';
@@ -29,7 +27,12 @@ class UpcomingTransactionsCard extends StatelessWidget {
: upcomingRaw.sublist(0, min(upcomingRaw.length, 3));
final transactions =
upcoming.isEmpty
? [Text("No upcoming transactions")]
? [
Text(
"No upcoming transactions",
style: Theme.of(context).textTheme.bodyLarge,
),
]
: upcoming
.map(
(t) => ListTile(
@@ -59,7 +62,14 @@ class UpcomingTransactionsCard extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: <Widget>[Text("Upcoming Transactions")] + transactions,
children:
<Widget>[
Text(
"Upcoming Transactions",
style: Theme.of(context).textTheme.titleLarge,
),
] +
transactions,
),
),
);

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:okane/database/collections/budget.dart';
import 'package:okane/database/database.dart';
import 'package:okane/ui/state/core.dart';
class AddBudgetPopup extends StatefulWidget {
final VoidCallback onDone;
const AddBudgetPopup({super.key, required this.onDone});
@override
AddBudgetState createState() => AddBudgetState();
}
class AddBudgetState extends State<AddBudgetPopup> {
final _budgetNameEditController = TextEditingController();
final _budgetIncomeEditController = TextEditingController();
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
decoration: InputDecoration(
hintText: "Budget name",
),
controller: _budgetNameEditController,
),
TextField(
decoration: InputDecoration(
hintText: "Income",
),
controller: _budgetIncomeEditController,
keyboardType: TextInputType.numberWithOptions(signed: false, decimal: true),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: () async {
if (_budgetNameEditController.text.isEmpty || _budgetIncomeEditController.text.isEmpty) {
return;
}
final bloc = GetIt.I.get<CoreCubit>();
final budget = Budget()
..name = _budgetNameEditController.text
..period = BudgetPeriod.month
..income = double.parse(_budgetIncomeEditController.text)
..account.value = bloc.activeAccount!;
await upsertBudget(budget);
widget.onDone();
},
child: Text("Add"),
),
],
),
],
);
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:okane/database/collections/budget.dart';
import 'package:okane/database/collections/expense_category.dart';
import 'package:okane/database/database.dart';
import 'package:okane/ui/state/core.dart';
import 'package:okane/ui/utils.dart';
import 'package:okane/ui/widgets/add_expense_category.dart';
class AddBudgetItemPopup extends StatefulWidget {
final VoidCallback onDone;
final Budget budget;
const AddBudgetItemPopup({super.key, required this.onDone, required this.budget});
@override
AddBudgetItemState createState() => AddBudgetItemState();
}
class AddBudgetItemState extends State<AddBudgetItemPopup> {
final _budgetItemAmountEditController = TextEditingController();
ExpenseCategory? _expenseCategory;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text("Expense category"),
OutlinedButton(
onPressed: () async {
final category = await showDialogOrModal(
context: context,
builder: (_) => AddExpenseCategory(),
);
if (category == null) {
return;
}
setState(() => _expenseCategory = category);
},
child: Text(_expenseCategory?.name ?? "None"),
),
],
),
TextField(
decoration: InputDecoration(
hintText: "Amount",
),
controller: _budgetItemAmountEditController,
keyboardType: TextInputType.numberWithOptions(signed: false, decimal: true),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: () async {
if (_budgetItemAmountEditController.text.isEmpty || _expenseCategory == null) {
return;
}
if (widget.budget.items.where((i) => i.expenseCategory.value!.name == _expenseCategory!.name).firstOrNull != null) {
return;
}
final item = BudgetItem()
..expenseCategory.value = _expenseCategory
..amount = double.parse(_budgetItemAmountEditController.text);
await upsertBudgetItem(item);
widget.budget.items.add(item);
await upsertBudget(widget.budget);
widget.onDone();
},
child: Text("Add"),
),
],
),
],
);
}
}

View File

@@ -0,0 +1,374 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:okane/database/collections/budget.dart';
import 'package:okane/database/database.dart';
import 'package:okane/ui/pages/account/breakdown_card.dart';
import 'package:okane/ui/pages/budgets/add_budget_item.dart';
import 'package:okane/ui/state/core.dart';
import 'package:okane/ui/utils.dart';
class BudgetDetailsPage extends StatelessWidget {
final bool isPage;
const BudgetDetailsPage({this.isPage = false, super.key});
static MaterialPageRoute<void> get mobileRoute =>
MaterialPageRoute(builder: (_) => BudgetDetailsPage(isPage: true));
void _addBudgetItem(BuildContext context, CoreState state) {
showDialogOrModal(
context: context,
builder:
(_) => AddBudgetItemPopup(
budget: state.activeBudget!,
onDone: () {
Navigator.of(context).pop();
},
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
if (isPage)
SizedBox(
height: 50,
child: Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back),
onPressed: () {
Navigator.of(context).pop();
},
),
],
),
),
BlocBuilder<CoreCubit, CoreState>(
builder: (context, state) {
if (state.activeBudget == null) {
return Text("No budget selected");
}
if (state.activeBudget!.items.isEmpty) {
return Row(
children: [
Text("No budget items added"),
Padding(
padding: EdgeInsets.only(left: 16),
child: IconButton(
onPressed: () => _addBudgetItem(context, state),
icon: Icon(Icons.add),
),
),
],
);
}
final bloc = GetIt.I.get<CoreCubit>();
final today = DateTime.now();
return FutureBuilder(
future: getTransactionsInTimeframe(
bloc.activeAccount!,
today,
TransactionQueryDateOption.thisMonth,
),
builder: (context, snapshot) {
final daysLeft = switch (state.activeBudget!.period) {
BudgetPeriod.month =>
monthEnding(today).difference(today).inDays,
};
if (!snapshot.hasData) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"Budget items",
style: Theme.of(context).textTheme.titleMedium,
),
ListView.builder(
shrinkWrap: true,
itemCount: state.activeBudget!.items.length,
itemBuilder: (context, index) {
final item = state.activeBudget!.items.elementAt(
index,
);
final amount = formatCurrency(item.amount);
return ListTile(
title: Text(
"${item.expenseCategory.value!.name} ($amount)",
),
subtitle: Text("..."),
);
},
),
],
);
}
final spending = <String, double>{};
for (final t in snapshot.data!) {
if (t.expenseCategory.value == null) {
continue;
}
spending.update(
t.expenseCategory.value!.name,
(value) => value + t.amount,
ifAbsent: () => t.amount,
);
}
final budgetTotal = state.activeBudget!.items
.map((i) => i.amount)
.reduce((acc, val) => acc + val);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 100,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
Padding(
padding: EdgeInsets.all(8),
child: SizedBox(
height: 100,
width: 150,
child: Card(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Days left",
textAlign: TextAlign.center,
style:
Theme.of(
context,
).textTheme.titleLarge,
),
Text(
daysLeft.toString(),
textAlign: TextAlign.center,
style:
Theme.of(
context,
).textTheme.bodyLarge,
),
],
),
),
),
),
Padding(
padding: EdgeInsets.all(8),
child: SizedBox(
height: 100,
width: 150,
child: Card(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Budget total",
textAlign: TextAlign.center,
style:
Theme.of(
context,
).textTheme.titleLarge,
),
Text(
formatCurrency(budgetTotal),
textAlign: TextAlign.center,
style:
Theme.of(
context,
).textTheme.bodyLarge,
),
],
),
),
),
),
],
),
),
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(),
),
),
),
),
),
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,
),
),
)
.toList(),
),
),
],
),
],
),
),
),
),
Padding(
padding: EdgeInsets.all(8),
child: Row(
children: [
Text(
"Budget items",
style: Theme.of(context).textTheme.titleMedium,
),
Padding(
padding: EdgeInsets.only(left: 16),
child: IconButton(
icon: Icon(Icons.add),
onPressed: () => _addBudgetItem(context, state),
),
),
],
),
),
Padding(
padding: EdgeInsets.all(8),
child: ListView.builder(
shrinkWrap: true,
itemCount: state.activeBudget!.items.length,
itemBuilder: (context, index) {
final item = state.activeBudget!.items.elementAt(
index,
);
final amount = formatCurrency(item.amount);
final spent =
spending[item.expenseCategory.value!.name];
final left =
spent == null
? item.amount
: item.amount + spent;
final subtitleText =
left < 0
? "${formatCurrency(left)} over"
: "${formatCurrency(left)} left";
return ListTile(
title: Text(
"${item.expenseCategory.value!.name} ($amount)",
),
subtitle: Text(
subtitleText,
style: TextStyle(
color: left < 0 ? Colors.red : Colors.green,
),
),
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () {},
),
);
},
),
),
],
);
},
);
},
),
],
),
);
}
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
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/state/core.dart';
import 'package:okane/ui/utils.dart';
class BudgetListPage extends StatelessWidget {
const BudgetListPage({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<CoreCubit, CoreState>(
builder: (context, state) {
if (state.budgets.isEmpty) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text("No budgets"),
OutlinedButton(
onPressed: () {
showDialogOrModal(
context: context,
builder:
(_) => AddBudgetPopup(
onDone: () {
Navigator.of(context).pop();
},
),
);
},
child: Text("Add"),
),
],
);
}
return ListView.builder(
itemCount: state.budgets.length,
itemBuilder:
(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
},
),
onTap: () {
GetIt.I.get<CoreCubit>().setActiveBudget(
state.budgets[index],
);
if (getScreenSize(context) == ScreenSize.small) {
Navigator.of(context).pushNamed("/budgets/details");
}
},
),
);
},
);
}
}

View File

@@ -47,72 +47,89 @@ class TransactionDetailsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final widget = BlocBuilder<CoreCubit, CoreState>(
builder: (context, state) {
if (state.activeTransaction == null) {
return Text("No transaction selected");
}
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListView(
children: [
Row(
return Scaffold(
body: Column(
children: [
if (isPage)
SizedBox(
height: 50,
child: Row(
children: [
StreamBuilder(
stream: watchBeneficiaryObject(
state.activeTransaction!.beneficiary.value!.id,
),
builder: (context, snapshot) {
final obj =
snapshot.data ??
state.activeTransaction!.beneficiary.value!;
return ImageWrapper(
title: obj.name,
path: obj.imagePath,
onTap: () => _updateBeneficiaryIcon(obj),
);
},
),
Padding(
padding: EdgeInsets.only(left: 8),
child: Text(
state.activeTransaction!.beneficiary.value!.name,
),
),
Spacer(),
IconButton(
icon: Icon(Icons.edit),
icon: Icon(Icons.arrow_back),
onPressed: () {
// TODO: Implement
Navigator.of(context).pop();
},
),
],
),
Wrap(
spacing: 8,
children:
state.activeTransaction!.tags
.map((tag) => Chip(label: Text(tag)))
.toList(),
),
Row(
children: [
state.activeTransaction!.amount > 0
? Icon(Icons.add)
: Icon(Icons.remove),
Text(formatCurrency(state.activeTransaction!.amount)),
],
),
],
),
);
},
);
),
Expanded(
child: BlocBuilder<CoreCubit, CoreState>(
builder: (context, state) {
if (state.activeTransaction == null) {
return Text("No transaction selected");
}
if (isPage) {
return Scaffold(body: widget);
}
return widget;
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListView(
children: [
Row(
children: [
StreamBuilder(
stream: watchBeneficiaryObject(
state.activeTransaction!.beneficiary.value!.id,
),
builder: (context, snapshot) {
final obj =
snapshot.data ??
state.activeTransaction!.beneficiary.value!;
return ImageWrapper(
title: obj.name,
path: obj.imagePath,
onTap: () => _updateBeneficiaryIcon(obj),
);
},
),
Padding(
padding: EdgeInsets.only(left: 8),
child: Text(
state.activeTransaction!.beneficiary.value!.name,
),
),
Spacer(),
IconButton(
icon: Icon(Icons.edit),
onPressed: () {
// TODO: Implement
},
),
],
),
Wrap(
spacing: 8,
children:
state.activeTransaction!.tags
.map((tag) => Chip(label: Text(tag)))
.toList(),
),
Row(
children: [
state.activeTransaction!.amount > 0
? Icon(Icons.add)
: Icon(Icons.remove),
Text(formatCurrency(state.activeTransaction!.amount)),
],
),
],
),
);
},
),
),
],
),
);
}
}

View File

@@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:grouped_list/grouped_list.dart';
import 'package:okane/database/collections/transaction.dart';
import 'package:okane/database/database.dart';
import 'package:okane/screen.dart';
import 'package:okane/ui/pages/account/balance_graph_card.dart';
import 'package:okane/ui/state/core.dart';