stats: improved histogram axis
This commit is contained in:
parent
358cf901ed
commit
dbc8aa6227
3 changed files with 133 additions and 9 deletions
|
@ -14,6 +14,12 @@ extension ExtraDateTime on DateTime {
|
||||||
bool get isThisMonth => isAtSameMonthAs(DateTime.now());
|
bool get isThisMonth => isAtSameMonthAs(DateTime.now());
|
||||||
|
|
||||||
bool get isThisYear => isAtSameYearAs(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);
|
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||||
|
|
97
lib/widgets/stats/date/axis.dart
Normal file
97
lib/widgets/stats/date/axis.dart
Normal file
|
@ -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<charts.TickSpec<DateTime>> 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<charts.TickSpec<DateTime>> 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<DateTime>(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<charts.TickSpec<DateTime>> 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<DateTime>(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<charts.TickSpec<DateTime>> ticks = [];
|
||||||
|
for (int year = firstYear; year <= lastYear; year += delta) {
|
||||||
|
final tickDate = DateTime(year);
|
||||||
|
ticks.add(charts.TickSpec<DateTime>(tickDate, label: dateFormat.format(tickDate)));
|
||||||
|
}
|
||||||
|
return TimeAxisSpec(ticks);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,14 @@
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/date.dart';
|
import 'package:aves/model/filters/date.dart';
|
||||||
import 'package:aves/theme/durations.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/common/identity/aves_filter_chip.dart';
|
||||||
|
import 'package:aves/widgets/stats/date/axis.dart';
|
||||||
import 'package:charts_flutter/flutter.dart' as charts;
|
import 'package:charts_flutter/flutter.dart' as charts;
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class Histogram extends StatefulWidget {
|
class Histogram extends StatefulWidget {
|
||||||
|
@ -24,6 +27,7 @@ class Histogram extends StatefulWidget {
|
||||||
|
|
||||||
class _HistogramState extends State<Histogram> {
|
class _HistogramState extends State<Histogram> {
|
||||||
DateLevel _level = DateLevel.y;
|
DateLevel _level = DateLevel.y;
|
||||||
|
DateTime? _firstDate, _lastDate;
|
||||||
final Map<DateTime, int> _entryCountPerDate = {};
|
final Map<DateTime, int> _entryCountPerDate = {};
|
||||||
final ValueNotifier<EntryByDate?> _selection = ValueNotifier(null);
|
final ValueNotifier<EntryByDate?> _selection = ValueNotifier(null);
|
||||||
|
|
||||||
|
@ -33,20 +37,20 @@ class _HistogramState extends State<Histogram> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
final entries = widget.entries;
|
final entriesByDateDescending = List.of(widget.entries)..sort(AvesEntry.compareByDate);
|
||||||
final firstDate = entries.firstWhereOrNull((entry) => entry.bestDate != null)?.bestDate;
|
_lastDate = entriesByDateDescending.firstWhereOrNull((entry) => entry.bestDate != null)?.bestDate;
|
||||||
final lastDate = entries.lastWhereOrNull((entry) => entry.bestDate != null)?.bestDate;
|
_firstDate = entriesByDateDescending.lastWhereOrNull((entry) => entry.bestDate != null)?.bestDate;
|
||||||
|
|
||||||
if (lastDate != null && firstDate != null) {
|
if (_lastDate != null && _firstDate != null) {
|
||||||
final range = firstDate.difference(lastDate);
|
final rangeDays = _lastDate!.difference(_firstDate!).inDays;
|
||||||
if (range > const Duration(days: 1)) {
|
if (rangeDays > 1) {
|
||||||
if (range < const Duration(days: 30)) {
|
if (rangeDays <= 31) {
|
||||||
_level = DateLevel.ymd;
|
_level = DateLevel.ymd;
|
||||||
} else if (range < const Duration(days: 365)) {
|
} else if (rangeDays <= 365) {
|
||||||
_level = DateLevel.ym;
|
_level = DateLevel.ym;
|
||||||
}
|
}
|
||||||
|
|
||||||
final dates = entries.map((entry) => entry.bestDate).whereNotNull();
|
final dates = entriesByDateDescending.map((entry) => entry.bestDate).whereNotNull();
|
||||||
late DateTime Function(DateTime) groupByKey;
|
late DateTime Function(DateTime) groupByKey;
|
||||||
switch (_level) {
|
switch (_level) {
|
||||||
case DateLevel.ymd:
|
case DateLevel.ymd:
|
||||||
|
@ -84,8 +88,18 @@ class _HistogramState extends State<Histogram> {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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 axisColor = charts.ColorUtil.fromDartColor(theme.colorScheme.onPrimary.withOpacity(.9));
|
||||||
final measureLineColor = charts.ColorUtil.fromDartColor(theme.colorScheme.onPrimary.withOpacity(.1));
|
final measureLineColor = charts.ColorUtil.fromDartColor(theme.colorScheme.onPrimary.withOpacity(.1));
|
||||||
|
final measureFormat = NumberFormat.decimalPattern(locale);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
@ -100,12 +114,17 @@ class _HistogramState extends State<Histogram> {
|
||||||
labelStyle: charts.TextStyleSpec(color: axisColor),
|
labelStyle: charts.TextStyleSpec(color: axisColor),
|
||||||
lineStyle: charts.LineStyleSpec(color: axisColor),
|
lineStyle: charts.LineStyleSpec(color: axisColor),
|
||||||
),
|
),
|
||||||
|
tickProviderSpec: timeAxisSpec != null && timeAxisSpec.tickSpecs.isNotEmpty ? charts.StaticDateTimeTickProviderSpec(timeAxisSpec.tickSpecs) : null,
|
||||||
),
|
),
|
||||||
primaryMeasureAxis: charts.NumericAxisSpec(
|
primaryMeasureAxis: charts.NumericAxisSpec(
|
||||||
renderSpec: charts.GridlineRendererSpec(
|
renderSpec: charts.GridlineRendererSpec(
|
||||||
labelStyle: charts.TextStyleSpec(color: axisColor),
|
labelStyle: charts.TextStyleSpec(color: axisColor),
|
||||||
lineStyle: charts.LineStyleSpec(color: measureLineColor),
|
lineStyle: charts.LineStyleSpec(color: measureLineColor),
|
||||||
),
|
),
|
||||||
|
tickFormatterSpec: charts.BasicNumericTickFormatterSpec((v) {
|
||||||
|
// localize and hide 0
|
||||||
|
return (v == null || v == 0) ? '' : measureFormat.format(v);
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
defaultRenderer: charts.BarRendererConfig<DateTime>(),
|
defaultRenderer: charts.BarRendererConfig<DateTime>(),
|
||||||
defaultInteractions: false,
|
defaultInteractions: false,
|
||||||
|
@ -152,6 +171,8 @@ class _HistogramState extends State<Histogram> {
|
||||||
|
|
||||||
return AnimatedSwitcher(
|
return AnimatedSwitcher(
|
||||||
duration: context.read<DurationsData>().formTransition,
|
duration: context.read<DurationsData>().formTransition,
|
||||||
|
switchInCurve: Curves.easeInOutCubic,
|
||||||
|
switchOutCurve: Curves.easeInOutCubic,
|
||||||
transitionBuilder: (child, animation) => FadeTransition(
|
transitionBuilder: (child, animation) => FadeTransition(
|
||||||
opacity: animation,
|
opacity: animation,
|
||||||
child: SizeTransition(
|
child: SizeTransition(
|
||||||
|
|
Loading…
Reference in a new issue