diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/osm/AccessManagerDialog.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/osm/AccessManagerDialog.kt index 66f9035666..760c23db43 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/osm/AccessManagerDialog.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/osm/AccessManagerDialog.kt @@ -3,22 +3,30 @@ package de.westnordost.streetcomplete.osm import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.width -import androidx.compose.material.AlertDialog +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.selection.toggleable import androidx.compose.material.Button +import androidx.compose.material.Checkbox +import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import de.westnordost.streetcomplete.data.meta.CountryInfo import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapChangesBuilder @@ -30,7 +38,7 @@ import de.westnordost.streetcomplete.resources.cancel import de.westnordost.streetcomplete.resources.delete_confirmation import de.westnordost.streetcomplete.resources.ic_delete_24 import de.westnordost.streetcomplete.resources.ok -import de.westnordost.streetcomplete.ui.common.DropdownButton +import de.westnordost.streetcomplete.ui.common.dialogs.ScrollableAlertDialog import de.westnordost.streetcomplete.ui.common.dialogs.SimpleListPickerDialog import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -42,124 +50,344 @@ fun AccessManagerDialog( countryInfo: CountryInfo, onClickOk: (StringMapChangesBuilder) -> Unit ) { - val originalAccessTags = tags.filterKeys { key -> accessKeys.any { it == key || key.startsWith("$it:") } } - var newAccessTags by remember { mutableStateOf(originalAccessTags) } + val originalAccessTagsSets: Map> = remember(tags) { + tags.filterKeys { key -> accessKeys.any { it == key || key.startsWith("$it:") } } + .mapValues { entry -> parseValues(entry.value) } + } + + val newAccessTags = remember(originalAccessTagsSets) { + mutableStateMapOf>().apply { + originalAccessTagsSets.forEach { (key, values) -> + put(key, values.toMutableSet()) + } + } + } + var showAddDialog by remember { mutableStateOf(false) } var addKey by remember { mutableStateOf(null) } var showAddConditionalDialog by remember { mutableStateOf(false) } - AlertDialog( + val hasChanges = + normalizeAccessTags(originalAccessTagsSets) != + normalizeAccessTags(newAccessTags.mapValues { it.value.toSet() }) + + val sortedEntries = newAccessTags.toSortedMap().entries.map { it.key to it.value.sorted() } + + ScrollableAlertDialog( onDismissRequest = onDismissRequest, - buttons = { - Row(horizontalArrangement = Arrangement.End) { - TextButton(onDismissRequest) { Text(stringResource(Res.string.cancel)) } - TextButton( - onClick = { - val builder = StringMapChangesBuilder(tags) - newAccessTags.forEach { - if (originalAccessTags[it.key] != it.value) - builder[it.key] = it.value + content = { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + item { + Text( + text = stringResource(Res.string.access_manager_message), + style = MaterialTheme.typography.body1, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + + itemsIndexed(sortedEntries) { index, (key, values) -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = key, + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 4.dp) + ) + + values.forEach { value -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = value, + style = MaterialTheme.typography.body1, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { + val updated = newAccessTags[key]?.toMutableSet() ?: mutableSetOf() + updated.remove(value) + if (updated.isEmpty()) { + newAccessTags.remove(key) + } else { + newAccessTags[key] = updated + } + } + ) { + Icon( + painterResource(Res.drawable.ic_delete_24), + stringResource(Res.string.delete_confirmation) + ) + } + } + } + + if (index != sortedEntries.lastIndex) { + Divider(modifier = Modifier.padding(vertical = 8.dp)) + } } - originalAccessTags.keys.forEach { - if (it !in newAccessTags) - builder.remove(it) + } + + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Button( + onClick = { showAddDialog = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(Res.string.access_manager_button_add)) + } + + Button( + onClick = { showAddConditionalDialog = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(Res.string.access_manager_button_add_conditional)) + } } - onClickOk(builder) - }, - enabled = originalAccessTags != newAccessTags - ) { - Text(stringResource(Res.string.ok)) + } } } }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text(stringResource(Res.string.access_manager_message)) - newAccessTags.forEach { (key, value) -> - Row(verticalAlignment = Alignment.CenterVertically) { - Text(key, Modifier.weight(0.4f)) - Spacer(Modifier.width(6.dp)) - val values = if (value in accessValues) accessValues else (listOf(value) + accessValues) - DropdownButton(values, { newAccessTags += key to it }, Modifier.weight(0.4f), value, itemContent = { Text(it)} ) - IconButton({ newAccessTags -= key }) { Icon(painterResource(Res.drawable.ic_delete_24), stringResource(Res.string.delete_confirmation)) } + buttons = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(Res.string.cancel)) + } + TextButton( + onClick = { + val builder = StringMapChangesBuilder(tags) + + newAccessTags.forEach { (key, values) -> + val joined = serializeValues(values) + val originalJoined = originalAccessTagsSets[key]?.let { serializeValues(it) } + if (joined != originalJoined) { + if (joined.isEmpty()) builder.remove(key) else builder[key] = joined + } } - } - Button({ showAddDialog = true }, Modifier.fillMaxWidth()) { Text(stringResource(Res.string.access_manager_button_add)) } - Button({ showAddConditionalDialog = true }, Modifier.fillMaxWidth()) { Text(stringResource(Res.string.access_manager_button_add_conditional)) } + + originalAccessTagsSets.keys.forEach { key -> + if (key !in newAccessTags || newAccessTags[key].isNullOrEmpty()) { + builder.remove(key) + } + } + + onClickOk(builder) + }, + enabled = hasChanges + ) { + Text(stringResource(Res.string.ok)) } } ) + if (showAddDialog) { SimpleListPickerDialog( - onDismissRequest = { showAddDialog = false }, + onDismissRequest = { showAddDialog = false }, items = accessKeys, - onItemSelected = { addKey = it } + onItemSelected = { selectedKey -> + addKey = selectedKey + showAddDialog = false + } ) } - if (addKey != null) { - val key = addKey!! - SimpleListPickerDialog( - onDismissRequest = { addKey = null }, - items = accessValues, - onItemSelected = { newAccessTags += key to it } + + addKey?.let { key -> + AccessValuesDialog( + title = key, + values = accessValues, + initialSelected = newAccessTags[key]?.toSet() ?: emptySet(), + onDismissRequest = { addKey = null }, + onConfirm = { selected -> + if (selected.isEmpty()) { + newAccessTags.remove(key) + } else { + newAccessTags[key] = selected.toMutableSet() + } + addKey = null + } ) } + if (showAddConditionalDialog) { AddConditionalDialog( onDismissRequest = { showAddConditionalDialog = false }, - keys = accessKeys.toList(), - values = listOf("yes", "no", "delivery", "destination"), - numberOnly = true, + keys = accessKeys, + values = listOf("yes", "no", "delivery", "destination", "discouraged", "private"), + numberOnly = false, countryInfo = countryInfo ) { key, value -> - newAccessTags += key to value + val updated = (newAccessTags[key]?.toMutableSet() ?: mutableSetOf()) + updated.add(value) + newAccessTags[key] = updated showAddConditionalDialog = false } } } +@Composable +private fun AccessValuesDialog( + title: String, + values: List, + initialSelected: Set, + onDismissRequest: () -> Unit, + onConfirm: (Set) -> Unit +) { + val selectedStates = remember(values, initialSelected) { + mutableStateMapOf().apply { + values.forEach { value -> + put(value, value in initialSelected) + } + } + } + + ScrollableAlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text( + text = title, + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.SemiBold + ) + }, + content = { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 360.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + items(values) { value -> + val checked = selectedStates[value] == true + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = checked, + onValueChange = { isChecked -> + selectedStates[value] = isChecked + } + ) + ) { + Checkbox( + checked = checked, + onCheckedChange = { isChecked -> + selectedStates[value] = isChecked + } + ) + Text( + text = value, + style = MaterialTheme.typography.body1, + modifier = Modifier.padding(start = 6.dp) + ) + } + } + } + } + }, + buttons = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(Res.string.cancel)) + } + TextButton( + onClick = { + val selected = selectedStates + .filterValues { it } + .keys + .toSet() + onConfirm(selected) + } + ) { + Text(stringResource(Res.string.ok)) + } + } + ) +} + +private fun parseValues(value: String): Set = + value.split(';') + .map { it.trim() } + .filter { it.isNotEmpty() } + .toSet() + +private fun serializeValues(values: Set): String = + values.filter { it.isNotBlank() } + .sorted() + .joinToString(";") + +private fun normalizeAccessTags(tags: Map>): Map = + tags.mapValues { (_, values) -> serializeValues(values) } + .filterValues { it.isNotEmpty() } + +// Access keys and values are used in multiple places (dialogs, overlays) - Usage figures as of February 2026 val accessKeys = listOf( // sorted by number of uses - "access", // 18m - "foot", // 7m - "bicycle", // 7m - "bus", // 3.5m - "motor_vehicle", // 2m - "horse", // 1.6m - "hgv", // 790k - "motorcar", // 590k - "motorcycle", // 580k - "vehicle", // 350k - "moped", // 235k - "mofa", // 200k - "golf_cart", // 158k - "psv", // 115k - "hazmat", // 87k - "dog", // 80k + "access", // 25m + "foot", // 13m + "bicycle", // 9m + "bus", // 4.8m + "motor_vehicle", // 2.6m + "horse", // 1.9m + "hgv", // 1.5m + "motorcycle", // 900k + "motorcar", // 800k + "vehicle", // 460k + "mofa", // 318k + "moped", // 317k + "golf_cart", // 229k + "hazmat", // 168k + "dog", // 156k + "psv", // 127k + "snowmobile", // 117k + "emergency", // 117k + "mtb", // 88k + "ski", // 70k "bdouble", // 60k - "ski", // 60k - "goods", // 41k - "taxi", // 23k - "carriage", // 20k + "goods", // 53k + "taxi", // 30k + "carriage", // 22k + "disabled", // 21k "hov", // 20k - "disabled", // 13.5k - "tourist_bus", // 13k - "atv", // 12k - "hand_cart", // 6.8k - "inline_skates", // 5k - "speed_pedelec", // 3.7k - "motorhome", // 3.5k - "trailer", // 2.7k - "ohv", // 2.4k - "caravan", // 2k - "coach", // 1.7k - "carpool", // 1.5k - "hgv_articulated", // 1k - "small_electric_vehicle", // 800 - "auto_rickshaw", // 625 - "electric_bicycle", // 335 - "cycle_rickshaw", // 78 - "nev", // 62 - "kick_scooter", // 60 + "atv", // 19k + "tourist_bus", // 18k + "trailer", // 12k + "motorhome", // 10.9k + "ohv", // 9.9k + "hand_cart", // 7.6k + "speed_pedelec", // 7.2k + "inline_skates", // 6.8k + "small_electric_vehicle", // 4.6k + "coach", // 3.7k + "caravan", // 2.8k + "electric_bicycle", // 2k + "carpool", // 1.9k + "hgv_articulated", // 1.9k + "auto_rickshaw", // 1.2k + "kick_scooter", // 467 + "cycle_rickshaw", // 237 + "nev", // 66 ) val accessValues = listOf( @@ -177,5 +405,6 @@ val accessValues = listOf( "agricultural", "forestry", "discouraged", // really required explicit sign + // "military", disputed tag //"variable", doesn't make sense without supporting access:lanes )