245 lines
8.4 KiB
Dart
245 lines
8.4 KiB
Dart
import 'dart:math';
|
|
|
|
import 'package:aves/model/filters/filters.dart';
|
|
import 'package:aves/model/filters/location.dart';
|
|
import 'package:aves/model/filters/tag.dart';
|
|
import 'package:aves/model/image_entry.dart';
|
|
import 'package:aves/model/source/collection_lens.dart';
|
|
import 'package:aves/utils/color_utils.dart';
|
|
import 'package:aves/utils/constants.dart';
|
|
import 'package:aves/widgets/album/empty.dart';
|
|
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
|
|
import 'package:aves/widgets/common/icons.dart';
|
|
import 'package:aves/widgets/stats/filter_table.dart';
|
|
import 'package:charts_flutter/flutter.dart' as charts;
|
|
import 'package:collection/collection.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:percent_indicator/linear_percent_indicator.dart';
|
|
|
|
class StatsPage extends StatelessWidget {
|
|
final CollectionLens collection;
|
|
final Map<String, int> entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {};
|
|
|
|
List<ImageEntry> get entries => collection.sortedEntries;
|
|
|
|
static const mimeDonutMinWidth = 124.0;
|
|
|
|
StatsPage({this.collection}) {
|
|
entries.forEach((entry) {
|
|
if (entry.isLocated) {
|
|
final address = entry.addressDetails;
|
|
var country = address.countryName;
|
|
if (country != null && country.isNotEmpty) {
|
|
country += ';${address.countryCode}';
|
|
entryCountPerCountry[country] = (entryCountPerCountry[country] ?? 0) + 1;
|
|
}
|
|
final place = address.place;
|
|
if (place != null && place.isNotEmpty) {
|
|
entryCountPerPlace[place] = (entryCountPerPlace[place] ?? 0) + 1;
|
|
}
|
|
}
|
|
entry.xmpSubjects.forEach((tag) {
|
|
entryCountPerTag[tag] = (entryCountPerTag[tag] ?? 0) + 1;
|
|
});
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
Widget child;
|
|
if (collection.isEmpty) {
|
|
child = const EmptyContent(
|
|
icon: AIcons.image,
|
|
text: 'No images',
|
|
);
|
|
} else {
|
|
final byMimeTypes = groupBy<ImageEntry, 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/')));
|
|
final videoByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('video/')));
|
|
final mimeDonuts = Wrap(
|
|
alignment: WrapAlignment.center,
|
|
children: [
|
|
_buildMimeDonut(context, (sum) => Intl.plural(sum, one: 'image', other: 'images'), imagesByMimeTypes),
|
|
_buildMimeDonut(context, (sum) => Intl.plural(sum, one: 'video', other: 'videos'), videoByMimeTypes),
|
|
],
|
|
);
|
|
|
|
final catalogued = entries.where((entry) => entry.isCatalogued);
|
|
final withGps = catalogued.where((entry) => entry.hasGps);
|
|
final withGpsPercent = withGps.length / collection.entryCount;
|
|
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
|
final lineHeight = 16 * textScaleFactor;
|
|
final locationIndicator = Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
children: [
|
|
LinearPercentIndicator(
|
|
percent: withGpsPercent,
|
|
lineHeight: lineHeight,
|
|
backgroundColor: Colors.white24,
|
|
progressColor: Theme.of(context).accentColor,
|
|
animation: true,
|
|
leading: const Icon(AIcons.location),
|
|
// right padding to match leading, so that inside label is aligned with outside label below
|
|
padding: EdgeInsets.symmetric(horizontal: lineHeight) + const EdgeInsets.only(right: 24),
|
|
center: Text(NumberFormat.percentPattern().format(withGpsPercent)),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text('${withGps.length} ${Intl.plural(withGps.length, one: 'item', other: 'items')} with location'),
|
|
],
|
|
),
|
|
);
|
|
child = ListView(
|
|
children: [
|
|
mimeDonuts,
|
|
locationIndicator,
|
|
..._buildTopFilters('Top Countries', entryCountPerCountry, (s) => LocationFilter(LocationLevel.country, s)),
|
|
..._buildTopFilters('Top Places', entryCountPerPlace, (s) => LocationFilter(LocationLevel.place, s)),
|
|
..._buildTopFilters('Top Tags', entryCountPerTag, (s) => TagFilter(s)),
|
|
],
|
|
);
|
|
}
|
|
return MediaQueryDataProvider(
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Stats'),
|
|
),
|
|
body: SafeArea(
|
|
child: child,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _cleanMime(String mime) {
|
|
mime = mime.toUpperCase().replaceFirst(RegExp('.*/(X-)?'), '').replaceFirst('+XML', '').replaceFirst('VND.', '');
|
|
return mime;
|
|
}
|
|
|
|
Widget _buildMimeDonut(BuildContext context, String Function(num) label, Map<String, num> byMimeTypes) {
|
|
if (byMimeTypes.isEmpty) return const SizedBox.shrink();
|
|
|
|
final sum = byMimeTypes.values.fold<int>(0, (prev, v) => prev + v);
|
|
|
|
final seriesData = byMimeTypes.entries.map((kv) => StringNumDatum(_cleanMime(kv.key), kv.value)).toList();
|
|
seriesData.sort((kv1, kv2) {
|
|
final c = kv2.value.compareTo(kv1.value);
|
|
return c != 0 ? c : compareAsciiUpperCase(kv1.key, kv2.key);
|
|
});
|
|
|
|
final series = [
|
|
charts.Series<StringNumDatum, String>(
|
|
id: 'mime',
|
|
colorFn: (d, i) => charts.ColorUtil.fromDartColor(stringToColor(d.key)),
|
|
domainFn: (d, i) => d.key,
|
|
measureFn: (d, i) => d.value,
|
|
data: seriesData,
|
|
labelAccessorFn: (d, _) => '${d.key}: ${d.value}',
|
|
),
|
|
];
|
|
|
|
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 = Container(
|
|
width: dim,
|
|
height: dim,
|
|
child: Stack(
|
|
children: [
|
|
charts.PieChart(
|
|
series,
|
|
defaultRenderer: charts.ArcRendererConfig(
|
|
arcWidth: 16,
|
|
),
|
|
),
|
|
Center(
|
|
child: Text(
|
|
'$sum\n${label(sum)}',
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
final legend = SizedBox(
|
|
width: dim,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: seriesData
|
|
.map((kv) => Text.rich(
|
|
TextSpan(
|
|
children: [
|
|
WidgetSpan(
|
|
alignment: PlaceholderAlignment.middle,
|
|
child: Padding(
|
|
padding: const EdgeInsetsDirectional.only(end: 8),
|
|
child: Icon(AIcons.disc, color: stringToColor(kv.key)),
|
|
),
|
|
),
|
|
TextSpan(text: '${kv.key} '),
|
|
TextSpan(text: '${kv.value}', style: const TextStyle(color: Colors.white70)),
|
|
],
|
|
),
|
|
overflow: TextOverflow.fade,
|
|
softWrap: false,
|
|
maxLines: 1,
|
|
))
|
|
.toList(),
|
|
),
|
|
);
|
|
final children = [
|
|
donut,
|
|
legend,
|
|
];
|
|
return availableWidth > minWidth * 2
|
|
? Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: children,
|
|
)
|
|
: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: children,
|
|
);
|
|
});
|
|
}
|
|
|
|
List<Widget> _buildTopFilters(
|
|
String title,
|
|
Map<String, int> entryCountMap,
|
|
CollectionFilter Function(String key) filterBuilder,
|
|
) {
|
|
if (entryCountMap.isEmpty) return [];
|
|
|
|
return [
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Text(
|
|
title,
|
|
style: Constants.titleTextStyle,
|
|
),
|
|
),
|
|
FilterTable(
|
|
collection: collection,
|
|
entryCountMap: entryCountMap,
|
|
filterBuilder: filterBuilder,
|
|
),
|
|
];
|
|
}
|
|
}
|
|
|
|
class StringNumDatum {
|
|
final String key;
|
|
final num value;
|
|
|
|
const StringNumDatum(this.key, this.value);
|
|
|
|
@override
|
|
String toString() {
|
|
return '[$runtimeType#$hashCode: key=$key, value=$value]';
|
|
}
|
|
}
|