Skip to content

Commit f472f45

Browse files
committed
#1921 stats: export to CSV/JSON file selected fields of filtered collection
1 parent 6350109 commit f472f45

File tree

6 files changed

+233
-0
lines changed

6 files changed

+233
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file.
99
- Collection: allow using width/height when bulk renaming
1010
- Video: allow forcing hardware acceleration
1111
- Search: allow regex (wrapped in `/.../`) in query filter
12+
- Stats: export to CSV/JSON file selected fields of filtered collection
1213

1314
### Fixed
1415

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import 'package:aves/widgets/common/extensions/build_context.dart';
2+
import 'package:aves_model/aves_model.dart';
3+
import 'package:flutter/widgets.dart';
4+
5+
extension ExtraExportableEntryFields on ExportableEntryField {
6+
String getText(BuildContext context) {
7+
final l10n = context.l10n;
8+
return switch (this) {
9+
ExportableEntryField.uri => l10n.viewerInfoLabelUri,
10+
ExportableEntryField.path => l10n.viewerInfoLabelPath,
11+
ExportableEntryField.date => l10n.viewerInfoLabelDate,
12+
ExportableEntryField.size => l10n.viewerInfoLabelSize,
13+
ExportableEntryField.width => l10n.exportEntryDialogWidth,
14+
ExportableEntryField.height => l10n.exportEntryDialogHeight,
15+
ExportableEntryField.duration => l10n.viewerInfoLabelDuration,
16+
ExportableEntryField.coordinates => l10n.viewerInfoLabelCoordinates,
17+
ExportableEntryField.address => l10n.viewerInfoLabelAddress,
18+
};
19+
}
20+
}

lib/widgets/common/basic/text_dropdown_button.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class TextDropdownButton<T> extends StatefulWidget {
99
final bool isExpanded;
1010
final double? itemHeight;
1111
final Color? dropdownColor;
12+
final EdgeInsetsGeometry? padding;
1213
final ValueChanged<T?>? onChanged;
1314

1415
const TextDropdownButton({
@@ -21,6 +22,7 @@ class TextDropdownButton<T> extends StatefulWidget {
2122
this.isExpanded = false,
2223
this.itemHeight = kMinInteractiveDimension,
2324
this.dropdownColor,
25+
this.padding,
2426
required this.onChanged,
2527
});
2628

@@ -50,6 +52,7 @@ class _TextDropdownButtonState<T> extends State<TextDropdownButton<T>> {
5052
isExpanded: widget.isExpanded,
5153
itemHeight: widget.itemHeight,
5254
dropdownColor: widget.dropdownColor,
55+
padding: widget.padding,
5356
onChanged: widget.onChanged,
5457
);
5558
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import 'package:aves/ref/mime_types.dart';
2+
import 'package:aves/theme/themes.dart';
3+
import 'package:aves/utils/mime_utils.dart';
4+
import 'package:aves/view/src/metadata/exportable_fields.dart';
5+
import 'package:aves/widgets/common/basic/text/outlined.dart';
6+
import 'package:aves/widgets/common/basic/text_dropdown_button.dart';
7+
import 'package:aves/widgets/common/extensions/build_context.dart';
8+
import 'package:aves/widgets/common/identity/highlight_title.dart';
9+
import 'package:aves/widgets/dialogs/aves_dialog.dart';
10+
import 'package:aves_model/aves_model.dart';
11+
import 'package:flutter/material.dart';
12+
13+
class ExportCollectionStatsDialog extends StatefulWidget {
14+
static const routeName = '/dialog/export_collection_stats';
15+
16+
const ExportCollectionStatsDialog({super.key});
17+
18+
@override
19+
State<ExportCollectionStatsDialog> createState() => _ExportCollectionStatsDialogState();
20+
}
21+
22+
class _ExportCollectionStatsDialogState extends State<ExportCollectionStatsDialog> {
23+
static const List<ExportableEntryField> _entryFieldOptions = ExportableEntryField.values;
24+
final Set<ExportableEntryField> _selectedFields = {};
25+
static const List<String> _exportMimeTypeOptions = [MimeTypes.csv, MimeTypes.json];
26+
late String _exportMimeType;
27+
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
28+
29+
@override
30+
void initState() {
31+
super.initState();
32+
_exportMimeType = MimeTypes.csv;
33+
_validate();
34+
}
35+
36+
@override
37+
void dispose() {
38+
_isValidNotifier.dispose();
39+
super.dispose();
40+
}
41+
42+
@override
43+
Widget build(BuildContext context) {
44+
final l10n = context.l10n;
45+
return AvesDialog(
46+
title: l10n.settingsActionExport,
47+
scrollableContent: [
48+
TextDropdownButton<String>(
49+
values: _exportMimeTypeOptions,
50+
valueText: MimeUtils.displayType,
51+
value: _exportMimeType,
52+
onChanged: (v) {
53+
_exportMimeType = v!;
54+
setState(() {});
55+
},
56+
isExpanded: true,
57+
dropdownColor: Themes.thirdLayerColor(context),
58+
padding: const EdgeInsets.symmetric(horizontal: 16),
59+
),
60+
..._entryFieldOptions.map(_toTile),
61+
],
62+
actions: [
63+
const CancelButton(),
64+
ValueListenableBuilder<bool>(
65+
valueListenable: _isValidNotifier,
66+
builder: (context, isValid, child) {
67+
return TextButton(
68+
onPressed: isValid ? () => _submit(context) : null,
69+
child: Text(context.l10n.applyButtonLabel),
70+
);
71+
},
72+
),
73+
],
74+
);
75+
}
76+
77+
Widget _toTile(ExportableEntryField field) {
78+
return SwitchListTile(
79+
value: _selectedFields.contains(field),
80+
onChanged: (selected) {
81+
selected ? _selectedFields.add(field) : _selectedFields.remove(field);
82+
setState(_validate);
83+
},
84+
title: Align(
85+
alignment: Alignment.centerLeft,
86+
child: OutlinedText(
87+
textSpans: [
88+
TextSpan(
89+
text: field.getText(context),
90+
style: TextStyle(
91+
shadows: HighlightTitle.shadows(context),
92+
),
93+
),
94+
],
95+
outlineColor: Themes.firstLayerColor(context),
96+
),
97+
),
98+
);
99+
}
100+
101+
void _validate() => _isValidNotifier.value = _selectedFields.isNotEmpty;
102+
103+
void _submit(BuildContext context) => Navigator.maybeOf(context)?.pop((_exportMimeType, _selectedFields));
104+
}

lib/widgets/stats/stats_page.dart

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import 'dart:async';
2+
import 'dart:convert';
3+
import 'dart:typed_data';
24

35
import 'package:aves/model/entry/entry.dart';
6+
import 'package:aves/model/entry/extensions/location.dart';
47
import 'package:aves/model/filters/covered/location.dart';
58
import 'package:aves/model/filters/covered/stored_album.dart';
69
import 'package:aves/model/filters/covered/tag.dart';
@@ -9,12 +12,16 @@ import 'package:aves/model/filters/rating.dart';
912
import 'package:aves/model/settings/settings.dart';
1013
import 'package:aves/model/source/collection_lens.dart';
1114
import 'package:aves/model/source/collection_source.dart';
15+
import 'package:aves/ref/locales.dart';
16+
import 'package:aves/ref/mime_types.dart';
17+
import 'package:aves/services/common/services.dart';
1218
import 'package:aves/theme/durations.dart';
1319
import 'package:aves/theme/format.dart';
1420
import 'package:aves/theme/icons.dart';
1521
import 'package:aves/theme/styles.dart';
1622
import 'package:aves/theme/themes.dart';
1723
import 'package:aves/utils/file_utils.dart';
24+
import 'package:aves/view/src/metadata/exportable_fields.dart';
1825
import 'package:aves/widgets/collection/collection_page.dart';
1926
import 'package:aves/widgets/common/action_mixins/feedback.dart';
2027
import 'package:aves/widgets/common/action_mixins/vault_aware.dart';
@@ -23,16 +30,20 @@ import 'package:aves/widgets/common/basic/scaffold.dart';
2330
import 'package:aves/widgets/common/basic/tv_edge_focus.dart';
2431
import 'package:aves/widgets/common/extensions/build_context.dart';
2532
import 'package:aves/widgets/common/identity/empty.dart';
33+
import 'package:aves/widgets/dialogs/export_collection_stats_dialog.dart';
2634
import 'package:aves/widgets/stats/date/histogram.dart';
2735
import 'package:aves/widgets/stats/filter_table.dart';
2836
import 'package:aves/widgets/stats/mime_donut.dart';
2937
import 'package:aves/widgets/stats/percent_text.dart';
3038
import 'package:aves/widgets/stats/top_page.dart';
3139
import 'package:aves/widgets/viewer/controls/notifications.dart';
40+
import 'package:aves_model/aves_model.dart';
3241
import 'package:collection/collection.dart';
42+
import 'package:csv/csv.dart';
3343
import 'package:flutter/material.dart';
3444
import 'package:flutter/scheduler.dart';
3545
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
46+
import 'package:intl/intl.dart';
3647
import 'package:percent_indicator/linear_percent_indicator.dart';
3748
import 'package:provider/provider.dart';
3849

@@ -211,6 +222,13 @@ class _StatsPageState extends State<StatsPage> with FeedbackMixin, VaultAwareMix
211222
appBar: AppBar(
212223
automaticallyImplyLeading: !useTvLayout,
213224
title: Text(l10n.statsPageTitle),
225+
actions: [
226+
IconButton(
227+
icon: Icon(AIcons.fileExport),
228+
onPressed: () => _export(context),
229+
tooltip: context.l10n.settingsActionExport,
230+
),
231+
],
214232
),
215233
body: GestureAreaProtectorStack(
216234
child: SafeArea(
@@ -378,6 +396,81 @@ class _StatsPageState extends State<StatsPage> with FeedbackMixin, VaultAwareMix
378396
(route) => false,
379397
);
380398
}
399+
400+
Future<void> _export(BuildContext context) async {
401+
final options = await showDialog<(String, Set<ExportableEntryField>)>(
402+
context: context,
403+
builder: (context) => const ExportCollectionStatsDialog(),
404+
routeSettings: const RouteSettings(name: ExportCollectionStatsDialog.routeName),
405+
);
406+
if (options == null) return;
407+
408+
final (mimeType, fieldSet) = options;
409+
final index = ExportableEntryField.values.indexOf;
410+
final fieldList = fieldSet.sorted((a, b) => index(a).compareTo(index(b)));
411+
412+
String body = '';
413+
switch (mimeType) {
414+
case MimeTypes.csv:
415+
body = _exportToCsv(fieldList, context);
416+
case MimeTypes.json:
417+
body = _exportToJson(fieldList);
418+
}
419+
420+
final success = await storageService.createFile(
421+
'aves-stats-${DateFormat('yyyyMMdd_HHmmss', asciiLocale).format(DateTime.now())}${MimeTypes.extensionFor(mimeType)}',
422+
mimeType,
423+
Uint8List.fromList(utf8.encode(body)),
424+
);
425+
if (success != null) {
426+
if (success) {
427+
showFeedback(context, FeedbackType.info, context.l10n.genericSuccessFeedback);
428+
} else {
429+
showFeedback(context, FeedbackType.warn, context.l10n.genericFailureFeedback);
430+
}
431+
}
432+
}
433+
434+
Object? _exportEntryField(AvesEntry entry, ExportableEntryField field) {
435+
switch (field) {
436+
case ExportableEntryField.uri:
437+
return entry.uri;
438+
case ExportableEntryField.path:
439+
return entry.path;
440+
case ExportableEntryField.date:
441+
return entry.bestDate?.toIso8601String();
442+
case ExportableEntryField.size:
443+
return entry.sizeBytes;
444+
case ExportableEntryField.width:
445+
return entry.displaySize.width.toInt();
446+
case ExportableEntryField.height:
447+
return entry.displaySize.height.toInt();
448+
case ExportableEntryField.duration:
449+
final durationMillis = entry.durationMillis ?? 0;
450+
return durationMillis > 0 ? durationMillis : null;
451+
case ExportableEntryField.coordinates:
452+
final latLng = entry.latLng;
453+
return latLng != null ? '${latLng.latitude},${latLng.longitude}' : null;
454+
case ExportableEntryField.address:
455+
final shortAddress = entry.shortAddress;
456+
return shortAddress.isNotEmpty ? shortAddress : null;
457+
}
458+
}
459+
460+
String _exportToCsv(List<ExportableEntryField> fields, BuildContext context) {
461+
final headers = fields.map((v) => v.getText(context)).toList();
462+
List<String?> toCsvValues(AvesEntry entry) => fields.map((field) {
463+
return _exportEntryField(entry, field)?.toString();
464+
}).toList();
465+
return const ListToCsvConverter().convert([headers, ...entries.map(toCsvValues)], convertNullTo: '');
466+
}
467+
468+
String _exportToJson(List<ExportableEntryField> fields) {
469+
Map<String, Object?> toJsonMap(AvesEntry entry) => Map.fromEntries(fields.map((field) {
470+
return MapEntry(field.name, _exportEntryField(entry, field));
471+
}));
472+
return jsonEncode(entries.map(toJsonMap).toList());
473+
}
381474
}
382475

383476
class _LocationIndicator extends StatelessWidget {

plugins/aves_model/lib/src/metadata/enums.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,15 @@ class MetadataTypes {
7373
MetadataType.jpegDucky,
7474
};
7575
}
76+
77+
enum ExportableEntryField {
78+
uri,
79+
path,
80+
date,
81+
size,
82+
width,
83+
height,
84+
duration,
85+
coordinates,
86+
address,
87+
}

0 commit comments

Comments
 (0)