#423 map: cluster context menu, edit cluster location
This commit is contained in:
parent
01ceb25129
commit
9bd01b16f4
22 changed files with 371 additions and 158 deletions
|
@ -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)
|
||||
|
||||
|
|
|
@ -231,7 +231,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
|
||||
// fetch new entries
|
||||
final tempUris = <String>{};
|
||||
final newEntries = <AvesEntry>{};
|
||||
final newEntries = <AvesEntry>{}, entriesToRefresh = <AvesEntry>{};
|
||||
final existingDirectories = <String>{};
|
||||
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;
|
||||
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 {
|
|||
}
|
||||
}
|
||||
|
||||
if (newEntries.isNotEmpty) {
|
||||
invalidateAlbumFilterSummary(directories: existingDirectories);
|
||||
|
||||
if (newEntries.isNotEmpty) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Set<AvesEntry>?> _getEditableTargetItems(
|
||||
BuildContext context, {
|
||||
required bool Function(AvesEntry entry) canEdit,
|
||||
}) =>
|
||||
_getEditableItems(context, _getTargetItems(context), canEdit: canEdit);
|
||||
|
||||
Future<Set<AvesEntry>?> _getEditableItems(
|
||||
BuildContext context,
|
||||
Set<AvesEntry> entries, {
|
||||
required bool Function(AvesEntry entry) canEdit,
|
||||
}) async {
|
||||
final bySupported = groupBy<AvesEntry, bool>(_getTargetItems(context), canEdit);
|
||||
final bySupported = groupBy<AvesEntry, bool>(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<LatLng?> quickLocationByMap(BuildContext context, Set<AvesEntry> 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<void> _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<void> _goToMap(BuildContext context) async {
|
||||
final collection = context.read<CollectionLens>();
|
||||
final entries = _getTargetItems(context);
|
||||
|
||||
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(
|
||||
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(collection: mapCollection),
|
||||
),
|
||||
);
|
||||
mapCollection.dispose();
|
||||
}
|
||||
|
||||
void _goToSlideshow(BuildContext context) {
|
||||
|
|
|
@ -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<ChipAction>(
|
||||
context: context,
|
||||
position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size),
|
||||
|
|
|
@ -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<AvesEntry> Function() getClusterEntries)? onMarkerTap;
|
||||
final void Function(LatLng clusterLocation, AvesEntry markerEntry)? onMarkerTap;
|
||||
final void Function(Offset tapLocalPosition, Set<AvesEntry> 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<GeoMap> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
void _onMarkerTap(GeoEntry<AvesEntry> 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<AvesEntry> 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<Settings, EntryMapStyle?>(
|
||||
selector: (context, s) => s.mapStyle,
|
||||
builder: (context, mapStyle, child) {
|
||||
|
@ -192,6 +159,7 @@ class _GeoMapState extends State<GeoMap> {
|
|||
onUserZoomChange: widget.onUserZoomChange,
|
||||
onMapTap: widget.onMapTap,
|
||||
onMarkerTap: _onMarkerTap,
|
||||
onMarkerLongPress: _onMarkerLongPress,
|
||||
);
|
||||
break;
|
||||
case EntryMapStyle.osmHot:
|
||||
|
@ -222,6 +190,7 @@ class _GeoMapState extends State<GeoMap> {
|
|||
onUserZoomChange: widget.onUserZoomChange,
|
||||
onMapTap: widget.onMapTap,
|
||||
onMarkerTap: _onMarkerTap,
|
||||
onMarkerLongPress: _onMarkerLongPress,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
@ -378,7 +347,7 @@ class _GeoMapState extends State<GeoMap> {
|
|||
|
||||
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<GeoMap> {
|
|||
}));
|
||||
}
|
||||
|
||||
Set<AvesEntry> _getClusterEntries(GeoEntry<AvesEntry> 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<AvesEntry> 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<void> _onMarkerLongPress(GeoEntry<AvesEntry> 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(
|
||||
|
|
|
@ -29,6 +29,7 @@ class EntryLeafletMap<T> extends StatefulWidget {
|
|||
final UserZoomChangeCallback? onUserZoomChange;
|
||||
final MapTapCallback? onMapTap;
|
||||
final MarkerTapCallback<T>? onMarkerTap;
|
||||
final MarkerLongPressCallback<T>? onMarkerLongPress;
|
||||
|
||||
const EntryLeafletMap({
|
||||
super.key,
|
||||
|
@ -50,6 +51,7 @@ class EntryLeafletMap<T> extends StatefulWidget {
|
|||
this.onUserZoomChange,
|
||||
this.onMapTap,
|
||||
this.onMarkerTap,
|
||||
this.onMarkerLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -134,6 +136,7 @@ class _EntryLeafletMapState<T> extends State<EntryLeafletMap<T>> 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,
|
||||
|
|
|
@ -174,11 +174,6 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
|||
}
|
||||
|
||||
Future<void> _pickLocation() async {
|
||||
final latLng = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: LocationPickDialog.routeName),
|
||||
builder: (context) {
|
||||
final baseCollection = widget.collection;
|
||||
final mapCollection = baseCollection != null
|
||||
? CollectionLens(
|
||||
|
@ -187,14 +182,18 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
|||
fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).toList(),
|
||||
)
|
||||
: null;
|
||||
return LocationPickDialog(
|
||||
final latLng = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: LocationPickDialog.routeName),
|
||||
builder: (context) => LocationPickDialog(
|
||||
collection: mapCollection,
|
||||
initialLocation: _mapCoordinates,
|
||||
);
|
||||
},
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
mapCollection?.dispose();
|
||||
if (latLng != null) {
|
||||
settings.mapDefaultCenter = latLng;
|
||||
setState(() {
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -238,19 +238,19 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
|
|||
}
|
||||
}
|
||||
|
||||
void _goToMap(BuildContext context, Set<T> filters) {
|
||||
Navigator.push(
|
||||
Future<void> _goToMap(BuildContext context, Set<T> filters) async {
|
||||
final mapCollection = CollectionLens(
|
||||
source: context.read<CollectionSource>(),
|
||||
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<CollectionSource>(),
|
||||
fixedSelection: _selectedEntries(context, filters).where((entry) => entry.hasGps).toList(),
|
||||
),
|
||||
),
|
||||
builder: (context) => MapPage(collection: mapCollection),
|
||||
),
|
||||
);
|
||||
mapCollection.dispose();
|
||||
}
|
||||
|
||||
void _goToSlideshow(BuildContext context, Set<T> filters) {
|
||||
|
|
|
@ -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<HighlightInfo>().set(markerEntry);
|
||||
context.read<HighlightInfo>().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<void> _onMarkerLongPress(
|
||||
Offset tapLocalPosition,
|
||||
Set<AvesEntry> clusterEntries,
|
||||
LatLng clusterLocation,
|
||||
WidgetBuilder markerBuilder,
|
||||
) async {
|
||||
final overlay = Overlay.of(context)!.context.findRenderObject() as RenderBox;
|
||||
const touchArea = Size(kMinInteractiveDimension, kMinInteractiveDimension);
|
||||
final selectedAction = await showMenu<EntrySetAction>(
|
||||
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<EntrySetAction> _buildMenuItem(EntrySetAction action) {
|
||||
return PopupMenuItem(
|
||||
value: action,
|
||||
child: MenuIconTheme(
|
||||
child: MenuRow(
|
||||
text: action.getText(context),
|
||||
icon: action.getIcon(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
),
|
||||
builder: (context) => MapPage(
|
||||
collection: mapCollection,
|
||||
overlayEntry: mappedGeoTiff,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
mapCollection.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -87,7 +87,7 @@ class _LocationSectionState extends State<LocationSection> {
|
|||
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<LocationSection> {
|
|||
);
|
||||
}
|
||||
|
||||
void _openMapPage(BuildContext context) {
|
||||
Future<void> _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() {
|
||||
|
|
|
@ -10,3 +10,4 @@ typedef MarkerImageReadyChecker<T> = bool Function(MarkerKey<T> key);
|
|||
typedef UserZoomChangeCallback = void Function(double zoom);
|
||||
typedef MapTapCallback = void Function(LatLng location);
|
||||
typedef MarkerTapCallback<T> = void Function(GeoEntry<T> geoEntry);
|
||||
typedef MarkerLongPressCallback<T> = void Function(GeoEntry<T> geoEntry, LatLng tapLocation);
|
||||
|
|
44
plugins/aves_map/lib/src/marker/arrow_painter.dart
Normal file
44
plugins/aves_map/lib/src/marker/arrow_painter.dart
Normal file
|
@ -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;
|
||||
}
|
|
@ -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,8 +118,14 @@ class ImageMarker extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
return CustomPaint(
|
||||
foregroundPainter: _MarkerArrowPainter(
|
||||
child = Container(
|
||||
decoration: outerDecoration,
|
||||
child: child,
|
||||
);
|
||||
|
||||
if (drawArrow) {
|
||||
child = CustomPaint(
|
||||
foregroundPainter: MarkerArrowPainter(
|
||||
color: innerBorderColor,
|
||||
outlineColor: outerBorderColor,
|
||||
outlineWidth: outerBorderWidth,
|
||||
|
@ -121,54 +133,36 @@ class ImageMarker extends StatelessWidget {
|
|||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: arrowSize.height),
|
||||
child: Container(
|
||||
decoration: outerDecoration,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
class _MarkerArrowPainter extends CustomPainter {
|
||||
final Color color, outlineColor;
|
||||
final double outlineWidth;
|
||||
final Size size;
|
||||
static const _crs = Epsg3857();
|
||||
|
||||
const _MarkerArrowPainter({
|
||||
required this.color,
|
||||
required this.outlineColor,
|
||||
required this.outlineWidth,
|
||||
required this.size,
|
||||
static GeoEntry<T>? markerMatch<T>(LatLng position, double zoom, Set<GeoEntry<T>> 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);
|
||||
});
|
||||
|
||||
@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;
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,5 +29,6 @@ abstract class MobileServices {
|
|||
required UserZoomChangeCallback? onUserZoomChange,
|
||||
required MapTapCallback? onMapTap,
|
||||
required MarkerTapCallback<T>? onMarkerTap,
|
||||
required MarkerLongPressCallback<T>? onMarkerLongPress,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -74,6 +74,7 @@ class PlatformMobileServices extends MobileServices {
|
|||
required UserZoomChangeCallback? onUserZoomChange,
|
||||
required MapTapCallback? onMapTap,
|
||||
required MarkerTapCallback<T>? onMarkerTap,
|
||||
required MarkerLongPressCallback<T>? onMarkerLongPress,
|
||||
}) {
|
||||
return EntryGoogleMap<T>(
|
||||
controller: controller,
|
||||
|
@ -93,6 +94,7 @@ class PlatformMobileServices extends MobileServices {
|
|||
onUserZoomChange: onUserZoomChange,
|
||||
onMapTap: onMapTap,
|
||||
onMarkerTap: onMarkerTap,
|
||||
onMarkerLongPress: onMarkerLongPress,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ class EntryGoogleMap<T> extends StatefulWidget {
|
|||
final UserZoomChangeCallback? onUserZoomChange;
|
||||
final MapTapCallback? onMapTap;
|
||||
final MarkerTapCallback<T>? onMarkerTap;
|
||||
final MarkerLongPressCallback<T>? onMarkerLongPress;
|
||||
|
||||
const EntryGoogleMap({
|
||||
super.key,
|
||||
|
@ -44,6 +45,7 @@ class EntryGoogleMap<T> extends StatefulWidget {
|
|||
this.onUserZoomChange,
|
||||
this.onMapTap,
|
||||
this.onMarkerTap,
|
||||
this.onMarkerLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -143,6 +145,7 @@ class _EntryGoogleMapState<T> extends State<EntryGoogleMap<T>> with WidgetsBindi
|
|||
}
|
||||
|
||||
Widget _buildMap() {
|
||||
final _onMarkerLongPress = widget.onMarkerLongPress;
|
||||
return StreamBuilder(
|
||||
stream: _markerBitmapReadyStreamController.stream,
|
||||
builder: (context, _) {
|
||||
|
@ -226,7 +229,17 @@ class _EntryGoogleMapState<T> extends State<EntryGoogleMap<T>> 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
@ -63,6 +63,7 @@ class PlatformMobileServices extends MobileServices {
|
|||
required UserZoomChangeCallback? onUserZoomChange,
|
||||
required MapTapCallback? onMapTap,
|
||||
required MarkerTapCallback<T>? onMarkerTap,
|
||||
required MarkerLongPressCallback<T>? onMarkerLongPress,
|
||||
}) {
|
||||
return EntryHmsMap<T>(
|
||||
controller: controller,
|
||||
|
@ -82,6 +83,7 @@ class PlatformMobileServices extends MobileServices {
|
|||
onUserZoomChange: onUserZoomChange,
|
||||
onMapTap: onMapTap,
|
||||
onMarkerTap: onMarkerTap,
|
||||
onMarkerLongPress: onMarkerLongPress,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ class EntryHmsMap<T> extends StatefulWidget {
|
|||
final UserZoomChangeCallback? onUserZoomChange;
|
||||
final MapTapCallback? onMapTap;
|
||||
final MarkerTapCallback<T>? onMarkerTap;
|
||||
final MarkerLongPressCallback<T>? onMarkerLongPress;
|
||||
|
||||
const EntryHmsMap({
|
||||
super.key,
|
||||
|
@ -44,6 +45,7 @@ class EntryHmsMap<T> extends StatefulWidget {
|
|||
this.onUserZoomChange,
|
||||
this.onMapTap,
|
||||
this.onMarkerTap,
|
||||
this.onMarkerLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -122,6 +124,7 @@ class _EntryHmsMapState<T> extends State<EntryHmsMap<T>> {
|
|||
}
|
||||
|
||||
Widget _buildMap() {
|
||||
final _onMarkerLongPress = widget.onMarkerLongPress;
|
||||
return StreamBuilder(
|
||||
stream: _markerBitmapReadyStreamController.stream,
|
||||
builder: (context, _) {
|
||||
|
@ -228,7 +231,17 @@ class _EntryHmsMapState<T> extends State<EntryHmsMap<T>> {
|
|||
},
|
||||
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) {
|
||||
|
|
|
@ -35,6 +35,7 @@ class PlatformMobileServices extends MobileServices {
|
|||
required UserZoomChangeCallback? onUserZoomChange,
|
||||
required MapTapCallback? onMapTap,
|
||||
required MarkerTapCallback<T>? onMarkerTap,
|
||||
required MarkerLongPressCallback<T>? onMarkerLongPress,
|
||||
}) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue