The LocationPickerScreen displays a MapTiler-backed MapLibre GL map (the MapTiler SDK Kotlin — see map-screen.org's design decisions; the 2026-10-10 swap deleted the MapCompose-era MapUtils /=OsmTileProvider= pair this screen used) where the user can tap to select a geographic coordinate. It also supports using the device's current GPS location via a "locate me" FAB, backed by the SDK's MTLocationManager. The selected point shows as an MTMarker on the style; the API key and style resolve through the shared MtMapStyle helper against the AppPreferences set in SettingsScreen.
LocationPickerScreen
Design: MapTiler SDK for the pick surface
MTMapView + MTMapViewController render the same style the Find-tab map uses (MTMapReferenceStyle resolution incl. custom UUID/URL styles). ON_TAP events carry the tap's LngLat — that IS the picked coordinate (GL-accurate, no hand-rolled projection). The selection marker is an MTMarker re-attached on every selection change and again on ON_LOAD (the style proxy is null until load, so setSelection stores the selection and re-installs); the initial center/zoom come from MTMapOptions (initial location → zoom 14, none → zoom 2 world view).
Design: Permission handling inline
Location permission is requested inline via ActivityResultContracts.RequestMultiplePermissions rather than through the ViewModel. This keeps the map screen self-contained. If you don't use the location features you don't need them. The SDK's MTLocationManager delivers fixes through its delegate on the main thread; without permission granted the FAB routes through the launcher first.
package computer.whatthefuck.arcology.app.ui.screens
import android.Manifest
import android.location.Location
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material.icons.filled.Place
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.maptiler.maptilersdk.MTConfig
import com.maptiler.maptilersdk.annotations.MTMarker
import com.maptiler.maptilersdk.events.MTEvent
import com.maptiler.maptilersdk.location.MTLocationManager
import com.maptiler.maptilersdk.location.MTLocationManagerDelegate
import com.maptiler.maptilersdk.map.LngLat
import com.maptiler.maptilersdk.map.types.MTData
import com.maptiler.maptilersdk.map.MTMapOptions
import com.maptiler.maptilersdk.map.MTMapViewController
import com.maptiler.maptilersdk.map.MTMapView
import com.maptiler.maptilersdk.map.MTMapViewDelegate
import com.maptiler.maptilersdk.map.style.MTMapReferenceStyle
import computer.whatthefuck.arcology.app.data.AppPreferences
import computer.whatthefuck.arcology.app.map.MtMapStyle
import computer.whatthefuck.arcology.domain.GeoCoordinate
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
/**
* Delegate wiring SDK events to the pick state: taps ARE the picked
* coordinate, the selection marker (re)attaches on every change and after
* the style loads, and the locate-me fixes arrive via the LocationManager
* delegate half.
*
* Bridge-timing rule (same as the Find-tab map screen): the Kotlin MTStyle
* proxy goes non-null BEFORE the WebView's JS `map` object exists, and any
* style call dispatched early throws `map is not defined` on the JS side and
* silently vanishes — marker attach waits on the SDK's own ON_LOAD event plus
* an isSourceLoaded probe gate, with its lifecycle logged under TAG because
* the SDK swallows its own constructor failures invisibly.
*/
private const val TAG = "ArcologyMap"
private class LocationPickerDelegate(
val controller: MTMapViewController,
private val scope: kotlinx.coroutines.CoroutineScope,
private val markerColor: Int,
private val onPicked: (GeoCoordinate) -> Unit,
private val onLocating: (Boolean) -> Unit
) : MTMapViewDelegate, MTLocationManagerDelegate {
private var selection: GeoCoordinate? = null
private var marker: MTMarker? = null
private var attachInFlight = false
private var mapReady = false
/** True when the readiness probe exhausted its attempts — surfaced as a
* card so a silent SDK death is diagnosable on-device. */
var failed by androidx.compose.runtime.mutableStateOf(false)
override fun onMapViewInitialized() {
android.util.Log.i(TAG, "picker: onMapViewInitialized")
}
override fun onEventTriggered(event: MTEvent, data: MTData?) {
android.util.Log.i(TAG, "picker: event $event")
when (event) {
MTEvent.ON_TAP -> data?.coordinate?.let { lngLat ->
val coord = GeoCoordinate(lngLat.lat, lngLat.lng)
setSelection(coord)
onPicked(coord)
}
MTEvent.ON_LOAD -> applySelection() // style went non-null
else -> {}
}
}
/** (Re)attach the selection marker remembering the latest selection.
* Pre-readiness this only stashes — the SDK's own ON_LOAD event starts
* the gate, so nothing dispatches during page init (racing it prints one
* `map is not defined` uncaught per early attempt). */
fun setSelection(coord: GeoCoordinate?) {
selection = coord
if (mapReady) {
scope.launch { attachSelection() }
}
}
/** Timer backstop for the readiness gate (see the picker screen's
* LaunchedEffect): starts the gate even when the SDK's events never
* reach us. */
fun onMountTimerFired() {
if (!mapReady) applySelection()
}
/** Wait for the bridge's JS `map` object (isSourceLoaded probe — see the
* map screen delegate's bridge-timing rule), then attach the marker. */
private fun applySelection() {
if (attachInFlight) return
attachInFlight = true
scope.launch {
try {
var attempts = 0
while (!mapReady && attempts < 90) {
attempts++
try {
android.util.Log.i(
TAG,
"picker gate attempt $attempts: style=${controller.style}"
)
if (controller.style?.isSourceLoaded(MtMapStyle.MAP_READY_PROBE) == false) {
mapReady = true
android.util.Log.i(TAG, "picker gate: probe definitive false → mapReady")
}
} catch (e: Exception) {
android.util.Log.i(
TAG,
"picker gate attempt $attempts threw: ${e.message}"
)
}
if (!mapReady) kotlinx.coroutines.delay(250)
}
if (!mapReady) failed = true
if (mapReady) attachSelection()
} finally {
attachInFlight = false
}
}
}
private suspend fun attachSelection() {
val style = controller.style ?: return
marker?.let { old ->
runCatching { style.removeMarker(old) }
marker = null
}
selection?.let { picked ->
try {
val newMarker = MTMarker(LngLat(picked.longitude, picked.latitude), markerColor)
style.addMarker(newMarker)
marker = newMarker
} catch (e: Exception) {
marker = null
}
}
}
override fun didUpdateLocation(location: Location) {
onLocating(false)
val coord = GeoCoordinate(location.latitude, location.longitude)
setSelection(coord)
onPicked(coord)
controller.setCenter(LngLat(coord.longitude, coord.latitude))
controller.setZoom(13.0)
}
override fun didFailWithError(error: Throwable) {
onLocating(false)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LocationPickerScreen(
initialLocation: GeoCoordinate?,
onLocationSelected: (GeoCoordinate) -> Unit,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val appPreferences: AppPreferences = koinInject()
// Same key gate as the main map screen: without a MapTiler key the GL
// map cannot render, so show the hint and stop.
val apiKey = remember {
appPreferences.getMapTilerApiKey().also { if (it.isNotBlank()) MTConfig.apiKey = it }
}
if (apiKey.isBlank()) {
Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(24.dp)) {
Text("No MapTiler key", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
"Location picking renders from MapTiler Cloud. Set your API key " +
"in Settings → Map Display, or enter the coordinates directly.",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
return
}
val primaryColor = MaterialTheme.colorScheme.primary.toArgb()
val referenceStyle = remember { MtMapStyle.resolveReference(appPreferences.getMapStyle(), apiKey) }
val styleVariant = remember { MtMapStyle.resolveVariant(appPreferences.getMapStyle()) }
android.util.Log.i(
TAG,
"picker style resolved: ${referenceStyle.getName()} custom=${referenceStyle.isCustom()}" +
(referenceStyle as? MTMapReferenceStyle.CUSTOM)?.url?.let { " $it" }.orEmpty()
)
var selectedLocation by remember { mutableStateOf(initialLocation) }
var isLocating by remember { mutableStateOf(false) }
val controller = remember { MTMapViewController(context) }
val delegate = remember(controller, primaryColor) {
LocationPickerDelegate(
controller = controller,
scope = scope,
markerColor = primaryColor,
onPicked = { coord -> selectedLocation = coord },
onLocating = { isLocating = it }
).also { controller.delegate = it }
}
// Selection marker follows the state; the delegate also re-installs it
// when the style loads.
LaunchedEffect(selectedLocation) { delegate.setSelection(selectedLocation) }
// Timer backstop for the readiness gate (see the map screen comment):
// ON_LOAD should start it, but if the SDK's events never cross the
// bridge the marker would silently never attach.
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(2500)
delegate.onMountTimerFired()
}
val locationManager = remember(context, delegate) {
MTLocationManager(context).also { it.delegate = delegate }
}
var hasLocationPermission by remember {
mutableStateOf(locationManager.hasLocationPermission())
}
val locationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
hasLocationPermission = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
if (hasLocationPermission) {
isLocating = true
locationManager.requestLocationOnce()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Select Location") },
navigationIcon = {
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Cancel")
}
},
actions = {
IconButton(
onClick = {
selectedLocation?.let { onLocationSelected(it) }
},
enabled = selectedLocation != null
) {
Icon(Icons.Default.Check, contentDescription = "Confirm")
}
}
)
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
// Map. Options remembered — an inline MTMapOptions recomposes into
// a "changed" parameter on every state change and the SDK re-runs
// initializeMap over itself (same re-init thrash as the Find-tab
// map screen; see its mapOptions comment).
val pickerMapOptions = remember(initialLocation) {
MTMapOptions(
center = initialLocation?.let { LngLat(it.longitude, it.latitude) },
zoom = if (initialLocation != null) 14.0 else 2.0
)
}
MTMapView(
referenceStyle = referenceStyle,
styleVariant = styleVariant,
options = pickerMapOptions,
controller = controller,
modifier = Modifier.fillMaxSize()
)
if (delegate.failed) {
Card(
modifier = Modifier
.align(Alignment.Center)
.padding(16.dp)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text("Map failed to initialize", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
"The MapTiler bridge never went ready. Check Settings → Map Display " +
"(API key, style) and the device network, then reopen.",
style = MaterialTheme.typography.bodySmall
)
}
}
}
// Locate me FAB
FloatingActionButton(
onClick = {
if (!hasLocationPermission) {
locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
} else {
isLocating = true
locationManager.requestLocationOnce()
}
},
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
containerColor = MaterialTheme.colorScheme.primaryContainer
) {
if (isLocating) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
Icon(
Icons.Default.MyLocation,
contentDescription = "Use current location"
)
}
}
// Coordinates display
selectedLocation?.let { coord ->
Surface(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(16.dp)
.padding(bottom = 72.dp),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f),
tonalElevation = 2.dp
) {
Text(
text = "%.6f, %.6f".format(coord.latitude, coord.longitude),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodySmall
)
}
}
// Instruction text when no location selected
if (selectedLocation == null) {
Surface(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 16.dp),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f),
tonalElevation = 2.dp
) {
Text(
text = "Tap on the map to select a location",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}Related Modules
Uses the
MapTiler SDK Kotlin(external,com.maptiler:maptiler-sdk-kotlin)Uses
MtMapStylefromapp/map/(local) — style + key resolution shared with the Find-tab mapUses
GeoCoordinatefrom models.org andAppPreferencesfrom data.orgCalled from screen.org (OrgDocumentEditorScreen)