stacked bar graph, edit / remove transaction & budget page base

This commit is contained in:
2024-02-06 23:58:29 +01:00
parent 3abee9ff6f
commit 3610c466d2
25 changed files with 483 additions and 180 deletions

View File

@@ -1,10 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:tunas/domains/account/account_bloc.dart';
import 'package:tunas/pages/budgets/widgets/budgets_actions.dart';
class BudgetsPage extends StatelessWidget {
const BudgetsPage({super.key});
@override
Widget build(BuildContext context) {
return const Text('Budgets');
return BlocListener<AccountBloc, AccountState>(
listener: (context, state) {
if (state.showAddDialog) {
// TransactionAddDialog.show(context);
}
},
child: const Flex(
direction: Axis.horizontal,
children: [
Expanded(
child: Column(
children: [
BudgetsActions(),
],
))
],
),
);
}
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:tunas/domains/account/account_bloc.dart';
class BudgetsActions extends StatelessWidget {
const BudgetsActions({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<AccountBloc, AccountState>(
builder: (context, state) => Container(
padding: const EdgeInsets.symmetric(vertical: 9, horizontal: 10),
margin: const EdgeInsets.symmetric(vertical: 2, horizontal: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Budgets',
style: TextStyle(
fontWeight: FontWeight.w900,
fontSize: 35,
),
),
IconButton(
onPressed: () => null,
icon: const Icon(
Icons.add
)
),
],
)
)
);
}
}

View File

@@ -21,10 +21,10 @@ class HomePage extends StatelessWidget {
title: const Text('Tunas'),
bottom: const TabBar(
tabs: [
Tab(text: 'Dashboard'),
Tab(text: 'Transactions'),
Tab(text: 'Budgets'),
Tab(text: 'Data'),
Tab(icon: Icon(Icons.insights)),
Tab(icon: Icon(Icons.receipt_long)),
Tab(icon: Icon(Icons.pie_chart)),
Tab(icon: Icon(Icons.settings)),
],
),
),

View File

@@ -45,15 +45,15 @@ class StatsPage extends StatelessWidget {
),
SizedBox(
height: 500,
child: MonthlyCategoriesTotalChart(categoriesMonthlyTotals: state.scopedCategoriesMonthlyTotals)
child: MonthlyCategoriesTotalChart(categoriesMonthlyTotals: state.scopedCategoriesMonthlyTotals, categoriesColors: state.categoriesColors)
),
SizedBox(
height: 450,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CategoriesTotalsChart(categoriesTotals: state.scopedCategoriesPositiveTotals, categoriesTotalsPercents: state.scopedCategoriesPositiveTotalsPercents,),
CategoriesTotalsChart(categoriesTotals: state.scopedCategoriesNegativeTotals, categoriesTotalsPercents: state.scopedSimplifiedCategoriesNegativeTotalsPercents,),
CategoriesTotalsChart(categoriesTotals: state.scopedCategoriesPositiveTotals, categoriesTotalsPercents: state.scopedCategoriesPositiveTotalsPercents, categoriesColors: state.categoriesColors),
CategoriesTotalsChart(categoriesTotals: state.scopedCategoriesNegativeTotals, categoriesTotalsPercents: state.scopedSimplifiedCategoriesNegativeTotalsPercents, categoriesColors: state.categoriesColors),
],
)
),

View File

@@ -25,8 +25,8 @@ class AccountCounter extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(10),
margin: EdgeInsets.all(20),
padding: const EdgeInsets.all(10),
margin: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
color: Colors.blue

View File

@@ -6,33 +6,11 @@ import 'package:tunas/domains/charts/models/chart_item.dart';
class CategoriesTotalsChart extends StatelessWidget {
final List<ChartItem> categoriesTotals;
final List<ChartItem> categoriesTotalsPercents;
final Map<String, Color> categoriesColors;
const CategoriesTotalsChart({super.key, required this.categoriesTotals, required this.categoriesTotalsPercents});
const CategoriesTotalsChart({super.key, required this.categoriesTotals, required this.categoriesTotalsPercents, required this.categoriesColors});
List<PieChartSectionData> _convertDataForChart() {
var count = 1;
var colors = [
Colors.purple.shade300,
Colors.purple.shade500,
Colors.purple.shade700,
Colors.purple.shade900,
Colors.blue.shade300,
Colors.blue.shade500,
Colors.blue.shade700,
Colors.blue.shade900,
Colors.green.shade300,
Colors.green.shade500,
Colors.green.shade700,
Colors.green.shade900,
Colors.yellow.shade300,
Colors.yellow.shade500,
Colors.yellow.shade700,
Colors.yellow.shade900,
Colors.red.shade300,
Colors.red.shade500,
Colors.red.shade700,
Colors.red.shade900,
];
return categoriesTotalsPercents
.map((item) =>
PieChartSectionData(
@@ -45,7 +23,7 @@ class CategoriesTotalsChart extends StatelessWidget {
titlePositionPercentageOffset: 0.8,
borderSide: const BorderSide(width: 0),
radius: 150,
color: colors[count++]
color: categoriesColors[item.label]
))
.toList();
}
@@ -55,9 +33,19 @@ class CategoriesTotalsChart extends StatelessWidget {
.map((item) => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("${item.label}: "),
Row(
children: [
Container(
height: 10,
width: 10,
color: categoriesColors[item.label],
),
Container(width: 5),
Text(item.label),
],
),
Text(
NumberFormat("#00 €").format(item.value),
NumberFormat("#00 €").format(item.value.abs()),
style: const TextStyle(
fontFamily: 'NovaMono',
)
@@ -70,7 +58,7 @@ class CategoriesTotalsChart extends StatelessWidget {
Widget build(BuildContext context) {
return Container(
height: 320,
width: 550,
width: 560,
padding: const EdgeInsets.all(10),
margin: const EdgeInsets.all(20),
decoration: BoxDecoration(
@@ -99,12 +87,14 @@ class CategoriesTotalsChart extends StatelessWidget {
),
Container(
height: 300,
width: 250,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
color: Colors.blueGrey
),
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -2,14 +2,73 @@ import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
class MonthlyCategoriesTotalChart extends StatelessWidget {
final Map<String, Map<String, double>> categoriesMonthlyTotals;
final Map<int, Map<String, double>> categoriesMonthlyTotals;
final Map<String, Color> categoriesColors;
const MonthlyCategoriesTotalChart({super.key, required this.categoriesMonthlyTotals});
const MonthlyCategoriesTotalChart({super.key, required this.categoriesMonthlyTotals, required this.categoriesColors});
BarChartRodData _computeStack(double barsWidth, MapEntry<int, Map<String, double>> entry) {
var subcounter = 0.0;
var a = entry.value.entries.map((subEntry) => BarChartRodStackItem(subcounter, subcounter += subEntry.value, categoriesColors[subEntry.key] ?? Colors.red)).toList();
return BarChartRodData(
fromY: 0,
toY: subcounter,
width: barsWidth,
borderRadius: BorderRadius.zero,
rodStackItems: a
);
}
List<BarChartGroupData> _computeBarGroups(double barsSpace, double barsWidth) {
var a = categoriesMonthlyTotals.entries
.map((entry) {
return BarChartGroupData(
x: entry.key,
barsSpace: barsSpace,
barRods: [_computeStack(barsWidth, entry)]
);
})
.toList();
return a;
}
SideTitleWidget _computeBottomTitles(double value, TitleMeta meta) {
const titles = ['Jan', 'Fev', 'Mar', 'Avr', 'Mai', 'Juin', 'Jui', 'Aou', 'Sep', 'Oct', 'Nov', 'Dec'];
String title = titles[value.toInt() - 1];
return SideTitleWidget(
axisSide: meta.axisSide,
child: Text(title)
);
}
@override
Widget build(BuildContext context) {
return BarChart(
BarChartData()
return AspectRatio(
aspectRatio: 1.66,
child: LayoutBuilder(
builder: (context, constraints) {
final barsSpace = 4.0 * constraints.maxWidth / 400;
final barsWidth = 8.0 * constraints.maxWidth / 100;
return BarChart(
BarChartData(
barGroups: _computeBarGroups(barsSpace, barsWidth),
titlesData: FlTitlesData(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: _computeBottomTitles
)
)
)
)
);
},
)
);
}
}

View File

@@ -5,31 +5,30 @@ import 'package:tunas/pages/transactions/widgets/transactions_actions.dart';
import 'package:tunas/pages/transactions/widgets/transactions_header.dart';
import 'package:tunas/pages/transactions/widgets/transactions_list.dart';
class TransactionsPage extends StatelessWidget {
const TransactionsPage({super.key});
@override
Widget build(BuildContext context) {
return BlocListener<AccountBloc, AccountState>(
listener: (context, state) {
if (state.showAddDialog) {
// TransactionAddDialog.show(context);
}
},
child: const Flex(
direction: Axis.horizontal,
children: [
Expanded(
child: Column(
children: [
TransactionsActions(),
TransactionsHeader(),
TransactionsList(),
],
))
],
),
);
listener: (context, state) {
if (state.showAddDialog) {
// TransactionAddDialog.show(context);
}
},
child: const Flex(
direction: Axis.horizontal,
children: [
Expanded(
child: Column(
children: [
TransactionsActions(),
TransactionsHeader(),
TransactionsList(),
],
))
],
),
);
}
}

View File

@@ -4,6 +4,7 @@ class AutocompleteInput extends StatelessWidget {
final List<String> options;
final String hintText;
final String? errorText;
final String? initialValue;
final ValueChanged<String>? onChanged;
const AutocompleteInput({
@@ -11,12 +12,14 @@ class AutocompleteInput extends StatelessWidget {
required this.options,
required this.hintText,
required this.errorText,
required this.initialValue,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return RawAutocomplete<String>(
initialValue: TextEditingValue(text: initialValue ?? ''),
optionsBuilder: (TextEditingValue textEditingValue) => options.where((String option) =>option.contains(textEditingValue.text.toLowerCase())),
fieldViewBuilder: (
BuildContext context,

View File

@@ -1,40 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:tunas/domains/account/account_bloc.dart';
import 'package:tunas/pages/transactions/widgets/transaction_add_form.dart';
import 'package:tunas/pages/transactions/widgets/transaction_form.dart';
import 'package:tunas/repositories/account/models/transaction.dart';
class TransactionAddDialog extends StatelessWidget {
const TransactionAddDialog({super.key});
static void show(BuildContext context) => showDialog(
context: context,
barrierDismissible: false,
useRootNavigator: false,
builder: (_) => BlocProvider.value(
value: BlocProvider.of<AccountBloc>(context),
child: const TransactionAddDialog()
)
);
static void show(BuildContext context, Transaction? transaction) {
context.read<AccountBloc>().add(TransactionSetCurrent(transaction));
showDialog(
context: context,
barrierDismissible: false,
useRootNavigator: false,
builder: (_) => BlocProvider.value(
value: BlocProvider.of<AccountBloc>(context),
child: const TransactionAddDialog()
)
);
}
static void hide(BuildContext context) => Navigator.pop(context);
_computeActions(BuildContext context, Transaction? currentTransaction) {
final actions = [
IconButton(
onPressed: () => TransactionAddDialog.hide(context),
icon: const Icon(Icons.close)
),
IconButton(
onPressed: () => context.read<AccountBloc>().add(const TransactionAdd()),
icon: const Icon(Icons.save)
),
];
if (currentTransaction != null) {
actions.add(IconButton(
onPressed: () => context.read<AccountBloc>().add(const TransactionDeleteCurrent()),
icon: const Icon(Icons.delete)
));
}
return actions;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Add transaction'),
actions: [
TextButton(
onPressed: () => TransactionAddDialog.hide(context),
child: Text('Close')
),
TextButton(
onPressed: () => context.read<AccountBloc>().add(TransactionAdd()),
child: Text('Add')
),
],
content: SizedBox(
height: 400,
child: TransactionAddForm(),
return BlocBuilder<AccountBloc, AccountState>(
buildWhen: (previous, current) => previous.currentTransaction != current.currentTransaction,
builder: (context, state) => AlertDialog(
title: Text(state.currentTransaction == null ? 'Add Transaction' : 'Edit Transaction'),
actions: _computeActions(context, state.currentTransaction),
content: const SizedBox(
height: 400,
child: TransactionForm(),
)
)
);
}

View File

@@ -5,8 +5,9 @@ import 'package:tunas/domains/account/account_bloc.dart';
import 'autocomplete_input.dart';
class TransactionAddForm extends StatelessWidget {
const TransactionAddForm({super.key});
class TransactionForm extends StatelessWidget {
const TransactionForm({super.key});
@override
Widget build(BuildContext context) {
@@ -61,6 +62,7 @@ class _TransactionCategoryInput extends StatelessWidget {
child: AutocompleteInput(
options: state.categories,
hintText: 'Category',
initialValue: state.transactionCategory.value,
errorText: state.transactionCategory.isNotValid ? state.transactionCategory.error?.message : null,
onChanged: (value) => context.read<AccountBloc>().add(TransactionCategoryChange(value)),
),
@@ -98,6 +100,7 @@ class _TransactionAccountInput extends StatelessWidget {
child: AutocompleteInput(
options: state.accountsTotals.keys.toList(),
hintText: 'Account',
initialValue: state.transactionAccount.value,
errorText: state.transactionAccount.isNotValid ? state.transactionAccount.error?.message : null,
onChanged: (value) => context.read<AccountBloc>().add(TransactionAccountChange(value)),
),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:tunas/pages/transactions/widgets/transaction_add_dialog.dart';
import 'package:tunas/repositories/account/models/transaction.dart';
class TransactionLine extends StatelessWidget {
@@ -10,54 +11,57 @@ class TransactionLine extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MergeSemantics(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 9, horizontal: 10),
margin: const EdgeInsets.symmetric(vertical: 2, horizontal: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SizedBox(
width: 100,
child: Text(DateFormat('dd-MM-yyyy', 'fr_FR').format(transaction.date))
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
transaction.category,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)
),
Text(transaction.description),
],
return InkWell(
onTap: () => TransactionAddDialog.show(context, transaction),
child: MergeSemantics(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 9, horizontal: 10),
margin: const EdgeInsets.symmetric(vertical: 2, horizontal: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SizedBox(
width: 100,
child: Text(DateFormat('dd-MM-yyyy', 'fr_FR').format(transaction.date))
),
),
SizedBox(
width: 100,
child: Text(transaction.account),
),
SizedBox(
width: 120,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
NumberFormat(transaction.value > 0 ? '+#######.00 €' : '#######.00 €', 'fr_FR').format(transaction.value),
style: TextStyle(
color: transaction.value > 0 ? Colors.green : Colors.red
)
),
Text(
NumberFormat('#######.00 €', 'fr_FR').format(subTotal),
style: TextStyle(
color: subTotal > 0 ? Colors.green : Colors.red
)
),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
transaction.category,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)
),
Text(transaction.description),
],
),
),
)
],
SizedBox(
width: 100,
child: Text(transaction.account),
),
SizedBox(
width: 120,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
NumberFormat(transaction.value > 0 ? '+#######.00 €' : '#######.00 €', 'fr_FR').format(transaction.value),
style: TextStyle(
color: transaction.value > 0 ? Colors.green : Colors.red
)
),
Text(
NumberFormat('#######.00 €', 'fr_FR').format(subTotal),
style: TextStyle(
color: subTotal > 0 ? Colors.green : Colors.red
)
),
],
),
)
],
)
)
)
);

View File

@@ -22,12 +22,11 @@ class TransactionsActions extends StatelessWidget {
fontSize: 35,
),
),
ElevatedButton.icon(
onPressed: () => TransactionAddDialog.show(context),
IconButton(
onPressed: () => TransactionAddDialog.show(context, null),
icon: const Icon(
Icons.add
),
label: const Text('Add'),
)
),
],
)