From 9c7eeeb35dcc4c6ddcc6935370a1d8328e952075 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 19 Aug 2021 17:39:50 +0900 Subject: [PATCH] map: rotation control, zoom bounds, move to scrolled thumb thumb: fixed burst overlay --- lib/widgets/common/identity/aves_icons.dart | 12 +- lib/widgets/common/map/buttons.dart | 143 ++++++++++++------ lib/widgets/common/map/compass.dart | 43 ++++++ lib/widgets/common/map/controller.dart | 23 +++ lib/widgets/common/map/geo_map.dart | 11 +- lib/widgets/common/map/google/map.dart | 69 +++++++-- lib/widgets/common/map/leaflet/map.dart | 61 +++++--- .../common/map/leaflet/scale_layer.dart | 1 - lib/widgets/common/map/zoomed_bounds.dart | 6 +- lib/widgets/common/thumbnail/scroller.dart | 32 ++-- lib/widgets/map/map_page.dart | 17 ++- .../viewer/overlay/bottom/multipage.dart | 1 - 12 files changed, 315 insertions(+), 104 deletions(-) create mode 100644 lib/widgets/common/map/compass.dart create mode 100644 lib/widgets/common/map/controller.dart diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index 20b713d53..a4192241a 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -122,12 +122,20 @@ class MultiPageIcon extends StatelessWidget { } icon = AIcons.multiPage; } - return OverlayIcon( + final gridTheme = context.watch(); + final child = OverlayIcon( icon: icon, - size: context.select((t) => t.iconSize), + size: gridTheme.iconSize, iconScale: .8, text: text, ); + return DefaultTextStyle( + style: TextStyle( + color: Colors.grey.shade200, + fontSize: gridTheme.fontSize, + ), + child: child, + ); } } diff --git a/lib/widgets/common/map/buttons.dart b/lib/widgets/common/map/buttons.dart index febc07549..5dae98abf 100644 --- a/lib/widgets/common/map/buttons.dart +++ b/lib/widgets/common/map/buttons.dart @@ -8,6 +8,8 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/borders.dart'; +import 'package:aves/widgets/common/map/compass.dart'; +import 'package:aves/widgets/common/map/zoomed_bounds.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; @@ -16,19 +18,23 @@ import 'package:flutter/scheduler.dart'; import 'package:latlong2/latlong.dart'; class MapButtonPanel extends StatelessWidget { - final LatLng latLng; + final ValueNotifier boundsNotifier; final Future Function(double amount)? zoomBy; + final VoidCallback? resetRotation; static const double padding = 4; const MapButtonPanel({ Key? key, - required this.latLng, + required this.boundsNotifier, this.zoomBy, + this.resetRotation, }) : super(key: key); @override Widget build(BuildContext context) { + final iconTheme = IconTheme.of(context); + final iconSize = Size.square(iconTheme.size!); return Positioned.fill( child: Align( alignment: AlignmentDirectional.centerEnd, @@ -38,53 +44,96 @@ class MapButtonPanel extends StatelessWidget { data: TooltipTheme.of(context).copyWith( preferBelow: false, ), - child: Column( - mainAxisSize: MainAxisSize.min, + child: Stack( children: [ - MapOverlayButton( - icon: AIcons.openOutside, - onPressed: () => AndroidAppService.openMap(latLng).then((success) { - if (!success) showNoMatchingAppDialog(context); - }), - tooltip: context.l10n.entryActionOpenMap, - ), - const SizedBox(height: padding), - MapOverlayButton( - icon: AIcons.layers, - onPressed: () async { - final hasPlayServices = await availability.hasPlayServices; - final availableStyles = EntryMapStyle.values.where((style) => !style.isGoogleMaps || hasPlayServices); - final preferredStyle = settings.infoMapStyle; - final initialStyle = availableStyles.contains(preferredStyle) ? preferredStyle : availableStyles.first; - final style = await showDialog( - context: context, - builder: (context) { - return AvesSelectionDialog( - initialValue: initialStyle, - options: Map.fromEntries(availableStyles.map((v) => MapEntry(v, v.getName(context)))), - title: context.l10n.viewerInfoMapStyleTitle, + if (resetRotation != null) + Positioned( + left: 0, + child: ValueListenableBuilder( + valueListenable: boundsNotifier, + builder: (context, bounds, child) { + final degrees = bounds.rotation; + return AnimatedOpacity( + opacity: degrees == 0 ? 0 : 1, + duration: Durations.viewerOverlayAnimation, + child: MapOverlayButton( + icon: Transform( + origin: iconSize.center(Offset.zero), + transform: Matrix4.rotationZ(degToRadian(degrees)), + child: CustomPaint( + painter: CompassPainter( + color: iconTheme.color!, + ), + size: iconSize, + ), + ), + onPressed: () => resetRotation?.call(), + tooltip: context.l10n.viewerInfoMapZoomInTooltip, + ), ); }, - ); - // wait for the dialog to hide as applying the change may block the UI - await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); - if (style != null && style != settings.infoMapStyle) { - settings.infoMapStyle = style; - } - }, - tooltip: context.l10n.viewerInfoMapStyleTooltip, + ), + ), + Positioned( + right: 0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + MapOverlayButton( + icon: const Icon(AIcons.openOutside), + onPressed: () => AndroidAppService.openMap(boundsNotifier.value.center).then((success) { + if (!success) showNoMatchingAppDialog(context); + }), + tooltip: context.l10n.entryActionOpenMap, + ), + const SizedBox(height: padding), + MapOverlayButton( + icon: const Icon(AIcons.layers), + onPressed: () async { + final hasPlayServices = await availability.hasPlayServices; + final availableStyles = EntryMapStyle.values.where((style) => !style.isGoogleMaps || hasPlayServices); + final preferredStyle = settings.infoMapStyle; + final initialStyle = availableStyles.contains(preferredStyle) ? preferredStyle : availableStyles.first; + final style = await showDialog( + context: context, + builder: (context) { + return AvesSelectionDialog( + initialValue: initialStyle, + options: Map.fromEntries(availableStyles.map((v) => MapEntry(v, v.getName(context)))), + title: context.l10n.viewerInfoMapStyleTitle, + ); + }, + ); + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); + if (style != null && style != settings.infoMapStyle) { + settings.infoMapStyle = style; + } + }, + tooltip: context.l10n.viewerInfoMapStyleTooltip, + ), + ], + ), ), - const Spacer(), - MapOverlayButton( - icon: AIcons.zoomIn, - onPressed: zoomBy != null ? () => zoomBy!(1) : null, - tooltip: context.l10n.viewerInfoMapZoomInTooltip, - ), - const SizedBox(height: padding), - MapOverlayButton( - icon: AIcons.zoomOut, - onPressed: zoomBy != null ? () => zoomBy!(-1) : null, - tooltip: context.l10n.viewerInfoMapZoomOutTooltip, + Positioned( + right: 0, + bottom: 0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + MapOverlayButton( + icon: const Icon(AIcons.zoomIn), + onPressed: zoomBy != null ? () => zoomBy?.call(1) : null, + tooltip: context.l10n.viewerInfoMapZoomInTooltip, + ), + const SizedBox(height: padding), + MapOverlayButton( + icon: const Icon(AIcons.zoomOut), + onPressed: zoomBy != null ? () => zoomBy?.call(-1) : null, + tooltip: context.l10n.viewerInfoMapZoomOutTooltip, + ), + ], + ), ), ], ), @@ -96,7 +145,7 @@ class MapButtonPanel extends StatelessWidget { } class MapOverlayButton extends StatelessWidget { - final IconData icon; + final Widget icon; final String tooltip; final VoidCallback? onPressed; @@ -123,7 +172,7 @@ class MapOverlayButton extends StatelessWidget { child: IconButton( iconSize: 20, visualDensity: VisualDensity.compact, - icon: Icon(icon), + icon: icon, onPressed: onPressed, tooltip: tooltip, ), diff --git a/lib/widgets/common/map/compass.dart b/lib/widgets/common/map/compass.dart new file mode 100644 index 000000000..0e8cc4f70 --- /dev/null +++ b/lib/widgets/common/map/compass.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +class CompassPainter extends CustomPainter { + final Color color; + + const CompassPainter({ + required this.color, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final base = size.width * .3; + final height = size.height * .4; + + final northTriangle = Path() + ..moveTo(center.dx - base / 2, center.dy) + ..lineTo(center.dx, center.dy - height) + ..lineTo(center.dx + base / 2, center.dy) + ..close(); + final southTriangle = Path() + ..moveTo(center.dx - base / 2, center.dy) + ..lineTo(center.dx + base / 2, center.dy) + ..lineTo(center.dx, center.dy + height) + ..close(); + + final fillPaint = Paint() + ..style = PaintingStyle.fill + ..color = color.withOpacity(.6); + final strokePaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.7 + ..strokeJoin = StrokeJoin.round + ..color = color; + + canvas.drawPath(northTriangle, fillPaint); + canvas.drawPath(northTriangle, strokePaint); + canvas.drawPath(southTriangle, strokePaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} diff --git a/lib/widgets/common/map/controller.dart b/lib/widgets/common/map/controller.dart new file mode 100644 index 000000000..584f6ea7d --- /dev/null +++ b/lib/widgets/common/map/controller.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +import 'package:latlong2/latlong.dart'; + +class AvesMapController { + final StreamController _streamController = StreamController.broadcast(); + + Stream get _events => _streamController.stream; + + Stream get moveEvents => _events.where((event) => event is MapControllerMoveEvent).cast(); + + void dispose() { + _streamController.close(); + } + + void moveTo(LatLng latLng) => _streamController.add(MapControllerMoveEvent(latLng)); +} + +class MapControllerMoveEvent { + final LatLng latLng; + + MapControllerMoveEvent(this.latLng); +} diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart index 9b561b313..635949167 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -10,6 +10,7 @@ import 'package:aves/utils/constants.dart'; import 'package:aves/utils/math_utils.dart'; import 'package:aves/widgets/common/map/attribution.dart'; import 'package:aves/widgets/common/map/buttons.dart'; +import 'package:aves/widgets/common/map/controller.dart'; import 'package:aves/widgets/common/map/decorator.dart'; import 'package:aves/widgets/common/map/geo_entry.dart'; import 'package:aves/widgets/common/map/google/map.dart'; @@ -23,6 +24,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class GeoMap extends StatefulWidget { + final AvesMapController? controller; final List entries; final bool interactive; final double? mapHeight; @@ -35,6 +37,7 @@ class GeoMap extends StatefulWidget { const GeoMap({ Key? key, + this.controller, required this.entries, required this.interactive, this.mapHeight, @@ -118,8 +121,11 @@ class _GeoMapState extends State with TickerProviderStateMixin { Widget child = isGoogleMaps ? EntryGoogleMap( + controller: widget.controller, boundsNotifier: _boundsNotifier, interactive: interactive, + minZoom: 0, + maxZoom: 20, style: mapStyle, markerBuilder: _buildMarker, markerCluster: _defaultMarkerCluster, @@ -128,8 +134,11 @@ class _GeoMapState extends State with TickerProviderStateMixin { onMarkerTap: _onMarkerTap, ) : EntryLeafletMap( + controller: widget.controller, boundsNotifier: _boundsNotifier, interactive: interactive, + minZoom: 2, + maxZoom: 16, style: mapStyle, markerBuilder: _buildMarker, markerCluster: _defaultMarkerCluster, @@ -172,7 +181,7 @@ class _GeoMapState extends State with TickerProviderStateMixin { interactive: interactive, ), MapButtonPanel( - latLng: _boundsNotifier.value.center, + boundsNotifier: _boundsNotifier, ), ], ); diff --git a/lib/widgets/common/map/google/map.dart b/lib/widgets/common/map/google/map.dart index dc317bf12..876c1cfa1 100644 --- a/lib/widgets/common/map/google/map.dart +++ b/lib/widgets/common/map/google/map.dart @@ -6,6 +6,7 @@ import 'package:aves/model/entry_images.dart'; import 'package:aves/model/settings/enums.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/widgets/common/map/buttons.dart'; +import 'package:aves/widgets/common/map/controller.dart'; import 'package:aves/widgets/common/map/decorator.dart'; import 'package:aves/widgets/common/map/geo_entry.dart'; import 'package:aves/widgets/common/map/geo_map.dart'; @@ -18,8 +19,10 @@ import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:latlong2/latlong.dart' as ll; class EntryGoogleMap extends StatefulWidget { + final AvesMapController? controller; final ValueNotifier boundsNotifier; final bool interactive; + final double? minZoom, maxZoom; final EntryMapStyle style; final EntryMarkerBuilder markerBuilder; final Fluster markerCluster; @@ -29,8 +32,11 @@ class EntryGoogleMap extends StatefulWidget { const EntryGoogleMap({ Key? key, + this.controller, required this.boundsNotifier, required this.interactive, + this.minZoom, + this.maxZoom, required this.style, required this.markerBuilder, required this.markerCluster, @@ -44,7 +50,8 @@ class EntryGoogleMap extends StatefulWidget { } class _EntryGoogleMapState extends State with WidgetsBindingObserver { - GoogleMapController? _controller; + GoogleMapController? _googleMapController; + final List _subscriptions = []; final Map _markerBitmaps = {}; final AChangeNotifier _markerBitmapChangeNotifier = AChangeNotifier(); @@ -52,11 +59,17 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse ZoomedBounds get bounds => boundsNotifier.value; + bool get interactive => widget.interactive; + static const uninitializedLatLng = LatLng(0, 0); @override void initState() { super.initState(); + final avesMapController = widget.controller; + if (avesMapController != null) { + _subscriptions.add(avesMapController.moveEvents.listen((event) => _moveTo(_toGoogleLatLng(event.latLng)))); + } } @override @@ -70,7 +83,10 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse @override void dispose() { - _controller?.dispose(); + _googleMapController?.dispose(); + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); super.dispose(); } @@ -84,7 +100,7 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse case AppLifecycleState.resumed: // workaround for blank Google Maps when resuming app // cf https://github.com/flutter/flutter/issues/40284 - _controller?.setMapStyle(null); + _googleMapController?.setMapStyle(null); break; } } @@ -116,12 +132,13 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse }, ), MapDecorator( - interactive: widget.interactive, + interactive: interactive, child: _buildMap(geoEntryByMarkerKey), ), MapButtonPanel( - latLng: bounds.center, + boundsNotifier: boundsNotifier, zoomBy: _zoomBy, + resetRotation: interactive ? _resetRotation : null, ), ], ); @@ -154,17 +171,18 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse target: _toGoogleLatLng(bounds.center), zoom: bounds.zoom, ), - onMapCreated: (controller) { - _controller = controller; - controller.getZoomLevel().then(_updateVisibleRegion); + onMapCreated: (controller) async { + _googleMapController = controller; + final zoom = await controller.getZoomLevel(); + await _updateVisibleRegion(zoom: zoom, rotation: 0); setState(() {}); }, - // TODO TLAD [map] add common compass button for both google/leaflet + // compass disabled to use provider agnostic controls compassEnabled: false, mapToolbarEnabled: false, mapType: _toMapType(widget.style), - // TODO TLAD [map] allow rotation when leaflet scale layer is fixed - rotateGesturesEnabled: false, + minMaxZoomPreference: MinMaxZoomPreference(widget.minZoom, widget.maxZoom), + rotateGesturesEnabled: true, scrollGesturesEnabled: interactive, // zoom controls disabled to use provider agnostic controls zoomControlsEnabled: false, @@ -176,14 +194,14 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse myLocationEnabled: false, myLocationButtonEnabled: false, markers: markers, - onCameraMove: (position) => _updateVisibleRegion(position.zoom), + onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing), ); }, ); } - Future _updateVisibleRegion(double zoom) async { - final bounds = await _controller?.getVisibleRegion(); + Future _updateVisibleRegion({required double zoom, required double rotation}) async { + final bounds = await _googleMapController?.getVisibleRegion(); if (bounds != null && (bounds.northeast != uninitializedLatLng || bounds.southwest != uninitializedLatLng)) { boundsNotifier.value = ZoomedBounds( west: bounds.southwest.longitude, @@ -191,25 +209,44 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse east: bounds.northeast.longitude, north: bounds.northeast.latitude, zoom: zoom, + rotation: rotation, ); } else { // the visible region is sometimes uninitialized when queried right after creation, // so we query it again next frame WidgetsBinding.instance!.addPostFrameCallback((_) { if (!mounted) return; - _updateVisibleRegion(zoom); + _updateVisibleRegion(zoom: zoom, rotation: rotation); }); } } + Future _resetRotation() async { + final controller = _googleMapController; + if (controller == null) return; + + final bounds = boundsNotifier.value; + await controller.animateCamera(CameraUpdate.newCameraPosition(CameraPosition( + target: _toGoogleLatLng(bounds.center), + zoom: bounds.zoom, + ))); + } + Future _zoomBy(double amount) async { - final controller = _controller; + final controller = _googleMapController; if (controller == null) return; widget.onUserZoomChange?.call(await controller.getZoomLevel() + amount); await controller.animateCamera(CameraUpdate.zoomBy(amount)); } + Future _moveTo(LatLng latLng) async { + final controller = _googleMapController; + if (controller == null) return; + + await controller.animateCamera(CameraUpdate.newLatLng(latLng)); + } + // `LatLng` used by `google_maps_flutter` is not the one from `latlong2` package LatLng _toGoogleLatLng(ll.LatLng latLng) => LatLng(latLng.latitude, latLng.longitude); diff --git a/lib/widgets/common/map/leaflet/map.dart b/lib/widgets/common/map/leaflet/map.dart index 0a0c3fde7..6241e4ab0 100644 --- a/lib/widgets/common/map/leaflet/map.dart +++ b/lib/widgets/common/map/leaflet/map.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/enums.dart'; import 'package:aves/widgets/common/map/buttons.dart'; +import 'package:aves/widgets/common/map/controller.dart'; import 'package:aves/widgets/common/map/decorator.dart'; import 'package:aves/widgets/common/map/geo_entry.dart'; import 'package:aves/widgets/common/map/geo_map.dart'; @@ -16,8 +17,10 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; class EntryLeafletMap extends StatefulWidget { + final AvesMapController? controller; final ValueNotifier boundsNotifier; final bool interactive; + final double minZoom, maxZoom; final EntryMapStyle style; final EntryMarkerBuilder markerBuilder; final Fluster markerCluster; @@ -28,8 +31,11 @@ class EntryLeafletMap extends StatefulWidget { const EntryLeafletMap({ Key? key, + this.controller, required this.boundsNotifier, required this.interactive, + this.minZoom = 0, + this.maxZoom = 22, required this.style, required this.markerBuilder, required this.markerCluster, @@ -44,27 +50,26 @@ class EntryLeafletMap extends StatefulWidget { } class _EntryLeafletMapState extends State with TickerProviderStateMixin { - final MapController _mapController = MapController(); + final MapController _leafletMapController = MapController(); final List _subscriptions = []; ValueNotifier get boundsNotifier => widget.boundsNotifier; ZoomedBounds get bounds => boundsNotifier.value; + bool get interactive => widget.interactive; + // duration should match the uncustomizable Google Maps duration static const _cameraAnimationDuration = Duration(milliseconds: 400); - static const _zoomMin = 1.0; - - // TODO TLAD [map] also limit zoom on pinch-to-zoom gesture - static const _zoomMax = 16.0; - - // TODO TLAD [map] allow rotation when leaflet scale layer is fixed - static const interactiveFlags = InteractiveFlag.all & ~InteractiveFlag.rotate; @override void initState() { super.initState(); - _subscriptions.add(_mapController.mapEventStream.listen((event) => _updateVisibleRegion())); + final avesMapController = widget.controller; + if (avesMapController != null) { + _subscriptions.add(avesMapController.moveEvents.listen((event) => _moveTo(event.latLng))); + } + _subscriptions.add(_leafletMapController.mapEventStream.listen((event) => _updateVisibleRegion())); WidgetsBinding.instance!.addPostFrameCallback((_) => _updateVisibleRegion()); } @@ -95,12 +100,13 @@ class _EntryLeafletMapState extends State with TickerProviderSt return Stack( children: [ MapDecorator( - interactive: widget.interactive, + interactive: interactive, child: _buildMap(geoEntryByMarkerKey), ), MapButtonPanel( - latLng: bounds.center, + boundsNotifier: boundsNotifier, zoomBy: _zoomBy, + resetRotation: interactive ? _resetRotation : null, ), ], ); @@ -133,14 +139,19 @@ class _EntryLeafletMapState extends State with TickerProviderSt options: MapOptions( center: bounds.center, zoom: bounds.zoom, - interactiveFlags: widget.interactive ? interactiveFlags : InteractiveFlag.none, + minZoom: widget.minZoom, + maxZoom: widget.maxZoom, + interactiveFlags: widget.interactive ? InteractiveFlag.all : InteractiveFlag.none, + controller: _leafletMapController, ), - mapController: _mapController, - children: [ - _buildMapLayer(), + mapController: _leafletMapController, + nonRotatedChildren: [ ScaleLayerWidget( options: ScaleLayerOptions(), ), + ], + children: [ + _buildMapLayer(), MarkerLayerWidget( options: MarkerLayerOptions( markers: markers, @@ -166,29 +177,35 @@ class _EntryLeafletMapState extends State with TickerProviderSt } void _updateVisibleRegion() { - final bounds = _mapController.bounds; + final bounds = _leafletMapController.bounds; if (bounds != null) { boundsNotifier.value = ZoomedBounds( west: bounds.west, south: bounds.south, east: bounds.east, north: bounds.north, - zoom: _mapController.zoom, + zoom: _leafletMapController.zoom, + rotation: _leafletMapController.rotation, ); } } + Future _resetRotation() async { + final rotationTween = Tween(begin: _leafletMapController.rotation, end: 0); + await _animateCamera((animation) => _leafletMapController.rotate(rotationTween.evaluate(animation))); + } + Future _zoomBy(double amount) async { - final endZoom = (_mapController.zoom + amount).clamp(_zoomMin, _zoomMax); + final endZoom = (_leafletMapController.zoom + amount).clamp(widget.minZoom, widget.maxZoom); widget.onUserZoomChange?.call(endZoom); - final zoomTween = Tween(begin: _mapController.zoom, end: endZoom); - await _animateCamera((animation) => _mapController.move(_mapController.center, zoomTween.evaluate(animation))); + final zoomTween = Tween(begin: _leafletMapController.zoom, end: endZoom); + await _animateCamera((animation) => _leafletMapController.move(_leafletMapController.center, zoomTween.evaluate(animation))); } Future _moveTo(LatLng point) async { - final centerTween = LatLngTween(begin: _mapController.center, end: point); - await _animateCamera((animation) => _mapController.move(centerTween.evaluate(animation)!, _mapController.zoom)); + final centerTween = LatLngTween(begin: _leafletMapController.center, end: point); + await _animateCamera((animation) => _leafletMapController.move(centerTween.evaluate(animation)!, _leafletMapController.zoom)); } Future _animateCamera(void Function(Animation animation) animate) async { diff --git a/lib/widgets/common/map/leaflet/scale_layer.dart b/lib/widgets/common/map/leaflet/scale_layer.dart index 4a97aa3c1..d87005bcc 100644 --- a/lib/widgets/common/map/leaflet/scale_layer.dart +++ b/lib/widgets/common/map/leaflet/scale_layer.dart @@ -23,7 +23,6 @@ class ScaleLayerOptions extends LayerOptions { } } -// TODO TLAD [map] scale bar should not rotate together with map layer class ScaleLayerWidget extends StatelessWidget { final ScaleLayerOptions options; diff --git a/lib/widgets/common/map/zoomed_bounds.dart b/lib/widgets/common/map/zoomed_bounds.dart index a19f42e5f..2faca0e46 100644 --- a/lib/widgets/common/map/zoomed_bounds.dart +++ b/lib/widgets/common/map/zoomed_bounds.dart @@ -6,14 +6,14 @@ import 'package:latlong2/latlong.dart'; @immutable class ZoomedBounds extends Equatable { - final double west, south, east, north, zoom; + final double west, south, east, north, zoom, rotation; List get boundingBox => [west, south, east, north]; LatLng get center => LatLng((north + south) / 2, (east + west) / 2); @override - List get props => [west, south, east, north, zoom]; + List get props => [west, south, east, north, zoom, rotation]; const ZoomedBounds({ required this.west, @@ -21,6 +21,7 @@ class ZoomedBounds extends Equatable { required this.east, required this.north, required this.zoom, + required this.rotation, }); static const _collocationMaxDeltaThreshold = 360 / (2 << 19); @@ -59,6 +60,7 @@ class ZoomedBounds extends Equatable { east: east, north: north, zoom: zoom, + rotation: 0, ); } } diff --git a/lib/widgets/common/thumbnail/scroller.dart b/lib/widgets/common/thumbnail/scroller.dart index 28b5f03e8..dfbc08ade 100644 --- a/lib/widgets/common/thumbnail/scroller.dart +++ b/lib/widgets/common/thumbnail/scroller.dart @@ -12,7 +12,6 @@ class ThumbnailScroller extends StatefulWidget { final int entryCount; final AvesEntry? Function(int index) entryBuilder; final int? initialIndex; - final bool Function(int page) isCurrentIndex; final void Function(int index) onIndexChange; const ThumbnailScroller({ @@ -21,7 +20,6 @@ class ThumbnailScroller extends StatefulWidget { required this.entryCount, required this.entryBuilder, required this.initialIndex, - required this.isCurrentIndex, required this.onIndexChange, }) : super(key: key); @@ -33,6 +31,7 @@ class _ThumbnailScrollerState extends State { final _cancellableNotifier = ValueNotifier(true); late ScrollController _scrollController; bool _syncScroll = true; + ValueNotifier _currentIndexNotifier = ValueNotifier(-1); static const double extent = 48; static const double separatorWidth = 2; @@ -62,7 +61,8 @@ class _ThumbnailScrollerState extends State { } void _registerWidget() { - final scrollOffset = indexToScrollOffset(widget.initialIndex ?? 0); + _currentIndexNotifier.value = widget.initialIndex ?? 0; + final scrollOffset = indexToScrollOffset(_currentIndexNotifier.value); _scrollController = ScrollController(initialScrollOffset: scrollOffset); _scrollController.addListener(_onScrollChange); } @@ -112,11 +112,16 @@ class _ThumbnailScrollerState extends State { ), ), IgnorePointer( - child: AnimatedContainer( - color: widget.isCurrentIndex(page) ? Colors.transparent : Colors.black45, - width: extent, - height: extent, - duration: Durations.thumbnailScrollerShadeAnimation, + child: ValueListenableBuilder( + valueListenable: _currentIndexNotifier, + builder: (context, currentIndex, child) { + return AnimatedContainer( + color: currentIndex == page ? Colors.transparent : Colors.black45, + width: extent, + height: extent, + duration: Durations.thumbnailScrollerShadeAnimation, + ); + }, ), ) ], @@ -131,7 +136,7 @@ class _ThumbnailScrollerState extends State { Future _goTo(int index) async { _syncScroll = false; - widget.onIndexChange(index); + setCurrentIndex(index); await _scrollController.animateTo( indexToScrollOffset(index), duration: Durations.thumbnailScrollerScrollAnimation, @@ -142,10 +147,17 @@ class _ThumbnailScrollerState extends State { void _onScrollChange() { if (_syncScroll) { - widget.onIndexChange(scrollOffsetToIndex(_scrollController.offset)); + setCurrentIndex(scrollOffsetToIndex(_scrollController.offset)); } } + void setCurrentIndex(int index) { + if (_currentIndexNotifier.value == index) return; + + _currentIndexNotifier.value = index; + widget.onIndexChange(index); + } + double indexToScrollOffset(int index) => index * (extent + separatorWidth); int scrollOffsetToIndex(double offset) => (offset / (extent + separatorWidth)).round(); diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index 8d55c5ff9..823d63a74 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -3,6 +3,7 @@ import 'package:aves/model/settings/map_style.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/map/controller.dart'; import 'package:aves/widgets/common/map/geo_map.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/thumbnail/scroller.dart'; @@ -27,6 +28,7 @@ class MapPage extends StatefulWidget { } class _MapPageState extends State { + final AvesMapController _mapController = AvesMapController(); late final ValueNotifier _isAnimatingNotifier; int _selectedIndex = 0; @@ -46,6 +48,12 @@ class _MapPageState extends State { } } + @override + void dispose() { + _mapController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return MediaQueryDataProvider( @@ -59,6 +67,7 @@ class _MapPageState extends State { children: [ Expanded( child: GeoMap( + controller: _mapController, entries: entries, interactive: true, isAnimatingNotifier: _isAnimatingNotifier, @@ -75,9 +84,13 @@ class _MapPageState extends State { availableWidth: mqWidth, entryCount: entries.length, entryBuilder: (index) => entries[index], + // TODO TLAD provide notifier instead initialIndex: _selectedIndex, - isCurrentIndex: (index) => _selectedIndex == index, - onIndexChange: (index) => _selectedIndex = index, + onIndexChange: (index) { + _selectedIndex = index; + // TODO TLAD debounce move + _mapController.moveTo(widget.entries[_selectedIndex].latLng!); + }, ); }, ), diff --git a/lib/widgets/viewer/overlay/bottom/multipage.dart b/lib/widgets/viewer/overlay/bottom/multipage.dart index 96a4f57e8..5943af6ba 100644 --- a/lib/widgets/viewer/overlay/bottom/multipage.dart +++ b/lib/widgets/viewer/overlay/bottom/multipage.dart @@ -67,7 +67,6 @@ class _MultiPageOverlayState extends State { entryCount: multiPageInfo?.pageCount ?? 0, entryBuilder: (page) => multiPageInfo?.getPageEntryByIndex(page), initialIndex: _initControllerPage, - isCurrentIndex: (page) => controller.page == page, onIndexChange: _setPage, ); },