diff --git a/lib/app.dart b/lib/app.dart index 614bdfe..68a830a 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -46,6 +46,8 @@ class _AppViewState extends State { _transactionsRepository.loadTransactions(); _metadataRepository.loadMetadata(); + + _metadataRepository.getSettingsStream().listen((event) => setState(() {})); } @override @@ -66,11 +68,11 @@ class _AppViewState extends State { darkColorScheme = darkDynamic.harmonized(); } else { lightColorScheme = ColorScheme.fromSeed( - seedColor: const Color.fromARGB(1, 5, 236, 55), + seedColor: const Color.fromARGB(255, 103, 6, 231), ); darkColorScheme = ColorScheme.fromSeed( - seedColor: const Color.fromARGB(1, 5, 236, 55), + seedColor: const Color.fromARGB(255, 103, 6, 231), brightness: Brightness.dark, ); } @@ -79,6 +81,7 @@ class _AppViewState extends State { title: 'Tunas', theme: ThemeData(colorScheme: lightColorScheme), darkTheme: ThemeData(colorScheme: darkColorScheme), + themeMode: _metadataRepository.getSettings().themeMode, initialRoute: '/home', routes: { '/home':(context) => const HomePage(), diff --git a/lib/domains/account/account_bloc.dart b/lib/domains/account/account_bloc.dart index f08e7f3..f1080a8 100644 --- a/lib/domains/account/account_bloc.dart +++ b/lib/domains/account/account_bloc.dart @@ -55,6 +55,7 @@ class AccountBloc extends Bloc { on(_onAccountEditLabel); on(_onAccountEditSaving); on(_onAccountEditColor); + on(_onClearData); _metadataRepository .getAccountsStream() @@ -209,4 +210,9 @@ class AccountBloc extends Bloc { _metadataRepository.saveAccounts(accounts); return accounts; } + + FutureOr _onClearData(ClearData event, Emitter emit) { + _metadataRepository.deleteMetadata(); + _transactionsRepository.deleteTransactions(); + } } diff --git a/lib/domains/account/account_event.dart b/lib/domains/account/account_event.dart index b0ce95c..f83975a 100644 --- a/lib/domains/account/account_event.dart +++ b/lib/domains/account/account_event.dart @@ -7,6 +7,10 @@ sealed class AccountEvent extends Equatable { List get props => []; } +final class ClearData extends AccountEvent { + const ClearData(); +} + final class AccountImportCSV extends AccountEvent { const AccountImportCSV(); } diff --git a/lib/domains/budget/budget_bloc.dart b/lib/domains/budget/budget_bloc.dart index 1f714d2..bb4ae2c 100644 --- a/lib/domains/budget/budget_bloc.dart +++ b/lib/domains/budget/budget_bloc.dart @@ -226,10 +226,14 @@ class BudgetBloc extends Bloc { BudgetState _computeState(List budgets, double? initialBudget) { final compareResult = _computeCompareBudget(state.budgets, state.compareYear, state.compareMonth); + + final budgetValues = budgets.map((budget) => budget.value); + final budgetReducedValues = budgetValues.isEmpty ? 0 : budgetValues.reduce((value, element) => value + element); + return state.copyWith( budgets: budgets, initialBudget: (initialBudget ?? state.initialBudget), - remainingBudget: (initialBudget ?? state.initialBudget) - budgets.map((budget) => budget.value).reduce((value, element) => value + element), + remainingBudget: (initialBudget ?? state.initialBudget) - budgetReducedValues, compareBudgets: compareResult.$1, otherBudgets: compareResult.$2, ); diff --git a/lib/domains/settings/settings_bloc.dart b/lib/domains/settings/settings_bloc.dart new file mode 100644 index 0000000..93bb0f4 --- /dev/null +++ b/lib/domains/settings/settings_bloc.dart @@ -0,0 +1,36 @@ +import 'dart:async'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tunas/repositories/metadata/metadata_repository.dart'; +import 'package:tunas/repositories/metadata/models/settings.dart'; + +part 'settings_event.dart'; +part 'settings_state.dart'; + +class SettingsBloc extends Bloc { + final MetadataRepository _metadataRepository; + + SettingsBloc({required MetadataRepository metadataRepository}) : _metadataRepository = metadataRepository, super(const SettingsState()) { + on(_onSettingsLoad); + on(_onSetThemeMode); + + _metadataRepository + .getSettingsStream() + .listen((settings) => add(SettingsLoad(settings))); + } + + FutureOr _onSettingsLoad(SettingsLoad event, Emitter emit) { + emit(state.copyWith( + themeMode: event.settings.themeMode, + )); + } + + FutureOr _onSetThemeMode(SetThemeMode event, Emitter emit) { + _metadataRepository.saveSettings(Settings(themeMode: event.themeMode)); + emit(state.copyWith( + themeMode: event.themeMode, + )); + } +} diff --git a/lib/domains/settings/settings_event.dart b/lib/domains/settings/settings_event.dart new file mode 100644 index 0000000..08542d6 --- /dev/null +++ b/lib/domains/settings/settings_event.dart @@ -0,0 +1,20 @@ +part of 'settings_bloc.dart'; + +sealed class SettingsEvent extends Equatable { + const SettingsEvent(); + + @override + List get props => []; +} + +final class SettingsLoad extends SettingsEvent { + final Settings settings; + + const SettingsLoad(this.settings); +} + +final class SetThemeMode extends SettingsEvent { + final ThemeMode themeMode; + + const SetThemeMode(this.themeMode); +} diff --git a/lib/domains/settings/settings_state.dart b/lib/domains/settings/settings_state.dart new file mode 100644 index 0000000..3cec37d --- /dev/null +++ b/lib/domains/settings/settings_state.dart @@ -0,0 +1,17 @@ +part of 'settings_bloc.dart'; + +class SettingsState { + final ThemeMode themeMode; + + const SettingsState({ + this.themeMode = ThemeMode.system, + }); + + SettingsState copyWith({ + ThemeMode? themeMode, + }) { + return SettingsState( + themeMode: themeMode ?? this.themeMode, + ); + } +} diff --git a/lib/pages/budgets/widgets/budget_cards.dart b/lib/pages/budgets/widgets/budget_cards.dart new file mode 100644 index 0000000..aae428a --- /dev/null +++ b/lib/pages/budgets/widgets/budget_cards.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import 'package:tunas/domains/budget/budget_bloc.dart'; +import 'package:tunas/repositories/metadata/models/budget.dart'; + +class BudgetCards extends StatelessWidget { + const BudgetCards({super.key}); + + List _computeBudgetsCompare(BuildContext context, List targetBudgets, List realBudgets) { + List list = [ + const Text('Budget'), + Container( + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + width: 2, + color: Theme.of(context).colorScheme.onPrimaryContainer + ) + ) + ), + ) + ]; + + list.addAll(targetBudgets.map((budget) => Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container(width: 5), + Text(budget.label), + ], + ), + Text( + '${NumberFormat("#00").format(budget.value.abs())}/${NumberFormat("#00 €").format(realBudgets.firstWhere((rbudget) => rbudget.label == budget.label).value.abs())}', + style: const TextStyle( + fontFamily: 'NovaMono', + ) + ) + ], + ))); + return list; + } + + List _computeOtherBudgets(BuildContext context, List otherBudgets) { + List list = [ + const Text('Hors budget'), + Container( + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + width: 2, + color: Theme.of(context).colorScheme.onPrimaryContainer + ) + ) + ), + ) + ]; + + list.addAll(otherBudgets.map((budget) => Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container(width: 5), + Text(budget.label), + ], + ), + Text( + NumberFormat("#00 €").format(budget.value.abs()), + style: const TextStyle( + fontFamily: 'NovaMono', + ) + ) + ], + ))); + return list; + } + + @override + Widget build(BuildContext context) { + bool smallVerticalScreen = MediaQuery.sizeOf(context).width < 800; + return BlocBuilder( + builder: (context, state) => Column( + children: [ + Container( + width: smallVerticalScreen ? null : 300, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + color: Theme.of(context).colorScheme.secondaryContainer + ), + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: _computeBudgetsCompare(context, state.budgets, state.compareBudgets), + ), + ) + ), + Container( + width: smallVerticalScreen ? null : 300, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + color: Theme.of(context).colorScheme.secondaryContainer + ), + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: _computeOtherBudgets(context, state.otherBudgets), + ), + ) + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/pages/budgets/widgets/budget_comparator.dart b/lib/pages/budgets/widgets/budget_comparator.dart index 73a1260..e4cee22 100644 --- a/lib/pages/budgets/widgets/budget_comparator.dart +++ b/lib/pages/budgets/widgets/budget_comparator.dart @@ -1,135 +1,50 @@ -import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:tunas/domains/budget/budget_bloc.dart'; +import 'package:tunas/pages/budgets/widgets/budget_cards.dart'; +import 'package:tunas/pages/budgets/widgets/budget_compare_selector.dart'; +import 'package:tunas/pages/budgets/widgets/budget_radar.dart'; import 'package:tunas/pages/common/titled_container.dart'; -import 'package:tunas/repositories/metadata/models/budget.dart'; class BudgetComparator extends StatelessWidget { const BudgetComparator({super.key}); - List _computeDataSet(BuildContext context, List targetBudgets, List realBudgets) { - RadarDataSet targetDataSet = RadarDataSet( - fillColor: Theme.of(context).colorScheme.primary.withAlpha(100), - borderColor: Theme.of(context).colorScheme.primary, - entryRadius: 5, - dataEntries: targetBudgets.map((budget) => RadarEntry(value: budget.value)).toList(), - ); - if (realBudgets.isNotEmpty) { - targetDataSet.dataEntries.add(const RadarEntry(value: 0)); - } - RadarDataSet realDataSet = RadarDataSet( - fillColor: Theme.of(context).colorScheme.onPrimary.withAlpha(100), - borderColor: Theme.of(context).colorScheme.onPrimary, - entryRadius: 8, - dataEntries: realBudgets.map((budget) => RadarEntry(value: budget.value)).toList(), - ); - return realBudgets.isEmpty ? [RadarDataSet(dataEntries: [const RadarEntry(value: 1), const RadarEntry(value: 2), const RadarEntry(value: 3)])] : [realDataSet, targetDataSet]; - } - - RadarChartTitle _computeDataSetTitle(int index, List realBudgets) { - return realBudgets.isEmpty ? const RadarChartTitle(text: 'No data') : RadarChartTitle(text: realBudgets[index].label); - } - - List _computeOtherBudgets(BuildContext context, List otherBudgets) { - List list = [ - const Text('Hors budget'), - Container( - margin: const EdgeInsets.only(bottom: 10), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - width: 2, - color: Theme.of(context).colorScheme.onPrimaryContainer - ) - ) - ), - ) - ]; - - list.addAll(otherBudgets.map((budget) => Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Container(width: 5), - Text(budget.label), - ], - ), - Text( - NumberFormat("#00 €").format(budget.value.abs()), - style: const TextStyle( - fontFamily: 'NovaMono', - ) - ) - ], - ))); - return list; - } - @override Widget build(BuildContext context) { + bool smallVerticalScreen = MediaQuery.sizeOf(context).width < 800; return BlocBuilder( builder: (context, state) => TitledContainer( title: 'Compare', - child: Column( + child: smallVerticalScreen + ? const Column( children: [ - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - IconButton( - onPressed: () => context.read().add(BudgetComparePrevious()), - icon: const Icon(Icons.skip_previous) - ), - Text('${NumberFormat('00', 'fr_FR').format(state.compareMonth)} - ${state.compareYear}'), - IconButton( - onPressed: () => context.read().add(BudgetCompareNext()), - icon: const Icon(Icons.skip_next) - ), - ], + BudgetCompareSelector(), + SizedBox( + height: 500, + child: BudgetRadar(), ), + SizedBox( + height: 10, + ), + SizedBox( + height: 500, + child: BudgetCards(), + ), + ], + ) + : const Column( + children: [ + BudgetCompareSelector(), Row( children: [ - Container( - height: 300, - width: 250, - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(5), - color: Theme.of(context).colorScheme.secondaryContainer - ), - child: SingleChildScrollView( - scrollDirection: Axis.vertical, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: _computeOtherBudgets(context, state.otherBudgets), - ), - ) - ), - Expanded( - child: AspectRatio( - aspectRatio: 1.3, - child: RadarChart( - RadarChartData( - titlePositionPercentageOffset: 0.15, - tickBorderData: BorderSide(color: Theme.of(context).colorScheme.secondary, width: 1), - gridBorderData: BorderSide(color: Theme.of(context).colorScheme.secondary, width: 2), - radarBorderData: BorderSide(color: Theme.of(context).colorScheme.secondary, width: 3), - radarBackgroundColor: Theme.of(context).colorScheme.secondaryContainer.withAlpha(100), - radarShape: RadarShape.circle, - dataSets: _computeDataSet(context, state.budgets, state.compareBudgets), - getTitle: (index, angle) => _computeDataSetTitle(index, state.compareBudgets), - ) - ), - ) - ), + BudgetCards(), + BudgetRadar(), ], ) - ], + ] ), - ), + ) ); } } \ No newline at end of file diff --git a/lib/pages/budgets/widgets/budget_compare_selector.dart b/lib/pages/budgets/widgets/budget_compare_selector.dart new file mode 100644 index 0000000..d8160a6 --- /dev/null +++ b/lib/pages/budgets/widgets/budget_compare_selector.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import 'package:tunas/domains/budget/budget_bloc.dart'; + +class BudgetCompareSelector extends StatelessWidget { + const BudgetCompareSelector({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) => Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + onPressed: () => context.read().add(BudgetComparePrevious()), + icon: const Icon(Icons.skip_previous) + ), + Text('${NumberFormat('00', 'fr_FR').format(state.compareMonth)} - ${state.compareYear}'), + IconButton( + onPressed: () => context.read().add(BudgetCompareNext()), + icon: const Icon(Icons.skip_next) + ), + ], + ) + ); + } +} \ No newline at end of file diff --git a/lib/pages/budgets/widgets/budget_line.dart b/lib/pages/budgets/widgets/budget_line.dart new file mode 100644 index 0000000..61b648b --- /dev/null +++ b/lib/pages/budgets/widgets/budget_line.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import 'package:tunas/domains/budget/budget_bloc.dart'; +import 'package:tunas/domains/category/category_bloc.dart'; +import 'package:tunas/repositories/metadata/models/budget.dart'; +import 'package:tunas/repositories/metadata/models/category.dart'; + +class BudgetLine extends StatelessWidget { + final Budget budget; + + const BudgetLine({super.key, required this.budget}); + + Widget _largeScreenLine(BuildContext context, List categories, double initialBudget, double remainingBudget) { + return Row ( + children: [ + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: DropdownButtonFormField( + value: budget.label, + onChanged: (value) => context.read().add(BudgetSetLabel(budget, value!)), + items: categories.map((e) => DropdownMenuItem(value: e.label, child: Text(e.label))).toList(), + ), + ), + const SizedBox(width: 30), + IconButton( + onPressed: () => context.read().add(BudgetSetValue(budget, budget.value.round().toDouble() - 1)), + icon: const Icon(Icons.remove_circle) + ), + Text( + NumberFormat('####000 €', 'fr_FR').format(budget.value), + style: const TextStyle( + fontFamily: 'NovaMono', + fontWeight: FontWeight.bold, + fontSize: 15, + ) + ), + IconButton( + onPressed: () => context.read().add(BudgetSetValue(budget, budget.value.round().toDouble() + 1)), + icon: const Icon(Icons.add_circle) + ), + const SizedBox(width: 5), + ], + ) + ), + Expanded( + child: Slider( + min: 0, + max: initialBudget, + label: budget.value.round().toString(), + value: budget.value, + secondaryTrackValue: remainingBudget + budget.value, + onChanged: (value) => context.read().add(BudgetSetValue(budget, value.round().toDouble())), + ), + ), + IconButton( + onPressed: () => context.read().add(BudgetRemove(budget)), + icon: const Icon(Icons.delete), + ), + ] + ); + } + + Widget _smallScreenLine(BuildContext context, List categories, double initialBudget, double remainingBudget) { + return Column( + children: [ + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + value: budget.label, + onChanged: (value) => context.read().add(BudgetSetLabel(budget, value!)), + items: categories.map((e) => DropdownMenuItem(value: e.label, child: Text(e.label))).toList(), + ), + ), + const SizedBox(width: 30), + Text( + NumberFormat('####000 €', 'fr_FR').format(budget.value), + style: const TextStyle( + fontFamily: 'NovaMono', + fontWeight: FontWeight.bold, + fontSize: 15, + ) + ), + IconButton( + onPressed: () => context.read().add(BudgetSetValue(budget, budget.value.round().toDouble() - 1)), + icon: const Icon(Icons.remove_circle) + ), + IconButton( + onPressed: () => context.read().add(BudgetSetValue(budget, budget.value.round().toDouble() + 1)), + icon: const Icon(Icons.add_circle) + ), + IconButton( + onPressed: () => context.read().add(BudgetRemove(budget)), + icon: const Icon(Icons.delete), + ), + ], + ), + Slider( + min: 0, + max: initialBudget, + label: budget.value.round().toString(), + value: budget.value, + secondaryTrackValue: remainingBudget + budget.value, + onChanged: (value) => context.read().add(BudgetSetValue(budget, value.round().toDouble())), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final categoryState = context.watch().state; + bool smallVerticalScreen = MediaQuery.sizeOf(context).width < 800; + return BlocBuilder( + builder: (context, state) => smallVerticalScreen + ? _smallScreenLine(context, categoryState.categories, state.initialBudget, state.remainingBudget) + : _largeScreenLine(context, categoryState.categories, state.initialBudget, state.remainingBudget), + ); + } +} \ No newline at end of file diff --git a/lib/pages/budgets/widgets/budget_maker.dart b/lib/pages/budgets/widgets/budget_maker.dart index 50fc3fa..c2810b7 100644 --- a/lib/pages/budgets/widgets/budget_maker.dart +++ b/lib/pages/budgets/widgets/budget_maker.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:tunas/domains/budget/budget_bloc.dart'; import 'package:tunas/domains/category/category_bloc.dart'; +import 'package:tunas/pages/budgets/widgets/budget_line.dart'; import 'package:tunas/pages/common/titled_container.dart'; import 'package:tunas/repositories/metadata/models/budget.dart'; @@ -10,7 +11,6 @@ class BudgetMaker extends StatelessWidget { const BudgetMaker({super.key}); List _computeBudgetLines(BuildContext context, List budgets, double initialBudget, double remainingBudget) { - final categoryState = context.watch().state; List list = []; list.add( @@ -38,50 +38,7 @@ class BudgetMaker extends StatelessWidget { )) ); - list.addAll(budgets.map((budget) => Row( - children: [ - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: DropdownButtonFormField( - value: budget.label, - onChanged: (value) => context.read().add(BudgetSetLabel(budget, value!)), - items: categoryState.categories.map((e) => DropdownMenuItem(value: e.label, child: Text(e.label))).toList(), - ), - ), - const SizedBox(width: 20), - Text( - NumberFormat('####000 €', 'fr_FR').format(budget.value), - style: const TextStyle( - fontFamily: 'NovaMono', - fontWeight: FontWeight.bold, - fontSize: 15, - ) - ), - ], - ) - ), - Expanded( - child: SliderTheme( - data: const SliderThemeData(), - child: Slider( - min: 0, - max: initialBudget, - label: budget.value.round().toString(), - value: budget.value, - secondaryTrackValue: remainingBudget + budget.value, - onChanged: (value) => context.read().add(BudgetSetValue(budget, value.round().toDouble())), - ), - ) - ), - IconButton( - onPressed: () => context.read().add(BudgetRemove(budget)), - icon: const Icon(Icons.delete), - ), - ], - )).toList()); + list.addAll(budgets.map((budget) => BudgetLine(budget: budget)).toList()); list.add( Container( @@ -99,9 +56,10 @@ class BudgetMaker extends StatelessWidget { list.add( Row( + mainAxisAlignment: MainAxisAlignment.end, children: [ Text( - ' Remaining ${NumberFormat('#####00 €', 'fr_FR').format(remainingBudget)} out of ${NumberFormat('#####00 €', 'fr_FR').format(initialBudget)}', + '${NumberFormat('#####00 €', 'fr_FR').format(remainingBudget)} remaining ', style: const TextStyle( fontFamily: 'NovaMono', fontWeight: FontWeight.bold, diff --git a/lib/pages/budgets/widgets/budget_radar.dart b/lib/pages/budgets/widgets/budget_radar.dart new file mode 100644 index 0000000..f5a2b22 --- /dev/null +++ b/lib/pages/budgets/widgets/budget_radar.dart @@ -0,0 +1,67 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tunas/domains/budget/budget_bloc.dart'; +import 'package:tunas/repositories/metadata/models/budget.dart'; + +class BudgetRadar extends StatelessWidget { + const BudgetRadar({super.key}); + + List _computeDataSet(BuildContext context, List targetBudgets, List realBudgets) { + if (realBudgets.isEmpty || targetBudgets.isEmpty) { + return [RadarDataSet(dataEntries: [const RadarEntry(value: 1), const RadarEntry(value: 2), const RadarEntry(value: 3)])]; + } + + RadarDataSet targetDataSet = RadarDataSet( + fillColor: Theme.of(context).colorScheme.primary.withAlpha(100), + borderColor: Theme.of(context).colorScheme.primary, + entryRadius: 5, + dataEntries: targetBudgets.map((budget) => RadarEntry(value: budget.value)).toList(), + ); + + if (realBudgets.isNotEmpty) { + targetDataSet.dataEntries.add(const RadarEntry(value: 0)); + } + + RadarDataSet realDataSet = RadarDataSet( + fillColor: Theme.of(context).colorScheme.onPrimary.withAlpha(100), + borderColor: Theme.of(context).colorScheme.onPrimary, + entryRadius: 8, + dataEntries: realBudgets.map((budget) => RadarEntry(value: budget.value)).toList(), + ); + + return [realDataSet, targetDataSet]; + } + + RadarChartTitle _computeDataSetTitle(int index, List realBudgets) { + return RadarChartTitle(text: realBudgets[index].label); + } + + bool canShowData(List targetBudgets, List realBudgets) { + return realBudgets.length >= 3 && targetBudgets.length >= 3; + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) => canShowData(state.budgets, state.compareBudgets) ? Expanded( + child: AspectRatio( + aspectRatio: 1.3, + child: RadarChart( + RadarChartData( + titlePositionPercentageOffset: 0.15, + tickBorderData: BorderSide(color: Theme.of(context).colorScheme.secondary, width: 1), + gridBorderData: BorderSide(color: Theme.of(context).colorScheme.secondary, width: 2), + radarBorderData: BorderSide(color: Theme.of(context).colorScheme.secondary, width: 3), + radarBackgroundColor: Theme.of(context).colorScheme.secondaryContainer.withAlpha(100), + radarShape: RadarShape.circle, + dataSets: _computeDataSet(context, state.budgets, state.compareBudgets), + getTitle: (index, angle) => _computeDataSetTitle(index, state.compareBudgets), + ) + ), + ) + ) + : const Text('No data to show'), + ); + } +} \ No newline at end of file diff --git a/lib/pages/data/data_page.dart b/lib/pages/data/data_page.dart index 54c1650..0404597 100644 --- a/lib/pages/data/data_page.dart +++ b/lib/pages/data/data_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:tunas/pages/data/widgets/account_settings.dart'; import 'package:tunas/pages/data/widgets/categories_settings.dart'; import 'package:tunas/pages/data/widgets/import_settings.dart'; +import 'package:tunas/pages/data/widgets/settings_settings.dart'; class DataPage extends StatelessWidget { const DataPage({super.key}); @@ -25,6 +26,7 @@ class DataPage extends StatelessWidget { fontSize: 35, ), ), + SettingsSettings(), ImportSettings(), AccountSettings(), CategoriesSettings(), diff --git a/lib/pages/data/widgets/import_settings.dart b/lib/pages/data/widgets/import_settings.dart index 47f2e87..c8ade1a 100644 --- a/lib/pages/data/widgets/import_settings.dart +++ b/lib/pages/data/widgets/import_settings.dart @@ -12,25 +12,36 @@ class ImportSettings extends StatelessWidget { builder: (context, state) => TitledContainer( title: "Import", child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - FilledButton( + FilledButton.icon( + onPressed: () => context.read().add(const ClearData()), + label: const Text('ClearData'), + icon: const Icon(Icons.delete_forever), + ), + const SizedBox(height: 5), + FilledButton.tonalIcon( onPressed: () => context.read().add(const AccountImportCSV()), - child: const Text('Import CSV') + label: const Text('Import CSV'), + icon: const Icon(Icons.upload_file), ), const SizedBox(height: 5), - FilledButton( + FilledButton.tonalIcon( onPressed: () => context.read().add(const AccountImportJSON()), - child: const Text('Import JSON') + label: const Text('Import JSON'), + icon: const Icon(Icons.upload_file), ), const SizedBox(height: 5), - FilledButton( + FilledButton.tonalIcon( onPressed: () => context.read().add(const AccountExportCSV()), - child: const Text('Export CSV') + label: const Text('Export CSV'), + icon: const Icon(Icons.download), ), const SizedBox(height: 5), - FilledButton( + FilledButton.tonalIcon( onPressed: () => context.read().add(const AccountExportJSON()), - child: const Text('Export JSON') + label: const Text('Export JSON'), + icon: const Icon(Icons.download), ), ], ), diff --git a/lib/pages/data/widgets/settings_settings.dart b/lib/pages/data/widgets/settings_settings.dart new file mode 100644 index 0000000..cfc2272 --- /dev/null +++ b/lib/pages/data/widgets/settings_settings.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tunas/domains/settings/settings_bloc.dart'; +import 'package:tunas/pages/common/titled_container.dart'; + +class SettingsSettings extends StatelessWidget { + const SettingsSettings({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) => TitledContainer( + title: "Theme", + child: Column( + children: [ + SegmentedButton( + segments: const [ + ButtonSegment( + value: ThemeMode.system, + icon: Icon(Icons.settings) + ), + ButtonSegment( + value: ThemeMode.light, + icon: Icon(Icons.light_mode) + ), + ButtonSegment( + value: ThemeMode.dark, + icon: Icon(Icons.dark_mode) + ), + ], + selected: {state.themeMode}, + onSelectionChanged: (themeMode) => context.read().add(SetThemeMode(themeMode.first)), + ) + ], + ), + ) + ); + } +} \ No newline at end of file diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart index 31fe6a9..5d28f0e 100644 --- a/lib/pages/home/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -4,6 +4,7 @@ import 'package:tunas/domains/account/account_bloc.dart'; import 'package:tunas/domains/budget/budget_bloc.dart'; import 'package:tunas/domains/category/category_bloc.dart'; import 'package:tunas/domains/charts/chart_bloc.dart'; +import 'package:tunas/domains/settings/settings_bloc.dart'; import 'package:tunas/domains/transaction/transaction_bloc.dart'; import 'package:tunas/pages/budgets/budgets_page.dart'; import 'package:tunas/pages/data/data_page.dart'; @@ -95,6 +96,7 @@ class HomePage extends StatelessWidget { BlocProvider(create: (context) => CategoryBloc(metadataRepository: RepositoryProvider.of(context), transactionsRepository: RepositoryProvider.of(context))), BlocProvider(create: (context) => BudgetBloc(metadataRepository: RepositoryProvider.of(context), transactionsRepository: RepositoryProvider.of(context))), BlocProvider(create: (context) => ChartBloc(metadataRepository: RepositoryProvider.of(context), transactionsRepository: RepositoryProvider.of(context))), + BlocProvider(create: (context) => SettingsBloc(metadataRepository: RepositoryProvider.of(context))), ], child: DefaultTabController( length: 4, diff --git a/lib/repositories/metadata/metadata_repository.dart b/lib/repositories/metadata/metadata_repository.dart index 876d9d4..d4208ac 100644 --- a/lib/repositories/metadata/metadata_repository.dart +++ b/lib/repositories/metadata/metadata_repository.dart @@ -4,6 +4,7 @@ import 'package:tunas/repositories/metadata/models/budget.dart'; import 'package:tunas/repositories/metadata/models/category.dart'; import 'package:tunas/repositories/metadata/models/account.dart'; import 'package:tunas/repositories/metadata/models/metadata.dart'; +import 'package:tunas/repositories/metadata/models/settings.dart'; class MetadataRepository { @@ -11,6 +12,7 @@ class MetadataRepository { final _categoriesController = BehaviorSubject>.seeded(const []); final _budgetController = BehaviorSubject>.seeded(const []); final _accountController = BehaviorSubject>.seeded(const []); + final _settingsController = BehaviorSubject.seeded(const Settings()); MetadataRepository({ required jsonRepository, @@ -40,6 +42,14 @@ class MetadataRepository { return _accountController.asBroadcastStream(); } + Settings getSettings() { + return _settingsController.value; + } + + Stream getSettingsStream() { + return _settingsController.asBroadcastStream(); + } + void loadMetadata() async { Metadata metadata = await _jsonRepository.loadJson(Metadata(), MetadataFactory()); _broadcastMetadata(metadata); @@ -69,6 +79,14 @@ class MetadataRepository { return accounts; } + Settings saveSettings(Settings settings) { + Metadata metadata = _constructMetadataFromControllers(); + metadata.settings = settings; + _jsonRepository.saveJson(metadata); + _settingsController.add(settings); + return settings; + } + void deleteMetadata() { Metadata metadata = Metadata(); _jsonRepository.saveJson(metadata); @@ -79,6 +97,7 @@ class MetadataRepository { _categoriesController.add(metadata.categories); _budgetController.add(metadata.budgets); _accountController.add(metadata.accounts); + _settingsController.add(metadata.settings); } Metadata _constructMetadataFromControllers() { @@ -86,6 +105,7 @@ class MetadataRepository { categories: _categoriesController.value, budgets: _budgetController.value, accounts: _accountController.value, + settings: _settingsController.value, ); } } \ No newline at end of file diff --git a/lib/repositories/metadata/models/metadata.dart b/lib/repositories/metadata/models/metadata.dart index c458905..549dcb8 100644 --- a/lib/repositories/metadata/models/metadata.dart +++ b/lib/repositories/metadata/models/metadata.dart @@ -2,16 +2,19 @@ import 'package:tunas/repositories/metadata/models/budget.dart'; import 'package:tunas/repositories/metadata/models/category.dart'; import 'package:tunas/repositories/json/models/json.dart'; import 'package:tunas/repositories/metadata/models/account.dart'; +import 'package:tunas/repositories/metadata/models/settings.dart'; class Metadata implements Json { List budgets; List categories; List accounts; + Settings settings; Metadata({ this.budgets = const [], this.categories = const [], this.accounts = const [], + this.settings = const Settings(), }); @override diff --git a/lib/repositories/metadata/models/settings.dart b/lib/repositories/metadata/models/settings.dart new file mode 100644 index 0000000..a4b266a --- /dev/null +++ b/lib/repositories/metadata/models/settings.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +class Settings { + final ThemeMode themeMode; + + const Settings({ + this.themeMode = ThemeMode.system, + }); + + factory Settings.fromJson(Map json) { + return Settings( + themeMode: ThemeMode.values.byName(json['themeMode']), + ); + } + + Map toJson() => { + 'themeMode': themeMode.name, + }; +} \ No newline at end of file