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 expenseData; /// Wallet currency /// /// Used to show currency symbol final Currency currency; /// Expense data, but sorted List get expenseDataSorted => List.from(expenseData)..sort((a, b) => a.compareTo(b)); /// Income data used for the graph final List incomeData; /// Income data, but sorted List get incomeDataSorted => List.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.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 expenseData; /// Wallet currency /// /// Used to show currency symbol final Currency currency; /// Expense data, but sorted List get expenseDataSorted => List.from(expenseData)..sort((a, b) => a.compareTo(b)); /// Income data used for the graph final List incomeData; /// Income data, but sorted List get incomeDataSorted => List.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.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 entries; /// Categories to be displayed final List categories; /// Currency symbol displayed on the chart final String symbol; /// User locale final String locale; @override State createState() => _CategoriesPieChartState(); } class _CategoriesPieChartState extends State { 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.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.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( 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( 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), ], ); }