feat(overview): Add setting to hide unmatched devices

This commit is contained in:
darken
2026-05-31 10:04:26 +02:00
committed by Matthias Urhahn
parent 5ee777b196
commit fdd4f69247
7 changed files with 136 additions and 8 deletions
@@ -83,6 +83,8 @@ class GeneralSettings @Inject constructor(
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false) val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
val hideUnmatchedDevices = dataStore.createValue("ui.overview.unmatched.hidden", false)
val themeMode = dataStore.createValue( val themeMode = dataStore.createValue(
"core.ui.theme.mode", ThemeMode.SYSTEM, json, "core.ui.theme.mode", ThemeMode.SYSTEM, json,
onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV, onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV,
@@ -352,17 +352,17 @@ fun OverviewScreen(
} }
// 5. Monitoring active card // 5. Monitoring active card
if (state.profiles.isNotEmpty() && state.devices.isEmpty()) { if (state.profiles.isNotEmpty() && state.profiledDevices.isEmpty() && !state.shouldShowUnmatchedSection) {
item(key = "monitoring_active") { item(key = "monitoring_active") {
MonitoringActiveCard() MonitoringActiveCard()
} }
} }
// 6. Unmatched devices section // 6. Unmatched devices section
if (state.unmatchedDevices.isNotEmpty()) { if (state.shouldShowUnmatchedSection) {
item(key = "unmatched_header") { item(key = "unmatched_header") {
UnmatchedDevicesCard( UnmatchedDevicesCard(
count = state.unmatchedDevices.size, count = state.visibleUnmatchedDevices.size,
isExpanded = state.showUnmatchedDevices, isExpanded = state.showUnmatchedDevices,
onToggle = onToggleUnmatched, onToggle = onToggleUnmatched,
) )
@@ -370,7 +370,7 @@ fun OverviewScreen(
if (state.showUnmatchedDevices) { if (state.showUnmatchedDevices) {
itemsIndexed( itemsIndexed(
items = state.unmatchedDevices, items = state.visibleUnmatchedDevices,
key = { index, device -> unmatchedDeviceKey(device, index) }, key = { index, device -> unmatchedDeviceKey(device, index) },
) { _, device -> ) { _, device ->
PodDeviceCard( PodDeviceCard(
@@ -34,7 +34,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.combine as combineFlows
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@@ -78,6 +80,26 @@ class OverviewViewModel @Inject constructor(
private val showUnmatchedDevices = MutableStateFlow(false) private val showUnmatchedDevices = MutableStateFlow(false)
private val userExpansionOverrides = MutableStateFlow<Set<String>>(emptySet()) private val userExpansionOverrides = MutableStateFlow<Set<String>>(emptySet())
private data class OverviewUiSettings(
val reactionsHintDismissed: Boolean,
val hideUnmatchedDevices: Boolean,
)
private val overviewUiSettings = combineFlows(
generalSettings.reactionsHintDismissed.flow,
generalSettings.hideUnmatchedDevices.flow,
) { reactionsHintDismissed, hideUnmatched -> OverviewUiSettings(reactionsHintDismissed, hideUnmatched) }
init {
// When the persistent "hide unmatched" setting is enabled, reset the in-session expand
// toggle so the section reappears collapsed if the user later disables the setting.
launch {
generalSettings.hideUnmatchedDevices.flow
.filter { it }
.collect { showUnmatchedDevices.value = false }
}
}
val workerAutolaunch = combine( val workerAutolaunch = combine(
permissionTool.missingScanPermissions, permissionTool.missingScanPermissions,
monitorModeResolver.effectiveMode, monitorModeResolver.effectiveMode,
@@ -134,9 +156,9 @@ class OverviewViewModel @Inject constructor(
upgradeRepo.upgradeInfo, upgradeRepo.upgradeInfo,
showUnmatchedDevices, showUnmatchedDevices,
userExpansionOverrides, userExpansionOverrides,
generalSettings.reactionsHintDismissed.flow, overviewUiSettings,
profilesRepo.hadLegacyReactionData, profilesRepo.hadLegacyReactionData,
) { _, permissions, devices, isDebug, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, reactionsHintDismissed, hadLegacyReactionData -> ) { _, permissions, devices, isDebug, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, uiSettings, hadLegacyReactionData ->
// Prune stale overrides (profiles that no longer exist) // Prune stale overrides (profiles that no longer exist)
val currentProfileIds = profiles.map { it.id }.toSet() val currentProfileIds = profiles.map { it.id }.toSet()
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet() val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
@@ -151,7 +173,8 @@ class OverviewViewModel @Inject constructor(
upgradeInfo = upgradeInfo, upgradeInfo = upgradeInfo,
showUnmatchedDevices = showUnmatched, showUnmatchedDevices = showUnmatched,
userExpandedIds = prunedExpandedIds, userExpandedIds = prunedExpandedIds,
showReactionsHint = hadLegacyReactionData && !reactionsHintDismissed, showReactionsHint = hadLegacyReactionData && !uiSettings.reactionsHintDismissed,
hideUnmatchedDevices = uiSettings.hideUnmatchedDevices,
) )
}.asLiveState() }.asLiveState()
@@ -168,6 +191,7 @@ class OverviewViewModel @Inject constructor(
val showUnmatchedDevices: Boolean, val showUnmatchedDevices: Boolean,
val userExpandedIds: Set<String> = emptySet(), val userExpandedIds: Set<String> = emptySet(),
val showReactionsHint: Boolean = false, val showReactionsHint: Boolean = false,
val hideUnmatchedDevices: Boolean = false,
) { ) {
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking } val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
@@ -188,6 +212,11 @@ class OverviewViewModel @Inject constructor(
val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.profileId == null } val unmatchedDevices: List<PodDevice> get() = devices.filter { it.profileId == null }
/** Unmatched devices actually shown on the dashboard (empty when the hide setting is on). */
val visibleUnmatchedDevices: List<PodDevice>
get() = if (hideUnmatchedDevices) emptyList() else unmatchedDevices
val shouldShowUnmatchedSection: Boolean get() = visibleUnmatchedDevices.isNotEmpty()
val bluetoothIconState: BluetoothIconState val bluetoothIconState: BluetoothIconState
get() = when { get() = when {
isScanBlocked -> BluetoothIconState.HIDDEN isScanBlocked -> BluetoothIconState.HIDDEN
@@ -12,6 +12,7 @@ import androidx.compose.material.icons.twotone.FilterList
import androidx.compose.material.icons.automirrored.twotone.Message import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.twotone.Notifications import androidx.compose.material.icons.twotone.Notifications
import androidx.compose.material.icons.twotone.Palette import androidx.compose.material.icons.twotone.Palette
import androidx.compose.material.icons.twotone.VisibilityOff
import androidx.compose.material.icons.automirrored.twotone.ViewList import androidx.compose.material.icons.automirrored.twotone.ViewList
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -58,6 +59,7 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
onOffloadedFilteringDisabledChanged = { disabled -> vm.setOffloadedFilteringDisabled(disabled) }, onOffloadedFilteringDisabledChanged = { disabled -> vm.setOffloadedFilteringDisabled(disabled) },
onOffloadedBatchingDisabledChanged = { disabled -> vm.setOffloadedBatchingDisabled(disabled) }, onOffloadedBatchingDisabledChanged = { disabled -> vm.setOffloadedBatchingDisabled(disabled) },
onUseIndirectScanResultCallbackChanged = { enabled -> vm.setUseIndirectScanResultCallback(enabled) }, onUseIndirectScanResultCallbackChanged = { enabled -> vm.setUseIndirectScanResultCallback(enabled) },
onHideUnmatchedDevicesChanged = { enabled -> vm.setHideUnmatchedDevices(enabled) },
onThemeModeSelected = { mode -> vm.setThemeMode(mode) }, onThemeModeSelected = { mode -> vm.setThemeMode(mode) },
onThemeStyleSelected = { style -> vm.setThemeStyle(style) }, onThemeStyleSelected = { style -> vm.setThemeStyle(style) },
onThemeColorSelected = { color -> vm.setThemeColor(color) }, onThemeColorSelected = { color -> vm.setThemeColor(color) },
@@ -75,6 +77,7 @@ fun GeneralSettingsScreen(
onOffloadedFilteringDisabledChanged: (Boolean) -> Unit, onOffloadedFilteringDisabledChanged: (Boolean) -> Unit,
onOffloadedBatchingDisabledChanged: (Boolean) -> Unit, onOffloadedBatchingDisabledChanged: (Boolean) -> Unit,
onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit, onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit,
onHideUnmatchedDevicesChanged: (Boolean) -> Unit,
onThemeModeSelected: (ThemeMode) -> Unit = {}, onThemeModeSelected: (ThemeMode) -> Unit = {},
onThemeStyleSelected: (ThemeStyle) -> Unit = {}, onThemeStyleSelected: (ThemeStyle) -> Unit = {},
onThemeColorSelected: (ThemeColor) -> Unit = {}, onThemeColorSelected: (ThemeColor) -> Unit = {},
@@ -197,6 +200,21 @@ fun GeneralSettingsScreen(
}, },
) )
} }
item {
SettingsBaseItem(
title = stringResource(R.string.settings_overview_hide_unmatched_label),
subtitle = stringResource(R.string.settings_overview_hide_unmatched_description),
icon = Icons.TwoTone.VisibilityOff,
onClick = { onHideUnmatchedDevicesChanged(!state.hideUnmatchedDevices) },
trailingContent = {
Switch(
checked = state.hideUnmatchedDevices,
onCheckedChange = onHideUnmatchedDevicesChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
item { item {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_compatibility_options_title)) SettingsCategoryHeader(text = stringResource(R.string.settings_category_compatibility_options_title))
} }
@@ -267,6 +285,7 @@ private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State
isOffloadedFilteringDisabled = false, isOffloadedFilteringDisabled = false,
isOffloadedBatchingDisabled = false, isOffloadedBatchingDisabled = false,
useIndirectScanResultCallback = false, useIndirectScanResultCallback = false,
hideUnmatchedDevices = false,
themeState = ThemeState(), themeState = ThemeState(),
) )
@@ -281,6 +300,7 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper {
onOffloadedFilteringDisabledChanged = {}, onOffloadedFilteringDisabledChanged = {},
onOffloadedBatchingDisabledChanged = {}, onOffloadedBatchingDisabledChanged = {},
onUseIndirectScanResultCallbackChanged = {}, onUseIndirectScanResultCallbackChanged = {},
onHideUnmatchedDevicesChanged = {},
) )
} }
@@ -295,5 +315,6 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper {
onOffloadedFilteringDisabledChanged = {}, onOffloadedFilteringDisabledChanged = {},
onOffloadedBatchingDisabledChanged = {}, onOffloadedBatchingDisabledChanged = {},
onUseIndirectScanResultCallbackChanged = {}, onUseIndirectScanResultCallbackChanged = {},
onHideUnmatchedDevicesChanged = {},
) )
} }
@@ -34,6 +34,7 @@ class GeneralSettingsViewModel @Inject constructor(
val isOffloadedFilteringDisabled: Boolean, val isOffloadedFilteringDisabled: Boolean,
val isOffloadedBatchingDisabled: Boolean, val isOffloadedBatchingDisabled: Boolean,
val useIndirectScanResultCallback: Boolean, val useIndirectScanResultCallback: Boolean,
val hideUnmatchedDevices: Boolean,
val themeState: ThemeState, val themeState: ThemeState,
) )
@@ -57,7 +58,8 @@ class GeneralSettingsViewModel @Inject constructor(
}, },
generalSettings.themeState, generalSettings.themeState,
isPro, isPro,
) { general, compat, themeState, isPro -> generalSettings.hideUnmatchedDevices.flow,
) { general, compat, themeState, isPro, hideUnmatched ->
State( State(
isPro = isPro, isPro = isPro,
showConnectedNotification = general[0] as Boolean, showConnectedNotification = general[0] as Boolean,
@@ -65,6 +67,7 @@ class GeneralSettingsViewModel @Inject constructor(
isOffloadedFilteringDisabled = compat[0] as Boolean, isOffloadedFilteringDisabled = compat[0] as Boolean,
isOffloadedBatchingDisabled = compat[1] as Boolean, isOffloadedBatchingDisabled = compat[1] as Boolean,
useIndirectScanResultCallback = compat[2] as Boolean, useIndirectScanResultCallback = compat[2] as Boolean,
hideUnmatchedDevices = hideUnmatched,
themeState = themeState, themeState = themeState,
) )
}.asLiveState() }.asLiveState()
@@ -94,6 +97,11 @@ class GeneralSettingsViewModel @Inject constructor(
generalSettings.useIndirectScanResultCallback.valueBlocking = enabled generalSettings.useIndirectScanResultCallback.valueBlocking = enabled
} }
fun setHideUnmatchedDevices(enabled: Boolean) {
log(TAG, INFO) { "setHideUnmatchedDevices($enabled)" }
generalSettings.hideUnmatchedDevices.valueBlocking = enabled
}
fun setThemeMode(mode: ThemeMode) = launch { fun setThemeMode(mode: ThemeMode) = launch {
log(TAG, INFO) { "setThemeMode($mode)" } log(TAG, INFO) { "setThemeMode($mode)" }
if (isPro.first()) { if (isPro.first()) {
+3
View File
@@ -127,6 +127,9 @@
<string name="settings_category_debug_label">Debug</string> <string name="settings_category_debug_label">Debug</string>
<string name="settings_general_label">Settings</string> <string name="settings_general_label">Settings</string>
<string name="settings_general_description">General tweaks that affect the whole app.</string> <string name="settings_general_description">General tweaks that affect the whole app.</string>
<string name="settings_overview_hide_unmatched_label">Hide unmatched devices</string>
<string name="settings_overview_hide_unmatched_description">Don\'t show nearby devices that don\'t match any of your profiles in the overview, e.g. other people\'s AirPods.</string>
<string name="settings_acknowledgements_label">Acknowledgements</string> <string name="settings_acknowledgements_label">Acknowledgements</string>
<string name="settings_debug_autoreports_label">Automatic bug reports</string> <string name="settings_debug_autoreports_label">Automatic bug reports</string>
@@ -64,6 +64,7 @@ class OverviewViewModelTest : BaseTest() {
private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info> private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info>
private lateinit var effectiveModeFlow: MutableStateFlow<MonitorMode> private lateinit var effectiveModeFlow: MutableStateFlow<MonitorMode>
private lateinit var fakeReactionsHintDismissed: FakeDataStoreValue<Boolean> private lateinit var fakeReactionsHintDismissed: FakeDataStoreValue<Boolean>
private lateinit var fakeHideUnmatchedDevices: FakeDataStoreValue<Boolean>
@BeforeEach @BeforeEach
fun setup() { fun setup() {
@@ -78,6 +79,7 @@ class OverviewViewModelTest : BaseTest() {
upgradeInfoFlow = MutableStateFlow(mockk<UpgradeRepo.Info>(relaxed = true)) upgradeInfoFlow = MutableStateFlow(mockk<UpgradeRepo.Info>(relaxed = true))
effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC) effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC)
fakeReactionsHintDismissed = FakeDataStoreValue(false) fakeReactionsHintDismissed = FakeDataStoreValue(false)
fakeHideUnmatchedDevices = FakeDataStoreValue(false)
Bugs.isDebug.value = false Bugs.isDebug.value = false
monitorControl = mockk(relaxed = true) monitorControl = mockk(relaxed = true)
@@ -95,6 +97,7 @@ class OverviewViewModelTest : BaseTest() {
generalSettings = mockk<GeneralSettings>().also { generalSettings = mockk<GeneralSettings>().also {
every { it.reactionsHintDismissed } returns fakeReactionsHintDismissed.mock every { it.reactionsHintDismissed } returns fakeReactionsHintDismissed.mock
every { it.hideUnmatchedDevices } returns fakeHideUnmatchedDevices.mock
} }
monitorModeResolver = mockk<MonitorModeResolver>().also { monitorModeResolver = mockk<MonitorModeResolver>().also {
@@ -341,6 +344,68 @@ class OverviewViewModelTest : BaseTest() {
state.visibleProfiledDevices shouldBe listOf(profiled1) state.visibleProfiledDevices shouldBe listOf(profiled1)
state.hiddenProfiledDeviceCount shouldBe 1 state.hiddenProfiledDeviceCount shouldBe 1
} }
@Test
fun `hideUnmatchedDevices defaults to false`() = runTest(testDispatcher) {
val vm = createViewModel()
val state = vm.state.first()
state.hideUnmatchedDevices shouldBe false
}
@Test
fun `enabling hideUnmatchedDevices setting propagates to state`() = runTest(testDispatcher) {
fakeHideUnmatchedDevices.value = true
val vm = createViewModel()
val state = vm.state.first()
state.hideUnmatchedDevices shouldBe true
}
@Test
fun `hideUnmatchedDevices true hides the unmatched section`() {
val unmatched = PodDevice(profileId = null, ble = mockk(relaxed = true), aap = null)
val shown = OverviewViewModel.State(
now = java.time.Instant.now(),
permissions = emptySet(),
devices = listOf(unmatched),
isDebug = false,
isBluetoothEnabled = true,
profiles = emptyList(),
upgradeInfo = mockk(relaxed = true),
showUnmatchedDevices = false,
hideUnmatchedDevices = false,
)
shown.visibleUnmatchedDevices shouldBe listOf(unmatched)
shown.shouldShowUnmatchedSection shouldBe true
val hidden = shown.copy(hideUnmatchedDevices = true)
hidden.visibleUnmatchedDevices shouldBe emptyList()
hidden.shouldShowUnmatchedSection shouldBe false
}
@Test
fun `only hidden unmatched devices present - no visible content`() {
// Guards the dashboard blank-state: when only hidden unmatched devices are nearby there
// are no profiled devices AND no visible unmatched section, so the screen shows the
// "monitoring active" card instead of an empty list.
val unmatched = PodDevice(profileId = null, ble = mockk(relaxed = true), aap = null)
val state = OverviewViewModel.State(
now = java.time.Instant.now(),
permissions = emptySet(),
devices = listOf(unmatched),
isDebug = false,
isBluetoothEnabled = true,
profiles = emptyList(),
upgradeInfo = mockk(relaxed = true),
showUnmatchedDevices = false,
hideUnmatchedDevices = true,
)
state.profiledDevices shouldBe emptyList()
state.shouldShowUnmatchedSection shouldBe false
}
} }
@Nested @Nested