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 f9018464..f0330987 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 @@ -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") { 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 ebbb0e14..5ec1134f 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 @@ -135,6 +135,9 @@ class OverviewViewModel @Inject constructor( ) { val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking } val profiledDevices: List get() = devices.filter { it.profileId != null } + val visibleProfiledDevices: List + get() = if (upgradeInfo.isPro) profiledDevices else profiledDevices.take(FREE_DEVICE_LIMIT) + val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size val unmatchedDevices: List 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") } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DeviceLimitUpgradeCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DeviceLimitUpgradeCard.kt new file mode 100644 index 00000000..7e12487e --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DeviceLimitUpgradeCard.kt @@ -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 = {}, + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f34cf13c..44347e83 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -210,6 +210,10 @@ %d device without matching profile %d devices without matching profile + + %d more device + %d more devices + Bluetooth connect This app requires the \"Bluetooth connect\" permission to interact with paired devices and initiate connections. 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 e6edf51b..c4ee24f6 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 @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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