From 9bd01b16f404ebb251b6658c2d4698ae094833cd Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 5 Dec 2022 17:17:10 +0100 Subject: [PATCH] #423 map: cluster context menu, edit cluster location --- CHANGELOG.md | 1 + lib/model/source/media_store_source.dart | 20 +++- .../collection/entry_set_action_delegate.dart | 52 +++++++-- .../common/identity/aves_filter_chip.dart | 2 +- lib/widgets/common/map/geo_map.dart | 104 +++++++++++------- lib/widgets/common/map/leaflet/map.dart | 3 + .../entry_editors/edit_location_dialog.dart | 27 +++-- lib/widgets/dialogs/location_pick_dialog.dart | 4 +- .../common/action_delegates/chip_set.dart | 16 +-- lib/widgets/map/map_page.dart | 72 +++++++++++- .../action/entry_info_action_delegate.dart | 18 +-- lib/widgets/viewer/info/location_section.dart | 16 +-- plugins/aves_map/lib/src/interface.dart | 1 + .../lib/src/marker/arrow_painter.dart | 44 ++++++++ plugins/aves_map/lib/src/marker/image.dart | 104 +++++++++--------- plugins/aves_map/lib/src/zoomed_bounds.dart | 9 +- plugins/aves_services/lib/aves_services.dart | 1 + .../lib/aves_services_platform.dart | 2 + plugins/aves_services_google/lib/src/map.dart | 15 ++- .../lib/aves_services_platform.dart | 2 + plugins/aves_services_huawei/lib/src/map.dart | 15 ++- .../lib/aves_services_platform.dart | 1 + 22 files changed, 371 insertions(+), 158 deletions(-) create mode 100644 plugins/aves_map/lib/src/marker/arrow_painter.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7185617e3..5846b78d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. - Viewer: optionally show rating & tags on overlay - Viewer: long press on copy/move/rating/tag quick action for quicker action - Search: missing address, portrait, landscape filters +- Map: edit cluster location - Lithuanian translation (thanks Gediminas Murauskas) - Norsk (Bokmål) translation (thanks Allan Nordhøy) diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index d11cc5f1d..d64c2e0f1 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -231,7 +231,7 @@ class MediaStoreSource extends CollectionSource { // fetch new entries final tempUris = {}; - final newEntries = {}; + final newEntries = {}, entriesToRefresh = {}; final existingDirectories = {}; for (final kv in uriByContentId.entries) { final contentId = kv.key; @@ -244,8 +244,12 @@ class MediaStoreSource extends CollectionSource { final newPath = sourceEntry.path; final volume = newPath != null ? androidFileUtils.getStorageVolume(newPath) : null; if (volume != null) { - sourceEntry.id = existingEntry?.id ?? metadataDb.nextId; - newEntries.add(sourceEntry); + if (existingEntry != null) { + entriesToRefresh.add(existingEntry); + } else { + sourceEntry.id = metadataDb.nextId; + newEntries.add(sourceEntry); + } final existingDirectory = existingEntry?.directory; if (existingDirectory != null) { existingDirectories.add(existingDirectory); @@ -258,15 +262,19 @@ class MediaStoreSource extends CollectionSource { } } + invalidateAlbumFilterSummary(directories: existingDirectories); + if (newEntries.isNotEmpty) { - invalidateAlbumFilterSummary(directories: existingDirectories); addEntries(newEntries); await metadataDb.saveEntries(newEntries); - cleanEmptyAlbums(existingDirectories); - await analyze(analysisController, entries: newEntries); } + if (entriesToRefresh.isNotEmpty) { + final allDataTypes = EntryDataType.values.toSet(); + await Future.forEach(entriesToRefresh, (entry) => refreshEntry(entry, allDataTypes)); + } + return tempUris; } } diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 5790d0000..ed4e46efb 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -32,6 +32,7 @@ import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart'; +import 'package:aves/widgets/dialogs/location_pick_dialog.dart'; import 'package:aves/widgets/map/map_page.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/stats/stats_page.dart'; @@ -39,6 +40,7 @@ import 'package:aves/widgets/viewer/slideshow_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -427,8 +429,15 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware Future?> _getEditableTargetItems( BuildContext context, { required bool Function(AvesEntry entry) canEdit, + }) => + _getEditableItems(context, _getTargetItems(context), canEdit: canEdit); + + Future?> _getEditableItems( + BuildContext context, + Set entries, { + required bool Function(AvesEntry entry) canEdit, }) async { - final bySupported = groupBy(_getTargetItems(context), canEdit); + final bySupported = groupBy(entries, canEdit); final supported = (bySupported[true] ?? []).toSet(); final unsupported = (bySupported[false] ?? []).toSet(); @@ -500,6 +509,27 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware await _edit(context, entries, (entry) => entry.editLocation(location)); } + Future quickLocationByMap(BuildContext context, Set entries, LatLng clusterLocation, CollectionLens mapCollection) async { + final editableEntries = await _getEditableItems(context, entries, canEdit: (entry) => entry.canEditLocation); + if (editableEntries == null || editableEntries.isEmpty) return null; + + final location = await Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: LocationPickDialog.routeName), + builder: (context) => LocationPickDialog( + collection: mapCollection, + initialLocation: clusterLocation, + ), + fullscreenDialog: true, + ), + ); + if (location == null) return null; + + await _edit(context, editableEntries, (entry) => entry.editLocation(location)); + return location; + } + Future _editTitleDescription(BuildContext context) async { final entries = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditTitleDescription); if (entries == null || entries.isEmpty) return; @@ -549,24 +579,24 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware await _edit(context, entries, (entry) => entry.removeMetadata(types)); } - void _goToMap(BuildContext context) { + Future _goToMap(BuildContext context) async { final collection = context.read(); final entries = _getTargetItems(context); - Navigator.push( + // need collection with fresh ID to prevent hero from scroller on Map page to Collection page + final mapCollection = CollectionLens( + source: collection.source, + filters: collection.filters, + fixedSelection: entries.where((entry) => entry.hasGps).toList(), + ); + await Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: MapPage.routeName), - builder: (context) => MapPage( - // need collection with fresh ID to prevent hero from scroller on Map page to Collection page - collection: CollectionLens( - source: collection.source, - filters: collection.filters, - fixedSelection: entries.where((entry) => entry.hasGps).toList(), - ), - ), + builder: (context) => MapPage(collection: mapCollection), ), ); + mapCollection.dispose(); } void _goToSlideshow(BuildContext context) { diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index 7f30ff28a..e260f128c 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -106,7 +106,7 @@ class AvesFilterChip extends StatefulWidget { FocusManager.instance.primaryFocus?.unfocus(); final overlay = Overlay.of(context)!.context.findRenderObject() as RenderBox; - const touchArea = Size(40, 40); + const touchArea = Size(kMinInteractiveDimension, kMinInteractiveDimension); final selectedAction = await showMenu( context: context, position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size), diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart index 44d079d4c..a0a209d18 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -38,7 +38,8 @@ class GeoMap extends StatefulWidget { final MapOverlay? overlayEntry; final UserZoomChangeCallback? onUserZoomChange; final MapTapCallback? onMapTap; - final void Function(LatLng averageLocation, AvesEntry markerEntry, Set Function() getClusterEntries)? onMarkerTap; + final void Function(LatLng clusterLocation, AvesEntry markerEntry)? onMarkerTap; + final void Function(Offset tapLocalPosition, Set clusterEntries, LatLng clusterLocation, WidgetBuilder markerBuilder)? onMarkerLongPress; final void Function(BuildContext context)? openMapPage; const GeoMap({ @@ -54,6 +55,7 @@ class GeoMap extends StatefulWidget { this.onUserZoomChange, this.onMapTap, this.onMarkerTap, + this.onMarkerLongPress, this.openMapPage, }); @@ -118,41 +120,6 @@ class _GeoMapState extends State { @override Widget build(BuildContext context) { - void _onMarkerTap(GeoEntry geoEntry) { - final onTap = widget.onMarkerTap; - if (onTap == null) return; - - final clusterId = geoEntry.clusterId; - AvesEntry? markerEntry; - if (clusterId != null) { - final uri = geoEntry.childMarkerId; - markerEntry = entries.firstWhereOrNull((v) => v.uri == uri); - } else { - markerEntry = geoEntry.entry; - } - - if (markerEntry == null) return; - - Set getClusterEntries() { - if (clusterId == null) { - return {geoEntry.entry!}; - } - - var points = _defaultMarkerCluster?.points(clusterId) ?? []; - if (points.length != geoEntry.pointsSize) { - // `Fluster.points()` method does not always return all the points contained in a cluster - // the higher `nodeSize` is, the higher the chance to get all the points (i.e. as many as the cluster `pointsSize`) - _slowMarkerCluster ??= _buildFluster(nodeSize: smallestPowerOf2(entries.length)); - points = _slowMarkerCluster!.points(clusterId); - assert(points.length == geoEntry.pointsSize, 'got ${points.length}/${geoEntry.pointsSize} for geoEntry=$geoEntry'); - } - return points.map((geoEntry) => geoEntry.entry!).toSet(); - } - - final clusterAverageLocation = LatLng(geoEntry.latitude!, geoEntry.longitude!); - onTap(clusterAverageLocation, markerEntry, getClusterEntries); - } - return Selector( selector: (context, s) => s.mapStyle, builder: (context, mapStyle, child) { @@ -192,6 +159,7 @@ class _GeoMapState extends State { onUserZoomChange: widget.onUserZoomChange, onMapTap: widget.onMapTap, onMarkerTap: _onMarkerTap, + onMarkerLongPress: _onMarkerLongPress, ); break; case EntryMapStyle.osmHot: @@ -222,6 +190,7 @@ class _GeoMapState extends State { onUserZoomChange: widget.onUserZoomChange, onMapTap: widget.onMapTap, onMarkerTap: _onMarkerTap, + onMarkerLongPress: _onMarkerLongPress, ); break; } @@ -378,7 +347,7 @@ class _GeoMapState extends State { final availableSize = window.physicalSize / window.devicePixelRatio; final neededSize = bounds.toDisplaySize(); - if (neededSize.longestSide > availableSize.shortestSide) { + if (neededSize.width > availableSize.width || neededSize.height > availableSize.height) { return _initBoundsForEntries(entries: entries, recentCount: (recentCount ?? 10000) ~/ 10); } return bounds; @@ -433,6 +402,67 @@ class _GeoMapState extends State { })); } + Set _getClusterEntries(GeoEntry geoEntry) { + final clusterId = geoEntry.clusterId; + if (clusterId == null) { + return {geoEntry.entry!}; + } + + var points = _defaultMarkerCluster?.points(clusterId) ?? []; + if (points.length != geoEntry.pointsSize) { + // `Fluster.points()` method does not always return all the points contained in a cluster + // the higher `nodeSize` is, the higher the chance to get all the points (i.e. as many as the cluster `pointsSize`) + _slowMarkerCluster ??= _buildFluster(nodeSize: smallestPowerOf2(entries.length)); + points = _slowMarkerCluster!.points(clusterId); + assert(points.length == geoEntry.pointsSize, 'got ${points.length}/${geoEntry.pointsSize} for geoEntry=$geoEntry'); + } + return points.map((geoEntry) => geoEntry.entry!).toSet(); + } + + void _onMarkerTap(GeoEntry geoEntry) { + final onTap = widget.onMarkerTap; + if (onTap == null) return; + + final clusterId = geoEntry.clusterId; + AvesEntry? markerEntry; + if (clusterId != null) { + final uri = geoEntry.childMarkerId; + markerEntry = entries.firstWhereOrNull((v) => v.uri == uri); + } else { + markerEntry = geoEntry.entry; + } + if (markerEntry == null) return; + + final clusterLocation = LatLng(geoEntry.latitude!, geoEntry.longitude!); + onTap(clusterLocation, markerEntry); + } + + Future _onMarkerLongPress(GeoEntry geoEntry, LatLng tapLocation) async { + final onMarkerLongPress = widget.onMarkerLongPress; + if (onMarkerLongPress == null) return; + + final clusterEntries = _getClusterEntries(geoEntry); + final tapLocalPosition = _boundsNotifier.value.offset(tapLocation); + + AvesEntry markerEntry; + if (geoEntry.isCluster!) { + final uri = geoEntry.childMarkerId; + markerEntry = entries.firstWhere((v) => v.uri == uri); + } else { + markerEntry = geoEntry.entry!; + } + final clusterLocation = LatLng(geoEntry.latitude!, geoEntry.longitude!); + Widget markerBuilder(BuildContext context) => ImageMarker( + count: geoEntry.pointsSize, + drawArrow: false, + buildThumbnailImage: (extent) => ThumbnailImage( + entry: markerEntry, + extent: extent, + ), + ); + onMarkerLongPress(tapLocalPosition, clusterEntries, clusterLocation, markerBuilder); + } + Widget _decorateMap(BuildContext context, Widget? child) => MapDecorator(child: child); Widget _buildButtonPanel( diff --git a/lib/widgets/common/map/leaflet/map.dart b/lib/widgets/common/map/leaflet/map.dart index 1f744e249..8e2b15d07 100644 --- a/lib/widgets/common/map/leaflet/map.dart +++ b/lib/widgets/common/map/leaflet/map.dart @@ -29,6 +29,7 @@ class EntryLeafletMap extends StatefulWidget { final UserZoomChangeCallback? onUserZoomChange; final MapTapCallback? onMapTap; final MarkerTapCallback? onMarkerTap; + final MarkerLongPressCallback? onMarkerLongPress; const EntryLeafletMap({ super.key, @@ -50,6 +51,7 @@ class EntryLeafletMap extends StatefulWidget { this.onUserZoomChange, this.onMapTap, this.onMarkerTap, + this.onMarkerLongPress, }); @override @@ -134,6 +136,7 @@ class _EntryLeafletMapState extends State> with TickerProv // marker tap handling prevents the default handling of focal zoom on double tap, // so we reimplement the double tap gesture here onDoubleTap: interactive ? () => _zoomBy(1, focalPoint: latLng) : null, + onLongPress: () => widget.onMarkerLongPress?.call(geoEntry, LatLng(geoEntry.latitude!, geoEntry.longitude!)), child: widget.markerWidgetBuilder(markerKey), ), width: markerSize.width, diff --git a/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart index fd169f457..7216f4823 100644 --- a/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart @@ -174,27 +174,26 @@ class _EditEntryLocationDialogState extends State { } Future _pickLocation() async { + final baseCollection = widget.collection; + final mapCollection = baseCollection != null + ? CollectionLens( + source: baseCollection.source, + filters: baseCollection.filters, + fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).toList(), + ) + : null; final latLng = await Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: LocationPickDialog.routeName), - builder: (context) { - final baseCollection = widget.collection; - final mapCollection = baseCollection != null - ? CollectionLens( - source: baseCollection.source, - filters: baseCollection.filters, - fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).toList(), - ) - : null; - return LocationPickDialog( - collection: mapCollection, - initialLocation: _mapCoordinates, - ); - }, + builder: (context) => LocationPickDialog( + collection: mapCollection, + initialLocation: _mapCoordinates, + ), fullscreenDialog: true, ), ); + mapCollection?.dispose(); if (latLng != null) { settings.mapDefaultCenter = latLng; setState(() { diff --git a/lib/widgets/dialogs/location_pick_dialog.dart b/lib/widgets/dialogs/location_pick_dialog.dart index f81e38528..a39882683 100644 --- a/lib/widgets/dialogs/location_pick_dialog.dart +++ b/lib/widgets/dialogs/location_pick_dialog.dart @@ -142,9 +142,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin isAnimatingNotifier: _isPageAnimatingNotifier, dotLocationNotifier: _dotLocationNotifier, onMapTap: _setLocation, - onMarkerTap: (averageLocation, markerEntry, getClusterEntries) { - _setLocation(averageLocation); - }, + onMarkerTap: (location, entry) => _setLocation(location), ), ); } diff --git a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart index b697b8fa6..c9795de39 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -238,19 +238,19 @@ abstract class ChipSetActionDelegate with FeedbackMi } } - void _goToMap(BuildContext context, Set filters) { - Navigator.push( + Future _goToMap(BuildContext context, Set filters) async { + final mapCollection = CollectionLens( + source: context.read(), + fixedSelection: _selectedEntries(context, filters).where((entry) => entry.hasGps).toList(), + ); + await Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: MapPage.routeName), - builder: (context) => MapPage( - collection: CollectionLens( - source: context.read(), - fixedSelection: _selectedEntries(context, filters).where((entry) => entry.hasGps).toList(), - ), - ), + builder: (context) => MapPage(collection: mapCollection), ), ); + mapCollection.dispose(); } void _goToSlideshow(BuildContext context, Set filters) { diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index ea83119db..a7b2d0761 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:aves/app_mode.dart'; +import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/coordinate.dart'; import 'package:aves/model/filters/filters.dart'; @@ -13,6 +14,8 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/debouncer.dart'; import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; +import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/empty.dart'; @@ -160,6 +163,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin _overlayVisible.removeListener(_onOverlayVisibleChange); _mapController.dispose(); _selectedIndexNotifier.removeListener(_onThumbnailIndexChange); + _regionCollectionNotifier.value?.dispose(); super.dispose(); } @@ -243,14 +247,15 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin overlayOpacityNotifier: _overlayOpacityNotifier, overlayEntry: widget.overlayEntry, onMapTap: (_) => _toggleOverlay(), - onMarkerTap: (averageLocation, markerEntry, getClusterEntries) async { - final index = regionCollection?.sortedEntries.indexOf(markerEntry); + onMarkerTap: (location, entry) async { + final index = regionCollection?.sortedEntries.indexOf(entry); if (index != null && _selectedIndexNotifier.value != index) { _selectedIndexNotifier.value = index; } await Future.delayed(const Duration(milliseconds: 500)); - context.read().set(markerEntry); + context.read().set(entry); }, + onMarkerLongPress: _onMarkerLongPress, ), ); } @@ -346,12 +351,15 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin selectedEntry = selectedIndex != null && 0 <= selectedIndex && selectedIndex < regionEntries.length ? regionEntries[selectedIndex] : null; } - _regionCollectionNotifier.value = openingCollection.copyWith( + final oldRegionCollection = _regionCollectionNotifier.value; + final newRegionCollection = openingCollection.copyWith( filters: { ...openingCollection.filters.whereNot((v) => v is CoordinateFilter), CoordinateFilter(bounds.sw, bounds.ne), }, ); + _regionCollectionNotifier.value = newRegionCollection; + oldRegionCollection?.dispose(); // get entries from the new collection, so the entry order is the same // as the one used by the thumbnail scroller (considering sort/section/group) @@ -449,4 +457,60 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin } } } + + // cluster context menu + + Future _onMarkerLongPress( + Offset tapLocalPosition, + Set clusterEntries, + LatLng clusterLocation, + WidgetBuilder markerBuilder, + ) async { + final overlay = Overlay.of(context)!.context.findRenderObject() as RenderBox; + const touchArea = Size(kMinInteractiveDimension, kMinInteractiveDimension); + final selectedAction = await showMenu( + context: context, + position: RelativeRect.fromRect(tapLocalPosition & touchArea, Offset.zero & overlay.size), + items: [ + PopupMenuItem( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + markerBuilder(context), + const SizedBox(width: 16), + Text(context.l10n.itemCount(clusterEntries.length)), + ], + ), + ), + const PopupMenuDivider(), + _buildMenuItem(EntrySetAction.editLocation), + ], + ); + if (selectedAction != null) { + // wait for the popup menu to hide before proceeding with the action + await Future.delayed(Durations.popupMenuAnimation * timeDilation); + switch (selectedAction) { + case EntrySetAction.editLocation: + final location = await EntrySetActionDelegate().quickLocationByMap(context, clusterEntries, clusterLocation, openingCollection); + if (location != null) { + _mapController.moveTo(location); + } + break; + default: + break; + } + } + } + + PopupMenuItem _buildMenuItem(EntrySetAction action) { + return PopupMenuItem( + value: action, + child: MenuIconTheme( + child: MenuRow( + text: action.getText(context), + icon: action.getIcon(), + ), + ), + ); + } } diff --git a/lib/widgets/viewer/action/entry_info_action_delegate.dart b/lib/widgets/viewer/action/entry_info_action_delegate.dart index 0d458be95..62dbc7f47 100644 --- a/lib/widgets/viewer/action/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_info_action_delegate.dart @@ -255,20 +255,20 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi final baseCollection = collection; if (baseCollection == null) return; + final mapCollection = baseCollection.copyWith( + listenToSource: true, + fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).where((entry) => entry != targetEntry).toList(), + ); await Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: MapPage.routeName), - builder: (context) { - return MapPage( - collection: baseCollection.copyWith( - listenToSource: true, - fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).where((entry) => entry != targetEntry).toList(), - ), - overlayEntry: mappedGeoTiff, - ); - }, + builder: (context) => MapPage( + collection: mapCollection, + overlayEntry: mappedGeoTiff, + ), ), ); + mapCollection.dispose(); } } diff --git a/lib/widgets/viewer/info/location_section.dart b/lib/widgets/viewer/info/location_section.dart index ed18d17ba..cb86baa0e 100644 --- a/lib/widgets/viewer/info/location_section.dart +++ b/lib/widgets/viewer/info/location_section.dart @@ -87,7 +87,7 @@ class _LocationSectionState extends State { entries: [entry], isAnimatingNotifier: widget.isScrollingNotifier, onUserZoomChange: (zoom) => settings.infoMapZoom = zoom.roundToDouble(), - onMarkerTap: collection != null ? (_, __, ___) => _openMapPage(context) : null, + onMarkerTap: collection != null ? (location, entry) => _openMapPage(context) : null, openMapPage: collection != null ? _openMapPage : null, ), ), @@ -129,23 +129,25 @@ class _LocationSectionState extends State { ); } - void _openMapPage(BuildContext context) { + Future _openMapPage(BuildContext context) async { final baseCollection = collection; if (baseCollection == null) return; - Navigator.push( + final mapCollection = baseCollection.copyWith( + listenToSource: true, + fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).toList(), + ); + await Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: MapPage.routeName), builder: (context) => MapPage( - collection: baseCollection.copyWith( - listenToSource: true, - fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).toList(), - ), + collection: mapCollection, initialEntry: entry, ), ), ); + mapCollection.dispose(); } void _onMetadataChange() { diff --git a/plugins/aves_map/lib/src/interface.dart b/plugins/aves_map/lib/src/interface.dart index f2c31eb02..6bc04ef61 100644 --- a/plugins/aves_map/lib/src/interface.dart +++ b/plugins/aves_map/lib/src/interface.dart @@ -10,3 +10,4 @@ typedef MarkerImageReadyChecker = bool Function(MarkerKey key); typedef UserZoomChangeCallback = void Function(double zoom); typedef MapTapCallback = void Function(LatLng location); typedef MarkerTapCallback = void Function(GeoEntry geoEntry); +typedef MarkerLongPressCallback = void Function(GeoEntry geoEntry, LatLng tapLocation); diff --git a/plugins/aves_map/lib/src/marker/arrow_painter.dart b/plugins/aves_map/lib/src/marker/arrow_painter.dart new file mode 100644 index 000000000..c38367f60 --- /dev/null +++ b/plugins/aves_map/lib/src/marker/arrow_painter.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +class MarkerArrowPainter extends CustomPainter { + final Color color, outlineColor; + final double outlineWidth; + final Size size; + + const MarkerArrowPainter({ + required this.color, + required this.outlineColor, + required this.outlineWidth, + required this.size, + }); + + @override + void paint(Canvas canvas, Size size) { + final triangleWidth = this.size.width; + final triangleHeight = this.size.height; + + final bottomCenter = Offset(size.width / 2, size.height); + final topLeft = bottomCenter + Offset(-triangleWidth / 2, -triangleHeight); + final topRight = bottomCenter + Offset(triangleWidth / 2, -triangleHeight); + + canvas.drawPath( + Path() + ..moveTo(bottomCenter.dx, bottomCenter.dy) + ..lineTo(topRight.dx, topRight.dy) + ..lineTo(topLeft.dx, topLeft.dy) + ..close(), + Paint()..color = outlineColor); + + canvas.translate(0, -outlineWidth.ceilToDouble()); + canvas.drawPath( + Path() + ..moveTo(bottomCenter.dx, bottomCenter.dy) + ..lineTo(topRight.dx, topRight.dy) + ..lineTo(topLeft.dx, topLeft.dy) + ..close(), + Paint()..color = color); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/plugins/aves_map/lib/src/marker/image.dart b/plugins/aves_map/lib/src/marker/image.dart index 08ccc4b5a..3a2c33f52 100644 --- a/plugins/aves_map/lib/src/marker/image.dart +++ b/plugins/aves_map/lib/src/marker/image.dart @@ -1,9 +1,14 @@ -import 'package:aves_map/src/theme.dart'; +import 'package:aves_map/aves_map.dart'; +import 'package:aves_map/src/marker/arrow_painter.dart'; +import 'package:collection/collection.dart'; import 'package:custom_rounded_rectangle_border/custom_rounded_rectangle_border.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; class ImageMarker extends StatelessWidget { final int? count; + final bool drawArrow; final Widget Function(double extent) buildThumbnailImage; static const double outerBorderRadiusDim = 8; @@ -18,6 +23,7 @@ class ImageMarker extends StatelessWidget { const ImageMarker({ super.key, required this.count, + this.drawArrow = true, required this.buildThumbnailImage, }); @@ -112,63 +118,51 @@ class ImageMarker extends StatelessWidget { ); } - return CustomPaint( - foregroundPainter: _MarkerArrowPainter( - color: innerBorderColor, - outlineColor: outerBorderColor, - outlineWidth: outerBorderWidth, - size: arrowSize, - ), - child: Padding( - padding: EdgeInsets.only(bottom: arrowSize.height), - child: Container( - decoration: outerDecoration, + child = Container( + decoration: outerDecoration, + child: child, + ); + + if (drawArrow) { + child = CustomPaint( + foregroundPainter: MarkerArrowPainter( + color: innerBorderColor, + outlineColor: outerBorderColor, + outlineWidth: outerBorderWidth, + size: arrowSize, + ), + child: Padding( + padding: EdgeInsets.only(bottom: arrowSize.height), child: child, ), - ), - ); - } -} + ); + } -class _MarkerArrowPainter extends CustomPainter { - final Color color, outlineColor; - final double outlineWidth; - final Size size; - - const _MarkerArrowPainter({ - required this.color, - required this.outlineColor, - required this.outlineWidth, - required this.size, - }); - - @override - void paint(Canvas canvas, Size size) { - final triangleWidth = this.size.width; - final triangleHeight = this.size.height; - - final bottomCenter = Offset(size.width / 2, size.height); - final topLeft = bottomCenter + Offset(-triangleWidth / 2, -triangleHeight); - final topRight = bottomCenter + Offset(triangleWidth / 2, -triangleHeight); - - canvas.drawPath( - Path() - ..moveTo(bottomCenter.dx, bottomCenter.dy) - ..lineTo(topRight.dx, topRight.dy) - ..lineTo(topLeft.dx, topLeft.dy) - ..close(), - Paint()..color = outlineColor); - - canvas.translate(0, -outlineWidth.ceilToDouble()); - canvas.drawPath( - Path() - ..moveTo(bottomCenter.dx, bottomCenter.dy) - ..lineTo(topRight.dx, topRight.dy) - ..lineTo(topLeft.dx, topLeft.dy) - ..close(), - Paint()..color = color); + return child; } - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; + static const _crs = Epsg3857(); + + static GeoEntry? markerMatch(LatLng position, double zoom, Set> markers) { + final pressPoint = _crs.latLngToPoint(position, zoom); + final pressOffset = Offset(pressPoint.x.toDouble(), pressPoint.y.toDouble()); + + const double markerWidth = extent; + const double markerHeight = extent; + + return markers.firstWhereOrNull((marker) { + final latitude = marker.latitude; + final longitude = marker.longitude; + if (latitude == null || longitude == null) return false; + + final markerAnchorPoint = _crs.latLngToPoint(LatLng(latitude, longitude), zoom); + final bottom = markerAnchorPoint.y.toDouble(); + final top = bottom - markerHeight; + final left = markerAnchorPoint.x.toDouble() - markerWidth / 2; + final right = left + markerWidth; + final markerRect = Rect.fromLTRB(left, top, right, bottom); + + return markerRect.contains(pressOffset); + }); + } } diff --git a/plugins/aves_map/lib/src/zoomed_bounds.dart b/plugins/aves_map/lib/src/zoomed_bounds.dart index 5741186b5..d0ccc1d73 100644 --- a/plugins/aves_map/lib/src/zoomed_bounds.dart +++ b/plugins/aves_map/lib/src/zoomed_bounds.dart @@ -89,11 +89,18 @@ class ZoomedBounds extends Equatable { ); } - bool contains(LatLng point) => GeoUtils.contains(sw, ne, point); + bool contains(LatLng location) => GeoUtils.contains(sw, ne, location); Size toDisplaySize() { final swPoint = _crs.latLngToPoint(sw, zoom); final nePoint = _crs.latLngToPoint(ne, zoom); return Size((swPoint.x - nePoint.x).abs().toDouble(), (swPoint.y - nePoint.y).abs().toDouble()); } + + Offset offset(LatLng location) { + final swPoint = _crs.latLngToPoint(sw, zoom); + final nePoint = _crs.latLngToPoint(ne, zoom); + final point = _crs.latLngToPoint(location, zoom); + return Offset((point.x - swPoint.x).toDouble(), (point.y - nePoint.y).toDouble()); + } } diff --git a/plugins/aves_services/lib/aves_services.dart b/plugins/aves_services/lib/aves_services.dart index 200c6ead2..2778ffb89 100644 --- a/plugins/aves_services/lib/aves_services.dart +++ b/plugins/aves_services/lib/aves_services.dart @@ -29,5 +29,6 @@ abstract class MobileServices { required UserZoomChangeCallback? onUserZoomChange, required MapTapCallback? onMapTap, required MarkerTapCallback? onMarkerTap, + required MarkerLongPressCallback? onMarkerLongPress, }); } diff --git a/plugins/aves_services_google/lib/aves_services_platform.dart b/plugins/aves_services_google/lib/aves_services_platform.dart index 92b643e11..9b79f6101 100644 --- a/plugins/aves_services_google/lib/aves_services_platform.dart +++ b/plugins/aves_services_google/lib/aves_services_platform.dart @@ -74,6 +74,7 @@ class PlatformMobileServices extends MobileServices { required UserZoomChangeCallback? onUserZoomChange, required MapTapCallback? onMapTap, required MarkerTapCallback? onMarkerTap, + required MarkerLongPressCallback? onMarkerLongPress, }) { return EntryGoogleMap( controller: controller, @@ -93,6 +94,7 @@ class PlatformMobileServices extends MobileServices { onUserZoomChange: onUserZoomChange, onMapTap: onMapTap, onMarkerTap: onMarkerTap, + onMarkerLongPress: onMarkerLongPress, ); } } diff --git a/plugins/aves_services_google/lib/src/map.dart b/plugins/aves_services_google/lib/src/map.dart index c371fe973..4007294be 100644 --- a/plugins/aves_services_google/lib/src/map.dart +++ b/plugins/aves_services_google/lib/src/map.dart @@ -24,6 +24,7 @@ class EntryGoogleMap extends StatefulWidget { final UserZoomChangeCallback? onUserZoomChange; final MapTapCallback? onMapTap; final MarkerTapCallback? onMarkerTap; + final MarkerLongPressCallback? onMarkerLongPress; const EntryGoogleMap({ super.key, @@ -44,6 +45,7 @@ class EntryGoogleMap extends StatefulWidget { this.onUserZoomChange, this.onMapTap, this.onMarkerTap, + this.onMarkerLongPress, }); @override @@ -143,6 +145,7 @@ class _EntryGoogleMapState extends State> with WidgetsBindi } Widget _buildMap() { + final _onMarkerLongPress = widget.onMarkerLongPress; return StreamBuilder( stream: _markerBitmapReadyStreamController.stream, builder: (context, _) { @@ -226,7 +229,17 @@ class _EntryGoogleMapState extends State> with WidgetsBindi }, onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing), onCameraIdle: _onIdle, - onTap: (position) => widget.onMapTap?.call(_fromServiceLatLng(position)), + onTap: (v) => widget.onMapTap?.call(_fromServiceLatLng(v)), + onLongPress: _onMarkerLongPress != null + ? (v) { + final pressLocation = _fromServiceLatLng(v); + final markers = _geoEntryByMarkerKey.values.toSet(); + final geoEntry = ImageMarker.markerMatch(pressLocation, bounds.zoom, markers); + if (geoEntry != null) { + _onMarkerLongPress(geoEntry, pressLocation); + } + } + : null, ); }, ); diff --git a/plugins/aves_services_huawei/lib/aves_services_platform.dart b/plugins/aves_services_huawei/lib/aves_services_platform.dart index 6648b9ae7..467cb51a3 100644 --- a/plugins/aves_services_huawei/lib/aves_services_platform.dart +++ b/plugins/aves_services_huawei/lib/aves_services_platform.dart @@ -63,6 +63,7 @@ class PlatformMobileServices extends MobileServices { required UserZoomChangeCallback? onUserZoomChange, required MapTapCallback? onMapTap, required MarkerTapCallback? onMarkerTap, + required MarkerLongPressCallback? onMarkerLongPress, }) { return EntryHmsMap( controller: controller, @@ -82,6 +83,7 @@ class PlatformMobileServices extends MobileServices { onUserZoomChange: onUserZoomChange, onMapTap: onMapTap, onMarkerTap: onMarkerTap, + onMarkerLongPress: onMarkerLongPress, ); } } diff --git a/plugins/aves_services_huawei/lib/src/map.dart b/plugins/aves_services_huawei/lib/src/map.dart index b7226c42f..1616101fb 100644 --- a/plugins/aves_services_huawei/lib/src/map.dart +++ b/plugins/aves_services_huawei/lib/src/map.dart @@ -24,6 +24,7 @@ class EntryHmsMap extends StatefulWidget { final UserZoomChangeCallback? onUserZoomChange; final MapTapCallback? onMapTap; final MarkerTapCallback? onMarkerTap; + final MarkerLongPressCallback? onMarkerLongPress; const EntryHmsMap({ super.key, @@ -44,6 +45,7 @@ class EntryHmsMap extends StatefulWidget { this.onUserZoomChange, this.onMapTap, this.onMarkerTap, + this.onMarkerLongPress, }); @override @@ -122,6 +124,7 @@ class _EntryHmsMapState extends State> { } Widget _buildMap() { + final _onMarkerLongPress = widget.onMarkerLongPress; return StreamBuilder( stream: _markerBitmapReadyStreamController.stream, builder: (context, _) { @@ -228,7 +231,17 @@ class _EntryHmsMapState extends State> { }, onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: position.bearing), onCameraIdle: _onIdle, - onClick: (position) => widget.onMapTap?.call(_fromServiceLatLng(position)), + onClick: (v) => widget.onMapTap?.call(_fromServiceLatLng(v)), + onLongPress: _onMarkerLongPress != null + ? (v) { + final pressLocation = _fromServiceLatLng(v); + final markers = _geoEntryByMarkerKey.values.toSet(); + final geoEntry = ImageMarker.markerMatch(pressLocation, bounds.zoom, markers); + if (geoEntry != null) { + _onMarkerLongPress(geoEntry, pressLocation); + } + } + : null, onPoiClick: (poi) { final poiPosition = poi.latLng; if (poiPosition != null) { diff --git a/plugins/aves_services_none/lib/aves_services_platform.dart b/plugins/aves_services_none/lib/aves_services_platform.dart index 4d326fc1c..59164c5f3 100644 --- a/plugins/aves_services_none/lib/aves_services_platform.dart +++ b/plugins/aves_services_none/lib/aves_services_platform.dart @@ -35,6 +35,7 @@ class PlatformMobileServices extends MobileServices { required UserZoomChangeCallback? onUserZoomChange, required MapTapCallback? onMapTap, required MarkerTapCallback? onMarkerTap, + required MarkerLongPressCallback? onMarkerLongPress, }) { return const SizedBox(); }