feat(reactions): Add discovery hint and outline settings icon

This commit is contained in:
darken
2026-04-30 17:46:27 +02:00
committed by Matthias Urhahn
parent b952aff38d
commit abde0b7d6a
12 changed files with 158 additions and 7 deletions
@@ -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),
+2
View File
@@ -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(