refactor(device-settings): Reorganize sections and extract components

Visual: wrap Noise Control in SettingsSection for consistency with Sound/Controls/Smart features, add divider between ANC mode picker and trailing items.

Categories: rename 'Other' to 'Smart features', move Microphone Mode to Sound, move Conversation Awareness to Smart features alongside Sleep Detection, place Adaptive Noise adjacent to ANC mode picker.

Extraction: split DeviceInfoCard, NotConnectedCard, AapUnavailableCard into cards/; AutoConnectConditionDialog, RenameDialog, SystemRenameUnavailableDialog into dialogs/; NoiseControlCombined into components/. Each with isolated previews.
This commit is contained in:
darken
2026-04-14 14:39:01 +02:00
committed by Matthias Urhahn
parent 566a6cd120
commit 2f3cae2b2c
9 changed files with 806 additions and 555 deletions
@@ -3,16 +3,11 @@ package eu.darken.capod.main.ui.devicesettings
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -24,7 +19,6 @@ import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.automirrored.twotone.VolumeUp
import androidx.compose.material.icons.twotone.BluetoothConnected
import androidx.compose.material.icons.twotone.DevicesOther
import androidx.compose.material.icons.twotone.Edit
import androidx.compose.material.icons.twotone.GraphicEq
import androidx.compose.material.icons.twotone.Headphones
import androidx.compose.material.icons.twotone.Hearing
@@ -34,19 +28,13 @@ import androidx.compose.material.icons.twotone.Mic
import androidx.compose.material.icons.twotone.Nightlight
import androidx.compose.material.icons.twotone.PauseCircle
import androidx.compose.material.icons.twotone.PlayCircle
import androidx.compose.material.icons.twotone.QuestionMark
import androidx.compose.material.icons.twotone.Speed
import androidx.compose.material.icons.twotone.Swipe
import androidx.compose.material.icons.twotone.Timer
import androidx.compose.material.icons.twotone.TouchApp
import androidx.compose.material.icons.twotone.Visibility
import androidx.compose.material.icons.twotone.VisibilityOff
import androidx.compose.material.icons.twotone.Workspaces
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -72,13 +60,10 @@ 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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -91,7 +76,6 @@ import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.settings.SettingsBaseItem
import eu.darken.capod.common.settings.SettingsCategoryHeader
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.common.settings.InfoBoxType
import eu.darken.capod.common.settings.SettingsInfoBox
@@ -99,6 +83,12 @@ import eu.darken.capod.common.settings.SettingsPreferenceItem
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.common.settings.SettingsSliderItem
import eu.darken.capod.common.settings.SettingsSwitchItem
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard
import eu.darken.capod.main.ui.devicesettings.components.NoiseControlCombined
import eu.darken.capod.main.ui.devicesettings.dialogs.AutoConnectConditionDialog
import eu.darken.capod.main.ui.devicesettings.dialogs.SystemRenameUnavailableDialog
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.firstSeenFormatted
import eu.darken.capod.monitor.core.lastSeenFormatted
@@ -420,77 +410,61 @@ fun DeviceSettingsScreen(
// ── Noise Control ────────────────────────────
val ancMode = device.ancMode
val ncOneAirpod = device.ncWithOneAirPod
val adaptiveNoise = device.adaptiveAudioNoise
if (features.hasAncControl && ancMode != null) {
item("noise_control_header") {
SettingsCategoryHeader(text = stringResource(R.string.device_settings_noise_control_label))
}
val cycleMask = if (features.hasListeningModeCycle) {
(device.listeningModeCycle ?: AapSetting.ListeningModeCycle(modeMask = 0x0E)).modeMask
} else null
item("noise_control") {
NoiseControlCombined(
currentMode = ancMode.current,
pendingMode = device.pendingAncMode,
supportedModes = ancMode.supported,
onModeSelected = onAncModeChange,
cycleMask = if (isPro) cycleMask else null,
onCycleMaskChange = onListeningModeCycleChange,
onAllowOffChange = onAllowOffOptionChange,
onOffVisibilityChange = onOffVisibilityChange,
enabled = enabled,
)
}
if (!isPro && features.hasListeningModeCycle) {
item("noise_control_cycle_pro") {
SettingsBaseItem(
icon = Icons.TwoTone.Loop,
title = stringResource(R.string.device_settings_listening_mode_cycle_label),
subtitle = stringResource(R.string.device_settings_listening_mode_cycle_description),
onClick = onUpgrade,
requiresUpgrade = true,
)
}
}
val convAwareness = device.conversationalAwareness
if (features.hasConversationAwareness && convAwareness != null) {
item("conversation_awareness") {
SettingsSwitchItem(
icon = Icons.TwoTone.Hearing,
title = stringResource(R.string.conversation_awareness_label),
subtitle = stringResource(R.string.device_settings_conversation_awareness_description),
checked = convAwareness.enabled,
onCheckedChange = onConversationalAwarenessChange,
enabled = enabled,
)
}
}
val ncOneAirpod = device.ncWithOneAirPod
if (features.hasNcOneAirpod && ncOneAirpod != null) {
item("nc_one_airpod") {
SettingsSwitchItem(
icon = Icons.TwoTone.Headphones,
title = stringResource(R.string.device_settings_nc_one_airpod_label),
subtitle = stringResource(R.string.device_settings_nc_one_airpod_description),
checked = ncOneAirpod.enabled,
onCheckedChange = onNcWithOneAirPodChange,
enabled = enabled,
)
}
}
val adaptiveNoise = device.adaptiveAudioNoise
if (features.hasAdaptiveAudioNoise && adaptiveNoise != null) {
item("adaptive_noise") {
AdaptiveNoiseSlider(
level = adaptiveNoise.level,
onLevelChange = onAdaptiveAudioNoiseChange,
item("noise_control_section") {
SettingsSection(title = stringResource(R.string.device_settings_noise_control_label)) {
NoiseControlCombined(
currentMode = ancMode.current,
pendingMode = device.pendingAncMode,
supportedModes = ancMode.supported,
onModeSelected = onAncModeChange,
cycleMask = if (isPro) cycleMask else null,
onCycleMaskChange = onListeningModeCycleChange,
onAllowOffChange = onAllowOffOptionChange,
onOffVisibilityChange = onOffVisibilityChange,
enabled = enabled,
)
val hasNoiseExtras = (features.hasAdaptiveAudioNoise && adaptiveNoise != null) ||
(features.hasNcOneAirpod && ncOneAirpod != null) ||
(!isPro && features.hasListeningModeCycle)
if (hasNoiseExtras) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
if (features.hasAdaptiveAudioNoise && adaptiveNoise != null) {
AdaptiveNoiseSlider(
level = adaptiveNoise.level,
onLevelChange = onAdaptiveAudioNoiseChange,
enabled = enabled,
)
}
if (features.hasNcOneAirpod && ncOneAirpod != null) {
SettingsSwitchItem(
icon = Icons.TwoTone.Headphones,
title = stringResource(R.string.device_settings_nc_one_airpod_label),
subtitle = stringResource(R.string.device_settings_nc_one_airpod_description),
checked = ncOneAirpod.enabled,
onCheckedChange = onNcWithOneAirPodChange,
enabled = enabled,
)
}
if (!isPro && features.hasListeningModeCycle) {
SettingsBaseItem(
icon = Icons.TwoTone.Loop,
title = stringResource(R.string.device_settings_listening_mode_cycle_label),
subtitle = stringResource(R.string.device_settings_listening_mode_cycle_description),
onClick = onUpgrade,
requiresUpgrade = true,
)
}
}
}
}
@@ -499,7 +473,9 @@ fun DeviceSettingsScreen(
val personalizedVol = device.personalizedVolume
val toneVol = device.toneVolume
val showSoundSection =
(features.hasPersonalizedVolume && personalizedVol != null) || (features.hasToneVolume && toneVol != null)
(features.hasPersonalizedVolume && personalizedVol != null) ||
(features.hasToneVolume && toneVol != null) ||
features.hasMicrophoneMode
if (showSoundSection) {
item("sound_section") {
SettingsSection(title = stringResource(R.string.device_settings_category_sound_label)) {
@@ -520,6 +496,57 @@ fun DeviceSettingsScreen(
enabled = enabled,
)
}
if (features.hasMicrophoneMode) {
val micMode = device.microphoneMode
?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO)
SegmentedSettingRow(
icon = Icons.TwoTone.Mic,
title = stringResource(R.string.device_settings_microphone_mode_label),
subtitle = stringResource(R.string.device_settings_microphone_mode_description),
options = listOf(
stringResource(R.string.device_settings_microphone_mode_auto) to AapSetting.MicrophoneMode.Mode.AUTO,
stringResource(R.string.device_settings_microphone_mode_left) to AapSetting.MicrophoneMode.Mode.ALWAYS_LEFT,
stringResource(R.string.device_settings_microphone_mode_right) to AapSetting.MicrophoneMode.Mode.ALWAYS_RIGHT,
),
selected = micMode.mode,
onSelected = onMicrophoneModeChange,
enabled = enabled,
)
}
}
}
}
// ── Smart Features ───────────────────────────
val convAwareness = device.conversationalAwareness
val showSmartFeaturesSection =
(features.hasConversationAwareness && convAwareness != null) || features.hasSleepDetection
if (showSmartFeaturesSection) {
item("smart_features_section") {
SettingsSection(title = stringResource(R.string.device_settings_category_smart_features_label)) {
if (features.hasConversationAwareness && convAwareness != null) {
SettingsSwitchItem(
icon = Icons.TwoTone.Hearing,
title = stringResource(R.string.conversation_awareness_label),
subtitle = stringResource(R.string.device_settings_conversation_awareness_description),
checked = convAwareness.enabled,
onCheckedChange = onConversationalAwarenessChange,
enabled = enabled,
)
}
if (features.hasSleepDetection) {
val sleepDet = device.sleepDetection
?: AapSetting.SleepDetection(enabled = true)
SettingsSwitchItem(
icon = Icons.TwoTone.Nightlight,
title = stringResource(R.string.device_settings_sleep_detection_label),
subtitle = stringResource(R.string.device_settings_sleep_detection_description),
checked = sleepDet.enabled,
onCheckedChange = onSleepDetectionChange,
enabled = enabled,
requiresUpgrade = !isPro,
)
}
}
}
}
@@ -615,45 +642,6 @@ fun DeviceSettingsScreen(
}
}
// ── Other ───────────────────────────────────
val showOtherSection = features.hasMicrophoneMode || features.hasSleepDetection
if (showOtherSection) {
item("other_section") {
SettingsSection(title = stringResource(R.string.settings_category_other_label)) {
if (features.hasMicrophoneMode) {
val micMode = device.microphoneMode
?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO)
SegmentedSettingRow(
icon = Icons.TwoTone.Mic,
title = stringResource(R.string.device_settings_microphone_mode_label),
subtitle = stringResource(R.string.device_settings_microphone_mode_description),
options = listOf(
stringResource(R.string.device_settings_microphone_mode_auto) to AapSetting.MicrophoneMode.Mode.AUTO,
stringResource(R.string.device_settings_microphone_mode_left) to AapSetting.MicrophoneMode.Mode.ALWAYS_LEFT,
stringResource(R.string.device_settings_microphone_mode_right) to AapSetting.MicrophoneMode.Mode.ALWAYS_RIGHT,
),
selected = micMode.mode,
onSelected = onMicrophoneModeChange,
enabled = enabled,
)
}
if (features.hasSleepDetection) {
val sleepDet = device.sleepDetection
?: AapSetting.SleepDetection(enabled = true)
SettingsSwitchItem(
icon = Icons.TwoTone.Nightlight,
title = stringResource(R.string.device_settings_sleep_detection_label),
subtitle = stringResource(R.string.device_settings_sleep_detection_description),
checked = sleepDet.enabled,
onCheckedChange = onSleepDetectionChange,
enabled = enabled,
requiresUpgrade = !isPro,
)
}
}
}
}
// ── Connections ───────────────────────────────
val connectedDevices = device.connectedDevices
if (connectedDevices != null && connectedDevices.devices.isNotEmpty()) {
@@ -701,223 +689,6 @@ fun DeviceSettingsScreen(
}
}
@Composable
private fun AutoConnectConditionDialog(
current: AutoConnectCondition,
hasEarDetection: Boolean,
hasCase: Boolean,
onSelect: (AutoConnectCondition) -> Unit,
onDismiss: () -> Unit,
) {
val options = AutoConnectCondition.entries.filter { condition ->
when (condition) {
AutoConnectCondition.IN_EAR -> hasEarDetection
AutoConnectCondition.CASE_OPEN -> hasCase
AutoConnectCondition.WHEN_SEEN -> true
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.settings_autoconnect_condition_label)) },
text = {
Column(Modifier.selectableGroup()) {
options.forEach { condition ->
val isSelected = condition == current
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = { onSelect(condition) },
role = Role.RadioButton,
)
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = isSelected, onClick = null)
Text(
text = stringResource(condition.labelRes),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(android.R.string.cancel))
}
},
)
}
@Composable
private fun DeviceInfoCard(
deviceInfo: AapDeviceInfo?,
connectionStateLabel: String?,
lastSeen: String?,
firstSeen: String?,
canRename: Boolean = false,
onRename: (String) -> Unit = {},
) {
var showRenameDialog by remember { mutableStateOf(false) }
if (showRenameDialog && deviceInfo != null) {
RenameDialog(
currentName = deviceInfo.name,
onConfirm = { newName ->
onRename(newName)
showRenameDialog = false
},
onDismiss = { showRenameDialog = false },
)
}
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
if (deviceInfo != null) {
if (deviceInfo.name.isNotBlank()) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
InfoRow(
label = stringResource(R.string.device_settings_info_name_label),
value = deviceInfo.name,
modifier = Modifier.weight(1f),
)
if (canRename) {
IconButton(onClick = { showRenameDialog = true }) {
Icon(
imageVector = Icons.TwoTone.Edit,
contentDescription = stringResource(R.string.device_settings_rename_label),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
if (deviceInfo.serialNumber.isNotBlank()) {
InfoRow(
label = stringResource(R.string.device_settings_info_serial_label),
value = deviceInfo.serialNumber,
)
}
if (deviceInfo.firmwareVersion.isNotBlank()) {
InfoRow(
label = stringResource(R.string.device_settings_info_firmware_label),
value = deviceInfo.firmwareVersion,
)
}
}
if (connectionStateLabel != null) {
InfoRow(
label = stringResource(R.string.device_settings_info_status_label),
value = connectionStateLabel,
)
}
if (lastSeen != null) {
InfoRow(
label = stringResource(R.string.device_settings_info_last_seen_label),
value = lastSeen,
)
}
if (firstSeen != null) {
InfoRow(
label = stringResource(R.string.device_settings_info_first_seen_label),
value = firstSeen,
)
}
}
}
}
@Composable
private fun NotConnectedCard(
isNudgeAvailable: Boolean,
isForceConnecting: Boolean,
onConnect: () -> Unit,
) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.device_settings_not_connected_label),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.device_settings_not_connected_description),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onConnect,
enabled = !isForceConnecting,
modifier = Modifier.align(Alignment.End),
) {
Text(
text = stringResource(
if (isNudgeAvailable) R.string.device_settings_not_connected_connect_action
else R.string.device_settings_not_connected_open_settings_action
),
)
}
}
}
}
@Composable
private fun AapUnavailableCard() {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.device_settings_aap_unavailable_label),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.device_settings_aap_unavailable_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun InfoRow(label: String, value: String, modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(vertical = 2.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
)
}
}
@Composable
private fun ToneVolumeSlider(
level: Int,
@@ -1229,194 +1000,6 @@ private fun ConnectedDevicesList(
}
}
@Composable
private fun RenameDialog(
currentName: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var textValue by remember { mutableStateOf(currentName) }
val focusRequester = remember { FocusRequester() }
// The decoder in DefaultAapDeviceProfile only round-trips printable ASCII (0x20..0x7E),
// so even if the device accepts a UTF-8 name we can't display it back correctly. Accept any
// input up to the 32-byte UX cap, but flag non-ASCII with an inline error so the user
// understands why Rename is disabled.
val hasInvalidAscii = textValue.any { it.code !in 0x20..0x7E }
val isValid = textValue.isNotBlank() && !hasInvalidAscii
val canConfirm = isValid && textValue != currentName
LaunchedEffect(Unit) { focusRequester.requestFocus() }
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.device_settings_rename_label)) },
text = {
androidx.compose.material3.OutlinedTextField(
value = textValue,
onValueChange = { newValue ->
// US_ASCII encoding maps non-ASCII chars to '?' (1 byte each), giving a
// stable upper bound equal to the UTF-16 char count. Keeps the cap the user
// sees consistent regardless of character content.
if (newValue.toByteArray(Charsets.US_ASCII).size <= 32) textValue = newValue
},
singleLine = true,
label = { Text(stringResource(R.string.device_settings_rename_hint)) },
isError = hasInvalidAscii,
supportingText = if (hasInvalidAscii) {
{ Text(stringResource(R.string.device_settings_rename_invalid_ascii)) }
} else null,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { if (canConfirm) onConfirm(textValue) }),
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
)
},
confirmButton = {
androidx.compose.material3.TextButton(
onClick = { if (canConfirm) onConfirm(textValue) },
enabled = canConfirm,
) {
Text(stringResource(R.string.device_settings_rename_confirm))
}
},
dismissButton = {
androidx.compose.material3.TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
@Composable
private fun SystemRenameUnavailableDialog(
onOpenBluetoothSettings: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.device_settings_rename_system_unavailable_title)) },
text = { Text(stringResource(R.string.device_settings_rename_system_unavailable)) },
confirmButton = {
TextButton(onClick = onOpenBluetoothSettings) {
Text(stringResource(R.string.device_settings_rename_system_unavailable_bt_settings_action))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.ok))
}
},
)
}
@Composable
private fun NoiseControlCombined(
currentMode: AapSetting.AncMode.Value,
pendingMode: AapSetting.AncMode.Value?,
supportedModes: List<AapSetting.AncMode.Value>,
onModeSelected: (AapSetting.AncMode.Value) -> Unit,
cycleMask: Int?,
onCycleMaskChange: (Int) -> Unit,
onAllowOffChange: (Boolean) -> Unit = {},
onOffVisibilityChange: (enabled: Boolean, currentCycleMask: Int) -> Unit = { _, _ -> },
enabled: Boolean,
) {
val displayMode = pendingMode ?: currentMode
val cycleBits = mapOf(
AapSetting.AncMode.Value.OFF to 0x01,
AapSetting.AncMode.Value.ON to 0x02,
AapSetting.AncMode.Value.TRANSPARENCY to 0x04,
AapSetting.AncMode.Value.ADAPTIVE to 0x08,
)
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
for (mode in supportedModes) {
val isSelected = mode == displayMode
val bit = cycleBits[mode] ?: continue
val inCycle = cycleMask?.let { (it and bit) != 0 }
val cycleCount = cycleMask?.let { Integer.bitCount(it and 0x0F) } ?: 0
val canRemoveFromCycle = inCycle != true || cycleCount > 2
Row(
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Visibility toggle — independent click target
if (cycleMask != null) {
IconButton(
onClick = {
val isOff = mode == AapSetting.AncMode.Value.OFF
if (isOff) {
onOffVisibilityChange(inCycle != true, cycleMask ?: 0)
} else if (inCycle == true && canRemoveFromCycle) {
onCycleMaskChange(cycleMask xor bit)
} else if (inCycle != true) {
onCycleMaskChange((cycleMask ?: 0) or bit)
}
},
enabled = enabled && (inCycle != true || canRemoveFromCycle),
) {
Icon(
imageVector = if (inCycle == true) Icons.TwoTone.Visibility else Icons.TwoTone.VisibilityOff,
contentDescription = null,
tint = if (inCycle == true) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
},
)
}
}
// Mode selection — label + radio as one click target
Row(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clickable(enabled = enabled) { onModeSelected(mode) }
.padding(start = if (cycleMask == null) 16.dp else 0.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = mode.label(),
style = MaterialTheme.typography.bodyLarge,
color = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 1f else 0.5f)
},
fontWeight = if (isSelected) androidx.compose.ui.text.font.FontWeight.Bold else null,
modifier = Modifier.weight(1f),
)
RadioButton(
selected = isSelected,
onClick = null,
enabled = enabled,
modifier = Modifier.padding(16.dp),
)
}
}
}
}
}
}
@Composable
private fun AapSetting.AncMode.Value.label(): String = when (this) {
AapSetting.AncMode.Value.OFF -> stringResource(R.string.device_settings_listening_mode_cycle_off)
AapSetting.AncMode.Value.ON -> stringResource(R.string.device_settings_listening_mode_cycle_anc)
AapSetting.AncMode.Value.TRANSPARENCY -> stringResource(R.string.device_settings_listening_mode_cycle_transparency)
AapSetting.AncMode.Value.ADAPTIVE -> stringResource(R.string.device_settings_listening_mode_cycle_adaptive)
}
private fun previewFullState(isPro: Boolean) = DeviceSettingsViewModel.State(
device = PodDevice(
profileId = "preview",
@@ -1525,32 +1108,3 @@ private fun DeviceSettingsCachedOnlyPreview() = PreviewWrapper {
)
}
@Preview2
@Composable
private fun NotConnectedCardNudgeAvailablePreview() = PreviewWrapper {
NotConnectedCard(
isNudgeAvailable = true,
isForceConnecting = false,
onConnect = {},
)
}
@Preview2
@Composable
private fun NotConnectedCardNudgeUnavailablePreview() = PreviewWrapper {
NotConnectedCard(
isNudgeAvailable = false,
isForceConnecting = false,
onConnect = {},
)
}
@Preview2
@Composable
private fun NotConnectedCardForceConnectingPreview() = PreviewWrapper {
NotConnectedCard(
isNudgeAvailable = true,
isForceConnecting = true,
onConnect = {},
)
}
@@ -0,0 +1,47 @@
package eu.darken.capod.main.ui.devicesettings.cards
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
internal fun AapUnavailableCard() {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.device_settings_aap_unavailable_label),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.device_settings_aap_unavailable_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Preview2
@Composable
private fun AapUnavailableCardPreview() = PreviewWrapper {
AapUnavailableCard()
}
@@ -0,0 +1,180 @@
package eu.darken.capod.main.ui.devicesettings.cards
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Edit
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.main.ui.devicesettings.dialogs.RenameDialog
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
@Composable
internal fun DeviceInfoCard(
deviceInfo: AapDeviceInfo?,
connectionStateLabel: String?,
lastSeen: String?,
firstSeen: String?,
canRename: Boolean = false,
onRename: (String) -> Unit = {},
) {
var showRenameDialog by remember { mutableStateOf(false) }
if (showRenameDialog && deviceInfo != null) {
RenameDialog(
currentName = deviceInfo.name,
onConfirm = { newName ->
onRename(newName)
showRenameDialog = false
},
onDismiss = { showRenameDialog = false },
)
}
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
if (deviceInfo != null) {
if (deviceInfo.name.isNotBlank()) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
InfoRow(
label = stringResource(R.string.device_settings_info_name_label),
value = deviceInfo.name,
modifier = Modifier.weight(1f),
)
if (canRename) {
IconButton(onClick = { showRenameDialog = true }) {
Icon(
imageVector = Icons.TwoTone.Edit,
contentDescription = stringResource(R.string.device_settings_rename_label),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
if (deviceInfo.serialNumber.isNotBlank()) {
InfoRow(
label = stringResource(R.string.device_settings_info_serial_label),
value = deviceInfo.serialNumber,
)
}
if (deviceInfo.firmwareVersion.isNotBlank()) {
InfoRow(
label = stringResource(R.string.device_settings_info_firmware_label),
value = deviceInfo.firmwareVersion,
)
}
}
if (connectionStateLabel != null) {
InfoRow(
label = stringResource(R.string.device_settings_info_status_label),
value = connectionStateLabel,
)
}
if (lastSeen != null) {
InfoRow(
label = stringResource(R.string.device_settings_info_last_seen_label),
value = lastSeen,
)
}
if (firstSeen != null) {
InfoRow(
label = stringResource(R.string.device_settings_info_first_seen_label),
value = firstSeen,
)
}
}
}
}
@Composable
private fun InfoRow(label: String, value: String, modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(vertical = 2.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
)
}
}
@Preview2
@Composable
private fun DeviceInfoCardFullPreview() = PreviewWrapper {
DeviceInfoCard(
deviceInfo = AapDeviceInfo(
name = "AirPods Pro",
modelNumber = "A2699",
manufacturer = "Apple Inc.",
serialNumber = "W5J7KV0N04",
firmwareVersion = "7A305",
),
connectionStateLabel = "Connected",
lastSeen = "Just now",
firstSeen = "5 minutes ago",
canRename = true,
)
}
@Composable
@Preview2
private fun DeviceInfoCardWithoutRenamePreview() = PreviewWrapper {
DeviceInfoCard(
deviceInfo = AapDeviceInfo(
name = "AirPods Pro",
modelNumber = "A2699",
manufacturer = "Apple Inc.",
serialNumber = "W5J7KV0N04",
firmwareVersion = "7A305",
),
connectionStateLabel = "Disconnected",
lastSeen = "2 hours ago",
firstSeen = null,
canRename = false,
)
}
@Composable
@Preview2
private fun DeviceInfoCardSparsePreview() = PreviewWrapper {
DeviceInfoCard(
deviceInfo = null,
connectionStateLabel = "Disconnected",
lastSeen = "Just now",
firstSeen = null,
)
}
@@ -0,0 +1,89 @@
package eu.darken.capod.main.ui.devicesettings.cards
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
internal fun NotConnectedCard(
isNudgeAvailable: Boolean,
isForceConnecting: Boolean,
onConnect: () -> Unit,
) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.device_settings_not_connected_label),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.device_settings_not_connected_description),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onConnect,
enabled = !isForceConnecting,
modifier = Modifier.align(Alignment.End),
) {
Text(
text = stringResource(
if (isNudgeAvailable) R.string.device_settings_not_connected_connect_action
else R.string.device_settings_not_connected_open_settings_action
),
)
}
}
}
}
@Preview2
@Composable
private fun NotConnectedCardNudgeAvailablePreview() = PreviewWrapper {
NotConnectedCard(
isNudgeAvailable = true,
isForceConnecting = false,
onConnect = {},
)
}
@Preview2
@Composable
private fun NotConnectedCardNudgeUnavailablePreview() = PreviewWrapper {
NotConnectedCard(
isNudgeAvailable = false,
isForceConnecting = false,
onConnect = {},
)
}
@Preview2
@Composable
private fun NotConnectedCardForceConnectingPreview() = PreviewWrapper {
NotConnectedCard(
isNudgeAvailable = true,
isForceConnecting = true,
onConnect = {},
)
}
@@ -0,0 +1,150 @@
package eu.darken.capod.main.ui.devicesettings.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Visibility
import androidx.compose.material.icons.twotone.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.runtime.Composable
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
@Composable
internal fun NoiseControlCombined(
currentMode: AapSetting.AncMode.Value,
pendingMode: AapSetting.AncMode.Value?,
supportedModes: List<AapSetting.AncMode.Value>,
onModeSelected: (AapSetting.AncMode.Value) -> Unit,
cycleMask: Int?,
onCycleMaskChange: (Int) -> Unit,
onAllowOffChange: (Boolean) -> Unit = {},
onOffVisibilityChange: (enabled: Boolean, currentCycleMask: Int) -> Unit = { _, _ -> },
enabled: Boolean,
) {
val displayMode = pendingMode ?: currentMode
val cycleBits = mapOf(
AapSetting.AncMode.Value.OFF to 0x01,
AapSetting.AncMode.Value.ON to 0x02,
AapSetting.AncMode.Value.TRANSPARENCY to 0x04,
AapSetting.AncMode.Value.ADAPTIVE to 0x08,
)
Column(modifier = Modifier.padding(vertical = 4.dp, horizontal = 4.dp)) {
for (mode in supportedModes) {
val isSelected = mode == displayMode
val bit = cycleBits[mode] ?: continue
val inCycle = cycleMask?.let { (it and bit) != 0 }
val cycleCount = cycleMask?.let { Integer.bitCount(it and 0x0F) } ?: 0
val canRemoveFromCycle = inCycle != true || cycleCount > 2
Row(
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Visibility toggle — independent click target
if (cycleMask != null) {
IconButton(
onClick = {
val isOff = mode == AapSetting.AncMode.Value.OFF
if (isOff) {
onOffVisibilityChange(inCycle != true, cycleMask)
} else if (inCycle == true && canRemoveFromCycle) {
onCycleMaskChange(cycleMask xor bit)
} else if (inCycle != true) {
onCycleMaskChange(cycleMask or bit)
}
},
enabled = enabled && (inCycle != true || canRemoveFromCycle),
) {
Icon(
imageVector = if (inCycle == true) Icons.TwoTone.Visibility else Icons.TwoTone.VisibilityOff,
contentDescription = null,
tint = if (inCycle == true) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
},
)
}
}
// Mode selection — label + radio as one click target
Row(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clickable(enabled = enabled) { onModeSelected(mode) }
.padding(start = if (cycleMask == null) 16.dp else 0.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = mode.label(),
style = MaterialTheme.typography.bodyLarge,
color = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 1f else 0.5f)
},
fontWeight = if (isSelected) FontWeight.Bold else null,
modifier = Modifier.weight(1f),
)
RadioButton(
selected = isSelected,
onClick = null,
enabled = enabled,
modifier = Modifier.padding(16.dp),
)
}
}
}
}
}
@Composable
private fun AapSetting.AncMode.Value.label(): String = when (this) {
AapSetting.AncMode.Value.OFF -> stringResource(R.string.device_settings_listening_mode_cycle_off)
AapSetting.AncMode.Value.ON -> stringResource(R.string.device_settings_listening_mode_cycle_anc)
AapSetting.AncMode.Value.TRANSPARENCY -> stringResource(R.string.device_settings_listening_mode_cycle_transparency)
AapSetting.AncMode.Value.ADAPTIVE -> stringResource(R.string.device_settings_listening_mode_cycle_adaptive)
}
private val ALL_MODES = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
AapSetting.AncMode.Value.ADAPTIVE,
)
@Preview2
@Composable
private fun NoiseControlCombinedProPreview() = PreviewWrapper {
NoiseControlCombined(
currentMode = AapSetting.AncMode.Value.ADAPTIVE,
pendingMode = null,
supportedModes = ALL_MODES,
onModeSelected = {},
cycleMask = 0x0E,
onCycleMaskChange = {},
enabled = true,
)
}
@@ -0,0 +1,98 @@
package eu.darken.capod.main.ui.devicesettings.dialogs
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
@Composable
internal fun AutoConnectConditionDialog(
current: AutoConnectCondition,
hasEarDetection: Boolean,
hasCase: Boolean,
onSelect: (AutoConnectCondition) -> Unit,
onDismiss: () -> Unit,
) {
val options = AutoConnectCondition.entries.filter { condition ->
when (condition) {
AutoConnectCondition.IN_EAR -> hasEarDetection
AutoConnectCondition.CASE_OPEN -> hasCase
AutoConnectCondition.WHEN_SEEN -> true
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.settings_autoconnect_condition_label)) },
text = {
Column(Modifier.selectableGroup()) {
options.forEach { condition ->
val isSelected = condition == current
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = { onSelect(condition) },
role = Role.RadioButton,
)
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = isSelected, onClick = null)
Text(
text = stringResource(condition.labelRes),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(android.R.string.cancel))
}
},
)
}
@Preview2
@Composable
private fun AutoConnectConditionDialogFullPreview() = PreviewWrapper {
AutoConnectConditionDialog(
current = AutoConnectCondition.IN_EAR,
hasEarDetection = true,
hasCase = true,
onSelect = {},
onDismiss = {},
)
}
@Preview2
@Composable
private fun AutoConnectConditionDialogMinimalPreview() = PreviewWrapper {
AutoConnectConditionDialog(
current = AutoConnectCondition.WHEN_SEEN,
hasEarDetection = false,
hasCase = false,
onSelect = {},
onDismiss = {},
)
}
@@ -0,0 +1,91 @@
package eu.darken.capod.main.ui.devicesettings.dialogs
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
internal fun RenameDialog(
currentName: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var textValue by remember { mutableStateOf(currentName) }
val focusRequester = remember { FocusRequester() }
// The decoder in DefaultAapDeviceProfile only round-trips printable ASCII (0x20..0x7E),
// so even if the device accepts a UTF-8 name we can't display it back correctly. Accept any
// input up to the 32-byte UX cap, but flag non-ASCII with an inline error so the user
// understands why Rename is disabled.
val hasInvalidAscii = textValue.any { it.code !in 0x20..0x7E }
val isValid = textValue.isNotBlank() && !hasInvalidAscii
val canConfirm = isValid && textValue != currentName
LaunchedEffect(Unit) { focusRequester.requestFocus() }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.device_settings_rename_label)) },
text = {
OutlinedTextField(
value = textValue,
onValueChange = { newValue ->
// US_ASCII encoding maps non-ASCII chars to '?' (1 byte each), giving a
// stable upper bound equal to the UTF-16 char count. Keeps the cap the user
// sees consistent regardless of character content.
if (newValue.toByteArray(Charsets.US_ASCII).size <= 32) textValue = newValue
},
singleLine = true,
label = { Text(stringResource(R.string.device_settings_rename_hint)) },
isError = hasInvalidAscii,
supportingText = if (hasInvalidAscii) {
{ Text(stringResource(R.string.device_settings_rename_invalid_ascii)) }
} else null,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { if (canConfirm) onConfirm(textValue) }),
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
)
},
confirmButton = {
TextButton(
onClick = { if (canConfirm) onConfirm(textValue) },
enabled = canConfirm,
) {
Text(stringResource(R.string.device_settings_rename_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
@Preview2
@Composable
private fun RenameDialogPreview() = PreviewWrapper {
RenameDialog(
currentName = "AirPods Pro",
onConfirm = {},
onDismiss = {},
)
}
@@ -0,0 +1,41 @@
package eu.darken.capod.main.ui.devicesettings.dialogs
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
internal fun SystemRenameUnavailableDialog(
onOpenBluetoothSettings: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.device_settings_rename_system_unavailable_title)) },
text = { Text(stringResource(R.string.device_settings_rename_system_unavailable)) },
confirmButton = {
TextButton(onClick = onOpenBluetoothSettings) {
Text(stringResource(R.string.device_settings_rename_system_unavailable_bt_settings_action))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.ok))
}
},
)
}
@Preview2
@Composable
private fun SystemRenameUnavailableDialogPreview() = PreviewWrapper {
SystemRenameUnavailableDialog(
onOpenBluetoothSettings = {},
onDismiss = {},
)
}
+1
View File
@@ -444,6 +444,7 @@
<string name="device_settings_aap_unavailable_description">This phone\'s Bluetooth does not support the connection required for advanced AirPods settings.</string>
<string name="device_settings_category_sound_label">Sound</string>
<string name="device_settings_category_controls_label">Controls</string>
<string name="device_settings_category_smart_features_label">Smart features</string>
<string name="device_settings_nc_one_airpod_label">Noise Cancellation with One AirPod</string>
<string name="device_settings_nc_one_airpod_description">Keep noise cancellation active with only one AirPod</string>
<string name="device_settings_personalized_volume_label">Personalized Volume</string>