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,438 @@
|
|
|
1
|
+
/** A key-free native Android map. STX geometry never enters a WebView. */
|
|
2
|
+
internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMapReadyCallback {
|
|
3
|
+
private val mapView: MapView
|
|
4
|
+
private var map: MapLibreMap? = null
|
|
5
|
+
private var destroyed = false
|
|
6
|
+
private var lifecycleStarted = false
|
|
7
|
+
private var lifecycleResumed = false
|
|
8
|
+
private var lifecycleOwner: androidx.lifecycle.LifecycleOwner? = null
|
|
9
|
+
private val lifecycleObserver = object : androidx.lifecycle.DefaultLifecycleObserver {
|
|
10
|
+
override fun onStart(owner: androidx.lifecycle.LifecycleOwner) { startMap() }
|
|
11
|
+
override fun onResume(owner: androidx.lifecycle.LifecycleOwner) { resumeMap() }
|
|
12
|
+
override fun onPause(owner: androidx.lifecycle.LifecycleOwner) { pauseMap() }
|
|
13
|
+
override fun onStop(owner: androidx.lifecycle.LifecycleOwner) { stopMap() }
|
|
14
|
+
}
|
|
15
|
+
private var pendingProps = JSONObject()
|
|
16
|
+
private var pendingChildren = JSONArray()
|
|
17
|
+
private var styleKey: String? = null
|
|
18
|
+
private var styleVersion = 0
|
|
19
|
+
private var readyStyle: Style? = null
|
|
20
|
+
private var initialCameraKey: String? = null
|
|
21
|
+
private var reportedRegion: LatLngBounds? = null
|
|
22
|
+
private var reportedCamera: CameraPosition? = null
|
|
23
|
+
private var reportedViewport: Pair<Int, Int>? = null
|
|
24
|
+
private var userChangingCamera = false
|
|
25
|
+
private var geometryPoints = emptyList<LatLng>()
|
|
26
|
+
private val markerHandlers = mutableMapOf<String, String>()
|
|
27
|
+
private var onMarkerTap: ((String) -> Unit)? = null
|
|
28
|
+
var onMapPress: ((LatLng) -> Unit)? = null
|
|
29
|
+
var onRegionChangeComplete: ((JSONObject) -> Unit)? = null
|
|
30
|
+
private val attribution = TextView(context).apply {
|
|
31
|
+
textSize = 9f
|
|
32
|
+
setTextColor(Color.DKGRAY)
|
|
33
|
+
setBackgroundColor(Color.argb(230, 255, 255, 255))
|
|
34
|
+
maxLines = 2
|
|
35
|
+
}
|
|
36
|
+
private val scale = LinearLayout(context).apply {
|
|
37
|
+
orientation = LinearLayout.VERTICAL
|
|
38
|
+
setPadding(4, 2, 4, 2)
|
|
39
|
+
setBackgroundColor(Color.argb(230, 255, 255, 255))
|
|
40
|
+
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
|
41
|
+
visibility = View.GONE
|
|
42
|
+
}
|
|
43
|
+
private val scaleLabel = TextView(context).apply { textSize = 11f; setTextColor(Color.rgb(37, 51, 33)) }
|
|
44
|
+
private val scaleLine = View(context).apply { setBackgroundColor(Color.rgb(37, 51, 33)) }
|
|
45
|
+
|
|
46
|
+
init {
|
|
47
|
+
MapLibre.getInstance(context)
|
|
48
|
+
mapView = MapView(context)
|
|
49
|
+
addView(mapView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
|
50
|
+
scale.addView(scaleLabel)
|
|
51
|
+
scale.addView(scaleLine, LinearLayout.LayoutParams(1, 2))
|
|
52
|
+
addView(scale, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.START).apply {
|
|
53
|
+
leftMargin = (8 * resources.displayMetrics.density).roundToInt()
|
|
54
|
+
bottomMargin = (24 * resources.displayMetrics.density).roundToInt()
|
|
55
|
+
})
|
|
56
|
+
addView(attribution, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.END).apply {
|
|
57
|
+
rightMargin = (8 * resources.displayMetrics.density).roundToInt()
|
|
58
|
+
bottomMargin = (8 * resources.displayMetrics.density).roundToInt()
|
|
59
|
+
})
|
|
60
|
+
mapView.onCreate(null)
|
|
61
|
+
mapView.addOnDidFinishLoadingMapListener { if (!destroyed && initialCameraKey == null) applyPending() }
|
|
62
|
+
mapView.getMapAsync(this)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
override fun onMapReady(nativeMap: MapLibreMap) {
|
|
66
|
+
if (destroyed) return
|
|
67
|
+
map = nativeMap
|
|
68
|
+
nativeMap.addOnMapClickListener { point ->
|
|
69
|
+
val screen = nativeMap.projection.toScreenLocation(point)
|
|
70
|
+
val radius = 18 * resources.displayMetrics.density
|
|
71
|
+
val hit = nativeMap.queryRenderedFeatures(android.graphics.RectF(screen.x - radius, screen.y - radius, screen.x + radius, screen.y + radius), "craft-map-markers", "craft-map-directions").firstOrNull()
|
|
72
|
+
val id = hit?.takeIf { it.hasProperty("craftMarkerId") }?.getStringProperty("craftMarkerId")
|
|
73
|
+
if (id != null) onMarkerTap?.invoke(id) else onMapPress?.invoke(point)
|
|
74
|
+
true
|
|
75
|
+
}
|
|
76
|
+
nativeMap.addOnCameraMoveStartedListener { reason ->
|
|
77
|
+
if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) userChangingCamera = true
|
|
78
|
+
}
|
|
79
|
+
nativeMap.addOnCameraMoveListener { updateScale() }
|
|
80
|
+
nativeMap.addOnCameraIdleListener {
|
|
81
|
+
updateScale()
|
|
82
|
+
if (userChangingCamera) {
|
|
83
|
+
userChangingCamera = false
|
|
84
|
+
val bounds = nativeMap.projection.visibleRegion.latLngBounds
|
|
85
|
+
val camera = nativeMap.cameraPosition
|
|
86
|
+
val center = camera.target
|
|
87
|
+
if (center != null) {
|
|
88
|
+
val event = JSONObject()
|
|
89
|
+
.put("center", JSONObject().put("latitude", center.latitude).put("longitude", center.longitude))
|
|
90
|
+
.put("span", JSONObject().put("latitudeDelta", bounds.latitudeNorth - bounds.latitudeSouth).put("longitudeDelta", bounds.longitudeEast - bounds.longitudeWest))
|
|
91
|
+
.put("heading", camera.bearing).put("pitch", camera.tilt)
|
|
92
|
+
reportedRegion = region(event)
|
|
93
|
+
reportedCamera = camera
|
|
94
|
+
reportedViewport = width to height
|
|
95
|
+
onRegionChangeComplete?.invoke(event)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
applyPending()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
fun configure(props: JSONObject, children: JSONArray, markerHandler: (String) -> Unit) {
|
|
103
|
+
pendingProps = props
|
|
104
|
+
pendingChildren = children
|
|
105
|
+
onMarkerTap = markerHandler
|
|
106
|
+
applyPending()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
fun markerHandler(id: String): String? = markerHandlers[id]
|
|
110
|
+
|
|
111
|
+
private fun applyPending() {
|
|
112
|
+
val nativeMap = map ?: return
|
|
113
|
+
if (destroyed) return
|
|
114
|
+
nativeMap.uiSettings.isCompassEnabled = craftNativeBoolean(pendingProps.opt("showsCompass"))
|
|
115
|
+
nativeMap.uiSettings.isScrollGesturesEnabled = craftNativeBoolean(pendingProps.opt("scrollEnabled"), true)
|
|
116
|
+
nativeMap.uiSettings.isZoomGesturesEnabled = craftNativeBoolean(pendingProps.opt("zoomEnabled"), true)
|
|
117
|
+
nativeMap.uiSettings.isRotateGesturesEnabled = craftNativeBoolean(pendingProps.opt("rotateEnabled"))
|
|
118
|
+
nativeMap.uiSettings.isTiltGesturesEnabled = craftNativeBoolean(pendingProps.opt("pitchEnabled"), true)
|
|
119
|
+
// The authored attribution is kept visible rather than hidden in a menu.
|
|
120
|
+
nativeMap.uiSettings.isAttributionEnabled = false
|
|
121
|
+
nativeMap.uiSettings.isLogoEnabled = false
|
|
122
|
+
applyPadding()
|
|
123
|
+
val nextStyle = basemapStyle()
|
|
124
|
+
if (styleKey != nextStyle) {
|
|
125
|
+
styleKey = nextStyle
|
|
126
|
+
readyStyle = null
|
|
127
|
+
val version = ++styleVersion
|
|
128
|
+
nativeMap.setStyle(Style.Builder().fromJson(nextStyle)) { loaded ->
|
|
129
|
+
if (!destroyed && version == styleVersion) {
|
|
130
|
+
readyStyle = loaded
|
|
131
|
+
installOverlays(loaded)
|
|
132
|
+
applyPending()
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
val loaded = readyStyle ?: return
|
|
138
|
+
updateOverlays(loaded)
|
|
139
|
+
applyLocation(nativeMap, loaded)
|
|
140
|
+
val explicit = region(pendingProps.optJSONObject("region"))
|
|
141
|
+
val initial = region(pendingProps.optJSONObject("initialRegion"))
|
|
142
|
+
if (explicit != null) {
|
|
143
|
+
// A measured region is a bounding envelope, especially with
|
|
144
|
+
// padding or rotation. Refitting its unchanged echo would zoom
|
|
145
|
+
// out after every gesture. New requests still move the camera.
|
|
146
|
+
if (!isCurrentReportedRegion(explicit)) moveCamera(explicit, 0)
|
|
147
|
+
initialCameraKey = null
|
|
148
|
+
} else if (initial != null) {
|
|
149
|
+
val key = "region:${pendingProps.optJSONObject("initialRegion")}"
|
|
150
|
+
if (initialCameraKey != key) { moveCamera(initial, 0); initialCameraKey = key }
|
|
151
|
+
} else if (craftNativeBoolean(pendingProps.opt("fitToBounds"))) {
|
|
152
|
+
val key = "$width,$height:${pendingProps.optJSONObject("edgePadding")}:${pendingProps.opt("cameraPitch")}:${pendingProps.opt("cameraHeading")}:" + geometryPoints.joinToString(";") { "${it.latitude},${it.longitude}" }
|
|
153
|
+
if (initialCameraKey != key && geometryPoints.isNotEmpty() && width > 0 && height > 0) {
|
|
154
|
+
val distinct = geometryPoints.distinctBy { it.latitude to it.longitude }
|
|
155
|
+
if (distinct.size == 1) nativeMap.moveCamera(CameraUpdateFactory.newLatLngZoom(distinct.first(), 15.0))
|
|
156
|
+
else moveCamera(LatLngBounds.Builder().includes(geometryPoints).build(), if (pendingProps.has("edgePadding")) 0 else (24 * resources.displayMetrics.density).roundToInt())
|
|
157
|
+
initialCameraKey = key
|
|
158
|
+
}
|
|
159
|
+
} else initialCameraKey = null
|
|
160
|
+
applyOrientation()
|
|
161
|
+
updateScale()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private fun basemapStyle(): String {
|
|
165
|
+
val custom = pendingProps.optJSONObject("tileOverlay")
|
|
166
|
+
val template = custom?.optString("urlTemplate").orEmpty()
|
|
167
|
+
val credit = custom?.optString("attribution").orEmpty()
|
|
168
|
+
val customValid = template.startsWith("https://") && listOf("{z}", "{x}", "{y}").all { template.contains(it) } && credit.isNotBlank()
|
|
169
|
+
val type = pendingProps.optString("mapType", "standard").lowercase()
|
|
170
|
+
val satellite = type == "satellite" || type == "hybrid"
|
|
171
|
+
val url = when {
|
|
172
|
+
customValid -> template
|
|
173
|
+
satellite -> "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
|
174
|
+
type == "terrain" -> "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}"
|
|
175
|
+
else -> "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
176
|
+
}
|
|
177
|
+
val attributionText = when {
|
|
178
|
+
customValid -> credit
|
|
179
|
+
satellite -> "Imagery © Esri, Maxar, Earthstar Geographics, and the GIS User Community"
|
|
180
|
+
type == "terrain" -> "Topography: Esri, USGS, NOAA, OpenStreetMap contributors"
|
|
181
|
+
else -> "© OpenStreetMap contributors"
|
|
182
|
+
}
|
|
183
|
+
attribution.text = attributionText
|
|
184
|
+
val sources = JSONObject().put("basemap", rasterSource(url, attributionText))
|
|
185
|
+
val layers = JSONArray().put(JSONObject().put("id", "basemap").put("type", "raster").put("source", "basemap"))
|
|
186
|
+
if (!customValid && type == "hybrid") {
|
|
187
|
+
sources.put("labels", rasterSource("https://services.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}", "Labels © Esri"))
|
|
188
|
+
layers.put(JSONObject().put("id", "labels").put("type", "raster").put("source", "labels"))
|
|
189
|
+
attribution.text = "$attributionText · Labels © Esri"
|
|
190
|
+
}
|
|
191
|
+
return JSONObject().put("version", 8).put("sources", sources).put("layers", layers).toString()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private fun rasterSource(url: String, credit: String): JSONObject = JSONObject()
|
|
195
|
+
.put("type", "raster").put("tiles", JSONArray().put(url)).put("tileSize", 256).put("maxzoom", 19).put("attribution", credit)
|
|
196
|
+
|
|
197
|
+
private fun installOverlays(style: Style) {
|
|
198
|
+
style.addSource(GeoJsonSource("craft-map-overlays"))
|
|
199
|
+
style.addImage("craft-map-direction", directionBitmap())
|
|
200
|
+
style.addLayer(LineLayer("craft-map-routes", "craft-map-overlays")
|
|
201
|
+
.withFilter(Expression.eq(Expression.get("kind"), "line"))
|
|
202
|
+
.withProperties(lineColor(Expression.toColor(Expression.get("color"))), lineWidth(Expression.get("width")), lineCap("round"), lineJoin("round")))
|
|
203
|
+
style.addLayer(CircleLayer("craft-map-markers", "craft-map-overlays")
|
|
204
|
+
.withFilter(Expression.eq(Expression.get("kind"), "marker"))
|
|
205
|
+
.withProperties(circleColor(Expression.toColor(Expression.get("color"))), circleRadius(7f), circleStrokeColor(Color.WHITE), circleStrokeWidth(2f)))
|
|
206
|
+
style.addLayer(SymbolLayer("craft-map-directions", "craft-map-overlays")
|
|
207
|
+
.withFilter(Expression.eq(Expression.get("kind"), "direction"))
|
|
208
|
+
.withProperties(iconImage("craft-map-direction"), iconRotate(Expression.get("bearing")), iconRotationAlignment("map"), iconAllowOverlap(true), iconIgnorePlacement(true), iconAnchor("center")))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private fun updateOverlays(style: Style) {
|
|
212
|
+
val features = JSONArray()
|
|
213
|
+
val pointsForFit = mutableListOf<LatLng>()
|
|
214
|
+
markerHandlers.clear()
|
|
215
|
+
for (index in 0 until pendingChildren.length()) {
|
|
216
|
+
val child = pendingChildren.optJSONObject(index) ?: continue
|
|
217
|
+
val type = child.optString("type").lowercase()
|
|
218
|
+
val props = child.optJSONObject("props") ?: JSONObject()
|
|
219
|
+
val events = child.optJSONObject("events") ?: JSONObject()
|
|
220
|
+
val id = craftNativeOptionalString(props.opt("id")) ?: craftNativeOptionalString(child.opt("id")) ?: "overlay-$index"
|
|
221
|
+
val points = coordinates(props.optJSONArray("geometry"))
|
|
222
|
+
val properties = JSONObject().put("craftOverlayId", id)
|
|
223
|
+
val geometry = JSONObject()
|
|
224
|
+
if ((type == "mapmarker" || type == "marker") && points.isNotEmpty()) {
|
|
225
|
+
val point = points.first()
|
|
226
|
+
val bearing = finiteNumber(props.opt("bearing"))
|
|
227
|
+
properties.put("kind", if (bearing == null) "marker" else "direction").put("craftMarkerId", id)
|
|
228
|
+
properties.put("bearing", (bearing ?: 0.0) % 360).put("color", cssColor(props.opt("pinColor"), Color.rgb(36, 114, 75)))
|
|
229
|
+
geometry.put("type", "Point").put("coordinates", JSONArray().put(point.longitude).put(point.latitude))
|
|
230
|
+
listOf(events.opt("onPress"), events.opt("onMarkerPress")).firstNotNullOfOrNull(::craftNativeOptionalString)?.let { markerHandlers[id] = it }
|
|
231
|
+
pointsForFit.add(point)
|
|
232
|
+
} else if ((type == "mappolyline" || type == "polyline") && points.size > 1) {
|
|
233
|
+
val authoredWidth = props.opt("strokeWidth")
|
|
234
|
+
val width = if (authoredWidth == null || authoredWidth == JSONObject.NULL) 3.0 else finiteNumber(authoredWidth) ?: continue
|
|
235
|
+
if (width <= 0 || !width.toFloat().isFinite()) continue
|
|
236
|
+
properties.put("kind", "line").put("color", cssColor(props.opt("strokeColor"), Color.BLUE)).put("width", width)
|
|
237
|
+
geometry.put("type", "LineString").put("coordinates", JSONArray().apply { points.forEach { put(JSONArray().put(it.longitude).put(it.latitude)) } })
|
|
238
|
+
pointsForFit.addAll(points)
|
|
239
|
+
} else continue
|
|
240
|
+
features.put(JSONObject().put("type", "Feature").put("properties", properties).put("geometry", geometry))
|
|
241
|
+
}
|
|
242
|
+
geometryPoints = pointsForFit
|
|
243
|
+
style.getSourceAs<GeoJsonSource>("craft-map-overlays")?.setGeoJson(JSONObject().put("type", "FeatureCollection").put("features", features).toString())
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private fun cssColor(value: Any?, fallback: Int): String {
|
|
247
|
+
val color = (value as? String)?.let { runCatching { craftNativeColor(it) }.getOrNull() } ?: fallback
|
|
248
|
+
return "rgba(${Color.red(color)},${Color.green(color)},${Color.blue(color)},${Color.alpha(color) / 255.0})"
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private fun directionBitmap(): Bitmap {
|
|
252
|
+
val size = (26 * resources.displayMetrics.density).roundToInt().coerceAtLeast(24)
|
|
253
|
+
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
|
254
|
+
bitmap.density = resources.displayMetrics.densityDpi
|
|
255
|
+
val canvas = Canvas(bitmap)
|
|
256
|
+
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
|
257
|
+
paint.color = Color.WHITE
|
|
258
|
+
canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
|
|
259
|
+
paint.color = Color.rgb(36, 114, 75)
|
|
260
|
+
canvas.drawCircle(size / 2f, size / 2f, size * 0.43f, paint)
|
|
261
|
+
paint.color = Color.WHITE
|
|
262
|
+
paint.textSize = size * 0.76f
|
|
263
|
+
paint.textAlign = Paint.Align.CENTER
|
|
264
|
+
paint.typeface = Typeface.DEFAULT_BOLD
|
|
265
|
+
canvas.drawText("↑", size / 2f, size / 2f - (paint.ascent() + paint.descent()) / 2, paint)
|
|
266
|
+
return bitmap
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private fun applyLocation(nativeMap: MapLibreMap, style: Style) {
|
|
270
|
+
val permitted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
|
271
|
+
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
|
272
|
+
val enabled = permitted && craftNativeBoolean(pendingProps.opt("showsUserLocation"))
|
|
273
|
+
val component = nativeMap.locationComponent
|
|
274
|
+
if (!enabled && !component.isLocationComponentActivated) return
|
|
275
|
+
try {
|
|
276
|
+
if (!component.isLocationComponentActivated) component.activateLocationComponent(LocationComponentActivationOptions.builder(context, style).useDefaultLocationEngine(true).build())
|
|
277
|
+
component.isLocationComponentEnabled = enabled
|
|
278
|
+
} catch (error: SecurityException) {
|
|
279
|
+
// Permission may be revoked between the check and native activation.
|
|
280
|
+
if (component.isLocationComponentActivated) component.isLocationComponentEnabled = false
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private fun applyPadding() {
|
|
285
|
+
val value = pendingProps.optJSONObject("edgePadding")
|
|
286
|
+
fun inset(key: String): Double = (finiteNumber(value?.opt(key)) ?: 0.0).coerceIn(0.0, 10_000.0) * resources.displayMetrics.density
|
|
287
|
+
var left = inset("left"); var right = inset("right"); var top = inset("top"); var bottom = inset("bottom")
|
|
288
|
+
val availableWidth = (width - 40 * resources.displayMetrics.density).coerceAtLeast(0f)
|
|
289
|
+
val availableHeight = (height - 40 * resources.displayMetrics.density).coerceAtLeast(0f)
|
|
290
|
+
val horizontal = left + right; val vertical = top + bottom
|
|
291
|
+
if (horizontal > availableWidth && horizontal > 0) { left *= availableWidth / horizontal; right *= availableWidth / horizontal }
|
|
292
|
+
if (vertical > availableHeight && vertical > 0) { top *= availableHeight / vertical; bottom *= availableHeight / vertical }
|
|
293
|
+
map?.setPadding(left.roundToInt(), top.roundToInt(), right.roundToInt(), bottom.roundToInt())
|
|
294
|
+
// Floating STX controls reserve the same space for credits and scale
|
|
295
|
+
// as for route fitting, so a profile never covers either utility.
|
|
296
|
+
(attribution.layoutParams as LayoutParams).let { params ->
|
|
297
|
+
val margin = bottom.roundToInt() + (8 * resources.displayMetrics.density).roundToInt()
|
|
298
|
+
if (params.bottomMargin != margin) { params.bottomMargin = margin; attribution.layoutParams = params }
|
|
299
|
+
}
|
|
300
|
+
(scale.layoutParams as LayoutParams).let { params ->
|
|
301
|
+
val margin = bottom.roundToInt() + (32 * resources.displayMetrics.density).roundToInt()
|
|
302
|
+
if (params.bottomMargin != margin) { params.bottomMargin = margin; scale.layoutParams = params }
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private fun moveCamera(bounds: LatLngBounds, padding: Int) {
|
|
307
|
+
post {
|
|
308
|
+
val nativeMap = map
|
|
309
|
+
if (!destroyed && nativeMap != null && width > 0 && height > 0) {
|
|
310
|
+
val camera = nativeMap.cameraPosition
|
|
311
|
+
val bearing = finiteNumber(pendingProps.opt("cameraHeading")) ?: camera.bearing
|
|
312
|
+
val tilt = finiteNumber(pendingProps.opt("cameraPitch"))?.coerceIn(0.0, 60.0) ?: camera.tilt
|
|
313
|
+
val insets = nativeMap.padding.map { it + padding }.toIntArray()
|
|
314
|
+
val target = nativeMap.getCameraForLatLngBounds(bounds, insets, bearing, tilt)
|
|
315
|
+
if (target == null) initialCameraKey = null
|
|
316
|
+
else nativeMap.moveCamera(CameraUpdateFactory.newCameraPosition(target))
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private fun isCurrentReportedRegion(requested: LatLngBounds): Boolean {
|
|
322
|
+
val reported = reportedRegion ?: return false
|
|
323
|
+
val measured = reportedCamera ?: return false
|
|
324
|
+
val current = map?.cameraPosition ?: return false
|
|
325
|
+
val measuredTarget = measured.target ?: return false
|
|
326
|
+
val currentTarget = current.target ?: return false
|
|
327
|
+
fun same(a: Double, b: Double): Boolean = abs(a - b) < 0.000000001
|
|
328
|
+
return reportedViewport == (width to height) &&
|
|
329
|
+
same(requested.latitudeNorth, reported.latitudeNorth) && same(requested.latitudeSouth, reported.latitudeSouth) &&
|
|
330
|
+
same(requested.longitudeEast, reported.longitudeEast) && same(requested.longitudeWest, reported.longitudeWest) &&
|
|
331
|
+
abs(current.zoom - measured.zoom) < 0.000001 &&
|
|
332
|
+
same(currentTarget.latitude, measuredTarget.latitude) && same(currentTarget.longitude, measuredTarget.longitude)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private fun applyOrientation() {
|
|
336
|
+
val nativeMap = map ?: return
|
|
337
|
+
val camera = nativeMap.cameraPosition
|
|
338
|
+
val pitch = finiteNumber(pendingProps.opt("cameraPitch"))?.coerceIn(0.0, 60.0) ?: camera.tilt
|
|
339
|
+
val heading = finiteNumber(pendingProps.opt("cameraHeading")) ?: camera.bearing
|
|
340
|
+
if (abs(camera.tilt - pitch) > 0.01 || abs(camera.bearing - heading) > 0.01) {
|
|
341
|
+
nativeMap.moveCamera(CameraUpdateFactory.newCameraPosition(CameraPosition.Builder(camera).tilt(pitch).bearing(heading).build()))
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private fun updateScale() {
|
|
346
|
+
val nativeMap = map ?: return
|
|
347
|
+
if (!craftNativeBoolean(pendingProps.opt("showsScale")) || width <= 0 || height <= 0) { scale.visibility = View.GONE; return }
|
|
348
|
+
val pixels = 80 * resources.displayMetrics.density
|
|
349
|
+
val start = nativeMap.projection.fromScreenLocation(android.graphics.PointF(width / 2f, height / 2f))
|
|
350
|
+
val end = nativeMap.projection.fromScreenLocation(android.graphics.PointF(width / 2f + pixels, height / 2f))
|
|
351
|
+
val measured = FloatArray(1)
|
|
352
|
+
android.location.Location.distanceBetween(start.latitude, start.longitude, end.latitude, end.longitude, measured)
|
|
353
|
+
val meters = measured[0].toDouble()
|
|
354
|
+
if (!meters.isFinite() || meters <= 0) { scale.visibility = View.GONE; return }
|
|
355
|
+
val magnitude = Math.pow(10.0, kotlin.math.floor(kotlin.math.log10(meters)))
|
|
356
|
+
val length = listOf(5.0, 2.0, 1.0).firstOrNull { it * magnitude <= meters }?.times(magnitude) ?: magnitude
|
|
357
|
+
scaleLabel.text = if (length >= 1000) "${length / 1000} km" else "${length.roundToInt()} m"
|
|
358
|
+
scaleLine.layoutParams = LinearLayout.LayoutParams((pixels * length / meters).roundToInt().coerceAtLeast(1), 2)
|
|
359
|
+
scale.visibility = View.VISIBLE
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
|
|
363
|
+
super.onSizeChanged(width, height, oldWidth, oldHeight)
|
|
364
|
+
if (width > 0 && height > 0) applyPending()
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
override fun onAttachedToWindow() {
|
|
368
|
+
super.onAttachedToWindow()
|
|
369
|
+
if (destroyed) return
|
|
370
|
+
lifecycleOwner = context as? androidx.lifecycle.LifecycleOwner
|
|
371
|
+
val owner = lifecycleOwner
|
|
372
|
+
if (owner != null) owner.lifecycle.addObserver(lifecycleObserver)
|
|
373
|
+
else { startMap(); resumeMap() }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
override fun onDetachedFromWindow() {
|
|
377
|
+
lifecycleOwner?.lifecycle?.removeObserver(lifecycleObserver)
|
|
378
|
+
lifecycleOwner = null
|
|
379
|
+
stopMap()
|
|
380
|
+
super.onDetachedFromWindow()
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private fun startMap() {
|
|
384
|
+
if (!destroyed && !lifecycleStarted) { mapView.onStart(); lifecycleStarted = true }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private fun resumeMap() {
|
|
388
|
+
startMap()
|
|
389
|
+
if (!destroyed && !lifecycleResumed) { mapView.onResume(); lifecycleResumed = true }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private fun pauseMap() {
|
|
393
|
+
if (lifecycleResumed) { mapView.onPause(); lifecycleResumed = false }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private fun stopMap() {
|
|
397
|
+
pauseMap()
|
|
398
|
+
if (lifecycleStarted) { mapView.onStop(); lifecycleStarted = false }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
fun lowMemory() { if (!destroyed) mapView.onLowMemory() }
|
|
402
|
+
|
|
403
|
+
fun destroy() {
|
|
404
|
+
if (destroyed) return
|
|
405
|
+
destroyed = true
|
|
406
|
+
styleVersion++
|
|
407
|
+
onMarkerTap = null; onMapPress = null; onRegionChangeComplete = null
|
|
408
|
+
markerHandlers.clear(); geometryPoints = emptyList()
|
|
409
|
+
lifecycleOwner?.lifecycle?.removeObserver(lifecycleObserver)
|
|
410
|
+
lifecycleOwner = null
|
|
411
|
+
stopMap()
|
|
412
|
+
mapView.onDestroy()
|
|
413
|
+
readyStyle = null; map = null
|
|
414
|
+
reportedRegion = null; reportedCamera = null; reportedViewport = null
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private fun coordinates(value: JSONArray?): List<LatLng> {
|
|
418
|
+
if (value == null) return emptyList()
|
|
419
|
+
return (0 until value.length() - 1 step 2).mapNotNull { index ->
|
|
420
|
+
val latitude = finiteNumber(value.opt(index)) ?: return@mapNotNull null
|
|
421
|
+
val longitude = finiteNumber(value.opt(index + 1)) ?: return@mapNotNull null
|
|
422
|
+
if (latitude in -90.0..90.0 && longitude in -180.0..180.0) LatLng(latitude, longitude) else null
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private fun region(value: JSONObject?): LatLngBounds? {
|
|
427
|
+
val center = value?.optJSONObject("center") ?: return null
|
|
428
|
+
val span = value.optJSONObject("span") ?: return null
|
|
429
|
+
val latitude = finiteNumber(center.opt("latitude")) ?: return null
|
|
430
|
+
val longitude = finiteNumber(center.opt("longitude")) ?: return null
|
|
431
|
+
val height = finiteNumber(span.opt("latitudeDelta")) ?: finiteNumber(span.opt("latitude_delta")) ?: return null
|
|
432
|
+
val width = finiteNumber(span.opt("longitudeDelta")) ?: finiteNumber(span.opt("longitude_delta")) ?: return null
|
|
433
|
+
if (latitude !in -90.0..90.0 || longitude !in -180.0..180.0 || height <= 0 || height > 180 || width <= 0 || width > 360 || latitude - height / 2 < -90 || latitude + height / 2 > 90) return null
|
|
434
|
+
return LatLngBounds.from(latitude + height / 2, longitude + width / 2, latitude - height / 2, longitude - width / 2)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private fun finiteNumber(value: Any?): Double? = (value as? Number)?.toDouble()?.takeIf { it.isFinite() }
|
|
438
|
+
}
|
|
@@ -8,6 +8,7 @@ import android.content.Intent
|
|
|
8
8
|
import android.os.Build
|
|
9
9
|
import androidx.core.app.NotificationCompat
|
|
10
10
|
import androidx.core.app.NotificationManagerCompat
|
|
11
|
+
import androidx.core.content.ContextCompat
|
|
11
12
|
import com.google.firebase.messaging.FirebaseMessagingService
|
|
12
13
|
import com.google.firebase.messaging.RemoteMessage
|
|
13
14
|
import org.json.JSONObject
|
|
@@ -59,7 +60,8 @@ class CraftNativeMessagingService : FirebaseMessagingService() {
|
|
|
59
60
|
val id = (data["notification_id"]?.toIntOrNull() ?: System.currentTimeMillis().toInt())
|
|
60
61
|
val tap = PendingIntent.getActivity(this, id, open, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
|
61
62
|
val built = NotificationCompat.Builder(this, CHANNEL)
|
|
62
|
-
.setSmallIcon(
|
|
63
|
+
.setSmallIcon(R.drawable.craft_notification_icon)
|
|
64
|
+
.setColor(ContextCompat.getColor(this, R.color.craft_notification_color))
|
|
63
65
|
.setContentTitle(title)
|
|
64
66
|
.setContentText(body)
|
|
65
67
|
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
|
@@ -32,7 +32,8 @@ internal class CraftNativeNotificationReceiver : BroadcastReceiver() {
|
|
|
32
32
|
))
|
|
33
33
|
}
|
|
34
34
|
val notification = NotificationCompat.Builder(context, "craft_notifications")
|
|
35
|
-
.setSmallIcon(
|
|
35
|
+
.setSmallIcon(R.drawable.craft_notification_icon)
|
|
36
|
+
.setColor(ContextCompat.getColor(context, R.color.craft_notification_color))
|
|
36
37
|
.setContentTitle(value.optString("title"))
|
|
37
38
|
.setContentText(value.optString("body"))
|
|
38
39
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
|
@@ -12,6 +12,7 @@ import android.os.IBinder
|
|
|
12
12
|
import android.os.Looper
|
|
13
13
|
import androidx.core.app.NotificationCompat
|
|
14
14
|
import androidx.core.content.ContextCompat
|
|
15
|
+
import {{PACKAGE_NAME}}.R
|
|
15
16
|
import com.google.android.gms.location.LocationCallback
|
|
16
17
|
import com.google.android.gms.location.LocationRequest
|
|
17
18
|
import com.google.android.gms.location.LocationResult
|
|
@@ -287,7 +288,8 @@ class LocationRecordingService : Service() {
|
|
|
287
288
|
manager.createNotificationChannel(NotificationChannel(CHANNEL, "Activity recording", NotificationManager.IMPORTANCE_LOW))
|
|
288
289
|
}
|
|
289
290
|
val notification = NotificationCompat.Builder(this, CHANNEL)
|
|
290
|
-
.setSmallIcon(
|
|
291
|
+
.setSmallIcon(R.drawable.craft_notification_icon)
|
|
292
|
+
.setColor(ContextCompat.getColor(this, R.color.craft_notification_color))
|
|
291
293
|
.setContentTitle("Recording your activity")
|
|
292
294
|
.setContentText("${applicationInfo.loadLabel(packageManager)} is securely saving your route")
|
|
293
295
|
.setOngoing(true)
|