#423 map: cluster context menu, edit cluster location

This commit is contained in:
Thibault Deckers 2022-12-05 17:17:10 +01:00
parent 01ceb25129
commit 9bd01b16f4
22 changed files with 371 additions and 158 deletions

View file

@ -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)

View file

@ -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;
}
}

View file

@ -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) {

View file

@ -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),

View file

@ -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(

View file

@ -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,

View file

@ -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(() {

View file

@ -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),
),
);
}

View file

@ -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) {

View file

@ -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(),
),
),
);
}
}

View file

@ -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();
}
}

View file

@ -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() {

View file

@ -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);

View 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;
}

View file

@ -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;
}

View file

@ -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());
}
}

View file

@ -29,5 +29,6 @@ abstract class MobileServices {
required UserZoomChangeCallback? onUserZoomChange,
required MapTapCallback? onMapTap,
required MarkerTapCallback<T>? onMarkerTap,
required MarkerLongPressCallback<T>? onMarkerLongPress,
});
}

View file

@ -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,
);
}
}

View file

@ -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,
);
},
);

View file

@ -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,
);
}
}

View file

@ -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) {

View file

@ -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();
}