diff --git a/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt b/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt index e5227c27..06c4d0c0 100644 --- a/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt +++ b/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt @@ -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, 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 d306fe4a..659847f1 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 @@ -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 = {}, 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 b3822e91..f87e8e7f 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 @@ -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 = 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) diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt index 2a2eb5c3..36dedbac 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt @@ -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), diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/ReactionsMovedHintCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/ReactionsMovedHintCard.kt new file mode 100644 index 00000000..797bac4f --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/ReactionsMovedHintCard.kt @@ -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() +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt index 3c2e61d1..d88c021f 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt @@ -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), diff --git a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt index 96e980ea..55b4cf17 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt @@ -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> = settings.profiles.flow.map { it.profiles } + val hadLegacyReactionData: Flow = settings.hadLegacyReactionData.flow + suspend fun addProfile(profile: DeviceProfile, addFirst: Boolean = false) = mutex.withLock { val currentContainer = settings.profiles.valueBlocking checkAddressUniqueness(profile, currentContainer.profiles) diff --git a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesSettings.kt b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesSettings.kt index 6b6c0146..e3700631 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesSettings.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesSettings.kt @@ -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) } diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerScreen.kt b/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerScreen.kt index 2d3a4d95..0168c924 100644 --- a/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerScreen.kt +++ b/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerScreen.kt @@ -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), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d7977639..04bbc366 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -509,6 +509,8 @@ Double press to end call Open device settings This profile has no paired Bluetooth device. + Reactions are per device + Auto-play, auto-pause and pop-ups are now configured per device. Tap the settings icon on a card while your headphones are connected. General 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 a5940862..1fb794c5 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 @@ -58,9 +58,11 @@ class OverviewViewModelTest : BaseTest() { private lateinit var connectedDevicesFlow: MutableStateFlow> private lateinit var isBluetoothEnabledFlow: MutableStateFlow private lateinit var profilesFlow: MutableStateFlow> + private lateinit var hadLegacyReactionDataFlow: MutableStateFlow private lateinit var upgradeInfoFlow: MutableStateFlow private lateinit var fakeMonitorMode: FakeDataStoreValue private lateinit var fakeDebugMode: FakeDataStoreValue + private lateinit var fakeReactionsHintDismissed: FakeDataStoreValue @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(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().also { every { it.monitorMode } returns fakeMonitorMode.mock + every { it.reactionsHintDismissed } returns fakeReactionsHintDismissed.mock } debugSettings = mockk().also { @@ -107,6 +112,7 @@ class OverviewViewModelTest : BaseTest() { profilesRepo = mockk().also { every { it.profiles } returns profilesFlow + every { it.hadLegacyReactionData } returns hadLegacyReactionDataFlow } } diff --git a/app/src/test/java/eu/darken/capod/profiles/core/DeviceProfilesRepoReorderTest.kt b/app/src/test/java/eu/darken/capod/profiles/core/DeviceProfilesRepoReorderTest.kt index 4751b38c..5d79856c 100644 --- a/app/src/test/java/eu/darken/capod/profiles/core/DeviceProfilesRepoReorderTest.kt +++ b/app/src/test/java/eu/darken/capod/profiles/core/DeviceProfilesRepoReorderTest.kt @@ -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(