Add budgets
This commit is contained in:
65
lib/ui/pages/budgets/add_budget.dart
Normal file
65
lib/ui/pages/budgets/add_budget.dart
Normal 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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
85
lib/ui/pages/budgets/add_budget_item.dart
Normal file
85
lib/ui/pages/budgets/add_budget_item.dart
Normal 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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
374
lib/ui/pages/budgets/budget_details.dart
Normal file
374
lib/ui/pages/budgets/budget_details.dart
Normal 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: () {},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
67
lib/ui/pages/budgets/budgets.dart
Normal file
67
lib/ui/pages/budgets/budgets.dart
Normal 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");
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user