diff --git a/lib/widgets/stats/date/histogram.dart b/lib/widgets/stats/date/histogram.dart new file mode 100644 index 000000000..eac425d64 --- /dev/null +++ b/lib/widgets/stats/date/histogram.dart @@ -0,0 +1,332 @@ +import 'dart:math'; + +import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/date.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; +import 'package:aves/widgets/stats/date/axis.dart'; +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +class Histogram extends StatefulWidget { + final Set entries; + final FilterCallback onFilterSelection; + + const Histogram({ + super.key, + required this.entries, + required this.onFilterSelection, + }); + + @override + State createState() => _HistogramState(); +} + +class _HistogramState extends State { + DateLevel _level = DateLevel.y; + DateTime? _firstDate, _lastDate; + final Map _entryCountPerDate = {}; + final ValueNotifier<_EntryByDate?> _selection = ValueNotifier(null); + List<_EntryByDate>? _seriesData; + List<_EntryByDate>? _interpolatedData; + + static const histogramHeight = 200.0; + + @override + void initState() { + super.initState(); + + final entriesByDateDescending = List.of(widget.entries)..sort(AvesEntry.compareByDate); + var lastDate = entriesByDateDescending.firstWhereOrNull((entry) => entry.bestDate != null)?.bestDate; + var firstDate = entriesByDateDescending.lastWhereOrNull((entry) => entry.bestDate != null)?.bestDate; + + if (lastDate != null && firstDate != null) { + final rangeDays = lastDate.difference(firstDate).inDays; + if (rangeDays > 1) { + if (rangeDays <= 31) { + _level = DateLevel.ymd; + } else if (rangeDays <= 365) { + _level = DateLevel.ym; + } + + late DateTime Function(DateTime) normalizeDate; + switch (_level) { + case DateLevel.ymd: + normalizeDate = (v) => DateTime(v.year, v.month, v.day); + break; + case DateLevel.ym: + normalizeDate = (v) => DateTime(v.year, v.month); + break; + default: + normalizeDate = (v) => DateTime(v.year); + break; + } + _firstDate = normalizeDate(firstDate); + _lastDate = normalizeDate(lastDate); + + final dates = entriesByDateDescending.map((entry) => entry.bestDate).whereNotNull(); + _entryCountPerDate.addAll(groupBy(dates, normalizeDate).map((k, v) => MapEntry(k, v.length))); + if (_entryCountPerDate.isNotEmpty) { + // discrete points + _seriesData = _entryCountPerDate.entries.map((kv) { + return _EntryByDate(date: kv.key, entryCount: kv.value); + }).toList(); + + // smooth curve + _computeInterpolatedData(); + } + } + } + } + + void _computeInterpolatedData() { + final firstDate = _firstDate; + final lastDate = _lastDate; + if (firstDate == null || lastDate == null) return; + + final xRange = lastDate.difference(firstDate); + final xRangeInMillis = xRange.inMilliseconds; + late int xCount; + late DateTime Function(DateTime date) incrementDate; + switch (_level) { + case DateLevel.ymd: + xCount = xRange.inDays; + incrementDate = (date) => DateTime(date.year, date.month, date.day + 1); + break; + case DateLevel.ym: + xCount = (xRange.inDays / 30.5).round(); + incrementDate = (date) => DateTime(date.year, date.month + 1); + break; + default: + xCount = lastDate.year - firstDate.year; + incrementDate = (date) => DateTime(date.year + 1); + break; + } + final yMax = _entryCountPerDate.values.reduce(max).toDouble(); + final xInterval = yMax / xCount; + final controlPoints = []; + var date = firstDate; + for (int i = 0; i <= xCount; i++) { + controlPoints.add(Offset(i * xInterval, (_entryCountPerDate[date] ?? 0).toDouble())); + date = incrementDate(date); + } + final interpolatedPoints = controlPoints.length > 3 ? CatmullRomSpline(controlPoints).generateSamples().map((sample) => sample.value).toList() : controlPoints; + _interpolatedData = interpolatedPoints.map((p) { + final date = firstDate.add(Duration(milliseconds: p.dx * xRangeInMillis ~/ yMax)); + final entryCount = p.dy.clamp(0, yMax); + return _EntryByDate(date: date, entryCount: entryCount); + }).toList(); + } + + @override + Widget build(BuildContext context) { + if (_seriesData == null || _interpolatedData == null) return const SizedBox(); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8), + height: histogramHeight, + child: Stack( + children: [ + _buildChart(context, _interpolatedData!, isInterpolated: true, isArea: true), + _buildChart(context, _interpolatedData!, isInterpolated: true, isArea: false), + _buildChart(context, _seriesData!, isInterpolated: false, isArea: false), + ], + ), + ), + _buildSelectionRow(), + ], + ); + } + + Widget _buildChart(BuildContext context, List<_EntryByDate> seriesData, {required bool isInterpolated, required bool isArea}) { + final drawArea = isInterpolated && isArea; + final drawLine = isInterpolated && !isArea; + final drawPoints = !isInterpolated; + + final colorScheme = Theme.of(context).colorScheme; + final accentColor = colorScheme.secondary; + final axisColor = charts.ColorUtil.fromDartColor(drawPoints ? colorScheme.onPrimary.withOpacity(.9) : Colors.transparent); + final measureLineColor = charts.ColorUtil.fromDartColor(drawPoints ? colorScheme.onPrimary.withOpacity(.1) : Colors.transparent); + final histogramLineColor = charts.ColorUtil.fromDartColor(drawLine ? accentColor : Colors.white); + final histogramPointStrikeColor = axisColor; + final histogramPointFillColor = charts.ColorUtil.fromDartColor(colorScheme.background); + + final series = [ + if (drawLine || drawArea) + charts.Series<_EntryByDate, DateTime>( + id: 'curve', + data: seriesData, + domainFn: (d, i) => d.date, + measureFn: (d, i) => d.entryCount, + colorFn: (d, i) => histogramLineColor, + ), + if (drawPoints && !drawArea) + charts.Series<_EntryByDate, DateTime>( + id: 'points', + data: seriesData, + domainFn: (d, i) => d.date, + measureFn: (d, i) => d.entryCount, + colorFn: (d, i) => histogramPointStrikeColor, + fillColorFn: (d, i) => histogramPointFillColor, + )..setAttribute(charts.rendererIdKey, 'customPoint'), + ]; + + final locale = context.l10n.localeName; + final timeAxisSpec = _firstDate != null && _lastDate != null + ? TimeAxisSpec.forLevel( + locale: locale, + level: _level, + first: _firstDate!, + last: _lastDate!, + ) + : null; + final measureFormat = NumberFormat.decimalPattern(locale); + + final domainAxis = charts.DateTimeAxisSpec( + renderSpec: charts.SmallTickRendererSpec( + labelStyle: charts.TextStyleSpec(color: axisColor), + lineStyle: charts.LineStyleSpec(color: axisColor), + ), + tickProviderSpec: timeAxisSpec != null && timeAxisSpec.tickSpecs.isNotEmpty ? charts.StaticDateTimeTickProviderSpec(timeAxisSpec.tickSpecs) : null, + ); + + Widget chart = charts.TimeSeriesChart( + series, + domainAxis: domainAxis, + primaryMeasureAxis: charts.NumericAxisSpec( + renderSpec: charts.GridlineRendererSpec( + labelStyle: charts.TextStyleSpec(color: axisColor), + lineStyle: charts.LineStyleSpec(color: measureLineColor), + ), + tickFormatterSpec: charts.BasicNumericTickFormatterSpec((v) { + // localize and hide 0 + return (v == null || v == 0) ? '' : measureFormat.format(v); + }), + ), + defaultRenderer: charts.LineRendererConfig( + includeArea: drawArea, + areaOpacity: 1, + ), + customSeriesRenderers: [ + charts.PointRendererConfig( + customRendererId: 'customPoint', + strokeWidthPx: 2, + symbolRenderer: _CircleSymbolRenderer(isSolid: false), + ), + ], + defaultInteractions: false, + behaviors: drawPoints + ? [ + charts.SelectNearest(), + charts.LinePointHighlighter( + defaultRadiusPx: 8, + radiusPaddingPx: 2, + showHorizontalFollowLine: charts.LinePointHighlighterFollowLineType.nearest, + showVerticalFollowLine: charts.LinePointHighlighterFollowLineType.nearest, + ), + ] + : null, + selectionModels: [ + charts.SelectionModelConfig( + changedListener: (model) => _selection.value = model.selectedDatum.firstOrNull?.datum as _EntryByDate?, + ) + ], + ); + if (drawArea) { + chart = ShaderMask( + shaderCallback: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + accentColor.withOpacity(0), + accentColor, + ], + ).createShader, + blendMode: BlendMode.srcIn, + child: chart, + ); + } + return chart; + } + + Widget _buildSelectionRow() { + final locale = context.l10n.localeName; + final numberFormat = NumberFormat.decimalPattern(locale); + + return ValueListenableBuilder<_EntryByDate?>( + valueListenable: _selection, + builder: (context, selection, child) { + late Widget child; + if (selection == null) { + child = const SizedBox(); + } else { + final filter = DateFilter(_level, selection.date); + final count = selection.entryCount; + child = Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + AvesFilterChip( + filter: filter, + onTap: widget.onFilterSelection, + ), + const Spacer(), + Text( + numberFormat.format(count), + style: TextStyle( + color: Theme.of(context).textTheme.caption!.color, + ), + textAlign: TextAlign.end, + ), + ], + ), + ); + } + + return AnimatedSwitcher( + duration: context.read().formTransition, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axisAlignment: -1, + child: child, + ), + ), + child: child, + ); + }, + ); + } +} + +@immutable +class _EntryByDate extends Equatable { + final DateTime date; + final num entryCount; + + @override + List get props => [date, entryCount]; + + const _EntryByDate({ + required this.date, + required this.entryCount, + }); +} + +class _CircleSymbolRenderer extends charts.CircleSymbolRenderer { + _CircleSymbolRenderer({super.isSolid = true}); + + @override + charts.Color? getSolidFillColor(charts.Color? fillColor) => fillColor; +} diff --git a/lib/widgets/stats/histogram.dart b/lib/widgets/stats/histogram.dart deleted file mode 100644 index 2f01b34a1..000000000 --- a/lib/widgets/stats/histogram.dart +++ /dev/null @@ -1,263 +0,0 @@ -import 'package:aves/model/entry.dart'; -import 'package:aves/model/filters/date.dart'; -import 'package:aves/theme/durations.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; -import 'package:aves/widgets/stats/date/axis.dart'; -import 'package:charts_flutter/flutter.dart' as charts; -import 'package:collection/collection.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; - -class Histogram extends StatefulWidget { - final Set entries; - final FilterCallback onFilterSelection; - - const Histogram({ - super.key, - required this.entries, - required this.onFilterSelection, - }); - - @override - State createState() => _HistogramState(); -} - -class _HistogramState extends State { - DateLevel _level = DateLevel.y; - DateTime? _firstDate, _lastDate; - final Map _entryCountPerDate = {}; - final ValueNotifier _selection = ValueNotifier(null); - - static const histogramHeight = 200.0; - - @override - void initState() { - super.initState(); - - final entriesByDateDescending = List.of(widget.entries)..sort(AvesEntry.compareByDate); - _lastDate = entriesByDateDescending.firstWhereOrNull((entry) => entry.bestDate != null)?.bestDate; - _firstDate = entriesByDateDescending.lastWhereOrNull((entry) => entry.bestDate != null)?.bestDate; - - if (_lastDate != null && _firstDate != null) { - final rangeDays = _lastDate!.difference(_firstDate!).inDays; - if (rangeDays > 1) { - if (rangeDays <= 31) { - _level = DateLevel.ymd; - } else if (rangeDays <= 365) { - _level = DateLevel.ym; - } - - final dates = entriesByDateDescending.map((entry) => entry.bestDate).whereNotNull(); - late DateTime Function(DateTime) groupByKey; - switch (_level) { - case DateLevel.ymd: - groupByKey = (v) => DateTime(v.year, v.month, v.day); - break; - case DateLevel.ym: - groupByKey = (v) => DateTime(v.year, v.month); - break; - default: - groupByKey = (v) => DateTime(v.year); - break; - } - _entryCountPerDate.addAll(groupBy(dates, groupByKey).map((k, v) => MapEntry(k, v.length))); - } - } - } - - @override - Widget build(BuildContext context) { - if (_entryCountPerDate.isEmpty) return const SizedBox(); - - final locale = context.l10n.localeName; - final numberFormat = NumberFormat.decimalPattern(locale); - - final seriesData = _entryCountPerDate.entries.map((kv) { - return EntryByDate(date: kv.key, entryCount: kv.value); - }).toList(); - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8), - height: histogramHeight, - child: Stack( - children: [ - _buildChart(context, seriesData, drawArea: true), - _buildChart(context, seriesData, drawArea: false), - ], - ), - ), - ValueListenableBuilder( - valueListenable: _selection, - builder: (context, selection, child) { - late Widget child; - if (selection == null) { - child = const SizedBox(); - } else { - final filter = DateFilter(_level, selection.date); - final count = selection.entryCount; - child = Padding( - padding: const EdgeInsets.all(8), - child: Row( - children: [ - AvesFilterChip( - filter: filter, - onTap: widget.onFilterSelection, - ), - const Spacer(), - Text( - numberFormat.format(count), - style: TextStyle( - color: Theme.of(context).textTheme.caption!.color, - ), - textAlign: TextAlign.end, - ), - ], - ), - ); - } - - return AnimatedSwitcher( - duration: context.read().formTransition, - switchInCurve: Curves.easeInOutCubic, - switchOutCurve: Curves.easeInOutCubic, - transitionBuilder: (child, animation) => FadeTransition( - opacity: animation, - child: SizeTransition( - sizeFactor: animation, - axisAlignment: -1, - child: child, - ), - ), - child: child, - ); - }, - ), - ], - ); - } - - Widget _buildChart(BuildContext context, List seriesData, {required bool drawArea}) { - final theme = Theme.of(context); - final accentColor = theme.colorScheme.secondary; - final axisColor = charts.ColorUtil.fromDartColor(drawArea ? Colors.transparent : theme.colorScheme.onPrimary.withOpacity(.9)); - final measureLineColor = charts.ColorUtil.fromDartColor(drawArea ? Colors.transparent : theme.colorScheme.onPrimary.withOpacity(.1)); - final histogramLineColor = charts.ColorUtil.fromDartColor(drawArea ? Colors.white : accentColor); - final histogramPointStrikeColor = axisColor; - final histogramPointFillColor = charts.ColorUtil.fromDartColor(theme.colorScheme.background); - - final series = [ - charts.Series( - id: 'histogramLine', - data: seriesData, - domainFn: (d, i) => d.date, - measureFn: (d, i) => d.entryCount, - colorFn: (d, i) => histogramLineColor, - ), - if (!drawArea) - charts.Series( - id: 'histogramPoints', - data: seriesData, - domainFn: (d, i) => d.date, - measureFn: (d, i) => d.entryCount, - colorFn: (d, i) => histogramPointStrikeColor, - fillColorFn: (d, i) => histogramPointFillColor, - )..setAttribute(charts.rendererIdKey, 'customPoint'), - ]; - - final locale = context.l10n.localeName; - final timeAxisSpec = _firstDate != null && _lastDate != null - ? TimeAxisSpec.forLevel( - locale: locale, - level: _level, - first: _firstDate!, - last: _lastDate!, - ) - : null; - final measureFormat = NumberFormat.decimalPattern(locale); - - final domainAxis = charts.DateTimeAxisSpec( - renderSpec: charts.SmallTickRendererSpec( - labelStyle: charts.TextStyleSpec(color: axisColor), - lineStyle: charts.LineStyleSpec(color: axisColor), - ), - tickProviderSpec: timeAxisSpec != null && timeAxisSpec.tickSpecs.isNotEmpty ? charts.StaticDateTimeTickProviderSpec(timeAxisSpec.tickSpecs) : null, - ); - - Widget chart = charts.TimeSeriesChart( - series, - domainAxis: domainAxis, - primaryMeasureAxis: charts.NumericAxisSpec( - renderSpec: charts.GridlineRendererSpec( - labelStyle: charts.TextStyleSpec(color: axisColor), - lineStyle: charts.LineStyleSpec(color: measureLineColor), - ), - tickFormatterSpec: charts.BasicNumericTickFormatterSpec((v) { - // localize and hide 0 - return (v == null || v == 0) ? '' : measureFormat.format(v); - }), - ), - defaultRenderer: charts.LineRendererConfig( - includeArea: drawArea, - areaOpacity: 1, - ), - customSeriesRenderers: [ - charts.PointRendererConfig( - customRendererId: 'customPoint', - radiusPx: 3, - strokeWidthPx: 2, - symbolRenderer: charts.CircleSymbolRenderer(isSolid: true), - ), - ], - defaultInteractions: false, - behaviors: [ - charts.SelectNearest(), - charts.LinePointHighlighter( - defaultRadiusPx: 8, - radiusPaddingPx: 2, - showHorizontalFollowLine: charts.LinePointHighlighterFollowLineType.nearest, - showVerticalFollowLine: charts.LinePointHighlighterFollowLineType.nearest, - ), - ], - selectionModels: [ - charts.SelectionModelConfig( - changedListener: (model) => _selection.value = model.selectedDatum.firstOrNull?.datum as EntryByDate?, - ) - ], - ); - if (drawArea) { - chart = ShaderMask( - shaderCallback: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - accentColor.withOpacity(0), - accentColor, - ], - ).createShader, - blendMode: BlendMode.srcIn, - child: chart, - ); - } - return chart; - } -} - -@immutable -class EntryByDate extends Equatable { - final DateTime date; - final int entryCount; - - @override - List get props => [date, entryCount]; - - const EntryByDate({ - required this.date, - required this.entryCount, - }); -} diff --git a/lib/widgets/stats/stats_page.dart b/lib/widgets/stats/stats_page.dart index ad4faae6f..c4bdeaf17 100644 --- a/lib/widgets/stats/stats_page.dart +++ b/lib/widgets/stats/stats_page.dart @@ -19,8 +19,8 @@ import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/stats/date/histogram.dart'; import 'package:aves/widgets/stats/filter_table.dart'; -import 'package:aves/widgets/stats/histogram.dart'; import 'package:charts_flutter/flutter.dart' as charts; import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart';