stats: animation review
This commit is contained in:
parent
0aa1cdb587
commit
0f391cd3d5
5 changed files with 439 additions and 281 deletions
|
@ -95,6 +95,7 @@ class DurationsData {
|
||||||
// common animations
|
// common animations
|
||||||
final Duration expansionTileAnimation;
|
final Duration expansionTileAnimation;
|
||||||
final Duration formTransition;
|
final Duration formTransition;
|
||||||
|
final Duration chartTransition;
|
||||||
final Duration iconAnimation;
|
final Duration iconAnimation;
|
||||||
final Duration staggeredAnimation;
|
final Duration staggeredAnimation;
|
||||||
final Duration staggeredAnimationPageTarget;
|
final Duration staggeredAnimationPageTarget;
|
||||||
|
@ -110,6 +111,7 @@ class DurationsData {
|
||||||
const DurationsData({
|
const DurationsData({
|
||||||
this.expansionTileAnimation = const Duration(milliseconds: 200),
|
this.expansionTileAnimation = const Duration(milliseconds: 200),
|
||||||
this.formTransition = const Duration(milliseconds: 200),
|
this.formTransition = const Duration(milliseconds: 200),
|
||||||
|
this.chartTransition = const Duration(milliseconds: 400),
|
||||||
this.iconAnimation = const Duration(milliseconds: 300),
|
this.iconAnimation = const Duration(milliseconds: 300),
|
||||||
this.staggeredAnimation = const Duration(milliseconds: 375),
|
this.staggeredAnimation = const Duration(milliseconds: 375),
|
||||||
this.staggeredAnimationPageTarget = const Duration(milliseconds: 800),
|
this.staggeredAnimationPageTarget = const Duration(milliseconds: 800),
|
||||||
|
@ -123,6 +125,7 @@ class DurationsData {
|
||||||
// as of Flutter v2.5.1, `ExpansionPanelList` throws if animation duration is zero
|
// as of Flutter v2.5.1, `ExpansionPanelList` throws if animation duration is zero
|
||||||
expansionTileAnimation: const Duration(microseconds: 1),
|
expansionTileAnimation: const Duration(microseconds: 1),
|
||||||
formTransition: Duration.zero,
|
formTransition: Duration.zero,
|
||||||
|
chartTransition: Duration.zero,
|
||||||
iconAnimation: Duration.zero,
|
iconAnimation: Duration.zero,
|
||||||
staggeredAnimation: Duration.zero,
|
staggeredAnimation: Duration.zero,
|
||||||
staggeredAnimationPageTarget: Duration.zero,
|
staggeredAnimationPageTarget: Duration.zero,
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
@ -9,17 +10,21 @@ 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/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class Histogram extends StatefulWidget {
|
class Histogram extends StatefulWidget {
|
||||||
final Set<AvesEntry> entries;
|
final Set<AvesEntry> entries;
|
||||||
|
final Duration animationDuration;
|
||||||
final FilterCallback onFilterSelection;
|
final FilterCallback onFilterSelection;
|
||||||
|
|
||||||
const Histogram({
|
const Histogram({
|
||||||
super.key,
|
super.key,
|
||||||
required this.entries,
|
required this.entries,
|
||||||
|
required this.animationDuration,
|
||||||
required this.onFilterSelection,
|
required this.onFilterSelection,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -27,16 +32,19 @@ class Histogram extends StatefulWidget {
|
||||||
State<Histogram> createState() => _HistogramState();
|
State<Histogram> createState() => _HistogramState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HistogramState extends State<Histogram> {
|
class _HistogramState extends State<Histogram> with AutomaticKeepAliveClientMixin {
|
||||||
DateLevel _level = DateLevel.y;
|
DateLevel _level = DateLevel.y;
|
||||||
DateTime? _firstDate, _lastDate;
|
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);
|
||||||
List<_EntryByDate>? _seriesData;
|
List<_EntryByDate>? _seriesData;
|
||||||
List<_EntryByDate>? _interpolatedData;
|
late Future<List<_EntryByDate>?> _interpolatedDataLoader;
|
||||||
|
late Future<void> _areaChartLoader;
|
||||||
|
|
||||||
static const histogramHeight = 200.0;
|
static const histogramHeight = 200.0;
|
||||||
|
|
||||||
|
Duration get animationDuration => widget.animationDuration;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
@ -78,22 +86,33 @@ class _HistogramState extends State<Histogram> {
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
// smooth curve
|
// smooth curve
|
||||||
_computeInterpolatedData();
|
_interpolatedDataLoader = compute<_DataInterpolationArg, List<_EntryByDate>?>(
|
||||||
|
_computeInterpolatedData,
|
||||||
|
_DataInterpolationArg(
|
||||||
|
firstDate: _firstDate,
|
||||||
|
lastDate: _lastDate,
|
||||||
|
level: _level,
|
||||||
|
entryCountPerDate: _entryCountPerDate,
|
||||||
|
));
|
||||||
|
_areaChartLoader = _interpolatedDataLoader.then((_) => Future.delayed(animationDuration * timeDilation));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _computeInterpolatedData() {
|
static List<_EntryByDate>? _computeInterpolatedData(_DataInterpolationArg arg) {
|
||||||
final firstDate = _firstDate;
|
final firstDate = arg.firstDate;
|
||||||
final lastDate = _lastDate;
|
final lastDate = arg.lastDate;
|
||||||
if (firstDate == null || lastDate == null) return;
|
final level = arg.level;
|
||||||
|
final entryCountPerDate = arg.entryCountPerDate;
|
||||||
|
|
||||||
|
if (firstDate == null || lastDate == null) return null;
|
||||||
|
|
||||||
final xRange = lastDate.difference(firstDate);
|
final xRange = lastDate.difference(firstDate);
|
||||||
final xRangeInMillis = xRange.inMilliseconds;
|
final xRangeInMillis = xRange.inMilliseconds;
|
||||||
late int xCount;
|
late int xCount;
|
||||||
late DateTime Function(DateTime date) incrementDate;
|
late DateTime Function(DateTime date) incrementDate;
|
||||||
switch (_level) {
|
switch (level) {
|
||||||
case DateLevel.ymd:
|
case DateLevel.ymd:
|
||||||
xCount = xRange.inDays;
|
xCount = xRange.inDays;
|
||||||
incrementDate = (date) => DateTime(date.year, date.month, date.day + 1);
|
incrementDate = (date) => DateTime(date.year, date.month, date.day + 1);
|
||||||
|
@ -107,42 +126,69 @@ class _HistogramState extends State<Histogram> {
|
||||||
incrementDate = (date) => DateTime(date.year + 1);
|
incrementDate = (date) => DateTime(date.year + 1);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
final yMax = _entryCountPerDate.values.reduce(max).toDouble();
|
final yMax = entryCountPerDate.values.reduce(max).toDouble();
|
||||||
final xInterval = yMax / xCount;
|
final xInterval = yMax / xCount;
|
||||||
final controlPoints = <Offset>[];
|
final controlPoints = <Offset>[];
|
||||||
var date = firstDate;
|
var date = firstDate;
|
||||||
for (int i = 0; i <= xCount; i++) {
|
for (int i = 0; i <= xCount; i++) {
|
||||||
controlPoints.add(Offset(i * xInterval, (_entryCountPerDate[date] ?? 0).toDouble()));
|
controlPoints.add(Offset(i * xInterval, (entryCountPerDate[date] ?? 0).toDouble()));
|
||||||
date = incrementDate(date);
|
date = incrementDate(date);
|
||||||
}
|
}
|
||||||
final interpolatedPoints = controlPoints.length > 3 ? CatmullRomSpline(controlPoints).generateSamples().map((sample) => sample.value).toList() : controlPoints;
|
final interpolatedPoints = controlPoints.length > 3 ? CatmullRomSpline(controlPoints).generateSamples().map((sample) => sample.value).toList() : controlPoints;
|
||||||
_interpolatedData = interpolatedPoints.map((p) {
|
final interpolatedData = interpolatedPoints.map((p) {
|
||||||
final date = firstDate.add(Duration(milliseconds: p.dx * xRangeInMillis ~/ yMax));
|
final date = firstDate.add(Duration(milliseconds: p.dx * xRangeInMillis ~/ yMax));
|
||||||
final entryCount = p.dy.clamp(0, yMax);
|
final entryCount = p.dy.clamp(0, yMax);
|
||||||
return _EntryByDate(date: date, entryCount: entryCount);
|
return _EntryByDate(date: date, entryCount: entryCount);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
|
return interpolatedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_seriesData == null || _interpolatedData == null) return const SizedBox();
|
super.build(context);
|
||||||
|
|
||||||
return Column(
|
if (_seriesData == null) return const SizedBox();
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
return FutureBuilder<List<_EntryByDate>?>(
|
||||||
Container(
|
future: _interpolatedDataLoader,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
builder: (context, snapshot) {
|
||||||
height: histogramHeight,
|
final interpolatedData = snapshot.data;
|
||||||
child: Stack(
|
return Column(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
_buildChart(context, _interpolatedData!, isInterpolated: true, isArea: true),
|
children: [
|
||||||
_buildChart(context, _interpolatedData!, isInterpolated: true, isArea: false),
|
Container(
|
||||||
_buildChart(context, _seriesData!, isInterpolated: false, isArea: false),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
],
|
height: histogramHeight,
|
||||||
),
|
child: AnimatedSwitcher(
|
||||||
),
|
duration: animationDuration,
|
||||||
_buildSelectionRow(),
|
child: interpolatedData != null
|
||||||
],
|
? Stack(
|
||||||
|
children: [
|
||||||
|
FutureBuilder<void>(
|
||||||
|
future: _areaChartLoader,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
Widget child = const SizedBox();
|
||||||
|
if (snapshot.connectionState == ConnectionState.done) {
|
||||||
|
child = _buildChart(context, interpolatedData, isInterpolated: true, isArea: true);
|
||||||
|
}
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: animationDuration,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_buildChart(context, interpolatedData, isInterpolated: true, isArea: false),
|
||||||
|
_buildChart(context, _seriesData!, isInterpolated: false, isArea: false),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: const SizedBox(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildSelectionRow(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -200,6 +246,8 @@ class _HistogramState extends State<Histogram> {
|
||||||
|
|
||||||
Widget chart = charts.TimeSeriesChart(
|
Widget chart = charts.TimeSeriesChart(
|
||||||
series,
|
series,
|
||||||
|
animate: false,
|
||||||
|
animationDuration: animationDuration,
|
||||||
domainAxis: domainAxis,
|
domainAxis: domainAxis,
|
||||||
primaryMeasureAxis: charts.NumericAxisSpec(
|
primaryMeasureAxis: charts.NumericAxisSpec(
|
||||||
renderSpec: charts.GridlineRendererSpec(
|
renderSpec: charts.GridlineRendererSpec(
|
||||||
|
@ -308,6 +356,9 @@ class _HistogramState extends State<Histogram> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
|
@ -330,3 +381,16 @@ class _CircleSymbolRenderer extends charts.CircleSymbolRenderer {
|
||||||
@override
|
@override
|
||||||
charts.Color? getSolidFillColor(charts.Color? fillColor) => fillColor;
|
charts.Color? getSolidFillColor(charts.Color? fillColor) => fillColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _DataInterpolationArg {
|
||||||
|
final DateLevel level;
|
||||||
|
final DateTime? firstDate, lastDate;
|
||||||
|
final Map<DateTime, int> entryCountPerDate;
|
||||||
|
|
||||||
|
const _DataInterpolationArg({
|
||||||
|
required this.level,
|
||||||
|
required this.firstDate,
|
||||||
|
required this.lastDate,
|
||||||
|
required this.entryCountPerDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
|
import 'package:aves/model/settings/enums/accessibility_animations.dart';
|
||||||
import 'package:aves/model/settings/enums/enums.dart';
|
import 'package:aves/model/settings/enums/enums.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/utils/constants.dart';
|
import 'package:aves/utils/constants.dart';
|
||||||
|
@ -7,6 +8,7 @@ import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:percent_indicator/linear_percent_indicator.dart';
|
import 'package:percent_indicator/linear_percent_indicator.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class FilterTable<T extends Comparable> extends StatelessWidget {
|
class FilterTable<T extends Comparable> extends StatelessWidget {
|
||||||
final int totalEntryCount;
|
final int totalEntryCount;
|
||||||
|
@ -35,6 +37,7 @@ class FilterTable<T extends Comparable> extends StatelessWidget {
|
||||||
final locale = context.l10n.localeName;
|
final locale = context.l10n.localeName;
|
||||||
final numberFormat = NumberFormat.decimalPattern(locale);
|
final numberFormat = NumberFormat.decimalPattern(locale);
|
||||||
final percentFormat = NumberFormat.percentPattern();
|
final percentFormat = NumberFormat.percentPattern();
|
||||||
|
final animate = context.select<Settings, bool>((v) => v.accessibilityAnimations.animate);
|
||||||
|
|
||||||
final sortedEntries = entryCountMap.entries.toList();
|
final sortedEntries = entryCountMap.entries.toList();
|
||||||
if (sortByCount) {
|
if (sortByCount) {
|
||||||
|
@ -85,7 +88,7 @@ class FilterTable<T extends Comparable> extends StatelessWidget {
|
||||||
lineHeight: lineHeight,
|
lineHeight: lineHeight,
|
||||||
backgroundColor: theme.colorScheme.onPrimary.withOpacity(.1),
|
backgroundColor: theme.colorScheme.onPrimary.withOpacity(.1),
|
||||||
progressColor: isMonochrome ? theme.colorScheme.secondary : color,
|
progressColor: isMonochrome ? theme.colorScheme.secondary : color,
|
||||||
animation: true,
|
animation: animate,
|
||||||
isRTL: isRtl,
|
isRTL: isRtl,
|
||||||
barRadius: barRadius,
|
barRadius: barRadius,
|
||||||
center: Text(
|
center: Text(
|
||||||
|
|
183
lib/widgets/stats/mime_donut.dart
Normal file
183
lib/widgets/stats/mime_donut.dart
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:aves/model/filters/mime.dart';
|
||||||
|
import 'package:aves/model/settings/enums/accessibility_animations.dart';
|
||||||
|
import 'package:aves/model/settings/settings.dart';
|
||||||
|
import 'package:aves/theme/colors.dart';
|
||||||
|
import 'package:aves/theme/icons.dart';
|
||||||
|
import 'package:aves/utils/mime_utils.dart';
|
||||||
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
|
import 'package:aves/widgets/common/identity/aves_filter_chip.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 MimeDonut extends StatefulWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final Map<String, int> byMimeTypes;
|
||||||
|
final Duration animationDuration;
|
||||||
|
final FilterCallback onFilterSelection;
|
||||||
|
|
||||||
|
const MimeDonut({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.byMimeTypes,
|
||||||
|
required this.animationDuration,
|
||||||
|
required this.onFilterSelection,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MimeDonut> createState() => _MimeDonutState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MimeDonutState extends State<MimeDonut> with AutomaticKeepAliveClientMixin {
|
||||||
|
Map<String, int> get byMimeTypes => widget.byMimeTypes;
|
||||||
|
|
||||||
|
static const mimeDonutMinWidth = 124.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context);
|
||||||
|
|
||||||
|
if (byMimeTypes.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final locale = l10n.localeName;
|
||||||
|
final numberFormat = NumberFormat.decimalPattern(locale);
|
||||||
|
|
||||||
|
final sum = byMimeTypes.values.fold<int>(0, (prev, v) => prev + v);
|
||||||
|
|
||||||
|
final colors = context.watch<AvesColorsData>();
|
||||||
|
final seriesData = byMimeTypes.entries.map((kv) {
|
||||||
|
final mimeType = kv.key;
|
||||||
|
final displayText = MimeUtils.displayType(mimeType);
|
||||||
|
return EntryByMimeDatum(
|
||||||
|
mimeType: mimeType,
|
||||||
|
displayText: displayText,
|
||||||
|
color: colors.fromString(displayText),
|
||||||
|
entryCount: kv.value,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
seriesData.sort((d1, d2) {
|
||||||
|
final c = d2.entryCount.compareTo(d1.entryCount);
|
||||||
|
return c != 0 ? c : compareAsciiUpperCase(d1.displayText, d2.displayText);
|
||||||
|
});
|
||||||
|
|
||||||
|
final series = [
|
||||||
|
charts.Series<EntryByMimeDatum, String>(
|
||||||
|
id: 'mime',
|
||||||
|
colorFn: (d, i) => charts.ColorUtil.fromDartColor(d.color),
|
||||||
|
domainFn: (d, i) => d.displayText,
|
||||||
|
measureFn: (d, i) => d.entryCount,
|
||||||
|
data: seriesData,
|
||||||
|
labelAccessorFn: (d, _) => '${d.displayText}: ${d.entryCount}',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return LayoutBuilder(builder: (context, constraints) {
|
||||||
|
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||||
|
final minWidth = mimeDonutMinWidth * textScaleFactor;
|
||||||
|
final availableWidth = constraints.maxWidth;
|
||||||
|
final dim = max(minWidth, availableWidth / (availableWidth > 4 * minWidth ? 4 : (availableWidth > 2 * minWidth ? 2 : 1)));
|
||||||
|
|
||||||
|
final donut = SizedBox(
|
||||||
|
width: dim,
|
||||||
|
height: dim,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
charts.PieChart(
|
||||||
|
series,
|
||||||
|
animate: context.select<Settings, bool>((v) => v.accessibilityAnimations.animate),
|
||||||
|
animationDuration: widget.animationDuration,
|
||||||
|
defaultRenderer: charts.ArcRendererConfig<String>(
|
||||||
|
arcWidth: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(widget.icon),
|
||||||
|
Text(
|
||||||
|
numberFormat.format(sum),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final legend = SizedBox(
|
||||||
|
width: dim,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: seriesData
|
||||||
|
.map((d) => GestureDetector(
|
||||||
|
onTap: () => widget.onFilterSelection(MimeFilter(d.mimeType)),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(AIcons.disc, color: d.color),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
d.displayText,
|
||||||
|
overflow: TextOverflow.fade,
|
||||||
|
softWrap: false,
|
||||||
|
maxLines: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
numberFormat.format(d.entryCount),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).textTheme.caption!.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final children = [
|
||||||
|
donut,
|
||||||
|
legend,
|
||||||
|
];
|
||||||
|
return availableWidth > minWidth * 2
|
||||||
|
? Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: children,
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: children,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class EntryByMimeDatum extends Equatable {
|
||||||
|
final String mimeType, displayText;
|
||||||
|
final Color color;
|
||||||
|
final int entryCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [mimeType, displayText, color, entryCount];
|
||||||
|
|
||||||
|
const EntryByMimeDatum({
|
||||||
|
required this.mimeType,
|
||||||
|
required this.displayText,
|
||||||
|
required this.color,
|
||||||
|
required this.entryCount,
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,19 +1,17 @@
|
||||||
import 'dart:math';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/filters/location.dart';
|
import 'package:aves/model/filters/location.dart';
|
||||||
import 'package:aves/model/filters/mime.dart';
|
|
||||||
import 'package:aves/model/filters/rating.dart';
|
import 'package:aves/model/filters/rating.dart';
|
||||||
import 'package:aves/model/filters/tag.dart';
|
import 'package:aves/model/filters/tag.dart';
|
||||||
import 'package:aves/model/settings/enums/accessibility_animations.dart';
|
import 'package:aves/model/settings/enums/accessibility_animations.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/theme/colors.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/utils/constants.dart';
|
import 'package:aves/utils/constants.dart';
|
||||||
import 'package:aves/utils/mime_utils.dart';
|
|
||||||
import 'package:aves/widgets/collection/collection_page.dart';
|
import 'package:aves/widgets/collection/collection_page.dart';
|
||||||
import 'package:aves/widgets/common/basic/insets.dart';
|
import 'package:aves/widgets/common/basic/insets.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
|
@ -21,285 +19,209 @@ import 'package:aves/widgets/common/identity/empty.dart';
|
||||||
import 'package:aves/widgets/common/providers/media_query_data_provider.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/date/histogram.dart';
|
||||||
import 'package:aves/widgets/stats/filter_table.dart';
|
import 'package:aves/widgets/stats/filter_table.dart';
|
||||||
import 'package:charts_flutter/flutter.dart' as charts;
|
import 'package:aves/widgets/stats/mime_donut.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:percent_indicator/linear_percent_indicator.dart';
|
import 'package:percent_indicator/linear_percent_indicator.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class StatsPage extends StatelessWidget {
|
class StatsPage extends StatefulWidget {
|
||||||
static const routeName = '/collection/stats';
|
static const routeName = '/collection/stats';
|
||||||
|
|
||||||
|
final Set<AvesEntry> entries;
|
||||||
final CollectionSource source;
|
final CollectionSource source;
|
||||||
final CollectionLens? parentCollection;
|
final CollectionLens? parentCollection;
|
||||||
final Set<AvesEntry> entries;
|
|
||||||
final Map<String, int> entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {};
|
|
||||||
final Map<int, int> entryCountPerRating = Map.fromEntries(List.generate(7, (i) => MapEntry(5 - i, 0)));
|
|
||||||
|
|
||||||
static const mimeDonutMinWidth = 124.0;
|
const StatsPage({
|
||||||
|
|
||||||
StatsPage({
|
|
||||||
super.key,
|
super.key,
|
||||||
required this.entries,
|
required this.entries,
|
||||||
required this.source,
|
required this.source,
|
||||||
this.parentCollection,
|
this.parentCollection,
|
||||||
}) {
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatsPage> createState() => _StatsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatsPageState extends State<StatsPage> {
|
||||||
|
final Map<String, int> _entryCountPerCountry = {}, _entryCountPerPlace = {}, _entryCountPerTag = {};
|
||||||
|
final Map<int, int> _entryCountPerRating = Map.fromEntries(List.generate(7, (i) => MapEntry(5 - i, 0)));
|
||||||
|
late final ValueNotifier<bool> _isPageAnimatingNotifier;
|
||||||
|
|
||||||
|
Set<AvesEntry> get entries => widget.entries;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
_isPageAnimatingNotifier = ValueNotifier(true);
|
||||||
|
Future.delayed(Durations.pageTransitionAnimation * timeDilation).then((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_isPageAnimatingNotifier.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
entries.forEach((entry) {
|
entries.forEach((entry) {
|
||||||
if (entry.hasAddress) {
|
if (entry.hasAddress) {
|
||||||
final address = entry.addressDetails!;
|
final address = entry.addressDetails!;
|
||||||
var country = address.countryName;
|
var country = address.countryName;
|
||||||
if (country != null && country.isNotEmpty) {
|
if (country != null && country.isNotEmpty) {
|
||||||
country += '${LocationFilter.locationSeparator}${address.countryCode}';
|
country += '${LocationFilter.locationSeparator}${address.countryCode}';
|
||||||
entryCountPerCountry[country] = (entryCountPerCountry[country] ?? 0) + 1;
|
_entryCountPerCountry[country] = (_entryCountPerCountry[country] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
final place = address.place;
|
final place = address.place;
|
||||||
if (place != null && place.isNotEmpty) {
|
if (place != null && place.isNotEmpty) {
|
||||||
entryCountPerPlace[place] = (entryCountPerPlace[place] ?? 0) + 1;
|
_entryCountPerPlace[place] = (_entryCountPerPlace[place] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.tags.forEach((tag) {
|
entry.tags.forEach((tag) {
|
||||||
entryCountPerTag[tag] = (entryCountPerTag[tag] ?? 0) + 1;
|
_entryCountPerTag[tag] = (_entryCountPerTag[tag] ?? 0) + 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
final rating = entry.rating;
|
final rating = entry.rating;
|
||||||
entryCountPerRating[rating] = (entryCountPerRating[rating] ?? 0) + 1;
|
_entryCountPerRating[rating] = (_entryCountPerRating[rating] ?? 0) + 1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
return ValueListenableBuilder<bool>(
|
||||||
final locale = l10n.localeName;
|
valueListenable: _isPageAnimatingNotifier,
|
||||||
final numberFormat = NumberFormat.decimalPattern(locale);
|
builder: (context, animating, child) {
|
||||||
final percentFormat = NumberFormat.percentPattern();
|
final l10n = context.l10n;
|
||||||
|
|
||||||
Widget child;
|
Widget child = const SizedBox();
|
||||||
if (entries.isEmpty) {
|
|
||||||
child = EmptyContent(
|
if (!animating) {
|
||||||
icon: AIcons.image,
|
final durations = context.watch<DurationsData>();
|
||||||
text: l10n.collectionEmptyImages,
|
final percentFormat = NumberFormat.percentPattern();
|
||||||
);
|
|
||||||
} else {
|
if (entries.isEmpty) {
|
||||||
final theme = Theme.of(context);
|
child = EmptyContent(
|
||||||
final isDark = theme.brightness == Brightness.dark;
|
icon: AIcons.image,
|
||||||
final animate = context.select<Settings, bool>((v) => v.accessibilityAnimations.animate);
|
text: l10n.collectionEmptyImages,
|
||||||
final byMimeTypes = groupBy<AvesEntry, String>(entries, (entry) => entry.mimeType).map<String, int>((k, v) => MapEntry(k, v.length));
|
);
|
||||||
final imagesByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('image')));
|
} else {
|
||||||
final videoByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('video')));
|
final theme = Theme.of(context);
|
||||||
final mimeDonuts = Provider.value(
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
value: isDark ? NeonOnDark() : PastelOnLight(),
|
final chartAnimationDuration = context.read<DurationsData>().chartTransition;
|
||||||
child: Builder(
|
|
||||||
builder: (context) {
|
final byMimeTypes = groupBy<AvesEntry, String>(entries, (entry) => entry.mimeType).map<String, int>((k, v) => MapEntry(k, v.length));
|
||||||
return Wrap(
|
final imagesByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('image')));
|
||||||
|
final videoByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('video')));
|
||||||
|
final mimeDonuts = Wrap(
|
||||||
alignment: WrapAlignment.center,
|
alignment: WrapAlignment.center,
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_buildMimeDonut(context, AIcons.image, imagesByMimeTypes, animate, numberFormat),
|
MimeDonut(
|
||||||
_buildMimeDonut(context, AIcons.video, videoByMimeTypes, animate, numberFormat),
|
icon: AIcons.image,
|
||||||
|
byMimeTypes: imagesByMimeTypes,
|
||||||
|
animationDuration: chartAnimationDuration,
|
||||||
|
onFilterSelection: (filter) => _onFilterSelection(context, filter),
|
||||||
|
),
|
||||||
|
MimeDonut(
|
||||||
|
icon: AIcons.video,
|
||||||
|
byMimeTypes: videoByMimeTypes,
|
||||||
|
animationDuration: chartAnimationDuration,
|
||||||
|
onFilterSelection: (filter) => _onFilterSelection(context, filter),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final catalogued = entries.where((entry) => entry.isCatalogued);
|
final catalogued = entries.where((entry) => entry.isCatalogued);
|
||||||
final withGps = catalogued.where((entry) => entry.hasGps);
|
final withGps = catalogued.where((entry) => entry.hasGps);
|
||||||
final withGpsCount = withGps.length;
|
final withGpsCount = withGps.length;
|
||||||
final withGpsPercent = withGpsCount / entries.length;
|
final withGpsPercent = withGpsCount / entries.length;
|
||||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||||
final lineHeight = 16 * textScaleFactor;
|
final lineHeight = 16 * textScaleFactor;
|
||||||
final barRadius = Radius.circular(lineHeight / 2);
|
final barRadius = Radius.circular(lineHeight / 2);
|
||||||
final locationIndicator = Padding(
|
final locationIndicator = Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(AIcons.location),
|
|
||||||
Expanded(
|
|
||||||
child: LinearPercentIndicator(
|
|
||||||
percent: withGpsPercent,
|
|
||||||
lineHeight: lineHeight,
|
|
||||||
backgroundColor: theme.colorScheme.onPrimary.withOpacity(.1),
|
|
||||||
progressColor: theme.colorScheme.secondary,
|
|
||||||
animation: animate,
|
|
||||||
isRTL: context.isRtl,
|
|
||||||
barRadius: barRadius,
|
|
||||||
center: Text(
|
|
||||||
percentFormat.format(withGpsPercent),
|
|
||||||
style: TextStyle(
|
|
||||||
shadows: isDark ? Constants.embossShadows : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: lineHeight),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// end padding to match leading, so that inside label is aligned with outside label below
|
|
||||||
const SizedBox(width: 24),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
l10n.statsWithGps(withGpsCount),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final showRatings = entryCountPerRating.entries.any((kv) => kv.key != 0 && kv.value > 0);
|
|
||||||
child = ListView(
|
|
||||||
children: [
|
|
||||||
mimeDonuts,
|
|
||||||
Histogram(
|
|
||||||
entries: entries,
|
|
||||||
onFilterSelection: (filter) => _onFilterSelection(context, filter),
|
|
||||||
),
|
|
||||||
locationIndicator,
|
|
||||||
..._buildFilterSection<String>(context, l10n.statsTopCountriesSectionTitle, entryCountPerCountry, (v) => LocationFilter(LocationLevel.country, v)),
|
|
||||||
..._buildFilterSection<String>(context, l10n.statsTopPlacesSectionTitle, entryCountPerPlace, (v) => LocationFilter(LocationLevel.place, v)),
|
|
||||||
..._buildFilterSection<String>(context, l10n.statsTopTagsSectionTitle, entryCountPerTag, TagFilter.new),
|
|
||||||
if (showRatings) ..._buildFilterSection<int>(context, l10n.searchRatingSectionTitle, entryCountPerRating, RatingFilter.new, sortByCount: false, maxRowCount: null),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return MediaQueryDataProvider(
|
|
||||||
child: Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(l10n.statsPageTitle),
|
|
||||||
),
|
|
||||||
body: GestureAreaProtectorStack(
|
|
||||||
child: SafeArea(
|
|
||||||
bottom: false,
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildMimeDonut(
|
|
||||||
BuildContext context,
|
|
||||||
IconData icon,
|
|
||||||
Map<String, int> byMimeTypes,
|
|
||||||
bool animate,
|
|
||||||
NumberFormat numberFormat,
|
|
||||||
) {
|
|
||||||
if (byMimeTypes.isEmpty) return const SizedBox.shrink();
|
|
||||||
|
|
||||||
final sum = byMimeTypes.values.fold<int>(0, (prev, v) => prev + v);
|
|
||||||
|
|
||||||
final colors = context.watch<AvesColorsData>();
|
|
||||||
final seriesData = byMimeTypes.entries.map((kv) {
|
|
||||||
final mimeType = kv.key;
|
|
||||||
final displayText = MimeUtils.displayType(mimeType);
|
|
||||||
return EntryByMimeDatum(
|
|
||||||
mimeType: mimeType,
|
|
||||||
displayText: displayText,
|
|
||||||
color: colors.fromString(displayText),
|
|
||||||
entryCount: kv.value,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
seriesData.sort((d1, d2) {
|
|
||||||
final c = d2.entryCount.compareTo(d1.entryCount);
|
|
||||||
return c != 0 ? c : compareAsciiUpperCase(d1.displayText, d2.displayText);
|
|
||||||
});
|
|
||||||
|
|
||||||
final series = [
|
|
||||||
charts.Series<EntryByMimeDatum, String>(
|
|
||||||
id: 'mime',
|
|
||||||
colorFn: (d, i) => charts.ColorUtil.fromDartColor(d.color),
|
|
||||||
domainFn: (d, i) => d.displayText,
|
|
||||||
measureFn: (d, i) => d.entryCount,
|
|
||||||
data: seriesData,
|
|
||||||
labelAccessorFn: (d, _) => '${d.displayText}: ${d.entryCount}',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
return LayoutBuilder(builder: (context, constraints) {
|
|
||||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
|
||||||
final minWidth = mimeDonutMinWidth * textScaleFactor;
|
|
||||||
final availableWidth = constraints.maxWidth;
|
|
||||||
final dim = max(minWidth, availableWidth / (availableWidth > 4 * minWidth ? 4 : (availableWidth > 2 * minWidth ? 2 : 1)));
|
|
||||||
|
|
||||||
final donut = SizedBox(
|
|
||||||
width: dim,
|
|
||||||
height: dim,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
charts.PieChart(
|
|
||||||
series,
|
|
||||||
animate: animate,
|
|
||||||
defaultRenderer: charts.ArcRendererConfig<String>(
|
|
||||||
arcWidth: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Center(
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
Icon(icon),
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(AIcons.location),
|
||||||
|
Expanded(
|
||||||
|
child: LinearPercentIndicator(
|
||||||
|
percent: withGpsPercent,
|
||||||
|
lineHeight: lineHeight,
|
||||||
|
backgroundColor: theme.colorScheme.onPrimary.withOpacity(.1),
|
||||||
|
progressColor: theme.colorScheme.secondary,
|
||||||
|
animation: context.select<Settings, bool>((v) => v.accessibilityAnimations.animate),
|
||||||
|
isRTL: context.isRtl,
|
||||||
|
barRadius: barRadius,
|
||||||
|
center: Text(
|
||||||
|
percentFormat.format(withGpsPercent),
|
||||||
|
style: TextStyle(
|
||||||
|
shadows: isDark ? Constants.embossShadows : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: lineHeight),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// end padding to match leading, so that inside label is aligned with outside label below
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
numberFormat.format(sum),
|
l10n.statsWithGps(withGpsCount),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final legend = SizedBox(
|
|
||||||
width: dim,
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: seriesData
|
|
||||||
.map((d) => GestureDetector(
|
|
||||||
onTap: () => _onFilterSelection(context, MimeFilter(d.mimeType)),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(AIcons.disc, color: d.color),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
d.displayText,
|
|
||||||
overflow: TextOverflow.fade,
|
|
||||||
softWrap: false,
|
|
||||||
maxLines: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
numberFormat.format(d.entryCount),
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(context).textTheme.caption!.color,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
))
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final children = [
|
|
||||||
donut,
|
|
||||||
legend,
|
|
||||||
];
|
|
||||||
return availableWidth > minWidth * 2
|
|
||||||
? Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: children,
|
|
||||||
)
|
|
||||||
: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: children,
|
|
||||||
);
|
);
|
||||||
});
|
final showRatings = _entryCountPerRating.entries.any((kv) => kv.key != 0 && kv.value > 0);
|
||||||
|
child = AnimationLimiter(
|
||||||
|
child: ListView(
|
||||||
|
children: AnimationConfiguration.toStaggeredList(
|
||||||
|
duration: durations.staggeredAnimation,
|
||||||
|
delay: durations.staggeredAnimationDelay * timeDilation,
|
||||||
|
childAnimationBuilder: (child) => SlideAnimation(
|
||||||
|
verticalOffset: 50.0,
|
||||||
|
child: FadeInAnimation(
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
mimeDonuts,
|
||||||
|
Histogram(
|
||||||
|
entries: entries,
|
||||||
|
animationDuration: chartAnimationDuration,
|
||||||
|
onFilterSelection: (filter) => _onFilterSelection(context, filter),
|
||||||
|
),
|
||||||
|
locationIndicator,
|
||||||
|
..._buildFilterSection<String>(context, l10n.statsTopCountriesSectionTitle, _entryCountPerCountry, (v) => LocationFilter(LocationLevel.country, v)),
|
||||||
|
..._buildFilterSection<String>(context, l10n.statsTopPlacesSectionTitle, _entryCountPerPlace, (v) => LocationFilter(LocationLevel.place, v)),
|
||||||
|
..._buildFilterSection<String>(context, l10n.statsTopTagsSectionTitle, _entryCountPerTag, TagFilter.new),
|
||||||
|
if (showRatings) ..._buildFilterSection<int>(context, l10n.searchRatingSectionTitle, _entryCountPerRating, RatingFilter.new, sortByCount: false, maxRowCount: null),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaQueryDataProvider(
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(l10n.statsPageTitle),
|
||||||
|
),
|
||||||
|
body: GestureAreaProtectorStack(
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _buildFilterSection<T extends Comparable>(
|
List<Widget> _buildFilterSection<T extends Comparable>(
|
||||||
|
@ -332,7 +254,7 @@ class StatsPage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onFilterSelection(BuildContext context, CollectionFilter filter) {
|
void _onFilterSelection(BuildContext context, CollectionFilter filter) {
|
||||||
if (parentCollection != null) {
|
if (widget.parentCollection != null) {
|
||||||
_applyToParentCollectionPage(context, filter);
|
_applyToParentCollectionPage(context, filter);
|
||||||
} else {
|
} else {
|
||||||
_jumpToCollectionPage(context, filter);
|
_jumpToCollectionPage(context, filter);
|
||||||
|
@ -340,7 +262,7 @@ class StatsPage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applyToParentCollectionPage(BuildContext context, CollectionFilter filter) {
|
void _applyToParentCollectionPage(BuildContext context, CollectionFilter filter) {
|
||||||
parentCollection!.addFilter(filter);
|
widget.parentCollection!.addFilter(filter);
|
||||||
// We delay closing the current page after applying the filter selection
|
// We delay closing the current page after applying the filter selection
|
||||||
// so that hero animation target is ready in the `FilterBar`,
|
// so that hero animation target is ready in the `FilterBar`,
|
||||||
// even when the target is a child of an `AnimatedList`.
|
// even when the target is a child of an `AnimatedList`.
|
||||||
|
@ -355,7 +277,7 @@ class StatsPage extends StatelessWidget {
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
settings: const RouteSettings(name: CollectionPage.routeName),
|
settings: const RouteSettings(name: CollectionPage.routeName),
|
||||||
builder: (context) => CollectionPage(
|
builder: (context) => CollectionPage(
|
||||||
source: source,
|
source: widget.source,
|
||||||
filters: {filter},
|
filters: {filter},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -363,20 +285,3 @@ class StatsPage extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@immutable
|
|
||||||
class EntryByMimeDatum extends Equatable {
|
|
||||||
final String mimeType, displayText;
|
|
||||||
final Color color;
|
|
||||||
final int entryCount;
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [mimeType, displayText, color, entryCount];
|
|
||||||
|
|
||||||
const EntryByMimeDatum({
|
|
||||||
required this.mimeType,
|
|
||||||
required this.displayText,
|
|
||||||
required this.color,
|
|
||||||
required this.entryCount,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in a new issue