map: rotation control, zoom bounds, move to scrolled thumb
thumb: fixed burst overlay
This commit is contained in:
parent
91ab2ef17f
commit
9c7eeeb35d
12 changed files with 315 additions and 104 deletions
|
@ -122,12 +122,20 @@ class MultiPageIcon extends StatelessWidget {
|
|||
}
|
||||
icon = AIcons.multiPage;
|
||||
}
|
||||
return OverlayIcon(
|
||||
final gridTheme = context.watch<GridThemeData>();
|
||||
final child = OverlayIcon(
|
||||
icon: icon,
|
||||
size: context.select<GridThemeData, double>((t) => t.iconSize),
|
||||
size: gridTheme.iconSize,
|
||||
iconScale: .8,
|
||||
text: text,
|
||||
);
|
||||
return DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade200,
|
||||
fontSize: gridTheme.fontSize,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<ZoomedBounds> boundsNotifier;
|
||||
final Future<void> 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<EntryMapStyle>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesSelectionDialog<EntryMapStyle>(
|
||||
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<ZoomedBounds>(
|
||||
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<EntryMapStyle>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesSelectionDialog<EntryMapStyle>(
|
||||
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,
|
||||
),
|
||||
|
|
43
lib/widgets/common/map/compass.dart
Normal file
43
lib/widgets/common/map/compass.dart
Normal file
|
@ -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;
|
||||
}
|
23
lib/widgets/common/map/controller.dart
Normal file
23
lib/widgets/common/map/controller.dart
Normal file
|
@ -0,0 +1,23 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class AvesMapController {
|
||||
final StreamController _streamController = StreamController.broadcast();
|
||||
|
||||
Stream<dynamic> get _events => _streamController.stream;
|
||||
|
||||
Stream<MapControllerMoveEvent> get moveEvents => _events.where((event) => event is MapControllerMoveEvent).cast<MapControllerMoveEvent>();
|
||||
|
||||
void dispose() {
|
||||
_streamController.close();
|
||||
}
|
||||
|
||||
void moveTo(LatLng latLng) => _streamController.add(MapControllerMoveEvent(latLng));
|
||||
}
|
||||
|
||||
class MapControllerMoveEvent {
|
||||
final LatLng latLng;
|
||||
|
||||
MapControllerMoveEvent(this.latLng);
|
||||
}
|
|
@ -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<AvesEntry> 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<GeoMap> 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<GeoMap> 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<GeoMap> with TickerProviderStateMixin {
|
|||
interactive: interactive,
|
||||
),
|
||||
MapButtonPanel(
|
||||
latLng: _boundsNotifier.value.center,
|
||||
boundsNotifier: _boundsNotifier,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
@ -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<ZoomedBounds> boundsNotifier;
|
||||
final bool interactive;
|
||||
final double? minZoom, maxZoom;
|
||||
final EntryMapStyle style;
|
||||
final EntryMarkerBuilder markerBuilder;
|
||||
final Fluster<GeoEntry> 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<EntryGoogleMap> with WidgetsBindingObserver {
|
||||
GoogleMapController? _controller;
|
||||
GoogleMapController? _googleMapController;
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
final Map<MarkerKey, Uint8List> _markerBitmaps = {};
|
||||
final AChangeNotifier _markerBitmapChangeNotifier = AChangeNotifier();
|
||||
|
||||
|
@ -52,11 +59,17 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> 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<EntryGoogleMap> 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<EntryGoogleMap> 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<EntryGoogleMap> 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<EntryGoogleMap> 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<EntryGoogleMap> with WidgetsBindingObse
|
|||
myLocationEnabled: false,
|
||||
myLocationButtonEnabled: false,
|
||||
markers: markers,
|
||||
onCameraMove: (position) => _updateVisibleRegion(position.zoom),
|
||||
onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _updateVisibleRegion(double zoom) async {
|
||||
final bounds = await _controller?.getVisibleRegion();
|
||||
Future<void> _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<EntryGoogleMap> 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<void> _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<void> _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<void> _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);
|
||||
|
||||
|
|
|
@ -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<ZoomedBounds> boundsNotifier;
|
||||
final bool interactive;
|
||||
final double minZoom, maxZoom;
|
||||
final EntryMapStyle style;
|
||||
final EntryMarkerBuilder markerBuilder;
|
||||
final Fluster<GeoEntry> 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<EntryLeafletMap> with TickerProviderStateMixin {
|
||||
final MapController _mapController = MapController();
|
||||
final MapController _leafletMapController = MapController();
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
|
||||
ValueNotifier<ZoomedBounds> 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<EntryLeafletMap> 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<EntryLeafletMap> 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<EntryLeafletMap> 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<void> _resetRotation() async {
|
||||
final rotationTween = Tween<double>(begin: _leafletMapController.rotation, end: 0);
|
||||
await _animateCamera((animation) => _leafletMapController.rotate(rotationTween.evaluate(animation)));
|
||||
}
|
||||
|
||||
Future<void> _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<double>(begin: _mapController.zoom, end: endZoom);
|
||||
await _animateCamera((animation) => _mapController.move(_mapController.center, zoomTween.evaluate(animation)));
|
||||
final zoomTween = Tween<double>(begin: _leafletMapController.zoom, end: endZoom);
|
||||
await _animateCamera((animation) => _leafletMapController.move(_leafletMapController.center, zoomTween.evaluate(animation)));
|
||||
}
|
||||
|
||||
Future<void> _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<void> _animateCamera(void Function(Animation<double> animation) animate) async {
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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<double> get boundingBox => [west, south, east, north];
|
||||
|
||||
LatLng get center => LatLng((north + south) / 2, (east + west) / 2);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [west, south, east, north, zoom];
|
||||
List<Object?> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<ThumbnailScroller> {
|
|||
final _cancellableNotifier = ValueNotifier(true);
|
||||
late ScrollController _scrollController;
|
||||
bool _syncScroll = true;
|
||||
ValueNotifier<int> _currentIndexNotifier = ValueNotifier(-1);
|
||||
|
||||
static const double extent = 48;
|
||||
static const double separatorWidth = 2;
|
||||
|
@ -62,7 +61,8 @@ class _ThumbnailScrollerState extends State<ThumbnailScroller> {
|
|||
}
|
||||
|
||||
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<ThumbnailScroller> {
|
|||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
child: AnimatedContainer(
|
||||
color: widget.isCurrentIndex(page) ? Colors.transparent : Colors.black45,
|
||||
width: extent,
|
||||
height: extent,
|
||||
duration: Durations.thumbnailScrollerShadeAnimation,
|
||||
child: ValueListenableBuilder<int>(
|
||||
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<ThumbnailScroller> {
|
|||
|
||||
Future<void> _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<ThumbnailScroller> {
|
|||
|
||||
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();
|
||||
|
|
|
@ -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<MapPage> {
|
||||
final AvesMapController _mapController = AvesMapController();
|
||||
late final ValueNotifier<bool> _isAnimatingNotifier;
|
||||
int _selectedIndex = 0;
|
||||
|
||||
|
@ -46,6 +48,12 @@ class _MapPageState extends State<MapPage> {
|
|||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MediaQueryDataProvider(
|
||||
|
@ -59,6 +67,7 @@ class _MapPageState extends State<MapPage> {
|
|||
children: [
|
||||
Expanded(
|
||||
child: GeoMap(
|
||||
controller: _mapController,
|
||||
entries: entries,
|
||||
interactive: true,
|
||||
isAnimatingNotifier: _isAnimatingNotifier,
|
||||
|
@ -75,9 +84,13 @@ class _MapPageState extends State<MapPage> {
|
|||
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!);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
@ -67,7 +67,6 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
|
|||
entryCount: multiPageInfo?.pageCount ?? 0,
|
||||
entryBuilder: (page) => multiPageInfo?.getPageEntryByIndex(page),
|
||||
initialIndex: _initControllerPage,
|
||||
isCurrentIndex: (page) => controller.page == page,
|
||||
onIndexChange: _setPage,
|
||||
);
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue