Arcology Engine

Location Picker Screen

Contents

The LocationPickerScreen displays an OpenStreetMap-based map (via MapCompose) 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.

LocationPickerScreen

Design: MapCompose for offline maps

The map uses OsmTileProvider from app/map/ to render OpenStreetMap tiles. We love OpenStreetMap.

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.

arcology.app.ui.screens.LocationPickerScreen

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 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.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import computer.whatthefuck.arcology.app.data.LocationService
import computer.whatthefuck.arcology.app.map.MapUtils
import computer.whatthefuck.arcology.app.map.OsmTileProvider
import computer.whatthefuck.arcology.domain.GeoCoordinate
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import ovh.plrapps.mapcompose.api.*
import ovh.plrapps.mapcompose.ui.MapUI
import ovh.plrapps.mapcompose.ui.state.MapState
import kotlin.math.pow

/**
 ,* A full-screen location picker that allows users to select a location on a map.
 ,*
 ,* @param initialLocation Initial location to center on and mark, or null for default
 ,* @param onLocationSelected Callback when user confirms a location
 ,* @param onDismiss Callback when user cancels
 ,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LocationPickerScreen(
    initialLocation: GeoCoordinate?,
    onLocationSelected: (GeoCoordinate) -> Unit,
    onDismiss: () -> Unit
) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
    val locationService: LocationService = koinInject()

    // Selected location state
    var selectedLocation by remember { mutableStateOf(initialLocation) }
    var isLocating by remember { mutableStateOf(false) }

    // Permission state
    var hasLocationPermission by remember {
        mutableStateOf(locationService.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) {
            // Fetch location after permission granted
            scope.launch {
                isLocating = true
                val location = locationService.getCurrentLocation()
                if (location != null) {
                    selectedLocation = location
                }
                isLocating = false
            }
        }
    }

    // Create tile provider
    val tileProvider = remember { OsmTileProvider(context) }

    // Create map state
    val mapState = remember {
        MapState(
            levelCount = OsmTileProvider.MAX_ZOOM + 1,
            fullWidth = OsmTileProvider.TILE_SIZE * 2.0.pow(OsmTileProvider.MAX_ZOOM.toDouble()).toInt(),
            fullHeight = OsmTileProvider.TILE_SIZE * 2.0.pow(OsmTileProvider.MAX_ZOOM.toDouble()).toInt(),
            workerCount = 4
        ).apply {
            addLayer(tileProvider)
            // Set initial view
            val initial = initialLocation ?: GeoCoordinate(0.0, 0.0)
            val (x, y) = MapUtils.geoToNormalized(initial)
            // Set a reasonable zoom level
            scale = if (initialLocation != null) 0.01 else 0.0001
        }
    }

    // Marker ID
    val markerId = "selected-location"

    // Update marker when location changes
    LaunchedEffect(selectedLocation) {
        mapState.removeMarker(markerId)
        selectedLocation?.let { coord ->
            val (x, y) = MapUtils.geoToNormalized(coord)
            mapState.addMarker(
                id = markerId,
                x = x,
                y = y
            ) {
                Icon(
                    Icons.Default.Place,
                    contentDescription = "Selected location",
                    tint = MaterialTheme.colorScheme.primary,
                    modifier = Modifier.size(48.dp)
                )
            }
        }
    }

    // Center map on initial location
    LaunchedEffect(Unit) {
        initialLocation?.let { coord ->
            val (x, y) = MapUtils.geoToNormalized(coord)
            mapState.scrollTo(x, y)
        }
    }

    // Handle map taps to place marker
    LaunchedEffect(mapState) {
        mapState.onTap { x, y ->
            val coord = MapUtils.normalizedToGeo(x, y)
            selectedLocation = coord
        }
    }

    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
            MapUI(
                modifier = Modifier.fillMaxSize(),
                state = mapState
            )

            // Locate me FAB
            FloatingActionButton(
                onClick = {
                    if (!hasLocationPermission) {
                        locationPermissionLauncher.launch(
                            arrayOf(
                                Manifest.permission.ACCESS_FINE_LOCATION,
                                Manifest.permission.ACCESS_COARSE_LOCATION
                            )
                        )
                    } else {
                        scope.launch {
                            isLocating = true
                            val location = locationService.getCurrentLocation()
                            if (location != null) {
                                selectedLocation = location
                                // Center map on new location
                                val (x, y) = MapUtils.geoToNormalized(location)
                                mapState.scrollTo(x, y)
                            }
                            isLocating = false
                        }
                    }
                },
                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 MapCompose library (external)

  • Uses MapUtils and OsmTileProvider from app/map/ (local)

  • Uses LocationService and GeoCoordinate from models.org

  • Called from screen.org (OrgDocumentEditorScreen)