feat(overview): Gate dashboard device count for free users

Free users see only their highest-priority profiled device. Additional devices are hidden behind an upgrade card showing the count and a flavor-aware CTA (Upgrade/Donate).
This commit is contained in:
darken
2026-04-02 18:30:40 +02:00
committed by Matthias Urhahn
parent 927a198d8e
commit c609b03148
5 changed files with 232 additions and 2 deletions
@@ -46,6 +46,7 @@ import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledCard
import eu.darken.capod.main.ui.overview.cards.DeviceLimitUpgradeCard
import eu.darken.capod.main.ui.overview.cards.DualPodsCard
import eu.darken.capod.main.ui.overview.cards.MonitoringActiveCard
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
@@ -221,10 +222,10 @@ fun OverviewScreen(
}
}
// 4. Profiled device cards
// 4. Profiled device cards (limited to 1 for free users)
if (!state.isScanBlocked && state.isBluetoothEnabled) {
items(
items = state.profiledDevices,
items = state.visibleProfiledDevices,
key = { it.identifier?.toString() ?: it.hashCode() },
) { device ->
PodDeviceCard(
@@ -236,6 +237,17 @@ fun OverviewScreen(
)
}
// 4b. Upgrade card when additional devices are hidden
if (state.hiddenProfiledDeviceCount > 0) {
item(key = "device_limit_upgrade") {
DeviceLimitUpgradeCard(
hiddenCount = state.hiddenProfiledDeviceCount,
upgradeType = state.upgradeInfo.type,
onUpgrade = onUpgrade,
)
}
}
// 5. Monitoring active card
if (state.profiles.isNotEmpty() && state.devices.isEmpty()) {
item(key = "monitoring_active") {
@@ -135,6 +135,9 @@ class OverviewViewModel @Inject constructor(
) {
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
val profiledDevices: List<PodDevice> get() = devices.filter { it.profileId != null }
val visibleProfiledDevices: List<PodDevice>
get() = if (upgradeInfo.isPro) profiledDevices else profiledDevices.take(FREE_DEVICE_LIMIT)
val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.profileId == null }
}
@@ -187,6 +190,7 @@ class OverviewViewModel @Inject constructor(
}
companion object {
private const val FREE_DEVICE_LIMIT = 1
private val TAG = logTag("Overview", "VM")
}
}
@@ -0,0 +1,94 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Stars
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.upgrade.UpgradeRepo
@Composable
fun DeviceLimitUpgradeCard(
hiddenCount: Int,
upgradeType: UpgradeRepo.Type,
onUpgrade: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Text(
text = pluralStringResource(R.plurals.overview_more_devices_upgrade, hiddenCount, hiddenCount),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.upgrade_capod_description),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onUpgrade,
modifier = Modifier.align(Alignment.End),
) {
Icon(
imageVector = Icons.TwoTone.Stars,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
)
Text(
text = stringResource(
when (upgradeType) {
UpgradeRepo.Type.FOSS -> R.string.general_donate_action
UpgradeRepo.Type.GPLAY -> R.string.general_upgrade_action
}
),
)
}
}
}
}
@Preview2
@Composable
private fun DeviceLimitUpgradeCardGplayPreview() = PreviewWrapper {
DeviceLimitUpgradeCard(
hiddenCount = 2,
upgradeType = UpgradeRepo.Type.GPLAY,
onUpgrade = {},
)
}
@Preview2
@Composable
private fun DeviceLimitUpgradeCardFossPreview() = PreviewWrapper {
DeviceLimitUpgradeCard(
hiddenCount = 1,
upgradeType = UpgradeRepo.Type.FOSS,
onUpgrade = {},
)
}
+4
View File
@@ -210,6 +210,10 @@
<item quantity="one">%d device without matching profile</item>
<item quantity="other">%d devices without matching profile</item>
</plurals>
<plurals name="overview_more_devices_upgrade">
<item quantity="one">%d more device</item>
<item quantity="other">%d more devices</item>
</plurals>
<string name="permission_bluetooth_connect_label">Bluetooth connect</string>
<string name="permission_bluetooth_connect_description">This app requires the \"Bluetooth connect\" permission to interact with paired devices and initiate connections.</string>
@@ -217,6 +217,122 @@ class OverviewViewModelTest : BaseTest() {
state.unmatchedDevices shouldBe listOf(unmatched)
}
@Test
fun `free user with no profiled devices - visible empty, hidden 0`() {
val upgradeInfo = mockk<UpgradeRepo.Info> {
every { isPro } returns false
every { type } returns UpgradeRepo.Type.GPLAY
}
val state = OverviewViewModel.State(
now = java.time.Instant.now(),
permissions = emptySet(),
devices = emptyList(),
isDebugMode = false,
isBluetoothEnabled = true,
profiles = emptyList(),
upgradeInfo = upgradeInfo,
showUnmatchedDevices = false,
)
state.visibleProfiledDevices shouldBe emptyList()
state.hiddenProfiledDeviceCount shouldBe 0
}
@Test
fun `free user with 1 profiled device - visible 1, hidden 0`() {
val upgradeInfo = mockk<UpgradeRepo.Info> {
every { isPro } returns false
every { type } returns UpgradeRepo.Type.GPLAY
}
val profiled = PodDevice(profileId = "id-1", ble = mockk(relaxed = true), aap = null)
val state = OverviewViewModel.State(
now = java.time.Instant.now(),
permissions = emptySet(),
devices = listOf(profiled),
isDebugMode = false,
isBluetoothEnabled = true,
profiles = emptyList(),
upgradeInfo = upgradeInfo,
showUnmatchedDevices = false,
)
state.visibleProfiledDevices shouldBe listOf(profiled)
state.hiddenProfiledDeviceCount shouldBe 0
}
@Test
fun `free user with 3 profiled devices - visible 1, hidden 2`() {
val upgradeInfo = mockk<UpgradeRepo.Info> {
every { isPro } returns false
every { type } returns UpgradeRepo.Type.GPLAY
}
val device1 = PodDevice(profileId = "id-1", ble = mockk(relaxed = true), aap = null)
val device2 = PodDevice(profileId = "id-2", ble = mockk(relaxed = true), aap = null)
val device3 = PodDevice(profileId = "id-3", ble = mockk(relaxed = true), aap = null)
val state = OverviewViewModel.State(
now = java.time.Instant.now(),
permissions = emptySet(),
devices = listOf(device1, device2, device3),
isDebugMode = false,
isBluetoothEnabled = true,
profiles = emptyList(),
upgradeInfo = upgradeInfo,
showUnmatchedDevices = false,
)
state.visibleProfiledDevices shouldBe listOf(device1)
state.hiddenProfiledDeviceCount shouldBe 2
}
@Test
fun `pro user with multiple profiled devices - all visible, hidden 0`() {
val upgradeInfo = mockk<UpgradeRepo.Info> {
every { isPro } returns true
every { type } returns UpgradeRepo.Type.GPLAY
}
val device1 = PodDevice(profileId = "id-1", ble = mockk(relaxed = true), aap = null)
val device2 = PodDevice(profileId = "id-2", ble = mockk(relaxed = true), aap = null)
val device3 = PodDevice(profileId = "id-3", ble = mockk(relaxed = true), aap = null)
val state = OverviewViewModel.State(
now = java.time.Instant.now(),
permissions = emptySet(),
devices = listOf(device1, device2, device3),
isDebugMode = false,
isBluetoothEnabled = true,
profiles = emptyList(),
upgradeInfo = upgradeInfo,
showUnmatchedDevices = false,
)
state.visibleProfiledDevices shouldBe listOf(device1, device2, device3)
state.hiddenProfiledDeviceCount shouldBe 0
}
@Test
fun `unmatched devices unchanged regardless of pro status`() {
val upgradeInfo = mockk<UpgradeRepo.Info> {
every { isPro } returns false
every { type } returns UpgradeRepo.Type.GPLAY
}
val profiled1 = PodDevice(profileId = "id-1", ble = mockk(relaxed = true), aap = null)
val profiled2 = PodDevice(profileId = "id-2", ble = mockk(relaxed = true), aap = null)
val unmatched = PodDevice(profileId = null, ble = mockk(relaxed = true), aap = null)
val state = OverviewViewModel.State(
now = java.time.Instant.now(),
permissions = emptySet(),
devices = listOf(profiled1, profiled2, unmatched),
isDebugMode = false,
isBluetoothEnabled = true,
profiles = emptyList(),
upgradeInfo = upgradeInfo,
showUnmatchedDevices = false,
)
state.unmatchedDevices shouldBe listOf(unmatched)
state.visibleProfiledDevices shouldBe listOf(profiled1)
state.hiddenProfiledDeviceCount shouldBe 1
}
}
@Nested