feat(graphs): complete pie chart
This commit is contained in:
parent
4b035e0724
commit
d5e94d63d7
8 changed files with 318 additions and 104 deletions
|
@ -3,6 +3,7 @@
|
|||
- Change code according to more aggressive linting
|
||||
- Create a default "no category" category, mainly to store entries with removed categories
|
||||
- Categories now have changeable colors assigned to them
|
||||
- Added pie chart for expense/income data per category
|
||||
# 1.0.0-alpha+2
|
||||
- Fixed localization issues
|
||||
- Added graphs for expenses and income per month/year
|
||||
|
|
|
@ -80,5 +80,6 @@
|
|||
"wallet":"Peněženka",
|
||||
"noCategory":"Žádná kategorie",
|
||||
"done":"Hotovo",
|
||||
"pickColor":"Zvolte barvu"
|
||||
"pickColor":"Zvolte barvu",
|
||||
"changeDate":"Změnit ze kterého měsíce/roku brát data"
|
||||
}
|
|
@ -160,5 +160,6 @@
|
|||
"wallet":"Wallet",
|
||||
"noCategory":"No category",
|
||||
"done":"Done",
|
||||
"pickColor":"Pick a color"
|
||||
"pickColor":"Pick a color",
|
||||
"changeDate":"Change what month/year to pick data from"
|
||||
}
|
|
@ -5,7 +5,8 @@ import 'package:flutter/material.dart';
|
|||
/// Abstract class used to create widgets for the respective platform UI library
|
||||
abstract class PlatformWidget<A extends Widget, I extends Widget>
|
||||
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});
|
||||
|
||||
@override
|
||||
|
|
8
lib/util/get_last_date.dart
Normal file
8
lib/util/get_last_date.dart
Normal 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;
|
||||
}
|
||||
}
|
|
@ -3,9 +3,12 @@ import 'package:dynamic_color/dynamic_color.dart';
|
|||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.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/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]
|
||||
class ExpensesLineChart extends StatelessWidget {
|
||||
|
@ -72,7 +75,9 @@ class ExpensesLineChart extends StatelessWidget {
|
|||
getTooltipItems: (spots) => List<LineTooltipItem>.generate(
|
||||
spots.length,
|
||||
(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
|
||||
? AppLocalizations.of(context).incomeForMonth(
|
||||
DateFormat.MMMM(locale).format(
|
||||
|
@ -94,7 +99,7 @@ class ExpensesLineChart extends StatelessWidget {
|
|||
name: currency.name,
|
||||
).format(spots[index].y),
|
||||
))
|
||||
: (yearly
|
||||
: (yearly // expense chart
|
||||
? AppLocalizations.of(context).expensesForMonth(
|
||||
DateFormat.MMMM(locale).format(
|
||||
DateTime(
|
||||
|
@ -116,12 +121,25 @@ class ExpensesLineChart extends StatelessWidget {
|
|||
).format(spots[index].y),
|
||||
)),
|
||||
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,
|
||||
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,
|
||||
minX: 0,
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
|
@ -133,10 +151,13 @@ class ExpensesLineChart extends StatelessWidget {
|
|||
isStrokeCapRound: true,
|
||||
dotData: const FlDotData(show: false),
|
||||
belowBarData: BarAreaData(),
|
||||
color: Colors.green
|
||||
.harmonizeWith(Theme.of(context).colorScheme.secondary),
|
||||
color:
|
||||
(MediaQuery.of(context).platformBrightness == Brightness.dark)
|
||||
? Colors.green.shade300
|
||||
: Colors.green
|
||||
.harmonizeWith(Theme.of(context).colorScheme.primary),
|
||||
spots: List.generate(
|
||||
yearly ? 12 : DateTime(date.year, date.month, 0).day,
|
||||
yearly ? 12 : date.lastDay,
|
||||
(index) => FlSpot(index.toDouble(), incomeData[index]),
|
||||
),
|
||||
),
|
||||
|
@ -147,17 +168,37 @@ class ExpensesLineChart extends StatelessWidget {
|
|||
isStrokeCapRound: true,
|
||||
dotData: const FlDotData(show: false),
|
||||
belowBarData: BarAreaData(),
|
||||
color: Colors.red
|
||||
.harmonizeWith(Theme.of(context).colorScheme.secondary),
|
||||
color:
|
||||
(MediaQuery.of(context).platformBrightness == Brightness.dark)
|
||||
? Colors.red.shade300
|
||||
: Colors.red
|
||||
.harmonizeWith(Theme.of(context).colorScheme.primary),
|
||||
spots: List.generate(
|
||||
yearly ? 12 : DateTime(date.year, date.month, 0).day,
|
||||
(index) => FlSpot(index.toDouble() + 1, expenseData[index]),
|
||||
yearly
|
||||
? 12
|
||||
: date.lastDay, // no -1 because it's the length, not index
|
||||
(index) => FlSpot(index.toDouble(), expenseData[index]),
|
||||
),
|
||||
),
|
||||
], // actual data
|
||||
titlesData: FlTitlesData(
|
||||
rightTitles: 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(
|
||||
sideTitles: SideTitles(
|
||||
reservedSize: 30,
|
||||
|
@ -319,7 +360,7 @@ class ExpensesBarChart extends StatelessWidget {
|
|||
minY: 0,
|
||||
maxY: maxY,
|
||||
barGroups: List<BarChartGroupData>.generate(
|
||||
yearly ? 12 : DateTime(date.year, date.month, 0).day,
|
||||
yearly ? 12 : date.lastDay - 1,
|
||||
(index) => BarChartGroupData(
|
||||
x: index,
|
||||
barRods: [
|
||||
|
@ -327,13 +368,13 @@ class ExpensesBarChart extends StatelessWidget {
|
|||
BarChartRodData(
|
||||
toY: incomeData[index],
|
||||
color: Colors.green
|
||||
.harmonizeWith(Theme.of(context).colorScheme.secondary),
|
||||
.harmonizeWith(Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
if (expenseData.isNotEmpty)
|
||||
BarChartRodData(
|
||||
toY: expenseData[index],
|
||||
color: Colors.red
|
||||
.harmonizeWith(Theme.of(context).colorScheme.secondary),
|
||||
.harmonizeWith(Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -342,22 +383,112 @@ class ExpensesBarChart extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
class CategoriesPieChart extends StatelessWidget {
|
||||
const CategoriesPieChart(
|
||||
{super.key, required this.entries, required this.categories});
|
||||
/// [PieChart] used to display expenses/income visualized
|
||||
/// under their respective category
|
||||
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;
|
||||
|
||||
/// Categories to be displayed
|
||||
final List<WalletCategory> categories;
|
||||
|
||||
/// Currency symbol displayed on the chart
|
||||
final String symbol;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => PieChart(
|
||||
State<CategoriesPieChart> createState() => _CategoriesPieChartState();
|
||||
}
|
||||
|
||||
class _CategoriesPieChartState extends State<CategoriesPieChart> {
|
||||
int touchedIndex = -1;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
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(
|
||||
categories.length,
|
||||
widget.categories.length,
|
||||
(index) => PieChartSectionData(
|
||||
value: entries
|
||||
title: NumberFormat.compactCurrency(symbol: widget.symbol)
|
||||
.format(
|
||||
widget.entries
|
||||
.where(
|
||||
(element) => element.category.id == categories[index].id)
|
||||
(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) =>
|
||||
|
@ -366,5 +497,50 @@ class CategoriesPieChart extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -40,8 +40,9 @@ class _GraphViewState extends State<GraphView> {
|
|||
}
|
||||
|
||||
List<double> generateChartData(EntryType type) {
|
||||
final d = _selectedDate.add(const Duration(days: 31));
|
||||
final data = List<double>.filled(
|
||||
yearly ? 12 : DateTime(_selectedDate.year, _selectedDate.month, 0).day,
|
||||
yearly ? 12 : DateTime(d.year, d.month, 0).day,
|
||||
0,
|
||||
);
|
||||
if (selectedWallet == null) return [];
|
||||
|
@ -92,6 +93,47 @@ class _GraphViewState extends State<GraphView> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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(
|
||||
title: DropdownButton<int>(
|
||||
value:
|
||||
|
@ -137,11 +179,19 @@ class _GraphViewState extends State<GraphView> {
|
|||
].map((e) => PopupMenuItem(value: e, child: Text(e))).toList(),
|
||||
onSelected: (value) {
|
||||
if (value == AppLocalizations.of(context).settings) {
|
||||
Navigator.of(context).push(
|
||||
Navigator.of(context)
|
||||
.push(
|
||||
platformRoute(
|
||||
(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) {
|
||||
showAboutDialog(
|
||||
context: context,
|
||||
|
@ -219,58 +269,10 @@ class _GraphViewState extends State<GraphView> {
|
|||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
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(
|
||||
width: MediaQuery.of(context).size.width * 0.9,
|
||||
height: 300,
|
||||
height:
|
||||
MediaQuery.of(context).size.height * 0.35,
|
||||
child: (chartType == null)
|
||||
? const CircularProgressIndicator()
|
||||
: (chartType == 1)
|
||||
|
@ -292,8 +294,11 @@ class _GraphViewState extends State<GraphView> {
|
|||
)
|
||||
: [],
|
||||
)
|
||||
: ExpensesLineChart(
|
||||
currency: selectedWallet!.currency,
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: ExpensesLineChart(
|
||||
currency:
|
||||
selectedWallet!.currency,
|
||||
date: _selectedDate,
|
||||
locale: locale ?? "en",
|
||||
yearly: yearly,
|
||||
|
@ -311,10 +316,31 @@ class _GraphViewState extends State<GraphView> {
|
|||
: [],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
@ -62,7 +62,7 @@ class _SetupViewState extends State<SetupView> {
|
|||
Icons.payments.codePoint,
|
||||
fontFamily: 'MaterialIcons',
|
||||
),
|
||||
color: Colors.transparent,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
WalletCategory(
|
||||
name: AppLocalizations.of(context).categoryHealth,
|
||||
|
|
Loading…
Reference in a new issue