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
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
MapComposelibrary (external)Uses
MapUtilsandOsmTileProviderfromapp/map/(local)Uses
LocationServiceandGeoCoordinatefrom models.orgCalled from screen.org (OrgDocumentEditorScreen)