Arcology Engine

Location Picker Screen

Contents

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.

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/screens/LocationPickerScreen.kt
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 MtMapStyle from app/map/ (local) — style + key resolution shared with the Find-tab map

  • Uses GeoCoordinate from models.org and AppPreferences from data.org

  • Called from screen.org (OrgDocumentEditorScreen)