mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user