diff --git a/android/src/main/java/com/rngooglemapsplus/GoogleMapsViewImpl.kt b/android/src/main/java/com/rngooglemapsplus/GoogleMapsViewImpl.kt index 1e859b6..21790d5 100644 --- a/android/src/main/java/com/rngooglemapsplus/GoogleMapsViewImpl.kt +++ b/android/src/main/java/com/rngooglemapsplus/GoogleMapsViewImpl.kt @@ -2,7 +2,6 @@ package com.rngooglemapsplus import android.annotation.SuppressLint import android.graphics.Bitmap -import android.location.Location import android.util.Base64 import android.util.Size import android.widget.FrameLayout @@ -11,7 +10,6 @@ import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.uimanager.PixelUtil.dpToPx import com.facebook.react.uimanager.ThemedReactContext -import com.google.android.gms.common.ConnectionResult import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.GoogleMapOptions @@ -39,7 +37,11 @@ import com.rngooglemapsplus.extensions.toLatLng import com.rngooglemapsplus.extensions.toLocationErrorCode import com.rngooglemapsplus.extensions.toRNIndoorBuilding import com.rngooglemapsplus.extensions.toRNIndoorLevel +import com.rngooglemapsplus.extensions.toRNMapErrorCodeOrNull +import com.rngooglemapsplus.extensions.toRnCamera import com.rngooglemapsplus.extensions.toRnLatLng +import com.rngooglemapsplus.extensions.toRnLocation +import com.rngooglemapsplus.extensions.toRnRegion import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File @@ -65,6 +67,7 @@ class GoogleMapsViewImpl( LifecycleEventListener { private var initialized = false private var mapReady = false + private var destroyed = false private var googleMap: GoogleMap? = null private var mapView: MapView? = null @@ -83,7 +86,6 @@ class GoogleMapsViewImpl( private val kmlLayersById = mutableMapOf() private var cameraMoveReason = -1 - private var lastSubmittedLocation: Location? = null private var lastSubmittedCameraPosition: CameraPosition? = null init { @@ -94,31 +96,16 @@ class GoogleMapsViewImpl( if (initialized) return initialized = true val result = playServiceHandler.playServicesAvailability() + val errorCode = result.toRNMapErrorCodeOrNull() - when (result) { - ConnectionResult.SERVICE_MISSING -> { - onMapError?.invoke(RNMapErrorCode.PLAY_SERVICES_MISSING) - return - } + if (errorCode != null) { + onMapError?.invoke(errorCode) - ConnectionResult.SERVICE_INVALID -> { - onMapError?.invoke(RNMapErrorCode.PLAY_SERVICES_INVALID) + if (errorCode == RNMapErrorCode.PLAY_SERVICES_MISSING || + errorCode == RNMapErrorCode.PLAY_SERVICES_INVALID + ) { return } - - ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED -> - onMapError?.invoke(RNMapErrorCode.PLAY_SERVICES_OUTDATED) - - ConnectionResult.SERVICE_UPDATING -> - onMapError?.invoke(RNMapErrorCode.PLAY_SERVICE_UPDATING) - - ConnectionResult.SERVICE_DISABLED -> - onMapError?.invoke(RNMapErrorCode.PLAY_SERVICES_DISABLED) - - ConnectionResult.SUCCESS -> {} - - else -> - onMapError?.invoke(RNMapErrorCode.UNKNOWN) } mapView = @@ -143,8 +130,8 @@ class GoogleMapsViewImpl( googleMap?.setOnMapClickListener(this@GoogleMapsViewImpl) googleMap?.setOnMarkerDragListener(this@GoogleMapsViewImpl) } + applyProps() initLocationCallbacks() - applyPending() mapReady = true onMapReady?.invoke(true) } @@ -160,21 +147,9 @@ class GoogleMapsViewImpl( } val isGesture = GoogleMap.OnCameraMoveStartedListener.REASON_GESTURE == reason - val latDelta = bounds.northeast.latitude - bounds.southwest.latitude - val lngDelta = bounds.northeast.longitude - bounds.southwest.longitude - onCameraChangeStart?.invoke( - RNRegion( - center = bounds.center.toRnLatLng(), - latitudeDelta = latDelta, - longitudeDelta = lngDelta, - ), - RNCamera( - center = cameraPosition.target.toRnLatLng(), - zoom = cameraPosition.zoom.toDouble(), - bearing = cameraPosition.bearing.toDouble(), - tilt = cameraPosition.tilt.toDouble(), - ), + bounds.toRnRegion(), + cameraPosition.toRnCamera(), isGesture, ) } @@ -192,21 +167,9 @@ class GoogleMapsViewImpl( val isGesture = GoogleMap.OnCameraMoveStartedListener.REASON_GESTURE == cameraMoveReason - val latDelta = bounds.northeast.latitude - bounds.southwest.latitude - val lngDelta = bounds.northeast.longitude - bounds.southwest.longitude - - onCameraChange?.invoke( - RNRegion( - center = bounds.center.toRnLatLng(), - latitudeDelta = latDelta, - longitudeDelta = lngDelta, - ), - RNCamera( - center = cameraPosition.target.toRnLatLng(), - zoom = cameraPosition.zoom.toDouble(), - bearing = cameraPosition.bearing.toDouble(), - tilt = cameraPosition.tilt.toDouble(), - ), + onCameraChangeStart?.invoke( + bounds.toRnRegion(), + cameraPosition.toRnCamera(), isGesture, ) } @@ -220,39 +183,16 @@ class GoogleMapsViewImpl( } val isGesture = GoogleMap.OnCameraMoveStartedListener.REASON_GESTURE == cameraMoveReason - val latDelta = bounds.northeast.latitude - bounds.southwest.latitude - val lngDelta = bounds.northeast.longitude - bounds.southwest.longitude - - onCameraChangeComplete?.invoke( - RNRegion( - center = bounds.center.toRnLatLng(), - latitudeDelta = latDelta, - longitudeDelta = lngDelta, - ), - RNCamera( - center = cameraPosition.target.toRnLatLng(), - zoom = cameraPosition.zoom.toDouble(), - bearing = cameraPosition.bearing.toDouble(), - tilt = cameraPosition.tilt.toDouble(), - ), + onCameraChangeStart?.invoke( + bounds.toRnRegion(), + cameraPosition.toRnCamera(), isGesture, ) } fun initLocationCallbacks() { locationHandler.onUpdate = { location -> - // / only the coordinated are relevant right now - if (lastSubmittedLocation?.latitude != location.latitude || lastSubmittedLocation?.longitude != location.longitude || - lastSubmittedLocation?.bearing != location.bearing - ) { - onLocationUpdate?.invoke( - RNLocation( - RNLatLng(location.latitude, location.longitude), - location.bearing.toDouble(), - ), - ) - } - lastSubmittedLocation = location + onLocationUpdate?.invoke(location.toRnLocation()) } locationHandler.onError = { error -> @@ -261,65 +201,18 @@ class GoogleMapsViewImpl( locationHandler.start() } - fun applyPending() { - onUi { - mapPadding?.let { - googleMap?.setPadding( - it.left.dpToPx().toInt(), - it.top.dpToPx().toInt(), - it.right.dpToPx().toInt(), - it.bottom.dpToPx().toInt(), - ) - } - - uiSettings?.let { v -> - googleMap?.uiSettings?.apply { - v.allGesturesEnabled?.let { setAllGesturesEnabled(it) } - v.compassEnabled?.let { isCompassEnabled = it } - v.indoorLevelPickerEnabled?.let { isIndoorLevelPickerEnabled = it } - v.mapToolbarEnabled?.let { isMapToolbarEnabled = it } - v.myLocationButtonEnabled?.let { - googleMap?.setLocationSource(locationHandler) - isMyLocationButtonEnabled = it - } - v.rotateEnabled?.let { isRotateGesturesEnabled = it } - v.scrollEnabled?.let { isScrollGesturesEnabled = it } - v.scrollDuringRotateOrZoomEnabled?.let { - isScrollGesturesEnabledDuringRotateOrZoom = it - } - v.tiltEnabled?.let { isTiltGesturesEnabled = it } - v.zoomControlsEnabled?.let { isZoomControlsEnabled = it } - v.zoomGesturesEnabled?.let { isZoomGesturesEnabled = it } - } - } - - buildingEnabled?.let { - googleMap?.isBuildingsEnabled = it - } - trafficEnabled?.let { - googleMap?.isTrafficEnabled = it - } - indoorEnabled?.let { - googleMap?.isIndoorEnabled = it - } - googleMap?.setMapStyle(customMapStyle) - mapType?.let { - googleMap?.mapType = it - } - userInterfaceStyle?.let { - googleMap?.mapColorScheme = it - } - mapZoomConfig?.let { - googleMap?.setMinZoomPreference(it.min?.toFloat() ?: 2.0f) - googleMap?.setMaxZoomPreference(it.max?.toFloat() ?: 21.0f) - } - } - - locationConfig?.let { - locationHandler.priority = it.android?.priority?.toGooglePriority() - locationHandler.interval = it.android?.interval?.toLong() - locationHandler.minUpdateInterval = it.android?.minUpdateInterval?.toLong() - } + fun applyProps() { + mapPadding = mapPadding + uiSettings = uiSettings + myLocationEnabled = myLocationEnabled + buildingEnabled = buildingEnabled + trafficEnabled = trafficEnabled + indoorEnabled = indoorEnabled + customMapStyle = customMapStyle + mapType = mapType + userInterfaceStyle = userInterfaceStyle + mapZoomConfig = mapZoomConfig + locationConfig = locationConfig if (pendingMarkers.isNotEmpty()) { pendingMarkers.forEach { (id, opts) -> @@ -480,9 +373,11 @@ class GoogleMapsViewImpl( var locationConfig: RNLocationConfig? = null set(value) { field = value - locationHandler.priority = value?.android?.priority?.toGooglePriority() - locationHandler.interval = value?.android?.interval?.toLong() - locationHandler.minUpdateInterval = value?.android?.minUpdateInterval?.toLong() + locationHandler.updateConfig( + value?.android?.priority?.toGooglePriority(), + value?.android?.interval?.toLong(), + value?.android?.minUpdateInterval?.toLong(), + ) } var onMapError: ((RNMapErrorCode) -> Unit)? = null @@ -919,7 +814,7 @@ class GoogleMapsViewImpl( onUi { heatmapsById.values.forEach { it.remove() } } - circlesById.clear() + heatmapsById.clear() pendingHeatmaps.clear() } @@ -968,6 +863,8 @@ class GoogleMapsViewImpl( } fun destroyInternal() { + if (destroyed) return + destroyed = true onUi { locationHandler.stop() markerBuilder.cancelAllJobs() diff --git a/android/src/main/java/com/rngooglemapsplus/LocationHandler.kt b/android/src/main/java/com/rngooglemapsplus/LocationHandler.kt index c03c8b5..62bc806 100644 --- a/android/src/main/java/com/rngooglemapsplus/LocationHandler.kt +++ b/android/src/main/java/com/rngooglemapsplus/LocationHandler.kt @@ -35,30 +35,28 @@ class LocationHandler( private var listener: LocationSource.OnLocationChangedListener? = null private var locationRequest: LocationRequest? = null private var locationCallback: LocationCallback? = null - - var priority: Int? = PRIORITY_DEFAULT - set(value) { - field = value ?: PRIORITY_DEFAULT - start() - } - - var interval: Long? = INTERVAL_DEFAULT - set(value) { - field = value ?: INTERVAL_DEFAULT - buildLocationRequest() - } - - var minUpdateInterval: Long? = MIN_UPDATE_INTERVAL - set(value) { - field = value ?: MIN_UPDATE_INTERVAL - buildLocationRequest() - } + private var priority: Int = PRIORITY_DEFAULT + private var interval: Long = INTERVAL_DEFAULT + private var minUpdateInterval: Long = MIN_UPDATE_INTERVAL + private var lastSubmittedLocation: Location? = null + private var isActive = false var onUpdate: ((Location) -> Unit)? = null var onError: ((RNLocationErrorCode) -> Unit)? = null init { - buildLocationRequest() + buildLocationRequest(priority, interval, minUpdateInterval) + } + + fun updateConfig( + priority: Int? = null, + interval: Long? = null, + minUpdateInterval: Long? = null, + ) { + this.priority = priority ?: PRIORITY_DEFAULT + this.interval = interval ?: INTERVAL_DEFAULT + this.minUpdateInterval = minUpdateInterval ?: MIN_UPDATE_INTERVAL + buildLocationRequest(this.priority, this.interval, this.minUpdateInterval) } fun showLocationDialog() { @@ -108,11 +106,11 @@ class LocationHandler( } @Suppress("deprecation") - private fun buildLocationRequest() { - val priority = priority ?: Priority.PRIORITY_BALANCED_POWER_ACCURACY - val interval = interval ?: INTERVAL_DEFAULT - val minUpdateInterval = minUpdateInterval ?: MIN_UPDATE_INTERVAL - + private fun buildLocationRequest( + priority: Int, + interval: Long, + minUpdateInterval: Long, + ) { locationRequest = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { LocationRequest @@ -126,11 +124,13 @@ class LocationHandler( .setInterval(interval) .setFastestInterval(minUpdateInterval) } - restartLocationUpdates() } - private fun restartLocationUpdates() { - stop() + @SuppressLint("MissingPermission") + fun start() { + if (isActive) return + isActive = true + val playServicesStatus = GoogleApiAvailability .getInstance() @@ -139,17 +139,13 @@ class LocationHandler( onError?.invoke(RNLocationErrorCode.PLAY_SERVICE_NOT_AVAILABLE) return } - start() - } - - @SuppressLint("MissingPermission") - fun start() { try { fusedLocationClientProviderClient.lastLocation .addOnSuccessListener( OnSuccessListener { location -> - if (location != null) { + if (location != null && location != lastSubmittedLocation) { onUpdate?.invoke(location) + lastSubmittedLocation = location } }, ).addOnFailureListener { e -> @@ -161,8 +157,11 @@ class LocationHandler( override fun onLocationResult(locationResult: LocationResult) { val location = locationResult.lastLocation if (location != null) { - listener?.onLocationChanged(location) - onUpdate?.invoke(location) + if (location != lastSubmittedLocation) { + lastSubmittedLocation = location + listener?.onLocationChanged(location) + onUpdate?.invoke(location) + } } else { onError?.invoke(RNLocationErrorCode.POSITION_UNAVAILABLE) } @@ -186,9 +185,11 @@ class LocationHandler( } fun stop() { - listener = null + if (!isActive) return + isActive = false if (locationCallback != null) { fusedLocationClientProviderClient.removeLocationUpdates(locationCallback!!) + fusedLocationClientProviderClient.flushLocations() locationCallback = null } } @@ -199,6 +200,7 @@ class LocationHandler( } override fun deactivate() { + listener = null stop() } } diff --git a/android/src/main/java/com/rngooglemapsplus/extensions/CameraPositionExtension.kt b/android/src/main/java/com/rngooglemapsplus/extensions/CameraPositionExtension.kt new file mode 100644 index 0000000..529b80d --- /dev/null +++ b/android/src/main/java/com/rngooglemapsplus/extensions/CameraPositionExtension.kt @@ -0,0 +1,12 @@ +package com.rngooglemapsplus.extensions + +import com.google.android.gms.maps.model.CameraPosition +import com.rngooglemapsplus.RNCamera + +fun CameraPosition.toRnCamera(): RNCamera = + RNCamera( + center = target.toRnLatLng(), + zoom = zoom.toDouble(), + bearing = bearing.toDouble(), + tilt = tilt.toDouble(), + ) diff --git a/android/src/main/java/com/rngooglemapsplus/extensions/IntExtension.kt b/android/src/main/java/com/rngooglemapsplus/extensions/IntExtension.kt new file mode 100644 index 0000000..edff5d8 --- /dev/null +++ b/android/src/main/java/com/rngooglemapsplus/extensions/IntExtension.kt @@ -0,0 +1,28 @@ +package com.rngooglemapsplus.extensions + +import com.google.android.gms.common.ConnectionResult +import com.rngooglemapsplus.RNMapErrorCode + +fun Int.toRNMapErrorCodeOrNull(): RNMapErrorCode? = + when (this) { + ConnectionResult.SERVICE_MISSING -> + RNMapErrorCode.PLAY_SERVICES_MISSING + + ConnectionResult.SERVICE_INVALID -> + RNMapErrorCode.PLAY_SERVICES_INVALID + + ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED -> + RNMapErrorCode.PLAY_SERVICES_OUTDATED + + ConnectionResult.SERVICE_UPDATING -> + RNMapErrorCode.PLAY_SERVICE_UPDATING + + ConnectionResult.SERVICE_DISABLED -> + RNMapErrorCode.PLAY_SERVICES_DISABLED + + ConnectionResult.SUCCESS -> + null + + else -> + RNMapErrorCode.UNKNOWN + } diff --git a/android/src/main/java/com/rngooglemapsplus/extensions/LatLngBounds.kt b/android/src/main/java/com/rngooglemapsplus/extensions/LatLngBounds.kt new file mode 100644 index 0000000..a178c79 --- /dev/null +++ b/android/src/main/java/com/rngooglemapsplus/extensions/LatLngBounds.kt @@ -0,0 +1,15 @@ +package com.rngooglemapsplus.extensions + +import com.google.android.gms.maps.model.LatLngBounds +import com.rngooglemapsplus.RNRegion + +fun LatLngBounds.toRnRegion(): RNRegion { + val latDelta = northeast.latitude - southwest.latitude + val lngDelta = northeast.longitude - southwest.longitude + + return RNRegion( + center = center.toRnLatLng(), + latitudeDelta = latDelta, + longitudeDelta = lngDelta, + ) +} diff --git a/android/src/main/java/com/rngooglemapsplus/extensions/LocationExtension.kt b/android/src/main/java/com/rngooglemapsplus/extensions/LocationExtension.kt new file mode 100644 index 0000000..d1ee26e --- /dev/null +++ b/android/src/main/java/com/rngooglemapsplus/extensions/LocationExtension.kt @@ -0,0 +1,59 @@ +package com.rngooglemapsplus.extensions + +import android.location.Location +import android.os.Build +import com.rngooglemapsplus.RNLatLng +import com.rngooglemapsplus.RNLocation +import com.rngooglemapsplus.RNLocationAndroid + +fun Location.toRnLocation(): RNLocation = + RNLocation( + center = RNLatLng(latitude, longitude), + altitude = altitude, + accuracy = accuracy.toDouble(), + bearing = bearing.toDouble(), + speed = speed.toDouble(), + time = time.toDouble(), + android = + RNLocationAndroid( + provider = provider, + elapsedRealtimeNanos = elapsedRealtimeNanos.toDouble(), + bearingAccuracyDegrees = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + bearingAccuracyDegrees.toDouble() + } else { + null + }, + speedAccuracyMetersPerSecond = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + speedAccuracyMetersPerSecond.toDouble() + } else { + null + }, + verticalAccuracyMeters = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + verticalAccuracyMeters.toDouble() + } else { + null + }, + mslAltitudeMeters = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + mslAltitudeMeters + } else { + null + }, + mslAltitudeAccuracyMeters = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + mslAltitudeAccuracyMeters.toDouble() + } else { + null + }, + isMock = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + isMock + } else { + isFromMockProvider + }, + ), + ios = null, + ) diff --git a/example/package.json b/example/package.json index 5b9d365..c62c4d4 100644 --- a/example/package.json +++ b/example/package.json @@ -15,6 +15,7 @@ "@react-navigation/native-stack": "7.3.27", "@react-navigation/stack": "7.4.9", "react": "19.1.1", + "react-hook-form": "7.65.0", "react-native": "0.82.0", "react-native-clusterer": "4.0.0", "react-native-gesture-handler": "2.28.0", diff --git a/example/src/components/ControlPanel.tsx b/example/src/components/ControlPanel.tsx index 2d6d58b..4dc96b3 100644 --- a/example/src/components/ControlPanel.tsx +++ b/example/src/components/ControlPanel.tsx @@ -14,7 +14,7 @@ import Animated, { withTiming, } from 'react-native-reanimated'; import type { GoogleMapsViewRef } from 'react-native-google-maps-plus'; -import { useAppTheme } from '../theme'; +import { useAppTheme } from '../hooks/useAppTheme'; import { useNavigation } from '@react-navigation/native'; import type { RootNavigationProp } from '../types/navigation'; diff --git a/example/src/components/HeaderButton.tsx b/example/src/components/HeaderButton.tsx index 68e9880..2b988c2 100644 --- a/example/src/components/HeaderButton.tsx +++ b/example/src/components/HeaderButton.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react'; import { Pressable, StyleSheet, Text } from 'react-native'; -import { useAppTheme } from '../theme'; +import { useAppTheme } from '../hooks/useAppTheme'; type Props = { title: string; diff --git a/example/src/components/maptConfigDialog/MapConfigDialog.tsx b/example/src/components/maptConfigDialog/MapConfigDialog.tsx index d981748..7be3737 100644 --- a/example/src/components/maptConfigDialog/MapConfigDialog.tsx +++ b/example/src/components/maptConfigDialog/MapConfigDialog.tsx @@ -1,7 +1,5 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { - Alert, - FlatList, Modal, Pressable, ScrollView, @@ -9,21 +7,15 @@ import { Text, TextInput, View, + Alert, } from 'react-native'; -import type { Struct } from 'superstruct'; -import { validate } from 'superstruct'; -import { useAppTheme } from '../../theme'; - -type FieldType = 'text' | 'number' | 'boolean' | 'json'; - -export type FieldSchema = { - key: keyof T; - label?: string; - type: FieldType; - multiline?: boolean; - placeholder?: string; - options?: any[]; -}; +import { type Struct, validate } from 'superstruct'; +import { useAppTheme } from '../../hooks/useAppTheme'; +import { + formatSuperstructError, + parseWithUndefined, + stringifyWithUndefined, +} from './utils'; type Props = { visible: boolean; @@ -34,8 +26,6 @@ type Props = { validator?: Struct; }; -const boolOptions = [true, false] as const; - export default function MapConfigDialog({ visible, title, @@ -45,323 +35,89 @@ export default function MapConfigDialog({ validator, }: Props) { const theme = useAppTheme(); - const styles = useMemo(() => getThemedStyles(theme), [theme]); - const [draft, setDraft] = useState(initialData); - const [activeDropdown, setActiveDropdown] = useState(null); - - const autoSchema: FieldSchema[] = useMemo(() => { - if (!validator || !('schema' in validator)) return []; - const unwrap = (s: any): any => { - let base = s; - while ( - base && - ['optional', 'nullable', 'defaulted'].includes(base.type) - ) { - if (base._values && base.schema && !base.schema._values) - base.schema._values = base._values; - if (base._schema && base.schema && !base.schema._schema) - base.schema._schema = base._schema; - base = base.schema; - } - return base; - }; - - const extractOptions = (schema: any): string[] | undefined => { - let v = schema; - while (v && ['optional', 'nullable', 'defaulted'].includes(v.type)) { - v = v.schema; - } - - if (!v) return; - if ( - Array.isArray(v._values) && - v._values.every((x: any) => typeof x === 'string') - ) { - return v._values; - } - - if (v.type === 'enums' && v.schema && typeof v.schema === 'object') { - const vals = Object.values(v.schema).filter( - (x): x is string => typeof x === 'string' - ); - if (vals.length) { - return vals; + const styles = getThemedStyles(theme); + + const [text, setText] = useState(() => stringifyWithUndefined(initialData)); + const [isValid, setIsValid] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + setText(stringifyWithUndefined(initialData)); + setIsValid(true); + setError(null); + }, [initialData]); + + const handleChange = (value: string) => { + setText(value); + try { + const parsed = parseWithUndefined(value); + + if (validator) { + const [err] = validate(parsed, validator); + if (err) { + setIsValid(false); + setError(formatSuperstructError(err, validator)); + return; } } - if (v.type === 'union' && Array.isArray(v._schema)) { - const literals = v._schema - .map((x: any) => { - return typeof x === 'object' && - x.schema && - typeof x.schema === 'string' - ? x.schema - : (x.value ?? x._value); - }) - .filter((x: any): x is string => typeof x === 'string'); - if (literals.length) { - return literals; - } - } - - if (v.type === 'union' && Array.isArray(v.schema)) { - const literals = v.schema - .map((x: any) => { - return typeof x === 'object' && - x.schema && - typeof x.schema === 'string' - ? x.schema - : (x.value ?? x._value); - }) - .filter((x: any): x is string => typeof x === 'string'); - if (literals.length) { - return literals; - } - } - - return undefined; - }; - - const result: FieldSchema[] = []; - - for (const [key, raw] of Object.entries((validator as any).schema)) { - const unwrapped = unwrap(raw); - - const options = extractOptions(raw); - - let type: FieldType = 'text'; - if (!options) { - if (unwrapped?.type === 'boolean') type = 'boolean'; - else if (unwrapped?.type === 'number') type = 'number'; - else if (unwrapped?.type === 'object' || unwrapped?.type === 'array') - type = 'json'; - } - - result.push({ - key: key as keyof T, - label: key, - type, - options, - multiline: type === 'json', - }); + setIsValid(true); + setError(null); + } catch (e: any) { + setIsValid(false); + setError(e.message); } - - return result; - }, [validator]); - - const [jsonInputs, setJsonInputs] = useState>(() => { - const initial: Record = {}; - autoSchema.forEach((f) => { - if (f.type !== 'json') return; - const key = String(f.key); - const v = (initialData as any)[key]; - initial[key] = - v && typeof v === 'object' - ? JSON.stringify(v, null, 2) - : String(v ?? ''); - }); - return initial; - }); - - const updateField = (key: keyof T, value: any, type: FieldType) => { - setDraft((prev) => ({ - ...(prev as any), - [key]: - type === 'number' - ? value === '' || isNaN(Number(value)) - ? undefined - : Number(value) - : value, - })); }; const handleSave = () => { - const updated: any = { ...(draft as any) }; - for (const f of autoSchema) { - const k = String(f.key); - if (f.type === 'json') { - const jsonStr = jsonInputs[k] ?? ''; - if (jsonStr.trim().length === 0) { - updated[k] = undefined; - } else { - try { - updated[k] = JSON.parse(jsonStr); - } catch (e: any) { - Alert.alert('Invalid JSON', `${f.label ?? k}: ${e.message}`); - return; - } - } - } + if (!isValid) { + Alert.alert('Invalid JSON', error ?? 'Please fix JSON before saving.'); + return; } - if (validator) { - const [err, value] = validate(updated, validator); - if (err) { - Alert.alert( - 'Validation Error', - `${err.path?.join('.') || '(root)'}: ${err.message}` - ); - return; + + try { + const parsed = parseWithUndefined(text); + + if (validator) { + const [err, value] = validate(parsed, validator); + if (err) { + Alert.alert( + 'Validation Error', + formatSuperstructError(err, validator) + ); + return; + } + onSave(value as T); + } else { + onSave(parsed as T); } - onSave(value as T); - } else { - onSave(updated as T); + + onClose(); + } catch (e: any) { + Alert.alert('Invalid JSON', e.message); } - onClose(); }; - const renderDropdown = ( - key: keyof T, - label: string, - value: any, - options: any[] - ) => ( - - {label} - setActiveDropdown(String(key))} - style={[ - styles.dropdownTrigger, - activeDropdown === String(key) && { borderColor: theme.bgAccent }, - ]} - > - - {value?.toString() ?? 'Select...'} - - - - {activeDropdown === String(key) && ( - - - setActiveDropdown(null)} - /> - - {label} - item.toString()} - showsVerticalScrollIndicator={false} - renderItem={({ item }) => ( - [styles.dropdownItem]} - onPress={() => { - updateField(key, item, 'text'); - setActiveDropdown(null); - }} - > - - {item.toString()} - - - )} - /> - setActiveDropdown(null)} - style={styles.dropdownCancel} - > - Cancel - - - - - )} - - ); - - const renderMultilineInput = ( - k: string, - value: string, - onChangeText: (v: string) => void, - placeholder?: string - ) => ( - - {k} - - - - - ); - return ( {title} - - {autoSchema.map((f) => { - const key = f.key; - const k = String(key); - const label = f.label ?? k; - const value = (draft as any)[k]; - - if (f.options?.length) { - return renderDropdown(key, label, value, f.options); - } - - if (f.type === 'boolean') { - return renderDropdown(key, label, value, boolOptions as any); - } - - if (f.type === 'json') { - const jsonValue = jsonInputs[k] ?? ''; - return renderMultilineInput( - k, - jsonValue, - (v) => setJsonInputs((prev) => ({ ...prev, [k]: v })), - f.placeholder ?? '{}' - ); - } - - if (f.multiline) { - const str = value?.toString() ?? ''; - return renderMultilineInput( - k, - str, - (v) => updateField(key, v, f.type), - f.placeholder ?? label - ); - } - - return ( - - {label} - updateField(key, v, f.type)} - placeholder={f.placeholder ?? label} - placeholderTextColor={styles.placeholder.color} - autoCorrect={false} - autoComplete="off" - spellCheck={false} - autoCapitalize="none" - style={styles.input} - /> - - ); - })} + + + {!isValid && ( + + {error ?? 'Invalid JSON or schema mismatch'} + + )} @@ -392,90 +148,33 @@ const getThemedStyles = (theme: any) => borderRadius: 12, flexShrink: 1, }, - scroll: { paddingBottom: 12, margin: 12 }, + scroll: { padding: 12 }, title: { padding: 12, fontSize: 18, fontWeight: '600', color: theme.textPrimary, - marginBottom: 12, }, - field: { marginBottom: 12 }, - label: { fontSize: 14, marginBottom: 4, color: theme.label }, - placeholder: { color: theme.placeholder }, input: { borderWidth: 1, borderRadius: 8, - paddingHorizontal: 10, - paddingVertical: 6, + padding: 10, fontSize: 13, borderColor: theme.border, color: theme.textPrimary, backgroundColor: theme.inputBg, fontFamily: 'monospace', }, - multilineInput: { minHeight: 90, textAlignVertical: 'top' }, - innerScroll: { borderRadius: 8 }, - dropdownOverlay: { - flex: 1, - backgroundColor: theme.overlay, - justifyContent: 'center', - alignItems: 'center', - }, - dropdownBackdrop: { - ...StyleSheet.absoluteFillObject, - }, - dropdownContainer: { - width: '80%', - maxHeight: '70%', - borderRadius: 12, - backgroundColor: theme.bgPrimary, - paddingVertical: 12, - paddingHorizontal: 14, - shadowColor: '#000', - shadowOpacity: 0.25, - shadowRadius: 8, - elevation: 6, - }, - dropdownHeader: { - fontSize: 16, - fontWeight: '600', - color: theme.textPrimary, - marginBottom: 10, - textAlign: 'center', - }, - dropdownItem: { - paddingVertical: 10, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: theme.border, - }, - dropdownItemText: { - fontSize: 14, - color: theme.textPrimary, - textAlign: 'center', - }, - dropdownCancel: { - marginTop: 10, - backgroundColor: theme.cancelBg, - alignSelf: 'center', - borderRadius: 8, - paddingVertical: 8, - paddingHorizontal: 16, - }, - dropdownCancelText: { - color: theme.textOnAccent, - fontWeight: '500', - fontSize: 14, + multiline: { minHeight: 250, textAlignVertical: 'top' }, + errorText: { + marginTop: 6, + color: theme.errorBorder, + fontSize: 12, + fontFamily: 'monospace', }, - dropdownTrigger: { - borderWidth: 1, - borderRadius: 8, - paddingHorizontal: 10, - paddingVertical: 10, - borderColor: theme.border, - backgroundColor: theme.inputBg, + error: { + borderColor: theme.errorBorder, }, - dropdownText: { fontSize: 14, color: theme.textPrimary }, actions: { flexDirection: 'row', justifyContent: 'flex-end', diff --git a/example/src/components/maptConfigDialog/utils.ts b/example/src/components/maptConfigDialog/utils.ts new file mode 100644 index 0000000..9858458 --- /dev/null +++ b/example/src/components/maptConfigDialog/utils.ts @@ -0,0 +1,91 @@ +import type { Struct, StructError } from 'superstruct'; + +export function getSchemaNodeAtPath( + validator: Struct, + path: Array +): any | null { + let node: any = (validator as any)?.schema; + if (!node) return null; + + for (const seg of path) { + if (!node) return null; + if ( + node.type === 'object' && + node.schema && + typeof node.schema === 'object' + ) { + node = node.schema[seg as any]; + continue; + } + + if (node && typeof node === 'object' && seg in node) { + node = node[seg as any]; + continue; + } + return null; + } + return node || null; +} + +export function extractAllowedValuesFromNode(node: any): string[] | null { + if (!node || typeof node !== 'object') return null; + + if (node.type === 'union' && Array.isArray(node._schema)) { + const vals: string[] = node._schema + .map((s: any) => String(s?.schema)) + .filter((v: any) => typeof v === 'string'); + return vals.length ? [...new Set(vals)] : null; + } + + if (node.type === 'enums' && node.schema && typeof node.schema === 'object') { + const vals = Object.values(node.schema) + .filter((v) => typeof v === 'string' || typeof v === 'number') + .map(String); + const strOnly = vals.filter((v) => isNaN(Number(v))); + return (strOnly.length ? strOnly : vals).length + ? [...new Set(strOnly.length ? strOnly : vals)] + : null; + } + + if (node.type === 'literal') { + const lit = node.schema; + if (typeof lit === 'string' || typeof lit === 'number') + return [String(lit)]; + } + + return null; +} + +export function formatSuperstructError( + err: StructError, + validator: Struct +): string { + const path = err.path ?? []; + const pathStr = path.length ? path.join('.') : '(root)'; + + const node = getSchemaNodeAtPath(validator, path); + + const allowed = extractAllowedValuesFromNode(node); + if (allowed && allowed.length) { + return `${pathStr}: must be one of ${allowed.map((v) => `"${v}"`).join(', ')}`; + } + + if (node?.type && ['number', 'boolean', 'string'].includes(node.type)) { + return `${pathStr}: expected ${node.type}`; + } + + return `${pathStr}: ${err.message}`; +} + +export function stringifyWithUndefined(obj: any) { + return JSON.stringify( + obj, + (_, v) => (v === undefined ? '__undefined__' : v), + 2 + ).replace(/"__undefined__"/g, 'undefined'); +} + +export function parseWithUndefined(json: string) { + const fixed = json.replace(/\bundefined\b/g, '"__undefined__"'); + return JSON.parse(fixed, (_, v) => (v === '__undefined__' ? undefined : v)); +} diff --git a/example/src/hooks/useAppTheme.tsx b/example/src/hooks/useAppTheme.tsx new file mode 100644 index 0000000..98b0514 --- /dev/null +++ b/example/src/hooks/useAppTheme.tsx @@ -0,0 +1,7 @@ +import { useColorScheme } from 'react-native'; +import { type AppTheme, darkTheme, lightTheme } from '../theme'; + +export function useAppTheme(): AppTheme { + const scheme = useColorScheme(); + return scheme === 'dark' ? darkTheme : lightTheme; +} diff --git a/example/src/screens/BasicMapScreen.tsx b/example/src/screens/BasicMapScreen.tsx index d6cf5d8..fb58297 100644 --- a/example/src/screens/BasicMapScreen.tsx +++ b/example/src/screens/BasicMapScreen.tsx @@ -40,18 +40,18 @@ export default function BasicMapScreen() { zoomControlsEnabled: true, zoomGesturesEnabled: true, }, - myLocationEnabled: false, + myLocationEnabled: true, buildingEnabled: undefined, trafficEnabled: undefined, indoorEnabled: undefined, customMapStyle: '', userInterfaceStyle: 'default', mapZoomConfig: { min: 0, max: 20 }, - mapPadding: { top: 20, left: 20, bottom: layout.bottom, right: 20 }, + mapPadding: { top: 20, left: 20, bottom: layout.bottom + 80, right: 20 }, mapType: 'normal', locationConfig: { android: { - priority: RNAndroidLocationPriority.PRIORITY_BALANCED_POWER_ACCURACY, + priority: RNAndroidLocationPriority.PRIORITY_HIGH_ACCURACY, interval: 5000, minUpdateInterval: 5000, }, diff --git a/example/src/screens/HomeScreen.tsx b/example/src/screens/HomeScreen.tsx index 94698d6..166f537 100644 --- a/example/src/screens/HomeScreen.tsx +++ b/example/src/screens/HomeScreen.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react'; import { ScrollView, StyleSheet, Text, TouchableOpacity } from 'react-native'; import type { StackNavigationProp } from '@react-navigation/stack'; import { useNavigation } from '@react-navigation/native'; -import { useAppTheme } from '../theme'; +import { useAppTheme } from '../hooks/useAppTheme'; const screens = [ { name: 'BasicMap', title: 'Basic Map' }, diff --git a/example/src/screens/SnaptshotTestScreen.tsx b/example/src/screens/SnaptshotTestScreen.tsx index 73c88ae..4e760b8 100644 --- a/example/src/screens/SnaptshotTestScreen.tsx +++ b/example/src/screens/SnaptshotTestScreen.tsx @@ -2,7 +2,7 @@ import React, { useMemo, useRef, useState } from 'react'; import { Image, Modal, Pressable, StyleSheet, Text, View } from 'react-native'; import MapWrapper from '../components/MapWrapper'; import ControlPanel from '../components/ControlPanel'; -import { useAppTheme } from '../theme'; +import { useAppTheme } from '../hooks/useAppTheme'; import type { GoogleMapsViewRef } from 'react-native-google-maps-plus'; export default function SnapshotTestScreen() { diff --git a/example/src/theme.ts b/example/src/theme.ts index 7b2cf42..824183a 100644 --- a/example/src/theme.ts +++ b/example/src/theme.ts @@ -1,5 +1,3 @@ -import { useColorScheme } from 'react-native'; - export const lightTheme = { bgPrimary: '#FFFFFF', bgAccent: '#3B82F6', @@ -14,6 +12,7 @@ export const lightTheme = { inputBg: '#FFFFFF', buttonBg: '#3B82F6', cancelBg: '#9CA3AF', + errorBorder: '#ff0000', }; export const darkTheme = { @@ -30,11 +29,7 @@ export const darkTheme = { inputBg: '#2C2C2E', buttonBg: '#2D6BE9', cancelBg: '#4B5563', + errorBorder: '#ff0000', }; export type AppTheme = typeof lightTheme; - -export function useAppTheme(): AppTheme { - const scheme = useColorScheme(); - return scheme === 'dark' ? darkTheme : lightTheme; -} diff --git a/ios/GoogleMapViewImpl.swift b/ios/GoogleMapViewImpl.swift index 5da6af7..1bb8d07 100644 --- a/ios/GoogleMapViewImpl.swift +++ b/ios/GoogleMapViewImpl.swift @@ -11,6 +11,7 @@ GMSIndoorDisplayDelegate { private var mapView: GMSMapView? private var initialized = false private var mapReady = false + private var deInitialized = false private var pendingMarkers: [(id: String, marker: GMSMarker)] = [] private var pendingPolylines: [(id: String, polyline: GMSPolyline)] = [] @@ -28,7 +29,6 @@ GMSIndoorDisplayDelegate { private var cameraMoveReasonIsGesture: Bool = false private var lastSubmittedCameraPosition: GMSCameraPosition? - private var lastSubmittedLocation: CLLocation? init( frame: CGRect = .zero, @@ -72,8 +72,8 @@ GMSIndoorDisplayDelegate { mapView?.autoresizingMask = [.flexibleWidth, .flexibleHeight] mapView?.paddingAdjustmentBehavior = .never mapView.map { addSubview($0) } + applyProps() initLocationCallbacks() - applyPending() onMapReady?(true) mapReady = true } @@ -82,18 +82,7 @@ GMSIndoorDisplayDelegate { private func initLocationCallbacks() { locationHandler.onUpdate = { [weak self] loc in guard let self = self else { return } - if self.lastSubmittedLocation?.coordinate.latitude - != loc.coordinate.latitude - || self.lastSubmittedLocation?.coordinate.longitude - != loc.coordinate.longitude { - self.onLocationUpdate?( - RNLocation( - loc.coordinate.toRNLatLng(), - loc.course - ) - ) - } - self.lastSubmittedLocation = loc + self.onLocationUpdate?(loc.toRnLocation()) } locationHandler.onError = { [weak self] error in self?.onLocationError?(error) @@ -102,55 +91,19 @@ GMSIndoorDisplayDelegate { } @MainActor - private func applyPending() { - mapPadding.map { - mapView?.padding = UIEdgeInsets( - top: $0.top, - left: $0.left, - bottom: $0.bottom, - right: $0.right - ) - } - - if let v = uiSettings { - v.allGesturesEnabled.map { mapView?.settings.setAllGesturesEnabled($0) } - v.compassEnabled.map { mapView?.settings.compassButton = $0 } - v.indoorLevelPickerEnabled.map { mapView?.settings.indoorPicker = $0 } - v.mapToolbarEnabled.map { _ in /* not supported */ } - v.myLocationButtonEnabled.map { mapView?.settings.myLocationButton = $0 } - v.rotateEnabled.map { mapView?.settings.rotateGestures = $0 } - v.scrollEnabled.map { mapView?.settings.scrollGestures = $0 } - v.scrollDuringRotateOrZoomEnabled.map { - mapView?.settings.allowScrollGesturesDuringRotateOrZoom = $0 - } - v.tiltEnabled.map { mapView?.settings.tiltGestures = $0 } - v.zoomControlsEnabled.map { _ in /* not supported */ } - v.zoomGesturesEnabled.map { mapView?.settings.zoomGestures = $0 } - } - - myLocationEnabled.map { mapView?.isMyLocationEnabled = $0 } - buildingEnabled.map { mapView?.isBuildingsEnabled = $0 } - trafficEnabled.map { mapView?.isTrafficEnabled = $0 } - indoorEnabled.map { - mapView?.isIndoorEnabled = $0 - mapView?.indoorDisplay.delegate = $0 == true ? self : nil - } - customMapStyle.map { mapView?.mapStyle = $0 } - mapType.map { mapView?.mapType = $0 } - userInterfaceStyle.map { mapView?.overrideUserInterfaceStyle = $0 } + private func applyProps() { + ({ self.uiSettings = self.uiSettings })() + ({ self.mapPadding = self.mapPadding })() + ({ self.myLocationEnabled = self.myLocationEnabled })() + ({ self.buildingEnabled = self.buildingEnabled })() + ({ self.trafficEnabled = self.trafficEnabled })() + ({ self.indoorEnabled = self.indoorEnabled })() + ({ self.customMapStyle = self.customMapStyle })() + ({ self.mapType = self.mapType })() + ({ self.userInterfaceStyle = self.userInterfaceStyle })() + ({ self.mapZoomConfig = self.mapZoomConfig })() + ({ self.locationConfig = self.locationConfig })() - mapZoomConfig.map { - mapView?.setMinZoom( - Float($0.min ?? 2), - maxZoom: Float($0.max ?? 21) - ) - } - - locationConfig.map { - locationHandler.desiredAccuracy = - $0.ios?.desiredAccuracy?.toCLLocationAccuracy - locationHandler.distanceFilterMeters = $0.ios?.distanceFilterMeters - } if !pendingMarkers.isEmpty { pendingMarkers.forEach { addMarkerInternal(id: $0.id, marker: $0.marker) } pendingMarkers.removeAll() @@ -402,7 +355,7 @@ GMSIndoorDisplayDelegate { ) -> NitroModules.Promise { let promise = Promise() - DispatchQueue.main.async { + onMainAsync { guard let mapView = self.mapView else { promise.resolve(withResult: nil) return @@ -435,7 +388,6 @@ GMSIndoorDisplayDelegate { return } - // Rückgabe if resultIsFile { let filename = "map_snapshot_\(Int(Date().timeIntervalSince1970)).\(format)" @@ -660,6 +612,8 @@ GMSIndoorDisplayDelegate { } func deinitInternal() { + guard !deInitialized else { return } + deInitialized = true onMain { self.locationHandler.stop() self.markerBuilder.cancelAllIconTasks() @@ -703,33 +657,14 @@ GMSIndoorDisplayDelegate { func mapView(_ mapView: GMSMapView, willMove gesture: Bool) { onMain { + self.cameraMoveReasonIsGesture = gesture let visibleRegion = mapView.projection.visibleRegion() let bounds = GMSCoordinateBounds(region: visibleRegion) - let center = CLLocationCoordinate2D( - latitude: (bounds.northEast.latitude + bounds.southWest.latitude) / 2.0, - longitude: (bounds.northEast.longitude + bounds.southWest.longitude) - / 2.0 - ) + let region = bounds.toRNRegion() + let camera = mapView.camera.toRNCamera() - let latDelta = bounds.northEast.latitude - bounds.southWest.latitude - let lngDelta = bounds.northEast.longitude - bounds.southWest.longitude - - let cp = mapView.camera - let region = RNRegion( - center: center.toRNLatLng(), - latitudeDelta: latDelta, - longitudeDelta: lngDelta - ) - let cam = RNCamera( - center: cp.target.toRNLatLng(), - zoom: Double(cp.zoom), - bearing: cp.bearing, - tilt: cp.viewingAngle - ) - self.cameraMoveReasonIsGesture = gesture - - self.onCameraChangeStart?(region, cam, gesture) + self.onCameraChange?(region, camera, gesture) } } @@ -748,28 +683,10 @@ GMSIndoorDisplayDelegate { let visibleRegion = mapView.projection.visibleRegion() let bounds = GMSCoordinateBounds(region: visibleRegion) - let center = CLLocationCoordinate2D( - latitude: (bounds.northEast.latitude + bounds.southWest.latitude) / 2.0, - longitude: (bounds.northEast.longitude + bounds.southWest.longitude) - / 2.0 - ) - - let latDelta = bounds.northEast.latitude - bounds.southWest.latitude - let lngDelta = bounds.northEast.longitude - bounds.southWest.longitude + let region = bounds.toRNRegion() + let camera = mapView.camera.toRNCamera() - let cp = mapView.camera - let region = RNRegion( - center: center.toRNLatLng(), - latitudeDelta: latDelta, - longitudeDelta: lngDelta - ) - let cam = RNCamera( - center: cp.target.toRNLatLng(), - zoom: Double(cp.zoom), - bearing: cp.bearing, - tilt: cp.viewingAngle - ) - self.onCameraChange?(region, cam, self.cameraMoveReasonIsGesture) + self.onCameraChange?(region, camera, self.cameraMoveReasonIsGesture) } } @@ -778,28 +695,10 @@ GMSIndoorDisplayDelegate { let visibleRegion = mapView.projection.visibleRegion() let bounds = GMSCoordinateBounds(region: visibleRegion) - let center = CLLocationCoordinate2D( - latitude: (bounds.northEast.latitude + bounds.southWest.latitude) / 2.0, - longitude: (bounds.northEast.longitude + bounds.southWest.longitude) - / 2.0 - ) - - let latDelta = bounds.northEast.latitude - bounds.southWest.latitude - let lngDelta = bounds.northEast.longitude - bounds.southWest.longitude + let region = bounds.toRNRegion() + let camera = mapView.camera.toRNCamera() - let cp = mapView.camera - let region = RNRegion( - center: center.toRNLatLng(), - latitudeDelta: latDelta, - longitudeDelta: lngDelta - ) - let cam = RNCamera( - center: cp.target.toRNLatLng(), - zoom: Double(cp.zoom), - bearing: cp.bearing, - tilt: cp.viewingAngle - ) - self.onCameraChangeComplete?(region, cam, self.cameraMoveReasonIsGesture) + self.onCameraChange?(region, camera, self.cameraMoveReasonIsGesture) } } @@ -893,12 +792,3 @@ GMSIndoorDisplayDelegate { } } } - -@inline(__always) -func onMain(_ block: @escaping () -> Void) { - if Thread.isMainThread { - block() - } else { - DispatchQueue.main.async { block() } - } -} diff --git a/ios/LocationHandler.swift b/ios/LocationHandler.swift index 3ebe414..a08501d 100644 --- a/ios/LocationHandler.swift +++ b/ios/LocationHandler.swift @@ -34,7 +34,7 @@ final class LocationHandler: NSObject, CLLocationManagerDelegate { } func showLocationDialog() { - DispatchQueue.main.async { + onMainAsync { [weak self] in guard let vc = Self.topMostViewController() else { return } let title = Bundle.main.object(forInfoDictionaryKey: "LocationNotAvailableTitle") @@ -61,7 +61,7 @@ final class LocationHandler: NSObject, CLLocationManagerDelegate { title: openLocationSettingsButton ?? "Open settings", style: .default ) { _ in - self.openLocationSettings() + self?.openLocationSettings() } ) vc.present(alert, animated: true, completion: nil) @@ -78,7 +78,7 @@ final class LocationHandler: NSObject, CLLocationManagerDelegate { } func openLocationSettings() { - DispatchQueue.main.async { + onMainAsync { let openSettings = { if #available(iOS 18.3, *) { guard @@ -114,18 +114,10 @@ final class LocationHandler: NSObject, CLLocationManagerDelegate { didFailWithError error: Error ) { let code: RNLocationErrorCode - if let clError = error as? CLError { - switch clError.code { - case .denied: - code = RNLocationErrorCode.permissionDenied - case .locationUnknown, .network: - code = RNLocationErrorCode.positionUnavailable - default: - code = RNLocationErrorCode.internalError - } + code = clError.code.toRNLocationErrorCode } else { - code = RNLocationErrorCode.internalError + code = .internalError } onError?(code) } diff --git a/ios/MapHelper.swift b/ios/MapHelper.swift index b8ebaf0..b233433 100644 --- a/ios/MapHelper.swift +++ b/ios/MapHelper.swift @@ -18,3 +18,23 @@ func withCATransaction( body() CATransaction.commit() } + +@MainActor @inline(__always) +func onMain(_ block: @escaping @MainActor () -> Void) { + if Thread.isMainThread { + block() + } else { + Task { @MainActor in block() } + } +} + +@inline(__always) +func onMainAsync( + _ block: @MainActor @escaping () async -> Void +) { + if Thread.isMainThread { + Task { @MainActor in await block() } + } else { + Task { @MainActor in await block() } + } +} diff --git a/ios/MapMarkerBuilder.swift b/ios/MapMarkerBuilder.swift index 6174158..1141e70 100644 --- a/ios/MapMarkerBuilder.swift +++ b/ios/MapMarkerBuilder.swift @@ -3,13 +3,12 @@ import SVGKit import UIKit final class MapMarkerBuilder { - private let iconCache = NSCache() + private let iconCache: NSCache = { + let c = NSCache() + c.countLimit = 512 + return c + }() private var tasks: [String: Task] = [:] - private let queue = DispatchQueue( - label: "map.marker.render", - qos: .userInitiated, - attributes: .concurrent - ) func build(_ m: RNMarker, icon: UIImage?) -> GMSMarker { let marker = GMSMarker( @@ -35,7 +34,8 @@ final class MapMarkerBuilder { } m.zIndex.map { marker.zIndex = Int32($0) } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak marker] in + onMainAsync { [weak marker] in + try? await Task.sleep(nanoseconds: 250_000_000) marker?.tracksViewChanges = false } @@ -84,7 +84,7 @@ final class MapMarkerBuilder { m.tracksViewChanges = true m.icon = img - if prev.anchor?.x != next.anchor?.x || prev.anchor?.y != next.anchor?.y{ + if prev.anchor?.x != next.anchor?.x || prev.anchor?.y != next.anchor?.y { m.groundAnchor = CGPoint( x: next.anchor?.x ?? 0.5, y: next.anchor?.y ?? 1 @@ -99,12 +99,13 @@ final class MapMarkerBuilder { ) } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak m] in + onMainAsync { [weak m] in + try? await Task.sleep(nanoseconds: 250_000_000) m?.tracksViewChanges = false } } } else { - if prev.anchor?.x != next.anchor?.x || prev.anchor?.y != next.anchor?.y{ + if prev.anchor?.x != next.anchor?.x || prev.anchor?.y != next.anchor?.y { m.groundAnchor = CGPoint( x: next.anchor?.x ?? 0.5, y: next.anchor?.y ?? 1 @@ -144,7 +145,8 @@ final class MapMarkerBuilder { guard let self else { return } defer { self.tasks.removeValue(forKey: id) } - let img = await self.renderUIImage(m) + let scale = UIScreen.main.scale + let img = await self.renderUIImage(m, scale) guard let img, !Task.isCancelled else { return } self.iconCache.setObject(img, forKey: key) @@ -172,65 +174,38 @@ final class MapMarkerBuilder { iconCache.removeAllObjects() } - private func renderUIImage(_ m: RNMarker) async -> UIImage? { - guard let iconSvg = m.iconSvg else { - return nil - } - - return await withTaskCancellationHandler( - operation: { - await withCheckedContinuation { - (cont: CheckedContinuation) in - queue.async { - if Task.isCancelled { - cont.resume(returning: nil) - return - } - - let targetW = max(1, Int(CGFloat(iconSvg.width))) - let targetH = max(1, Int(CGFloat(iconSvg.height))) - let size = CGSize(width: targetW, height: targetH) - - guard - let data = iconSvg.svgString.data(using: .utf8), - let svgImg = SVGKImage(data: data) - else { - cont.resume(returning: nil) - return - } - - svgImg.size = size - - if Task.isCancelled { - cont.resume(returning: nil) - return - } - - guard let base = svgImg.uiImage else { - cont.resume(returning: nil) - return - } - - let scale = UIScreen.main.scale - let img: UIImage - if let cg = base.cgImage { - img = UIImage(cgImage: cg, scale: scale, orientation: .up) - } else { - let fmt = UIGraphicsImageRendererFormat.default() - fmt.opaque = false - fmt.scale = scale - let renderer = UIGraphicsImageRenderer(size: size, format: fmt) - img = renderer.image { _ in - base.draw(in: CGRect(origin: .zero, size: size)) - } - } - - cont.resume(returning: img) - } - } + private func renderUIImage(_ m: RNMarker, _ scale: CGFloat) async -> UIImage? { + guard let iconSvg = m.iconSvg, + let data = iconSvg.svgString.data(using: .utf8) + else { return nil } - }, - onCancel: {} + let size = CGSize( + width: max(1, CGFloat(iconSvg.width)), + height: max(1, CGFloat(iconSvg.height)) ) + + return await Task.detached(priority: .userInitiated) { + autoreleasepool { + guard let svgImg = SVGKImage(data: data) else { return nil } + svgImg.size = size + + guard !Task.isCancelled else { return nil } + guard let base = svgImg.uiImage else { return nil } + + if let cg = base.cgImage { + return UIImage(cgImage: cg, scale: scale, orientation: .up) + } + guard !Task.isCancelled else { return nil } + let fmt = UIGraphicsImageRendererFormat.default() + fmt.opaque = false + fmt.scale = scale + guard !Task.isCancelled else { return nil } + let renderer = UIGraphicsImageRenderer(size: size, format: fmt) + return renderer.image { _ in + base.draw(in: CGRect(origin: .zero, size: size)) + } + } + }.value } + } diff --git a/ios/RNGoogleMapsPlusView.swift b/ios/RNGoogleMapsPlusView.swift index 0d9f42b..e1a8ef9 100644 --- a/ios/RNGoogleMapsPlusView.swift +++ b/ios/RNGoogleMapsPlusView.swift @@ -30,21 +30,25 @@ final class RNGoogleMapsPlusView: HybridRNGoogleMapsPlusViewSpec { ) } + @MainActor func afterUpdate() { if !propsInitialized { propsInitialized = true - Task { @MainActor in - let options = GMSMapViewOptions() - initialProps?.mapId.map { options.mapID = GMSMapID(identifier: $0) } - initialProps?.liteMode.map { _ in /* not supported */ } - initialProps?.camera.map { - options.camera = $0.toGMSCameraPosition(current: nil) - } - impl.initMapView(googleMapOptions: options) + let options = GMSMapViewOptions() + initialProps?.mapId.map { options.mapID = GMSMapID(identifier: $0) } + initialProps?.liteMode.map { _ in /* not supported */ } + initialProps?.camera.map { + options.camera = $0.toGMSCameraPosition(current: nil) } + impl.initMapView(googleMapOptions: options) } } + @MainActor + func dispose() { + impl.deinitInternal() + } + @MainActor var initialProps: RNInitialProps? { didSet { diff --git a/ios/extensions/CLError+Extension.swift b/ios/extensions/CLError+Extension.swift new file mode 100644 index 0000000..5cda693 --- /dev/null +++ b/ios/extensions/CLError+Extension.swift @@ -0,0 +1,14 @@ +import CoreLocation + +extension CLError.Code { + var toRNLocationErrorCode: RNLocationErrorCode { + switch self { + case .denied: + return .permissionDenied + case .locationUnknown, .network: + return .positionUnavailable + default: + return .internalError + } + } +} diff --git a/ios/extensions/CLLocation+Extension.swift b/ios/extensions/CLLocation+Extension.swift new file mode 100644 index 0000000..c94855b --- /dev/null +++ b/ios/extensions/CLLocation+Extension.swift @@ -0,0 +1,27 @@ +import CoreLocation + +extension CLLocation { + func toRnLocation() -> RNLocation { + return RNLocation( + center: RNLatLng( + latitude: coordinate.latitude, + longitude: coordinate.longitude + ), + altitude: altitude, + accuracy: horizontalAccuracy, + bearing: course, + speed: speed, + time: timestamp.timeIntervalSince1970 * 1000, + android: nil, + ios: RNLocationIOS( + horizontalAccuracy: horizontalAccuracy, + verticalAccuracy: verticalAccuracy, + speedAccuracy: speedAccuracy, + courseAccuracy: courseAccuracy, + floor: floor.map { Double($0.level)}, + isFromMockProvider: false, + timestamp: timestamp.timeIntervalSince1970 * 1000 + ) + ) + } +} diff --git a/ios/extensions/GMSCameraPosition+Extension.swift b/ios/extensions/GMSCameraPosition+Extension.swift new file mode 100644 index 0000000..016e5af --- /dev/null +++ b/ios/extensions/GMSCameraPosition+Extension.swift @@ -0,0 +1,12 @@ +import GoogleMaps + +extension GMSCameraPosition { + func toRNCamera() -> RNCamera { + return RNCamera( + center: target.toRNLatLng(), + zoom: Double(zoom), + bearing: bearing, + tilt: viewingAngle + ) + } +} diff --git a/ios/extensions/GMSCoordinateBounds+Extension.swift b/ios/extensions/GMSCoordinateBounds+Extension.swift new file mode 100644 index 0000000..8676b88 --- /dev/null +++ b/ios/extensions/GMSCoordinateBounds+Extension.swift @@ -0,0 +1,19 @@ +import GoogleMaps + +extension GMSCoordinateBounds { + func toRNRegion() -> RNRegion { + let center = CLLocationCoordinate2D( + latitude: (northEast.latitude + southWest.latitude) / 2.0, + longitude: (northEast.longitude + southWest.longitude) / 2.0 + ) + + let latDelta = northEast.latitude - southWest.latitude + let lngDelta = northEast.longitude - southWest.longitude + + return RNRegion( + center: center.toRNLatLng(), + latitudeDelta: latDelta, + longitudeDelta: lngDelta + ) + } +} diff --git a/ios/extensions/RNMarker+Extension.swift b/ios/extensions/RNMarker+Extension.swift index b6bbd07..a7fd2a6 100644 --- a/ios/extensions/RNMarker+Extension.swift +++ b/ios/extensions/RNMarker+Extension.swift @@ -17,14 +17,13 @@ extension RNMarker { func markerStyleEquals(_ b: RNMarker) -> Bool { iconSvg?.width == b.iconSvg?.width && iconSvg?.height == b.iconSvg?.height && iconSvg?.svgString == b.iconSvg?.svgString - } - func styleHash() -> NSString { + func styleHash() -> NSNumber { var hasher = Hasher() hasher.combine(iconSvg?.width) hasher.combine(iconSvg?.height) hasher.combine(iconSvg?.svgString) - return String(hasher.finalize()) as NSString + return NSNumber(value: hasher.finalize()) } } diff --git a/package.json b/package.json index 3397a21..469e9d8 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "module": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", "source": "src/index", + "react-native": "src/index", "scripts": { "typecheck": "tsc --noEmit", "lint": "yarn lint:js && yarn lint:android && yarn lint:ios", @@ -148,22 +149,14 @@ "source": "src", "output": "lib", "targets": [ + "module", [ - "module", + "typescript", { - "esm": true + "project": "tsconfig.json" } - ], - "typescript" + ] ] }, - "exports": { - ".": { - "source": "./src/index.tsx", - "types": "./lib/typescript/src/index.d.ts", - "default": "./lib/module/index.js" - }, - "./package.json": "./package.json" - }, "packageManager": "yarn@3.6.1" } diff --git a/release.config.cjs b/release.config.cjs index 580065b..14e6e3a 100644 --- a/release.config.cjs +++ b/release.config.cjs @@ -29,7 +29,7 @@ const sortMap = Object.fromEntries( * @type {import('semantic-release').GlobalConfig} */ module.exports = { - branches: ['main', { name: 'dev', prerelease: 'dev' }], + branches: ['main', { name: 'dev', channel: 'dev', prerelease: 'dev' }], plugins: [ [ '@semantic-release/commit-analyzer', diff --git a/scripts/nitrogen-patch.js b/scripts/nitrogen-patch.js index c4d5353..7d5404d 100644 --- a/scripts/nitrogen-patch.js +++ b/scripts/nitrogen-patch.js @@ -16,6 +16,7 @@ import { fileURLToPath } from 'url'; import { basename } from 'path'; import path from 'node:path'; import { readdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, copyFile } from 'node:fs/promises'; const ROOT_ANDROID = path.join( process.cwd(), @@ -24,6 +25,22 @@ const ROOT_ANDROID = path.join( 'android' ); const ROOT_IOS = path.join(process.cwd(), 'nitrogen', 'generated', 'ios'); +const SRC_JSON_DIR = path.join( + process.cwd(), + 'nitrogen', + 'generated', + 'shared', + 'json' +); +const DEST_JSON_DIR = path.join( + process.cwd(), + 'lib', + 'nitrogen', + 'generated', + 'shared', + 'json' +); + const ANDROID_ONLOAD_FILE = path.join( ROOT_ANDROID, 'RNGoogleMapsPlusOnLoad.cpp' @@ -53,7 +70,19 @@ const REPLACEMENTS = [ const __filename = fileURLToPath(import.meta.url); const filename = basename(__filename); -const RECYCLE_METHOD_ANDROID = ` +const ANDROID_VIEW_MANAGER_METHODS = + /override fun onDropViewInstance\(view: View\)\s*\{\s*super\.onDropViewInstance\(view\)\s*views\.remove\(view\)\s*\}/m; + +const ANDROID_VIEW_MANAGER_METHODS_NEW = ` + override fun onDropViewInstance(view: View) { + super.onDropViewInstance(view) + views.remove(view) + /// added by ${filename} + if (view is GoogleMapsViewImpl) { + view.destroyInternal() + } + } + /// added by ${filename} override fun prepareToRecycleView(reactContext: ThemedReactContext, view: View): View? { return null @@ -66,7 +95,15 @@ const RECYCLE_METHOD_IOS = ` { return NO; } -`; + +/// added by ${filename} +- (void)dealloc { + if (_hybridView) { + RNGoogleMapsPlus::HybridRNGoogleMapsPlusViewSpec_cxx& swiftPart = _hybridView->getSwiftPart(); + swiftPart.dispose(); + _hybridView.reset(); + } +}`; async function processFile(filePath) { let content = await readFile(filePath, 'utf8'); @@ -81,17 +118,15 @@ async function processFile(filePath) { } if (path.resolve(filePath) === path.resolve(HYBRID_VIEW_MANAGER)) { - if (!/override fun prepareToRecycleView/.test(updated)) { - const pattern = - /(override fun onDropViewInstance\(view: View\)\s*\{[^}]+\}\s*)/m; - - if (pattern.test(updated)) { - updated = updated.replace(pattern, `$1${RECYCLE_METHOD_ANDROID}\n`); - } else { - throw new Error( - `Pattern for "onDropViewInstance" not found in ${filePath}` - ); - } + if (ANDROID_VIEW_MANAGER_METHODS.test(updated)) { + updated = updated.replace( + ANDROID_VIEW_MANAGER_METHODS, + ANDROID_VIEW_MANAGER_METHODS_NEW + ); + } else { + throw new Error( + `Pattern for HybridRNGoogleMapsPlusViewManager not found in ${filePath}` + ); } } @@ -110,7 +145,7 @@ async function processFile(filePath) { if (updated !== content) { await writeFile(filePath, updated, 'utf8'); - console.log(`✔ Updated: ${filePath}`); + console.log(`Updated: ${filePath}`); } } @@ -126,8 +161,26 @@ async function start(dir) { } } +async function copyJsonFiles() { + try { + await mkdir(DEST_JSON_DIR, { recursive: true }); + const files = await readdir(SRC_JSON_DIR); + for (const file of files) { + if (file.endsWith('.json')) { + const src = path.join(SRC_JSON_DIR, file); + const dest = path.join(DEST_JSON_DIR, file); + await copyFile(src, dest); + console.log(`Copied JSON: ${file}`); + } + } + } catch (err) { + console.warn('Failed to copy JSON view configs:', err.message); + } +} + (async () => { try { + await copyJsonFiles(); await start(ROOT_ANDROID); await start(ROOT_IOS); console.log('All Nitrogen files patched successfully.'); diff --git a/src/GoogleMapsPlus.tsx b/src/GoogleMapsPlus.tsx new file mode 100644 index 0000000..9d194f9 --- /dev/null +++ b/src/GoogleMapsPlus.tsx @@ -0,0 +1,20 @@ +import { getHostComponent, NitroModules } from 'react-native-nitro-modules'; + +import ViewConfig from '../nitrogen/generated/shared/json/RNGoogleMapsPlusViewConfig.json' with { type: 'json' }; + +import type { + RNGoogleMapsPlusViewMethods, + RNGoogleMapsPlusViewProps, +} from './RNGoogleMapsPlusView.nitro.js'; + +import type { RNGoogleMapsPlusModule } from './RNGoogleMapsPlusModule.nitro.js'; + +export const GoogleMapsView = getHostComponent< + RNGoogleMapsPlusViewProps, + RNGoogleMapsPlusViewMethods +>('RNGoogleMapsPlusView', () => ViewConfig); + +export const GoogleMapsModule = + NitroModules.createHybridObject( + 'RNGoogleMapsPlusModule' + ); diff --git a/src/index.tsx b/src/index.tsx index 181a667..2a24f2c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,24 +1,16 @@ -import { getHostComponent, NitroModules } from 'react-native-nitro-modules'; - -import ViewConfig from '../nitrogen/generated/shared/json/RNGoogleMapsPlusViewConfig.json'; - +import { GoogleMapsView, GoogleMapsModule } from './GoogleMapsPlus'; import type { RNGoogleMapsPlusViewMethods, RNGoogleMapsPlusViewProps, } from './RNGoogleMapsPlusView.nitro'; - import type { RNGoogleMapsPlusModule } from './RNGoogleMapsPlusModule.nitro'; export * from './types'; -export type { RNGoogleMapsPlusViewMethods, RNGoogleMapsPlusViewProps }; - -export const GoogleMapsView = getHostComponent< +export type { + RNGoogleMapsPlusViewMethods, RNGoogleMapsPlusViewProps, - RNGoogleMapsPlusViewMethods ->('RNGoogleMapsPlusView', () => ViewConfig); + RNGoogleMapsPlusModule, +}; -export const GoogleMapsModule = - NitroModules.createHybridObject( - 'RNGoogleMapsPlusModule' - ); +export { GoogleMapsView, GoogleMapsModule }; diff --git a/src/types.ts b/src/types.ts index b88b69c..9b18036 100644 --- a/src/types.ts +++ b/src/types.ts @@ -259,6 +259,7 @@ export type RNLocationConfig = { android?: RNAndroidLocationConfig; ios?: RNIOSLocationConfig; }; + export type RNAndroidLocationConfig = { priority?: RNAndroidLocationPriority; interval?: number; @@ -302,7 +303,33 @@ export enum RNIOSPermissionResult { export type RNLocation = { center: RNLatLng; + altitude: number; + accuracy: number; bearing: number; + speed: number; + time: number; + android?: RNLocationAndroid; + ios?: RNLocationIOS; +}; + +export type RNLocationAndroid = { + provider?: string | null; + elapsedRealtimeNanos?: number; + bearingAccuracyDegrees?: number; + speedAccuracyMetersPerSecond?: number; + verticalAccuracyMeters?: number; + mslAltitudeMeters?: number; + mslAltitudeAccuracyMeters?: number; + isMock?: boolean; +}; +export type RNLocationIOS = { + horizontalAccuracy?: number; + verticalAccuracy?: number; + speedAccuracy?: number; + courseAccuracy?: number; + floor?: number | null; + isFromMockProvider?: boolean; + timestamp?: number; }; export enum RNLocationErrorCode { diff --git a/tsconfig.json b/tsconfig.json index eab4aee..b42e7cc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,24 +4,23 @@ "allowUnreachableCode": false, "allowUnusedLabels": false, "esModuleInterop": true, + "allowSyntheticDefaultImports": true, "forceConsistentCasingInFileNames": true, "jsx": "react", "lib": ["esnext"], "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", + "target": "esnext", + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "strict": true, "noEmit": false, + "skipLibCheck": true, "noFallthroughCasesInSwitch": true, "noImplicitReturns": true, - "noImplicitUseStrict": false, - "noStrictGenericChecks": false, "noUncheckedIndexedAccess": true, "noUnusedLocals": true, - "noUnusedParameters": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "strict": true, - "target": "esnext", - "verbatimModuleSyntax": true + "noUnusedParameters": true }, "exclude": [ "**/node_modules", diff --git a/yarn.lock b/yarn.lock index e2f5d7c..904ab39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11599,6 +11599,15 @@ __metadata: languageName: node linkType: hard +"react-hook-form@npm:7.65.0": + version: 7.65.0 + resolution: "react-hook-form@npm:7.65.0" + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + checksum: ad7b6c6919e0f987587a94fe9bed0bd7d4c6817d7f9c4651f8d9d747261ad99b20d85f2ff2f329b86aadecc1ab48d4b0ef2eb13123e1a5d48022dbca259c4aef + languageName: node + linkType: hard + "react-is@npm:^16.13.1, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -11697,6 +11706,7 @@ __metadata: "@react-navigation/stack": 7.4.9 "@types/react": 19.1.1 react: 19.1.1 + react-hook-form: 7.65.0 react-native: 0.82.0 react-native-builder-bob: 0.40.13 react-native-clusterer: 4.0.0