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,16 +1,3 @@
# tunas # Tunas
A new Flutter project. A very complicated tool that should have been an excel sheet.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View File

@@ -14,6 +14,7 @@ import 'package:tunas/domains/account/models/transaction_line.dart';
import 'package:tunas/domains/account/models/transaction_value.dart'; import 'package:tunas/domains/account/models/transaction_value.dart';
import 'package:tunas/repositories/account/account_repository.dart'; import 'package:tunas/repositories/account/account_repository.dart';
import 'package:tunas/repositories/account/models/transaction.dart'; import 'package:tunas/repositories/account/models/transaction.dart';
import 'package:uuid/uuid.dart';
part 'account_event.dart'; part 'account_event.dart';
part 'account_state.dart'; part 'account_state.dart';
@@ -24,7 +25,6 @@ class AccountBloc extends Bloc<AccountEvent, AccountState> {
AccountBloc({required AccountRepository accountRepository}) AccountBloc({required AccountRepository accountRepository})
: _accountRepository = accountRepository, : _accountRepository = accountRepository,
super(const AccountState()) { super(const AccountState()) {
on<AccountAddTransaction>(_onTransactionAdd);
on<AccountLoad>(_onAccountLoad); on<AccountLoad>(_onAccountLoad);
on<AccountImportJSON>(_onAccountImportJSON); on<AccountImportJSON>(_onAccountImportJSON);
on<AccountImportCSV>(_onAccountImportCSV); on<AccountImportCSV>(_onAccountImportCSV);
@@ -38,23 +38,14 @@ class AccountBloc extends Bloc<AccountEvent, AccountState> {
on<TransactionOpenAddDialog>(_onTransactionOpenAddDialog); on<TransactionOpenAddDialog>(_onTransactionOpenAddDialog);
on<TransactionHideAddDialog>(_onTransactionHideAddDialog); on<TransactionHideAddDialog>(_onTransactionHideAddDialog);
on<TransactionAdd>(_onTransactionAddDialog); on<TransactionAdd>(_onTransactionAddDialog);
on<TransactionSetCurrent>(_onTransactionSetCurrent);
on<TransactionDeleteCurrent>(_onTransactionDeleteCurrent);
_accountRepository _accountRepository
.getTransactionsStream() .getTransactionsStream()
.listen((transactions) => add(AccountLoad(transactions))); .listen((transactions) => add(AccountLoad(transactions)));
} }
_onTransactionAdd(AccountAddTransaction event, Emitter<AccountState> emit) {
state.transactions.add(event.transaction);
var computeResult = _computeTransactionLine(state.transactions);
emit(state.copyWith(
transactionsLines: computeResult.list,
globalTotal: computeResult.globalTotal,
accountsTotals: computeResult.accountsTotals,
));
}
_onAccountLoad(AccountLoad event, Emitter<AccountState> emit) { _onAccountLoad(AccountLoad event, Emitter<AccountState> emit) {
var computeResult = _computeTransactionLine(event.transactions); var computeResult = _computeTransactionLine(event.transactions);
emit(state.copyWith( emit(state.copyWith(
@@ -78,6 +69,7 @@ class AccountBloc extends Bloc<AccountEvent, AccountState> {
final transactions = csvList final transactions = csvList
.map((line) => Transaction( .map((line) => Transaction(
uuid: const Uuid().v8(),
date: DateTime.parse(line[0]), date: DateTime.parse(line[0]),
category: line[1], category: line[1],
description: line[2], description: line[2],
@@ -200,18 +192,83 @@ class AccountBloc extends Bloc<AccountEvent, AccountState> {
_onTransactionAddDialog( _onTransactionAddDialog(
TransactionAdd event, Emitter<AccountState> emit TransactionAdd event, Emitter<AccountState> emit
) { ) async {
if (state.isValid) { if (state.isValid) {
state.transactions.add(Transaction( List<Transaction> transactions = state.transactions;
Transaction? currentTransaction = state.currentTransaction;
if (currentTransaction != null) {
transactions.removeWhere((transaction) => transaction.uuid == currentTransaction.uuid);
}
transactions.add(Transaction(
uuid: const Uuid().v8(),
date: state.transactionDate.value ?? DateTime.now(), date: state.transactionDate.value ?? DateTime.now(),
category: state.transactionCategory.value, category: state.transactionCategory.value,
description: state.transactionDescription.value, description: state.transactionDescription.value,
account: state.transactionAccount.value, account: state.transactionAccount.value,
value: state.transactionValue.value value: state.transactionValue.value
)); ));
var computeResult = _computeTransactionLine(state.transactions); final computeResult = _computeTransactionLine(transactions);
await _accountRepository.saveTransactions(transactions);
emit(state.copyWith( emit(state.copyWith(
currentTransaction: null,
transactionDate: const TransactionDate.pure(),
transactionCategory: const TransactionCategory.pure(),
transactionDescription: const TransactionDescription.pure(),
transactionAccount: const TransactionAccount.pure(),
transactionValue: const TransactionValue.pure(),
transactions: transactions,
transactionsLines: computeResult.list,
globalTotal: computeResult.globalTotal,
accountsTotals: computeResult.accountsTotals,
));
}
}
_onTransactionSetCurrent(
TransactionSetCurrent event, Emitter<AccountState> emit
) {
Transaction? transaction = event.transaction;
if (transaction == null) {
emit(state.copyWith(
currentTransaction: event.transaction,
transactionDate: const TransactionDate.pure(),
transactionCategory: const TransactionCategory.pure(),
transactionDescription: const TransactionDescription.pure(),
transactionAccount: const TransactionAccount.pure(),
transactionValue: const TransactionValue.pure(),
));
} else {
emit(state.copyWith(
currentTransaction: transaction,
transactionDate: TransactionDate.dirty(transaction.date),
transactionCategory: TransactionCategory.dirty(transaction.category),
transactionDescription: TransactionDescription.dirty(transaction.description),
transactionAccount: TransactionAccount.dirty(transaction.account),
transactionValue: TransactionValue.dirty(transaction.value),
));
}
}
_onTransactionDeleteCurrent(
TransactionDeleteCurrent event, Emitter<AccountState> emit
) async {
Transaction? currentTransaction = state.currentTransaction;
if (currentTransaction != null) {
List<Transaction> transactions = state.transactions;
transactions.removeWhere((transaction) => transaction.uuid == currentTransaction.uuid);
final computeResult = _computeTransactionLine(transactions);
await _accountRepository.saveTransactions(transactions);
emit(state.copyWith(
currentTransaction: null,
transactionDate: const TransactionDate.pure(),
transactionCategory: const TransactionCategory.pure(),
transactionDescription: const TransactionDescription.pure(),
transactionAccount: const TransactionAccount.pure(),
transactionValue: const TransactionValue.pure(),
transactions: transactions,
transactionsLines: computeResult.list, transactionsLines: computeResult.list,
globalTotal: computeResult.globalTotal, globalTotal: computeResult.globalTotal,
accountsTotals: computeResult.accountsTotals, accountsTotals: computeResult.accountsTotals,

View File

@@ -7,14 +7,6 @@ sealed class AccountEvent extends Equatable {
List<Object> get props => []; List<Object> get props => [];
} }
final class AccountAddTransaction extends AccountEvent {
final Transaction transaction;
const AccountAddTransaction(this.transaction);
@override
List<Object> get props => [transaction];
}
final class AccountLoad extends AccountEvent { final class AccountLoad extends AccountEvent {
final List<Transaction> transactions; final List<Transaction> transactions;
const AccountLoad(this.transactions); const AccountLoad(this.transactions);
@@ -86,4 +78,13 @@ final class TransactionOpenAddDialog extends AccountEvent {
final class TransactionHideAddDialog extends AccountEvent { final class TransactionHideAddDialog extends AccountEvent {
const TransactionHideAddDialog(); const TransactionHideAddDialog();
}
final class TransactionSetCurrent extends AccountEvent {
final Transaction? transaction;
const TransactionSetCurrent(this.transaction);
}
final class TransactionDeleteCurrent extends AccountEvent {
const TransactionDeleteCurrent();
} }

View File

@@ -16,6 +16,8 @@ final class AccountState extends Equatable {
final bool isValid; final bool isValid;
final bool showAddDialog; final bool showAddDialog;
final Transaction? currentTransaction;
const AccountState({ const AccountState({
this.transactions = const [], this.transactions = const [],
this.transactionsLines = const [], this.transactionsLines = const [],
@@ -29,6 +31,7 @@ final class AccountState extends Equatable {
this.transactionValue = const TransactionValue.pure(), this.transactionValue = const TransactionValue.pure(),
this.isValid = false, this.isValid = false,
this.showAddDialog = false, this.showAddDialog = false,
this.currentTransaction
}); });
AccountState copyWith({ AccountState copyWith({
@@ -44,6 +47,7 @@ final class AccountState extends Equatable {
TransactionValue? transactionValue, TransactionValue? transactionValue,
bool? isValid, bool? isValid,
bool? showAddDialog, bool? showAddDialog,
Transaction? currentTransaction,
}) { }) {
return AccountState( return AccountState(
transactions: transactions ?? this.transactions, transactions: transactions ?? this.transactions,
@@ -58,6 +62,7 @@ final class AccountState extends Equatable {
transactionValue: transactionValue ?? this.transactionValue, transactionValue: transactionValue ?? this.transactionValue,
isValid: isValid ?? this.isValid, isValid: isValid ?? this.isValid,
showAddDialog: showAddDialog ?? this.showAddDialog, showAddDialog: showAddDialog ?? this.showAddDialog,
currentTransaction: currentTransaction ?? this.currentTransaction,
); );
} }
@@ -75,5 +80,6 @@ final class AccountState extends Equatable {
transactionValue, transactionValue,
isValid, isValid,
showAddDialog, showAddDialog,
currentTransaction,
]; ];
} }

View File

@@ -0,0 +1,10 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
part 'budget_event.dart';
part 'budget_state.dart';
class BudgetBloc extends Bloc<BudgetEvent, BudgetState> {
BudgetBloc(super.initialState);
}

View File

@@ -0,0 +1,8 @@
part of 'budget_bloc.dart';
sealed class BudgetEvent extends Equatable {
const BudgetEvent();
@override
List<Object> get props => [];
}

View File

@@ -0,0 +1,6 @@
part of 'budget_bloc.dart';
final class BudgetState extends Equatable {
@override
List<Object?> get props => [];
}

View File

@@ -1,5 +1,8 @@
import 'dart:ui';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:tunas/domains/account/models/transaction_line.dart'; import 'package:tunas/domains/account/models/transaction_line.dart';
import 'package:tunas/domains/charts/models/chart_item.dart'; import 'package:tunas/domains/charts/models/chart_item.dart';
@@ -9,6 +12,29 @@ import 'package:tunas/repositories/account/models/transaction.dart';
part 'chart_event.dart'; part 'chart_event.dart';
part 'chart_state.dart'; part 'chart_state.dart';
final 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,
];
class ChartBloc extends Bloc<ChartEvent, ChartState> { class ChartBloc extends Bloc<ChartEvent, ChartState> {
final AccountRepository _accountRepository; final AccountRepository _accountRepository;
@@ -95,7 +121,9 @@ class ChartBloc extends Bloc<ChartEvent, ChartState> {
List<ChartItem> scopedCategoriesPositiveTotals = []; List<ChartItem> scopedCategoriesPositiveTotals = [];
List<ChartItem> scopedCategoriesNegativeTotals = []; List<ChartItem> scopedCategoriesNegativeTotals = [];
Map<int, FlSpot> scopedMonthlyTotals = {}; Map<int, FlSpot> scopedMonthlyTotals = {};
Map<String, Map<String, double>> scopedCategoriesMonthlyTotals = {}; Map<int, Map<String, double>> scopedCategoriesMonthlyTotals = {};
Map<String, Color> categoriesColors = {};
int colorIndex = 0;
for(var transaction in state.transactions) { for(var transaction in state.transactions) {
double subTotal = globalTotal + transaction.value; double subTotal = globalTotal + transaction.value;
@@ -120,6 +148,11 @@ class ChartBloc extends Bloc<ChartEvent, ChartState> {
continue; continue;
} }
if (categoriesColors[transaction.category] == null) {
categoriesColors[transaction.category] = colors[colorIndex];
colorIndex++;
}
if (transaction.value >= 0) { if (transaction.value >= 0) {
ChartItem? chartItem = scopedCategoriesPositiveTotals.firstWhere( ChartItem? chartItem = scopedCategoriesPositiveTotals.firstWhere(
(item) => item.label == transaction.category, (item) => item.label == transaction.category,
@@ -142,7 +175,16 @@ class ChartBloc extends Bloc<ChartEvent, ChartState> {
} }
); );
chartItem.value += transaction.value; chartItem.value += transaction.value;
Map<String, double>? a = scopedCategoriesMonthlyTotals[transaction.date.month];
if (scopedCategoriesMonthlyTotals[transaction.date.month] == null) {
a = {};
}
a?[transaction.category] = transaction.value.abs() + (a[transaction.category] ?? 0);
scopedCategoriesMonthlyTotals[transaction.date.month] = a!;
} }
} }
List<ChartItem> scopedCategoriesPositiveTotalsPercents = []; List<ChartItem> scopedCategoriesPositiveTotalsPercents = [];
@@ -202,6 +244,7 @@ class ChartBloc extends Bloc<ChartEvent, ChartState> {
scopedSimplifiedCategoriesNegativeTotalsPercents: scopedSimplifiedCategoriesNegativeTotalsPercents, scopedSimplifiedCategoriesNegativeTotalsPercents: scopedSimplifiedCategoriesNegativeTotalsPercents,
scopedMonthlyTotals: scopedMonthlyTotals.values.toList(), scopedMonthlyTotals: scopedMonthlyTotals.values.toList(),
scopedCategoriesMonthlyTotals: scopedCategoriesMonthlyTotals, scopedCategoriesMonthlyTotals: scopedCategoriesMonthlyTotals,
categoriesColors: categoriesColors,
); );
} }
} }

View File

@@ -27,7 +27,9 @@ final class ChartState extends Equatable {
final List<ChartItem> scopedSimplifiedCategoriesNegativeTotalsPercents; final List<ChartItem> scopedSimplifiedCategoriesNegativeTotalsPercents;
final List<FlSpot> scopedMonthlyTotals; final List<FlSpot> scopedMonthlyTotals;
final Map<String, Map<String, double>> scopedCategoriesMonthlyTotals; final Map<int, Map<String, double>> scopedCategoriesMonthlyTotals;
final Map<String, Color> categoriesColors;
const ChartState({ const ChartState({
this.transactions = const [], this.transactions = const [],
@@ -50,6 +52,7 @@ final class ChartState extends Equatable {
this.scopedSimplifiedCategoriesNegativeTotalsPercents = const [], this.scopedSimplifiedCategoriesNegativeTotalsPercents = const [],
this.scopedMonthlyTotals = const [], this.scopedMonthlyTotals = const [],
this.scopedCategoriesMonthlyTotals = const {}, this.scopedCategoriesMonthlyTotals = const {},
this.categoriesColors = const {},
}); });
ChartState copyWith({ ChartState copyWith({
@@ -72,7 +75,8 @@ final class ChartState extends Equatable {
List<ChartItem>? scopedSimplifiedCategoriesNegativeTotals, List<ChartItem>? scopedSimplifiedCategoriesNegativeTotals,
List<ChartItem>? scopedSimplifiedCategoriesNegativeTotalsPercents, List<ChartItem>? scopedSimplifiedCategoriesNegativeTotalsPercents,
List<FlSpot>? scopedMonthlyTotals, List<FlSpot>? scopedMonthlyTotals,
Map<String, Map<String, double>>? scopedCategoriesMonthlyTotals, Map<int, Map<String, double>>? scopedCategoriesMonthlyTotals,
Map<String, Color>? categoriesColors,
}) { }) {
return ChartState( return ChartState(
transactions: transactions ?? this.transactions, transactions: transactions ?? this.transactions,
@@ -95,6 +99,7 @@ final class ChartState extends Equatable {
scopedSimplifiedCategoriesNegativeTotalsPercents: scopedSimplifiedCategoriesNegativeTotalsPercents ?? this.scopedSimplifiedCategoriesNegativeTotalsPercents, scopedSimplifiedCategoriesNegativeTotalsPercents: scopedSimplifiedCategoriesNegativeTotalsPercents ?? this.scopedSimplifiedCategoriesNegativeTotalsPercents,
scopedMonthlyTotals: scopedMonthlyTotals ?? this.scopedMonthlyTotals, scopedMonthlyTotals: scopedMonthlyTotals ?? this.scopedMonthlyTotals,
scopedCategoriesMonthlyTotals: scopedCategoriesMonthlyTotals ?? this.scopedCategoriesMonthlyTotals, scopedCategoriesMonthlyTotals: scopedCategoriesMonthlyTotals ?? this.scopedCategoriesMonthlyTotals,
categoriesColors: categoriesColors ?? this.categoriesColors,
); );
} }
@@ -118,6 +123,7 @@ final class ChartState extends Equatable {
scopedSimplifiedCategoriesNegativeTotalsPercents, scopedSimplifiedCategoriesNegativeTotalsPercents,
scopedMonthlyTotals, scopedMonthlyTotals,
scopedCategoriesMonthlyTotals, scopedCategoriesMonthlyTotals,
categoriesColors,
]; ];
} }

View File

@@ -1,10 +1,30 @@
import 'package:flutter/material.dart'; 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 { class BudgetsPage extends StatelessWidget {
const BudgetsPage({super.key}); const BudgetsPage({super.key});
@override @override
Widget build(BuildContext context) { 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'), title: const Text('Tunas'),
bottom: const TabBar( bottom: const TabBar(
tabs: [ tabs: [
Tab(text: 'Dashboard'), Tab(icon: Icon(Icons.insights)),
Tab(text: 'Transactions'), Tab(icon: Icon(Icons.receipt_long)),
Tab(text: 'Budgets'), Tab(icon: Icon(Icons.pie_chart)),
Tab(text: 'Data'), Tab(icon: Icon(Icons.settings)),
], ],
), ),
), ),

View File

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

View File

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

View File

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

View File

@@ -2,14 +2,73 @@ import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class MonthlyCategoriesTotalChart extends StatelessWidget { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BarChart( return AspectRatio(
BarChartData() 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_header.dart';
import 'package:tunas/pages/transactions/widgets/transactions_list.dart'; import 'package:tunas/pages/transactions/widgets/transactions_list.dart';
class TransactionsPage extends StatelessWidget { class TransactionsPage extends StatelessWidget {
const TransactionsPage({super.key}); const TransactionsPage({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocListener<AccountBloc, AccountState>( return BlocListener<AccountBloc, AccountState>(
listener: (context, state) { listener: (context, state) {
if (state.showAddDialog) { if (state.showAddDialog) {
// TransactionAddDialog.show(context); // TransactionAddDialog.show(context);
} }
}, },
child: const Flex( child: const Flex(
direction: Axis.horizontal, direction: Axis.horizontal,
children: [ children: [
Expanded( Expanded(
child: Column( child: Column(
children: [ children: [
TransactionsActions(), TransactionsActions(),
TransactionsHeader(), TransactionsHeader(),
TransactionsList(), TransactionsList(),
], ],
)) ))
], ],
), ),
); );
} }
} }

View File

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

View File

@@ -1,40 +1,60 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:tunas/domains/account/account_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 { class TransactionAddDialog extends StatelessWidget {
const TransactionAddDialog({super.key}); const TransactionAddDialog({super.key});
static void show(BuildContext context) => showDialog( static void show(BuildContext context, Transaction? transaction) {
context: context, context.read<AccountBloc>().add(TransactionSetCurrent(transaction));
barrierDismissible: false, showDialog(
useRootNavigator: false, context: context,
builder: (_) => BlocProvider.value( barrierDismissible: false,
value: BlocProvider.of<AccountBloc>(context), useRootNavigator: false,
child: const TransactionAddDialog() builder: (_) => BlocProvider.value(
) value: BlocProvider.of<AccountBloc>(context),
); child: const TransactionAddDialog()
)
);
}
static void hide(BuildContext context) => Navigator.pop(context); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return BlocBuilder<AccountBloc, AccountState>(
title: Text('Add transaction'), buildWhen: (previous, current) => previous.currentTransaction != current.currentTransaction,
actions: [ builder: (context, state) => AlertDialog(
TextButton( title: Text(state.currentTransaction == null ? 'Add Transaction' : 'Edit Transaction'),
onPressed: () => TransactionAddDialog.hide(context), actions: _computeActions(context, state.currentTransaction),
child: Text('Close') content: const SizedBox(
), height: 400,
TextButton( child: TransactionForm(),
onPressed: () => context.read<AccountBloc>().add(TransactionAdd()), )
child: Text('Add')
),
],
content: SizedBox(
height: 400,
child: TransactionAddForm(),
) )
); );
} }

View File

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

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:tunas/pages/transactions/widgets/transaction_add_dialog.dart';
import 'package:tunas/repositories/account/models/transaction.dart'; import 'package:tunas/repositories/account/models/transaction.dart';
class TransactionLine extends StatelessWidget { class TransactionLine extends StatelessWidget {
@@ -10,54 +11,57 @@ class TransactionLine extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MergeSemantics( return InkWell(
child: Container( onTap: () => TransactionAddDialog.show(context, transaction),
padding: const EdgeInsets.symmetric(vertical: 9, horizontal: 10), child: MergeSemantics(
margin: const EdgeInsets.symmetric(vertical: 2, horizontal: 10), child: Container(
child: Row( padding: const EdgeInsets.symmetric(vertical: 9, horizontal: 10),
mainAxisAlignment: MainAxisAlignment.spaceBetween, margin: const EdgeInsets.symmetric(vertical: 2, horizontal: 10),
children: [ child: Row(
SizedBox( mainAxisAlignment: MainAxisAlignment.spaceBetween,
width: 100, children: [
child: Text(DateFormat('dd-MM-yyyy', 'fr_FR').format(transaction.date)) SizedBox(
), width: 100,
Expanded( child: Text(DateFormat('dd-MM-yyyy', 'fr_FR').format(transaction.date))
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
transaction.category,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)
),
Text(transaction.description),
],
), ),
), Expanded(
SizedBox( child: Column(
width: 100, crossAxisAlignment: CrossAxisAlignment.start,
child: Text(transaction.account), children: [
), Text(
SizedBox( transaction.category,
width: 120, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)
child: Column( ),
crossAxisAlignment: CrossAxisAlignment.end, Text(transaction.description),
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
)
),
],
), ),
) 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, fontSize: 35,
), ),
), ),
ElevatedButton.icon( IconButton(
onPressed: () => TransactionAddDialog.show(context), onPressed: () => TransactionAddDialog.show(context, null),
icon: const Icon( icon: const Icon(
Icons.add Icons.add
), )
label: const Text('Add'),
), ),
], ],
) )

View File

@@ -1,4 +1,7 @@
import 'package:uuid/uuid.dart';
class Transaction { class Transaction {
String uuid;
DateTime date; DateTime date;
String category; String category;
String description; String description;
@@ -6,6 +9,7 @@ class Transaction {
double value; double value;
Transaction({ Transaction({
required this.uuid,
required this.date, required this.date,
required this.category, required this.category,
required this.description, required this.description,
@@ -15,6 +19,7 @@ class Transaction {
factory Transaction.fromJson(Map<String, dynamic> json) { factory Transaction.fromJson(Map<String, dynamic> json) {
return Transaction( return Transaction(
uuid: json['uuid'] ?? const Uuid().v8(),
date: DateTime.parse(json['date']), date: DateTime.parse(json['date']),
category: json['category'], category: json['category'],
description: json['description'], description: json['description'],
@@ -24,6 +29,7 @@ class Transaction {
} }
Map<String, String> toJson() => { Map<String, String> toJson() => {
'uuid': uuid,
'date': date.toIso8601String(), 'date': date.toIso8601String(),
'category': category, 'category': category,
'description': description, 'description': description,

View File

@@ -49,6 +49,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.18.0"
crypto:
dependency: transitive
description:
name: crypto
sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab
url: "https://pub.dev"
source: hosted
version: "3.0.3"
csv: csv:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -97,6 +105,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.1" version: "6.1.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
fl_chart: fl_chart:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -314,6 +330,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.0" version: "1.10.0"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@@ -354,6 +378,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.1" version: "0.6.1"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
url: "https://pub.dev"
source: hosted
version: "1.3.2"
uuid:
dependency: "direct main"
description:
name: uuid
sha256: cd210a09f7c18cbe5a02511718e0334de6559871052c90a90c0cca46a4aa81c8
url: "https://pub.dev"
source: hosted
version: "4.3.3"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:

View File

@@ -25,6 +25,7 @@ dependencies:
sdk: flutter sdk: flutter
intl: any intl: any
formz: ^0.6.1 formz: ^0.6.1
uuid: ^4.3.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: