mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 19:26:12 -04:00
feat(profiles): Modernize profile creation/edit screen UI
This commit is contained in:
+327
-192
@@ -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,
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user