From b6abb4ae4733cad499b15f93aaf4af2a327f5ed4 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Tue, 9 Jul 2024 00:57:03 +0800 Subject: [PATCH 01/10] New MapBrowser to browse photo clusters on a map --- app/lib/app_init.dart | 4 + app/lib/db/entity_converter.dart | 11 ++ app/lib/di_container.dart | 17 +++ app/lib/entity/collection/adapter.dart | 4 + app/lib/entity/collection/adapter/ad_hoc.dart | 57 +++++++++ .../collection/content_provider/ad_hoc.dart | 83 +++++++++++++ .../collection/content_provider/ad_hoc.g.dart | 14 +++ .../entity/image_location/data_source.dart | 23 ++++ .../entity/image_location/data_source.g.dart | 15 +++ app/lib/entity/image_location/repo.dart | 50 ++++++++ app/lib/entity/image_location/repo.g.dart | 15 +++ app/lib/widget/map_browser.dart | 87 +++++++++++++ app/lib/widget/map_browser.g.dart | 90 ++++++++++++++ app/lib/widget/map_browser/bloc.dart | 58 +++++++++ app/lib/widget/map_browser/state_event.dart | 61 +++++++++ app/lib/widget/map_browser/type.dart | 21 ++++ app/lib/widget/map_browser/view.dart | 117 ++++++++++++++++++ app/lib/widget/my_app.dart | 2 + app/pubspec.lock | 62 ++++++++-- app/pubspec.yaml | 5 + np_db/lib/src/api.dart | 28 +++++ np_db/lib/src/api.g.dart | 7 ++ .../database/image_location_extension.dart | 69 +++++++++++ np_db_sqlite/lib/src/files_query_builder.dart | 6 + np_db_sqlite/lib/src/sqlite_api.dart | 17 +++ 25 files changed, 912 insertions(+), 11 deletions(-) create mode 100644 app/lib/entity/collection/adapter/ad_hoc.dart create mode 100644 app/lib/entity/collection/content_provider/ad_hoc.dart create mode 100644 app/lib/entity/collection/content_provider/ad_hoc.g.dart create mode 100644 app/lib/entity/image_location/data_source.dart create mode 100644 app/lib/entity/image_location/data_source.g.dart create mode 100644 app/lib/entity/image_location/repo.dart create mode 100644 app/lib/entity/image_location/repo.g.dart create mode 100644 app/lib/widget/map_browser.dart create mode 100644 app/lib/widget/map_browser.g.dart create mode 100644 app/lib/widget/map_browser/bloc.dart create mode 100644 app/lib/widget/map_browser/state_event.dart create mode 100644 app/lib/widget/map_browser/type.dart create mode 100644 app/lib/widget/map_browser/view.dart 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/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..7deee07e --- /dev/null +++ b/app/lib/entity/image_location/data_source.dart @@ -0,0 +1,23 @@ +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/db/entity_converter.dart'; +import 'package:nc_photos/entity/image_location/repo.dart'; +import 'package:np_async/np_async.dart'; +import 'package:np_codegen/np_codegen.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) async { + _log.info("[getLocations]"); + final results = await db.getImageLatLngWithFileIds(account: account.toDb()); + 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..a97f3f59 --- /dev/null +++ b/app/lib/entity/image_location/repo.dart @@ -0,0 +1,50 @@ +import 'package:equatable/equatable.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:np_codegen/np_codegen.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); +} + +@npLog +class BasicImageLocationRepo implements ImageLocationRepo { + const BasicImageLocationRepo(this.dataSrc); + + @override + Future> getLocations(Account account) => + dataSrc.getLocations(account); + + 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); +} 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/widget/map_browser.dart b/app/lib/widget/map_browser.dart new file mode 100644 index 00000000..5f9ccf2c --- /dev/null +++ b/app/lib/widget/map_browser.dart @@ -0,0 +1,87 @@ +import 'dart:async'; +import 'dart:ui'; + +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:kiwi/kiwi.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/bloc_util.dart'; +import 'package:nc_photos/controller/account_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/snack_bar_manager.dart'; +import 'package:nc_photos/widget/collection_browser.dart'; +import 'package:np_codegen/np_codegen.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 { + static const routeName = "/map-browser"; + + static Route buildRoute() => MaterialPageRoute( + builder: (_) => const MapBrowser(), + ); + + const MapBrowser({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => _Bloc( + KiwiContainer().resolve(), + account: context.read().account, + )..add(const _Init()), + child: const _WrappedMapBrowser(), + ); + } +} + +class _WrappedMapBrowser extends StatelessWidget { + const _WrappedMapBrowser(); + + @override + Widget build(BuildContext context) { + return AnnotatedRegion( + value: SystemUiOverlayStyle.dark, + child: Scaffold( + body: MultiBlocListener( + listeners: [ + _BlocListenerT( + selector: (state) => state.error, + listener: (context, error) { + if (error != null) { + SnackBarManager().showSnackBarForException(error.error); + } + }, + ), + ], + child: const _MapView(), + ), + ), + ); + } +} + +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..262a5949 --- /dev/null +++ b/app/lib/widget/map_browser.g.dart @@ -0,0 +1,90 @@ +// 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, + LatLng? initialPoint, + Set? markers, + ExceptionEvent? error}); +} + +class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { + _$_StateCopyWithWorkerImpl(this.that); + + @override + _State call( + {dynamic data, + dynamic initialPoint = copyWithNull, + dynamic markers, + dynamic error = copyWithNull}) { + return _State( + data: data as List<_DataPoint>? ?? that.data, + initialPoint: initialPoint == copyWithNull + ? that.initialPoint + : initialPoint as LatLng?, + markers: markers as Set? ?? that.markers, + 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}}, error: $error}"; + } +} + +extension _$_InitToString on _Init { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Init {}"; + } +} + +extension _$_SetMarkersToString on _SetMarkers { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetMarkers {markers: {length: ${markers.length}}}"; + } +} + +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..eb42226f --- /dev/null +++ b/app/lib/widget/map_browser/bloc.dart @@ -0,0 +1,58 @@ +part of '../map_browser.dart'; + +@npLog +class _Bloc extends Bloc<_Event, _State> + with BlocLogger, BlocForEachMixin<_Event, _State> { + _Bloc( + this._c, { + required this.account, + }) : super(_State.init()) { + on<_Init>(_onInit); + on<_SetMarkers>(_onSetMarkers); + on<_SetError>(_onSetError); + } + + @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 _onInit(_Init ev, Emitter<_State> emit) async { + _log.info(ev); + final raw = await _c.imageLocationRepo.getLocations(account); + _log.info("[_onInit] Loaded ${raw.length} markers"); + emit(state.copyWith( + data: raw.map(_DataPoint.fromImageLatLng).toList(), + initialPoint: state.initialPoint ?? + (raw.firstOrNull == null + ? null + : LatLng(raw.first.latitude, raw.first.longitude)), + )); + } + + void _onSetMarkers(_SetMarkers ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith(markers: ev.markers)); + } + + void _onSetError(_SetError ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace))); + } + + final DiContainer _c; + final Account account; + + 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..da6a4441 --- /dev/null +++ b/app/lib/widget/map_browser/state_event.dart @@ -0,0 +1,61 @@ +part of '../map_browser.dart'; + +@genCopyWith +@toString +class _State { + const _State({ + required this.data, + this.initialPoint, + required this.markers, + this.error, + }); + + factory _State.init() { + return const _State( + data: [], + markers: {}, + ); + } + + @override + String toString() => _$toString(); + + final List<_DataPoint> data; + final LatLng? initialPoint; + final Set markers; + + final ExceptionEvent? error; +} + +abstract class _Event { + const _Event(); +} + +@toString +class _Init implements _Event { + const _Init(); + + @override + String toString() => _$toString(); +} + +@toString +class _SetMarkers implements _Event { + const _SetMarkers(this.markers); + + @override + String toString() => _$toString(); + + final Set markers; +} + +@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..4ca232f0 --- /dev/null +++ b/app/lib/widget/map_browser/type.dart @@ -0,0 +1,21 @@ +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; +} diff --git a/app/lib/widget/map_browser/view.dart b/app/lib/widget/map_browser/view.dart new file mode 100644 index 00000000..b2b85938 --- /dev/null +++ b/app/lib/widget/map_browser/view.dart @@ -0,0 +1,117 @@ +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, 10)); + } + }, + ), + ], + child: _BlocBuilder( + buildWhen: (previous, current) => previous.markers != current.markers, + builder: (context, state) => GoogleMap( + mapType: MapType.normal, + initialCameraPosition: const CameraPosition(target: LatLng(0, 0)), + markers: state.markers, + onMapCreated: (controller) { + _clusterManager.setMapId(controller.mapId); + _mapController = controller; + if (state.initialPoint != null) { + controller.animateCamera( + CameraUpdate.newLatLngZoom(state.initialPoint!, 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, + }) async { + final PictureRecorder pictureRecorder = PictureRecorder(); + final Canvas canvas = Canvas(pictureRecorder); + final Paint paint1 = Paint()..color = Theme.of(context).colorScheme.primary; + + canvas.drawCircle(Offset(size / 2, size / 2), size / 2.0, paint1); + + if (text != null) { + TextPainter painter = TextPainter(textDirection: TextDirection.ltr); + painter.text = TextSpan( + text: text, + style: TextStyle( + fontSize: size / 3, + color: Theme.of(context).colorScheme.onPrimary, + fontWeight: FontWeight.normal, + ), + ); + painter.layout(); + painter.paint( + canvas, + Offset(size / 2 - painter.width / 2, size / 2 - painter.height / 2), + ); + } + + final img = await pictureRecorder.endRecording().toImage(size, size); + final data = await img.toByteData(format: ImageByteFormat.png) as ByteData; + + return BitmapDescriptor.fromBytes(data.buffer.asUint8List()); + } + + 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(cluster.isMultiple ? 125 : 50, + text: cluster.isMultiple ? cluster.count.toString() : null), + ), + ); + GoogleMapController? _mapController; +} diff --git a/app/lib/widget/my_app.dart b/app/lib/widget/my_app.dart index 9055fb33..3dbab0e7 100644 --- a/app/lib/widget/my_app.dart +++ b/app/lib/widget/my_app.dart @@ -37,6 +37,7 @@ import 'package:nc_photos/widget/home.dart'; import 'package:nc_photos/widget/image_editor.dart'; import 'package:nc_photos/widget/image_enhancer.dart'; import 'package:nc_photos/widget/local_file_viewer.dart'; +import 'package:nc_photos/widget/map_browser.dart'; import 'package:nc_photos/widget/people_browser.dart'; import 'package:nc_photos/widget/places_browser.dart'; import 'package:nc_photos/widget/result_viewer.dart'; @@ -219,6 +220,7 @@ class _WrappedAppState extends State<_WrappedApp> PlacesBrowser.routeName: PlacesBrowser.buildRoute, ArchiveBrowser.routeName: ArchiveBrowser.buildRoute, TrustedCertManager.routeName: TrustedCertManager.buildRoute, + MapBrowser.routeName: MapBrowser.buildRoute, }; Route? _onGenerateRoute(RouteSettings settings) { 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_db/lib/src/api.dart b/np_db/lib/src/api.dart index 0da8c578..21d8b664 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,11 @@ abstract class NpDb { List? excludeRelativeRoots, }); + /// Return the latitude, longitude and the file id of all files + Future> getImageLatLngWithFileIds({ + required DbAccount account, + }); + 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..53cb9a5e 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,76 @@ 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, + List? includeRelativeRoots, + List? includeRelativeDirs, + List? excludeRelativeRoots, + List? mimes, + }) async { + _log.info("[queryImageLatLngWithFileIds]"); + 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)); + } + 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..26c06443 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,22 @@ class NpDbSqlite implements NpDb { ); } + @override + Future> getImageLatLngWithFileIds({ + required DbAccount account, + }) async { + final sqlObjs = await _db.use((db) async { + return await db.queryImageLatLngWithFileIds( + account: ByAccount.db(account), + ); + }); + return sqlObjs.computeAll((e) => DbImageLatLng( + lat: e.lat, + lng: e.lng, + fileId: e.fileId, + )); + } + @override Future> getNcAlbums({ required DbAccount account, From 7cae2827c70cc8cdeb28f4ca768b92cfdaab74f4 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 13 Jul 2024 21:12:18 +0800 Subject: [PATCH 02/10] Update cluster marker style in map browser --- app/lib/widget/map_browser.dart | 1 + app/lib/widget/map_browser/view.dart | 93 +++++++++++++++++++++++++--- 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/app/lib/widget/map_browser.dart b/app/lib/widget/map_browser.dart index 5f9ccf2c..0b035afc 100644 --- a/app/lib/widget/map_browser.dart +++ b/app/lib/widget/map_browser.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:ui'; import 'package:copy_with/copy_with.dart'; +import 'package:flex_seed_scheme/flex_seed_scheme.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; diff --git a/app/lib/widget/map_browser/view.dart b/app/lib/widget/map_browser/view.dart index b2b85938..6929c7b1 100644 --- a/app/lib/widget/map_browser/view.dart +++ b/app/lib/widget/map_browser/view.dart @@ -56,27 +56,41 @@ class _MapViewState extends State<_MapView> { Future _getClusterBitmap( int size, { String? text, + required Color color, }) async { final PictureRecorder pictureRecorder = PictureRecorder(); final Canvas canvas = Canvas(pictureRecorder); - final Paint paint1 = Paint()..color = Theme.of(context).colorScheme.primary; + final Paint paint1 = Paint()..color = color; - canvas.drawCircle(Offset(size / 2, size / 2), size / 2.0, paint1); + 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, + paint1, + ); if (text != null) { TextPainter painter = TextPainter(textDirection: TextDirection.ltr); painter.text = TextSpan( text: text, style: TextStyle( - fontSize: size / 3, - color: Theme.of(context).colorScheme.onPrimary, + fontSize: size / 3.5, + color: Theme.of(context).colorScheme.onPrimaryContainer, fontWeight: FontWeight.normal, ), ); painter.layout(); painter.paint( canvas, - Offset(size / 2 - painter.width / 2, size / 2 - painter.height / 2), + Offset( + size / 2 - painter.width / 2 - shadowPaddingHalf, + size / 2 - painter.height / 2 - shadowPaddingHalf, + ), ); } @@ -86,6 +100,63 @@ class _MapViewState extends State<_MapView> { 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) { + final tone = (r * 30 + 65).toInt(); + return Color(_colorTonalPalette.get(tone)); + } else { + final tone = (60 - r * 30).toInt(); + return Color(_colorTonalPalette.get(tone)); + } + } + + 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 * 50).toInt() + 90; + } + late final _clusterManager = ClusterManager<_DataPoint>( const [], (markers) { @@ -109,9 +180,17 @@ class _MapViewState extends State<_MapView> { arguments: CollectionBrowserArguments(c), ); }, - icon: await _getClusterBitmap(cluster.isMultiple ? 125 : 50, - text: cluster.isMultiple ? cluster.count.toString() : null), + icon: await _getClusterBitmap( + _getMarkerSize(cluster.count * 1), + text: _getMarkerCountString(cluster.count * 1), + color: _getMarkerColor(cluster.count * 1), + ), ), ); GoogleMapController? _mapController; + + late final _colorTonalPalette = () { + final hct = Hct.fromInt(Theme.of(context).colorScheme.primary.value); + return FlexTonalPalette.of(hct.hue, hct.chroma); + }(); } From 4bb825a7dc95f7dc07b900eb0fc5cd5e9133a047 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Mon, 15 Jul 2024 01:17:50 +0800 Subject: [PATCH 03/10] Correctly filter map browser query --- app/lib/entity/image_location/data_source.dart | 14 +++++++++++++- np_db/lib/src/api.dart | 3 +++ np_db_sqlite/lib/src/sqlite_api.dart | 6 ++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app/lib/entity/image_location/data_source.dart b/app/lib/entity/image_location/data_source.dart index 7deee07e..3b8b4919 100644 --- a/app/lib/entity/image_location/data_source.dart +++ b/app/lib/entity/image_location/data_source.dart @@ -1,7 +1,11 @@ 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_db/np_db.dart'; @@ -15,7 +19,15 @@ class ImageLocationNpDbDataSource implements ImageLocationDataSource { @override Future> getLocations(Account account) async { _log.info("[getLocations]"); - final results = await db.getImageLatLngWithFileIds(account: account.toDb()); + final results = await db.getImageLatLngWithFileIds( + account: account.toDb(), + 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); } diff --git a/np_db/lib/src/api.dart b/np_db/lib/src/api.dart index 21d8b664..a184f250 100644 --- a/np_db/lib/src/api.dart +++ b/np_db/lib/src/api.dart @@ -435,6 +435,9 @@ abstract class NpDb { /// Return the latitude, longitude and the file id of all files Future> getImageLatLngWithFileIds({ required DbAccount account, + List? includeRelativeRoots, + List? excludeRelativeRoots, + List? mimes, }); Future> getNcAlbums({ diff --git a/np_db_sqlite/lib/src/sqlite_api.dart b/np_db_sqlite/lib/src/sqlite_api.dart index 26c06443..b0c90919 100644 --- a/np_db_sqlite/lib/src/sqlite_api.dart +++ b/np_db_sqlite/lib/src/sqlite_api.dart @@ -578,10 +578,16 @@ class NpDbSqlite implements NpDb { @override Future> getImageLatLngWithFileIds({ required DbAccount account, + List? includeRelativeRoots, + List? excludeRelativeRoots, + List? mimes, }) async { final sqlObjs = await _db.use((db) async { return await db.queryImageLatLngWithFileIds( account: ByAccount.db(account), + includeRelativeRoots: includeRelativeRoots, + excludeRelativeRoots: excludeRelativeRoots, + mimes: mimes, ); }); return sqlObjs.computeAll((e) => DbImageLatLng( From 739b23b8ba0b03d0ba8d6e63feedfc43513ef3c9 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Mon, 15 Jul 2024 01:19:03 +0800 Subject: [PATCH 04/10] DB api to support filtering locations by time range --- app/lib/entity/image_location/data_source.dart | 7 +++++-- app/lib/entity/image_location/repo.dart | 10 ++++++---- np_db/lib/src/api.dart | 1 + .../lib/src/database/image_location_extension.dart | 8 +++++++- np_db_sqlite/lib/src/sqlite_api.dart | 2 ++ 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/app/lib/entity/image_location/data_source.dart b/app/lib/entity/image_location/data_source.dart index 3b8b4919..53cc3d8e 100644 --- a/app/lib/entity/image_location/data_source.dart +++ b/app/lib/entity/image_location/data_source.dart @@ -8,6 +8,7 @@ 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'; @@ -17,10 +18,12 @@ class ImageLocationNpDbDataSource implements ImageLocationDataSource { const ImageLocationNpDbDataSource(this.db); @override - Future> getLocations(Account account) async { - _log.info("[getLocations]"); + 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) diff --git a/app/lib/entity/image_location/repo.dart b/app/lib/entity/image_location/repo.dart index a97f3f59..cca0b0e9 100644 --- a/app/lib/entity/image_location/repo.dart +++ b/app/lib/entity/image_location/repo.dart @@ -2,6 +2,7 @@ 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'; @@ -28,7 +29,7 @@ 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); + Future> getLocations(Account account, TimeRange timeRange); } @npLog @@ -36,8 +37,9 @@ class BasicImageLocationRepo implements ImageLocationRepo { const BasicImageLocationRepo(this.dataSrc); @override - Future> getLocations(Account account) => - dataSrc.getLocations(account); + Future> getLocations( + Account account, TimeRange timeRange) => + dataSrc.getLocations(account, timeRange); final ImageLocationDataSource dataSrc; } @@ -46,5 +48,5 @@ 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); + Future> getLocations(Account account, TimeRange timeRange); } diff --git a/np_db/lib/src/api.dart b/np_db/lib/src/api.dart index a184f250..1c988b24 100644 --- a/np_db/lib/src/api.dart +++ b/np_db/lib/src/api.dart @@ -435,6 +435,7 @@ abstract class NpDb { /// Return the latitude, longitude and the file id of all files Future> getImageLatLngWithFileIds({ required DbAccount account, + TimeRange? timeRange, List? includeRelativeRoots, List? excludeRelativeRoots, List? mimes, 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 53cb9a5e..901dfd33 100644 --- a/np_db_sqlite/lib/src/database/image_location_extension.dart +++ b/np_db_sqlite/lib/src/database/image_location_extension.dart @@ -31,12 +31,13 @@ class ImageLatLng { extension SqliteDbImageLocationExtension on SqliteDb { Future> queryImageLatLngWithFileIds({ required ByAccount account, + TimeRange? timeRange, List? includeRelativeRoots, List? includeRelativeDirs, List? excludeRelativeRoots, List? mimes, }) async { - _log.info("[queryImageLatLngWithFileIds]"); + _log.info("[queryImageLatLngWithFileIds] timeRange: $timeRange"); final query = _queryFiles().let((q) { q ..setQueryMode( @@ -73,6 +74,11 @@ extension SqliteDbImageLocationExtension on SqliteDb { } 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()) diff --git a/np_db_sqlite/lib/src/sqlite_api.dart b/np_db_sqlite/lib/src/sqlite_api.dart index b0c90919..bff7c0aa 100644 --- a/np_db_sqlite/lib/src/sqlite_api.dart +++ b/np_db_sqlite/lib/src/sqlite_api.dart @@ -578,6 +578,7 @@ class NpDbSqlite implements NpDb { @override Future> getImageLatLngWithFileIds({ required DbAccount account, + TimeRange? timeRange, List? includeRelativeRoots, List? excludeRelativeRoots, List? mimes, @@ -585,6 +586,7 @@ class NpDbSqlite implements NpDb { final sqlObjs = await _db.use((db) async { return await db.queryImageLatLngWithFileIds( account: ByAccount.db(account), + timeRange: timeRange, includeRelativeRoots: includeRelativeRoots, excludeRelativeRoots: excludeRelativeRoots, mimes: mimes, From 877bed1640ec0fffe25fedac379f6514187d2122 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Mon, 15 Jul 2024 01:23:56 +0800 Subject: [PATCH 05/10] Update marker style --- app/lib/widget/map_browser/view.dart | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/app/lib/widget/map_browser/view.dart b/app/lib/widget/map_browser/view.dart index 6929c7b1..1acd911d 100644 --- a/app/lib/widget/map_browser/view.dart +++ b/app/lib/widget/map_browser/view.dart @@ -131,11 +131,19 @@ class _MapViewState extends State<_MapView> { r = (count / 10) * step; } if (Theme.of(context).brightness == Brightness.light) { - final tone = (r * 30 + 65).toInt(); - return Color(_colorTonalPalette.get(tone)); + return HSLColor.fromAHSL( + 1, + _colorHsl.hue, + r * .8 + .2, + (_colorHsl.lightness - (.1 - r * .1)).clamp(0, 1), + ).toColor(); } else { - final tone = (60 - r * 30).toInt(); - return Color(_colorTonalPalette.get(tone)); + return HSLColor.fromAHSL( + 1, + _colorHsl.hue, + r * .65 + .35, + (_colorHsl.lightness - (.1 - r * .1)).clamp(0, 1), + ).toColor(); } } @@ -154,7 +162,7 @@ class _MapViewState extends State<_MapView> { default: r = (count / 10) * step; } - return (r * 50).toInt() + 90; + return (r * 85).toInt() + 75; } late final _clusterManager = ClusterManager<_DataPoint>( @@ -189,8 +197,6 @@ class _MapViewState extends State<_MapView> { ); GoogleMapController? _mapController; - late final _colorTonalPalette = () { - final hct = Hct.fromInt(Theme.of(context).colorScheme.primary.value); - return FlexTonalPalette.of(hct.hue, hct.chroma); - }(); + late final _colorHsl = + HSLColor.fromColor(Theme.of(context).colorScheme.primaryContainer); } From 17e7aa55f3b394cc3ea690a3f8f104f148ecb3c3 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Mon, 15 Jul 2024 01:42:39 +0800 Subject: [PATCH 06/10] Filter photos by date in map browser --- app/lib/l10n/app_en.arb | 8 + app/lib/l10n/untranslated-messages.txt | 102 ++++++++- app/lib/widget/map_browser.dart | 50 +++- app/lib/widget/map_browser.g.dart | 44 +++- app/lib/widget/map_browser/bloc.dart | 110 ++++++++- app/lib/widget/map_browser/state_event.dart | 61 ++++- app/lib/widget/map_browser/type.dart | 21 ++ app/lib/widget/map_browser/view.dart | 239 ++++++++++++++++++++ np_datetime/lib/src/date_range.dart | 16 ++ np_datetime/lib/src/time_range.dart | 16 ++ 10 files changed, 637 insertions(+), 30 deletions(-) diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 9ea6a2ae..0675149b 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1487,6 +1487,14 @@ "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", "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..e5b40963 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -249,6 +249,11 @@ "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom", "errorUnauthenticated", "errorDisconnected", "errorLocked", @@ -284,7 +289,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "de": [ @@ -318,7 +328,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "el": [ @@ -455,7 +470,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "es": [ @@ -483,7 +503,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "fi": [ @@ -511,7 +536,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "fr": [ @@ -539,7 +569,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "it": [ @@ -572,7 +607,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "nl": [ @@ -942,6 +982,11 @@ "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom", "errorUnauthenticated", "errorDisconnected", "errorLocked", @@ -981,7 +1026,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "pt": [ @@ -1029,7 +1079,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "ru": [ @@ -1057,7 +1112,20 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" + ], + + "tr": [ + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "zh": [ @@ -1116,7 +1184,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "zh_Hant": [ @@ -1269,6 +1342,11 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ] } diff --git a/app/lib/widget/map_browser.dart b/app/lib/widget/map_browser.dart index 0b035afc..757d643c 100644 --- a/app/lib/widget/map_browser.dart +++ b/app/lib/widget/map_browser.dart @@ -1,16 +1,18 @@ import 'dart:async'; import 'dart:ui'; +import 'package:clock/clock.dart'; import 'package:copy_with/copy_with.dart'; -import 'package:flex_seed_scheme/flex_seed_scheme.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/di_container.dart'; @@ -18,9 +20,14 @@ 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/widget/collection_browser.dart'; +import 'package:nc_photos/widget/measure.dart'; import 'package:np_codegen/np_codegen.dart'; +import 'package:np_datetime/np_datetime.dart'; import 'package:to_string/to_string.dart'; part 'map_browser.g.dart'; @@ -44,7 +51,7 @@ class MapBrowser extends StatelessWidget { create: (_) => _Bloc( KiwiContainer().resolve(), account: context.read().account, - )..add(const _Init()), + )..add(const _LoadData()), child: const _WrappedMapBrowser(), ); } @@ -69,7 +76,42 @@ class _WrappedMapBrowser extends StatelessWidget { }, ), ], - child: const _MapView(), + 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(), + ), + ), + ), + ], + ), ), ), ); @@ -79,7 +121,7 @@ class _WrappedMapBrowser extends StatelessWidget { typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; // typedef _BlocListener = BlocListener<_Bloc, _State>; typedef _BlocListenerT = BlocListenerT<_Bloc, _State, T>; -// typedef _BlocSelector = BlocSelector<_Bloc, _State, T>; +typedef _BlocSelector = BlocSelector<_Bloc, _State, T>; extension on BuildContext { _Bloc get bloc => read<_Bloc>(); diff --git a/app/lib/widget/map_browser.g.dart b/app/lib/widget/map_browser.g.dart index 262a5949..43c60435 100644 --- a/app/lib/widget/map_browser.g.dart +++ b/app/lib/widget/map_browser.g.dart @@ -17,6 +17,9 @@ abstract class $_StateCopyWithWorker { {List<_DataPoint>? data, LatLng? initialPoint, Set? markers, + bool? isShowDataRangeControlPanel, + _DateRangeType? dateRangeType, + DateRange? localDateRange, ExceptionEvent? error}); } @@ -28,6 +31,9 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { {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, @@ -35,6 +41,10 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { ? that.initialPoint : initialPoint as LatLng?, 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?); } @@ -64,14 +74,14 @@ extension _$_BlocNpLog on _Bloc { extension _$_StateToString on _State { String _$toString() { // ignore: unnecessary_string_interpolations - return "_State {data: [length: ${data.length}], initialPoint: $initialPoint, markers: {length: ${markers.length}}, error: $error}"; + return "_State {data: [length: ${data.length}], initialPoint: $initialPoint, markers: {length: ${markers.length}}, isShowDataRangeControlPanel: $isShowDataRangeControlPanel, dateRangeType: ${dateRangeType.name}, localDateRange: $localDateRange, error: $error}"; } } -extension _$_InitToString on _Init { +extension _$_LoadDataToString on _LoadData { String _$toString() { // ignore: unnecessary_string_interpolations - return "_Init {}"; + return "_LoadData {}"; } } @@ -82,6 +92,34 @@ extension _$_SetMarkersToString on _SetMarkers { } } +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 diff --git a/app/lib/widget/map_browser/bloc.dart b/app/lib/widget/map_browser/bloc.dart index eb42226f..2c3df23b 100644 --- a/app/lib/widget/map_browser/bloc.dart +++ b/app/lib/widget/map_browser/bloc.dart @@ -6,10 +6,31 @@ class _Bloc extends Bloc<_Event, _State> _Bloc( this._c, { required this.account, - }) : super(_State.init()) { - on<_Init>(_onInit); + }) : 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 @@ -28,10 +49,16 @@ class _Bloc extends Bloc<_Event, _State> super.onError(error, stackTrace); } - Future _onInit(_Init ev, Emitter<_State> emit) async { + Future _onLoadData(_LoadData ev, Emitter<_State> emit) async { _log.info(ev); - final raw = await _c.imageLocationRepo.getLocations(account); - _log.info("[_onInit] Loaded ${raw.length} markers"); + // 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"); emit(state.copyWith( data: raw.map(_DataPoint.fromImageLatLng).toList(), initialPoint: state.initialPoint ?? @@ -46,13 +73,86 @@ class _Bloc extends Bloc<_Event, _State> 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 _subscriptions = []; + var _isHandlingError = false; } diff --git a/app/lib/widget/map_browser/state_event.dart b/app/lib/widget/map_browser/state_event.dart index da6a4441..145948f2 100644 --- a/app/lib/widget/map_browser/state_event.dart +++ b/app/lib/widget/map_browser/state_event.dart @@ -7,13 +7,22 @@ class _State { required this.data, this.initialPoint, required this.markers, + required this.isShowDataRangeControlPanel, + required this.dateRangeType, + required this.localDateRange, this.error, }); - factory _State.init() { - return const _State( - data: [], - markers: {}, + factory _State.init({ + required _DateRangeType dateRangeType, + required DateRange localDateRange, + }) { + return _State( + data: const [], + markers: const {}, + isShowDataRangeControlPanel: false, + dateRangeType: dateRangeType, + localDateRange: localDateRange, ); } @@ -24,6 +33,10 @@ class _State { final LatLng? initialPoint; final Set markers; + final bool isShowDataRangeControlPanel; + final _DateRangeType dateRangeType; + final DateRange localDateRange; + final ExceptionEvent? error; } @@ -32,8 +45,8 @@ abstract class _Event { } @toString -class _Init implements _Event { - const _Init(); +class _LoadData implements _Event { + const _LoadData(); @override String toString() => _$toString(); @@ -49,6 +62,42 @@ class _SetMarkers implements _Event { 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]); diff --git a/app/lib/widget/map_browser/type.dart b/app/lib/widget/map_browser/type.dart index 4ca232f0..a7b7affc 100644 --- a/app/lib/widget/map_browser/type.dart +++ b/app/lib/widget/map_browser/type.dart @@ -19,3 +19,24 @@ class _DataPoint implements ClusterItem { 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; + } + } +} diff --git a/app/lib/widget/map_browser/view.dart b/app/lib/widget/map_browser/view.dart index 1acd911d..52580007 100644 --- a/app/lib/widget/map_browser/view.dart +++ b/app/lib/widget/map_browser/view.dart @@ -200,3 +200,242 @@ class _MapViewState extends State<_MapView> { 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)); +} 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 ? "[" : "("}" From 0c8b611e46c68828aecc82cdc269a62d4ea0dab3 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Wed, 17 Jul 2024 00:51:57 +0800 Subject: [PATCH 07/10] Improve map marker readability --- app/lib/widget/map_browser/view.dart | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/app/lib/widget/map_browser/view.dart b/app/lib/widget/map_browser/view.dart index 52580007..84ce884b 100644 --- a/app/lib/widget/map_browser/view.dart +++ b/app/lib/widget/map_browser/view.dart @@ -60,7 +60,13 @@ class _MapViewState extends State<_MapView> { }) async { final PictureRecorder pictureRecorder = PictureRecorder(); final Canvas canvas = Canvas(pictureRecorder); - final Paint paint1 = Paint()..color = color; + 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; @@ -71,7 +77,12 @@ class _MapViewState extends State<_MapView> { canvas.drawCircle( Offset(size / 2 - shadowPaddingHalf, size / 2 - shadowPaddingHalf), size / 2 - shadowPaddingHalf, - paint1, + fillPaint, + ); + canvas.drawCircle( + Offset(size / 2 - shadowPaddingHalf, size / 2 - shadowPaddingHalf), + size / 2 - shadowPaddingHalf - (size / 28 / 2), + outlinePaint, ); if (text != null) { @@ -79,7 +90,7 @@ class _MapViewState extends State<_MapView> { painter.text = TextSpan( text: text, style: TextStyle( - fontSize: size / 3.5, + fontSize: size / 3 - ((text.length / 6) * (size * 0.1)), color: Theme.of(context).colorScheme.onPrimaryContainer, fontWeight: FontWeight.normal, ), @@ -134,14 +145,14 @@ class _MapViewState extends State<_MapView> { return HSLColor.fromAHSL( 1, _colorHsl.hue, - r * .8 + .2, + r * .7 + .3, (_colorHsl.lightness - (.1 - r * .1)).clamp(0, 1), ).toColor(); } else { return HSLColor.fromAHSL( 1, _colorHsl.hue, - r * .65 + .35, + r * .6 + .4, (_colorHsl.lightness - (.1 - r * .1)).clamp(0, 1), ).toColor(); } @@ -162,7 +173,7 @@ class _MapViewState extends State<_MapView> { default: r = (count / 10) * step; } - return (r * 85).toInt() + 75; + return (r * 85).toInt() + 85; } late final _clusterManager = ClusterManager<_DataPoint>( From 8c8dcb3e8ea767470fd49ed88d6938d2424f1c2e Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 20 Jul 2024 03:34:46 +0800 Subject: [PATCH 08/10] Map browser now default to the position of the last known latest photo --- app/lib/controller/pref_controller.dart | 15 ++++++++++++ app/lib/controller/pref_controller.g.dart | 9 ++++++++ app/lib/controller/pref_controller/util.dart | 16 +++++++++++++ app/lib/entity/pref.dart | 3 +++ app/lib/json_util.dart | 13 +++++++++++ app/lib/widget/map_browser.dart | 4 ++++ app/lib/widget/map_browser.g.dart | 4 ++-- app/lib/widget/map_browser/bloc.dart | 24 ++++++++++++++------ app/lib/widget/map_browser/state_event.dart | 2 +- app/lib/widget/map_browser/type.dart | 4 ++++ app/lib/widget/map_browser/view.dart | 17 +++++++++----- 11 files changed, 95 insertions(+), 16 deletions(-) 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/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/widget/map_browser.dart b/app/lib/widget/map_browser.dart index 757d643c..df736452 100644 --- a/app/lib/widget/map_browser.dart +++ b/app/lib/widget/map_browser.dart @@ -15,6 +15,7 @@ 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'; @@ -27,7 +28,9 @@ import 'package:nc_photos/theme.dart'; import 'package:nc_photos/widget/collection_browser.dart'; import 'package:nc_photos/widget/measure.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'; @@ -51,6 +54,7 @@ class MapBrowser extends StatelessWidget { create: (_) => _Bloc( KiwiContainer().resolve(), account: context.read().account, + prefController: context.read(), )..add(const _LoadData()), child: const _WrappedMapBrowser(), ); diff --git a/app/lib/widget/map_browser.g.dart b/app/lib/widget/map_browser.g.dart index 43c60435..e4eee5b4 100644 --- a/app/lib/widget/map_browser.g.dart +++ b/app/lib/widget/map_browser.g.dart @@ -15,7 +15,7 @@ part of 'map_browser.dart'; abstract class $_StateCopyWithWorker { _State call( {List<_DataPoint>? data, - LatLng? initialPoint, + MapCoord? initialPoint, Set? markers, bool? isShowDataRangeControlPanel, _DateRangeType? dateRangeType, @@ -39,7 +39,7 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { data: data as List<_DataPoint>? ?? that.data, initialPoint: initialPoint == copyWithNull ? that.initialPoint - : initialPoint as LatLng?, + : initialPoint as MapCoord?, markers: markers as Set? ?? that.markers, isShowDataRangeControlPanel: isShowDataRangeControlPanel as bool? ?? that.isShowDataRangeControlPanel, diff --git a/app/lib/widget/map_browser/bloc.dart b/app/lib/widget/map_browser/bloc.dart index 2c3df23b..52b46194 100644 --- a/app/lib/widget/map_browser/bloc.dart +++ b/app/lib/widget/map_browser/bloc.dart @@ -6,6 +6,7 @@ class _Bloc extends Bloc<_Event, _State> _Bloc( this._c, { required this.account, + required this.prefController, }) : super(_State.init( dateRangeType: _DateRangeType.thisMonth, localDateRange: @@ -59,13 +60,21 @@ class _Bloc extends Bloc<_Event, _State> ); final raw = await _c.imageLocationRepo.getLocations(account, utcTimeRange); _log.info("[_onLoadData] Loaded ${raw.length} markers"); - emit(state.copyWith( - data: raw.map(_DataPoint.fromImageLatLng).toList(), - initialPoint: state.initialPoint ?? - (raw.firstOrNull == null - ? null - : LatLng(raw.first.latitude, raw.first.longitude)), - )); + 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) { @@ -151,6 +160,7 @@ class _Bloc extends Bloc<_Event, _State> final DiContainer _c; final Account account; + final PrefController prefController; final _subscriptions = []; diff --git a/app/lib/widget/map_browser/state_event.dart b/app/lib/widget/map_browser/state_event.dart index 145948f2..e90efac6 100644 --- a/app/lib/widget/map_browser/state_event.dart +++ b/app/lib/widget/map_browser/state_event.dart @@ -30,7 +30,7 @@ class _State { String toString() => _$toString(); final List<_DataPoint> data; - final LatLng? initialPoint; + final MapCoord? initialPoint; final Set markers; final bool isShowDataRangeControlPanel; diff --git a/app/lib/widget/map_browser/type.dart b/app/lib/widget/map_browser/type.dart index a7b7affc..6b760c9e 100644 --- a/app/lib/widget/map_browser/type.dart +++ b/app/lib/widget/map_browser/type.dart @@ -40,3 +40,7 @@ enum _DateRangeType { } } } + +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 index 84ce884b..b84c0df3 100644 --- a/app/lib/widget/map_browser/view.dart +++ b/app/lib/widget/map_browser/view.dart @@ -18,12 +18,12 @@ class _MapViewState extends State<_MapView> { _clusterManager.setItems(data); }, ), - _BlocListenerT( + _BlocListenerT( selector: (state) => state.initialPoint, listener: (context, initialPoint) { if (initialPoint != null) { - _mapController - ?.animateCamera(CameraUpdate.newLatLngZoom(initialPoint, 10)); + _mapController?.animateCamera( + CameraUpdate.newLatLngZoom(initialPoint.toLatLng(), 10)); } }, ), @@ -32,14 +32,19 @@ class _MapViewState extends State<_MapView> { buildWhen: (previous, current) => previous.markers != current.markers, builder: (context, state) => GoogleMap( mapType: MapType.normal, - initialCameraPosition: const CameraPosition(target: LatLng(0, 0)), + 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 (state.initialPoint != null) { - controller.animateCamera( - CameraUpdate.newLatLngZoom(state.initialPoint!, 10)); + controller.animateCamera(CameraUpdate.newLatLngZoom( + state.initialPoint!.toLatLng(), 10)); } }, onCameraMove: _clusterManager.onCameraMove, From 942d36d4a6cdc10f055dd607e06d8ab421c6eca6 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 20 Jul 2024 16:17:09 +0800 Subject: [PATCH 09/10] Move map browser to tab instead of being a standalone page --- app/lib/l10n/app_en.arb | 1 + app/lib/l10n/untranslated-messages.txt | 41 +++++++--- app/lib/widget/home.dart | 13 ++- app/lib/widget/map_browser.dart | 109 ++++++++++++------------- app/lib/widget/my_app.dart | 2 - 5 files changed, 93 insertions(+), 73 deletions(-) diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 0675149b..a7162afa 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1495,6 +1495,7 @@ "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 e5b40963..bab42341 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -254,6 +254,7 @@ "mapBrowserDateRangePrevMonth", "mapBrowserDateRangeThisYear", "mapBrowserDateRangeCustom", + "homeTabMapBrowser", "errorUnauthenticated", "errorDisconnected", "errorLocked", @@ -294,7 +295,8 @@ "mapBrowserDateRangeThisMonth", "mapBrowserDateRangePrevMonth", "mapBrowserDateRangeThisYear", - "mapBrowserDateRangeCustom" + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "de": [ @@ -333,7 +335,8 @@ "mapBrowserDateRangeThisMonth", "mapBrowserDateRangePrevMonth", "mapBrowserDateRangeThisYear", - "mapBrowserDateRangeCustom" + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "el": [ @@ -475,7 +478,8 @@ "mapBrowserDateRangeThisMonth", "mapBrowserDateRangePrevMonth", "mapBrowserDateRangeThisYear", - "mapBrowserDateRangeCustom" + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "es": [ @@ -508,7 +512,8 @@ "mapBrowserDateRangeThisMonth", "mapBrowserDateRangePrevMonth", "mapBrowserDateRangeThisYear", - "mapBrowserDateRangeCustom" + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "fi": [ @@ -541,7 +546,8 @@ "mapBrowserDateRangeThisMonth", "mapBrowserDateRangePrevMonth", "mapBrowserDateRangeThisYear", - "mapBrowserDateRangeCustom" + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "fr": [ @@ -574,7 +580,8 @@ "mapBrowserDateRangeThisMonth", "mapBrowserDateRangePrevMonth", "mapBrowserDateRangeThisYear", - "mapBrowserDateRangeCustom" + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "it": [ @@ -612,7 +619,8 @@ "mapBrowserDateRangeThisMonth", "mapBrowserDateRangePrevMonth", "mapBrowserDateRangeThisYear", - "mapBrowserDateRangeCustom" + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "nl": [ @@ -987,6 +995,7 @@ "mapBrowserDateRangePrevMonth", "mapBrowserDateRangeThisYear", "mapBrowserDateRangeCustom", + "homeTabMapBrowser", "errorUnauthenticated", "errorDisconnected", "errorLocked", @@ -1031,7 +1040,8 @@ "mapBrowserDateRangeThisMonth", "mapBrowserDateRangePrevMonth", "mapBrowserDateRangeThisYear", - "mapBrowserDateRangeCustom" + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "pt": [ @@ -1084,7 +1094,8 @@ "mapBrowserDateRangeThisMonth", "mapBrowserDateRangePrevMonth", "mapBrowserDateRangeThisYear", - "mapBrowserDateRangeCustom" + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "ru": [ @@ -1117,7 +1128,8 @@ "mapBrowserDateRangeThisMonth", "mapBrowserDateRangePrevMonth", "mapBrowserDateRangeThisYear", - "mapBrowserDateRangeCustom" + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "tr": [ @@ -1125,7 +1137,8 @@ "mapBrowserDateRangeThisMonth", "mapBrowserDateRangePrevMonth", "mapBrowserDateRangeThisYear", - "mapBrowserDateRangeCustom" + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "zh": [ @@ -1189,7 +1202,8 @@ "mapBrowserDateRangeThisMonth", "mapBrowserDateRangePrevMonth", "mapBrowserDateRangeThisYear", - "mapBrowserDateRangeCustom" + "mapBrowserDateRangeCustom", + "homeTabMapBrowser" ], "zh_Hant": [ @@ -1347,6 +1361,7 @@ "mapBrowserDateRangeThisMonth", "mapBrowserDateRangePrevMonth", "mapBrowserDateRangeThisYear", - "mapBrowserDateRangeCustom" + "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 index df736452..423f11d0 100644 --- a/app/lib/widget/map_browser.dart +++ b/app/lib/widget/map_browser.dart @@ -25,8 +25,10 @@ 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'; @@ -40,12 +42,6 @@ part 'map_browser/type.dart'; part 'map_browser/view.dart'; class MapBrowser extends StatelessWidget { - static const routeName = "/map-browser"; - - static Route buildRoute() => MaterialPageRoute( - builder: (_) => const MapBrowser(), - ); - const MapBrowser({super.key}); @override @@ -66,57 +62,58 @@ class _WrappedMapBrowser extends StatelessWidget { @override Widget build(BuildContext context) { - return AnnotatedRegion( - value: SystemUiOverlayStyle.dark, - child: Scaffold( - body: 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(), - ), - ), - ), - ], - ), + 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, + ), + ), + ], ), ); } diff --git a/app/lib/widget/my_app.dart b/app/lib/widget/my_app.dart index 3dbab0e7..9055fb33 100644 --- a/app/lib/widget/my_app.dart +++ b/app/lib/widget/my_app.dart @@ -37,7 +37,6 @@ import 'package:nc_photos/widget/home.dart'; import 'package:nc_photos/widget/image_editor.dart'; import 'package:nc_photos/widget/image_enhancer.dart'; import 'package:nc_photos/widget/local_file_viewer.dart'; -import 'package:nc_photos/widget/map_browser.dart'; import 'package:nc_photos/widget/people_browser.dart'; import 'package:nc_photos/widget/places_browser.dart'; import 'package:nc_photos/widget/result_viewer.dart'; @@ -220,7 +219,6 @@ class _WrappedAppState extends State<_WrappedApp> PlacesBrowser.routeName: PlacesBrowser.buildRoute, ArchiveBrowser.routeName: ArchiveBrowser.buildRoute, TrustedCertManager.routeName: TrustedCertManager.buildRoute, - MapBrowser.routeName: MapBrowser.buildRoute, }; Route? _onGenerateRoute(RouteSettings settings) { From 8143c16ae772b4246da230e72c043b059bb430cc Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 21 Jul 2024 11:54:58 +0800 Subject: [PATCH 10/10] Add dark theme for map --- app/lib/widget/map_browser/view.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/lib/widget/map_browser/view.dart b/app/lib/widget/map_browser/view.dart index b84c0df3..cc852564 100644 --- a/app/lib/widget/map_browser/view.dart +++ b/app/lib/widget/map_browser/view.dart @@ -42,6 +42,9 @@ class _MapViewState extends State<_MapView> { 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)); @@ -455,3 +458,7 @@ class _DateFieldState extends State<_DateField> { 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"}]}]';