mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
feat(overview): Add setting to hide unmatched devices
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<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(
|
||||
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<String> = 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<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
|
||||
get() = when {
|
||||
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.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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
+9
-1
@@ -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()) {
|
||||
|
||||
@@ -127,6 +127,9 @@
|
||||
<string name="settings_category_debug_label">Debug</string>
|
||||
<string name="settings_general_label">Settings</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_debug_autoreports_label">Automatic bug reports</string>
|
||||
|
||||
@@ -64,6 +64,7 @@ class OverviewViewModelTest : BaseTest() {
|
||||
private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info>
|
||||
private lateinit var effectiveModeFlow: MutableStateFlow<MonitorMode>
|
||||
private lateinit var fakeReactionsHintDismissed: FakeDataStoreValue<Boolean>
|
||||
private lateinit var fakeHideUnmatchedDevices: FakeDataStoreValue<Boolean>
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
@@ -78,6 +79,7 @@ class OverviewViewModelTest : BaseTest() {
|
||||
upgradeInfoFlow = MutableStateFlow(mockk<UpgradeRepo.Info>(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<GeneralSettings>().also {
|
||||
every { it.reactionsHintDismissed } returns fakeReactionsHintDismissed.mock
|
||||
every { it.hideUnmatchedDevices } returns fakeHideUnmatchedDevices.mock
|
||||
}
|
||||
|
||||
monitorModeResolver = mockk<MonitorModeResolver>().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
|
||||
|
||||
Reference in New Issue
Block a user