diff --git a/app/lib/app_init.dart b/app/lib/app_init.dart index c4db8b0f..29a4e6c7 100644 --- a/app/lib/app_init.dart +++ b/app/lib/app_init.dart @@ -75,7 +75,7 @@ Future init(InitIsolateType isolateType) async { } await _initDiContainer(isolateType); _initVisibilityDetector(); - GpsMap.init(); + initGpsMap(); // init session storage SessionStorage(); diff --git a/app/lib/controller/pref_controller.dart b/app/lib/controller/pref_controller.dart index 4bab89a1..e34a315f 100644 --- a/app/lib/controller/pref_controller.dart +++ b/app/lib/controller/pref_controller.dart @@ -161,10 +161,12 @@ class PrefController { value: value, ); - Future setMapBrowserPrevPosition(MapCoord value) => _set( + Future setMapBrowserPrevPosition(MapCoord? value) => + _setOrRemove( 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 diff --git a/app/lib/controller/pref_controller/util.dart b/app/lib/controller/pref_controller/util.dart index 64f6d1d9..2ca3368d 100644 --- a/app/lib/controller/pref_controller/util.dart +++ b/app/lib/controller/pref_controller/util.dart @@ -91,8 +91,13 @@ extension on Pref { String? getMapBrowserPrevPosition() => provider.getString(PrefKey.mapBrowserPrevPosition); - Future setMapBrowserPrevPosition(String value) => - provider.setString(PrefKey.mapBrowserPrevPosition, value); + Future setMapBrowserPrevPosition(String? value) { + if (value == null) { + return provider.remove(PrefKey.mapBrowserPrevPosition); + } else { + return provider.setString(PrefKey.mapBrowserPrevPosition, value); + } + } } MapCoord? _tryMapCoordFromJson(dynamic json) { diff --git a/app/lib/stream_extension.dart b/app/lib/stream_extension.dart index c4f5e094..1738b36b 100644 --- a/app/lib/stream_extension.dart +++ b/app/lib/stream_extension.dart @@ -28,4 +28,10 @@ extension StreamExtension on Stream { Stream distinctBy(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 distinctByIgnoreFirst(U Function(T element) keyOf) => + distinct((previous, next) => keyOf(previous) == keyOf(next)).skip(1); } diff --git a/app/lib/widget/map_browser.dart b/app/lib/widget/map_browser.dart index 423f11d0..4b169717 100644 --- a/app/lib/widget/map_browser.dart +++ b/app/lib/widget/map_browser.dart @@ -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'; diff --git a/app/lib/widget/map_browser.g.dart b/app/lib/widget/map_browser.g.dart index e4eee5b4..fe4e9a06 100644 --- a/app/lib/widget/map_browser.g.dart +++ b/app/lib/widget/map_browser.g.dart @@ -16,7 +16,6 @@ abstract class $_StateCopyWithWorker { _State call( {List<_DataPoint>? data, MapCoord? initialPoint, - Set? 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? ?? 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 diff --git a/app/lib/widget/map_browser/bloc.dart b/app/lib/widget/map_browser/bloc.dart index 52b46194..c1d55b42 100644 --- a/app/lib/widget/map_browser/bloc.dart +++ b/app/lib/widget/map_browser/bloc.dart @@ -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); diff --git a/app/lib/widget/map_browser/state_event.dart b/app/lib/widget/map_browser/state_event.dart index e90efac6..f9912cb2 100644 --- a/app/lib/widget/map_browser/state_event.dart +++ b/app/lib/widget/map_browser/state_event.dart @@ -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 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 markers; -} - @toString class _OpenDataRangeControlPanel implements _Event { const _OpenDataRangeControlPanel(); diff --git a/app/lib/widget/map_browser/type.dart b/app/lib/widget/map_browser/type.dart index 6b760c9e..58d607e5 100644 --- a/app/lib/widget/map_browser/type.dart +++ b/app/lib/widget/map_browser/type.dart @@ -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 build(List 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 _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); -} diff --git a/app/lib/widget/map_browser/view.dart b/app/lib/widget/map_browser/view.dart index cc852564..ec70b892 100644 --- a/app/lib/widget/map_browser/view.dart +++ b/app/lib/widget/map_browser/view.dart @@ -12,212 +12,60 @@ class _MapViewState extends State<_MapView> { Widget build(BuildContext context) { return MultiBlocListener( listeners: [ - _BlocListenerT>( - selector: (state) => state.data, - listener: (context, data) { - _clusterManager.setItems(data); - }, - ), _BlocListenerT( 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() - .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().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 _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"}]}]'; diff --git a/app/pubspec.lock b/app/pubspec.lock index 940429e6..90cbb32c 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -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" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 3543b785..6e2cbc5e 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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 diff --git a/np_gps_map/lib/np_gps_map.dart b/np_gps_map/lib/np_gps_map.dart index 556e4d6b..11e5edf1 100644 --- a/np_gps_map/lib/np_gps_map.dart +++ b/np_gps_map/lib/np_gps_map.dart @@ -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; diff --git a/np_gps_map/lib/src/gps_map.dart b/np_gps_map/lib/src/gps_map.dart index f6fff5ea..fa6f7feb 100644 --- a/np_gps_map/lib/src/gps_map.dart +++ b/np_gps_map/lib/src/gps_map.dart @@ -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; } diff --git a/np_gps_map/lib/src/interactive_map.dart b/np_gps_map/lib/src/interactive_map.dart new file mode 100644 index 00000000..d5fc75ac --- /dev/null +++ b/np_gps_map/lib/src/interactive_map.dart @@ -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? dataPoints; + final void Function(List dataPoints)? onClusterTap; + final GoogleClusterBuilder? googleClusterBuilder; + final EdgeInsets? contentPadding; + final void Function(InteractiveMapController controller)? onMapCreated; +} diff --git a/np_gps_map/lib/src/interactive_map/google.dart b/np_gps_map/lib/src/interactive_map/google.dart new file mode 100644 index 00000000..9da9b182 --- /dev/null +++ b/np_gps_map/lib/src/interactive_map/google.dart @@ -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 Function( + BuildContext context, List 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 createState() => _GoogleInteractiveMapState(); + + final MapCoord? initialPosition; + final double? initialZoom; + final List? dataPoints; + final GoogleClusterBuilder? clusterBuilder; + final void Function(List dataPoints)? onClusterTap; + final EdgeInsets? contentPadding; + final void Function(InteractiveMapController controller)? onMapCreated; +} + +class _GoogleInteractiveMapState extends State { + @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 = {}; +} + +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"}]}]'; diff --git a/np_gps_map/lib/src/util.dart b/np_gps_map/lib/src/util.dart new file mode 100644 index 00000000..641e0190 --- /dev/null +++ b/np_gps_map/lib/src/util.dart @@ -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; diff --git a/np_gps_map/pubspec.yaml b/np_gps_map/pubspec.yaml index a6d24b89..3cfcfec5 100644 --- a/np_gps_map/pubspec.yaml +++ b/np_gps_map/pubspec.yaml @@ -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