craft-native 0.0.238 → 0.0.242
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android/src/index.d.ts +23 -1
- package/dist/android/src/index.js +102 -9
- package/dist/android/src/native-map.d.ts +7 -0
- package/dist/android/templates/CraftBridge.kt.template +2 -1
- package/dist/android/templates/CraftNativeBackground.kt.template +28 -2
- package/dist/android/templates/CraftNativeMapGoogle.kt.template +440 -0
- package/dist/android/templates/CraftNativeMapLibre.kt.template +438 -0
- package/dist/android/templates/CraftNativeMessagingService.kt.template +3 -1
- package/dist/android/templates/CraftNativeNotificationReceiver.kt.template +2 -1
- package/dist/android/templates/LocationRecordingService.kt.template +3 -1
- package/dist/android/templates/MainActivityNative.kt.template +14 -433
- package/dist/android/templates/build.gradle.kts.app.template +1 -1
- package/dist/android/templates/build.gradle.kts.project.template +1 -1
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +12 -1
- package/dist/ios/src/index.js +32 -7
- package/dist/ios/src/testing.d.ts +14 -2
- package/dist/ios/templates/CraftApp.swift +117 -29
- package/dist/ios/templates/CraftHybrid.swift +193 -29
- package/dist/ios/templates/CraftNativeActions.swift +26 -27
- package/dist/ios/templates/CraftNativeFiles.swift +205 -1
- package/dist/ios/templates/CraftNativeScreen.swift +230 -33
- package/dist/ios/templates/testing/DriverTests.swift.template +37 -0
- package/dist/macos/templates/native_layout.zig +41 -5
- package/package.json +1 -1
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/** The Android host for the small, host-neutral native trail map contract. */
|
|
2
|
+
private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMapReadyCallback {
|
|
3
|
+
private val mapView = MapView(context)
|
|
4
|
+
private val scaleView = LinearLayout(context).apply {
|
|
5
|
+
orientation = LinearLayout.VERTICAL
|
|
6
|
+
setPadding(4, 2, 4, 2)
|
|
7
|
+
background = GradientDrawable().apply { setColor(Color.argb(230, 255, 255, 255)); cornerRadius = 4f }
|
|
8
|
+
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
|
9
|
+
}
|
|
10
|
+
private val scaleLabel = TextView(context).apply { textSize = 11f; setTextColor(Color.rgb(37, 51, 33)) }
|
|
11
|
+
private val scaleLine = View(context).apply { setBackgroundColor(Color.rgb(37, 51, 33)) }
|
|
12
|
+
private var googleMap: GoogleMap? = null
|
|
13
|
+
private var tileOverlay: com.google.android.gms.maps.model.TileOverlay? = null
|
|
14
|
+
private var tileTemplate: String? = null
|
|
15
|
+
private val tileAttribution = TextView(context).apply { textSize = 9f; setTextColor(Color.DKGRAY); setBackgroundColor(Color.argb(220, 255, 255, 255)); maxLines = 2; visibility = View.GONE }
|
|
16
|
+
private var pendingProps = JSONObject()
|
|
17
|
+
private var pendingChildren = JSONArray()
|
|
18
|
+
private val markers = mutableMapOf<String, Marker>()
|
|
19
|
+
private val polylines = mutableMapOf<String, Polyline>()
|
|
20
|
+
private val markerHandlers = mutableMapOf<String, String>()
|
|
21
|
+
private var onMarkerTap: ((String) -> Unit)? = null
|
|
22
|
+
var onMapPress: ((LatLng) -> Unit)? = null
|
|
23
|
+
var onRegionChangeComplete: ((JSONObject) -> Unit)? = null
|
|
24
|
+
private var userChangingCamera = false
|
|
25
|
+
private var markerBearings = mutableMapOf<String, Float>()
|
|
26
|
+
private var markerPinColors = mutableMapOf<String, Int>()
|
|
27
|
+
private val pinIcons = mutableMapOf<Int, com.google.android.gms.maps.model.BitmapDescriptor>()
|
|
28
|
+
private var directionIcon: com.google.android.gms.maps.model.BitmapDescriptor? = null
|
|
29
|
+
private var lifecycleStarted = false
|
|
30
|
+
private var destroyed = false
|
|
31
|
+
private var initialCameraKey: String? = null
|
|
32
|
+
|
|
33
|
+
init {
|
|
34
|
+
addView(mapView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
|
35
|
+
scaleView.addView(scaleLabel)
|
|
36
|
+
scaleView.addView(scaleLine, LinearLayout.LayoutParams(1, 2))
|
|
37
|
+
addView(scaleView, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.START).apply {
|
|
38
|
+
leftMargin = (8 * resources.displayMetrics.density).roundToInt()
|
|
39
|
+
bottomMargin = (24 * resources.displayMetrics.density).roundToInt()
|
|
40
|
+
})
|
|
41
|
+
scaleView.visibility = View.GONE
|
|
42
|
+
addView(tileAttribution, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.END).apply { rightMargin = (8 * resources.displayMetrics.density).roundToInt(); bottomMargin = (8 * resources.displayMetrics.density).roundToInt() })
|
|
43
|
+
mapView.onCreate(null)
|
|
44
|
+
mapView.getMapAsync(this)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
override fun onMapReady(map: GoogleMap) {
|
|
48
|
+
if (destroyed) return
|
|
49
|
+
googleMap = map
|
|
50
|
+
map.setOnMapClickListener { point -> onMapPress?.invoke(point) }
|
|
51
|
+
map.setOnCameraMoveStartedListener { reason ->
|
|
52
|
+
if (reason == GoogleMap.OnCameraMoveStartedListener.REASON_GESTURE) userChangingCamera = true
|
|
53
|
+
}
|
|
54
|
+
map.setOnCameraMoveListener { updateScale() }
|
|
55
|
+
map.setOnCameraIdleListener {
|
|
56
|
+
updateScale()
|
|
57
|
+
if (userChangingCamera) {
|
|
58
|
+
userChangingCamera = false
|
|
59
|
+
val bounds = map.projection.visibleRegion.latLngBounds
|
|
60
|
+
val position = map.cameraPosition
|
|
61
|
+
val longitudeDelta = (bounds.northeast.longitude - bounds.southwest.longitude + 360) % 360
|
|
62
|
+
onRegionChangeComplete?.invoke(JSONObject()
|
|
63
|
+
.put("center", JSONObject().put("latitude", position.target.latitude).put("longitude", position.target.longitude))
|
|
64
|
+
.put("span", JSONObject().put("latitudeDelta", bounds.northeast.latitude - bounds.southwest.latitude).put("longitudeDelta", longitudeDelta))
|
|
65
|
+
.put("heading", position.bearing).put("pitch", position.tilt))
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
map.setOnMarkerClickListener { marker ->
|
|
69
|
+
val id = markers.entries.firstOrNull { it.value == marker }?.key ?: return@setOnMarkerClickListener false
|
|
70
|
+
onMarkerTap?.invoke(id)
|
|
71
|
+
// The native contract dispatches the marker event itself. Consume
|
|
72
|
+
// the click so Google Maps does not also show an unsolicited info
|
|
73
|
+
// window for the marker title.
|
|
74
|
+
true
|
|
75
|
+
}
|
|
76
|
+
applyPending()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
fun configure(props: JSONObject, children: JSONArray, markerHandler: (String) -> Unit) {
|
|
80
|
+
pendingProps = props
|
|
81
|
+
pendingChildren = children
|
|
82
|
+
onMarkerTap = markerHandler
|
|
83
|
+
applyPending()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fun markerHandler(id: String): String? = markerHandlers[id]
|
|
87
|
+
|
|
88
|
+
private fun applyPending() {
|
|
89
|
+
val map = googleMap ?: return
|
|
90
|
+
applyMapPadding()
|
|
91
|
+
map.uiSettings.isCompassEnabled = craftNativeBoolean(pendingProps.opt("showsCompass"))
|
|
92
|
+
// The toolbar is replaced by authored actions; the scale uses the native projection.
|
|
93
|
+
map.uiSettings.isMapToolbarEnabled = false
|
|
94
|
+
map.uiSettings.isScrollGesturesEnabled = craftNativeBoolean(pendingProps.opt("scrollEnabled"), true)
|
|
95
|
+
map.uiSettings.isZoomGesturesEnabled = craftNativeBoolean(pendingProps.opt("zoomEnabled"), true)
|
|
96
|
+
map.uiSettings.isRotateGesturesEnabled = craftNativeBoolean(pendingProps.opt("rotateEnabled"))
|
|
97
|
+
map.uiSettings.isTiltGesturesEnabled = craftNativeBoolean(pendingProps.opt("pitchEnabled"), true)
|
|
98
|
+
map.mapType = when (craftNativeOptionalString(pendingProps.opt("mapType")).orEmpty().lowercase()) {
|
|
99
|
+
"satellite" -> GoogleMap.MAP_TYPE_SATELLITE
|
|
100
|
+
"hybrid" -> GoogleMap.MAP_TYPE_HYBRID
|
|
101
|
+
"terrain" -> GoogleMap.MAP_TYPE_TERRAIN
|
|
102
|
+
else -> GoogleMap.MAP_TYPE_NORMAL
|
|
103
|
+
}
|
|
104
|
+
applyTileOverlay()
|
|
105
|
+
val showUserLocation = craftNativeBoolean(pendingProps.opt("showsUserLocation")) && hasLocationPermission()
|
|
106
|
+
runCatching { map.isMyLocationEnabled = showUserLocation }
|
|
107
|
+
val nextMarkers = mutableMapOf<String, Pair<LatLng, String?>>()
|
|
108
|
+
val nextBearings = mutableMapOf<String, Float>()
|
|
109
|
+
val nextPinColors = mutableMapOf<String, Int>()
|
|
110
|
+
val nextHandlers = mutableMapOf<String, String>()
|
|
111
|
+
val nextPolylines = mutableMapOf<String, Triple<List<LatLng>, Int, Float>>()
|
|
112
|
+
for (index in 0 until pendingChildren.length()) {
|
|
113
|
+
val child = pendingChildren.optJSONObject(index) ?: continue
|
|
114
|
+
val type = child.optString("type").lowercase()
|
|
115
|
+
val props = child.optJSONObject("props") ?: JSONObject()
|
|
116
|
+
val events = child.optJSONObject("events") ?: JSONObject()
|
|
117
|
+
val id = craftNativeOptionalString(props.opt("id"))
|
|
118
|
+
?: craftNativeOptionalString(child.opt("id"))
|
|
119
|
+
?: "overlay-$index"
|
|
120
|
+
val points = coordinates(props.optJSONArray("geometry"))
|
|
121
|
+
if (type == "mapmarker" || type == "marker") {
|
|
122
|
+
points.firstOrNull()?.let { point ->
|
|
123
|
+
nextMarkers[id] = point to (props.opt("title") as? String)
|
|
124
|
+
(props.opt("pinColor") as? String)?.let { value -> runCatching { craftNativeColor(value) }.getOrNull()?.let { nextPinColors[id] = it } }
|
|
125
|
+
(props.opt("bearing") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.let { nextBearings[id] = (it % 360).toFloat() }
|
|
126
|
+
listOf(events.opt("onPress"), events.opt("onMarkerPress"))
|
|
127
|
+
.firstNotNullOfOrNull(::craftNativeOptionalString)
|
|
128
|
+
?.let { nextHandlers[id] = it }
|
|
129
|
+
}
|
|
130
|
+
} else if (type == "mappolyline" || type == "polyline") {
|
|
131
|
+
if (points.size > 1) {
|
|
132
|
+
// Keep malformed authored widths inert, matching the iOS
|
|
133
|
+
// host. An omitted or null width still gets the shared
|
|
134
|
+
// default; an attacker-controlled NaN, infinity, zero,
|
|
135
|
+
// or negative value must not become a valid overlay.
|
|
136
|
+
val rawWidth = props.opt("strokeWidth")
|
|
137
|
+
val authoredWidth = when (rawWidth) {
|
|
138
|
+
null, JSONObject.NULL -> 3.0
|
|
139
|
+
is Number -> rawWidth.toDouble()
|
|
140
|
+
else -> 3.0
|
|
141
|
+
}
|
|
142
|
+
// MapKit's lineWidth is expressed in points, while the
|
|
143
|
+
// Google Maps SDK consumes screen pixels. Keep the
|
|
144
|
+
// protocol in logical points and convert only at this
|
|
145
|
+
// Android host boundary so dense displays stay in parity.
|
|
146
|
+
val width = (authoredWidth * resources.displayMetrics.density).toFloat()
|
|
147
|
+
if (!authoredWidth.isFinite() || !width.isFinite() || width <= 0f) continue
|
|
148
|
+
nextPolylines[id] = Triple(points, color(craftNativeOptionalString(props.opt("strokeColor")).orEmpty()), width)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
markerBearings = nextBearings
|
|
153
|
+
markerPinColors = nextPinColors
|
|
154
|
+
pinIcons.keys.filter { it !in markerPinColors.values }.forEach { pinIcons.remove(it) }
|
|
155
|
+
markers.keys.filter { nextMarkers[it] == null }.forEach { markers.remove(it)?.remove() }
|
|
156
|
+
for ((id, value) in nextMarkers) {
|
|
157
|
+
val marker = markers[id]
|
|
158
|
+
if (marker != null) {
|
|
159
|
+
marker.position = value.first
|
|
160
|
+
marker.title = value.second
|
|
161
|
+
marker.setIcon(if (markerBearings.containsKey(id)) directionMarkerIcon() else pinMarkerIcon(id))
|
|
162
|
+
marker.isFlat = markerBearings.containsKey(id)
|
|
163
|
+
marker.rotation = markerBearings[id] ?: 0f
|
|
164
|
+
marker.setAnchor(0.5f, if (markerBearings.containsKey(id) || markerPinColors.containsKey(id)) 0.5f else 1f)
|
|
165
|
+
} else {
|
|
166
|
+
map.addMarker(MarkerOptions().position(value.first).title(value.second).icon(if (markerBearings.containsKey(id)) directionMarkerIcon() else pinMarkerIcon(id)).flat(markerBearings.containsKey(id)).rotation(markerBearings[id] ?: 0f).anchor(0.5f, if (markerBearings.containsKey(id) || markerPinColors.containsKey(id)) 0.5f else 1f))?.let { markers[id] = it }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
markerHandlers.clear()
|
|
170
|
+
markerHandlers.putAll(nextHandlers)
|
|
171
|
+
polylines.keys.filter { nextPolylines[it] == null }.forEach { id ->
|
|
172
|
+
polylines.remove(id)?.remove()
|
|
173
|
+
}
|
|
174
|
+
for ((id, value) in nextPolylines) {
|
|
175
|
+
val polyline = polylines[id]
|
|
176
|
+
if (polyline != null) {
|
|
177
|
+
polyline.points = value.first
|
|
178
|
+
polyline.color = value.second
|
|
179
|
+
polyline.width = value.third
|
|
180
|
+
} else {
|
|
181
|
+
map.addPolyline(PolylineOptions().addAll(value.first).color(value.second).width(value.third))?.let { polylines[id] = it }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
val explicitRegion = region(pendingProps.optJSONObject("region"))
|
|
185
|
+
val initialRegion = region(pendingProps.optJSONObject("initialRegion"))
|
|
186
|
+
if (explicitRegion != null) {
|
|
187
|
+
// A controlled region is an exact camera request. The padding
|
|
188
|
+
// used for fitToBounds is framing chrome, not part of the region
|
|
189
|
+
// contract, and would otherwise zoom every Android screen out.
|
|
190
|
+
moveCamera(explicitRegion, padding = 0)
|
|
191
|
+
initialCameraKey = null
|
|
192
|
+
} else if (initialRegion != null) {
|
|
193
|
+
val key = "region:${pendingProps.optJSONObject("initialRegion")}"
|
|
194
|
+
if (initialCameraKey != key) {
|
|
195
|
+
moveCamera(initialRegion, padding = 0)
|
|
196
|
+
initialCameraKey = key
|
|
197
|
+
}
|
|
198
|
+
} else if (craftNativeBoolean(pendingProps.opt("fitToBounds"))) {
|
|
199
|
+
val key = "fit:${overlayKey(nextMarkers, nextPolylines)}:${pendingProps.optJSONObject("edgePadding")}"
|
|
200
|
+
if (initialCameraKey != key) {
|
|
201
|
+
if (fitToBounds()) initialCameraKey = key
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
initialCameraKey = null
|
|
205
|
+
}
|
|
206
|
+
applyCameraOrientation()
|
|
207
|
+
updateScale()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private fun applyTileOverlay() {
|
|
211
|
+
val map = googleMap ?: return
|
|
212
|
+
val options = pendingProps.optJSONObject("tileOverlay")
|
|
213
|
+
val template = options?.optString("urlTemplate").orEmpty()
|
|
214
|
+
val credit = options?.optString("attribution").orEmpty()
|
|
215
|
+
val next = template.takeIf { it.startsWith("https://") && listOf("{z}", "{x}", "{y}").all { key -> it.contains(key) } && credit.isNotBlank() }
|
|
216
|
+
if (next != tileTemplate) {
|
|
217
|
+
tileOverlay?.remove(); tileOverlay = null; tileTemplate = next
|
|
218
|
+
if (next != null) {
|
|
219
|
+
val provider = object : com.google.android.gms.maps.model.UrlTileProvider(256, 256) {
|
|
220
|
+
override fun getTileUrl(x: Int, y: Int, zoom: Int): java.net.URL? = runCatching { java.net.URL(next.replace("{x}", x.toString()).replace("{y}", y.toString()).replace("{z}", zoom.toString())) }.getOrNull()
|
|
221
|
+
}
|
|
222
|
+
tileOverlay = map.addTileOverlay(com.google.android.gms.maps.model.TileOverlayOptions().tileProvider(provider).zIndex(-1f))
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (next != null) map.mapType = GoogleMap.MAP_TYPE_NONE
|
|
226
|
+
tileAttribution.text = credit
|
|
227
|
+
tileAttribution.visibility = if (next == null) View.GONE else View.VISIBLE
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private fun applyMapPadding() {
|
|
231
|
+
val authored = pendingProps.optJSONObject("edgePadding")
|
|
232
|
+
fun inset(name: String): Double = (authored?.opt(name) as? Number)?.toDouble()?.takeIf { it.isFinite() }?.coerceIn(0.0, 10_000.0)?.times(resources.displayMetrics.density) ?: 0.0
|
|
233
|
+
var left = inset("left"); var top = inset("top"); var right = inset("right"); var bottom = inset("bottom")
|
|
234
|
+
val horizontal = left + right
|
|
235
|
+
val vertical = top + bottom
|
|
236
|
+
val availableWidth = (width - 40 * resources.displayMetrics.density).coerceAtLeast(0f).toDouble()
|
|
237
|
+
val availableHeight = (height - 40 * resources.displayMetrics.density).coerceAtLeast(0f).toDouble()
|
|
238
|
+
if (horizontal > availableWidth && horizontal > 0) { left *= availableWidth / horizontal; right *= availableWidth / horizontal }
|
|
239
|
+
if (vertical > availableHeight && vertical > 0) { top *= availableHeight / vertical; bottom *= availableHeight / vertical }
|
|
240
|
+
googleMap?.setPadding(left.roundToInt(), top.roundToInt(), right.roundToInt(), bottom.roundToInt())
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private fun updateScale() {
|
|
244
|
+
val map = googleMap ?: return
|
|
245
|
+
if (!craftNativeBoolean(pendingProps.opt("showsScale")) || width <= 0 || height <= 0) { scaleView.visibility = View.GONE; return }
|
|
246
|
+
val maximumWidth = (80 * resources.displayMetrics.density).roundToInt()
|
|
247
|
+
val origin = map.projection.fromScreenLocation(android.graphics.Point(width / 2, height / 2))
|
|
248
|
+
val end = map.projection.fromScreenLocation(android.graphics.Point(width / 2 + maximumWidth, height / 2))
|
|
249
|
+
val distance = FloatArray(1)
|
|
250
|
+
android.location.Location.distanceBetween(origin.latitude, origin.longitude, end.latitude, end.longitude, distance)
|
|
251
|
+
val metres = distance[0].toDouble()
|
|
252
|
+
if (!metres.isFinite() || metres <= 0) { scaleView.visibility = View.GONE; return }
|
|
253
|
+
val magnitude = Math.pow(10.0, kotlin.math.floor(kotlin.math.log10(metres)))
|
|
254
|
+
val length = listOf(5.0, 2.0, 1.0).firstOrNull { it * magnitude <= metres }?.times(magnitude) ?: magnitude
|
|
255
|
+
scaleLabel.text = if (length >= 1000) "${length / 1000} km" else "${length.roundToInt()} m"
|
|
256
|
+
scaleLine.layoutParams = LinearLayout.LayoutParams((maximumWidth * length / metres).roundToInt().coerceAtLeast(1), 2)
|
|
257
|
+
scaleView.visibility = View.VISIBLE
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private fun applyCameraOrientation() {
|
|
261
|
+
val map = googleMap ?: return
|
|
262
|
+
val camera = map.cameraPosition
|
|
263
|
+
val pitch = (pendingProps.opt("cameraPitch") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.coerceIn(0.0, 60.0)?.toFloat() ?: camera.tilt
|
|
264
|
+
val heading = (pendingProps.opt("cameraHeading") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.toFloat() ?: camera.bearing
|
|
265
|
+
if (kotlin.math.abs(camera.tilt - pitch) > 0.01f || kotlin.math.abs(camera.bearing - heading) > 0.01f) {
|
|
266
|
+
map.moveCamera(CameraUpdateFactory.newCameraPosition(com.google.android.gms.maps.model.CameraPosition.Builder(camera).tilt(pitch).bearing(heading).build()))
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private fun pinMarkerIcon(id: String): com.google.android.gms.maps.model.BitmapDescriptor {
|
|
271
|
+
val color = markerPinColors[id] ?: return BitmapDescriptorFactory.defaultMarker()
|
|
272
|
+
return pinIcons.getOrPut(color) {
|
|
273
|
+
val size = (18 * resources.displayMetrics.density).roundToInt().coerceAtLeast(18)
|
|
274
|
+
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
|
275
|
+
val canvas = Canvas(bitmap)
|
|
276
|
+
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
|
277
|
+
paint.color = Color.WHITE
|
|
278
|
+
canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
|
|
279
|
+
paint.color = color
|
|
280
|
+
canvas.drawCircle(size / 2f, size / 2f, size * 0.38f, paint)
|
|
281
|
+
BitmapDescriptorFactory.fromBitmap(bitmap)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private fun directionMarkerIcon(): com.google.android.gms.maps.model.BitmapDescriptor {
|
|
286
|
+
directionIcon?.let { return it }
|
|
287
|
+
val size = (26 * resources.displayMetrics.density).roundToInt().coerceAtLeast(24)
|
|
288
|
+
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
|
289
|
+
val canvas = Canvas(bitmap)
|
|
290
|
+
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
|
291
|
+
paint.color = Color.WHITE
|
|
292
|
+
canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
|
|
293
|
+
paint.color = Color.rgb(36, 114, 75)
|
|
294
|
+
canvas.drawCircle(size / 2f, size / 2f, size * 0.43f, paint)
|
|
295
|
+
paint.color = Color.WHITE
|
|
296
|
+
paint.textSize = size * 0.76f
|
|
297
|
+
paint.textAlign = Paint.Align.CENTER
|
|
298
|
+
paint.typeface = Typeface.DEFAULT_BOLD
|
|
299
|
+
canvas.drawText("↑", size / 2f, size / 2f - (paint.ascent() + paint.descent()) / 2, paint)
|
|
300
|
+
return BitmapDescriptorFactory.fromBitmap(bitmap).also { directionIcon = it }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
|
|
304
|
+
super.onSizeChanged(width, height, oldWidth, oldHeight)
|
|
305
|
+
applyMapPadding()
|
|
306
|
+
updateScale()
|
|
307
|
+
// MapView can become ready before its host has a usable viewport. Do
|
|
308
|
+
// not mark a fit as complete until the camera update can be issued.
|
|
309
|
+
if (width > 0 && height > 0 && craftNativeBoolean(pendingProps.opt("fitToBounds")) && initialCameraKey == null) {
|
|
310
|
+
applyPending()
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private fun moveCamera(bounds: LatLngBounds, padding: Int = 24) {
|
|
315
|
+
post {
|
|
316
|
+
if (width > 0 && height > 0) {
|
|
317
|
+
googleMap?.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, padding))
|
|
318
|
+
applyCameraOrientation()
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private fun overlayKey(
|
|
324
|
+
markers: Map<String, Pair<LatLng, String?>>,
|
|
325
|
+
polylines: Map<String, Triple<List<LatLng>, Int, Float>>,
|
|
326
|
+
): String {
|
|
327
|
+
val markerKey = markers.toSortedMap().entries.joinToString("|") { (id, value) ->
|
|
328
|
+
"$id:${value.first.latitude},${value.first.longitude}"
|
|
329
|
+
}
|
|
330
|
+
val polylineKey = polylines.toSortedMap().entries.joinToString("|") { (id, value) ->
|
|
331
|
+
"$id:" + value.first.joinToString(";") { point -> "${point.latitude},${point.longitude}" }
|
|
332
|
+
}
|
|
333
|
+
return "m[$markerKey]p[$polylineKey]"
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private fun fitToBounds(): Boolean {
|
|
337
|
+
val map = googleMap ?: return false
|
|
338
|
+
if (width <= 0 || height <= 0) return false
|
|
339
|
+
val builder = LatLngBounds.Builder()
|
|
340
|
+
var count = 0
|
|
341
|
+
markers.values.forEach { builder.include(it.position); count++ }
|
|
342
|
+
polylines.values.flatMap { it.points }.forEach { builder.include(it); count++ }
|
|
343
|
+
if (count == 0) return false
|
|
344
|
+
post {
|
|
345
|
+
if (width > 0 && height > 0) {
|
|
346
|
+
applyMapPadding()
|
|
347
|
+
val padding = if (pendingProps.has("edgePadding")) 0 else (24 * resources.displayMetrics.density).roundToInt()
|
|
348
|
+
map.moveCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), padding))
|
|
349
|
+
applyCameraOrientation()
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return true
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
override fun onAttachedToWindow() {
|
|
356
|
+
super.onAttachedToWindow()
|
|
357
|
+
if (destroyed) return
|
|
358
|
+
mapView.onStart()
|
|
359
|
+
mapView.onResume()
|
|
360
|
+
lifecycleStarted = true
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
override fun onDetachedFromWindow() {
|
|
364
|
+
if (lifecycleStarted) {
|
|
365
|
+
mapView.onPause()
|
|
366
|
+
mapView.onStop()
|
|
367
|
+
lifecycleStarted = false
|
|
368
|
+
}
|
|
369
|
+
super.onDetachedFromWindow()
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
fun destroy() {
|
|
373
|
+
if (destroyed) return
|
|
374
|
+
destroyed = true
|
|
375
|
+
if (lifecycleStarted) {
|
|
376
|
+
mapView.onPause()
|
|
377
|
+
mapView.onStop()
|
|
378
|
+
lifecycleStarted = false
|
|
379
|
+
}
|
|
380
|
+
onMarkerTap = null
|
|
381
|
+
onMapPress = null
|
|
382
|
+
onRegionChangeComplete = null
|
|
383
|
+
directionIcon = null
|
|
384
|
+
pinIcons.clear(); markerPinColors.clear()
|
|
385
|
+
tileOverlay?.remove(); tileOverlay = null
|
|
386
|
+
markerHandlers.clear()
|
|
387
|
+
markers.values.forEach { it.remove() }
|
|
388
|
+
markers.clear()
|
|
389
|
+
polylines.values.forEach { it.remove() }
|
|
390
|
+
polylines.clear()
|
|
391
|
+
mapView.onDestroy()
|
|
392
|
+
googleMap = null
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
fun lowMemory() {
|
|
396
|
+
if (!destroyed) mapView.onLowMemory()
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private fun hasLocationPermission(): Boolean =
|
|
400
|
+
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
|
401
|
+
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
|
402
|
+
|
|
403
|
+
private fun coordinates(geometry: JSONArray?): List<LatLng> {
|
|
404
|
+
if (geometry == null) return emptyList()
|
|
405
|
+
return (0 until geometry.length() - 1 step 2).mapNotNull { index ->
|
|
406
|
+
val latitude = finiteNumber(geometry.opt(index)) ?: return@mapNotNull null
|
|
407
|
+
val longitude = finiteNumber(geometry.opt(index + 1)) ?: return@mapNotNull null
|
|
408
|
+
if (latitude.isFinite() && longitude.isFinite() &&
|
|
409
|
+
latitude >= -90.0 && latitude <= 90.0 &&
|
|
410
|
+
longitude >= -180.0 && longitude <= 180.0
|
|
411
|
+
) LatLng(latitude, longitude) else null
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private fun region(value: JSONObject?): LatLngBounds? {
|
|
416
|
+
val center = value?.optJSONObject("center") ?: return null
|
|
417
|
+
val span = value.optJSONObject("span") ?: return null
|
|
418
|
+
val latitude = finiteNumber(center.opt("latitude")) ?: return null
|
|
419
|
+
val longitude = finiteNumber(center.opt("longitude")) ?: return null
|
|
420
|
+
val latitudeDelta = finiteNumber(span.opt("latitudeDelta"))
|
|
421
|
+
?: finiteNumber(span.opt("latitude_delta"))
|
|
422
|
+
?: return null
|
|
423
|
+
val longitudeDelta = finiteNumber(span.opt("longitudeDelta"))
|
|
424
|
+
?: finiteNumber(span.opt("longitude_delta"))
|
|
425
|
+
?: return null
|
|
426
|
+
if (!latitude.isFinite() || !longitude.isFinite() || !latitudeDelta.isFinite() || !longitudeDelta.isFinite() ||
|
|
427
|
+
latitude < -90.0 || latitude > 90.0 || longitude < -180.0 || longitude > 180.0 ||
|
|
428
|
+
latitudeDelta <= 0.0 || latitudeDelta > 180.0 ||
|
|
429
|
+
longitudeDelta <= 0.0 || longitudeDelta > 360.0 ||
|
|
430
|
+
latitude - latitudeDelta / 2.0 < -90.0 || latitude + latitudeDelta / 2.0 > 90.0
|
|
431
|
+
) return null
|
|
432
|
+
return LatLngBounds(LatLng(latitude - latitudeDelta / 2, longitude - longitudeDelta / 2), LatLng(latitude + latitudeDelta / 2, longitude + longitudeDelta / 2))
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private fun finiteNumber(value: Any?): Double? =
|
|
436
|
+
(value as? Number)?.toDouble()?.takeIf { it.isFinite() }
|
|
437
|
+
|
|
438
|
+
private fun color(value: String): Int = runCatching { craftNativeColor(value) }.getOrDefault(Color.BLUE)
|
|
439
|
+
}
|
|
440
|
+
|