prasule/lib/util/graphs.dart
Matyáš Caras 3e0e2409a2 chore: publish version 1.1.1 (#43)
Co-authored-by: Renovator <kontakt+renovator@caras.cafe>
Reviewed-on: #43
2024-04-22 17:05:22 +02:00

586 lines
21 KiB
Dart

import 'package:currency_picker/currency_picker.dart';
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/wallet_entry.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 {
/// Monthly/Yearly expense/income [LineChart]
const ExpensesLineChart({
required this.date,
required this.locale,
required this.expenseData,
required this.incomeData,
required this.currency,
super.key,
this.yearly = false,
});
/// If the graph will be shown yearly
final bool yearly;
/// Selected date
///
/// Used to get either month or year
final DateTime date;
/// Current locale
///
/// Used mainly for formatting
final String locale;
/// The expense data used for the graph
final List<double> expenseData;
/// Wallet currency
///
/// Used to show currency symbol
final Currency currency;
/// Expense data, but sorted
List<double> get expenseDataSorted =>
List<double>.from(expenseData)..sort((a, b) => a.compareTo(b));
/// Income data used for the graph
final List<double> incomeData;
/// Income data, but sorted
List<double> get incomeDataSorted =>
List<double>.from(incomeData)..sort((a, b) => a.compareTo(b));
/// Calculates maxY for the graph
double get maxY {
if (incomeData.isEmpty) return expenseDataSorted.last;
if (expenseData.isEmpty) return incomeDataSorted.last;
if (expenseDataSorted.last > incomeDataSorted.last) {
return expenseDataSorted.last;
} else {
return incomeDataSorted.last;
}
}
@override
Widget build(BuildContext context) {
return LineChart(
LineChartData(
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipColor: (group) =>
Theme.of(context).colorScheme.secondaryContainer,
getTooltipItems: (spots) => List<LineTooltipItem>.generate(
spots.length,
(index) => LineTooltipItem(
// Changes what's rendered on the tooltip
// when clicked in the chart
(spots[index].barIndex == 0 &&
incomeData.isNotEmpty) // income chart
? (yearly
? AppLocalizations.of(context).incomeForMonth(
DateFormat.MMMM(locale).format(
DateTime(
date.year,
spots[index].x.toInt() + 1,
),
),
NumberFormat.compactCurrency(
locale: locale,
symbol: currency.symbol,
name: currency.name,
).format(spots[index].y),
)
: AppLocalizations.of(context).incomeForDay(
NumberFormat.compactCurrency(
locale: locale,
symbol: currency.symbol,
name: currency.name,
).format(spots[index].y),
))
: (yearly // expense chart
? AppLocalizations.of(context).expensesForMonth(
DateFormat.MMMM(locale).format(
DateTime(
date.year,
spots[index].x.toInt() + 1,
),
),
NumberFormat.compactCurrency(
locale: locale,
symbol: currency.symbol,
name: currency.name,
).format(spots[index].y),
)
: AppLocalizations.of(context).expensesForDay(
NumberFormat.compactCurrency(
locale: locale,
symbol: currency.symbol,
name: currency.name,
).format(spots[index].y),
)),
TextStyle(color: spots[index].bar.color),
children: [
if (!yearly)
TextSpan(
text:
"\n${DateFormat.yMMMMd(locale).format(DateTime(date.year, date.month, spots[index].spotIndex + 1))}",
),
],
),
),
),
),
maxY: maxY,
maxX: yearly
? 11
: date.lastDay.toDouble() -
1, // remove 1 because we are indexing from 0
minY: 0,
minX: 0,
lineBarsData: [
if (incomeData.isNotEmpty)
LineChartBarData(
isCurved: true,
barWidth: 8,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(),
color: ((MediaQuery.of(context).platformBrightness ==
Brightness.dark)
? Colors.green.shade300
: Colors.green)
.harmonizeWith(Theme.of(context).colorScheme.primary),
spots: List.generate(
yearly ? 12 : date.lastDay,
(index) => FlSpot(index.toDouble(), incomeData[index]),
),
),
if (expenseData.isNotEmpty)
LineChartBarData(
isCurved: true,
barWidth: 8,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(),
color: ((MediaQuery.of(context).platformBrightness ==
Brightness.dark)
? Colors.red.shade300
: Colors.red)
.harmonizeWith(Theme.of(context).colorScheme.primary),
spots: List.generate(
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: ((expenseDataSorted.isNotEmpty &&
NumberFormat.compact(locale: locale)
.format(expenseDataSorted.last)
.length >=
5) ||
(incomeDataSorted.isNotEmpty &&
NumberFormat.compact(locale: locale)
.format(incomeDataSorted.last)
.length >=
5))
? 50
: 25,
showTitles: true,
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
reservedSize: 30,
showTitles: true,
getTitlesWidget: (value, meta) {
String text;
if (yearly) {
text = DateFormat.MMM(locale).format(
DateTime(date.year, value.toInt() + 1),
);
} else {
text = (value.toInt() + 1).toString();
}
return SideTitleWidget(
axisSide: meta.axisSide,
child: Text(text),
);
},
),
),
), // axis descriptions
),
);
}
}
/// Renders expenses/income as a [BarChart]
class ExpensesBarChart extends StatelessWidget {
/// Renders expenses/income as a [BarChart]
const ExpensesBarChart({
required this.yearly,
required this.date,
required this.locale,
required this.expenseData,
required this.incomeData,
required this.currency,
super.key,
});
/// If the graph will be shown yearly
final bool yearly;
/// Selected date
///
/// Used to get either month or year
final DateTime date;
/// Current locale
///
/// Used mainly for formatting
final String locale;
/// The expense data used for the graph
final List<double> expenseData;
/// Wallet currency
///
/// Used to show currency symbol
final Currency currency;
/// Expense data, but sorted
List<double> get expenseDataSorted =>
List<double>.from(expenseData)..sort((a, b) => a.compareTo(b));
/// Income data used for the graph
final List<double> incomeData;
/// Income data, but sorted
List<double> get incomeDataSorted =>
List<double>.from(incomeData)..sort((a, b) => a.compareTo(b));
/// Calculates maxY for the graph
double get maxY {
if (incomeData.isEmpty) return expenseDataSorted.last;
if (expenseData.isEmpty) return incomeDataSorted.last;
if (expenseDataSorted.last > incomeDataSorted.last) {
return expenseDataSorted.last;
} else {
return incomeDataSorted.last;
}
}
@override
Widget build(BuildContext context) => BarChart(
BarChartData(
barTouchData: BarTouchData(
enabled: true,
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) =>
yearly // create custom tooltips for graph bars
? BarTooltipItem(
(rodIndex == 1 || incomeData.isEmpty) // expense
? AppLocalizations.of(context).expensesForMonth(
DateFormat.MMMM(locale).format(
DateTime(date.year, groupIndex + 1),
),
NumberFormat.compactCurrency(
locale: locale,
symbol: currency.symbol,
name: currency.name,
).format(rod.toY),
)
: AppLocalizations.of(context).incomeForMonth(
// income
DateFormat.MMMM(locale).format(
DateTime(date.year, groupIndex + 1),
),
NumberFormat.compactCurrency(
locale: locale,
symbol: currency.symbol,
name: currency.name,
).format(rod.toY),
),
TextStyle(color: rod.color),
)
: BarTooltipItem(
(rodIndex == 1)
? AppLocalizations.of(context).expensesForDay(
NumberFormat.compactCurrency(
locale: locale,
symbol: currency.symbol,
name: currency.name,
).format(rod.toY),
)
: AppLocalizations.of(context).incomeForDay(
NumberFormat.compactCurrency(
locale: locale,
symbol: currency.symbol,
name: currency.name,
).format(rod.toY),
),
TextStyle(color: rod.color),
),
),
),
titlesData: FlTitlesData(
rightTitles: const AxisTitles(),
topTitles: const AxisTitles(),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
getTitlesWidget: (value, meta) {
String text;
if (yearly) {
text = DateFormat.MMM(locale).format(
DateTime(date.year, value.toInt() + 1),
);
} else {
text = (value.toInt() + 1).toString();
}
return SideTitleWidget(
axisSide: meta.axisSide,
child: Text(text),
);
},
),
),
), // axis descriptions,
minY: 0,
maxY: maxY,
barGroups: List<BarChartGroupData>.generate(
yearly ? 12 : date.lastDay - 1,
(index) => BarChartGroupData(
x: index,
barRods: [
if (incomeData.isNotEmpty)
BarChartRodData(
toY: incomeData[index],
color: Colors.green
.harmonizeWith(Theme.of(context).colorScheme.primary),
),
if (expenseData.isNotEmpty)
BarChartRodData(
toY: expenseData[index],
color: Colors.red
.harmonizeWith(Theme.of(context).colorScheme.primary),
),
],
),
),
),
);
}
/// [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,
required this.locale,
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;
/// User locale
final String locale;
@override
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 != -1 &&
touchedIndex ==
widget.categories
.where(
(element) => widget.entries
.where(
(w) =>
w.category.id ==
element
.id, // TODO: more elegant fix
)
.isNotEmpty,
)
.toList()
.indexOf(widget.categories[index]))
? 18
: 16,
color: widget.categories[index].color,
text: widget.categories[index].name,
textStyle: TextStyle(
fontWeight: (touchedIndex != -1 &&
touchedIndex ==
widget.categories
.where(
(element) => widget.entries
.where(
(w) =>
w.category.id == element.id,
)
.isNotEmpty,
)
.toList()
.indexOf(widget.categories[index]))
? FontWeight.bold
: FontWeight.normal,
),
),
),
),
),
),
const SizedBox(
height: 5,
),
LimitedBox(
maxHeight: MediaQuery.of(context).size.height * 0.23,
maxWidth: MediaQuery.of(context).size.width * 0.9,
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,
locale: widget.locale,
).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,
backgroundColor: widget.categories[index].color,
),
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;
/// Text style of the indicator
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),
],
);
}