From ebcb603596520c245d1e7833e22b33692484c2bd Mon Sep 17 00:00:00 2001 From: mcliquid Date: Tue, 10 Feb 2026 15:13:28 +0100 Subject: [PATCH 01/10] Allow multiple access values per key in access manager --- .../util/AccessManagerDialog.kt | 136 ++++++++++++++---- app/src/androidMain/res/layout/row_access.xml | 18 +-- 2 files changed, 114 insertions(+), 40 deletions(-) diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/AccessManagerDialog.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/AccessManagerDialog.kt index 1657688e63f..ee5c96451b8 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/AccessManagerDialog.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/AccessManagerDialog.kt @@ -3,8 +3,7 @@ package de.westnordost.streetcomplete.util import android.content.Context import android.view.LayoutInflater import android.view.View -import android.widget.AdapterView -import android.widget.ArrayAdapter +import android.widget.CheckBox import androidx.appcompat.app.AlertDialog import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapChangesBuilder @@ -13,15 +12,33 @@ import de.westnordost.streetcomplete.databinding.RowAccessBinding import de.westnordost.streetcomplete.util.dialogs.showAddConditionalDialog import de.westnordost.streetcomplete.util.ktx.dpToPx -class AccessManagerDialog(context: Context, tags: Map, onClickOk: (StringMapChangesBuilder) -> Unit) : AlertDialog(context) { +class AccessManagerDialog( + context: Context, + tags: Map, + onClickOk: (StringMapChangesBuilder) -> Unit +) : AlertDialog(context) { + private val binding = DialogAccessManagerBinding.inflate(LayoutInflater.from(context)) - private val originalAccessTags = tags.filterKeys { key -> accessKeys.any { it == key || key.startsWith("$it:") } } - private val newAccessTags = LinkedHashMap(originalAccessTags) + + // original tags filtered to access keys, but parsed into sets + private val originalAccessTagsSets: Map> = + tags.filterKeys { key -> accessKeys.any { it == key || key.startsWith("$it:") } } + .mapValues { (_, v) -> parseValues(v) } + // working copy: mutable sets we update from UI + private val newAccessTags: MutableMap> = + LinkedHashMap(originalAccessTagsSets.mapValues { (_, v) -> v.toMutableSet() }) init { binding.addConditionalButton.setOnClickListener { - showAddConditionalDialog(context, accessKeys.toList(), listOf("yes", "no", "delivery", "destination"), null) { k, v -> - newAccessTags[k] = v + showAddConditionalDialog( + context, + accessKeys.toList(), + listOf("yes", "no", "delivery", "destination"), + null + ) { k, v -> + // ensure set exists + val set = newAccessTags.getOrPut(k) { mutableSetOf() } + set.add(v) createAccessTagViews() } } @@ -32,59 +49,104 @@ class AccessManagerDialog(context: Context, tags: Map, onClickOk setButton(BUTTON_NEGATIVE, context.getString(android.R.string.cancel)) { _, _ -> } setButton(BUTTON_POSITIVE, context.getString(android.R.string.ok)) { _, _ -> val builder = StringMapChangesBuilder(tags) - newAccessTags.forEach { - if (originalAccessTags[it.key] != it.value) - builder[it.key] = it.value + newAccessTags.forEach { (k, set) -> + val joined = serializeValues(set) + val origJoined = originalAccessTagsSets[k]?.let { serializeValues(it) } + if (origJoined != joined) { + if (joined.isEmpty()) builder.remove(k) else builder[k] = joined + } } - originalAccessTags.keys.forEach { - if (it !in newAccessTags) - builder.remove(it) + originalAccessTagsSets.keys.forEach { k -> + if (k !in newAccessTags || newAccessTags[k].isNullOrEmpty()) builder.remove(k) } onClickOk(builder) } + setOnShowListener { + updateOkButton() + } } private fun updateOkButton() { - getButton(BUTTON_POSITIVE).isEnabled = originalAccessTags != newAccessTags + val origMap = originalAccessTagsSets.mapValues { (_, s) -> serializeValues(s) } + val newMap = newAccessTags.mapValues { (_, s) -> serializeValues(s) } + getButton(BUTTON_POSITIVE)?.isEnabled = origMap != newMap } private fun createAccessTagViews() { binding.accessTags.removeAllViews() - newAccessTags.forEach { binding.accessTags.addView(accessView(it.key, it.value)) } + newAccessTags.forEach { (key, set) -> + binding.accessTags.addView(accessView(key, set)) + } + updateOkButton() } - private fun accessView(key: String, value: String): View { + private fun accessView(key: String, valuesSet: MutableSet): View { val view = RowAccessBinding.inflate(LayoutInflater.from(context)) view.keyText.text = key - val values = if (value in accessValues) accessValues else (arrayOf(value) + accessValues) - view.valueSpinner.adapter = ArrayAdapter(binding.root.context, android.R.layout.simple_dropdown_item_1line, values) - view.valueSpinner.setSelection(accessValues.indexOf(value)) - view.valueSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - val selected = values[position] - newAccessTags[key] = selected + + // Clear any previous dynamic checkbox container if present + // We assume row_access.xml has a ViewGroup with id 'valueContainer' + val container = view.valueContainer // provided in XML + + container.removeAllViews() + + // create checkbox for each known accessValue; if the current value is not in accessValues, show it first as checked + val existing = valuesSet.toMutableSet() + // Only show currently selected values in the compact list + val valuesToShow: List = existing.sorted() + + for (valStr in valuesToShow) { + val check = CheckBox(binding.root.context) + check.text = valStr + check.isChecked = valStr in existing + check.setOnCheckedChangeListener { _, checked -> + if (!checked) { + valuesSet.remove(valStr) + if (valuesSet.isEmpty()) { + newAccessTags.remove(key) + } + createAccessTagViews() + } updateOkButton() } - override fun onNothingSelected(p0: AdapterView<*>?) { } // just do nothing? or remove tag? + // small padding + check.setPadding(0, context.resources.dpToPx(2).toInt(), 0, context.resources.dpToPx(2).toInt()) + container.addView(check) } + + // delete button removes the whole key view.deleteButton.setOnClickListener { newAccessTags.remove(key) createAccessTagViews() } + view.root.setPadding(0, context.resources.dpToPx(4).toInt(), 0, context.resources.dpToPx(4).toInt()) return view.root } - // maybe reduce height, but need a simple solution... + // Show dialog to add a new key -> choose key then multi-choice values private fun showAddAccessDialog(context: Context) { - Builder(context) - .setTitle("key") + AlertDialog.Builder(context) + .setTitle(R.string.add_access) .setSingleChoiceItems(accessKeys, -1) { di, i -> + val key = accessKeys[i] + // multi-choice values dialog + val checked = BooleanArray(accessValues.size) Builder(context) - .setTitle("value") - .setSingleChoiceItems(accessValues, -1) { di2, j -> - newAccessTags[accessKeys[i]] = accessValues[j] - createAccessTagViews() + .setTitle(R.string.manage_access) + .setMultiChoiceItems(accessValues, checked) { _, idx, isChecked -> + checked[idx] = isChecked + } + .setPositiveButton(android.R.string.ok) { di2, _ -> + val selected = accessValues + .withIndex() + .filter { checked[it.index] } + .map { it.value } + if (selected.isNotEmpty()) { + val set = newAccessTags.getOrPut(key) { mutableSetOf() } + set.addAll(selected) + createAccessTagViews() + } di2.dismiss() } .setNegativeButton(android.R.string.cancel, null) @@ -94,8 +156,20 @@ class AccessManagerDialog(context: Context, tags: Map, onClickOk .setNegativeButton(android.R.string.cancel, null) .show() } + + companion object { + + // helper: parse semicolon separated values into set + private fun parseValues(v: String): MutableSet = + v.split(';').map { it.trim() }.filter { it.isNotEmpty() }.toMutableSet() + + // serialize set into stable ; separated string + private fun serializeValues(values: Set): String = + values.filter { it.isNotBlank() }.toSet().sorted().joinToString(";") + } } +// Access keys and values are used in multiple places (dialogs, overlays) val accessKeys = arrayOf( // sorted by number of uses "access", // 18m "foot", // 7m diff --git a/app/src/androidMain/res/layout/row_access.xml b/app/src/androidMain/res/layout/row_access.xml index 31af9872f44..4a08dbf11ac 100644 --- a/app/src/androidMain/res/layout/row_access.xml +++ b/app/src/androidMain/res/layout/row_access.xml @@ -13,21 +13,21 @@ android:layout_width="0dp" android:layout_weight="0.4" android:layout_height="wrap_content" - android:textAppearance="@android:style/TextAppearance.Theme.Dialog" + android:textAppearance="@android:style/TextAppearance.Widget.TextView" android:textSize="16sp" tools:text="access" android:paddingStart="0dp" - android:paddingEnd="4dp"/> + android:paddingEnd="8dp"/> - + + android:layout_height="wrap_content" + android:orientation="vertical" + android:paddingStart="4dp" + android:paddingEnd="4dp" /> Date: Wed, 11 Feb 2026 13:43:13 +0100 Subject: [PATCH 02/10] Updated accessKeys: Order by usage, added some new (popular) tags --- .../util/AccessManagerDialog.kt | 85 ++++++++++--------- 1 file changed, 45 insertions(+), 40 deletions(-) diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/AccessManagerDialog.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/AccessManagerDialog.kt index ee5c96451b8..3174fa85368 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/AccessManagerDialog.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/AccessManagerDialog.kt @@ -169,49 +169,53 @@ class AccessManagerDialog( } } -// Access keys and values are used in multiple places (dialogs, overlays) +// Access keys and values are used in multiple places (dialogs, overlays) - Usage figures as of February 2026 val accessKeys = arrayOf( // 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 + "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 = arrayOf( @@ -229,5 +233,6 @@ val accessValues = arrayOf( "agricultural", "forestry", "discouraged", // really required explicit sign + // "military", disputed tag //"variable", doesn't make sense without supporting access:lanes ) From e93b751cce5e71ce8e1a294fc7c1ded5e0b3e3a5 Mon Sep 17 00:00:00 2001 From: mcliquid Date: Tue, 17 Feb 2026 17:55:40 +0100 Subject: [PATCH 03/10] removed duplicate entry --- .../de/westnordost/streetcomplete/util/AccessManagerDialog.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/AccessManagerDialog.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/AccessManagerDialog.kt index 3174fa85368..2a07458bd84 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/AccessManagerDialog.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/AccessManagerDialog.kt @@ -191,7 +191,6 @@ val accessKeys = arrayOf( // sorted by number of uses "emergency", // 117k "mtb", // 88k "ski", // 70k - "ski", // 70k "bdouble", // 60k "goods", // 53k "taxi", // 30k From 01e1e881f18e4086a75137e3a20f8edef5c8cad6 Mon Sep 17 00:00:00 2001 From: mcliquid Date: Wed, 18 Feb 2026 08:02:31 +0100 Subject: [PATCH 04/10] Two more values for conditionals For hiking trails mostly --- .../de/westnordost/streetcomplete/util/AccessManagerDialog.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/AccessManagerDialog.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/AccessManagerDialog.kt index 2a07458bd84..ae3199a74ab 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/AccessManagerDialog.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/AccessManagerDialog.kt @@ -33,7 +33,7 @@ class AccessManagerDialog( showAddConditionalDialog( context, accessKeys.toList(), - listOf("yes", "no", "delivery", "destination"), + listOf("yes", "no", "delivery", "destination", "discouraged", "private"), null ) { k, v -> // ensure set exists From 2558181a2bd69e01bdb19769cf5e37d205af732a Mon Sep 17 00:00:00 2001 From: mcliquid Date: Wed, 1 Apr 2026 15:56:01 +0200 Subject: [PATCH 05/10] Rebuild access manager dialog with multi selection in Compose --- .../streetcomplete/osm/AccessManagerDialog.kt | 375 ++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 app/src/commonMain/kotlin/de/westnordost/streetcomplete/osm/AccessManagerDialog.kt diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/osm/AccessManagerDialog.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/osm/AccessManagerDialog.kt new file mode 100644 index 00000000000..fb9f99a6154 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/osm/AccessManagerDialog.kt @@ -0,0 +1,375 @@ +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.foundation.selection.toggleable +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.Checkbox +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.data.meta.CountryInfo +import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapChangesBuilder +import de.westnordost.streetcomplete.resources.Res +import de.westnordost.streetcomplete.resources.access_manager_button_add +import de.westnordost.streetcomplete.resources.access_manager_button_add_conditional +import de.westnordost.streetcomplete.resources.access_manager_message +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.dialogs.SimpleListPickerDialog +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +@Composable +fun AccessManagerDialog( + onDismissRequest: () -> Unit, + tags: Map, + countryInfo: CountryInfo, + onClickOk: (StringMapChangesBuilder) -> Unit +) { + 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 editKey by remember { mutableStateOf(null) } + var showAddConditionalDialog by remember { mutableStateOf(false) } + + val hasChanges = + normalizeAccessTags(originalAccessTagsSets) != + normalizeAccessTags(newAccessTags.mapValues { it.value.toSet() }) + + AlertDialog( + onDismissRequest = onDismissRequest, + buttons = { + Row(horizontalArrangement = Arrangement.End) { + 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 + } + } + } + + originalAccessTagsSets.keys.forEach { key -> + if (key !in newAccessTags || newAccessTags[key].isNullOrEmpty()) { + builder.remove(key) + } + } + + onClickOk(builder) + }, + enabled = hasChanges + ) { + Text(stringResource(Res.string.ok)) + } + } + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(Res.string.access_manager_message)) + + newAccessTags.toSortedMap().forEach { (key, values) -> + Row(verticalAlignment = Alignment.Top) { + Text(key, Modifier.weight(0.35f)) + Spacer(Modifier.width(6.dp)) + + Column( + modifier = Modifier.weight(0.45f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + values.sorted().forEach { value -> + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = true, + onCheckedChange = { checked -> + if (!checked) { + val updated = values.toMutableSet() + updated.remove(value) + if (updated.isEmpty()) { + newAccessTags.remove(key) + } else { + newAccessTags[key] = updated + } + } + } + ) + Text(value) + } + } + + TextButton(onClick = { editKey = key }) { + Text(stringResource(Res.string.access_manager_button_add)) + } + } + + IconButton(onClick = { newAccessTags.remove(key) }) { + Icon( + painterResource(Res.drawable.ic_delete_24), + stringResource(Res.string.delete_confirmation) + ) + } + } + } + + 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)) + } + } + } + ) + + if (showAddDialog) { + SimpleListPickerDialog( + onDismissRequest = { showAddDialog = false }, + items = accessKeys, + onItemSelected = { selectedKey -> + addKey = selectedKey + showAddDialog = false + } + ) + } + + addKey?.let { key -> + AccessValuesDialog( + title = key, + values = accessValues, + initialSelected = newAccessTags[key]?.toSet() ?: emptySet(), + onDismissRequest = { addKey = null }, + onConfirm = { selected -> + if (selected.isNotEmpty()) { + newAccessTags[key] = selected.toMutableSet() + } + addKey = null + } + ) + } + + editKey?.let { key -> + AccessValuesDialog( + title = key, + values = accessValues, + initialSelected = newAccessTags[key]?.toSet() ?: emptySet(), + onDismissRequest = { editKey = null }, + onConfirm = { selected -> + if (selected.isEmpty()) { + newAccessTags.remove(key) + } else { + newAccessTags[key] = selected.toMutableSet() + } + editKey = null + } + ) + } + + if (showAddConditionalDialog) { + AddConditionalDialog( + onDismissRequest = { showAddConditionalDialog = false }, + keys = accessKeys, + values = listOf("yes", "no", "delivery", "destination", "discouraged", "private"), + numberOnly = false, + countryInfo = countryInfo + ) { key, 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) + } + } + } + + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + values.forEach { 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(value) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + val selected = selectedStates + .filterValues { it } + .keys + .toSet() + onConfirm(selected) + } + ) { + Text(stringResource(Res.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(Res.string.cancel)) + } + } + ) +} + +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", // 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 + "goods", // 53k + "taxi", // 30k + "carriage", // 22k + "disabled", // 21k + "hov", // 20k + "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( + "yes", + "no", + "private", + "permissive", + "permit", + "destination", + "delivery", + "customers", + "designated", // not for access + "use_sidepath", // usually for foot / bicycle + "dismount", // bicycle + "agricultural", + "forestry", + "discouraged", // really required explicit sign + // "military", disputed tag + //"variable", doesn't make sense without supporting access:lanes +) From a7b318e6c4e7d2f196f98d1cfc03ddabc22165bd Mon Sep 17 00:00:00 2001 From: mcliquid Date: Wed, 1 Apr 2026 16:05:57 +0200 Subject: [PATCH 06/10] get latest changes to get it work, then commit the multi selection changes again ... --- .../streetcomplete/osm/AccessManagerDialog.kt | 342 ++++++++++++++---- 1 file changed, 268 insertions(+), 74 deletions(-) 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 66f9035666e..8d97d18488b 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/osm/AccessManagerDialog.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/osm/AccessManagerDialog.kt @@ -6,16 +6,19 @@ 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.foundation.selection.toggleable import androidx.compose.material.AlertDialog import androidx.compose.material.Button +import androidx.compose.material.Checkbox import androidx.compose.material.Icon import androidx.compose.material.IconButton 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.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -30,7 +33,6 @@ 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.SimpleListPickerDialog import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -42,31 +44,60 @@ 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 editKey by remember { mutableStateOf(null) } var showAddConditionalDialog by remember { mutableStateOf(false) } + val hasChanges = + normalizeAccessTags(originalAccessTagsSets) != + normalizeAccessTags(newAccessTags.mapValues { it.value.toSet() }) + AlertDialog( onDismissRequest = onDismissRequest, buttons = { Row(horizontalArrangement = Arrangement.End) { - TextButton(onDismissRequest) { Text(stringResource(Res.string.cancel)) } + TextButton(onClick = 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 + + 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 + } + } } - originalAccessTags.keys.forEach { - if (it !in newAccessTags) - builder.remove(it) + + originalAccessTagsSets.keys.forEach { key -> + if (key !in newAccessTags || newAccessTags[key].isNullOrEmpty()) { + builder.remove(key) + } } + onClickOk(builder) }, - enabled = originalAccessTags != newAccessTags + enabled = hasChanges ) { Text(stringResource(Res.string.ok)) } @@ -75,91 +106,253 @@ fun AccessManagerDialog( 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)) + + newAccessTags.toSortedMap().forEach { (key, values) -> + Row(verticalAlignment = Alignment.Top) { + Text(key, Modifier.weight(0.35f)) 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)) } + + Column( + modifier = Modifier.weight(0.45f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + values.sorted().forEach { value -> + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = true, + onCheckedChange = { checked -> + if (!checked) { + val updated = values.toMutableSet() + updated.remove(value) + if (updated.isEmpty()) { + newAccessTags.remove(key) + } else { + newAccessTags[key] = updated + } + } + } + ) + Text(value) + } + } + + TextButton(onClick = { editKey = key }) { + Text(stringResource(Res.string.access_manager_button_add)) + } + } + + IconButton(onClick = { newAccessTags.remove(key) }) { + Icon( + painterResource(Res.drawable.ic_delete_24), + stringResource(Res.string.delete_confirmation) + ) + } } } - 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)) } + + 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)) + } } } ) + 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.isNotEmpty()) { + newAccessTags[key] = selected.toMutableSet() + } + addKey = null + } ) } + + editKey?.let { key -> + AccessValuesDialog( + title = key, + values = accessValues, + initialSelected = newAccessTags[key]?.toSet() ?: emptySet(), + onDismissRequest = { editKey = null }, + onConfirm = { selected -> + if (selected.isEmpty()) { + newAccessTags.remove(key) + } else { + newAccessTags[key] = selected.toMutableSet() + } + editKey = 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) + } + } + } + + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + values.forEach { 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(value) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + val selected = selectedStates + .filterValues { it } + .keys + .toSet() + onConfirm(selected) + } + ) { + Text(stringResource(Res.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(Res.string.cancel)) + } + } + ) +} + +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( @@ -171,11 +364,12 @@ val accessValues = listOf( "destination", "delivery", "customers", - "designated", // not for access - "use_sidepath", // usually for foot / bicycle - "dismount", // bicycle + "designated", + "use_sidepath", + "dismount", "agricultural", "forestry", "discouraged", // really required explicit sign + // "military", disputed tag //"variable", doesn't make sense without supporting access:lanes ) From c7538edb8f31c09b8910097def4f270363c5c5cc Mon Sep 17 00:00:00 2001 From: mcliquid Date: Wed, 1 Apr 2026 22:25:45 +0200 Subject: [PATCH 07/10] added comments again --- .../westnordost/streetcomplete/osm/AccessManagerDialog.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 8d97d18488b..fb9f99a6154 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/osm/AccessManagerDialog.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/osm/AccessManagerDialog.kt @@ -364,9 +364,9 @@ val accessValues = listOf( "destination", "delivery", "customers", - "designated", - "use_sidepath", - "dismount", + "designated", // not for access + "use_sidepath", // usually for foot / bicycle + "dismount", // bicycle "agricultural", "forestry", "discouraged", // really required explicit sign From 033cc21bd10c61f7caa4221ae3dfb661b1768613 Mon Sep 17 00:00:00 2001 From: mcliquid Date: Thu, 2 Apr 2026 22:15:48 +0200 Subject: [PATCH 08/10] optimized access dialog in Compose --- .../streetcomplete/osm/AccessManagerDialog.kt | 261 ++++++++++-------- 1 file changed, 146 insertions(+), 115 deletions(-) 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 fb9f99a6154..588e0b310bb 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/osm/AccessManagerDialog.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/osm/AccessManagerDialog.kt @@ -3,25 +3,31 @@ 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.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.AlertDialog 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.getValue 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 @@ -59,109 +65,134 @@ fun AccessManagerDialog( var showAddDialog by remember { mutableStateOf(false) } var addKey by remember { mutableStateOf(null) } - var editKey by remember { mutableStateOf(null) } var showAddConditionalDialog by remember { mutableStateOf(false) } val hasChanges = normalizeAccessTags(originalAccessTagsSets) != normalizeAccessTags(newAccessTags.mapValues { it.value.toSet() }) + val sortedEntries = newAccessTags.toSortedMap().entries.map { it.key to it.value.sorted() } + AlertDialog( onDismissRequest = onDismissRequest, - buttons = { - Row(horizontalArrangement = Arrangement.End) { - TextButton(onClick = onDismissRequest) { - Text(stringResource(Res.string.cancel)) - } - TextButton( - onClick = { - val builder = StringMapChangesBuilder(tags) + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(Res.string.cancel)) + } + }, + confirmButton = { + 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 - } - } + 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 } + } - originalAccessTagsSets.keys.forEach { key -> - if (key !in newAccessTags || newAccessTags[key].isNullOrEmpty()) { - builder.remove(key) - } + originalAccessTagsSets.keys.forEach { key -> + if (key !in newAccessTags || newAccessTags[key].isNullOrEmpty()) { + builder.remove(key) } + } - onClickOk(builder) - }, - enabled = hasChanges - ) { - Text(stringResource(Res.string.ok)) - } + onClickOk(builder) + }, + enabled = hasChanges + ) { + Text(stringResource(Res.string.ok)) } }, text = { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text(stringResource(Res.string.access_manager_message)) + 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) + ) + } - newAccessTags.toSortedMap().forEach { (key, values) -> - Row(verticalAlignment = Alignment.Top) { - Text(key, Modifier.weight(0.35f)) - Spacer(Modifier.width(6.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) + ) - Column( - modifier = Modifier.weight(0.45f), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - values.sorted().forEach { value -> - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = true, - onCheckedChange = { checked -> - if (!checked) { - val updated = values.toMutableSet() - updated.remove(value) - if (updated.isEmpty()) { - newAccessTags.remove(key) - } else { - newAccessTags[key] = updated - } - } + 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) ) - Text(value) } } - - TextButton(onClick = { editKey = key }) { - Text(stringResource(Res.string.access_manager_button_add)) - } } - IconButton(onClick = { newAccessTags.remove(key) }) { - Icon( - painterResource(Res.drawable.ic_delete_24), - stringResource(Res.string.delete_confirmation) - ) + if (index != sortedEntries.lastIndex) { + Divider(modifier = Modifier.padding(vertical = 8.dp)) } } } - Button( - onClick = { showAddDialog = true }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(Res.string.access_manager_button_add)) - } + 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)) + Button( + onClick = { showAddConditionalDialog = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(Res.string.access_manager_button_add_conditional)) + } + } } } } @@ -184,28 +215,13 @@ fun AccessManagerDialog( values = accessValues, initialSelected = newAccessTags[key]?.toSet() ?: emptySet(), onDismissRequest = { addKey = null }, - onConfirm = { selected -> - if (selected.isNotEmpty()) { - newAccessTags[key] = selected.toMutableSet() - } - addKey = null - } - ) - } - - editKey?.let { key -> - AccessValuesDialog( - title = key, - values = accessValues, - initialSelected = newAccessTags[key]?.toSet() ?: emptySet(), - onDismissRequest = { editKey = null }, onConfirm = { selected -> if (selected.isEmpty()) { newAccessTags.remove(key) } else { newAccessTags[key] = selected.toMutableSet() } - editKey = null + addKey = null } ) } @@ -244,10 +260,39 @@ private fun AccessValuesDialog( AlertDialog( onDismissRequest = onDismissRequest, - title = { Text(title) }, + title = { + Text( + text = title, + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.SemiBold + ) + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(Res.string.cancel)) + } + }, + confirmButton = { + TextButton( + onClick = { + val selected = selectedStates + .filterValues { it } + .keys + .toSet() + onConfirm(selected) + } + ) { + Text(stringResource(Res.string.ok)) + } + }, text = { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - values.forEach { value -> + 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, @@ -266,28 +311,14 @@ private fun AccessValuesDialog( selectedStates[value] = isChecked } ) - Text(value) + Text( + text = value, + style = MaterialTheme.typography.body1, + modifier = Modifier.padding(start = 6.dp) + ) } } } - }, - confirmButton = { - TextButton( - onClick = { - val selected = selectedStates - .filterValues { it } - .keys - .toSet() - onConfirm(selected) - } - ) { - Text(stringResource(Res.string.ok)) - } - }, - dismissButton = { - TextButton(onClick = onDismissRequest) { - Text(stringResource(Res.string.cancel)) - } } ) } From 8d8a9fc4f223ed8d23352fb33e71d3edf4f6660f Mon Sep 17 00:00:00 2001 From: mcliquid Date: Thu, 2 Apr 2026 22:20:20 +0200 Subject: [PATCH 09/10] Revert changes in old androidMain dialog --- .../util/AccessManagerDialog.kt | 218 ++++++------------ app/src/androidMain/res/layout/row_access.xml | 18 +- 2 files changed, 79 insertions(+), 157 deletions(-) diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/AccessManagerDialog.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/AccessManagerDialog.kt index ae3199a74ab..1657688e63f 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/AccessManagerDialog.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/AccessManagerDialog.kt @@ -3,7 +3,8 @@ package de.westnordost.streetcomplete.util import android.content.Context import android.view.LayoutInflater import android.view.View -import android.widget.CheckBox +import android.widget.AdapterView +import android.widget.ArrayAdapter import androidx.appcompat.app.AlertDialog import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapChangesBuilder @@ -12,33 +13,15 @@ import de.westnordost.streetcomplete.databinding.RowAccessBinding import de.westnordost.streetcomplete.util.dialogs.showAddConditionalDialog import de.westnordost.streetcomplete.util.ktx.dpToPx -class AccessManagerDialog( - context: Context, - tags: Map, - onClickOk: (StringMapChangesBuilder) -> Unit -) : AlertDialog(context) { - +class AccessManagerDialog(context: Context, tags: Map, onClickOk: (StringMapChangesBuilder) -> Unit) : AlertDialog(context) { private val binding = DialogAccessManagerBinding.inflate(LayoutInflater.from(context)) - - // original tags filtered to access keys, but parsed into sets - private val originalAccessTagsSets: Map> = - tags.filterKeys { key -> accessKeys.any { it == key || key.startsWith("$it:") } } - .mapValues { (_, v) -> parseValues(v) } - // working copy: mutable sets we update from UI - private val newAccessTags: MutableMap> = - LinkedHashMap(originalAccessTagsSets.mapValues { (_, v) -> v.toMutableSet() }) + private val originalAccessTags = tags.filterKeys { key -> accessKeys.any { it == key || key.startsWith("$it:") } } + private val newAccessTags = LinkedHashMap(originalAccessTags) init { binding.addConditionalButton.setOnClickListener { - showAddConditionalDialog( - context, - accessKeys.toList(), - listOf("yes", "no", "delivery", "destination", "discouraged", "private"), - null - ) { k, v -> - // ensure set exists - val set = newAccessTags.getOrPut(k) { mutableSetOf() } - set.add(v) + showAddConditionalDialog(context, accessKeys.toList(), listOf("yes", "no", "delivery", "destination"), null) { k, v -> + newAccessTags[k] = v createAccessTagViews() } } @@ -49,104 +32,59 @@ class AccessManagerDialog( setButton(BUTTON_NEGATIVE, context.getString(android.R.string.cancel)) { _, _ -> } setButton(BUTTON_POSITIVE, context.getString(android.R.string.ok)) { _, _ -> val builder = StringMapChangesBuilder(tags) - newAccessTags.forEach { (k, set) -> - val joined = serializeValues(set) - val origJoined = originalAccessTagsSets[k]?.let { serializeValues(it) } - if (origJoined != joined) { - if (joined.isEmpty()) builder.remove(k) else builder[k] = joined - } + newAccessTags.forEach { + if (originalAccessTags[it.key] != it.value) + builder[it.key] = it.value } - originalAccessTagsSets.keys.forEach { k -> - if (k !in newAccessTags || newAccessTags[k].isNullOrEmpty()) builder.remove(k) + originalAccessTags.keys.forEach { + if (it !in newAccessTags) + builder.remove(it) } onClickOk(builder) } - setOnShowListener { - updateOkButton() - } } private fun updateOkButton() { - val origMap = originalAccessTagsSets.mapValues { (_, s) -> serializeValues(s) } - val newMap = newAccessTags.mapValues { (_, s) -> serializeValues(s) } - getButton(BUTTON_POSITIVE)?.isEnabled = origMap != newMap + getButton(BUTTON_POSITIVE).isEnabled = originalAccessTags != newAccessTags } private fun createAccessTagViews() { binding.accessTags.removeAllViews() - newAccessTags.forEach { (key, set) -> - binding.accessTags.addView(accessView(key, set)) - } - updateOkButton() + newAccessTags.forEach { binding.accessTags.addView(accessView(it.key, it.value)) } } - private fun accessView(key: String, valuesSet: MutableSet): View { + private fun accessView(key: String, value: String): View { val view = RowAccessBinding.inflate(LayoutInflater.from(context)) view.keyText.text = key - - // Clear any previous dynamic checkbox container if present - // We assume row_access.xml has a ViewGroup with id 'valueContainer' - val container = view.valueContainer // provided in XML - - container.removeAllViews() - - // create checkbox for each known accessValue; if the current value is not in accessValues, show it first as checked - val existing = valuesSet.toMutableSet() - // Only show currently selected values in the compact list - val valuesToShow: List = existing.sorted() - - for (valStr in valuesToShow) { - val check = CheckBox(binding.root.context) - check.text = valStr - check.isChecked = valStr in existing - check.setOnCheckedChangeListener { _, checked -> - if (!checked) { - valuesSet.remove(valStr) - if (valuesSet.isEmpty()) { - newAccessTags.remove(key) - } - createAccessTagViews() - } + val values = if (value in accessValues) accessValues else (arrayOf(value) + accessValues) + view.valueSpinner.adapter = ArrayAdapter(binding.root.context, android.R.layout.simple_dropdown_item_1line, values) + view.valueSpinner.setSelection(accessValues.indexOf(value)) + view.valueSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + val selected = values[position] + newAccessTags[key] = selected updateOkButton() } - // small padding - check.setPadding(0, context.resources.dpToPx(2).toInt(), 0, context.resources.dpToPx(2).toInt()) - container.addView(check) + override fun onNothingSelected(p0: AdapterView<*>?) { } // just do nothing? or remove tag? } - - // delete button removes the whole key view.deleteButton.setOnClickListener { newAccessTags.remove(key) createAccessTagViews() } - view.root.setPadding(0, context.resources.dpToPx(4).toInt(), 0, context.resources.dpToPx(4).toInt()) return view.root } - // Show dialog to add a new key -> choose key then multi-choice values + // maybe reduce height, but need a simple solution... private fun showAddAccessDialog(context: Context) { - AlertDialog.Builder(context) - .setTitle(R.string.add_access) + Builder(context) + .setTitle("key") .setSingleChoiceItems(accessKeys, -1) { di, i -> - val key = accessKeys[i] - // multi-choice values dialog - val checked = BooleanArray(accessValues.size) Builder(context) - .setTitle(R.string.manage_access) - .setMultiChoiceItems(accessValues, checked) { _, idx, isChecked -> - checked[idx] = isChecked - } - .setPositiveButton(android.R.string.ok) { di2, _ -> - val selected = accessValues - .withIndex() - .filter { checked[it.index] } - .map { it.value } - if (selected.isNotEmpty()) { - val set = newAccessTags.getOrPut(key) { mutableSetOf() } - set.addAll(selected) - createAccessTagViews() - } + .setTitle("value") + .setSingleChoiceItems(accessValues, -1) { di2, j -> + newAccessTags[accessKeys[i]] = accessValues[j] + createAccessTagViews() di2.dismiss() } .setNegativeButton(android.R.string.cancel, null) @@ -156,65 +94,50 @@ class AccessManagerDialog( .setNegativeButton(android.R.string.cancel, null) .show() } - - companion object { - - // helper: parse semicolon separated values into set - private fun parseValues(v: String): MutableSet = - v.split(';').map { it.trim() }.filter { it.isNotEmpty() }.toMutableSet() - - // serialize set into stable ; separated string - private fun serializeValues(values: Set): String = - values.filter { it.isNotBlank() }.toSet().sorted().joinToString(";") - } } -// Access keys and values are used in multiple places (dialogs, overlays) - Usage figures as of February 2026 val accessKeys = arrayOf( // sorted by number of uses - "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 + "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 "bdouble", // 60k - "goods", // 53k - "taxi", // 30k - "carriage", // 22k - "disabled", // 21k + "ski", // 60k + "goods", // 41k + "taxi", // 23k + "carriage", // 20k "hov", // 20k - "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 + "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 ) val accessValues = arrayOf( @@ -232,6 +155,5 @@ val accessValues = arrayOf( "agricultural", "forestry", "discouraged", // really required explicit sign - // "military", disputed tag //"variable", doesn't make sense without supporting access:lanes ) diff --git a/app/src/androidMain/res/layout/row_access.xml b/app/src/androidMain/res/layout/row_access.xml index 4a08dbf11ac..31af9872f44 100644 --- a/app/src/androidMain/res/layout/row_access.xml +++ b/app/src/androidMain/res/layout/row_access.xml @@ -13,21 +13,21 @@ android:layout_width="0dp" android:layout_weight="0.4" android:layout_height="wrap_content" - android:textAppearance="@android:style/TextAppearance.Widget.TextView" + android:textAppearance="@android:style/TextAppearance.Theme.Dialog" android:textSize="16sp" tools:text="access" android:paddingStart="0dp" - android:paddingEnd="8dp"/> + android:paddingEnd="4dp"/> - - + android:layout_height="32sp" + android:textAppearance="@android:style/TextAppearance.Theme.Dialog" + android:textSize="16sp" + android:paddingStart="3dp" + android:paddingEnd="0dp"/> Date: Fri, 3 Apr 2026 13:10:55 +0200 Subject: [PATCH 10/10] Switch to ScrollableAlertDialog --- .../streetcomplete/osm/AccessManagerDialog.kt | 272 +++++++++--------- 1 file changed, 138 insertions(+), 134 deletions(-) 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 588e0b310bb..760c23db439 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/osm/AccessManagerDialog.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/osm/AccessManagerDialog.kt @@ -10,7 +10,6 @@ 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.AlertDialog import androidx.compose.material.Button import androidx.compose.material.Checkbox import androidx.compose.material.Divider @@ -39,6 +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.dialogs.ScrollableAlertDialog import de.westnordost.streetcomplete.ui.common.dialogs.SimpleListPickerDialog import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -73,14 +73,106 @@ fun AccessManagerDialog( val sortedEntries = newAccessTags.toSortedMap().entries.map { it.key to it.value.sorted() } - AlertDialog( + ScrollableAlertDialog( onDismissRequest = onDismissRequest, - dismissButton = { + 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)) + } + } + } + + 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)) + } + } + } + } + } + }, + buttons = { TextButton(onClick = onDismissRequest) { Text(stringResource(Res.string.cancel)) } - }, - confirmButton = { TextButton( onClick = { val builder = StringMapChangesBuilder(tags) @@ -105,96 +197,6 @@ fun AccessManagerDialog( ) { Text(stringResource(Res.string.ok)) } - }, - text = { - 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)) - } - } - } - - 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)) - } - } - } - } } ) @@ -258,7 +260,7 @@ private fun AccessValuesDialog( } } - AlertDialog( + ScrollableAlertDialog( onDismissRequest = onDismissRequest, title = { Text( @@ -267,12 +269,49 @@ private fun AccessValuesDialog( fontWeight = FontWeight.SemiBold ) }, - dismissButton = { + 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)) } - }, - confirmButton = { TextButton( onClick = { val selected = selectedStates @@ -284,41 +323,6 @@ private fun AccessValuesDialog( ) { Text(stringResource(Res.string.ok)) } - }, - text = { - 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) - ) - } - } - } } ) }