feat(aprs): pick a map symbol from a list instead of typing two characters
The symbol table and code were free-text fields with no validation and no hint. Only the first character was ever used, and only at packet-build time, so an operator could type "satellite" into the table field, watch it persist, and beacon as "/" - the field lied about what it did. aprs.fi's troubleshooting guidance puts transmit-side symbol misconfiguration among the first things to check when a station never appears correctly. The single strongest argument for a list: \S is Satellite/Pacsat but /S is SHUTTLE. One keystroke apart, and both look right to someone typing from memory. Fourteen entries covering fixed, on-foot, field, four vehicle classes, satellite, yagi, phone, internet-only and handheld. Renderings are from aprs.org/symbols/symbolsX.txt (WB4APR, Nov 2015) rather than recalled. A symbol the operator already set that is not on the list appears first in the menu and stays selected, so opening the picker cannot silently change an existing station's appearance. The default changes from "/>" (CAR) to "/-" (House). The old default's own comment conceded it was "a reasonable stand-in for a phone", but it showed every non-driving operator as a vehicle. A house is right for most users and obviously wrong rather than misleading for the rest. This cannot disturb an existing install: saveConfig writes every key unconditionally and the enable switch calls it, so anyone who has ever turned APRS on has both symbol keys on disk and the changed fallbacks cannot reach them. All three sites move together - AprsStore's load fallback, AprsCard's blank-field fallback, and AprsBeacon.DEFAULT_SYMBOL - because leaving one behind would substitute a car whenever the stored code was unusable. The list lives in core:domain as pure data holding resource names rather than text, so the wording stays in the locale files. Tests assert that every entry survives the transmit sanitiser, that the pairs and description keys are unique, and that a pair off the list reports as absent rather than resolving to something near it.
This commit is contained in:
1 parent
6db10b5b72
commit
08f9106749
8 files changed
+330
-20
No files matched your search
@@ -11,6 +11,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsSymbols
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
@@ -179,6 +185,7 @@ fun AprsCard() {
|
||||
}
|
||||
|
||||
/** APRS settings dialog (plenty of room, full config) */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AprsSettingsDialog(
|
||||
config: AprsConfig,
|
||||
@@ -194,6 +201,7 @@ private fun AprsSettingsDialog(
|
||||
var status by remember { mutableStateOf(config.statusText) }
|
||||
var symbolTable by remember { mutableStateOf(config.symbolTable) }
|
||||
var symbolCode by remember { mutableStateOf(config.symbolCode) }
|
||||
var symbolMenuExpanded by remember { mutableStateOf(false) }
|
||||
val textStyle = LocalTextStyle.current.copy(fontSize = 14.sp)
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -274,24 +282,62 @@ private fun AprsSettingsDialog(
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// A list rather than two free-text fields. The fields accepted any string and used
|
||||
// only the first character, so typing "satellite" persisted the word and beaconed
|
||||
// as `/`. The pair that makes this worth doing: \S is Satellite but /S is SHUTTLE.
|
||||
val selected = AprsSymbols.find(symbolTable, symbolCode)
|
||||
val customLabel = stringResource(
|
||||
id = R.string.prefs_aprs_symbol_custom,
|
||||
symbolTable.take(1),
|
||||
symbolCode.take(1)
|
||||
)
|
||||
val selectedLabel = selected?.let { symbolLabel(it.descriptionKey) } ?: customLabel
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = symbolMenuExpanded,
|
||||
onExpandedChange = { symbolMenuExpanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = symbolTable,
|
||||
onValueChange = { symbolTable = it },
|
||||
label = { Text(stringResource(id = R.string.prefs_aprs_symbol_table)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
textStyle = textStyle
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = symbolCode,
|
||||
onValueChange = { symbolCode = it },
|
||||
label = { Text(stringResource(id = R.string.prefs_aprs_symbol_code)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
textStyle = textStyle
|
||||
value = selectedLabel,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(id = R.string.prefs_aprs_symbol)) },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = symbolMenuExpanded)
|
||||
},
|
||||
textStyle = textStyle,
|
||||
modifier = Modifier
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = symbolMenuExpanded,
|
||||
onDismissRequest = { symbolMenuExpanded = false }
|
||||
) {
|
||||
// An existing setting that is not on the list appears first and stays
|
||||
// selectable, so opening the menu cannot silently change it.
|
||||
if (selected == null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = customLabel, maxLines = 1) },
|
||||
onClick = { symbolMenuExpanded = false }
|
||||
)
|
||||
}
|
||||
AprsSymbols.curated.forEach { symbol ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = symbolLabel(symbol.descriptionKey), maxLines = 1) },
|
||||
onClick = {
|
||||
symbolTable = symbol.table.toString()
|
||||
symbolCode = symbol.code.toString()
|
||||
symbolMenuExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_aprs_symbol_help),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_aprs_passcode_hint),
|
||||
fontSize = 12.sp,
|
||||
@@ -316,7 +362,7 @@ private fun AprsSettingsDialog(
|
||||
intervalMin = interval.toIntOrNull() ?: 5,
|
||||
statusText = status,
|
||||
symbolTable = symbolTable.ifBlank { "/" },
|
||||
symbolCode = symbolCode.ifBlank { ">" }
|
||||
symbolCode = symbolCode.ifBlank { "-" }
|
||||
)
|
||||
)
|
||||
}) { Text(stringResource(id = R.string.prefs_aprs_save)) }
|
||||
@@ -339,3 +385,30 @@ private fun AprsSwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: ((
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a symbol's description key to its localised text.
|
||||
*
|
||||
* AprsSymbols names a resource rather than holding text, because core:domain cannot reach resources
|
||||
* and hardcoding English there would put wording outside the locale files. The mapping has to live
|
||||
* on this side, and an unknown key falls back to the key itself rather than crashing - a missing
|
||||
* translation should not take the settings screen down.
|
||||
*/
|
||||
@Composable
|
||||
private fun symbolLabel(descriptionKey: String): String = when (descriptionKey) {
|
||||
"aprs_symbol_house" -> stringResource(id = R.string.aprs_symbol_house)
|
||||
"aprs_symbol_house_alt" -> stringResource(id = R.string.aprs_symbol_house_alt)
|
||||
"aprs_symbol_person" -> stringResource(id = R.string.aprs_symbol_person)
|
||||
"aprs_symbol_yagi" -> stringResource(id = R.string.aprs_symbol_yagi)
|
||||
"aprs_symbol_satellite" -> stringResource(id = R.string.aprs_symbol_satellite)
|
||||
"aprs_symbol_portable" -> stringResource(id = R.string.aprs_symbol_portable)
|
||||
"aprs_symbol_phone" -> stringResource(id = R.string.aprs_symbol_phone)
|
||||
"aprs_symbol_tcpip" -> stringResource(id = R.string.aprs_symbol_tcpip)
|
||||
"aprs_symbol_ht" -> stringResource(id = R.string.aprs_symbol_ht)
|
||||
"aprs_symbol_car" -> stringResource(id = R.string.aprs_symbol_car)
|
||||
"aprs_symbol_truck" -> stringResource(id = R.string.aprs_symbol_truck)
|
||||
"aprs_symbol_van" -> stringResource(id = R.string.aprs_symbol_van)
|
||||
"aprs_symbol_rv" -> stringResource(id = R.string.aprs_symbol_rv)
|
||||
"aprs_symbol_bike" -> stringResource(id = R.string.aprs_symbol_bike)
|
||||
else -> descriptionKey
|
||||
}
|
||||
Reference in new issue
Block a user