549 lines
19 KiB
Dart
549 lines
19 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(
|
|
tooltipBgColor: 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) // 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: [
|
|
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
|
|
: date.lastDay.toDouble() -
|
|
1, // remove 1 because we are indexing from 0
|
|
minY: 0,
|
|
minX: 0,
|
|
backgroundColor: Theme.of(context).colorScheme.background,
|
|
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: (NumberFormat.compact()
|
|
.format(expenseDataSorted.last)
|
|
.length >=
|
|
5 ||
|
|
NumberFormat.compact()
|
|
.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)
|
|
? 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(
|
|
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,
|
|
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
|
|
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,
|
|
),
|
|
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)
|
|
.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;
|
|
|
|
/// 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),
|
|
],
|
|
);
|
|
}
|