feat(graphs): complete pie chart

This commit is contained in:
Matyáš Caras 2024-01-08 15:23:42 +01:00
parent 4b035e0724
commit d5e94d63d7
Signed by untrusted user who does not match committer: hernik
GPG key ID: 2A3175F98820C5C6
8 changed files with 318 additions and 104 deletions

View file

@ -3,6 +3,7 @@
- Change code according to more aggressive linting - Change code according to more aggressive linting
- Create a default "no category" category, mainly to store entries with removed categories - Create a default "no category" category, mainly to store entries with removed categories
- Categories now have changeable colors assigned to them - Categories now have changeable colors assigned to them
- Added pie chart for expense/income data per category
# 1.0.0-alpha+2 # 1.0.0-alpha+2
- Fixed localization issues - Fixed localization issues
- Added graphs for expenses and income per month/year - Added graphs for expenses and income per month/year

View file

@ -80,5 +80,6 @@
"wallet":"Peněženka", "wallet":"Peněženka",
"noCategory":"Žádná kategorie", "noCategory":"Žádná kategorie",
"done":"Hotovo", "done":"Hotovo",
"pickColor":"Zvolte barvu" "pickColor":"Zvolte barvu",
"changeDate":"Změnit ze kterého měsíce/roku brát data"
} }

View file

@ -160,5 +160,6 @@
"wallet":"Wallet", "wallet":"Wallet",
"noCategory":"No category", "noCategory":"No category",
"done":"Done", "done":"Done",
"pickColor":"Pick a color" "pickColor":"Pick a color",
"changeDate":"Change what month/year to pick data from"
} }

View file

@ -5,7 +5,8 @@ import 'package:flutter/material.dart';
/// Abstract class used to create widgets for the respective platform UI library /// Abstract class used to create widgets for the respective platform UI library
abstract class PlatformWidget<A extends Widget, I extends Widget> abstract class PlatformWidget<A extends Widget, I extends Widget>
extends StatelessWidget { extends StatelessWidget {
/// Abstract class used to create widgets for the respective platform UI library /// Abstract class used to create widgets
/// for the respective platform UI library
const PlatformWidget({super.key}); const PlatformWidget({super.key});
@override @override

View file

@ -0,0 +1,8 @@
/// Extension to get last day of the month
extension LastDay on DateTime {
/// Returns the last day of the month as [int]
int get lastDay {
final d = add(const Duration(days: 31));
return DateTime(d.year, d.month, 0).day;
}
}

View file

@ -3,9 +3,12 @@ import 'package:dynamic_color/dynamic_color.dart';
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:intl/intl.dart';
import 'package:prasule/api/category.dart'; import 'package:prasule/api/category.dart';
import 'package:prasule/api/walletentry.dart'; import 'package:prasule/api/walletentry.dart';
import 'package:intl/intl.dart'; import 'package:prasule/main.dart';
import 'package:prasule/util/get_last_date.dart';
import 'package:prasule/util/text_color.dart';
/// Monthly/Yearly expense/income [LineChart] /// Monthly/Yearly expense/income [LineChart]
class ExpensesLineChart extends StatelessWidget { class ExpensesLineChart extends StatelessWidget {
@ -72,7 +75,9 @@ class ExpensesLineChart extends StatelessWidget {
getTooltipItems: (spots) => List<LineTooltipItem>.generate( getTooltipItems: (spots) => List<LineTooltipItem>.generate(
spots.length, spots.length,
(index) => LineTooltipItem( (index) => LineTooltipItem(
(spots[index].barIndex == 0) // Changes what's rendered on the tooltip
// when clicked in the chart
(spots[index].barIndex == 0) // income chart
? (yearly ? (yearly
? AppLocalizations.of(context).incomeForMonth( ? AppLocalizations.of(context).incomeForMonth(
DateFormat.MMMM(locale).format( DateFormat.MMMM(locale).format(
@ -94,7 +99,7 @@ class ExpensesLineChart extends StatelessWidget {
name: currency.name, name: currency.name,
).format(spots[index].y), ).format(spots[index].y),
)) ))
: (yearly : (yearly // expense chart
? AppLocalizations.of(context).expensesForMonth( ? AppLocalizations.of(context).expensesForMonth(
DateFormat.MMMM(locale).format( DateFormat.MMMM(locale).format(
DateTime( DateTime(
@ -116,12 +121,25 @@ class ExpensesLineChart extends StatelessWidget {
).format(spots[index].y), ).format(spots[index].y),
)), )),
TextStyle(color: spots[index].bar.color), TextStyle(color: spots[index].bar.color),
children: [
TextSpan(
text: "\n${yearly ? DateFormat.MMMM(locale).format(
DateTime(
date.year,
index + 1,
),
) : DateFormat.yMMMMd(locale).format(DateTime(date.year, date.month, spots[index].spotIndex + 1))}",
),
],
), ),
), ),
), ),
), ),
maxY: maxY, maxY: maxY,
maxX: yearly ? 12 : DateTime(date.year, date.month, 0).day.toDouble(), maxX: yearly
? 12
: date.lastDay.toDouble() -
1, // remove 1 because we are indexing from 0
minY: 0, minY: 0,
minX: 0, minX: 0,
backgroundColor: Theme.of(context).colorScheme.background, backgroundColor: Theme.of(context).colorScheme.background,
@ -133,10 +151,13 @@ class ExpensesLineChart extends StatelessWidget {
isStrokeCapRound: true, isStrokeCapRound: true,
dotData: const FlDotData(show: false), dotData: const FlDotData(show: false),
belowBarData: BarAreaData(), belowBarData: BarAreaData(),
color: Colors.green color:
.harmonizeWith(Theme.of(context).colorScheme.secondary), (MediaQuery.of(context).platformBrightness == Brightness.dark)
? Colors.green.shade300
: Colors.green
.harmonizeWith(Theme.of(context).colorScheme.primary),
spots: List.generate( spots: List.generate(
yearly ? 12 : DateTime(date.year, date.month, 0).day, yearly ? 12 : date.lastDay,
(index) => FlSpot(index.toDouble(), incomeData[index]), (index) => FlSpot(index.toDouble(), incomeData[index]),
), ),
), ),
@ -147,17 +168,37 @@ class ExpensesLineChart extends StatelessWidget {
isStrokeCapRound: true, isStrokeCapRound: true,
dotData: const FlDotData(show: false), dotData: const FlDotData(show: false),
belowBarData: BarAreaData(), belowBarData: BarAreaData(),
color: Colors.red color:
.harmonizeWith(Theme.of(context).colorScheme.secondary), (MediaQuery.of(context).platformBrightness == Brightness.dark)
? Colors.red.shade300
: Colors.red
.harmonizeWith(Theme.of(context).colorScheme.primary),
spots: List.generate( spots: List.generate(
yearly ? 12 : DateTime(date.year, date.month, 0).day, yearly
(index) => FlSpot(index.toDouble() + 1, expenseData[index]), ? 12
: date.lastDay, // no -1 because it's the length, not index
(index) => FlSpot(index.toDouble(), expenseData[index]),
), ),
), ),
], // actual data ], // actual data
titlesData: FlTitlesData( titlesData: FlTitlesData(
rightTitles: const AxisTitles(), rightTitles: const AxisTitles(),
topTitles: const AxisTitles(), topTitles: const AxisTitles(),
leftTitles: AxisTitles(
sideTitles: SideTitles(
reservedSize: (NumberFormat.compact()
.format(expenseDataSorted.last)
.length >=
5 ||
NumberFormat.compact()
.format(incomeDataSorted.last)
.length >=
5)
? 50
: 25,
showTitles: true,
),
),
bottomTitles: AxisTitles( bottomTitles: AxisTitles(
sideTitles: SideTitles( sideTitles: SideTitles(
reservedSize: 30, reservedSize: 30,
@ -319,7 +360,7 @@ class ExpensesBarChart extends StatelessWidget {
minY: 0, minY: 0,
maxY: maxY, maxY: maxY,
barGroups: List<BarChartGroupData>.generate( barGroups: List<BarChartGroupData>.generate(
yearly ? 12 : DateTime(date.year, date.month, 0).day, yearly ? 12 : date.lastDay - 1,
(index) => BarChartGroupData( (index) => BarChartGroupData(
x: index, x: index,
barRods: [ barRods: [
@ -327,13 +368,13 @@ class ExpensesBarChart extends StatelessWidget {
BarChartRodData( BarChartRodData(
toY: incomeData[index], toY: incomeData[index],
color: Colors.green color: Colors.green
.harmonizeWith(Theme.of(context).colorScheme.secondary), .harmonizeWith(Theme.of(context).colorScheme.primary),
), ),
if (expenseData.isNotEmpty) if (expenseData.isNotEmpty)
BarChartRodData( BarChartRodData(
toY: expenseData[index], toY: expenseData[index],
color: Colors.red color: Colors.red
.harmonizeWith(Theme.of(context).colorScheme.secondary), .harmonizeWith(Theme.of(context).colorScheme.primary),
), ),
], ],
), ),
@ -342,29 +383,164 @@ class ExpensesBarChart extends StatelessWidget {
); );
} }
class CategoriesPieChart extends StatelessWidget { /// [PieChart] used to display expenses/income visualized
const CategoriesPieChart( /// under their respective category
{super.key, required this.entries, required this.categories}); class CategoriesPieChart extends StatefulWidget {
/// [PieChart] used to display expenses/income visualized
/// under their respective category
const CategoriesPieChart({
required this.entries,
required this.categories,
required this.symbol,
super.key,
});
/// Entries to be used
final List<WalletSingleEntry> entries; final List<WalletSingleEntry> entries;
/// Categories to be displayed
final List<WalletCategory> categories; final List<WalletCategory> categories;
/// Currency symbol displayed on the chart
final String symbol;
@override @override
Widget build(BuildContext context) => PieChart( State<CategoriesPieChart> createState() => _CategoriesPieChartState();
PieChartData( }
sections: List<PieChartSectionData>.generate(
categories.length, class _CategoriesPieChartState extends State<CategoriesPieChart> {
(index) => PieChartSectionData( int touchedIndex = -1;
value: entries
.where( @override
(element) => element.category.id == categories[index].id) Widget build(BuildContext context) => Column(
.fold<double>( children: [
0, SizedBox(
(previousValue, element) => width: MediaQuery.of(context).size.width,
previousValue + element.data.amount, child: Wrap(
alignment: WrapAlignment.center,
spacing: 4,
children: List<Widget>.generate(
widget.categories.length,
(index) => Padding(
padding: const EdgeInsets.all(8),
child: Indicator(
size: touchedIndex == index ? 18 : 16,
color: widget.categories[index].color,
text: widget.categories[index].name,
textStyle: TextStyle(
fontWeight: (touchedIndex == index)
? FontWeight.bold
: FontWeight.normal,
),
), ),
),
),
), ),
), ),
), const SizedBox(
height: 5,
),
Expanded(
child: PieChart(
PieChartData(
centerSpaceRadius: double.infinity,
pieTouchData: PieTouchData(
touchCallback: (event, response) {
// Set touchedIndex so we can highlight
// the corresponding indicator
setState(() {
if (!event.isInterestedForInteractions ||
response == null ||
response.touchedSection == null) {
touchedIndex = -1;
return;
}
touchedIndex =
response.touchedSection!.touchedSectionIndex;
});
},
),
sections: List<PieChartSectionData>.generate(
widget.categories.length,
(index) => PieChartSectionData(
title: NumberFormat.compactCurrency(symbol: widget.symbol)
.format(
widget.entries
.where(
(element) =>
element.category.id ==
widget.categories[index].id,
)
.fold<double>(
0,
(previousValue, element) =>
previousValue + element.data.amount,
),
),
titleStyle: TextStyle(
color:
widget.categories[index].color.calculateTextColor(),
fontWeight: FontWeight.bold,
),
color: widget.categories[index].color,
value: widget.entries
.where(
(element) =>
element.category.id ==
widget.categories[index].id,
)
.fold<double>(
0,
(previousValue, element) =>
previousValue + element.data.amount,
),
),
),
),
),
),
],
);
}
/// Used to indicate which part of a chart is for what
class Indicator extends StatelessWidget {
/// Used to indicate which part of a chart is for what
const Indicator({
required this.size,
required this.color,
required this.text,
this.textStyle = const TextStyle(),
super.key,
});
/// Size of the indicator circle
final double size;
/// Color of the indicator circle
final Color color;
/// Text shown next to the indicator circle
final String text;
final TextStyle textStyle;
@override
Widget build(BuildContext context) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
),
),
const SizedBox(
width: 4,
),
Text(text, style: textStyle),
],
); );
} }

View file

@ -40,8 +40,9 @@ class _GraphViewState extends State<GraphView> {
} }
List<double> generateChartData(EntryType type) { List<double> generateChartData(EntryType type) {
final d = _selectedDate.add(const Duration(days: 31));
final data = List<double>.filled( final data = List<double>.filled(
yearly ? 12 : DateTime(_selectedDate.year, _selectedDate.month, 0).day, yearly ? 12 : DateTime(d.year, d.month, 0).day,
0, 0,
); );
if (selectedWallet == null) return []; if (selectedWallet == null) return [];
@ -92,6 +93,47 @@ class _GraphViewState extends State<GraphView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
floatingActionButton: Tooltip(
message: AppLocalizations.of(context).changeDate,
child: PlatformButton(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
Theme.of(context).colorScheme.primary,
),
foregroundColor: MaterialStateProperty.all(
Theme.of(context).colorScheme.onPrimary,
),
),
text: yearly
? DateFormat.y(locale).format(_selectedDate)
: DateFormat.yMMMM(locale).format(_selectedDate),
onPressed: () async {
final firstDate = (selectedWallet!.entries
..sort(
(a, b) => a.date.compareTo(b.date),
))
.first
.date;
final newDate = await showDatePicker(
context: context,
initialDate: DateTime(
_selectedDate.year,
_selectedDate.month,
),
firstDate: firstDate,
lastDate: DateTime.now(),
initialEntryMode: yearly
? DatePickerEntryMode.input
: DatePickerEntryMode.calendar,
initialDatePickerMode:
yearly ? DatePickerMode.year : DatePickerMode.day,
);
if (newDate == null) return;
_selectedDate = newDate;
setState(() {});
},
),
),
appBar: AppBar( appBar: AppBar(
title: DropdownButton<int>( title: DropdownButton<int>(
value: value:
@ -137,11 +179,19 @@ class _GraphViewState extends State<GraphView> {
].map((e) => PopupMenuItem(value: e, child: Text(e))).toList(), ].map((e) => PopupMenuItem(value: e, child: Text(e))).toList(),
onSelected: (value) { onSelected: (value) {
if (value == AppLocalizations.of(context).settings) { if (value == AppLocalizations.of(context).settings) {
Navigator.of(context).push( Navigator.of(context)
.push(
platformRoute( platformRoute(
(context) => const SettingsView(), (context) => const SettingsView(),
), ),
); )
.then((value) async {
selectedWallet =
await WalletManager.loadWallet(selectedWallet!.name);
final s = await SharedPreferences.getInstance();
chartType = s.getInt("monthlygraph") ?? 2;
setState(() {});
});
} else if (value == AppLocalizations.of(context).about) { } else if (value == AppLocalizations.of(context).about) {
showAboutDialog( showAboutDialog(
context: context, context: context,
@ -219,58 +269,10 @@ class _GraphViewState extends State<GraphView> {
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Column( child: Column(
children: [ children: [
PlatformButton(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
Theme.of(context).colorScheme.primary,
),
foregroundColor: MaterialStateProperty.all(
Theme.of(context).colorScheme.onPrimary,
),
),
text: yearly
? DateFormat.y(locale).format(_selectedDate)
: DateFormat.yMMMM(locale)
.format(_selectedDate),
onPressed: () async {
final firstDate = (selectedWallet!.entries
..sort(
(a, b) => a.date.compareTo(b.date),
))
.first
.date;
final lastDate = (selectedWallet!.entries
..sort(
(a, b) => b.date.compareTo(a.date),
))
.first
.date;
final newDate = await showDatePicker(
context: context,
initialDate: DateTime(
_selectedDate.year,
_selectedDate.month,
),
firstDate: firstDate,
lastDate: lastDate,
initialEntryMode: yearly
? DatePickerEntryMode.input
: DatePickerEntryMode.calendar,
initialDatePickerMode: yearly
? DatePickerMode.year
: DatePickerMode.day,
);
if (newDate == null) return;
_selectedDate = newDate;
setState(() {});
},
),
const SizedBox(
height: 5,
),
SizedBox( SizedBox(
width: MediaQuery.of(context).size.width * 0.9, width: MediaQuery.of(context).size.width * 0.9,
height: 300, height:
MediaQuery.of(context).size.height * 0.35,
child: (chartType == null) child: (chartType == null)
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: (chartType == 1) : (chartType == 1)
@ -292,29 +294,53 @@ class _GraphViewState extends State<GraphView> {
) )
: [], : [],
) )
: ExpensesLineChart( : Padding(
currency: selectedWallet!.currency, padding: const EdgeInsets.all(8),
date: _selectedDate, child: ExpensesLineChart(
locale: locale ?? "en", currency:
yearly: yearly, selectedWallet!.currency,
expenseData: (graphTypeSet date: _selectedDate,
.contains("expense")) locale: locale ?? "en",
? generateChartData( yearly: yearly,
EntryType.expense, expenseData: (graphTypeSet
) .contains("expense"))
: [], ? generateChartData(
incomeData: (graphTypeSet EntryType.expense,
.contains("income")) )
? generateChartData( : [],
EntryType.income, incomeData: (graphTypeSet
) .contains("income"))
: [], ? generateChartData(
EntryType.income,
)
: [],
),
), ),
), ),
], ],
), ),
), ),
), ),
const SizedBox(
height: 25,
),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color:
Theme.of(context).colorScheme.secondaryContainer,
),
width: MediaQuery.of(context).size.width * 0.95,
height: MediaQuery.of(context).size.height * 0.35,
child: Padding(
padding: const EdgeInsets.all(8),
child: CategoriesPieChart(
symbol: selectedWallet!.currency.symbol,
entries: selectedWallet!.entries,
categories: selectedWallet!.categories,
),
),
),
], ],
), ),
), ),

View file

@ -62,7 +62,7 @@ class _SetupViewState extends State<SetupView> {
Icons.payments.codePoint, Icons.payments.codePoint,
fontFamily: 'MaterialIcons', fontFamily: 'MaterialIcons',
), ),
color: Colors.transparent, color: Theme.of(context).colorScheme.secondary,
), ),
WalletCategory( WalletCategory(
name: AppLocalizations.of(context).categoryHealth, name: AppLocalizations.of(context).categoryHealth,