#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: optionally show rating & tags on overlay
|
||||||
- Viewer: long press on copy/move/rating/tag quick action for quicker action
|
- Viewer: long press on copy/move/rating/tag quick action for quicker action
|
||||||
- Search: missing address, portrait, landscape filters
|
- Search: missing address, portrait, landscape filters
|
||||||
|
- Map: edit cluster location
|
||||||
- Lithuanian translation (thanks Gediminas Murauskas)
|
- Lithuanian translation (thanks Gediminas Murauskas)
|
||||||
- Norsk (Bokmål) translation (thanks Allan Nordhøy)
|
- Norsk (Bokmål) translation (thanks Allan Nordhøy)
|
||||||
|
|
||||||
|
|
|
@ -231,7 +231,7 @@ class MediaStoreSource extends CollectionSource {
|
||||||
|
|
||||||
// fetch new entries
|
// fetch new entries
|
||||||
final tempUris = <String>{};
|
final tempUris = <String>{};
|
||||||
final newEntries = <AvesEntry>{};
|
final newEntries = <AvesEntry>{}, entriesToRefresh = <AvesEntry>{};
|
||||||
final existingDirectories = <String>{};
|
final existingDirectories = <String>{};
|
||||||
for (final kv in uriByContentId.entries) {
|
for (final kv in uriByContentId.entries) {
|
||||||
final contentId = kv.key;
|
final contentId = kv.key;
|
||||||
|
@ -244,8 +244,12 @@ class MediaStoreSource extends CollectionSource {
|
||||||
final newPath = sourceEntry.path;
|
final newPath = sourceEntry.path;
|
||||||
final volume = newPath != null ? androidFileUtils.getStorageVolume(newPath) : null;
|
final volume = newPath != null ? androidFileUtils.getStorageVolume(newPath) : null;
|
||||||
if (volume != null) {
|
if (volume != null) {
|
||||||
sourceEntry.id = existingEntry?.id ?? metadataDb.nextId;
|
if (existingEntry != null) {
|
||||||
newEntries.add(sourceEntry);
|
entriesToRefresh.add(existingEntry);
|
||||||
|
} else {
|
||||||
|
sourceEntry.id = metadataDb.nextId;
|
||||||
|
newEntries.add(sourceEntry);
|
||||||
|
}
|
||||||
final existingDirectory = existingEntry?.directory;
|
final existingDirectory = existingEntry?.directory;
|
||||||
if (existingDirectory != null) {
|
if (existingDirectory != null) {
|
||||||
existingDirectories.add(existingDirectory);
|
existingDirectories.add(existingDirectory);
|
||||||
|
@ -258,15 +262,19 @@ class MediaStoreSource extends CollectionSource {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateAlbumFilterSummary(directories: existingDirectories);
|
||||||
|
|
||||||
if (newEntries.isNotEmpty) {
|
if (newEntries.isNotEmpty) {
|
||||||
invalidateAlbumFilterSummary(directories: existingDirectories);
|
|
||||||
addEntries(newEntries);
|
addEntries(newEntries);
|
||||||
await metadataDb.saveEntries(newEntries);
|
await metadataDb.saveEntries(newEntries);
|
||||||
cleanEmptyAlbums(existingDirectories);
|
|
||||||
|
|
||||||
await analyze(analysisController, entries: newEntries);
|
await analyze(analysisController, entries: newEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entriesToRefresh.isNotEmpty) {
|
||||||
|
final allDataTypes = EntryDataType.values.toSet();
|
||||||
|
await Future.forEach(entriesToRefresh, (entry) => refreshEntry(entry, allDataTypes));
|
||||||
|
}
|
||||||
|
|
||||||
return tempUris;
|
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_confirmation_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_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/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/map/map_page.dart';
|
||||||
import 'package:aves/widgets/search/search_delegate.dart';
|
import 'package:aves/widgets/search/search_delegate.dart';
|
||||||
import 'package:aves/widgets/stats/stats_page.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:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:tuple/tuple.dart';
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
|
@ -427,8 +429,15 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
Future<Set<AvesEntry>?> _getEditableTargetItems(
|
Future<Set<AvesEntry>?> _getEditableTargetItems(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required bool Function(AvesEntry entry) canEdit,
|
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 {
|
}) async {
|
||||||
final bySupported = groupBy<AvesEntry, bool>(_getTargetItems(context), canEdit);
|
final bySupported = groupBy<AvesEntry, bool>(entries, canEdit);
|
||||||
final supported = (bySupported[true] ?? []).toSet();
|
final supported = (bySupported[true] ?? []).toSet();
|
||||||
final unsupported = (bySupported[false] ?? []).toSet();
|
final unsupported = (bySupported[false] ?? []).toSet();
|
||||||
|
|
||||||
|
@ -500,6 +509,27 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
await _edit(context, entries, (entry) => entry.editLocation(location));
|
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 {
|
Future<void> _editTitleDescription(BuildContext context) async {
|
||||||
final entries = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditTitleDescription);
|
final entries = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditTitleDescription);
|
||||||
if (entries == null || entries.isEmpty) return;
|
if (entries == null || entries.isEmpty) return;
|
||||||
|
@ -549,24 +579,24 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
await _edit(context, entries, (entry) => entry.removeMetadata(types));
|
await _edit(context, entries, (entry) => entry.removeMetadata(types));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _goToMap(BuildContext context) {
|
Future<void> _goToMap(BuildContext context) async {
|
||||||
final collection = context.read<CollectionLens>();
|
final collection = context.read<CollectionLens>();
|
||||||
final entries = _getTargetItems(context);
|
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,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
settings: const RouteSettings(name: MapPage.routeName),
|
settings: const RouteSettings(name: MapPage.routeName),
|
||||||
builder: (context) => MapPage(
|
builder: (context) => MapPage(collection: mapCollection),
|
||||||
// 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(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
mapCollection.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _goToSlideshow(BuildContext context) {
|
void _goToSlideshow(BuildContext context) {
|
||||||
|
|
|
@ -106,7 +106,7 @@ class AvesFilterChip extends StatefulWidget {
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
|
||||||
final overlay = Overlay.of(context)!.context.findRenderObject() as RenderBox;
|
final overlay = Overlay.of(context)!.context.findRenderObject() as RenderBox;
|
||||||
const touchArea = Size(40, 40);
|
const touchArea = Size(kMinInteractiveDimension, kMinInteractiveDimension);
|
||||||
final selectedAction = await showMenu<ChipAction>(
|
final selectedAction = await showMenu<ChipAction>(
|
||||||
context: context,
|
context: context,
|
||||||
position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size),
|
position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size),
|
||||||
|
|
|
@ -38,7 +38,8 @@ class GeoMap extends StatefulWidget {
|
||||||
final MapOverlay? overlayEntry;
|
final MapOverlay? overlayEntry;
|
||||||
final UserZoomChangeCallback? onUserZoomChange;
|
final UserZoomChangeCallback? onUserZoomChange;
|
||||||
final MapTapCallback? onMapTap;
|
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;
|
final void Function(BuildContext context)? openMapPage;
|
||||||
|
|
||||||
const GeoMap({
|
const GeoMap({
|
||||||
|
@ -54,6 +55,7 @@ class GeoMap extends StatefulWidget {
|
||||||
this.onUserZoomChange,
|
this.onUserZoomChange,
|
||||||
this.onMapTap,
|
this.onMapTap,
|
||||||
this.onMarkerTap,
|
this.onMarkerTap,
|
||||||
|
this.onMarkerLongPress,
|
||||||
this.openMapPage,
|
this.openMapPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -118,41 +120,6 @@ class _GeoMapState extends State<GeoMap> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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?>(
|
return Selector<Settings, EntryMapStyle?>(
|
||||||
selector: (context, s) => s.mapStyle,
|
selector: (context, s) => s.mapStyle,
|
||||||
builder: (context, mapStyle, child) {
|
builder: (context, mapStyle, child) {
|
||||||
|
@ -192,6 +159,7 @@ class _GeoMapState extends State<GeoMap> {
|
||||||
onUserZoomChange: widget.onUserZoomChange,
|
onUserZoomChange: widget.onUserZoomChange,
|
||||||
onMapTap: widget.onMapTap,
|
onMapTap: widget.onMapTap,
|
||||||
onMarkerTap: _onMarkerTap,
|
onMarkerTap: _onMarkerTap,
|
||||||
|
onMarkerLongPress: _onMarkerLongPress,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case EntryMapStyle.osmHot:
|
case EntryMapStyle.osmHot:
|
||||||
|
@ -222,6 +190,7 @@ class _GeoMapState extends State<GeoMap> {
|
||||||
onUserZoomChange: widget.onUserZoomChange,
|
onUserZoomChange: widget.onUserZoomChange,
|
||||||
onMapTap: widget.onMapTap,
|
onMapTap: widget.onMapTap,
|
||||||
onMarkerTap: _onMarkerTap,
|
onMarkerTap: _onMarkerTap,
|
||||||
|
onMarkerLongPress: _onMarkerLongPress,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -378,7 +347,7 @@ class _GeoMapState extends State<GeoMap> {
|
||||||
|
|
||||||
final availableSize = window.physicalSize / window.devicePixelRatio;
|
final availableSize = window.physicalSize / window.devicePixelRatio;
|
||||||
final neededSize = bounds.toDisplaySize();
|
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 _initBoundsForEntries(entries: entries, recentCount: (recentCount ?? 10000) ~/ 10);
|
||||||
}
|
}
|
||||||
return bounds;
|
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 _decorateMap(BuildContext context, Widget? child) => MapDecorator(child: child);
|
||||||
|
|
||||||
Widget _buildButtonPanel(
|
Widget _buildButtonPanel(
|
||||||
|
|
|
@ -29,6 +29,7 @@ class EntryLeafletMap<T> extends StatefulWidget {
|
||||||
final UserZoomChangeCallback? onUserZoomChange;
|
final UserZoomChangeCallback? onUserZoomChange;
|
||||||
final MapTapCallback? onMapTap;
|
final MapTapCallback? onMapTap;
|
||||||
final MarkerTapCallback<T>? onMarkerTap;
|
final MarkerTapCallback<T>? onMarkerTap;
|
||||||
|
final MarkerLongPressCallback<T>? onMarkerLongPress;
|
||||||
|
|
||||||
const EntryLeafletMap({
|
const EntryLeafletMap({
|
||||||
super.key,
|
super.key,
|
||||||
|
@ -50,6 +51,7 @@ class EntryLeafletMap<T> extends StatefulWidget {
|
||||||
this.onUserZoomChange,
|
this.onUserZoomChange,
|
||||||
this.onMapTap,
|
this.onMapTap,
|
||||||
this.onMarkerTap,
|
this.onMarkerTap,
|
||||||
|
this.onMarkerLongPress,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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,
|
// marker tap handling prevents the default handling of focal zoom on double tap,
|
||||||
// so we reimplement the double tap gesture here
|
// so we reimplement the double tap gesture here
|
||||||
onDoubleTap: interactive ? () => _zoomBy(1, focalPoint: latLng) : null,
|
onDoubleTap: interactive ? () => _zoomBy(1, focalPoint: latLng) : null,
|
||||||
|
onLongPress: () => widget.onMarkerLongPress?.call(geoEntry, LatLng(geoEntry.latitude!, geoEntry.longitude!)),
|
||||||
child: widget.markerWidgetBuilder(markerKey),
|
child: widget.markerWidgetBuilder(markerKey),
|
||||||
),
|
),
|
||||||
width: markerSize.width,
|
width: markerSize.width,
|
||||||
|
|
|
@ -174,27 +174,26 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickLocation() async {
|
Future<void> _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(
|
final latLng = await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
settings: const RouteSettings(name: LocationPickDialog.routeName),
|
settings: const RouteSettings(name: LocationPickDialog.routeName),
|
||||||
builder: (context) {
|
builder: (context) => LocationPickDialog(
|
||||||
final baseCollection = widget.collection;
|
collection: mapCollection,
|
||||||
final mapCollection = baseCollection != null
|
initialLocation: _mapCoordinates,
|
||||||
? CollectionLens(
|
),
|
||||||
source: baseCollection.source,
|
|
||||||
filters: baseCollection.filters,
|
|
||||||
fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).toList(),
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
return LocationPickDialog(
|
|
||||||
collection: mapCollection,
|
|
||||||
initialLocation: _mapCoordinates,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
fullscreenDialog: true,
|
fullscreenDialog: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
mapCollection?.dispose();
|
||||||
if (latLng != null) {
|
if (latLng != null) {
|
||||||
settings.mapDefaultCenter = latLng;
|
settings.mapDefaultCenter = latLng;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
|
@ -142,9 +142,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
|
||||||
isAnimatingNotifier: _isPageAnimatingNotifier,
|
isAnimatingNotifier: _isPageAnimatingNotifier,
|
||||||
dotLocationNotifier: _dotLocationNotifier,
|
dotLocationNotifier: _dotLocationNotifier,
|
||||||
onMapTap: _setLocation,
|
onMapTap: _setLocation,
|
||||||
onMarkerTap: (averageLocation, markerEntry, getClusterEntries) {
|
onMarkerTap: (location, entry) => _setLocation(location),
|
||||||
_setLocation(averageLocation);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -238,19 +238,19 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _goToMap(BuildContext context, Set<T> filters) {
|
Future<void> _goToMap(BuildContext context, Set<T> filters) async {
|
||||||
Navigator.push(
|
final mapCollection = CollectionLens(
|
||||||
|
source: context.read<CollectionSource>(),
|
||||||
|
fixedSelection: _selectedEntries(context, filters).where((entry) => entry.hasGps).toList(),
|
||||||
|
);
|
||||||
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
settings: const RouteSettings(name: MapPage.routeName),
|
settings: const RouteSettings(name: MapPage.routeName),
|
||||||
builder: (context) => MapPage(
|
builder: (context) => MapPage(collection: mapCollection),
|
||||||
collection: CollectionLens(
|
|
||||||
source: context.read<CollectionSource>(),
|
|
||||||
fixedSelection: _selectedEntries(context, filters).where((entry) => entry.hasGps).toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
mapCollection.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _goToSlideshow(BuildContext context, Set<T> filters) {
|
void _goToSlideshow(BuildContext context, Set<T> filters) {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/app_mode.dart';
|
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/entry.dart';
|
||||||
import 'package:aves/model/filters/coordinate.dart';
|
import 'package:aves/model/filters/coordinate.dart';
|
||||||
import 'package:aves/model/filters/filters.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/theme/icons.dart';
|
||||||
import 'package:aves/utils/debouncer.dart';
|
import 'package:aves/utils/debouncer.dart';
|
||||||
import 'package:aves/widgets/collection/collection_page.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/behaviour/routes.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/identity/empty.dart';
|
import 'package:aves/widgets/common/identity/empty.dart';
|
||||||
|
@ -160,6 +163,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
|
||||||
_overlayVisible.removeListener(_onOverlayVisibleChange);
|
_overlayVisible.removeListener(_onOverlayVisibleChange);
|
||||||
_mapController.dispose();
|
_mapController.dispose();
|
||||||
_selectedIndexNotifier.removeListener(_onThumbnailIndexChange);
|
_selectedIndexNotifier.removeListener(_onThumbnailIndexChange);
|
||||||
|
_regionCollectionNotifier.value?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -243,14 +247,15 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
|
||||||
overlayOpacityNotifier: _overlayOpacityNotifier,
|
overlayOpacityNotifier: _overlayOpacityNotifier,
|
||||||
overlayEntry: widget.overlayEntry,
|
overlayEntry: widget.overlayEntry,
|
||||||
onMapTap: (_) => _toggleOverlay(),
|
onMapTap: (_) => _toggleOverlay(),
|
||||||
onMarkerTap: (averageLocation, markerEntry, getClusterEntries) async {
|
onMarkerTap: (location, entry) async {
|
||||||
final index = regionCollection?.sortedEntries.indexOf(markerEntry);
|
final index = regionCollection?.sortedEntries.indexOf(entry);
|
||||||
if (index != null && _selectedIndexNotifier.value != index) {
|
if (index != null && _selectedIndexNotifier.value != index) {
|
||||||
_selectedIndexNotifier.value = index;
|
_selectedIndexNotifier.value = index;
|
||||||
}
|
}
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
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;
|
selectedEntry = selectedIndex != null && 0 <= selectedIndex && selectedIndex < regionEntries.length ? regionEntries[selectedIndex] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_regionCollectionNotifier.value = openingCollection.copyWith(
|
final oldRegionCollection = _regionCollectionNotifier.value;
|
||||||
|
final newRegionCollection = openingCollection.copyWith(
|
||||||
filters: {
|
filters: {
|
||||||
...openingCollection.filters.whereNot((v) => v is CoordinateFilter),
|
...openingCollection.filters.whereNot((v) => v is CoordinateFilter),
|
||||||
CoordinateFilter(bounds.sw, bounds.ne),
|
CoordinateFilter(bounds.sw, bounds.ne),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
_regionCollectionNotifier.value = newRegionCollection;
|
||||||
|
oldRegionCollection?.dispose();
|
||||||
|
|
||||||
// get entries from the new collection, so the entry order is the same
|
// 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)
|
// 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;
|
final baseCollection = collection;
|
||||||
if (baseCollection == null) return;
|
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(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
settings: const RouteSettings(name: MapPage.routeName),
|
settings: const RouteSettings(name: MapPage.routeName),
|
||||||
builder: (context) {
|
builder: (context) => MapPage(
|
||||||
return MapPage(
|
collection: mapCollection,
|
||||||
collection: baseCollection.copyWith(
|
overlayEntry: mappedGeoTiff,
|
||||||
listenToSource: true,
|
),
|
||||||
fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).where((entry) => entry != targetEntry).toList(),
|
|
||||||
),
|
|
||||||
overlayEntry: mappedGeoTiff,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
mapCollection.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -87,7 +87,7 @@ class _LocationSectionState extends State<LocationSection> {
|
||||||
entries: [entry],
|
entries: [entry],
|
||||||
isAnimatingNotifier: widget.isScrollingNotifier,
|
isAnimatingNotifier: widget.isScrollingNotifier,
|
||||||
onUserZoomChange: (zoom) => settings.infoMapZoom = zoom.roundToDouble(),
|
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,
|
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;
|
final baseCollection = collection;
|
||||||
if (baseCollection == null) return;
|
if (baseCollection == null) return;
|
||||||
|
|
||||||
Navigator.push(
|
final mapCollection = baseCollection.copyWith(
|
||||||
|
listenToSource: true,
|
||||||
|
fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).toList(),
|
||||||
|
);
|
||||||
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
settings: const RouteSettings(name: MapPage.routeName),
|
settings: const RouteSettings(name: MapPage.routeName),
|
||||||
builder: (context) => MapPage(
|
builder: (context) => MapPage(
|
||||||
collection: baseCollection.copyWith(
|
collection: mapCollection,
|
||||||
listenToSource: true,
|
|
||||||
fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).toList(),
|
|
||||||
),
|
|
||||||
initialEntry: entry,
|
initialEntry: entry,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
mapCollection.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onMetadataChange() {
|
void _onMetadataChange() {
|
||||||
|
|
|
@ -10,3 +10,4 @@ typedef MarkerImageReadyChecker<T> = bool Function(MarkerKey<T> key);
|
||||||
typedef UserZoomChangeCallback = void Function(double zoom);
|
typedef UserZoomChangeCallback = void Function(double zoom);
|
||||||
typedef MapTapCallback = void Function(LatLng location);
|
typedef MapTapCallback = void Function(LatLng location);
|
||||||
typedef MarkerTapCallback<T> = void Function(GeoEntry<T> geoEntry);
|
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:custom_rounded_rectangle_border/custom_rounded_rectangle_border.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
class ImageMarker extends StatelessWidget {
|
class ImageMarker extends StatelessWidget {
|
||||||
final int? count;
|
final int? count;
|
||||||
|
final bool drawArrow;
|
||||||
final Widget Function(double extent) buildThumbnailImage;
|
final Widget Function(double extent) buildThumbnailImage;
|
||||||
|
|
||||||
static const double outerBorderRadiusDim = 8;
|
static const double outerBorderRadiusDim = 8;
|
||||||
|
@ -18,6 +23,7 @@ class ImageMarker extends StatelessWidget {
|
||||||
const ImageMarker({
|
const ImageMarker({
|
||||||
super.key,
|
super.key,
|
||||||
required this.count,
|
required this.count,
|
||||||
|
this.drawArrow = true,
|
||||||
required this.buildThumbnailImage,
|
required this.buildThumbnailImage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -112,63 +118,51 @@ class ImageMarker extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return CustomPaint(
|
child = Container(
|
||||||
foregroundPainter: _MarkerArrowPainter(
|
decoration: outerDecoration,
|
||||||
color: innerBorderColor,
|
child: child,
|
||||||
outlineColor: outerBorderColor,
|
);
|
||||||
outlineWidth: outerBorderWidth,
|
|
||||||
size: arrowSize,
|
if (drawArrow) {
|
||||||
),
|
child = CustomPaint(
|
||||||
child: Padding(
|
foregroundPainter: MarkerArrowPainter(
|
||||||
padding: EdgeInsets.only(bottom: arrowSize.height),
|
color: innerBorderColor,
|
||||||
child: Container(
|
outlineColor: outerBorderColor,
|
||||||
decoration: outerDecoration,
|
outlineWidth: outerBorderWidth,
|
||||||
|
size: arrowSize,
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: arrowSize.height),
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MarkerArrowPainter extends CustomPainter {
|
return child;
|
||||||
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
|
static const _crs = Epsg3857();
|
||||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() {
|
Size toDisplaySize() {
|
||||||
final swPoint = _crs.latLngToPoint(sw, zoom);
|
final swPoint = _crs.latLngToPoint(sw, zoom);
|
||||||
final nePoint = _crs.latLngToPoint(ne, zoom);
|
final nePoint = _crs.latLngToPoint(ne, zoom);
|
||||||
return Size((swPoint.x - nePoint.x).abs().toDouble(), (swPoint.y - nePoint.y).abs().toDouble());
|
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 UserZoomChangeCallback? onUserZoomChange,
|
||||||
required MapTapCallback? onMapTap,
|
required MapTapCallback? onMapTap,
|
||||||
required MarkerTapCallback<T>? onMarkerTap,
|
required MarkerTapCallback<T>? onMarkerTap,
|
||||||
|
required MarkerLongPressCallback<T>? onMarkerLongPress,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -74,6 +74,7 @@ class PlatformMobileServices extends MobileServices {
|
||||||
required UserZoomChangeCallback? onUserZoomChange,
|
required UserZoomChangeCallback? onUserZoomChange,
|
||||||
required MapTapCallback? onMapTap,
|
required MapTapCallback? onMapTap,
|
||||||
required MarkerTapCallback<T>? onMarkerTap,
|
required MarkerTapCallback<T>? onMarkerTap,
|
||||||
|
required MarkerLongPressCallback<T>? onMarkerLongPress,
|
||||||
}) {
|
}) {
|
||||||
return EntryGoogleMap<T>(
|
return EntryGoogleMap<T>(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
@ -93,6 +94,7 @@ class PlatformMobileServices extends MobileServices {
|
||||||
onUserZoomChange: onUserZoomChange,
|
onUserZoomChange: onUserZoomChange,
|
||||||
onMapTap: onMapTap,
|
onMapTap: onMapTap,
|
||||||
onMarkerTap: onMarkerTap,
|
onMarkerTap: onMarkerTap,
|
||||||
|
onMarkerLongPress: onMarkerLongPress,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ class EntryGoogleMap<T> extends StatefulWidget {
|
||||||
final UserZoomChangeCallback? onUserZoomChange;
|
final UserZoomChangeCallback? onUserZoomChange;
|
||||||
final MapTapCallback? onMapTap;
|
final MapTapCallback? onMapTap;
|
||||||
final MarkerTapCallback<T>? onMarkerTap;
|
final MarkerTapCallback<T>? onMarkerTap;
|
||||||
|
final MarkerLongPressCallback<T>? onMarkerLongPress;
|
||||||
|
|
||||||
const EntryGoogleMap({
|
const EntryGoogleMap({
|
||||||
super.key,
|
super.key,
|
||||||
|
@ -44,6 +45,7 @@ class EntryGoogleMap<T> extends StatefulWidget {
|
||||||
this.onUserZoomChange,
|
this.onUserZoomChange,
|
||||||
this.onMapTap,
|
this.onMapTap,
|
||||||
this.onMarkerTap,
|
this.onMarkerTap,
|
||||||
|
this.onMarkerLongPress,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -143,6 +145,7 @@ class _EntryGoogleMapState<T> extends State<EntryGoogleMap<T>> with WidgetsBindi
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMap() {
|
Widget _buildMap() {
|
||||||
|
final _onMarkerLongPress = widget.onMarkerLongPress;
|
||||||
return StreamBuilder(
|
return StreamBuilder(
|
||||||
stream: _markerBitmapReadyStreamController.stream,
|
stream: _markerBitmapReadyStreamController.stream,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
|
@ -226,7 +229,17 @@ class _EntryGoogleMapState<T> extends State<EntryGoogleMap<T>> with WidgetsBindi
|
||||||
},
|
},
|
||||||
onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing),
|
onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing),
|
||||||
onCameraIdle: _onIdle,
|
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 UserZoomChangeCallback? onUserZoomChange,
|
||||||
required MapTapCallback? onMapTap,
|
required MapTapCallback? onMapTap,
|
||||||
required MarkerTapCallback<T>? onMarkerTap,
|
required MarkerTapCallback<T>? onMarkerTap,
|
||||||
|
required MarkerLongPressCallback<T>? onMarkerLongPress,
|
||||||
}) {
|
}) {
|
||||||
return EntryHmsMap<T>(
|
return EntryHmsMap<T>(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
@ -82,6 +83,7 @@ class PlatformMobileServices extends MobileServices {
|
||||||
onUserZoomChange: onUserZoomChange,
|
onUserZoomChange: onUserZoomChange,
|
||||||
onMapTap: onMapTap,
|
onMapTap: onMapTap,
|
||||||
onMarkerTap: onMarkerTap,
|
onMarkerTap: onMarkerTap,
|
||||||
|
onMarkerLongPress: onMarkerLongPress,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ class EntryHmsMap<T> extends StatefulWidget {
|
||||||
final UserZoomChangeCallback? onUserZoomChange;
|
final UserZoomChangeCallback? onUserZoomChange;
|
||||||
final MapTapCallback? onMapTap;
|
final MapTapCallback? onMapTap;
|
||||||
final MarkerTapCallback<T>? onMarkerTap;
|
final MarkerTapCallback<T>? onMarkerTap;
|
||||||
|
final MarkerLongPressCallback<T>? onMarkerLongPress;
|
||||||
|
|
||||||
const EntryHmsMap({
|
const EntryHmsMap({
|
||||||
super.key,
|
super.key,
|
||||||
|
@ -44,6 +45,7 @@ class EntryHmsMap<T> extends StatefulWidget {
|
||||||
this.onUserZoomChange,
|
this.onUserZoomChange,
|
||||||
this.onMapTap,
|
this.onMapTap,
|
||||||
this.onMarkerTap,
|
this.onMarkerTap,
|
||||||
|
this.onMarkerLongPress,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -122,6 +124,7 @@ class _EntryHmsMapState<T> extends State<EntryHmsMap<T>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMap() {
|
Widget _buildMap() {
|
||||||
|
final _onMarkerLongPress = widget.onMarkerLongPress;
|
||||||
return StreamBuilder(
|
return StreamBuilder(
|
||||||
stream: _markerBitmapReadyStreamController.stream,
|
stream: _markerBitmapReadyStreamController.stream,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
|
@ -228,7 +231,17 @@ class _EntryHmsMapState<T> extends State<EntryHmsMap<T>> {
|
||||||
},
|
},
|
||||||
onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: position.bearing),
|
onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: position.bearing),
|
||||||
onCameraIdle: _onIdle,
|
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) {
|
onPoiClick: (poi) {
|
||||||
final poiPosition = poi.latLng;
|
final poiPosition = poi.latLng;
|
||||||
if (poiPosition != null) {
|
if (poiPosition != null) {
|
||||||
|
|
|
@ -35,6 +35,7 @@ class PlatformMobileServices extends MobileServices {
|
||||||
required UserZoomChangeCallback? onUserZoomChange,
|
required UserZoomChangeCallback? onUserZoomChange,
|
||||||
required MapTapCallback? onMapTap,
|
required MapTapCallback? onMapTap,
|
||||||
required MarkerTapCallback<T>? onMarkerTap,
|
required MarkerTapCallback<T>? onMarkerTap,
|
||||||
|
required MarkerLongPressCallback<T>? onMarkerLongPress,
|
||||||
}) {
|
}) {
|
||||||
return const SizedBox();
|
return const SizedBox();
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue