mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
feat(reactions): Add discovery hint and outline settings icon
This commit is contained in:
@@ -76,6 +76,8 @@ class GeneralSettings @Inject constructor(
|
||||
|
||||
val isOnboardingDone = dataStore.createValue("core.onboarding.done", false)
|
||||
|
||||
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
|
||||
|
||||
val themeMode = dataStore.createValue(
|
||||
"core.ui.theme.mode", ThemeMode.SYSTEM, json,
|
||||
onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV,
|
||||
|
||||
@@ -59,6 +59,7 @@ 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
|
||||
import eu.darken.capod.main.ui.overview.cards.PermissionCard
|
||||
import eu.darken.capod.main.ui.overview.cards.ReactionsMovedHintCard
|
||||
import eu.darken.capod.main.ui.overview.cards.SinglePodsCard
|
||||
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
|
||||
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
|
||||
@@ -174,7 +175,10 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
onUpgrade = { vm.onUpgrade() },
|
||||
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
|
||||
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
|
||||
onDeviceSettings = { device -> vm.goToDeviceSettings(device) },
|
||||
onDeviceSettings = { device ->
|
||||
if (currentState.showReactionsHint) vm.dismissReactionsHint()
|
||||
vm.goToDeviceSettings(device)
|
||||
},
|
||||
onEditProfile = { device -> vm.goToEditProfile(device) },
|
||||
onToggleDeviceExpansion = { device ->
|
||||
device.profileId?.let { vm.toggleDeviceExpansion(it) }
|
||||
@@ -302,6 +306,12 @@ fun OverviewScreen(
|
||||
|
||||
// 4. Profiled device cards (limited to 1 for free users)
|
||||
if (!state.isScanBlocked && state.isBluetoothEnabled) {
|
||||
if (state.showReactionsHint && state.visibleProfiledDevices.isNotEmpty()) {
|
||||
item(key = "reactions_hint") {
|
||||
ReactionsMovedHintCard()
|
||||
}
|
||||
}
|
||||
|
||||
val duplicateProfileIds = state.visibleProfiledDevices
|
||||
.mapNotNull { it.profileId }
|
||||
.groupingBy { it }
|
||||
@@ -436,6 +446,7 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
|
||||
),
|
||||
upgradeInfo = MockPodDataProvider.fossInfo(),
|
||||
showUnmatchedDevices = false,
|
||||
showReactionsHint = true,
|
||||
),
|
||||
onRequestPermission = {},
|
||||
onBluetoothSettings = {},
|
||||
|
||||
@@ -4,6 +4,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.datastore.value
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
@@ -130,7 +131,9 @@ class OverviewViewModel @Inject constructor(
|
||||
upgradeRepo.upgradeInfo,
|
||||
showUnmatchedDevices,
|
||||
userExpansionOverrides,
|
||||
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds ->
|
||||
generalSettings.reactionsHintDismissed.flow,
|
||||
profilesRepo.hadLegacyReactionData,
|
||||
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, reactionsHintDismissed, hadLegacyReactionData ->
|
||||
// Prune stale overrides (profiles that no longer exist)
|
||||
val currentProfileIds = profiles.map { it.id }.toSet()
|
||||
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
|
||||
@@ -145,6 +148,7 @@ class OverviewViewModel @Inject constructor(
|
||||
upgradeInfo = upgradeInfo,
|
||||
showUnmatchedDevices = showUnmatched,
|
||||
userExpandedIds = prunedExpandedIds,
|
||||
showReactionsHint = hadLegacyReactionData && !reactionsHintDismissed,
|
||||
)
|
||||
}.asLiveState()
|
||||
|
||||
@@ -160,6 +164,7 @@ class OverviewViewModel @Inject constructor(
|
||||
val upgradeInfo: UpgradeRepo.Info,
|
||||
val showUnmatchedDevices: Boolean,
|
||||
val userExpandedIds: Set<String> = emptySet(),
|
||||
val showReactionsHint: Boolean = false,
|
||||
) {
|
||||
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
|
||||
|
||||
@@ -252,6 +257,13 @@ class OverviewViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissReactionsHint() {
|
||||
log(TAG, INFO) { "dismissReactionsHint()" }
|
||||
launch {
|
||||
generalSettings.reactionsHintDismissed.value(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestPermission(permission: Permission) {
|
||||
log(TAG, INFO) { "requestPermission($permission)" }
|
||||
requestPermissionEvent.tryEmit(permission)
|
||||
|
||||
@@ -29,8 +29,8 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedIconButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -168,7 +168,7 @@ fun DualPodsCard(
|
||||
}
|
||||
|
||||
if (device.profileId != null && onDeviceSettings != null) {
|
||||
IconButton(onClick = onDeviceSettings) {
|
||||
OutlinedIconButton(onClick = onDeviceSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Tune,
|
||||
contentDescription = stringResource(R.string.device_settings_open_cd),
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Tune
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
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.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
|
||||
@Composable
|
||||
fun ReactionsMovedHintCard(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(3.dp)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 14.dp, bottom = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Tune,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.overview_reactions_hint_title),
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.overview_reactions_hint_body),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
lineHeight = 18.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun ReactionsMovedHintCardPreview() = PreviewWrapper {
|
||||
ReactionsMovedHintCard()
|
||||
}
|
||||
@@ -28,8 +28,8 @@ import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedIconButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -150,7 +150,7 @@ fun SinglePodsCard(
|
||||
}
|
||||
|
||||
if (device.profileId != null && onDeviceSettings != null) {
|
||||
IconButton(onClick = onDeviceSettings) {
|
||||
OutlinedIconButton(onClick = onDeviceSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Tune,
|
||||
contentDescription = stringResource(R.string.device_settings_open_cd),
|
||||
|
||||
@@ -65,6 +65,30 @@ class DeviceProfilesRepo @Inject constructor(
|
||||
if (!settings.reactionMigrationDone.valueBlocking) {
|
||||
migrateLegacyReactions()
|
||||
}
|
||||
|
||||
detectLegacyReactionData()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether this install ever wrote to the pre-migration global reaction DataStore.
|
||||
* The legacy reader is a read-only probe; the legacy keys persist on old installs even
|
||||
* after [migrateLegacyReactions] has run, so this check works retroactively for users who
|
||||
* already migrated. Used by the Overview hint to target only existing users who actually
|
||||
* configured reactions before the per-device move.
|
||||
*/
|
||||
private suspend fun detectLegacyReactionData() {
|
||||
val hadData = try {
|
||||
val legacy = LegacyReactionSettingsReader(context, json).read()
|
||||
legacy.autoPause || legacy.autoPlay || legacy.autoConnect ||
|
||||
legacy.showPopUpOnCaseOpen || legacy.showPopUpOnConnection ||
|
||||
legacy.onePodMode
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Failed to detect legacy reaction data: ${e.message}" }
|
||||
false
|
||||
}
|
||||
if (hadData != settings.hadLegacyReactionData.valueBlocking) {
|
||||
settings.hadLegacyReactionData.valueBlocking = hadData
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +130,8 @@ class DeviceProfilesRepo @Inject constructor(
|
||||
|
||||
val profiles: Flow<List<DeviceProfile>> = settings.profiles.flow.map { it.profiles }
|
||||
|
||||
val hadLegacyReactionData: Flow<Boolean> = settings.hadLegacyReactionData.flow
|
||||
|
||||
suspend fun addProfile(profile: DeviceProfile, addFirst: Boolean = false) = mutex.withLock {
|
||||
val currentContainer = settings.profiles.valueBlocking
|
||||
checkAddressUniqueness(profile, currentContainer.profiles)
|
||||
|
||||
@@ -34,5 +34,6 @@ class DeviceProfilesSettings @Inject constructor(
|
||||
val singleToMultiMigrationDone = dataStore.createValue("profiles.migration.v2.done", false)
|
||||
val defaultProfileCreated = dataStore.createValue("profiles.default.v2.created", false)
|
||||
val reactionMigrationDone = dataStore.createValue("profiles.reactions.migration.done", false)
|
||||
val hadLegacyReactionData = dataStore.createValue("profiles.reactions.had_legacy_data", false)
|
||||
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedIconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
@@ -261,7 +262,7 @@ private fun ProfileRow(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDeviceSettings) {
|
||||
OutlinedIconButton(onClick = onDeviceSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Tune,
|
||||
contentDescription = stringResource(R.string.device_settings_open_cd),
|
||||
|
||||
@@ -509,6 +509,8 @@
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Double press to end call</string>
|
||||
<string name="device_settings_open_cd">Open device settings</string>
|
||||
<string name="overview_card_missing_paired_device">This profile has no paired Bluetooth device.</string>
|
||||
<string name="overview_reactions_hint_title">Reactions are per device</string>
|
||||
<string name="overview_reactions_hint_body">Auto-play, auto-pause and pop-ups are now configured per device. Tap the settings icon on a card while your headphones are connected.</string>
|
||||
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">General</string>
|
||||
|
||||
@@ -58,9 +58,11 @@ class OverviewViewModelTest : BaseTest() {
|
||||
private lateinit var connectedDevicesFlow: MutableStateFlow<List<BluetoothDevice2>>
|
||||
private lateinit var isBluetoothEnabledFlow: MutableStateFlow<Boolean>
|
||||
private lateinit var profilesFlow: MutableStateFlow<List<DeviceProfile>>
|
||||
private lateinit var hadLegacyReactionDataFlow: MutableStateFlow<Boolean>
|
||||
private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info>
|
||||
private lateinit var fakeMonitorMode: FakeDataStoreValue<MonitorMode>
|
||||
private lateinit var fakeDebugMode: FakeDataStoreValue<Boolean>
|
||||
private lateinit var fakeReactionsHintDismissed: FakeDataStoreValue<Boolean>
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
@@ -71,9 +73,11 @@ class OverviewViewModelTest : BaseTest() {
|
||||
connectedDevicesFlow = MutableStateFlow(emptyList())
|
||||
isBluetoothEnabledFlow = MutableStateFlow(true)
|
||||
profilesFlow = MutableStateFlow(emptyList())
|
||||
hadLegacyReactionDataFlow = MutableStateFlow(false)
|
||||
upgradeInfoFlow = MutableStateFlow(mockk<UpgradeRepo.Info>(relaxed = true))
|
||||
fakeMonitorMode = FakeDataStoreValue(MonitorMode.AUTOMATIC)
|
||||
fakeDebugMode = FakeDataStoreValue(false)
|
||||
fakeReactionsHintDismissed = FakeDataStoreValue(false)
|
||||
|
||||
monitorControl = mockk(relaxed = true)
|
||||
|
||||
@@ -90,6 +94,7 @@ class OverviewViewModelTest : BaseTest() {
|
||||
|
||||
generalSettings = mockk<GeneralSettings>().also {
|
||||
every { it.monitorMode } returns fakeMonitorMode.mock
|
||||
every { it.reactionsHintDismissed } returns fakeReactionsHintDismissed.mock
|
||||
}
|
||||
|
||||
debugSettings = mockk<DebugSettings>().also {
|
||||
@@ -107,6 +112,7 @@ class OverviewViewModelTest : BaseTest() {
|
||||
|
||||
profilesRepo = mockk<DeviceProfilesRepo>().also {
|
||||
every { it.profiles } returns profilesFlow
|
||||
every { it.hadLegacyReactionData } returns hadLegacyReactionDataFlow
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class DeviceProfilesRepoReorderTest : BaseTest() {
|
||||
every { profiles } returns fakeProfiles.mock
|
||||
every { defaultProfileCreated } returns FakeDataStoreValue(true).mock
|
||||
every { reactionMigrationDone } returns FakeDataStoreValue(true).mock
|
||||
every { hadLegacyReactionData } returns FakeDataStoreValue(false).mock
|
||||
}
|
||||
|
||||
val repo = DeviceProfilesRepo(
|
||||
|
||||
Reference in New Issue
Block a user