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, now = MOCK_NOW,
isPro = true, isPro = true,
isNudgeAvailable = true, isNudgeAvailable = true,
isClassicallyConnected = true,
), ),
onNavigateUp = {}, onNavigateUp = {},
) )
@@ -289,6 +289,8 @@ object MockPodDataProvider {
profileId = "preview-cached", profileId = "preview-cached",
ble = null, ble = null,
aap = null, aap = null,
profileAddress = "AA:BB:CC:DD:EE:FF",
profileModel = PodModel.AIRPODS_PRO2,
cached = CachedDeviceState( cached = CachedDeviceState(
profileId = "preview-cached", profileId = "preview-cached",
model = PodModel.AIRPODS_PRO2, 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.NoiseControlCard
import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard
import eu.darken.capod.main.ui.devicesettings.cards.ReactionsCard 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.SoundCard
import eu.darken.capod.main.ui.devicesettings.cards.buildDeviceInfoDetailItems import eu.darken.capod.main.ui.devicesettings.cards.buildDeviceInfoDetailItems
import eu.darken.capod.main.ui.devicesettings.cards.buildModelLabel import eu.darken.capod.main.ui.devicesettings.cards.buildModelLabel
@@ -86,6 +87,18 @@ fun DeviceSettingsScreenHost(
val state by vm.state.collectAsStateWithLifecycle(initialValue = null) val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message) val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message)
val chargeCapRejectedMessage = stringResource(R.string.device_settings_charge_cap_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) { LaunchedEffect(Unit) {
vm.events.collect { event -> vm.events.collect { event ->
@@ -148,6 +161,7 @@ fun DeviceSettingsScreenHost(
onDynamicEndOfChargeChange = { vm.setDynamicEndOfCharge(it) }, onDynamicEndOfChargeChange = { vm.setDynamicEndOfCharge(it) },
onDeviceNameChange = { vm.setDeviceName(it) }, onDeviceNameChange = { vm.setDeviceName(it) },
onPressControlsClick = { vm.navToPressControls() }, onPressControlsClick = { vm.navToPressControls() },
onEditProfile = { vm.navToEditProfile() },
onForceConnect = { vm.forceConnect() }, onForceConnect = { vm.forceConnect() },
onUpgrade = { vm.launchUpgrade() }, onUpgrade = { vm.launchUpgrade() },
onOnePodModeChange = { vm.setOnePodMode(it) }, onOnePodModeChange = { vm.setOnePodMode(it) },
@@ -185,6 +199,7 @@ fun DeviceSettingsScreen(
onDynamicEndOfChargeChange: (Boolean) -> Unit = {}, onDynamicEndOfChargeChange: (Boolean) -> Unit = {},
onDeviceNameChange: (String) -> Unit = {}, onDeviceNameChange: (String) -> Unit = {},
onPressControlsClick: () -> Unit = {}, onPressControlsClick: () -> Unit = {},
onEditProfile: () -> Unit = {},
onForceConnect: () -> Unit = {}, onForceConnect: () -> Unit = {},
onUpgrade: () -> Unit = {}, onUpgrade: () -> Unit = {},
onOnePodModeChange: (Boolean) -> Unit = {}, onOnePodModeChange: (Boolean) -> Unit = {},
@@ -279,27 +294,46 @@ fun DeviceSettingsScreen(
} }
} }
// Not connected info — BLE live but no AAP connection // Profile has no paired Bluetooth device — supersedes all other state cards
if (device != null && device.ble != null && !device.isAapConnected && device.address != null) { if (device != null && !device.hasSelectedPairedDevice) {
if (state.isClassicallyConnected) { item("missing_paired_device") {
// Device is connected for audio but AAP isn't available — show passive info MissingPairedDeviceBanner(
item("aap_unavailable_info") { onClick = onEditProfile,
AapUnavailableCard(onOpenTracker = onOpenAapTracker) modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
} )
} else {
// Device is nearby but not connected — prompt user to connect
item("not_connected_info") {
NotConnectedCard(
isNudgeAvailable = state.isNudgeAvailable,
isForceConnecting = state.isForceConnecting,
onConnect = onForceConnect,
)
}
} }
} }
// ── Reactions (per-profile, not gated on AAP) ───────────────── // Not nearby — no live BLE; settings require the device to be present
if (device != null && features != null) { 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") { item("reactions_section") {
ReactionsCard( ReactionsCard(
device = device, device = device,
@@ -322,15 +356,7 @@ fun DeviceSettingsScreen(
} }
// Settings — only show when AAP is connected // Settings — only show when AAP is connected
if (features != null && device.isAapConnected) { if (features != null && device.isAapConnected && device.hasSelectedPairedDevice) {
if (device.hasPendingSettings == true) {
item("pending_info") {
SettingsInfoBox(
text = stringResource(R.string.device_settings_pending_info),
)
}
}
// ── Noise Control ──────────────────────────── // ── Noise Control ────────────────────────────
if (features.hasAncControl && device.ancMode != null) { 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") { item("bottom_spacer") {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
@@ -501,6 +537,7 @@ internal fun previewFullState(isPro: Boolean) = DeviceSettingsViewModel.State(
), ),
now = MOCK_NOW, now = MOCK_NOW,
isPro = isPro, isPro = isPro,
isClassicallyConnected = true,
) )
@Preview2 @Preview2
@@ -566,3 +603,15 @@ private fun DeviceSettingsCachedOnlyPreview() = PreviewWrapper {
onNavigateUp = {}, 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)) 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() { fun launchUpgrade() {
log(TAG, INFO) { "launchUpgrade()" } log(TAG, INFO) { "launchUpgrade()" }
navTo(Nav.Main.Upgrade) 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_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_connect_action">Connect</string>
<string name="device_settings_not_connected_open_settings_action">Open Bluetooth Settings</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_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_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> <string name="device_settings_aap_unavailable_action">View compatibility tracker</string>