collection: added group & sort options

This commit is contained in:
Thibault Deckers 2019-08-28 23:43:37 +09:00
parent 5bb2e914c6
commit 98def189dc
11 changed files with 152 additions and 63 deletions

View file

@ -37,7 +37,11 @@ class HomePage extends StatefulWidget {
class _HomePageState extends State<HomePage> { class _HomePageState extends State<HomePage> {
static const EventChannel eventChannel = EventChannel('deckers.thibault/aves/mediastore'); static const EventChannel eventChannel = EventChannel('deckers.thibault/aves/mediastore');
ImageCollection localMediaCollection = ImageCollection(List()); ImageCollection localMediaCollection = ImageCollection(
entries: List(),
groupFactor: settings.collectionGroupFactor,
sortFactor: settings.collectionSortFactor,
);
@override @override
void initState() { void initState() {
@ -56,7 +60,7 @@ class _HomePageState extends State<HomePage> {
await metadataDb.init(); await metadataDb.init();
eventChannel.receiveBroadcastStream().cast<Map>().listen( eventChannel.receiveBroadcastStream().cast<Map>().listen(
(entryMap) => localMediaCollection.entries.add(ImageEntry.fromMap(entryMap)), (entryMap) => localMediaCollection.add(ImageEntry.fromMap(entryMap)),
onDone: () async { onDone: () async {
debugPrint('mediastore stream done'); debugPrint('mediastore stream done');
await localMediaCollection.loadCatalogMetadata(); await localMediaCollection.loadCatalogMetadata();

View file

@ -6,31 +6,62 @@ import "package:collection/collection.dart";
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ImageCollection with ChangeNotifier { class ImageCollection with ChangeNotifier {
final List<ImageEntry> entries; final List<ImageEntry> _rawEntries;
GroupFactor groupFactor = GroupFactor.date; GroupFactor groupFactor = GroupFactor.date;
SortFactor sortFactor = SortFactor.date;
ImageCollection(this.entries); ImageCollection({
@required List<ImageEntry> entries,
@required this.groupFactor,
@required this.sortFactor,
}) : _rawEntries = entries;
Map<dynamic, List<ImageEntry>> get sections { Map<dynamic, List<ImageEntry>> get sections {
switch (sortFactor) {
case SortFactor.date:
switch (groupFactor) { switch (groupFactor) {
case GroupFactor.album: case GroupFactor.album:
return groupBy(entries, (entry) => entry.bucketDisplayName); return groupBy(_rawEntries, (entry) => entry.bucketDisplayName);
case GroupFactor.date: case GroupFactor.date:
return groupBy(entries, (entry) => entry.monthTaken); return groupBy(_rawEntries, (entry) => entry.monthTaken);
}
break;
case SortFactor.size:
return Map.fromEntries([MapEntry('All', _rawEntries)]);
} }
return Map(); return Map();
} }
List<ImageEntry> get sortedEntries {
return List.unmodifiable(sections.entries.expand((e) => e.value));
}
group(GroupFactor groupFactor) { group(GroupFactor groupFactor) {
this.groupFactor = groupFactor; this.groupFactor = groupFactor;
notifyListeners(); notifyListeners();
} }
sort(SortFactor sortFactor) {
this.sortFactor = sortFactor;
switch (sortFactor) {
case SortFactor.date:
_rawEntries.sort((a, b) => b.bestDate.compareTo(a.bestDate));
break;
case SortFactor.size:
_rawEntries.sort((a, b) => b.sizeBytes.compareTo(a.sizeBytes));
break;
}
notifyListeners();
}
add(ImageEntry entry) => _rawEntries.add(entry);
Future<bool> delete(ImageEntry entry) async { Future<bool> delete(ImageEntry entry) async {
final success = await ImageFileService.delete(entry); final success = await ImageFileService.delete(entry);
if (success) { if (success) {
entries.remove(entry); _rawEntries.remove(entry);
notifyListeners(); notifyListeners();
} }
return success; return success;
@ -40,7 +71,7 @@ class ImageCollection with ChangeNotifier {
debugPrint('$runtimeType loadCatalogMetadata start'); debugPrint('$runtimeType loadCatalogMetadata start');
final start = DateTime.now(); final start = DateTime.now();
final saved = await metadataDb.loadMetadataEntries(); final saved = await metadataDb.loadMetadataEntries();
entries.forEach((entry) { _rawEntries.forEach((entry) {
final contentId = entry.contentId; final contentId = entry.contentId;
if (contentId != null) { if (contentId != null) {
entry.catalogMetadata = saved.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null); entry.catalogMetadata = saved.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null);
@ -53,7 +84,7 @@ class ImageCollection with ChangeNotifier {
debugPrint('$runtimeType loadAddresses start'); debugPrint('$runtimeType loadAddresses start');
final start = DateTime.now(); final start = DateTime.now();
final saved = await metadataDb.loadAddresses(); final saved = await metadataDb.loadAddresses();
entries.forEach((entry) { _rawEntries.forEach((entry) {
final contentId = entry.contentId; final contentId = entry.contentId;
if (contentId != null) { if (contentId != null) {
entry.addressDetails = saved.firstWhere((address) => address.contentId == contentId, orElse: () => null); entry.addressDetails = saved.firstWhere((address) => address.contentId == contentId, orElse: () => null);
@ -65,24 +96,23 @@ class ImageCollection with ChangeNotifier {
catalogEntries() async { catalogEntries() async {
debugPrint('$runtimeType catalogEntries start'); debugPrint('$runtimeType catalogEntries start');
final start = DateTime.now(); final start = DateTime.now();
final uncataloguedEntries = entries.where((entry) => !entry.isCatalogued); final uncataloguedEntries = _rawEntries.where((entry) => !entry.isCatalogued);
final newMetadata = List<CatalogMetadata>(); final newMetadata = List<CatalogMetadata>();
await Future.forEach<ImageEntry>(uncataloguedEntries, (entry) async { await Future.forEach<ImageEntry>(uncataloguedEntries, (entry) async {
await entry.catalog(); await entry.catalog();
newMetadata.add(entry.catalogMetadata); newMetadata.add(entry.catalogMetadata);
}); });
metadataDb.saveMetadata(List.unmodifiable(newMetadata));
debugPrint('$runtimeType catalogEntries complete in ${DateTime.now().difference(start).inSeconds}s with ${newMetadata.length} new entries'); debugPrint('$runtimeType catalogEntries complete in ${DateTime.now().difference(start).inSeconds}s with ${newMetadata.length} new entries');
// sort with more accurate date // notify because metadata dates might change groups and order
entries.sort((a, b) => b.bestDate.compareTo(a.bestDate)); notifyListeners();
metadataDb.saveMetadata(List.unmodifiable(newMetadata));
} }
locateEntries() async { locateEntries() async {
debugPrint('$runtimeType locateEntries start'); debugPrint('$runtimeType locateEntries start');
final start = DateTime.now(); final start = DateTime.now();
final unlocatedEntries = entries.where((entry) => !entry.isLocated); final unlocatedEntries = _rawEntries.where((entry) => entry.hasGps && !entry.isLocated);
final newAddresses = List<AddressDetails>(); final newAddresses = List<AddressDetails>();
await Future.forEach<ImageEntry>(unlocatedEntries, (entry) async { await Future.forEach<ImageEntry>(unlocatedEntries, (entry) async {
await entry.locate(); await entry.locate();
@ -92,8 +122,11 @@ class ImageCollection with ChangeNotifier {
newAddresses.clear(); newAddresses.clear();
} }
}); });
metadataDb.saveAddresses(List.unmodifiable(newAddresses));
debugPrint('$runtimeType locateEntries complete in ${DateTime.now().difference(start).inSeconds}s'); debugPrint('$runtimeType locateEntries complete in ${DateTime.now().difference(start).inSeconds}s');
} }
} }
enum SortFactor { date, size }
enum GroupFactor { album, date } enum GroupFactor { album, date }

View file

@ -147,10 +147,12 @@ class ImageEntry with ChangeNotifier {
locate() async { locate() async {
if (isLocated) return; if (isLocated) return;
await catalog(); await catalog();
final latitude = catalogMetadata?.latitude; final latitude = catalogMetadata?.latitude;
final longitude = catalogMetadata?.longitude; final longitude = catalogMetadata?.longitude;
if (latitude != null && longitude != null) { if (latitude == null || longitude == null) return;
final coordinates = Coordinates(latitude, longitude); final coordinates = Coordinates(latitude, longitude);
try { try {
final addresses = await Geocoder.local.findAddressesFromCoordinates(coordinates); final addresses = await Geocoder.local.findAddressesFromCoordinates(coordinates);
@ -169,7 +171,6 @@ class ImageEntry with ChangeNotifier {
debugPrint('$runtimeType addAddressToMetadata failed with exception=${e.message}'); debugPrint('$runtimeType addAddressToMetadata failed with exception=${e.message}');
} }
} }
}
String get shortAddress { String get shortAddress {
if (!isLocated) return ''; if (!isLocated) return '';

View file

@ -16,7 +16,6 @@ class ImageFileService {
} }
static Future<Uint8List> getImageBytes(ImageEntry entry, int width, int height) async { static Future<Uint8List> getImageBytes(ImageEntry entry, int width, int height) async {
// debugPrint('getImageBytes with path=${entry.path} contentId=${entry.contentId}');
if (width > 0 && height > 0) { if (width > 0 && height > 0) {
try { try {
final result = await platform.invokeMethod('getImageBytes', <String, dynamic>{ final result = await platform.invokeMethod('getImageBytes', <String, dynamic>{
@ -32,16 +31,6 @@ class ImageFileService {
return Uint8List(0); return Uint8List(0);
} }
static cancelGetImageBytes(String uri) async {
try {
await platform.invokeMethod('cancelGetImageBytes', <String, dynamic>{
'uri': uri,
});
} on PlatformException catch (e) {
debugPrint('cancelGetImageBytes failed with exception=${e.message}');
}
}
static Future<bool> delete(ImageEntry entry) async { static Future<bool> delete(ImageEntry entry) async {
try { try {
await platform.invokeMethod('delete', <String, dynamic>{ await platform.invokeMethod('delete', <String, dynamic>{

View file

@ -1,3 +1,4 @@
import 'package:aves/model/image_collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -14,6 +15,8 @@ class Settings {
Settings._private(); Settings._private();
// preferences // preferences
static const collectionGroupFactorKey = 'collection_group_factor';
static const collectionSortFactorKey = 'collection_sort_factor';
static const infoMapZoomKey = 'info_map_zoom'; static const infoMapZoomKey = 'info_map_zoom';
init() async { init() async {
@ -44,10 +47,28 @@ class Settings {
set infoMapZoom(double newValue) => setAndNotify(infoMapZoomKey, newValue); set infoMapZoom(double newValue) => setAndNotify(infoMapZoomKey, newValue);
GroupFactor get collectionGroupFactor => getEnumOrDefault(collectionGroupFactorKey, GroupFactor.date, GroupFactor.values);
set collectionGroupFactor(GroupFactor newValue) => setAndNotify(collectionGroupFactorKey, newValue.toString());
SortFactor get collectionSortFactor => getEnumOrDefault(collectionSortFactorKey, SortFactor.date, SortFactor.values);
set collectionSortFactor(SortFactor newValue) => setAndNotify(collectionSortFactorKey, newValue.toString());
// convenience methods // convenience methods
bool getBoolOrDefault(String key, bool defaultValue) => prefs.getKeys().contains(key) ? prefs.getBool(key) : defaultValue; bool getBoolOrDefault(String key, bool defaultValue) => prefs.getKeys().contains(key) ? prefs.getBool(key) : defaultValue;
T getEnumOrDefault<T>(String key, T defaultValue, List<T> values) {
final valueString = prefs.getString(key);
for (T element in values) {
if (element.toString() == valueString) {
return element;
}
}
return defaultValue;
}
setAndNotify(String key, dynamic newValue) { setAndNotify(String key, dynamic newValue) {
var oldValue = prefs.get(key); var oldValue = prefs.get(key);
if (newValue == null) { if (newValue == null) {

View file

@ -1,4 +1,5 @@
import 'package:aves/model/image_collection.dart'; import 'package:aves/model/image_collection.dart';
import 'package:aves/model/settings.dart';
import 'package:aves/widgets/album/search_delegate.dart'; import 'package:aves/widgets/album/search_delegate.dart';
import 'package:aves/widgets/album/thumbnail_collection.dart'; import 'package:aves/widgets/album/thumbnail_collection.dart';
import 'package:aves/widgets/common/menu_row.dart'; import 'package:aves/widgets/common/menu_row.dart';
@ -26,6 +27,16 @@ class AllCollectionPage extends StatelessWidget {
), ),
PopupMenuButton<AlbumAction>( PopupMenuButton<AlbumAction>(
itemBuilder: (context) => [ itemBuilder: (context) => [
PopupMenuItem(
value: AlbumAction.sortByDate,
child: MenuRow(text: 'Sort by date', checked: collection.sortFactor == SortFactor.date),
),
PopupMenuItem(
value: AlbumAction.sortBySize,
child: MenuRow(text: 'Sort by size', checked: collection.sortFactor == SortFactor.size),
),
PopupMenuDivider(),
if (collection.sortFactor == SortFactor.date) ...[
PopupMenuItem( PopupMenuItem(
value: AlbumAction.groupByAlbum, value: AlbumAction.groupByAlbum,
child: MenuRow(text: 'Group by album', checked: collection.groupFactor == GroupFactor.album), child: MenuRow(text: 'Group by album', checked: collection.groupFactor == GroupFactor.album),
@ -35,8 +46,9 @@ class AllCollectionPage extends StatelessWidget {
child: MenuRow(text: 'Group by date', checked: collection.groupFactor == GroupFactor.date), child: MenuRow(text: 'Group by date', checked: collection.groupFactor == GroupFactor.date),
), ),
PopupMenuDivider(), PopupMenuDivider(),
],
PopupMenuItem( PopupMenuItem(
value: AlbumAction.groupByAlbum, value: AlbumAction.debug,
child: MenuRow(text: 'Debug', icon: Icons.whatshot), child: MenuRow(text: 'Debug', icon: Icons.whatshot),
), ),
], ],
@ -50,14 +62,22 @@ class AllCollectionPage extends StatelessWidget {
onActionSelected(BuildContext context, AlbumAction action) { onActionSelected(BuildContext context, AlbumAction action) {
switch (action) { switch (action) {
case AlbumAction.debug:
goToDebug(context);
break;
case AlbumAction.groupByAlbum: case AlbumAction.groupByAlbum:
collection.group(GroupFactor.album); collection.group(GroupFactor.album);
break; break;
case AlbumAction.groupByDate: case AlbumAction.groupByDate:
collection.group(GroupFactor.date); collection.group(GroupFactor.date);
break; break;
case AlbumAction.debug: case AlbumAction.sortByDate:
goToDebug(context); settings.collectionSortFactor = SortFactor.date;
collection.sort(SortFactor.date);
break;
case AlbumAction.sortBySize:
settings.collectionSortFactor = SortFactor.size;
collection.sort(SortFactor.size);
break; break;
} }
} }
@ -67,11 +87,11 @@ class AllCollectionPage extends StatelessWidget {
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => DebugPage( builder: (context) => DebugPage(
entries: collection.entries, entries: collection.sortedEntries,
), ),
), ),
); );
} }
} }
enum AlbumAction { groupByDate, groupByAlbum, debug } enum AlbumAction { debug, groupByAlbum, groupByDate, sortByDate, sortBySize }

View file

@ -52,7 +52,7 @@ class ImageSearchDelegate extends SearchDelegate<ImageEntry> {
return SizedBox.shrink(); return SizedBox.shrink();
} }
final lowerQuery = query.toLowerCase(); final lowerQuery = query.toLowerCase();
final matches = collection.entries.where((entry) => entry.search(lowerQuery)).toList(); final matches = collection.sortedEntries.where((entry) => entry.search(lowerQuery)).toList();
if (matches.isEmpty) { if (matches.isEmpty) {
return Center( return Center(
child: Text( child: Text(
@ -61,6 +61,12 @@ class ImageSearchDelegate extends SearchDelegate<ImageEntry> {
), ),
); );
} }
return ThumbnailCollection(collection: ImageCollection(matches)); return ThumbnailCollection(
collection: ImageCollection(
entries: matches,
groupFactor: collection.groupFactor,
sortFactor: collection.sortFactor,
),
);
} }
} }

View file

@ -54,7 +54,6 @@ class ThumbnailState extends State<Thumbnail> {
@override @override
void dispose() { void dispose() {
entry.removeListener(onEntryChange); entry.removeListener(onEntryChange);
ImageFileService.cancelGetImageBytes(uri);
super.dispose(); super.dispose();
} }

View file

@ -91,10 +91,20 @@ class SectionSliver extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// debugPrint('$runtimeType build with sectionKey=$sectionKey');
final columnCount = 4; final columnCount = 4;
Widget header = SizedBox.shrink();
if (collection.sortFactor == SortFactor.date) {
switch (collection.groupFactor) {
case GroupFactor.album:
header = SectionHeader(text: sectionKey);
break;
case GroupFactor.date:
header = MonthSectionHeader(date: sectionKey);
break;
}
}
return SliverStickyHeader( return SliverStickyHeader(
header: collection.groupFactor == GroupFactor.date ? MonthSectionHeader(date: sectionKey) : SectionHeader(text: sectionKey), header: header,
sliver: SliverGrid( sliver: SliverGrid(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(sliverContext, index) { (sliverContext, index) {

View file

@ -1,6 +1,7 @@
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/settings.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -39,6 +40,11 @@ class DebugPageState extends State<DebugPage> {
body: Column( body: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Settings'),
Text('collectionGroupFactor: ${settings.collectionGroupFactor}'),
Text('collectionSortFactor: ${settings.collectionSortFactor}'),
Text('infoMapZoom: ${settings.infoMapZoom}'),
Divider(),
Text('Entries: ${entries.length}'), Text('Entries: ${entries.length}'),
...byMimeTypes.keys.map((mimeType) => Text('- $mimeType: ${byMimeTypes[mimeType].length}')), ...byMimeTypes.keys.map((mimeType) => Text('- $mimeType: ${byMimeTypes[mimeType].length}')),
Text('Catalogued: ${catalogued.length}'), Text('Catalogued: ${catalogued.length}'),

View file

@ -100,7 +100,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
ImageCollection get collection => widget.collection; ImageCollection get collection => widget.collection;
List<ImageEntry> get entries => widget.collection.entries; List<ImageEntry> get entries => widget.collection.sortedEntries;
@override @override
void initState() { void initState() {
@ -342,7 +342,7 @@ class ImagePage extends StatefulWidget {
} }
class ImagePageState extends State<ImagePage> with AutomaticKeepAliveClientMixin { class ImagePageState extends State<ImagePage> with AutomaticKeepAliveClientMixin {
List<ImageEntry> get entries => widget.collection.entries; List<ImageEntry> get entries => widget.collection.sortedEntries;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {