diff --git a/app/lib/app_init.dart b/app/lib/app_init.dart index d1ca85ed..c4db8b0f 100644 --- a/app/lib/app_init.dart +++ b/app/lib/app_init.dart @@ -16,6 +16,8 @@ import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/entity/file/data_source2.dart'; import 'package:nc_photos/entity/file/repo.dart'; +import 'package:nc_photos/entity/image_location/data_source.dart'; +import 'package:nc_photos/entity/image_location/repo.dart'; import 'package:nc_photos/entity/local_file.dart'; import 'package:nc_photos/entity/local_file/data_source.dart'; import 'package:nc_photos/entity/nc_album/data_source.dart'; @@ -187,6 +189,8 @@ Future _initDiContainer(InitIsolateType isolateType) async { const BasicRecognizeFaceRepo(RecognizeFaceRemoteDataSource()); c.recognizeFaceRepoLocal = BasicRecognizeFaceRepo(RecognizeFaceSqliteDbDataSource(c.npDb)); + c.imageLocationRepo = + BasicImageLocationRepo(ImageLocationNpDbDataSource(c.npDb)); c.touchManager = TouchManager(c); diff --git a/app/lib/controller/pref_controller.dart b/app/lib/controller/pref_controller.dart index e218318d..4bab89a1 100644 --- a/app/lib/controller/pref_controller.dart +++ b/app/lib/controller/pref_controller.dart @@ -1,11 +1,14 @@ // ignore_for_file: deprecated_member_use_from_same_package +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/collection/util.dart'; import 'package:nc_photos/entity/pref.dart'; +import 'package:nc_photos/json_util.dart'; import 'package:nc_photos/language_util.dart'; import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/protected_page_handler.dart'; @@ -158,6 +161,13 @@ class PrefController { value: value, ); + Future setMapBrowserPrevPosition(MapCoord value) => _set( + controller: _mapBrowserPrevPositionController, + setter: (pref, value) => pref.setMapBrowserPrevPosition( + jsonEncode([value!.latitude, value.longitude])), + value: value, + ); + Future _set({ required BehaviorSubject controller, required Future Function(Pref pref, T value) setter, @@ -258,6 +268,11 @@ class PrefController { @npSubjectAccessor late final _isDontShowVideoPreviewHintController = BehaviorSubject.seeded(_c.pref.isDontShowVideoPreviewHintOr(false)); + @npSubjectAccessor + late final _mapBrowserPrevPositionController = BehaviorSubject.seeded(_c.pref + .getMapBrowserPrevPosition() + ?.let(tryJsonDecode) + ?.let(_tryMapCoordFromJson)); } @npSubjectAccessor diff --git a/app/lib/controller/pref_controller.g.dart b/app/lib/controller/pref_controller.g.dart index edef3a3e..689ebad0 100644 --- a/app/lib/controller/pref_controller.g.dart +++ b/app/lib/controller/pref_controller.g.dart @@ -157,6 +157,15 @@ extension $PrefControllerNpSubjectAccessor on PrefController { isDontShowVideoPreviewHint.distinct().skip(1); bool get isDontShowVideoPreviewHintValue => _isDontShowVideoPreviewHintController.value; +// _mapBrowserPrevPositionController + ValueStream get mapBrowserPrevPosition => + _mapBrowserPrevPositionController.stream; + Stream get mapBrowserPrevPositionNew => + mapBrowserPrevPosition.skip(1); + Stream get mapBrowserPrevPositionChange => + mapBrowserPrevPosition.distinct().skip(1); + MapCoord? get mapBrowserPrevPositionValue => + _mapBrowserPrevPositionController.value; } extension $SecurePrefControllerNpSubjectAccessor on SecurePrefController { diff --git a/app/lib/controller/pref_controller/util.dart b/app/lib/controller/pref_controller/util.dart index f68d5fc0..64f6d1d9 100644 --- a/app/lib/controller/pref_controller/util.dart +++ b/app/lib/controller/pref_controller/util.dart @@ -88,4 +88,20 @@ extension on Pref { isDontShowVideoPreviewHint() ?? def; Future setDontShowVideoPreviewHint(bool value) => provider.setBool(PrefKey.dontShowVideoPreviewHint, value); + + String? getMapBrowserPrevPosition() => + provider.getString(PrefKey.mapBrowserPrevPosition); + Future setMapBrowserPrevPosition(String value) => + provider.setString(PrefKey.mapBrowserPrevPosition, value); +} + +MapCoord? _tryMapCoordFromJson(dynamic json) { + try { + final j = (json as List).cast(); + return MapCoord(j[0], j[1]); + } catch (e, stackTrace) { + _$__NpLog.log + .severe("[_tryMapCoordFromJson] Failed to parse json", e, stackTrace); + return null; + } } diff --git a/app/lib/db/entity_converter.dart b/app/lib/db/entity_converter.dart index 08b08a8b..814eafce 100644 --- a/app/lib/db/entity_converter.dart +++ b/app/lib/db/entity_converter.dart @@ -9,6 +9,7 @@ import 'package:nc_photos/entity/exif.dart'; import 'package:nc_photos/entity/face_recognition_person.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/entity/image_location/repo.dart'; import 'package:nc_photos/entity/nc_album.dart'; import 'package:nc_photos/entity/nc_album_item.dart'; import 'package:nc_photos/entity/recognize_face.dart'; @@ -275,6 +276,16 @@ abstract class DbLocationGroupConverter { } } +abstract class DbImageLatLngConverter { + static ImageLatLng fromDb(DbImageLatLng src) { + return ImageLatLng( + latitude: src.lat, + longitude: src.lng, + fileId: src.fileId, + ); + } +} + extension FileExtension on File { DbFileKey toDbKey() { if (fileId != null) { diff --git a/app/lib/di_container.dart b/app/lib/di_container.dart index 26f1a119..4426b6aa 100644 --- a/app/lib/di_container.dart +++ b/app/lib/di_container.dart @@ -4,6 +4,7 @@ import 'package:nc_photos/entity/face_recognition_person/repo.dart'; import 'package:nc_photos/entity/favorite.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file/repo.dart'; +import 'package:nc_photos/entity/image_location/repo.dart'; import 'package:nc_photos/entity/local_file.dart'; import 'package:nc_photos/entity/nc_album/repo.dart'; import 'package:nc_photos/entity/pref.dart'; @@ -48,6 +49,7 @@ enum DiType { recognizeFaceRepo, recognizeFaceRepoRemote, recognizeFaceRepoLocal, + imageLocationRepo, pref, touchManager, npDb, @@ -86,6 +88,7 @@ class DiContainer { RecognizeFaceRepo? recognizeFaceRepo, RecognizeFaceRepo? recognizeFaceRepoRemote, RecognizeFaceRepo? recognizeFaceRepoLocal, + ImageLocationRepo? imageLocationRepo, Pref? pref, TouchManager? touchManager, NpDb? npDb, @@ -120,6 +123,7 @@ class DiContainer { _recognizeFaceRepo = recognizeFaceRepo, _recognizeFaceRepoRemote = recognizeFaceRepoRemote, _recognizeFaceRepoLocal = recognizeFaceRepoLocal, + _imageLocationRepo = imageLocationRepo, _pref = pref, _touchManager = touchManager, _npDb = npDb, @@ -189,6 +193,8 @@ class DiContainer { return contianer._recognizeFaceRepoRemote != null; case DiType.recognizeFaceRepoLocal: return contianer._recognizeFaceRepoLocal != null; + case DiType.imageLocationRepo: + return contianer._imageLocationRepo != null; case DiType.pref: return contianer._pref != null; case DiType.touchManager: @@ -215,6 +221,7 @@ class DiContainer { OrNull? ncAlbumRepo, OrNull? faceRecognitionPersonRepo, OrNull? recognizeFaceRepo, + OrNull? imageLocationRepo, OrNull? pref, OrNull? touchManager, OrNull? npDb, @@ -240,6 +247,9 @@ class DiContainer { recognizeFaceRepo: recognizeFaceRepo == null ? _recognizeFaceRepo : recognizeFaceRepo.obj, + imageLocationRepo: imageLocationRepo == null + ? _imageLocationRepo + : imageLocationRepo.obj, pref: pref == null ? _pref : pref.obj, touchManager: touchManager == null ? _touchManager : touchManager.obj, npDb: npDb == null ? _npDb : npDb.obj, @@ -280,6 +290,7 @@ class DiContainer { RecognizeFaceRepo get recognizeFaceRepo => _recognizeFaceRepo!; RecognizeFaceRepo get recognizeFaceRepoRemote => _recognizeFaceRepoRemote!; RecognizeFaceRepo get recognizeFaceRepoLocal => _recognizeFaceRepoLocal!; + ImageLocationRepo get imageLocationRepo => _imageLocationRepo!; Pref get pref => _pref!; TouchManager get touchManager => _touchManager!; @@ -436,6 +447,11 @@ class DiContainer { _recognizeFaceRepoLocal = v; } + set imageLocationRepo(ImageLocationRepo v) { + assert(_imageLocationRepo == null); + _imageLocationRepo = v; + } + set pref(Pref v) { assert(_pref == null); _pref = v; @@ -489,6 +505,7 @@ class DiContainer { RecognizeFaceRepo? _recognizeFaceRepo; RecognizeFaceRepo? _recognizeFaceRepoRemote; RecognizeFaceRepo? _recognizeFaceRepoLocal; + ImageLocationRepo? _imageLocationRepo; Pref? _pref; TouchManager? _touchManager; diff --git a/app/lib/entity/collection/adapter.dart b/app/lib/entity/collection/adapter.dart index 9e6cd977..25318076 100644 --- a/app/lib/entity/collection/adapter.dart +++ b/app/lib/entity/collection/adapter.dart @@ -2,12 +2,14 @@ import 'package:flutter/foundation.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter/ad_hoc.dart'; import 'package:nc_photos/entity/collection/adapter/album.dart'; import 'package:nc_photos/entity/collection/adapter/location_group.dart'; import 'package:nc_photos/entity/collection/adapter/memory.dart'; import 'package:nc_photos/entity/collection/adapter/nc_album.dart'; import 'package:nc_photos/entity/collection/adapter/person.dart'; import 'package:nc_photos/entity/collection/adapter/tag.dart'; +import 'package:nc_photos/entity/collection/content_provider/ad_hoc.dart'; import 'package:nc_photos/entity/collection/content_provider/album.dart'; import 'package:nc_photos/entity/collection/content_provider/location_group.dart'; import 'package:nc_photos/entity/collection/content_provider/memory.dart'; @@ -30,6 +32,8 @@ abstract class CollectionAdapter { static CollectionAdapter of( DiContainer c, Account account, Collection collection) { switch (collection.contentProvider.runtimeType) { + case const (CollectionAdHocProvider): + return CollectionAdHocAdapter(c, account, collection); case const (CollectionAlbumProvider): return CollectionAlbumAdapter(c, account, collection); case const (CollectionLocationGroupProvider): diff --git a/app/lib/entity/collection/adapter/ad_hoc.dart b/app/lib/entity/collection/adapter/ad_hoc.dart new file mode 100644 index 00000000..5c92e3ba --- /dev/null +++ b/app/lib/entity/collection/adapter/ad_hoc.dart @@ -0,0 +1,57 @@ +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/collection/adapter/adapter_mixin.dart'; +import 'package:nc_photos/entity/collection/content_provider/ad_hoc.dart'; +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/entity/collection_item/basic_item.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/use_case/find_file_descriptor.dart'; + +class CollectionAdHocAdapter + with + CollectionAdapterReadOnlyTag, + CollectionAdapterUnremovableTag, + CollectionAdapterUnshareableTag + implements CollectionAdapter { + CollectionAdHocAdapter(this._c, this.account, this.collection) + : _provider = collection.contentProvider as CollectionAdHocProvider; + + @override + Stream> listItem() async* { + final files = await FindFileDescriptor(_c)( + account, + _provider.fileIds, + onFileNotFound: (_) { + // ignore not found + }, + ); + yield files + .where((f) => file_util.isSupportedFormat(f)) + .map((f) => BasicCollectionFileItem(f)) + .toList(); + } + + @override + Future adaptToNewItem(CollectionItem original) async { + if (original is CollectionFileItem) { + return BasicCollectionFileItem(original.file); + } else { + throw UnsupportedError("Unsupported type: ${original.runtimeType}"); + } + } + + @override + bool isItemDeletable(CollectionItem item) => true; + + @override + bool isPermitted(CollectionCapability capability) => + _provider.capabilities.contains(capability); + + final DiContainer _c; + final Account account; + final Collection collection; + + final CollectionAdHocProvider _provider; +} diff --git a/app/lib/entity/collection/content_provider/ad_hoc.dart b/app/lib/entity/collection/content_provider/ad_hoc.dart new file mode 100644 index 00000000..e7b464f2 --- /dev/null +++ b/app/lib/entity/collection/content_provider/ad_hoc.dart @@ -0,0 +1,83 @@ +import 'package:clock/clock.dart'; +import 'package:equatable/equatable.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/api/api_util.dart' as api_util; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/util.dart'; +import 'package:nc_photos/entity/collection_item/util.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:np_common/object_util.dart'; +import 'package:to_string/to_string.dart'; +import 'package:uuid/uuid.dart'; + +part 'ad_hoc.g.dart'; + +@toString +class CollectionAdHocProvider + with EquatableMixin + implements CollectionContentProvider { + CollectionAdHocProvider({ + required this.account, + required this.fileIds, + this.cover, + }); + + @override + String get fourCc => "ADHC"; + + @override + String get id => _id; + + @override + int? get count => fileIds.length; + + @override + DateTime get lastModified => clock.now(); + + @override + List get capabilities => [ + CollectionCapability.deleteItem, + ]; + + @override + CollectionItemSort get itemSort => CollectionItemSort.dateDescending; + + @override + List get shares => []; + + @override + String? getCoverUrl( + int width, + int height, { + bool? isKeepAspectRatio, + }) { + return cover?.let((cover) => api_util.getFilePreviewUrl( + account, + cover, + width: width, + height: height, + isKeepAspectRatio: isKeepAspectRatio ?? false, + )); + } + + @override + bool get isDynamicCollection => true; + + @override + bool get isPendingSharedAlbum => false; + + @override + bool get isOwned => true; + + @override + String toString() => _$toString(); + + @override + List get props => [account, fileIds, cover]; + + final Account account; + final List fileIds; + final FileDescriptor? cover; + + late final _id = const Uuid().v4(); +} diff --git a/app/lib/entity/collection/content_provider/ad_hoc.g.dart b/app/lib/entity/collection/content_provider/ad_hoc.g.dart new file mode 100644 index 00000000..fafb45be --- /dev/null +++ b/app/lib/entity/collection/content_provider/ad_hoc.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ad_hoc.dart'; + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$CollectionAdHocProviderToString on CollectionAdHocProvider { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "CollectionAdHocProvider {account: $account, fileIds: [length: ${fileIds.length}], cover: ${cover == null ? null : "${cover!.fdPath}"}, _id: $_id}"; + } +} diff --git a/app/lib/entity/image_location/data_source.dart b/app/lib/entity/image_location/data_source.dart new file mode 100644 index 00000000..53cc3d8e --- /dev/null +++ b/app/lib/entity/image_location/data_source.dart @@ -0,0 +1,38 @@ +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/db/entity_converter.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/entity/image_location/repo.dart'; +import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util; +import 'package:np_async/np_async.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_datetime/np_datetime.dart'; +import 'package:np_db/np_db.dart'; + +part 'data_source.g.dart'; + +@npLog +class ImageLocationNpDbDataSource implements ImageLocationDataSource { + const ImageLocationNpDbDataSource(this.db); + + @override + Future> getLocations( + Account account, TimeRange timeRange) async { + _log.info("[getLocations] timeRange: $timeRange"); + final results = await db.getImageLatLngWithFileIds( + account: account.toDb(), + timeRange: timeRange, + includeRelativeRoots: account.roots + .map((e) => File(path: file_util.unstripPath(account, e)) + .strippedPathWithEmpty) + .toList(), + excludeRelativeRoots: [remote_storage_util.remoteStorageDirRelativePath], + mimes: file_util.supportedFormatMimes, + ); + return results.computeAll(DbImageLatLngConverter.fromDb); + } + + final NpDb db; +} diff --git a/app/lib/entity/image_location/data_source.g.dart b/app/lib/entity/image_location/data_source.g.dart new file mode 100644 index 00000000..683ac5d0 --- /dev/null +++ b/app/lib/entity/image_location/data_source.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'data_source.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$ImageLocationNpDbDataSourceNpLog on ImageLocationNpDbDataSource { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("entity.image_location.data_source.ImageLocationNpDbDataSource"); +} diff --git a/app/lib/entity/image_location/repo.dart b/app/lib/entity/image_location/repo.dart new file mode 100644 index 00000000..cca0b0e9 --- /dev/null +++ b/app/lib/entity/image_location/repo.dart @@ -0,0 +1,52 @@ +import 'package:equatable/equatable.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_datetime/np_datetime.dart'; + +part 'repo.g.dart'; + +class ImageLatLng with EquatableMixin { + const ImageLatLng({ + required this.latitude, + required this.longitude, + required this.fileId, + }); + + @override + List get props => [ + latitude, + longitude, + fileId, + ]; + + final double latitude; + final double longitude; + final int fileId; +} + +abstract class ImageLocationRepo { + /// Query all locations with the corresponding file ids + /// + /// Returned data are sorted by the file date time in descending order + Future> getLocations(Account account, TimeRange timeRange); +} + +@npLog +class BasicImageLocationRepo implements ImageLocationRepo { + const BasicImageLocationRepo(this.dataSrc); + + @override + Future> getLocations( + Account account, TimeRange timeRange) => + dataSrc.getLocations(account, timeRange); + + final ImageLocationDataSource dataSrc; +} + +abstract class ImageLocationDataSource { + /// Query all locations with the corresponding file ids + /// + /// Returned data are sorted by the file date time in descending order + Future> getLocations(Account account, TimeRange timeRange); +} diff --git a/app/lib/entity/image_location/repo.g.dart b/app/lib/entity/image_location/repo.g.dart new file mode 100644 index 00000000..dbcfdc76 --- /dev/null +++ b/app/lib/entity/image_location/repo.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'repo.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$BasicImageLocationRepoNpLog on BasicImageLocationRepo { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("entity.image_location.repo.BasicImageLocationRepo"); +} diff --git a/app/lib/entity/pref.dart b/app/lib/entity/pref.dart index 9ee99885..04f95dd1 100644 --- a/app/lib/entity/pref.dart +++ b/app/lib/entity/pref.dart @@ -113,6 +113,7 @@ enum PrefKey implements PrefKeyInterface { protectedPageAuthPin, protectedPageAuthPassword, dontShowVideoPreviewHint, + mapBrowserPrevPosition, ; @override @@ -199,6 +200,8 @@ enum PrefKey implements PrefKeyInterface { return "protectedPageAuthPassword"; case PrefKey.dontShowVideoPreviewHint: return "dontShowVideoPreviewHint"; + case PrefKey.mapBrowserPrevPosition: + return "mapBrowserPrevPosition"; } } } diff --git a/app/lib/json_util.dart b/app/lib/json_util.dart index 1393010b..027cf660 100644 --- a/app/lib/json_util.dart +++ b/app/lib/json_util.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:nc_photos/object_extension.dart'; /// Convert a boolean to an indexable type in json for DB @@ -8,3 +10,14 @@ Object? boolToJson(bool? value) => value?.run((v) => v ? 1 : 0); /// Convert a boolean from an indexable type in json for DB bool? boolFromJson(Object? value) => value?.run((v) => v != 0); + +Object? tryJsonDecode(String source) { + try { + return jsonDecode(source); + } catch (_) { + return null; + } +} + +Object? jsonDecodeOr(String source, dynamic def) => + tryJsonDecode(source) ?? def; diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 9ea6a2ae..a7162afa 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1487,6 +1487,15 @@ "trustedCertManagerFailedToRemoveCertError": "Failed to remove certificate", "missingVideoThumbnailHelpDialogTitle": "Having trouble with video thumbnails?", "dontShowAgain": "Don't show again", + "mapBrowserDateRangeLabel": "Date range", + "@mapBrowserDateRangeLabel": { + "description": "Filter photos by date range" + }, + "mapBrowserDateRangeThisMonth": "This month", + "mapBrowserDateRangePrevMonth": "Previous month", + "mapBrowserDateRangeThisYear": "This year", + "mapBrowserDateRangeCustom": "Custom", + "homeTabMapBrowser": "Map", "errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues", "@errorUnauthenticated": { diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index 57d1ebce..bab42341 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -249,6 +249,12 @@ "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom", + "homeTabMapBrowser", "errorUnauthenticated", "errorDisconnected", "errorLocked", @@ -284,7 +290,13 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "de": [ @@ -318,7 +330,13 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "el": [ @@ -455,7 +473,13 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "es": [ @@ -483,7 +507,13 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "fi": [ @@ -511,7 +541,13 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "fr": [ @@ -539,7 +575,13 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "it": [ @@ -572,7 +614,13 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "nl": [ @@ -942,6 +990,12 @@ "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom", + "homeTabMapBrowser", "errorUnauthenticated", "errorDisconnected", "errorLocked", @@ -981,7 +1035,13 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "pt": [ @@ -1029,7 +1089,13 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "ru": [ @@ -1057,7 +1123,22 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" + ], + + "tr": [ + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "zh": [ @@ -1116,7 +1197,13 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "zh_Hant": [ @@ -1269,6 +1356,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ] } diff --git a/app/lib/widget/home.dart b/app/lib/widget/home.dart index 360293a8..98dafc7d 100644 --- a/app/lib/widget/home.dart +++ b/app/lib/widget/home.dart @@ -22,6 +22,7 @@ import 'package:nc_photos/use_case/import_potential_shared_album.dart'; import 'package:nc_photos/widget/home_collections.dart'; import 'package:nc_photos/widget/home_photos2.dart'; import 'package:nc_photos/widget/home_search.dart'; +import 'package:nc_photos/widget/map_browser.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/or_null.dart'; @@ -87,7 +88,7 @@ class _HomeState extends State with TickerProviderStateMixin { } @override - build(BuildContext context) { + Widget build(BuildContext context) { return Scaffold( bottomNavigationBar: _buildBottomNavigationBar(context), body: Builder(builder: (context) => _buildContent(context)), @@ -114,6 +115,11 @@ class _HomeState extends State with TickerProviderStateMixin { selectedIcon: const Icon(Icons.grid_view_sharp), label: L10n.global().collectionsTooltip, ), + NavigationDestination( + icon: const Icon(Icons.map_outlined), + selectedIcon: const Icon(Icons.map), + label: L10n.global().homeTabMapBrowser, + ), ], selectedIndex: _nextPage, onDestinationSelected: _onTapNavItem, @@ -125,7 +131,7 @@ class _HomeState extends State with TickerProviderStateMixin { return PageView.builder( controller: _pageController, physics: const NeverScrollableScrollPhysics(), - itemCount: 3, + itemCount: 4, itemBuilder: (context, index) => SlideTransition( position: Tween( begin: const Offset(0, .05), @@ -152,6 +158,9 @@ class _HomeState extends State with TickerProviderStateMixin { case 2: return const HomeCollections(); + case 3: + return const MapBrowser(); + default: throw ArgumentError("Invalid page index: $index"); } diff --git a/app/lib/widget/map_browser.dart b/app/lib/widget/map_browser.dart new file mode 100644 index 00000000..423f11d0 --- /dev/null +++ b/app/lib/widget/map_browser.dart @@ -0,0 +1,131 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:clock/clock.dart'; +import 'package:copy_with/copy_with.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:google_maps_cluster_manager/google_maps_cluster_manager.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:kiwi/kiwi.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/bloc_util.dart'; +import 'package:nc_photos/controller/account_controller.dart'; +import 'package:nc_photos/controller/pref_controller.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/content_provider/ad_hoc.dart'; +import 'package:nc_photos/entity/image_location/repo.dart'; +import 'package:nc_photos/exception_event.dart'; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/stream_extension.dart'; +import 'package:nc_photos/theme.dart'; +import 'package:nc_photos/theme/dimension.dart'; +import 'package:nc_photos/widget/collection_browser.dart'; +import 'package:nc_photos/widget/measure.dart'; +import 'package:nc_photos/widget/navigation_bar_blur_filter.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/object_util.dart'; +import 'package:np_datetime/np_datetime.dart'; +import 'package:np_gps_map/np_gps_map.dart'; +import 'package:to_string/to_string.dart'; + +part 'map_browser.g.dart'; +part 'map_browser/bloc.dart'; +part 'map_browser/state_event.dart'; +part 'map_browser/type.dart'; +part 'map_browser/view.dart'; + +class MapBrowser extends StatelessWidget { + const MapBrowser({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => _Bloc( + KiwiContainer().resolve(), + account: context.read().account, + prefController: context.read(), + )..add(const _LoadData()), + child: const _WrappedMapBrowser(), + ); + } +} + +class _WrappedMapBrowser extends StatelessWidget { + const _WrappedMapBrowser(); + + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + _BlocListenerT( + selector: (state) => state.error, + listener: (context, error) { + if (error != null) { + SnackBarManager().showSnackBarForException(error.error); + } + }, + ), + ], + child: Stack( + children: [ + const _MapView(), + Positioned.directional( + textDirection: Directionality.of(context), + top: MediaQuery.of(context).padding.top + 8, + end: 8, + child: const _DateRangeToggle(), + ), + _BlocSelector( + selector: (state) => state.isShowDataRangeControlPanel, + builder: (context, isShowAnyPanel) => Positioned.fill( + child: isShowAnyPanel + ? GestureDetector( + onTap: () { + context.addEvent(const _CloseControlPanel()); + }, + ) + : const SizedBox.shrink(), + ), + ), + Positioned( + left: 8, + right: 8, + top: MediaQuery.of(context).padding.top + 8, + child: _BlocSelector( + selector: (state) => state.isShowDataRangeControlPanel, + builder: (context, isShowDataRangeControlPanel) => + _PanelContainer( + isShow: isShowDataRangeControlPanel, + child: const _DateRangeControlPanel(), + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: NavigationBarBlurFilter( + height: AppDimension.of(context).homeBottomAppBarHeight, + ), + ), + ], + ), + ); + } +} + +typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; +// typedef _BlocListener = BlocListener<_Bloc, _State>; +typedef _BlocListenerT = BlocListenerT<_Bloc, _State, T>; +typedef _BlocSelector = BlocSelector<_Bloc, _State, T>; + +extension on BuildContext { + _Bloc get bloc => read<_Bloc>(); + // _State get state => bloc.state; + void addEvent(_Event event) => bloc.add(event); +} diff --git a/app/lib/widget/map_browser.g.dart b/app/lib/widget/map_browser.g.dart new file mode 100644 index 00000000..e4eee5b4 --- /dev/null +++ b/app/lib/widget/map_browser.g.dart @@ -0,0 +1,128 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'map_browser.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $_StateCopyWithWorker { + _State call( + {List<_DataPoint>? data, + MapCoord? initialPoint, + Set? markers, + bool? isShowDataRangeControlPanel, + _DateRangeType? dateRangeType, + DateRange? localDateRange, + ExceptionEvent? error}); +} + +class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { + _$_StateCopyWithWorkerImpl(this.that); + + @override + _State call( + {dynamic data, + dynamic initialPoint = copyWithNull, + dynamic markers, + dynamic isShowDataRangeControlPanel, + dynamic dateRangeType, + dynamic localDateRange, + dynamic error = copyWithNull}) { + return _State( + data: data as List<_DataPoint>? ?? that.data, + initialPoint: initialPoint == copyWithNull + ? that.initialPoint + : initialPoint as MapCoord?, + markers: markers as Set? ?? that.markers, + isShowDataRangeControlPanel: isShowDataRangeControlPanel as bool? ?? + that.isShowDataRangeControlPanel, + dateRangeType: dateRangeType as _DateRangeType? ?? that.dateRangeType, + localDateRange: localDateRange as DateRange? ?? that.localDateRange, + error: error == copyWithNull ? that.error : error as ExceptionEvent?); + } + + final _State that; +} + +extension $_StateCopyWith on _State { + $_StateCopyWithWorker get copyWith => _$copyWith; + $_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this); +} + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$_BlocNpLog on _Bloc { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.map_browser._Bloc"); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$_StateToString on _State { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_State {data: [length: ${data.length}], initialPoint: $initialPoint, markers: {length: ${markers.length}}, isShowDataRangeControlPanel: $isShowDataRangeControlPanel, dateRangeType: ${dateRangeType.name}, localDateRange: $localDateRange, error: $error}"; + } +} + +extension _$_LoadDataToString on _LoadData { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_LoadData {}"; + } +} + +extension _$_SetMarkersToString on _SetMarkers { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetMarkers {markers: {length: ${markers.length}}}"; + } +} + +extension _$_OpenDataRangeControlPanelToString on _OpenDataRangeControlPanel { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_OpenDataRangeControlPanel {}"; + } +} + +extension _$_CloseControlPanelToString on _CloseControlPanel { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_CloseControlPanel {}"; + } +} + +extension _$_SetDateRangeTypeToString on _SetDateRangeType { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetDateRangeType {value: ${value.name}}"; + } +} + +extension _$_SetLocalDateRangeToString on _SetLocalDateRange { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetLocalDateRange {value: $value}"; + } +} + +extension _$_SetErrorToString on _SetError { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetError {error: $error, stackTrace: $stackTrace}"; + } +} diff --git a/app/lib/widget/map_browser/bloc.dart b/app/lib/widget/map_browser/bloc.dart new file mode 100644 index 00000000..52b46194 --- /dev/null +++ b/app/lib/widget/map_browser/bloc.dart @@ -0,0 +1,168 @@ +part of '../map_browser.dart'; + +@npLog +class _Bloc extends Bloc<_Event, _State> + with BlocLogger, BlocForEachMixin<_Event, _State> { + _Bloc( + this._c, { + required this.account, + required this.prefController, + }) : super(_State.init( + dateRangeType: _DateRangeType.thisMonth, + localDateRange: + _calcDateRange(clock.now().toDate(), _DateRangeType.thisMonth), + )) { + on<_LoadData>(_onLoadData); + on<_SetMarkers>(_onSetMarkers); + on<_OpenDataRangeControlPanel>(_onOpenDataRangeControlPanel); + on<_CloseControlPanel>(_onCloseControlPanel); + on<_SetDateRangeType>(_onSetDateRangeType); + on<_SetLocalDateRange>(_onSetDateRange); + on<_SetError>(_onSetError); + + _subscriptions + .add(stream.distinctBy((state) => state.localDateRange).listen((state) { + add(const _LoadData()); + })); + } + + @override + Future close() { + for (final s in _subscriptions) { + s.cancel(); + } + return super.close(); + } + + @override + String get tag => _log.fullName; + + @override + void onError(Object error, StackTrace stackTrace) { + // we need this to prevent onError being triggered recursively + if (!isClosed && !_isHandlingError) { + _isHandlingError = true; + try { + add(_SetError(error, stackTrace)); + } catch (_) {} + _isHandlingError = false; + } + super.onError(error, stackTrace); + } + + Future _onLoadData(_LoadData ev, Emitter<_State> emit) async { + _log.info(ev); + // convert local DateRange to TimeRange in UTC + final localTimeRange = state.localDateRange.toLocalTimeRange(); + final utcTimeRange = localTimeRange.copyWith( + from: localTimeRange.from?.toUtc(), + to: localTimeRange.to?.toUtc(), + ); + final raw = await _c.imageLocationRepo.getLocations(account, utcTimeRange); + _log.info("[_onLoadData] Loaded ${raw.length} markers"); + if (state.initialPoint == null) { + final initialPoint = + raw.firstOrNull?.let((obj) => MapCoord(obj.latitude, obj.longitude)); + if (initialPoint != null) { + unawaited(prefController.setMapBrowserPrevPosition(initialPoint)); + } + emit(state.copyWith( + data: raw.map(_DataPoint.fromImageLatLng).toList(), + initialPoint: initialPoint, + )); + } else { + emit(state.copyWith( + data: raw.map(_DataPoint.fromImageLatLng).toList(), + )); + } + } + + void _onSetMarkers(_SetMarkers ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith(markers: ev.markers)); + } + + void _onOpenDataRangeControlPanel( + _OpenDataRangeControlPanel ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith( + isShowDataRangeControlPanel: true, + )); + } + + void _onCloseControlPanel(_CloseControlPanel ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith( + isShowDataRangeControlPanel: false, + )); + } + + void _onSetDateRangeType(_SetDateRangeType ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith( + dateRangeType: ev.value, + localDateRange: ev.value == _DateRangeType.custom + ? null + : _calcDateRange(clock.now().toDate(), ev.value), + )); + } + + void _onSetDateRange(_SetLocalDateRange ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith( + dateRangeType: _DateRangeType.custom, + localDateRange: ev.value, + )); + } + + void _onSetError(_SetError ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace))); + } + + static DateRange _calcDateRange(Date today, _DateRangeType type) { + assert(type != _DateRangeType.custom); + switch (type) { + case _DateRangeType.thisMonth: + return DateRange( + from: today.copyWith(day: 1), + to: today, + toBound: TimeRangeBound.inclusive, + ); + case _DateRangeType.prevMonth: + if (today.month == 1) { + return DateRange( + from: Date(today.year - 1, 12, 1), + to: Date(today.year - 1, 12, 31), + toBound: TimeRangeBound.inclusive, + ); + } else { + return DateRange( + from: Date(today.year, today.month - 1, 1), + to: Date(today.year, today.month, 1).add(day: -1), + toBound: TimeRangeBound.inclusive, + ); + } + case _DateRangeType.thisYear: + return DateRange( + from: today.copyWith(month: 1, day: 1), + to: today, + toBound: TimeRangeBound.inclusive, + ); + case _DateRangeType.custom: + return DateRange( + from: today, + to: today, + toBound: TimeRangeBound.inclusive, + ); + } + } + + final DiContainer _c; + final Account account; + final PrefController prefController; + + final _subscriptions = []; + + var _isHandlingError = false; +} diff --git a/app/lib/widget/map_browser/state_event.dart b/app/lib/widget/map_browser/state_event.dart new file mode 100644 index 00000000..e90efac6 --- /dev/null +++ b/app/lib/widget/map_browser/state_event.dart @@ -0,0 +1,110 @@ +part of '../map_browser.dart'; + +@genCopyWith +@toString +class _State { + const _State({ + required this.data, + this.initialPoint, + required this.markers, + required this.isShowDataRangeControlPanel, + required this.dateRangeType, + required this.localDateRange, + this.error, + }); + + factory _State.init({ + required _DateRangeType dateRangeType, + required DateRange localDateRange, + }) { + return _State( + data: const [], + markers: const {}, + isShowDataRangeControlPanel: false, + dateRangeType: dateRangeType, + localDateRange: localDateRange, + ); + } + + @override + String toString() => _$toString(); + + final List<_DataPoint> data; + final MapCoord? initialPoint; + final Set markers; + + final bool isShowDataRangeControlPanel; + final _DateRangeType dateRangeType; + final DateRange localDateRange; + + final ExceptionEvent? error; +} + +abstract class _Event { + const _Event(); +} + +@toString +class _LoadData implements _Event { + const _LoadData(); + + @override + String toString() => _$toString(); +} + +@toString +class _SetMarkers implements _Event { + const _SetMarkers(this.markers); + + @override + String toString() => _$toString(); + + final Set markers; +} + +@toString +class _OpenDataRangeControlPanel implements _Event { + const _OpenDataRangeControlPanel(); + + @override + String toString() => _$toString(); +} + +@toString +class _CloseControlPanel implements _Event { + const _CloseControlPanel(); + + @override + String toString() => _$toString(); +} + +@toString +class _SetDateRangeType implements _Event { + const _SetDateRangeType(this.value); + + @override + String toString() => _$toString(); + + final _DateRangeType value; +} + +@toString +class _SetLocalDateRange implements _Event { + const _SetLocalDateRange(this.value); + + @override + String toString() => _$toString(); + + final DateRange value; +} + +@toString +class _SetError implements _Event { + const _SetError(this.error, [this.stackTrace]); + + @override + String toString() => _$toString(); + + final Object error; + final StackTrace? stackTrace; +} diff --git a/app/lib/widget/map_browser/type.dart b/app/lib/widget/map_browser/type.dart new file mode 100644 index 00000000..6b760c9e --- /dev/null +++ b/app/lib/widget/map_browser/type.dart @@ -0,0 +1,46 @@ +part of '../map_browser.dart'; + +class _DataPoint implements ClusterItem { + const _DataPoint({ + required this.location, + required this.fileId, + }); + + factory _DataPoint.fromImageLatLng(ImageLatLng src) => _DataPoint( + location: LatLng(src.latitude, src.longitude), + fileId: src.fileId, + ); + + @override + String get geohash => + Geohash.encode(location, codeLength: ClusterManager.precision); + + @override + final LatLng location; + final int fileId; +} + +enum _DateRangeType { + thisMonth, + prevMonth, + thisYear, + custom, + ; + + String toDisplayString() { + switch (this) { + case thisMonth: + return L10n.global().mapBrowserDateRangeThisMonth; + case prevMonth: + return L10n.global().mapBrowserDateRangePrevMonth; + case thisYear: + return L10n.global().mapBrowserDateRangeThisYear; + case custom: + return L10n.global().mapBrowserDateRangeCustom; + } + } +} + +extension on MapCoord { + LatLng toLatLng() => LatLng(latitude, longitude); +} diff --git a/app/lib/widget/map_browser/view.dart b/app/lib/widget/map_browser/view.dart new file mode 100644 index 00000000..cc852564 --- /dev/null +++ b/app/lib/widget/map_browser/view.dart @@ -0,0 +1,464 @@ +part of '../map_browser.dart'; + +class _MapView extends StatefulWidget { + const _MapView(); + + @override + State createState() => _MapViewState(); +} + +class _MapViewState extends State<_MapView> { + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + _BlocListenerT>( + selector: (state) => state.data, + listener: (context, data) { + _clusterManager.setItems(data); + }, + ), + _BlocListenerT( + selector: (state) => state.initialPoint, + listener: (context, initialPoint) { + if (initialPoint != null) { + _mapController?.animateCamera( + CameraUpdate.newLatLngZoom(initialPoint.toLatLng(), 10)); + } + }, + ), + ], + child: _BlocBuilder( + buildWhen: (previous, current) => previous.markers != current.markers, + builder: (context, state) => GoogleMap( + mapType: MapType.normal, + initialCameraPosition: context + .read() + .mapBrowserPrevPositionValue + ?.let( + (p) => CameraPosition(target: p.toLatLng(), zoom: 10)) ?? + const CameraPosition(target: LatLng(0, 0)), + markers: state.markers, + onMapCreated: (controller) { + _clusterManager.setMapId(controller.mapId); + _mapController = controller; + if (Theme.of(context).brightness == Brightness.dark) { + controller.setMapStyle(_mapStyleNight); + } + if (state.initialPoint != null) { + controller.animateCamera(CameraUpdate.newLatLngZoom( + state.initialPoint!.toLatLng(), 10)); + } + }, + onCameraMove: _clusterManager.onCameraMove, + onCameraIdle: _clusterManager.updateMap, + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top, + bottom: MediaQuery.of(context).padding.bottom, + ), + ), + ), + ); + } + + Future _getClusterBitmap( + int size, { + String? text, + required Color color, + }) async { + final PictureRecorder pictureRecorder = PictureRecorder(); + final Canvas canvas = Canvas(pictureRecorder); + final fillPaint = Paint()..color = color; + final outlinePaint = Paint() + ..color = Theme.of(context).brightness == Brightness.light + ? Colors.black.withOpacity(.28) + : Colors.white.withOpacity(.6) + ..strokeWidth = size / 28 + ..style = PaintingStyle.stroke; + + const shadowPadding = 6.0; + const shadowPaddingHalf = shadowPadding / 2; + final shadowPath = Path() + ..addOval( + Rect.fromLTWH(0, 0, size - shadowPadding, size - shadowPadding)); + canvas.drawShadow(shadowPath, Colors.black, 1, false); + canvas.drawCircle( + Offset(size / 2 - shadowPaddingHalf, size / 2 - shadowPaddingHalf), + size / 2 - shadowPaddingHalf, + fillPaint, + ); + canvas.drawCircle( + Offset(size / 2 - shadowPaddingHalf, size / 2 - shadowPaddingHalf), + size / 2 - shadowPaddingHalf - (size / 28 / 2), + outlinePaint, + ); + + if (text != null) { + TextPainter painter = TextPainter(textDirection: TextDirection.ltr); + painter.text = TextSpan( + text: text, + style: TextStyle( + fontSize: size / 3 - ((text.length / 6) * (size * 0.1)), + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontWeight: FontWeight.normal, + ), + ); + painter.layout(); + painter.paint( + canvas, + Offset( + size / 2 - painter.width / 2 - shadowPaddingHalf, + size / 2 - painter.height / 2 - shadowPaddingHalf, + ), + ); + } + + final img = await pictureRecorder.endRecording().toImage(size, size); + final data = await img.toByteData(format: ImageByteFormat.png) as ByteData; + + return BitmapDescriptor.fromBytes(data.buffer.asUint8List()); + } + + String _getMarkerCountString(int count) { + switch (count) { + case >= 10000: + return "10000+"; + case >= 1000: + return "${count ~/ 1000 * 1000}+"; + case >= 100: + return "${count ~/ 100 * 100}+"; + case >= 10: + return "${count ~/ 10 * 10}+"; + default: + return count.toString(); + } + } + + Color _getMarkerColor(int count) { + const step = 1 / 4; + final double r; + switch (count) { + case >= 10000: + r = 1; + case >= 1000: + r = (count ~/ 1000) / 10 * step + step * 3; + case >= 100: + r = (count ~/ 100) / 10 * step + step * 2; + case >= 10: + r = (count ~/ 10) / 10 * step + step; + default: + r = (count / 10) * step; + } + if (Theme.of(context).brightness == Brightness.light) { + return HSLColor.fromAHSL( + 1, + _colorHsl.hue, + r * .7 + .3, + (_colorHsl.lightness - (.1 - r * .1)).clamp(0, 1), + ).toColor(); + } else { + return HSLColor.fromAHSL( + 1, + _colorHsl.hue, + r * .6 + .4, + (_colorHsl.lightness - (.1 - r * .1)).clamp(0, 1), + ).toColor(); + } + } + + int _getMarkerSize(int count) { + const step = 1 / 4; + final double r; + switch (count) { + case >= 10000: + r = 1; + case >= 1000: + r = (count ~/ 1000) / 10 * step + step * 3; + case >= 100: + r = (count ~/ 100) / 10 * step + step * 2; + case >= 10: + r = (count ~/ 10) / 10 * step + step; + default: + r = (count / 10) * step; + } + return (r * 85).toInt() + 85; + } + + late final _clusterManager = ClusterManager<_DataPoint>( + const [], + (markers) { + if (mounted) { + context.addEvent(_SetMarkers(markers)); + } + }, + markerBuilder: (cluster) async => Marker( + markerId: MarkerId(cluster.getId()), + position: cluster.location, + onTap: () { + final c = Collection( + name: "", + contentProvider: CollectionAdHocProvider( + account: context.bloc.account, + fileIds: cluster.items.map((e) => e.fileId).toList(), + ), + ); + Navigator.of(context).pushNamed( + CollectionBrowser.routeName, + arguments: CollectionBrowserArguments(c), + ); + }, + icon: await _getClusterBitmap( + _getMarkerSize(cluster.count * 1), + text: _getMarkerCountString(cluster.count * 1), + color: _getMarkerColor(cluster.count * 1), + ), + ), + ); + GoogleMapController? _mapController; + + late final _colorHsl = + HSLColor.fromColor(Theme.of(context).colorScheme.primaryContainer); +} + +class _PanelContainer extends StatefulWidget { + const _PanelContainer({ + required this.isShow, + required this.child, + }); + + @override + State createState() => _PanelContainerState(); + + final bool isShow; + final Widget child; +} + +class _PanelContainerState extends State<_PanelContainer> + with TickerProviderStateMixin { + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: k.animationDurationNormal, + vsync: this, + value: 0, + ); + _animation = CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _PanelContainer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isShow != widget.isShow) { + if (widget.isShow) { + _animationController.animateTo(1); + } else { + _animationController.animateBack(0); + } + } + } + + @override + Widget build(BuildContext context) { + return MatrixTransition( + animation: _animation, + onTransform: (animationValue) => Matrix4.identity() + ..translate(0.0, -(_size.height / 2) * (1 - animationValue), 0.0) + ..scale(1.0, animationValue, 1.0), + child: MeasureSize( + onChange: (size) => setState(() { + _size = size; + }), + child: widget.child, + ), + ); + } + + late AnimationController _animationController; + late Animation _animation; + var _size = Size.zero; +} + +class _DateRangeToggle extends StatelessWidget { + const _DateRangeToggle(); + + @override + Widget build(BuildContext context) { + return FloatingActionButton.small( + onPressed: () { + context.addEvent(const _OpenDataRangeControlPanel()); + }, + child: const Icon(Icons.date_range_outlined), + ); + } +} + +class _DateRangeControlPanel extends StatelessWidget { + const _DateRangeControlPanel(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + decoration: BoxDecoration( + color: theme.elevate(theme.colorScheme.surface, 2), + borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow( + blurRadius: 4, + offset: Offset(0, 2), + color: Colors.black26, + ), + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Expanded( + child: Text( + L10n.global().mapBrowserDateRangeLabel, + style: Theme.of(context).listTileTheme.titleTextStyle, + ), + ), + Expanded( + child: _BlocSelector<_DateRangeType>( + selector: (state) => state.dateRangeType, + builder: (context, dateRangeType) => + DropdownButtonFormField<_DateRangeType>( + items: _DateRangeType.values + .map((e) => DropdownMenuItem( + value: e, + child: Text(e.toDisplayString()), + )) + .toList(), + value: dateRangeType, + onChanged: (value) { + if (value != null) { + context.addEvent(_SetDateRangeType(value)); + } + }, + ), + ), + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Expanded( + child: _BlocSelector( + selector: (state) => state.localDateRange, + builder: (context, localDateRange) => _DateField( + localDateRange.from!, + onChanged: (value) { + context.addEvent(_SetLocalDateRange( + localDateRange.copyWith(from: value.toDate()))); + }, + ), + ), + ), + const Text(" - "), + Expanded( + child: _BlocSelector( + selector: (state) => state.localDateRange, + builder: (context, localDateRange) => _DateField( + localDateRange.to!, + onChanged: (value) { + context.addEvent(_SetLocalDateRange( + localDateRange.copyWith(to: value.toDate()))); + }, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } +} + +class _DateField extends StatefulWidget { + const _DateField( + this.date, { + this.onChanged, + }); + + @override + State createState() => _DateFieldState(); + + final Date date; + final ValueChanged? onChanged; +} + +class _DateFieldState extends State<_DateField> { + @override + void dispose() { + super.dispose(); + _controller.dispose(); + } + + @override + void didUpdateWidget(covariant _DateField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.date != oldWidget.date) { + _controller.text = _stringify(widget.date); + } + } + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () async { + final result = await showDatePicker( + context: context, + firstDate: DateTime(1970), + lastDate: clock.now(), + currentDate: widget.date.toLocalDateTime(), + ); + if (result == null) { + return; + } + widget.onChanged?.call(result); + }, + child: IgnorePointer( + child: ExcludeFocus( + child: TextFormField( + controller: _controller, + ), + ), + ), + ), + ); + } + + String _stringify(Date date) { + return intl.DateFormat(intl.DateFormat.YEAR_ABBR_MONTH_DAY, + Localizations.localeOf(context).languageCode) + .format(date.toLocalDateTime()); + } + + late final _controller = TextEditingController(text: _stringify(widget.date)); +} + +// Generated in https://mapstyle.withgoogle.com/ +const _mapStyleNight = + '[{"elementType":"geometry","stylers":[{"color":"#242f3e"}]},{"elementType":"labels.text.fill","stylers":[{"color":"#746855"}]},{"elementType":"labels.text.stroke","stylers":[{"color":"#242f3e"}]},{"featureType":"administrative.locality","elementType":"labels.text.fill","stylers":[{"color":"#d59563"}]},{"featureType":"poi","elementType":"labels.text.fill","stylers":[{"color":"#d59563"}]},{"featureType":"poi.park","elementType":"geometry","stylers":[{"color":"#263c3f"}]},{"featureType":"poi.park","elementType":"labels.text.fill","stylers":[{"color":"#6b9a76"}]},{"featureType":"road","elementType":"geometry","stylers":[{"color":"#38414e"}]},{"featureType":"road","elementType":"geometry.stroke","stylers":[{"color":"#212a37"}]},{"featureType":"road","elementType":"labels.text.fill","stylers":[{"color":"#9ca5b3"}]},{"featureType":"road.highway","elementType":"geometry","stylers":[{"color":"#746855"}]},{"featureType":"road.highway","elementType":"geometry.stroke","stylers":[{"color":"#1f2835"}]},{"featureType":"road.highway","elementType":"labels.text.fill","stylers":[{"color":"#f3d19c"}]},{"featureType":"transit","elementType":"geometry","stylers":[{"color":"#2f3948"}]},{"featureType":"transit.station","elementType":"labels.text.fill","stylers":[{"color":"#d59563"}]},{"featureType":"water","elementType":"geometry","stylers":[{"color":"#17263c"}]},{"featureType":"water","elementType":"labels.text.fill","stylers":[{"color":"#515c6d"}]},{"featureType":"water","elementType":"labels.text.stroke","stylers":[{"color":"#17263c"}]}]'; diff --git a/app/pubspec.lock b/app/pubspec.lock index 2a3a3ed7..940429e6 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -652,38 +652,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" - google_maps_flutter: + google_maps: dependency: transitive + description: + name: google_maps + sha256: "555d5d736339b0478e821167ac521c810d7b51c3b2734e6802a9f046b64ea37a" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_maps_cluster_manager: + dependency: "direct main" + description: + name: google_maps_cluster_manager + sha256: "36e9a4b2d831c470fc85d692a6c9cec70e0f385d578b9697de5f4de347561b83" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_maps_flutter: + dependency: "direct main" description: name: google_maps_flutter - sha256: abefcb1e5e5c96bdd8084939dda555257af272c7972902ca46d5631092c1df68 + sha256: ae66fef3e71261d7df2eff29b2a119e190b2884325ecaa55321b1e17b5504066 url: "https://pub.dev" source: hosted - version: "2.2.8" + version: "2.5.3" google_maps_flutter_android: - dependency: transitive + dependency: "direct overridden" description: name: google_maps_flutter_android - sha256: "9512c862df77c1f0fa5f445513dd3c57f5996f0a809dccb74e54b690ee4e3a0f" + sha256: "256b3c974e415bd17555ceff76a5d0badd2cbfd29febfc23070993358f639550" url: "https://pub.dev" source: hosted - version: "2.4.15" + version: "2.7.0" google_maps_flutter_ios: dependency: transitive description: name: google_maps_flutter_ios - sha256: a9462a433bf3ebe60aadcf4906d2d6341a270d69d3e0fcaa8eb2b64699fcfb4f + sha256: "244b3abc7cb611c4a5a2c5ce5a5f36a0d11bf5ccc3f05535d9baf61115ba9a5a" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.6.1" google_maps_flutter_platform_interface: - dependency: transitive + dependency: "direct overridden" description: name: google_maps_flutter_platform_interface - sha256: "308f0af138fa78e8224d598d46ca182673874d0ef4d754b7157c073b5b4b8e0d" + sha256: c14381cfbe65b27cc129a68a79c045083b0e19afac0c23b3ffb0e44d7c7e0944 url: "https://pub.dev" source: hosted - version: "2.2.7" + version: "2.5.0" + google_maps_flutter_web: + dependency: transitive + description: + name: google_maps_flutter_web + sha256: "6245721c160d6f531c1ef568cf9bef8d660cd585a982aa75121269030163785a" + url: "https://pub.dev" + source: hosted + version: "0.5.4+3" graphs: dependency: transitive description: @@ -765,6 +789,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.5" + js_wrapping: + dependency: transitive + description: + name: js_wrapping + sha256: e385980f7c76a8c1c9a560dfb623b890975841542471eade630b2871d243851c + url: "https://pub.dev" + source: hosted + version: "0.7.4" json_annotation: dependency: transitive description: @@ -1341,6 +1373,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" + sanitize_html: + dependency: transitive + description: + name: sanitize_html + sha256: "12669c4a913688a26555323fb9cec373d8f9fbe091f2d01c40c723b33caa8989" + url: "https://pub.dev" + source: hosted + version: "2.1.0" screen_brightness: dependency: "direct main" description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 0f4dfe5d..3543b785 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -158,6 +158,9 @@ dependencies: wakelock_plus: ^1.1.1 woozy_search: ^2.0.3 + google_maps_flutter: 2.5.3 + google_maps_cluster_manager: 3.1.0 + dependency_overrides: video_player: git: @@ -174,6 +177,8 @@ dependency_overrides: url: https://gitlab.com/nc-photos/flutter-plugins ref: video_player-v2.8.6-nc-photos-2 path: packages/video_player/video_player_platform_interface + google_maps_flutter_android: 2.7.0 + google_maps_flutter_platform_interface: 2.5.0 dev_dependencies: test: ^1.22.1 diff --git a/np_datetime/lib/src/date_range.dart b/np_datetime/lib/src/date_range.dart index 9498e644..0d69500f 100644 --- a/np_datetime/lib/src/date_range.dart +++ b/np_datetime/lib/src/date_range.dart @@ -9,6 +9,22 @@ class DateRange { this.toBound = TimeRangeBound.exclusive, }); + /// Return a copy of the current instance with some changed fields. Setting + /// null is not supported + DateRange copyWith({ + Date? from, + TimeRangeBound? fromBound, + Date? to, + TimeRangeBound? toBound, + }) { + return DateRange( + from: from ?? this.from, + fromBound: fromBound ?? this.fromBound, + to: to ?? this.to, + toBound: toBound ?? this.toBound, + ); + } + @override String toString() { return "${fromBound == TimeRangeBound.inclusive ? "[" : "("}" diff --git a/np_datetime/lib/src/time_range.dart b/np_datetime/lib/src/time_range.dart index 90c580b9..8523c892 100644 --- a/np_datetime/lib/src/time_range.dart +++ b/np_datetime/lib/src/time_range.dart @@ -11,6 +11,22 @@ class TimeRange { this.toBound = TimeRangeBound.exclusive, }); + /// Return a copy of the current instance with some changed fields. Setting + /// null is not supported + TimeRange copyWith({ + DateTime? from, + TimeRangeBound? fromBound, + DateTime? to, + TimeRangeBound? toBound, + }) { + return TimeRange( + from: from ?? this.from, + fromBound: fromBound ?? this.fromBound, + to: to ?? this.to, + toBound: toBound ?? this.toBound, + ); + } + @override String toString() { return "${fromBound == TimeRangeBound.inclusive ? "[" : "("}" diff --git a/np_db/lib/src/api.dart b/np_db/lib/src/api.dart index 0da8c578..1c988b24 100644 --- a/np_db/lib/src/api.dart +++ b/np_db/lib/src/api.dart @@ -131,6 +131,29 @@ class DbLocationGroupResult { final List countryCode; } +@toString +class DbImageLatLng with EquatableMixin { + const DbImageLatLng({ + required this.lat, + required this.lng, + required this.fileId, + }); + + @override + String toString() => _$toString(); + + @override + List get props => [ + lat, + lng, + fileId, + ]; + + final double lat; + final double lng; + final int fileId; +} + @genCopyWith @toString class DbFilesSummaryItem with EquatableMixin { @@ -409,6 +432,15 @@ abstract class NpDb { List? excludeRelativeRoots, }); + /// Return the latitude, longitude and the file id of all files + Future> getImageLatLngWithFileIds({ + required DbAccount account, + TimeRange? timeRange, + List? includeRelativeRoots, + List? excludeRelativeRoots, + List? mimes, + }); + Future> getNcAlbums({ required DbAccount account, }); diff --git a/np_db/lib/src/api.g.dart b/np_db/lib/src/api.g.dart index 9541d6b5..ea32abb0 100644 --- a/np_db/lib/src/api.g.dart +++ b/np_db/lib/src/api.g.dart @@ -124,6 +124,13 @@ extension _$DbLocationGroupResultToString on DbLocationGroupResult { } } +extension _$DbImageLatLngToString on DbImageLatLng { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "DbImageLatLng {lat: ${lat.toStringAsFixed(3)}, lng: ${lng.toStringAsFixed(3)}, fileId: $fileId}"; + } +} + extension _$DbFilesSummaryItemToString on DbFilesSummaryItem { String _$toString() { // ignore: unnecessary_string_interpolations diff --git a/np_db_sqlite/lib/src/database/image_location_extension.dart b/np_db_sqlite/lib/src/database/image_location_extension.dart index 60175a1c..901dfd33 100644 --- a/np_db_sqlite/lib/src/database/image_location_extension.dart +++ b/np_db_sqlite/lib/src/database/image_location_extension.dart @@ -16,7 +16,82 @@ class ImageLocationGroup { final DateTime latestDateTime; } +class ImageLatLng { + const ImageLatLng({ + required this.lat, + required this.lng, + required this.fileId, + }); + + final double lat; + final double lng; + final int fileId; +} + extension SqliteDbImageLocationExtension on SqliteDb { + Future> queryImageLatLngWithFileIds({ + required ByAccount account, + TimeRange? timeRange, + List? includeRelativeRoots, + List? includeRelativeDirs, + List? excludeRelativeRoots, + List? mimes, + }) async { + _log.info("[queryImageLatLngWithFileIds] timeRange: $timeRange"); + final query = _queryFiles().let((q) { + q + ..setQueryMode( + FilesQueryMode.expression, + expressions: [files.fileId], + ) + ..setExtraJoins([ + innerJoin( + imageLocations, + imageLocations.accountFile.equalsExp(accountFiles.rowId), + ), + ]) + ..setAccount(account); + if (includeRelativeRoots != null) { + if (includeRelativeRoots.none((p) => p.isEmpty)) { + for (final r in includeRelativeRoots) { + q.byOrRelativePathPattern("$r/%"); + } + } + } + return q.build(); + }); + query.addColumns([ + imageLocations.latitude, + imageLocations.longitude, + ]); + if (excludeRelativeRoots != null) { + for (final r in excludeRelativeRoots) { + query.where(accountFiles.relativePath.like("$r/%").not()); + } + } + if (mimes != null) { + query.where(files.contentType.isIn(mimes)); + } else { + query.where(files.isCollection.isNotValue(true)); + } + if (timeRange != null) { + accountFiles.bestDateTime + .isBetweenTimeRange(timeRange) + ?.let((e) => query.where(e)); + } + query + ..where(imageLocations.latitude.isNotNull() & + imageLocations.longitude.isNotNull()) + ..orderBy([OrderingTerm.desc(accountFiles.bestDateTime)]); + return query + .map((r) => ImageLatLng( + lat: r.read(imageLocations.latitude)!, + lng: r.read(imageLocations.longitude)!, + fileId: r.read(files.fileId)!, + )) + .get(); + } + Future> groupImageLocationsByName({ required ByAccount account, List? includeRelativeRoots, diff --git a/np_db_sqlite/lib/src/files_query_builder.dart b/np_db_sqlite/lib/src/files_query_builder.dart index d903b35e..49aebd4e 100644 --- a/np_db_sqlite/lib/src/files_query_builder.dart +++ b/np_db_sqlite/lib/src/files_query_builder.dart @@ -36,6 +36,10 @@ class FilesQueryBuilder { _selectExpressions = expressions; } + void setExtraJoins(List joins) { + _extraJoins = joins.toList(); + } + void setAccount(ByAccount account) { if (account.sqlAccount != null) { assert(_dbAccount == null); @@ -131,6 +135,7 @@ class FilesQueryBuilder { if (_queryMode == FilesQueryMode.completeFile || _byLocation != null) leftOuterJoin(db.imageLocations, db.imageLocations.accountFile.equalsExp(db.accountFiles.rowId)), + if (_extraJoins != null) ..._extraJoins!, ]) as JoinedSelectStatement; if (_queryMode == FilesQueryMode.expression) { query.addColumns(_selectExpressions!); @@ -232,6 +237,7 @@ class FilesQueryBuilder { FilesQueryMode _queryMode = FilesQueryMode.file; Iterable? _selectExpressions; + List? _extraJoins; Account? _sqlAccount; DbAccount? _dbAccount; diff --git a/np_db_sqlite/lib/src/sqlite_api.dart b/np_db_sqlite/lib/src/sqlite_api.dart index 6d6cede4..bff7c0aa 100644 --- a/np_db_sqlite/lib/src/sqlite_api.dart +++ b/np_db_sqlite/lib/src/sqlite_api.dart @@ -3,6 +3,7 @@ import 'dart:io' as io; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; +import 'package:np_async/np_async.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_collection/np_collection.dart'; import 'package:np_common/object_util.dart'; @@ -574,6 +575,30 @@ class NpDbSqlite implements NpDb { ); } + @override + Future> getImageLatLngWithFileIds({ + required DbAccount account, + TimeRange? timeRange, + List? includeRelativeRoots, + List? excludeRelativeRoots, + List? mimes, + }) async { + final sqlObjs = await _db.use((db) async { + return await db.queryImageLatLngWithFileIds( + account: ByAccount.db(account), + timeRange: timeRange, + includeRelativeRoots: includeRelativeRoots, + excludeRelativeRoots: excludeRelativeRoots, + mimes: mimes, + ); + }); + return sqlObjs.computeAll((e) => DbImageLatLng( + lat: e.lat, + lng: e.lng, + fileId: e.fileId, + )); + } + @override Future> getNcAlbums({ required DbAccount account,