feat(profiles): Modernize profile creation/edit screen UI

This commit is contained in:
darken
2026-02-26 00:05:24 +01:00
committed by Matthias Urhahn
parent ebcb997bdd
commit 25e3e7d792
2 changed files with 331 additions and 192 deletions
@@ -2,6 +2,7 @@ package eu.darken.capod.profiles.ui.creation
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -14,14 +15,19 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material.icons.twotone.Check
import androidx.compose.material.icons.twotone.Delete import androidx.compose.material.icons.twotone.Delete
import androidx.compose.material.icons.twotone.DevicesOther
import androidx.compose.material.icons.twotone.Key
import androidx.compose.material.icons.twotone.Save import androidx.compose.material.icons.twotone.Save
import androidx.compose.material.icons.twotone.SettingsInputAntenna
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -30,6 +36,8 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -42,15 +50,17 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.compose.waitForState import eu.darken.capod.common.compose.waitForState
import eu.darken.capod.common.error.ErrorEventHandler import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler import eu.darken.capod.common.navigation.NavigationEventHandler
@@ -195,7 +205,6 @@ fun DeviceProfileCreationScreen(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(16.dp), .padding(16.dp),
) { ) {
// Device Information Card
DeviceInfoCard( DeviceInfoCard(
name = state.name, name = state.name,
nameError = state.nameError, nameError = state.nameError,
@@ -210,7 +219,6 @@ fun DeviceProfileCreationScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Identity Key Card
KeyCard( KeyCard(
title = stringResource(R.string.profiles_identitykey_label), title = stringResource(R.string.profiles_identitykey_label),
description = stringResource(R.string.profiles_maindevice_identitykey_explanation), description = stringResource(R.string.profiles_maindevice_identitykey_explanation),
@@ -221,7 +229,6 @@ fun DeviceProfileCreationScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Encryption Key Card
KeyCard( KeyCard(
title = stringResource(R.string.profiles_maindevice_encryptionkey_label), title = stringResource(R.string.profiles_maindevice_encryptionkey_label),
description = stringResource(R.string.profiles_maindevice_encryptionkey_explanation), description = stringResource(R.string.profiles_maindevice_encryptionkey_explanation),
@@ -232,13 +239,55 @@ fun DeviceProfileCreationScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Signal Quality Card
SignalQualityCard( SignalQualityCard(
minimumSignalQuality = state.minimumSignalQuality, minimumSignalQuality = state.minimumSignalQuality,
onSignalQualityChange = onSignalQualityChange, onSignalQualityChange = onSignalQualityChange,
) )
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(48.dp))
}
}
}
@Composable
private fun ProfileSectionCard(
icon: ImageVector,
title: String,
description: String,
modifier: Modifier = Modifier,
headerEnd: (@Composable () -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (headerEnd != null) {
Spacer(modifier = Modifier.width(8.dp))
headerEnd()
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
content()
} }
} }
} }
@@ -256,140 +305,139 @@ private fun DeviceInfoCard(
onModelChange: (PodDevice.Model) -> Unit, onModelChange: (PodDevice.Model) -> Unit,
onDeviceChange: (BluetoothDevice2?) -> Unit, onDeviceChange: (BluetoothDevice2?) -> Unit,
) { ) {
Card(modifier = Modifier.fillMaxWidth()) { ProfileSectionCard(
Column(modifier = Modifier.padding(16.dp)) { icon = Icons.TwoTone.DevicesOther,
Text( title = stringResource(R.string.profiles_basic_info_title),
text = stringResource(R.string.profiles_basic_info_title), description = stringResource(R.string.profiles_basic_info_description),
style = MaterialTheme.typography.titleMedium, ) {
) // Name input
Spacer(modifier = Modifier.height(4.dp)) OutlinedTextField(
Text( value = name,
text = stringResource(R.string.profiles_basic_info_description), onValueChange = onNameChange,
style = MaterialTheme.typography.bodySmall, label = { Text(text = stringResource(R.string.profiles_name_label)) },
color = MaterialTheme.colorScheme.onSurfaceVariant, isError = nameError != null,
) supportingText = nameError?.let { { Text(text = it) } },
Spacer(modifier = Modifier.height(16.dp)) singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Name input Spacer(modifier = Modifier.height(12.dp))
// Model dropdown
var modelExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = modelExpanded,
onExpandedChange = { modelExpanded = it },
) {
OutlinedTextField( OutlinedTextField(
value = name, value = selectedModel?.label ?: "",
onValueChange = onNameChange, onValueChange = {},
label = { Text(text = stringResource(R.string.profiles_name_label)) }, readOnly = true,
isError = nameError != null, label = { Text(text = stringResource(R.string.profiles_model_label)) },
supportingText = nameError?.let { { Text(text = it) } }, leadingIcon = selectedModel?.let {
singleLine = true, {
modifier = Modifier.fillMaxWidth(), Icon(
) painter = painterResource(it.iconRes),
contentDescription = null,
Spacer(modifier = Modifier.height(12.dp)) modifier = Modifier.size(20.dp),
// Model dropdown
var modelExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = modelExpanded,
onExpandedChange = { modelExpanded = it },
) {
OutlinedTextField(
value = selectedModel?.label ?: "",
onValueChange = {},
readOnly = true,
label = { Text(text = stringResource(R.string.profiles_model_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = modelExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(
expanded = modelExpanded,
onDismissRequest = { modelExpanded = false },
) {
availableModels.forEach { model ->
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = painterResource(model.iconRes),
contentDescription = null,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Text(text = model.label)
}
},
onClick = {
onModelChange(model)
modelExpanded = false
},
) )
} }
},
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = modelExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(
expanded = modelExpanded,
onDismissRequest = { modelExpanded = false },
) {
availableModels.forEach { model ->
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = painterResource(model.iconRes),
contentDescription = null,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Text(text = model.label)
}
},
onClick = {
onModelChange(model)
modelExpanded = false
},
)
} }
} }
}
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Paired device dropdown // Paired device dropdown
var deviceExpanded by remember { mutableStateOf(false) } var deviceExpanded by remember { mutableStateOf(false) }
val noneLabel = stringResource(R.string.profiles_paired_device_none) val noneLabel = stringResource(R.string.profiles_paired_device_none)
val unknownLabel = stringResource(R.string.pods_unknown_label) val unknownLabel = stringResource(R.string.pods_unknown_label)
val deviceDisplayText = if (selectedDevice != null) { val deviceDisplayText = if (selectedDevice != null) {
"${selectedDevice.name ?: unknownLabel} (${selectedDevice.address})" "${selectedDevice.name ?: unknownLabel} (${selectedDevice.address})"
} else { } else {
noneLabel noneLabel
} }
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
expanded = deviceExpanded,
onExpandedChange = { deviceExpanded = it },
) {
OutlinedTextField(
value = deviceDisplayText,
onValueChange = {},
readOnly = true,
label = { Text(text = stringResource(R.string.profiles_paired_device_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = deviceExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(
expanded = deviceExpanded, expanded = deviceExpanded,
onExpandedChange = { deviceExpanded = it }, onDismissRequest = { deviceExpanded = false },
) { ) {
OutlinedTextField( // "None" option
value = deviceDisplayText, DropdownMenuItem(
onValueChange = {}, text = {
readOnly = true, Column {
label = { Text(text = stringResource(R.string.profiles_paired_device_label)) }, Text(text = noneLabel)
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = deviceExpanded) }, Text(
modifier = Modifier text = stringResource(R.string.profiles_paired_device_none_description),
.fillMaxWidth() style = MaterialTheme.typography.bodySmall,
.menuAnchor(MenuAnchorType.PrimaryNotEditable), color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
onClick = {
onDeviceChange(null)
deviceExpanded = false
},
) )
ExposedDropdownMenu( bondedDevices.forEach { device ->
expanded = deviceExpanded,
onDismissRequest = { deviceExpanded = false },
) {
// "None" option
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Column { Column {
Text(text = noneLabel) Text(text = device.name ?: unknownLabel)
Text( Text(
text = stringResource(R.string.profiles_paired_device_none_description), text = device.address,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
}, },
onClick = { onClick = {
onDeviceChange(null) onDeviceChange(device)
deviceExpanded = false deviceExpanded = false
}, },
) )
bondedDevices.forEach { device ->
DropdownMenuItem(
text = {
Column {
Text(text = device.name ?: unknownLabel)
Text(
text = device.address,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
onClick = {
onDeviceChange(device)
deviceExpanded = false
},
)
}
} }
} }
} }
@@ -424,63 +472,106 @@ private fun KeyCard(
val invalidFormatLabel = stringResource(R.string.profiles_key_invalid_format) val invalidFormatLabel = stringResource(R.string.profiles_key_invalid_format)
val expectedFormatLabel = stringResource(R.string.profiles_key_expected_format, exampleKey) val expectedFormatLabel = stringResource(R.string.profiles_key_expected_format, exampleKey)
Card(modifier = Modifier.fillMaxWidth()) { // Derive chip state from local validation, not from external keyValue
Column(modifier = Modifier.padding(16.dp)) { val isConfigured = textFieldValue.isNotEmpty() && keyError == null
Text(
text = title, ProfileSectionCard(
style = MaterialTheme.typography.titleMedium, icon = Icons.TwoTone.Key,
title = title,
description = description,
headerEnd = {
SuggestionChip(
onClick = {},
enabled = false,
label = {
Text(
text = stringResource(
if (isConfigured) R.string.profiles_key_status_configured
else R.string.profiles_key_status_not_set
),
style = MaterialTheme.typography.labelSmall,
)
},
icon = if (isConfigured) {
{
Icon(
imageVector = Icons.TwoTone.Check,
contentDescription = null,
modifier = Modifier.size(14.dp),
)
}
} else null,
colors = SuggestionChipDefaults.suggestionChipColors(
disabledContainerColor = if (isConfigured) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
disabledLabelColor = if (isConfigured) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
disabledIconContentColor = if (isConfigured) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
),
) )
Spacer(modifier = Modifier.height(4.dp)) },
Text( ) {
text = description, OutlinedTextField(
style = MaterialTheme.typography.bodySmall, value = textFieldValue,
color = MaterialTheme.colorScheme.onSurfaceVariant, onValueChange = { text ->
) textFieldValue = text
Spacer(modifier = Modifier.height(12.dp)) when {
text.isEmpty() -> {
keyError = null
onKeyChange(null)
}
OutlinedTextField( text.matches(keyRegex) -> {
value = textFieldValue, keyError = null
onValueChange = { text -> try {
textFieldValue = text val normalized = text
when { .replace(" ", "")
text.isEmpty() -> { .replace("-", "")
keyError = null .chunked(2)
onKeyChange(null) .map { it.toInt(16).toByte() }
} .toByteArray()
onKeyChange(normalized)
text.matches(keyRegex) -> { } catch (_: Exception) {
keyError = null keyError = invalidFormatLabel
try {
val normalized = text
.replace(" ", "")
.replace("-", "")
.chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
onKeyChange(normalized)
} catch (_: Exception) {
keyError = invalidFormatLabel
}
}
else -> {
keyError = expectedFormatLabel
} }
} }
},
label = { Text(text = stringResource(R.string.general_example_label, exampleKey)) },
isError = keyError != null,
supportingText = keyError?.let { { Text(text = it) } },
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp)) else -> {
keyError = expectedFormatLabel
onKeyChange(null)
}
}
},
label = {
Text(
text = stringResource(R.string.general_example_label, exampleKey),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
isError = keyError != null,
supportingText = keyError?.let { { Text(text = it) } },
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Spacer(modifier = Modifier.weight(1f))
OutlinedButton( OutlinedButton(
onClick = onKeyGuide, onClick = onKeyGuide,
modifier = Modifier.align(Alignment.End),
) { ) {
Text( Text(
text = stringResource(R.string.general_guide_action), text = stringResource(R.string.general_guide_action),
@@ -496,38 +587,42 @@ private fun SignalQualityCard(
minimumSignalQuality: Float, minimumSignalQuality: Float,
onSignalQualityChange: (Float) -> Unit, onSignalQualityChange: (Float) -> Unit,
) { ) {
val percentage = (minimumSignalQuality * 100).toInt() val clamped = minimumSignalQuality.coerceIn(0f, 1f)
val percentage = (clamped * 100).toInt()
Card(modifier = Modifier.fillMaxWidth()) { ProfileSectionCard(
Column(modifier = Modifier.padding(16.dp)) { icon = Icons.TwoTone.SettingsInputAntenna,
Text( title = stringResource(R.string.profiles_signal_quality_title),
text = stringResource(R.string.profiles_signal_quality_title), description = stringResource(R.string.profiles_signal_quality_description),
style = MaterialTheme.typography.titleMedium, ) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Slider(
value = clamped * 100f,
onValueChange = { onSignalQualityChange(it / 100f) },
valueRange = 0f..100f,
modifier = Modifier.weight(1f),
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.width(12.dp))
Text( Text(
text = stringResource(R.string.profiles_signal_quality_description), text = "$percentage%",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodyMedium,
)
}
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.profiles_signal_quality_min_label),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = stringResource(R.string.profiles_signal_quality_max_label),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Slider(
value = minimumSignalQuality * 100f,
onValueChange = { onSignalQualityChange(it / 100f) },
valueRange = 0f..100f,
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "$percentage%",
style = MaterialTheme.typography.bodyMedium,
)
}
} }
} }
} }
@@ -615,6 +710,46 @@ private fun DeviceProfileCreationScreenEditPreview() = PreviewWrapper {
) )
} }
@Preview2
@Composable
private fun DeviceProfileCreationScreenWithKeysPreview() = PreviewWrapper {
DeviceProfileCreationScreen(
state = DeviceProfileCreationViewModel.State(
isEditMode = true,
name = "My AirPods Pro",
nameError = null,
selectedModel = PodDevice.Model.AIRPODS_PRO2,
availableModels = PodDevice.Model.entries.filter { it != PodDevice.Model.UNKNOWN },
identityKey = byteArrayOf(
0xFE.toByte(), 0xD0.toByte(), 0x1C.toByte(), 0x54.toByte(),
0x11.toByte(), 0x81.toByte(), 0xBC.toByte(), 0xBC.toByte(),
0x87.toByte(), 0xD2.toByte(), 0xC4.toByte(), 0x3F.toByte(),
0x31.toByte(), 0x64.toByte(), 0x5F.toByte(), 0xEE.toByte(),
),
encryptionKey = byteArrayOf(
0xAB.toByte(), 0xCD.toByte(), 0xEF.toByte(), 0x01.toByte(),
0x23.toByte(), 0x45.toByte(), 0x67.toByte(), 0x89.toByte(),
0xAB.toByte(), 0xCD.toByte(), 0xEF.toByte(), 0x01.toByte(),
0x23.toByte(), 0x45.toByte(), 0x67.toByte(), 0x89.toByte(),
),
selectedDevice = null,
bondedDevices = emptyList(),
minimumSignalQuality = 0.35f,
canSave = true,
),
onBack = {},
onSave = {},
onDelete = {},
onNameChange = {},
onModelChange = {},
onDeviceChange = {},
onIdentityKeyChange = {},
onEncryptionKeyChange = {},
onSignalQualityChange = {},
onKeyGuide = {},
)
}
@Composable @Composable
private fun DeleteConfirmationDialog( private fun DeleteConfirmationDialog(
onDelete: () -> Unit, onDelete: () -> Unit,
+4
View File
@@ -270,6 +270,10 @@
<string name="profiles_maindevice_encryptionkey_explanation">AirPods send a status message, part of which is encrypted. The encryption key allows CAPod to decrypt the full message. You need one-time access to a MacBook.</string> <string name="profiles_maindevice_encryptionkey_explanation">AirPods send a status message, part of which is encrypted. The encryption key allows CAPod to decrypt the full message. You need one-time access to a MacBook.</string>
<string name="profiles_key_invalid_format">Invalid key format</string> <string name="profiles_key_invalid_format">Invalid key format</string>
<string name="profiles_key_expected_format">Expected format: %1$s</string> <string name="profiles_key_expected_format">Expected format: %1$s</string>
<string name="profiles_key_status_configured">Configured</string>
<string name="profiles_key_status_not_set">Not set</string>
<string name="profiles_signal_quality_min_label">Off</string>
<string name="profiles_signal_quality_max_label">Strict</string>
<string name="profiles_priority_hint">Profile order determines priority. Drag profiles to reorder them - profiles higher in the list take precedence when multiple devices match.</string> <string name="profiles_priority_hint">Profile order determines priority. Drag profiles to reorder them - profiles higher in the list take precedence when multiple devices match.</string>
<!-- Theme Mode --> <!-- Theme Mode -->