diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationScreen.kt b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationScreen.kt index b38315fe..aa612d96 100644 --- a/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationScreen.kt +++ b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationScreen.kt @@ -2,6 +2,7 @@ package eu.darken.capod.profiles.ui.creation import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -14,14 +15,19 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons 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.DevicesOther +import androidx.compose.material.icons.twotone.Key import androidx.compose.material.icons.twotone.Save +import androidx.compose.material.icons.twotone.SettingsInputAntenna import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -30,6 +36,8 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -42,15 +50,17 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel 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.PreviewWrapper -import eu.darken.capod.common.bluetooth.BluetoothDevice2 import eu.darken.capod.common.compose.waitForState import eu.darken.capod.common.error.ErrorEventHandler import eu.darken.capod.common.navigation.NavigationEventHandler @@ -195,7 +205,6 @@ fun DeviceProfileCreationScreen( .verticalScroll(rememberScrollState()) .padding(16.dp), ) { - // Device Information Card DeviceInfoCard( name = state.name, nameError = state.nameError, @@ -210,7 +219,6 @@ fun DeviceProfileCreationScreen( Spacer(modifier = Modifier.height(16.dp)) - // Identity Key Card KeyCard( title = stringResource(R.string.profiles_identitykey_label), description = stringResource(R.string.profiles_maindevice_identitykey_explanation), @@ -221,7 +229,6 @@ fun DeviceProfileCreationScreen( Spacer(modifier = Modifier.height(16.dp)) - // Encryption Key Card KeyCard( title = stringResource(R.string.profiles_maindevice_encryptionkey_label), description = stringResource(R.string.profiles_maindevice_encryptionkey_explanation), @@ -232,13 +239,55 @@ fun DeviceProfileCreationScreen( Spacer(modifier = Modifier.height(16.dp)) - // Signal Quality Card SignalQualityCard( minimumSignalQuality = state.minimumSignalQuality, 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, onDeviceChange: (BluetoothDevice2?) -> Unit, ) { - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = stringResource(R.string.profiles_basic_info_title), - style = MaterialTheme.typography.titleMedium, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.profiles_basic_info_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(16.dp)) + ProfileSectionCard( + icon = Icons.TwoTone.DevicesOther, + title = stringResource(R.string.profiles_basic_info_title), + description = stringResource(R.string.profiles_basic_info_description), + ) { + // Name input + OutlinedTextField( + value = name, + onValueChange = onNameChange, + label = { Text(text = stringResource(R.string.profiles_name_label)) }, + isError = nameError != null, + supportingText = nameError?.let { { Text(text = it) } }, + 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( - value = name, - onValueChange = onNameChange, - label = { Text(text = stringResource(R.string.profiles_name_label)) }, - isError = nameError != null, - supportingText = nameError?.let { { Text(text = it) } }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - Spacer(modifier = Modifier.height(12.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 - }, + value = selectedModel?.label ?: "", + onValueChange = {}, + readOnly = true, + label = { Text(text = stringResource(R.string.profiles_model_label)) }, + leadingIcon = selectedModel?.let { + { + Icon( + painter = painterResource(it.iconRes), + contentDescription = null, + modifier = Modifier.size(20.dp), ) } + }, + 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 - var deviceExpanded by remember { mutableStateOf(false) } - val noneLabel = stringResource(R.string.profiles_paired_device_none) - val unknownLabel = stringResource(R.string.pods_unknown_label) - val deviceDisplayText = if (selectedDevice != null) { - "${selectedDevice.name ?: unknownLabel} (${selectedDevice.address})" - } else { - noneLabel - } + // Paired device dropdown + var deviceExpanded by remember { mutableStateOf(false) } + val noneLabel = stringResource(R.string.profiles_paired_device_none) + val unknownLabel = stringResource(R.string.pods_unknown_label) + val deviceDisplayText = if (selectedDevice != null) { + "${selectedDevice.name ?: unknownLabel} (${selectedDevice.address})" + } else { + 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, - onExpandedChange = { deviceExpanded = it }, + onDismissRequest = { deviceExpanded = false }, ) { - 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), + // "None" option + DropdownMenuItem( + text = { + Column { + Text(text = noneLabel) + Text( + text = stringResource(R.string.profiles_paired_device_none_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + onClick = { + onDeviceChange(null) + deviceExpanded = false + }, ) - ExposedDropdownMenu( - expanded = deviceExpanded, - onDismissRequest = { deviceExpanded = false }, - ) { - // "None" option + bondedDevices.forEach { device -> DropdownMenuItem( text = { Column { - Text(text = noneLabel) + Text(text = device.name ?: unknownLabel) Text( - text = stringResource(R.string.profiles_paired_device_none_description), + text = device.address, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } }, onClick = { - onDeviceChange(null) + onDeviceChange(device) 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 expectedFormatLabel = stringResource(R.string.profiles_key_expected_format, exampleKey) - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, + // Derive chip state from local validation, not from external keyValue + val isConfigured = textFieldValue.isNotEmpty() && keyError == null + + ProfileSectionCard( + 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, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(12.dp)) + }, + ) { + OutlinedTextField( + value = textFieldValue, + onValueChange = { text -> + textFieldValue = text + when { + text.isEmpty() -> { + keyError = null + onKeyChange(null) + } - OutlinedTextField( - value = textFieldValue, - onValueChange = { text -> - textFieldValue = text - when { - text.isEmpty() -> { - keyError = null - onKeyChange(null) - } - - text.matches(keyRegex) -> { - keyError = null - try { - val normalized = text - .replace(" ", "") - .replace("-", "") - .chunked(2) - .map { it.toInt(16).toByte() } - .toByteArray() - onKeyChange(normalized) - } catch (_: Exception) { - keyError = invalidFormatLabel - } - } - - else -> { - keyError = expectedFormatLabel + text.matches(keyRegex) -> { + keyError = null + try { + val normalized = text + .replace(" ", "") + .replace("-", "") + .chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + onKeyChange(normalized) + } catch (_: Exception) { + keyError = invalidFormatLabel } } - }, - 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( onClick = onKeyGuide, - modifier = Modifier.align(Alignment.End), ) { Text( text = stringResource(R.string.general_guide_action), @@ -496,38 +587,42 @@ private fun SignalQualityCard( minimumSignalQuality: Float, onSignalQualityChange: (Float) -> Unit, ) { - val percentage = (minimumSignalQuality * 100).toInt() + val clamped = minimumSignalQuality.coerceIn(0f, 1f) + val percentage = (clamped * 100).toInt() - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = stringResource(R.string.profiles_signal_quality_title), - style = MaterialTheme.typography.titleMedium, + ProfileSectionCard( + icon = Icons.TwoTone.SettingsInputAntenna, + title = stringResource(R.string.profiles_signal_quality_title), + description = stringResource(R.string.profiles_signal_quality_description), + ) { + 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 = stringResource(R.string.profiles_signal_quality_description), - style = MaterialTheme.typography.bodySmall, + text = "$percentage%", + 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, ) - 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 private fun DeleteConfirmationDialog( onDelete: () -> Unit, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2018f2dc..e47c7c3a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -270,6 +270,10 @@ 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. Invalid key format Expected format: %1$s + Configured + Not set + Off + Strict Profile order determines priority. Drag profiles to reorder them - profiles higher in the list take precedence when multiple devices match.