diff --git a/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt b/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt index 300b9f96..aa53d736 100644 --- a/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt +++ b/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt @@ -83,6 +83,8 @@ class GeneralSettings @Inject constructor( val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false) + val hideUnmatchedDevices = dataStore.createValue("ui.overview.unmatched.hidden", false) + val themeMode = dataStore.createValue( "core.ui.theme.mode", ThemeMode.SYSTEM, json, onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV, diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt index 1e3bc325..cc4614ea 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt @@ -352,17 +352,17 @@ fun OverviewScreen( } // 5. Monitoring active card - if (state.profiles.isNotEmpty() && state.devices.isEmpty()) { + if (state.profiles.isNotEmpty() && state.profiledDevices.isEmpty() && !state.shouldShowUnmatchedSection) { item(key = "monitoring_active") { MonitoringActiveCard() } } // 6. Unmatched devices section - if (state.unmatchedDevices.isNotEmpty()) { + if (state.shouldShowUnmatchedSection) { item(key = "unmatched_header") { UnmatchedDevicesCard( - count = state.unmatchedDevices.size, + count = state.visibleUnmatchedDevices.size, isExpanded = state.showUnmatchedDevices, onToggle = onToggleUnmatched, ) @@ -370,7 +370,7 @@ fun OverviewScreen( if (state.showUnmatchedDevices) { itemsIndexed( - items = state.unmatchedDevices, + items = state.visibleUnmatchedDevices, key = { index, device -> unmatchedDeviceKey(device, index) }, ) { _, device -> PodDeviceCard( diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt index a15c7b45..1ae8d71d 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt @@ -34,7 +34,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.combine as combineFlows import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.flowOf @@ -78,6 +80,26 @@ class OverviewViewModel @Inject constructor( private val showUnmatchedDevices = MutableStateFlow(false) private val userExpansionOverrides = MutableStateFlow>(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( permissionTool.missingScanPermissions, monitorModeResolver.effectiveMode, @@ -134,9 +156,9 @@ class OverviewViewModel @Inject constructor( upgradeRepo.upgradeInfo, showUnmatchedDevices, userExpansionOverrides, - generalSettings.reactionsHintDismissed.flow, + overviewUiSettings, 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) val currentProfileIds = profiles.map { it.id }.toSet() val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet() @@ -151,7 +173,8 @@ class OverviewViewModel @Inject constructor( upgradeInfo = upgradeInfo, showUnmatchedDevices = showUnmatched, userExpandedIds = prunedExpandedIds, - showReactionsHint = hadLegacyReactionData && !reactionsHintDismissed, + showReactionsHint = hadLegacyReactionData && !uiSettings.reactionsHintDismissed, + hideUnmatchedDevices = uiSettings.hideUnmatchedDevices, ) }.asLiveState() @@ -168,6 +191,7 @@ class OverviewViewModel @Inject constructor( val showUnmatchedDevices: Boolean, val userExpandedIds: Set = emptySet(), val showReactionsHint: Boolean = false, + val hideUnmatchedDevices: Boolean = false, ) { 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 unmatchedDevices: List get() = devices.filter { it.profileId == null } + /** Unmatched devices actually shown on the dashboard (empty when the hide setting is on). */ + val visibleUnmatchedDevices: List + get() = if (hideUnmatchedDevices) emptyList() else unmatchedDevices + val shouldShowUnmatchedSection: Boolean get() = visibleUnmatchedDevices.isNotEmpty() + val bluetoothIconState: BluetoothIconState get() = when { isScanBlocked -> BluetoothIconState.HIDDEN diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsScreen.kt index f0ed3011..c58c79bc 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.material.icons.twotone.FilterList import androidx.compose.material.icons.automirrored.twotone.Message import androidx.compose.material.icons.twotone.Notifications 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.material3.Icon import androidx.compose.material3.IconButton @@ -58,6 +59,7 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) { onOffloadedFilteringDisabledChanged = { disabled -> vm.setOffloadedFilteringDisabled(disabled) }, onOffloadedBatchingDisabledChanged = { disabled -> vm.setOffloadedBatchingDisabled(disabled) }, onUseIndirectScanResultCallbackChanged = { enabled -> vm.setUseIndirectScanResultCallback(enabled) }, + onHideUnmatchedDevicesChanged = { enabled -> vm.setHideUnmatchedDevices(enabled) }, onThemeModeSelected = { mode -> vm.setThemeMode(mode) }, onThemeStyleSelected = { style -> vm.setThemeStyle(style) }, onThemeColorSelected = { color -> vm.setThemeColor(color) }, @@ -75,6 +77,7 @@ fun GeneralSettingsScreen( onOffloadedFilteringDisabledChanged: (Boolean) -> Unit, onOffloadedBatchingDisabledChanged: (Boolean) -> Unit, onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit, + onHideUnmatchedDevicesChanged: (Boolean) -> Unit, onThemeModeSelected: (ThemeMode) -> Unit = {}, onThemeStyleSelected: (ThemeStyle) -> 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 { SettingsCategoryHeader(text = stringResource(R.string.settings_category_compatibility_options_title)) } @@ -267,6 +285,7 @@ private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State isOffloadedFilteringDisabled = false, isOffloadedBatchingDisabled = false, useIndirectScanResultCallback = false, + hideUnmatchedDevices = false, themeState = ThemeState(), ) @@ -281,6 +300,7 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper { onOffloadedFilteringDisabledChanged = {}, onOffloadedBatchingDisabledChanged = {}, onUseIndirectScanResultCallbackChanged = {}, + onHideUnmatchedDevicesChanged = {}, ) } @@ -295,5 +315,6 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper { onOffloadedFilteringDisabledChanged = {}, onOffloadedBatchingDisabledChanged = {}, onUseIndirectScanResultCallbackChanged = {}, + onHideUnmatchedDevicesChanged = {}, ) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModel.kt index df7a5b29..4fe83be6 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModel.kt @@ -34,6 +34,7 @@ class GeneralSettingsViewModel @Inject constructor( val isOffloadedFilteringDisabled: Boolean, val isOffloadedBatchingDisabled: Boolean, val useIndirectScanResultCallback: Boolean, + val hideUnmatchedDevices: Boolean, val themeState: ThemeState, ) @@ -57,7 +58,8 @@ class GeneralSettingsViewModel @Inject constructor( }, generalSettings.themeState, isPro, - ) { general, compat, themeState, isPro -> + generalSettings.hideUnmatchedDevices.flow, + ) { general, compat, themeState, isPro, hideUnmatched -> State( isPro = isPro, showConnectedNotification = general[0] as Boolean, @@ -65,6 +67,7 @@ class GeneralSettingsViewModel @Inject constructor( isOffloadedFilteringDisabled = compat[0] as Boolean, isOffloadedBatchingDisabled = compat[1] as Boolean, useIndirectScanResultCallback = compat[2] as Boolean, + hideUnmatchedDevices = hideUnmatched, themeState = themeState, ) }.asLiveState() @@ -94,6 +97,11 @@ class GeneralSettingsViewModel @Inject constructor( generalSettings.useIndirectScanResultCallback.valueBlocking = enabled } + fun setHideUnmatchedDevices(enabled: Boolean) { + log(TAG, INFO) { "setHideUnmatchedDevices($enabled)" } + generalSettings.hideUnmatchedDevices.valueBlocking = enabled + } + fun setThemeMode(mode: ThemeMode) = launch { log(TAG, INFO) { "setThemeMode($mode)" } if (isPro.first()) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 32f3f944..a1026a12 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -127,6 +127,9 @@ Debug Settings General tweaks that affect the whole app. + + Hide unmatched devices + Don\'t show nearby devices that don\'t match any of your profiles in the overview, e.g. other people\'s AirPods. Acknowledgements Automatic bug reports diff --git a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt index 6b09d622..b1136bc0 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt @@ -64,6 +64,7 @@ class OverviewViewModelTest : BaseTest() { private lateinit var upgradeInfoFlow: MutableStateFlow private lateinit var effectiveModeFlow: MutableStateFlow private lateinit var fakeReactionsHintDismissed: FakeDataStoreValue + private lateinit var fakeHideUnmatchedDevices: FakeDataStoreValue @BeforeEach fun setup() { @@ -78,6 +79,7 @@ class OverviewViewModelTest : BaseTest() { upgradeInfoFlow = MutableStateFlow(mockk(relaxed = true)) effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC) fakeReactionsHintDismissed = FakeDataStoreValue(false) + fakeHideUnmatchedDevices = FakeDataStoreValue(false) Bugs.isDebug.value = false monitorControl = mockk(relaxed = true) @@ -95,6 +97,7 @@ class OverviewViewModelTest : BaseTest() { generalSettings = mockk().also { every { it.reactionsHintDismissed } returns fakeReactionsHintDismissed.mock + every { it.hideUnmatchedDevices } returns fakeHideUnmatchedDevices.mock } monitorModeResolver = mockk().also { @@ -341,6 +344,68 @@ class OverviewViewModelTest : BaseTest() { state.visibleProfiledDevices shouldBe listOf(profiled1) 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