diff --git a/lib/utils/time_utils.dart b/lib/utils/time_utils.dart index f448aef68..1c28bae55 100644 --- a/lib/utils/time_utils.dart +++ b/lib/utils/time_utils.dart @@ -14,6 +14,12 @@ extension ExtraDateTime on DateTime { bool get isThisMonth => isAtSameMonthAs(DateTime.now()); bool get isThisYear => isAtSameYearAs(DateTime.now()); + + DateTime get date => DateTime(year, month, day); + + DateTime addMonths(int months) => DateTime(year, month + months, day, hour, minute, second, millisecond, microsecond); + + DateTime addDays(int days) => DateTime(year, month, day + days, hour, minute, second, millisecond, microsecond); } final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); diff --git a/lib/widgets/stats/date/axis.dart b/lib/widgets/stats/date/axis.dart new file mode 100644 index 000000000..af043e92b --- /dev/null +++ b/lib/widgets/stats/date/axis.dart @@ -0,0 +1,97 @@ +import 'dart:math'; + +import 'package:aves/model/filters/date.dart'; +import 'package:aves/utils/time_utils.dart'; +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:intl/intl.dart'; + +// cf charts.DateTimeTickFormatter factory internals for default formats +class TimeAxisSpec { + final List> tickSpecs; + + TimeAxisSpec(this.tickSpecs); + + factory TimeAxisSpec.forLevel({ + required String locale, + required DateLevel level, + required DateTime first, + required DateTime last, + }) { + switch (level) { + case DateLevel.ymd: + return TimeAxisSpec.days(locale, first, last); + case DateLevel.ym: + return TimeAxisSpec.months(locale, first, last); + case DateLevel.y: + default: + return TimeAxisSpec.years(locale, first, last); + } + } + + factory TimeAxisSpec.days(String locale, DateTime first, DateTime last) { + final daysTickLongFormat = DateFormat.MMMd(locale); + final daysTickShortFormat = DateFormat.d(locale); + + first = first.date; + last = last.date; + final rangeDays = last.difference(first).inDays; + final delta = max(1, (rangeDays / 5).ceil()); + + List> ticks = []; + int lastContext = -1; + DateFormat dateFormat; + for (int i = 0; i < rangeDays; i += delta) { + final tickDate = first.addDays(i); + if (lastContext != tickDate.month) { + lastContext = tickDate.month; + dateFormat = daysTickLongFormat; + } else { + dateFormat = daysTickShortFormat; + } + ticks.add(charts.TickSpec(tickDate, label: dateFormat.format(tickDate))); + } + return TimeAxisSpec(ticks); + } + + factory TimeAxisSpec.months(String locale, DateTime first, DateTime last) { + final monthsTickLongFormat = DateFormat.yMMM(locale); + final monthsTickShortFormat = DateFormat.MMM(locale); + + first = DateTime(first.year, first.month); + last = DateTime(last.year, last.month); + final rangeMonths = last.month - first.month + (last.month < first.month ? 12 : 0); + if (rangeMonths < 12) { + first = first.addMonths(-((12 - rangeMonths) / 2).floor()); + } + + List> ticks = []; + int lastContext = -1; + DateFormat dateFormat; + for (int i = 0; i < DateTime.monthsPerYear; i += 3) { + final tickDate = first.addMonths(2 + i); + if (lastContext != tickDate.year) { + lastContext = tickDate.year; + dateFormat = monthsTickLongFormat; + } else { + dateFormat = monthsTickShortFormat; + } + ticks.add(charts.TickSpec(tickDate, label: dateFormat.format(tickDate))); + } + return TimeAxisSpec(ticks); + } + + factory TimeAxisSpec.years(String locale, DateTime first, DateTime last) { + final dateFormat = DateFormat.y(locale); + + final firstYear = first.year; + final lastYear = last.year; + final delta = max(1, ((lastYear - firstYear) / 5).ceil()); + + List> ticks = []; + for (int year = firstYear; year <= lastYear; year += delta) { + final tickDate = DateTime(year); + ticks.add(charts.TickSpec(tickDate, label: dateFormat.format(tickDate))); + } + return TimeAxisSpec(ticks); + } +} diff --git a/lib/widgets/stats/histogram.dart b/lib/widgets/stats/histogram.dart index af8dbd0af..f4e3d2eb0 100644 --- a/lib/widgets/stats/histogram.dart +++ b/lib/widgets/stats/histogram.dart @@ -1,11 +1,14 @@ 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 { @@ -24,6 +27,7 @@ class Histogram extends StatefulWidget { class _HistogramState extends State { DateLevel _level = DateLevel.y; + DateTime? _firstDate, _lastDate; final Map _entryCountPerDate = {}; final ValueNotifier _selection = ValueNotifier(null); @@ -33,20 +37,20 @@ class _HistogramState extends State { void initState() { super.initState(); - final entries = widget.entries; - final firstDate = entries.firstWhereOrNull((entry) => entry.bestDate != null)?.bestDate; - final lastDate = entries.lastWhereOrNull((entry) => entry.bestDate != null)?.bestDate; + 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 range = firstDate.difference(lastDate); - if (range > const Duration(days: 1)) { - if (range < const Duration(days: 30)) { + if (_lastDate != null && _firstDate != null) { + final rangeDays = _lastDate!.difference(_firstDate!).inDays; + if (rangeDays > 1) { + if (rangeDays <= 31) { _level = DateLevel.ymd; - } else if (range < const Duration(days: 365)) { + } else if (rangeDays <= 365) { _level = DateLevel.ym; } - final dates = entries.map((entry) => entry.bestDate).whereNotNull(); + final dates = entriesByDateDescending.map((entry) => entry.bestDate).whereNotNull(); late DateTime Function(DateTime) groupByKey; switch (_level) { case DateLevel.ymd: @@ -84,8 +88,18 @@ class _HistogramState extends State { ), ]; + final locale = context.l10n.localeName; + final timeAxisSpec = _firstDate != null && _lastDate != null + ? TimeAxisSpec.forLevel( + locale: locale, + level: _level, + first: _firstDate!, + last: _lastDate!, + ) + : null; final axisColor = charts.ColorUtil.fromDartColor(theme.colorScheme.onPrimary.withOpacity(.9)); final measureLineColor = charts.ColorUtil.fromDartColor(theme.colorScheme.onPrimary.withOpacity(.1)); + final measureFormat = NumberFormat.decimalPattern(locale); return Column( mainAxisSize: MainAxisSize.min, @@ -100,12 +114,17 @@ class _HistogramState extends State { labelStyle: charts.TextStyleSpec(color: axisColor), lineStyle: charts.LineStyleSpec(color: axisColor), ), + tickProviderSpec: timeAxisSpec != null && timeAxisSpec.tickSpecs.isNotEmpty ? charts.StaticDateTimeTickProviderSpec(timeAxisSpec.tickSpecs) : null, ), 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.BarRendererConfig(), defaultInteractions: false, @@ -152,6 +171,8 @@ class _HistogramState extends State { return AnimatedSwitcher( duration: context.read().formTransition, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, transitionBuilder: (child, animation) => FadeTransition( opacity: animation, child: SizeTransition(