Create abstraction and move gmap backend for map browser to package

This commit is contained in:
Ming Ming 2024-07-22 23:16:26 +08:00
parent 14140c5ef0
commit 812c8eee9c
18 changed files with 431 additions and 265 deletions

View file

@ -75,7 +75,7 @@ Future<void> init(InitIsolateType isolateType) async {
}
await _initDiContainer(isolateType);
_initVisibilityDetector();
GpsMap.init();
initGpsMap();
// init session storage
SessionStorage();

View file

@ -161,10 +161,12 @@ class PrefController {
value: value,
);
Future<bool> setMapBrowserPrevPosition(MapCoord value) => _set<MapCoord?>(
Future<bool> setMapBrowserPrevPosition(MapCoord? value) =>
_setOrRemove<MapCoord>(
controller: _mapBrowserPrevPositionController,
setter: (pref, value) => pref.setMapBrowserPrevPosition(
jsonEncode([value!.latitude, value.longitude])),
jsonEncode([value.latitude, value.longitude])),
remover: (pref) => pref.setMapBrowserPrevPosition(null),
value: value,
);
@ -270,9 +272,9 @@ class PrefController {
BehaviorSubject.seeded(_c.pref.isDontShowVideoPreviewHintOr(false));
@npSubjectAccessor
late final _mapBrowserPrevPositionController = BehaviorSubject.seeded(_c.pref
.getMapBrowserPrevPosition()
?.let(tryJsonDecode)
?.let(_tryMapCoordFromJson));
.getMapBrowserPrevPosition()
?.let(tryJsonDecode)
?.let(_tryMapCoordFromJson));
}
@npSubjectAccessor

View file

@ -91,8 +91,13 @@ extension on Pref {
String? getMapBrowserPrevPosition() =>
provider.getString(PrefKey.mapBrowserPrevPosition);
Future<bool> setMapBrowserPrevPosition(String value) =>
provider.setString(PrefKey.mapBrowserPrevPosition, value);
Future<bool> setMapBrowserPrevPosition(String? value) {
if (value == null) {
return provider.remove(PrefKey.mapBrowserPrevPosition);
} else {
return provider.setString(PrefKey.mapBrowserPrevPosition, value);
}
}
}
MapCoord? _tryMapCoordFromJson(dynamic json) {

View file

@ -28,4 +28,10 @@ extension StreamExtension<T> on Stream<T> {
Stream<T> distinctBy<U>(U Function(T element) keyOf) =>
distinct((previous, next) => keyOf(previous) == keyOf(next));
/// By default the first event will always pass through the distinct check,
/// which may not always make sense. In this variant, the first event will be
/// absorbed
Stream<T> distinctByIgnoreFirst<U>(U Function(T element) keyOf) =>
distinct((previous, next) => keyOf(previous) == keyOf(next)).skip(1);
}

View file

@ -6,7 +6,6 @@ import 'package:copy_with/copy_with.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_maps_cluster_manager/google_maps_cluster_manager.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:intl/intl.dart' as intl;
import 'package:kiwi/kiwi.dart';

View file

@ -16,7 +16,6 @@ abstract class $_StateCopyWithWorker {
_State call(
{List<_DataPoint>? data,
MapCoord? initialPoint,
Set<Marker>? markers,
bool? isShowDataRangeControlPanel,
_DateRangeType? dateRangeType,
DateRange? localDateRange,
@ -30,7 +29,6 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker {
_State call(
{dynamic data,
dynamic initialPoint = copyWithNull,
dynamic markers,
dynamic isShowDataRangeControlPanel,
dynamic dateRangeType,
dynamic localDateRange,
@ -40,7 +38,6 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker {
initialPoint: initialPoint == copyWithNull
? that.initialPoint
: initialPoint as MapCoord?,
markers: markers as Set<Marker>? ?? that.markers,
isShowDataRangeControlPanel: isShowDataRangeControlPanel as bool? ??
that.isShowDataRangeControlPanel,
dateRangeType: dateRangeType as _DateRangeType? ?? that.dateRangeType,
@ -74,7 +71,7 @@ extension _$_BlocNpLog on _Bloc {
extension _$_StateToString on _State {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_State {data: [length: ${data.length}], initialPoint: $initialPoint, markers: {length: ${markers.length}}, isShowDataRangeControlPanel: $isShowDataRangeControlPanel, dateRangeType: ${dateRangeType.name}, localDateRange: $localDateRange, error: $error}";
return "_State {data: [length: ${data.length}], initialPoint: $initialPoint, isShowDataRangeControlPanel: $isShowDataRangeControlPanel, dateRangeType: ${dateRangeType.name}, localDateRange: $localDateRange, error: $error}";
}
}
@ -85,13 +82,6 @@ extension _$_LoadDataToString on _LoadData {
}
}
extension _$_SetMarkersToString on _SetMarkers {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_SetMarkers {markers: {length: ${markers.length}}}";
}
}
extension _$_OpenDataRangeControlPanelToString on _OpenDataRangeControlPanel {
String _$toString() {
// ignore: unnecessary_string_interpolations

View file

@ -13,15 +13,15 @@ class _Bloc extends Bloc<_Event, _State>
_calcDateRange(clock.now().toDate(), _DateRangeType.thisMonth),
)) {
on<_LoadData>(_onLoadData);
on<_SetMarkers>(_onSetMarkers);
on<_OpenDataRangeControlPanel>(_onOpenDataRangeControlPanel);
on<_CloseControlPanel>(_onCloseControlPanel);
on<_SetDateRangeType>(_onSetDateRangeType);
on<_SetLocalDateRange>(_onSetDateRange);
on<_SetError>(_onSetError);
_subscriptions
.add(stream.distinctBy((state) => state.localDateRange).listen((state) {
_subscriptions.add(stream
.distinctByIgnoreFirst((state) => state.localDateRange)
.listen((state) {
add(const _LoadData());
}));
}
@ -77,11 +77,6 @@ class _Bloc extends Bloc<_Event, _State>
}
}
void _onSetMarkers(_SetMarkers ev, Emitter<_State> emit) {
_log.info(ev);
emit(state.copyWith(markers: ev.markers));
}
void _onOpenDataRangeControlPanel(
_OpenDataRangeControlPanel ev, Emitter<_State> emit) {
_log.info(ev);

View file

@ -6,7 +6,6 @@ class _State {
const _State({
required this.data,
this.initialPoint,
required this.markers,
required this.isShowDataRangeControlPanel,
required this.dateRangeType,
required this.localDateRange,
@ -19,7 +18,6 @@ class _State {
}) {
return _State(
data: const [],
markers: const {},
isShowDataRangeControlPanel: false,
dateRangeType: dateRangeType,
localDateRange: localDateRange,
@ -31,7 +29,6 @@ class _State {
final List<_DataPoint> data;
final MapCoord? initialPoint;
final Set<Marker> markers;
final bool isShowDataRangeControlPanel;
final _DateRangeType dateRangeType;
@ -52,16 +49,6 @@ class _LoadData implements _Event {
String toString() => _$toString();
}
@toString
class _SetMarkers implements _Event {
const _SetMarkers(this.markers);
@override
String toString() => _$toString();
final Set<Marker> markers;
}
@toString
class _OpenDataRangeControlPanel implements _Event {
const _OpenDataRangeControlPanel();

View file

@ -1,25 +1,155 @@
part of '../map_browser.dart';
class _DataPoint implements ClusterItem {
class _DataPoint extends DataPoint {
const _DataPoint({
required this.location,
required super.position,
required this.fileId,
});
factory _DataPoint.fromImageLatLng(ImageLatLng src) => _DataPoint(
location: LatLng(src.latitude, src.longitude),
position: MapCoord(src.latitude, src.longitude),
fileId: src.fileId,
);
@override
String get geohash =>
Geohash.encode(location, codeLength: ClusterManager.precision);
@override
final LatLng location;
final int fileId;
}
class _GoogleMarkerBuilder {
_GoogleMarkerBuilder(this.context);
Future<BitmapDescriptor> build(List<DataPoint> dataPoints) {
return _getClusterBitmap(
_getMarkerSize(dataPoints.length),
text: _getMarkerCountString(dataPoints.length),
color: _getMarkerColor(dataPoints.length),
);
}
String _getMarkerCountString(int count) {
switch (count) {
case >= 10000:
return "10000+";
case >= 1000:
return "${count ~/ 1000 * 1000}+";
case >= 100:
return "${count ~/ 100 * 100}+";
case >= 10:
return "${count ~/ 10 * 10}+";
default:
return count.toString();
}
}
Color _getMarkerColor(int count) {
const step = 1 / 4;
final double r;
switch (count) {
case >= 10000:
r = 1;
case >= 1000:
r = (count ~/ 1000) / 10 * step + step * 3;
case >= 100:
r = (count ~/ 100) / 10 * step + step * 2;
case >= 10:
r = (count ~/ 10) / 10 * step + step;
default:
r = (count / 10) * step;
}
if (Theme.of(context).brightness == Brightness.light) {
return HSLColor.fromAHSL(
1,
_colorHsl.hue,
r * .7 + .3,
(_colorHsl.lightness - (.1 - r * .1)).clamp(0, 1),
).toColor();
} else {
return HSLColor.fromAHSL(
1,
_colorHsl.hue,
r * .6 + .4,
(_colorHsl.lightness - (.1 - r * .1)).clamp(0, 1),
).toColor();
}
}
int _getMarkerSize(int count) {
const step = 1 / 4;
final double r;
switch (count) {
case >= 10000:
r = 1;
case >= 1000:
r = (count ~/ 1000) / 10 * step + step * 3;
case >= 100:
r = (count ~/ 100) / 10 * step + step * 2;
case >= 10:
r = (count ~/ 10) / 10 * step + step;
default:
r = (count / 10) * step;
}
return (r * 85).toInt() + 85;
}
Future<BitmapDescriptor> _getClusterBitmap(
int size, {
String? text,
required Color color,
}) async {
final PictureRecorder pictureRecorder = PictureRecorder();
final Canvas canvas = Canvas(pictureRecorder);
final fillPaint = Paint()..color = color;
final outlinePaint = Paint()
..color = Theme.of(context).brightness == Brightness.light
? Colors.black.withOpacity(.28)
: Colors.white.withOpacity(.6)
..strokeWidth = size / 28
..style = PaintingStyle.stroke;
const shadowPadding = 6.0;
const shadowPaddingHalf = shadowPadding / 2;
final shadowPath = Path()
..addOval(
Rect.fromLTWH(0, 0, size - shadowPadding, size - shadowPadding));
canvas.drawShadow(shadowPath, Colors.black, 1, false);
canvas.drawCircle(
Offset(size / 2 - shadowPaddingHalf, size / 2 - shadowPaddingHalf),
size / 2 - shadowPaddingHalf,
fillPaint,
);
canvas.drawCircle(
Offset(size / 2 - shadowPaddingHalf, size / 2 - shadowPaddingHalf),
size / 2 - shadowPaddingHalf - (size / 28 / 2),
outlinePaint,
);
if (text != null) {
TextPainter painter = TextPainter(textDirection: TextDirection.ltr);
painter.text = TextSpan(
text: text,
style: TextStyle(
fontSize: size / 3 - ((text.length / 6) * (size * 0.1)),
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
),
);
painter.layout();
painter.paint(
canvas,
Offset(
size / 2 - painter.width / 2 - shadowPaddingHalf,
size / 2 - painter.height / 2 - shadowPaddingHalf,
),
);
}
final img = await pictureRecorder.endRecording().toImage(size, size);
final data = await img.toByteData(format: ImageByteFormat.png) as ByteData;
return BitmapDescriptor.fromBytes(data.buffer.asUint8List());
}
final BuildContext context;
late final _colorHsl =
HSLColor.fromColor(Theme.of(context).colorScheme.primaryContainer);
}
enum _DateRangeType {
thisMonth,
prevMonth,
@ -40,7 +170,3 @@ enum _DateRangeType {
}
}
}
extension on MapCoord {
LatLng toLatLng() => LatLng(latitude, longitude);
}

View file

@ -12,212 +12,60 @@ class _MapViewState extends State<_MapView> {
Widget build(BuildContext context) {
return MultiBlocListener(
listeners: [
_BlocListenerT<List<_DataPoint>>(
selector: (state) => state.data,
listener: (context, data) {
_clusterManager.setItems(data);
},
),
_BlocListenerT<MapCoord?>(
selector: (state) => state.initialPoint,
listener: (context, initialPoint) {
if (initialPoint != null) {
_mapController?.animateCamera(
CameraUpdate.newLatLngZoom(initialPoint.toLatLng(), 10));
_controller?.setPosition(initialPoint);
}
},
),
],
child: _BlocBuilder(
buildWhen: (previous, current) => previous.markers != current.markers,
builder: (context, state) => GoogleMap(
mapType: MapType.normal,
initialCameraPosition: context
.read<PrefController>()
.mapBrowserPrevPositionValue
?.let(
(p) => CameraPosition(target: p.toLatLng(), zoom: 10)) ??
const CameraPosition(target: LatLng(0, 0)),
markers: state.markers,
onMapCreated: (controller) {
_clusterManager.setMapId(controller.mapId);
_mapController = controller;
if (Theme.of(context).brightness == Brightness.dark) {
controller.setMapStyle(_mapStyleNight);
}
if (state.initialPoint != null) {
controller.animateCamera(CameraUpdate.newLatLngZoom(
state.initialPoint!.toLatLng(), 10));
}
},
onCameraMove: _clusterManager.onCameraMove,
onCameraIdle: _clusterManager.updateMap,
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top,
bottom: MediaQuery.of(context).padding.bottom,
),
),
buildWhen: (previous, current) => previous.data != current.data,
builder: (context, state) {
final prevPosition =
context.read<PrefController>().mapBrowserPrevPositionValue;
return InteractiveMap(
providerHint: GpsMapProvider.google,
initialPosition: prevPosition ?? const MapCoord(0, 0),
initialZoom: prevPosition == null ? 2.5 : 10,
dataPoints: state.data,
onClusterTap: (dataPoints) {
final c = Collection(
name: "",
contentProvider: CollectionAdHocProvider(
account: context.bloc.account,
fileIds: dataPoints
.cast<_DataPoint>()
.map((e) => e.fileId)
.toList(),
),
);
Navigator.of(context).pushNamed(
CollectionBrowser.routeName,
arguments: CollectionBrowserArguments(c),
);
},
googleClusterBuilder: (context, dataPoints) =>
_GoogleMarkerBuilder(context).build(dataPoints),
contentPadding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top,
bottom: MediaQuery.of(context).padding.bottom,
),
onMapCreated: (controller) {
_controller = controller;
if (state.initialPoint != null) {
controller.setPosition(state.initialPoint!);
}
},
);
},
),
);
}
Future<BitmapDescriptor> _getClusterBitmap(
int size, {
String? text,
required Color color,
}) async {
final PictureRecorder pictureRecorder = PictureRecorder();
final Canvas canvas = Canvas(pictureRecorder);
final fillPaint = Paint()..color = color;
final outlinePaint = Paint()
..color = Theme.of(context).brightness == Brightness.light
? Colors.black.withOpacity(.28)
: Colors.white.withOpacity(.6)
..strokeWidth = size / 28
..style = PaintingStyle.stroke;
const shadowPadding = 6.0;
const shadowPaddingHalf = shadowPadding / 2;
final shadowPath = Path()
..addOval(
Rect.fromLTWH(0, 0, size - shadowPadding, size - shadowPadding));
canvas.drawShadow(shadowPath, Colors.black, 1, false);
canvas.drawCircle(
Offset(size / 2 - shadowPaddingHalf, size / 2 - shadowPaddingHalf),
size / 2 - shadowPaddingHalf,
fillPaint,
);
canvas.drawCircle(
Offset(size / 2 - shadowPaddingHalf, size / 2 - shadowPaddingHalf),
size / 2 - shadowPaddingHalf - (size / 28 / 2),
outlinePaint,
);
if (text != null) {
TextPainter painter = TextPainter(textDirection: TextDirection.ltr);
painter.text = TextSpan(
text: text,
style: TextStyle(
fontSize: size / 3 - ((text.length / 6) * (size * 0.1)),
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
),
);
painter.layout();
painter.paint(
canvas,
Offset(
size / 2 - painter.width / 2 - shadowPaddingHalf,
size / 2 - painter.height / 2 - shadowPaddingHalf,
),
);
}
final img = await pictureRecorder.endRecording().toImage(size, size);
final data = await img.toByteData(format: ImageByteFormat.png) as ByteData;
return BitmapDescriptor.fromBytes(data.buffer.asUint8List());
}
String _getMarkerCountString(int count) {
switch (count) {
case >= 10000:
return "10000+";
case >= 1000:
return "${count ~/ 1000 * 1000}+";
case >= 100:
return "${count ~/ 100 * 100}+";
case >= 10:
return "${count ~/ 10 * 10}+";
default:
return count.toString();
}
}
Color _getMarkerColor(int count) {
const step = 1 / 4;
final double r;
switch (count) {
case >= 10000:
r = 1;
case >= 1000:
r = (count ~/ 1000) / 10 * step + step * 3;
case >= 100:
r = (count ~/ 100) / 10 * step + step * 2;
case >= 10:
r = (count ~/ 10) / 10 * step + step;
default:
r = (count / 10) * step;
}
if (Theme.of(context).brightness == Brightness.light) {
return HSLColor.fromAHSL(
1,
_colorHsl.hue,
r * .7 + .3,
(_colorHsl.lightness - (.1 - r * .1)).clamp(0, 1),
).toColor();
} else {
return HSLColor.fromAHSL(
1,
_colorHsl.hue,
r * .6 + .4,
(_colorHsl.lightness - (.1 - r * .1)).clamp(0, 1),
).toColor();
}
}
int _getMarkerSize(int count) {
const step = 1 / 4;
final double r;
switch (count) {
case >= 10000:
r = 1;
case >= 1000:
r = (count ~/ 1000) / 10 * step + step * 3;
case >= 100:
r = (count ~/ 100) / 10 * step + step * 2;
case >= 10:
r = (count ~/ 10) / 10 * step + step;
default:
r = (count / 10) * step;
}
return (r * 85).toInt() + 85;
}
late final _clusterManager = ClusterManager<_DataPoint>(
const [],
(markers) {
if (mounted) {
context.addEvent(_SetMarkers(markers));
}
},
markerBuilder: (cluster) async => Marker(
markerId: MarkerId(cluster.getId()),
position: cluster.location,
onTap: () {
final c = Collection(
name: "",
contentProvider: CollectionAdHocProvider(
account: context.bloc.account,
fileIds: cluster.items.map((e) => e.fileId).toList(),
),
);
Navigator.of(context).pushNamed(
CollectionBrowser.routeName,
arguments: CollectionBrowserArguments(c),
);
},
icon: await _getClusterBitmap(
_getMarkerSize(cluster.count * 1),
text: _getMarkerCountString(cluster.count * 1),
color: _getMarkerColor(cluster.count * 1),
),
),
);
GoogleMapController? _mapController;
late final _colorHsl =
HSLColor.fromColor(Theme.of(context).colorScheme.primaryContainer);
InteractiveMapController? _controller;
}
class _PanelContainer extends StatefulWidget {
@ -430,7 +278,7 @@ class _DateFieldState extends State<_DateField> {
onTap: () async {
final result = await showDatePicker(
context: context,
firstDate: DateTime(1970),
firstDate: DateTime.fromMillisecondsSinceEpoch(0),
lastDate: clock.now(),
currentDate: widget.date.toLocalDateTime(),
);
@ -458,7 +306,3 @@ class _DateFieldState extends State<_DateField> {
late final _controller = TextEditingController(text: _stringify(widget.date));
}
// Generated in https://mapstyle.withgoogle.com/
const _mapStyleNight =
'[{"elementType":"geometry","stylers":[{"color":"#242f3e"}]},{"elementType":"labels.text.fill","stylers":[{"color":"#746855"}]},{"elementType":"labels.text.stroke","stylers":[{"color":"#242f3e"}]},{"featureType":"administrative.locality","elementType":"labels.text.fill","stylers":[{"color":"#d59563"}]},{"featureType":"poi","elementType":"labels.text.fill","stylers":[{"color":"#d59563"}]},{"featureType":"poi.park","elementType":"geometry","stylers":[{"color":"#263c3f"}]},{"featureType":"poi.park","elementType":"labels.text.fill","stylers":[{"color":"#6b9a76"}]},{"featureType":"road","elementType":"geometry","stylers":[{"color":"#38414e"}]},{"featureType":"road","elementType":"geometry.stroke","stylers":[{"color":"#212a37"}]},{"featureType":"road","elementType":"labels.text.fill","stylers":[{"color":"#9ca5b3"}]},{"featureType":"road.highway","elementType":"geometry","stylers":[{"color":"#746855"}]},{"featureType":"road.highway","elementType":"geometry.stroke","stylers":[{"color":"#1f2835"}]},{"featureType":"road.highway","elementType":"labels.text.fill","stylers":[{"color":"#f3d19c"}]},{"featureType":"transit","elementType":"geometry","stylers":[{"color":"#2f3948"}]},{"featureType":"transit.station","elementType":"labels.text.fill","stylers":[{"color":"#d59563"}]},{"featureType":"water","elementType":"geometry","stylers":[{"color":"#17263c"}]},{"featureType":"water","elementType":"labels.text.fill","stylers":[{"color":"#515c6d"}]},{"featureType":"water","elementType":"labels.text.stroke","stylers":[{"color":"#17263c"}]}]';

View file

@ -661,7 +661,7 @@ packages:
source: hosted
version: "6.3.0"
google_maps_cluster_manager:
dependency: "direct main"
dependency: transitive
description:
name: google_maps_cluster_manager
sha256: "36e9a4b2d831c470fc85d692a6c9cec70e0f385d578b9697de5f4de347561b83"

View file

@ -159,7 +159,6 @@ dependencies:
woozy_search: ^2.0.3
google_maps_flutter: 2.5.3
google_maps_cluster_manager: 3.1.0
dependency_overrides:
video_player:
@ -177,6 +176,7 @@ dependency_overrides:
url: https://gitlab.com/nc-photos/flutter-plugins
ref: video_player-v2.8.6-nc-photos-2
path: packages/video_player/video_player_platform_interface
# fix google_maps_cluster_manager
google_maps_flutter_android: 2.7.0
google_maps_flutter_platform_interface: 2.5.0

View file

@ -1,4 +1,6 @@
library np_gps_map;
export 'src/gps_map.dart';
export 'src/interactive_map.dart';
export 'src/map_coord.dart';
export 'src/util.dart' show initGpsMap;

View file

@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:np_gps_map/src/map_coord.dart';
import 'package:np_gps_map/src/native.dart';
import 'package:np_gps_map/src/native/google_gps_map.dart'
if (dart.library.html) 'package:np_gps_map/src/web/google_gps_map.dart';
import 'package:np_gps_map/src/osm_gps_map.dart';
import 'package:np_gps_map/src/util.dart';
import 'package:np_platform_util/np_platform_util.dart';
enum GpsMapProvider {
@ -21,16 +21,10 @@ class GpsMap extends StatelessWidget {
this.onTap,
});
static void init() {
if (getRawPlatform() == NpPlatform.android) {
Native.isNewGMapsRenderer().then((value) => _isNewGMapsRenderer = value);
}
}
@override
Widget build(BuildContext context) {
if (providerHint == GpsMapProvider.osm ||
(getRawPlatform() == NpPlatform.android && !_isNewGMapsRenderer)) {
(getRawPlatform() == NpPlatform.android && !isNewGMapsRenderer())) {
return OsmGpsMap(
center: center,
zoom: zoom,
@ -53,6 +47,4 @@ class GpsMap extends StatelessWidget {
final MapCoord center;
final double zoom;
final void Function()? onTap;
static bool _isNewGMapsRenderer = false;
}

View file

@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:np_gps_map/src/gps_map.dart';
import 'package:np_gps_map/src/interactive_map/google.dart';
import 'package:np_gps_map/src/map_coord.dart';
import 'package:np_gps_map/src/util.dart';
import 'package:np_platform_util/np_platform_util.dart';
// Client may extend this class to add custom data
class DataPoint {
const DataPoint({
required this.position,
});
final MapCoord position;
}
abstract class InteractiveMapController {
void setPosition(MapCoord position);
}
class InteractiveMap extends StatelessWidget {
const InteractiveMap({
super.key,
required this.providerHint,
this.initialPosition,
this.initialZoom,
this.dataPoints,
this.onClusterTap,
this.googleClusterBuilder,
this.contentPadding,
this.onMapCreated,
});
@override
Widget build(BuildContext context) {
if (providerHint == GpsMapProvider.osm ||
(getRawPlatform() == NpPlatform.android && !isNewGMapsRenderer())) {
return const SizedBox.shrink();
} else {
return GoogleInteractiveMap(
initialPosition: initialPosition,
initialZoom: initialZoom,
dataPoints: dataPoints,
onClusterTap: onClusterTap,
clusterBuilder: googleClusterBuilder,
contentPadding: contentPadding,
onMapCreated: onMapCreated,
);
}
}
/// The backend to provide the actual map. This works as a hint only, the
/// actual choice may be different depending on the runtime environment
final GpsMapProvider providerHint;
final MapCoord? initialPosition;
final double? initialZoom;
final List<DataPoint>? dataPoints;
final void Function(List<DataPoint> dataPoints)? onClusterTap;
final GoogleClusterBuilder? googleClusterBuilder;
final EdgeInsets? contentPadding;
final void Function(InteractiveMapController controller)? onMapCreated;
}

View file

@ -0,0 +1,137 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:google_maps_cluster_manager/google_maps_cluster_manager.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:np_common/object_util.dart';
import 'package:np_gps_map/src/interactive_map.dart';
import 'package:np_gps_map/src/map_coord.dart';
typedef GoogleClusterBuilder = FutureOr<BitmapDescriptor> Function(
BuildContext context, List<DataPoint> dataPoints);
class GoogleInteractiveMap extends StatefulWidget {
const GoogleInteractiveMap({
super.key,
this.initialPosition,
this.initialZoom,
this.dataPoints,
this.clusterBuilder,
this.onClusterTap,
this.contentPadding,
this.onMapCreated,
});
@override
State<StatefulWidget> createState() => _GoogleInteractiveMapState();
final MapCoord? initialPosition;
final double? initialZoom;
final List<DataPoint>? dataPoints;
final GoogleClusterBuilder? clusterBuilder;
final void Function(List<DataPoint> dataPoints)? onClusterTap;
final EdgeInsets? contentPadding;
final void Function(InteractiveMapController controller)? onMapCreated;
}
class _GoogleInteractiveMapState extends State<GoogleInteractiveMap> {
@override
void didUpdateWidget(covariant GoogleInteractiveMap oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.dataPoints != oldWidget.dataPoints) {
_clusterManager.setItems(widget.dataPoints
?.map((e) => _GoogleDataPoint(original: e))
.toList() ??
[]);
}
}
@override
Widget build(BuildContext context) {
return GoogleMap(
mapType: MapType.normal,
initialCameraPosition: widget.initialPosition?.let((e) => CameraPosition(
target: widget.initialPosition!.toLatLng(),
zoom: widget.initialZoom ?? 10,
)) ??
const CameraPosition(target: LatLng(0, 0)),
markers: _markers,
onMapCreated: _onMapCreated,
onCameraMove: _clusterManager.onCameraMove,
onCameraIdle: _clusterManager.updateMap,
padding: widget.contentPadding ?? EdgeInsets.zero,
);
}
void _onMapCreated(GoogleMapController controller) {
_parentController = _ParentController(controller);
widget.onMapCreated?.call(_parentController!);
_clusterManager.setMapId(controller.mapId);
if (Theme.of(context).brightness == Brightness.dark) {
controller.setMapStyle(_mapStyleNight);
}
}
late final _clusterManager = ClusterManager<_GoogleDataPoint>(
const [],
(markers) {
if (mounted) {
setState(() {
_markers = markers;
});
}
},
markerBuilder: (cluster) async {
final dataPoints = cluster.items.map((e) => e.original).toList();
return Marker(
markerId: MarkerId(cluster.getId()),
position: cluster.location,
onTap: () {
widget.onClusterTap?.call(dataPoints);
},
icon: await widget.clusterBuilder?.call(context, dataPoints) ??
BitmapDescriptor.defaultMarker,
);
},
);
_ParentController? _parentController;
var _markers = <Marker>{};
}
class _GoogleDataPoint implements ClusterItem {
const _GoogleDataPoint({
required this.original,
});
@override
LatLng get location => original.position.toLatLng();
@override
String get geohash =>
Geohash.encode(location, codeLength: ClusterManager.precision);
final DataPoint original;
}
class _ParentController implements InteractiveMapController {
const _ParentController(this.controller);
@override
void setPosition(MapCoord position) {
controller
.animateCamera(CameraUpdate.newLatLngZoom(position.toLatLng(), 10));
}
final GoogleMapController controller;
}
extension on MapCoord {
LatLng toLatLng() => LatLng(latitude, longitude);
}
// Generated by https://mapstyle.withgoogle.com/
const _mapStyleNight =
'[{"elementType":"geometry","stylers":[{"color":"#242f3e"}]},{"elementType":"labels.text.fill","stylers":[{"color":"#746855"}]},{"elementType":"labels.text.stroke","stylers":[{"color":"#242f3e"}]},{"featureType":"administrative.locality","elementType":"labels.text.fill","stylers":[{"color":"#d59563"}]},{"featureType":"poi","elementType":"labels.text.fill","stylers":[{"color":"#d59563"}]},{"featureType":"poi.park","elementType":"geometry","stylers":[{"color":"#263c3f"}]},{"featureType":"poi.park","elementType":"labels.text.fill","stylers":[{"color":"#6b9a76"}]},{"featureType":"road","elementType":"geometry","stylers":[{"color":"#38414e"}]},{"featureType":"road","elementType":"geometry.stroke","stylers":[{"color":"#212a37"}]},{"featureType":"road","elementType":"labels.text.fill","stylers":[{"color":"#9ca5b3"}]},{"featureType":"road.highway","elementType":"geometry","stylers":[{"color":"#746855"}]},{"featureType":"road.highway","elementType":"geometry.stroke","stylers":[{"color":"#1f2835"}]},{"featureType":"road.highway","elementType":"labels.text.fill","stylers":[{"color":"#f3d19c"}]},{"featureType":"transit","elementType":"geometry","stylers":[{"color":"#2f3948"}]},{"featureType":"transit.station","elementType":"labels.text.fill","stylers":[{"color":"#d59563"}]},{"featureType":"water","elementType":"geometry","stylers":[{"color":"#17263c"}]},{"featureType":"water","elementType":"labels.text.fill","stylers":[{"color":"#515c6d"}]},{"featureType":"water","elementType":"labels.text.stroke","stylers":[{"color":"#17263c"}]}]';

View file

@ -0,0 +1,12 @@
import 'package:np_gps_map/src/native.dart';
import 'package:np_platform_util/np_platform_util.dart';
void initGpsMap() {
if (getRawPlatform() == NpPlatform.android) {
Native.isNewGMapsRenderer().then((value) => _isNewGMapsRenderer = value);
}
}
bool isNewGMapsRenderer() => _isNewGMapsRenderer;
var _isNewGMapsRenderer = false;

View file

@ -12,14 +12,21 @@ dependencies:
flutter:
sdk: flutter
flutter_map: ^6.1.0
google_maps_flutter: ^2.2.8
google_maps_flutter: 2.5.3
google_maps_cluster_manager: 3.1.0
latlong2: any
np_async:
path: ../np_async
np_common:
path: ../np_common
np_platform_util:
path: ../np_platform_util
url_launcher: ^6.1.11
dependency_overrides:
google_maps_flutter_android: 2.7.0
google_maps_flutter_platform_interface: 2.5.0
dev_dependencies:
np_lints:
path: ../np_lints