feat(settings): Tailor device settings to connection state

- Hide reactions and AAP sections unless device is classically connected

- Move advanced-settings-unavailable card to the bottom of the list

- Show 'device not nearby' infobox when out of range

- Show missing-paired-device banner with edit-profile action

- Replace pending banner with snackbar on user-initiated change
This commit is contained in:
darken
2026-04-25 19:00:19 +02:00
committed by Matthias Urhahn
parent 175aef9e2f
commit a40599c1dc
5 changed files with 87 additions and 27 deletions
@@ -118,6 +118,7 @@ internal fun DeviceSettingsReactionsContent() = PreviewWrapper {
now = MOCK_NOW,
isPro = true,
isNudgeAvailable = true,
isClassicallyConnected = true,
),
onNavigateUp = {},
)
@@ -289,6 +289,8 @@ object MockPodDataProvider {
profileId = "preview-cached",
ble = null,
aap = null,
profileAddress = "AA:BB:CC:DD:EE:FF",
profileModel = PodModel.AIRPODS_PRO2,
cached = CachedDeviceState(
profileId = "preview-cached",
model = PodModel.AIRPODS_PRO2,
@@ -48,6 +48,7 @@ import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard
import eu.darken.capod.main.ui.devicesettings.cards.ReactionsCard
import eu.darken.capod.main.ui.overview.cards.components.MissingPairedDeviceBanner
import eu.darken.capod.main.ui.devicesettings.cards.SoundCard
import eu.darken.capod.main.ui.devicesettings.cards.buildDeviceInfoDetailItems
import eu.darken.capod.main.ui.devicesettings.cards.buildModelLabel
@@ -86,6 +87,18 @@ fun DeviceSettingsScreenHost(
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message)
val chargeCapRejectedMessage = stringResource(R.string.device_settings_charge_cap_rejected_message)
val pendingInfoMessage = stringResource(R.string.device_settings_pending_info)
val hasPendingSettings = state?.device?.hasPendingSettings
var lastPendingState by remember { mutableStateOf<Boolean?>(null) }
LaunchedEffect(hasPendingSettings) {
val current = hasPendingSettings ?: return@LaunchedEffect
val previous = lastPendingState
lastPendingState = current
if (previous == false && current) {
snackbarHostState.showSnackbar(pendingInfoMessage)
}
}
LaunchedEffect(Unit) {
vm.events.collect { event ->
@@ -148,6 +161,7 @@ fun DeviceSettingsScreenHost(
onDynamicEndOfChargeChange = { vm.setDynamicEndOfCharge(it) },
onDeviceNameChange = { vm.setDeviceName(it) },
onPressControlsClick = { vm.navToPressControls() },
onEditProfile = { vm.navToEditProfile() },
onForceConnect = { vm.forceConnect() },
onUpgrade = { vm.launchUpgrade() },
onOnePodModeChange = { vm.setOnePodMode(it) },
@@ -185,6 +199,7 @@ fun DeviceSettingsScreen(
onDynamicEndOfChargeChange: (Boolean) -> Unit = {},
onDeviceNameChange: (String) -> Unit = {},
onPressControlsClick: () -> Unit = {},
onEditProfile: () -> Unit = {},
onForceConnect: () -> Unit = {},
onUpgrade: () -> Unit = {},
onOnePodModeChange: (Boolean) -> Unit = {},
@@ -279,27 +294,46 @@ fun DeviceSettingsScreen(
}
}
// Not connected info — BLE live but no AAP connection
if (device != null && device.ble != null && !device.isAapConnected && device.address != null) {
if (state.isClassicallyConnected) {
// Device is connected for audio but AAP isn't available — show passive info
item("aap_unavailable_info") {
AapUnavailableCard(onOpenTracker = onOpenAapTracker)
}
} else {
// Device is nearby but not connected — prompt user to connect
item("not_connected_info") {
NotConnectedCard(
isNudgeAvailable = state.isNudgeAvailable,
isForceConnecting = state.isForceConnecting,
onConnect = onForceConnect,
)
}
// Profile has no paired Bluetooth device — supersedes all other state cards
if (device != null && !device.hasSelectedPairedDevice) {
item("missing_paired_device") {
MissingPairedDeviceBanner(
onClick = onEditProfile,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
// ── Reactions (per-profile, not gated on AAP) ─────────────────
if (device != null && features != null) {
// Not nearby — no live BLE; settings require the device to be present
if (device != null && device.hasSelectedPairedDevice &&
device.ble == null && !state.isClassicallyConnected
) {
item("not_nearby_info") {
SettingsInfoBox(
title = stringResource(R.string.device_settings_not_nearby_label),
text = stringResource(R.string.device_settings_not_nearby_description),
)
}
}
// Not connected info — device is nearby but not connected; prompt user to connect
if (device != null && device.hasSelectedPairedDevice &&
device.ble != null && !device.isAapConnected && device.address != null &&
!state.isClassicallyConnected
) {
item("not_connected_info") {
NotConnectedCard(
isNudgeAvailable = state.isNudgeAvailable,
isForceConnecting = state.isForceConnecting,
onConnect = onForceConnect,
)
}
}
// ── Reactions (gated on classic connection — needs phone to be the audio target) ──
if (device != null && device.hasSelectedPairedDevice &&
features != null && state.isClassicallyConnected
) {
item("reactions_section") {
ReactionsCard(
device = device,
@@ -322,15 +356,7 @@ fun DeviceSettingsScreen(
}
// Settings — only show when AAP is connected
if (features != null && device.isAapConnected) {
if (device.hasPendingSettings == true) {
item("pending_info") {
SettingsInfoBox(
text = stringResource(R.string.device_settings_pending_info),
)
}
}
if (features != null && device.isAapConnected && device.hasSelectedPairedDevice) {
// ── Noise Control ────────────────────────────
if (features.hasAncControl && device.ancMode != null) {
@@ -445,6 +471,16 @@ fun DeviceSettingsScreen(
}
}
// Advanced settings unavailable — phone's Bluetooth lacks AAP support; passive info, shown last
if (device != null && device.hasSelectedPairedDevice &&
device.ble != null && !device.isAapConnected && device.address != null &&
state.isClassicallyConnected
) {
item("aap_unavailable_info") {
AapUnavailableCard(onOpenTracker = onOpenAapTracker)
}
}
item("bottom_spacer") {
Spacer(modifier = Modifier.height(16.dp))
}
@@ -501,6 +537,7 @@ internal fun previewFullState(isPro: Boolean) = DeviceSettingsViewModel.State(
),
now = MOCK_NOW,
isPro = isPro,
isClassicallyConnected = true,
)
@Preview2
@@ -566,3 +603,15 @@ private fun DeviceSettingsCachedOnlyPreview() = PreviewWrapper {
onNavigateUp = {},
)
}
@Preview2
@Composable
private fun DeviceSettingsMissingPairedDevicePreview() = PreviewWrapper {
DeviceSettingsScreen(
state = DeviceSettingsViewModel.State(
device = MockPodDataProvider.dualPodMissingPairedDevice(),
now = MOCK_NOW,
),
onNavigateUp = {},
)
}
@@ -442,6 +442,12 @@ class DeviceSettingsViewModel @Inject constructor(
navTo(Nav.Main.PressControls(profileId = profileId))
}
fun navToEditProfile() = launch {
log(TAG, INFO) { "navToEditProfile()" }
val profileId = targetProfileId.value ?: return@launch
navTo(Nav.Main.DeviceProfileCreation(profileId = profileId))
}
fun launchUpgrade() {
log(TAG, INFO) { "launchUpgrade()" }
navTo(Nav.Main.Upgrade)
+2
View File
@@ -472,6 +472,8 @@
<string name="device_settings_not_connected_description">This device is nearby but not connected to this phone. Connect to access settings and controls.</string>
<string name="device_settings_not_connected_connect_action">Connect</string>
<string name="device_settings_not_connected_open_settings_action">Open Bluetooth Settings</string>
<string name="device_settings_not_nearby_label">Device not nearby</string>
<string name="device_settings_not_nearby_description">Settings are only available when this device is nearby and connected to your phone.</string>
<string name="device_settings_aap_unavailable_label">Advanced settings unavailable</string>
<string name="device_settings_aap_unavailable_description">Advanced AirPods settings require a Bluetooth feature that is not available on this phone. This may be resolved by a future Android update from your device manufacturer.</string>
<string name="device_settings_aap_unavailable_action">View compatibility tracker</string>