diff --git a/lib/database/collections/budget.dart b/lib/database/collections/budget.dart index 71c7d1a..e97b0d1 100644 --- a/lib/database/collections/budget.dart +++ b/lib/database/collections/budget.dart @@ -29,6 +29,8 @@ class Budget { late double income; + late bool includeOtherSpendings; + final account = IsarLink(); final items = IsarLinks(); diff --git a/lib/database/collections/budget.g.dart b/lib/database/collections/budget.g.dart index 01fe1eb..d017b8e 100644 --- a/lib/database/collections/budget.g.dart +++ b/lib/database/collections/budget.g.dart @@ -394,18 +394,23 @@ const BudgetSchema = CollectionSchema( name: r'Budget', id: -3383598594604670326, properties: { - r'income': PropertySchema( + r'includeOtherSpendings': PropertySchema( id: 0, + name: r'includeOtherSpendings', + type: IsarType.bool, + ), + r'income': PropertySchema( + id: 1, name: r'income', type: IsarType.double, ), r'name': PropertySchema( - id: 1, + id: 2, name: r'name', type: IsarType.string, ), r'period': PropertySchema( - id: 2, + id: 3, name: r'period', type: IsarType.byte, enumMap: _BudgetperiodEnumValueMap, @@ -454,9 +459,10 @@ void _budgetSerialize( List offsets, Map> allOffsets, ) { - writer.writeDouble(offsets[0], object.income); - writer.writeString(offsets[1], object.name); - writer.writeByte(offsets[2], object.period.index); + writer.writeBool(offsets[0], object.includeOtherSpendings); + writer.writeDouble(offsets[1], object.income); + writer.writeString(offsets[2], object.name); + writer.writeByte(offsets[3], object.period.index); } Budget _budgetDeserialize( @@ -467,10 +473,11 @@ Budget _budgetDeserialize( ) { final object = Budget(); object.id = id; - object.income = reader.readDouble(offsets[0]); - object.name = reader.readString(offsets[1]); + object.includeOtherSpendings = reader.readBool(offsets[0]); + object.income = reader.readDouble(offsets[1]); + object.name = reader.readString(offsets[2]); object.period = - _BudgetperiodValueEnumMap[reader.readByteOrNull(offsets[2])] ?? + _BudgetperiodValueEnumMap[reader.readByteOrNull(offsets[3])] ?? BudgetPeriod.month; return object; } @@ -483,10 +490,12 @@ P _budgetDeserializeProp

( ) { switch (propertyId) { case 0: - return (reader.readDouble(offset)) as P; + return (reader.readBool(offset)) as P; case 1: - return (reader.readString(offset)) as P; + return (reader.readDouble(offset)) as P; case 2: + return (reader.readString(offset)) as P; + case 3: return (_BudgetperiodValueEnumMap[reader.readByteOrNull(offset)] ?? BudgetPeriod.month) as P; default: @@ -643,6 +652,16 @@ extension BudgetQueryFilter on QueryBuilder { }); } + QueryBuilder + includeOtherSpendingsEqualTo(bool value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'includeOtherSpendings', + value: value, + )); + }); + } + QueryBuilder incomeEqualTo( double value, { double epsilon = Query.epsilon, @@ -962,6 +981,18 @@ extension BudgetQueryLinks on QueryBuilder { } extension BudgetQuerySortBy on QueryBuilder { + QueryBuilder sortByIncludeOtherSpendings() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'includeOtherSpendings', Sort.asc); + }); + } + + QueryBuilder sortByIncludeOtherSpendingsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'includeOtherSpendings', Sort.desc); + }); + } + QueryBuilder sortByIncome() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'income', Sort.asc); @@ -1012,6 +1043,18 @@ extension BudgetQuerySortThenBy on QueryBuilder { }); } + QueryBuilder thenByIncludeOtherSpendings() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'includeOtherSpendings', Sort.asc); + }); + } + + QueryBuilder thenByIncludeOtherSpendingsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'includeOtherSpendings', Sort.desc); + }); + } + QueryBuilder thenByIncome() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'income', Sort.asc); @@ -1050,6 +1093,12 @@ extension BudgetQuerySortThenBy on QueryBuilder { } extension BudgetQueryWhereDistinct on QueryBuilder { + QueryBuilder distinctByIncludeOtherSpendings() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'includeOtherSpendings'); + }); + } + QueryBuilder distinctByIncome() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'income'); @@ -1077,6 +1126,12 @@ extension BudgetQueryProperty on QueryBuilder { }); } + QueryBuilder includeOtherSpendingsProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'includeOtherSpendings'); + }); + } + QueryBuilder incomeProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'income'); diff --git a/lib/main.dart b/lib/main.dart index 373562a..056a03b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,22 +27,24 @@ class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider(create: (_) => GetIt.I.get()), - ], - child: MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + return SafeArea( + child: MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => GetIt.I.get()), + ], + child: MaterialApp( + title: 'Flutter Demo', + 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(builder: (_) => Text("Unknown!!")), + }, ), - home: const MyHomePage(), - onGenerateRoute: - (settings) => switch (settings.name) { - "/transactions/details" => TransactionDetailsPage.mobileRoute, - "/budgets/details" => BudgetDetailsPage.mobileRoute, - _ => MaterialPageRoute(builder: (_) => Text("Unknown!!")), - }, ), ); } diff --git a/lib/ui/pages/budgets/add_budget.dart b/lib/ui/pages/budgets/add_budget.dart index 5e36ccb..5c0e0a9 100644 --- a/lib/ui/pages/budgets/add_budget.dart +++ b/lib/ui/pages/budgets/add_budget.dart @@ -50,6 +50,7 @@ class AddBudgetState extends State { final budget = Budget() ..name = _budgetNameEditController.text ..period = BudgetPeriod.month + ..includeOtherSpendings = false ..income = double.parse(_budgetIncomeEditController.text) ..account.value = bloc.activeAccount!; await upsertBudget(budget); diff --git a/lib/ui/pages/budgets/budget_details.dart b/lib/ui/pages/budgets/budget_details.dart index c197fce..46b84d2 100644 --- a/lib/ui/pages/budgets/budget_details.dart +++ b/lib/ui/pages/budgets/budget_details.dart @@ -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 = {}; 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), diff --git a/lib/ui/pages/budgets/budgets.dart b/lib/ui/pages/budgets/budgets.dart index ddc1b4a..c76931a 100644 --- a/lib/ui/pages/budgets/budgets.dart +++ b/lib/ui/pages/budgets/budgets.dart @@ -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().setActiveBudget( diff --git a/lib/ui/pages/budgets/edit_budget.dart b/lib/ui/pages/budgets/edit_budget.dart new file mode 100644 index 0000000..7e9d340 --- /dev/null +++ b/lib/ui/pages/budgets/edit_budget.dart @@ -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 { + 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"), + ), + ], + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/ui/widgets/account_indicator.dart b/lib/ui/widgets/account_indicator.dart index 4971288..d8fc420 100644 --- a/lib/ui/widgets/account_indicator.dart +++ b/lib/ui/widgets/account_indicator.dart @@ -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!, ], - ) + ), ); } -} \ No newline at end of file +}