From f421deca4983e7aab815371e5bb0d36475222fdd Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 24 Feb 2026 18:48:32 +0100 Subject: [PATCH 1/3] fix(profiles): Don't show unsaved changes dialog when nothing was edited In create mode, the init block pre-filled _currentState with a default name but left _initialState empty. The hasUnsavedChanges() check saw the non-blank name as a change, triggering the dialog on back press even without user edits. Set _initialState to match _currentState defaults in create mode and unify the comparison logic to always use current != initial. --- .../DeviceProfileCreationViewModel.kt | 30 ++++--------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationViewModel.kt b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationViewModel.kt index 60308ae0..f0c806eb 100644 --- a/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationViewModel.kt +++ b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationViewModel.kt @@ -65,7 +65,9 @@ class DeviceProfileCreationViewModel @Inject constructor( loadProfile(profileId) } else { val defaultName = context.getString(R.string.profiles_name_default) - _currentState.value = _currentState.value.copy(name = defaultName) + val defaultState = ProfileEditorState(name = defaultName) + _initialState.value = defaultState + _currentState.value = defaultState } } @@ -76,16 +78,7 @@ class DeviceProfileCreationViewModel @Inject constructor( private val hasUnsavedChangesFlow = combine( _currentState, _initialState ) { current, initial -> - if (isEditMode) { - current != initial - } else { - current.name.isNotBlank() || - current.selectedModel != null || - current.identityKeyHex != null || - current.encryptionKeyHex != null || - current.selectedDeviceAddress != null || - current.minimumSignalQuality != DeviceProfile.DEFAULT_MINIMUM_SIGNAL_QUALITY - } + current != initial } private val isFormValid = combine( @@ -166,20 +159,7 @@ class DeviceProfileCreationViewModel @Inject constructor( log(TAG) { "Minimum signal quality updated: $quality" } } - fun hasUnsavedChanges(): Boolean { - val current = _currentState.value - val initial = _initialState.value - return if (isEditMode) { - current != initial - } else { - current.name.isNotBlank() || - current.selectedModel != null || - current.identityKeyHex != null || - current.encryptionKeyHex != null || - current.selectedDeviceAddress != null || - current.minimumSignalQuality != DeviceProfile.DEFAULT_MINIMUM_SIGNAL_QUALITY - } - } + fun hasUnsavedChanges(): Boolean = _currentState.value != _initialState.value fun onBackPressed() { log(TAG) { "onBackPressed()" } From f6b1c69edcfad903f2cf40cfba8b5d854099c21e Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 24 Feb 2026 20:33:54 +0100 Subject: [PATCH 2/3] fix(profiles): Pass profileId from NavKey and reset state on exit Navigation 3 doesn't auto-populate SavedStateHandle from NavKey args like Navigation 2.x did. Pass profileId explicitly from the entry lambda through the ScreenHost to the ViewModel via initialize(). Reset initialized flag on every exit path so re-entering a profile reloads fresh data from the repository. --- .../DeviceProfileCreationNavigation.kt | 4 ++- .../creation/DeviceProfileCreationScreen.kt | 7 +++- .../DeviceProfileCreationViewModel.kt | 35 ++++++++++++------- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationNavigation.kt b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationNavigation.kt index fdac8ed2..abe23d1d 100644 --- a/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationNavigation.kt +++ b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationNavigation.kt @@ -13,7 +13,9 @@ import javax.inject.Inject class DeviceProfileCreationNavigation @Inject constructor() : NavigationEntry { override fun EntryProviderScope.setup() { - entry { DeviceProfileCreationScreenHost() } + entry { key -> + DeviceProfileCreationScreenHost(profileId = key.profileId) + } } @Module diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationScreen.kt b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationScreen.kt index 271cc4a3..b38315fe 100644 --- a/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationScreen.kt +++ b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationScreen.kt @@ -58,7 +58,12 @@ import eu.darken.capod.common.toHex import eu.darken.capod.pods.core.PodDevice @Composable -fun DeviceProfileCreationScreenHost(vm: DeviceProfileCreationViewModel = hiltViewModel()) { +fun DeviceProfileCreationScreenHost( + profileId: String? = null, + vm: DeviceProfileCreationViewModel = hiltViewModel(), +) { + LaunchedEffect(Unit) { vm.initialize(profileId) } + ErrorEventHandler(vm) NavigationEventHandler(vm) diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationViewModel.kt b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationViewModel.kt index f0c806eb..71de003a 100644 --- a/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationViewModel.kt +++ b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationViewModel.kt @@ -1,7 +1,6 @@ package eu.darken.capod.profiles.ui.creation import android.content.Context -import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import eu.darken.capod.R @@ -43,15 +42,15 @@ private data class ProfileEditorState( @HiltViewModel class DeviceProfileCreationViewModel @Inject constructor( @ApplicationContext private val context: Context, - handle: SavedStateHandle, dispatcherProvider: DispatcherProvider, private val deviceProfilesRepo: DeviceProfilesRepo, private val bluetoothManager: BluetoothManager2, private val webpageTool: WebpageTool, ) : ViewModel4(dispatcherProvider) { - private val profileId: ProfileId? = handle.get("profileId") - private val isEditMode: Boolean = profileId != null + private var profileId: ProfileId? = null + private var isEditMode: Boolean = false + private var initialized = false private val _currentState = MutableStateFlow(ProfileEditorState()) private val _initialState = MutableStateFlow(ProfileEditorState()) @@ -60,7 +59,13 @@ class DeviceProfileCreationViewModel @Inject constructor( val showUnsavedChangesEvent = SingleEventFlow() val showDeleteConfirmationEvent = SingleEventFlow() - init { + fun initialize(profileId: String?) { + if (initialized && this.profileId == profileId) return + initialized = true + + this.profileId = profileId + this.isEditMode = profileId != null + if (isEditMode && profileId != null) { loadProfile(profileId) } else { @@ -161,12 +166,17 @@ class DeviceProfileCreationViewModel @Inject constructor( fun hasUnsavedChanges(): Boolean = _currentState.value != _initialState.value + private fun exitScreen() { + initialized = false + navUp() + } + fun onBackPressed() { log(TAG) { "onBackPressed()" } if (hasUnsavedChanges()) { showUnsavedChangesEvent.tryEmit(Unit) } else { - navUp() + exitScreen() } } @@ -204,7 +214,7 @@ class DeviceProfileCreationViewModel @Inject constructor( deviceProfilesRepo.addProfile(profile) log(TAG) { "Profile created: $profile" } } - navUp() + exitScreen() } catch (e: Exception) { log(TAG) { "Failed to save profile: $e" } errorEvents.emitBlocking(e) @@ -218,12 +228,13 @@ class DeviceProfileCreationViewModel @Inject constructor( } fun deleteProfile() { - if (isEditMode && profileId != null) { + val id = profileId + if (isEditMode && id != null) { launch { try { - deviceProfilesRepo.removeProfile(profileId) - log(TAG) { "Profile deleted: $profileId" } - navUp() + deviceProfilesRepo.removeProfile(id) + log(TAG) { "Profile deleted: $id" } + exitScreen() } catch (e: Exception) { log(TAG) { "Failed to delete profile: $e" } errorEvents.emitBlocking(e) @@ -234,7 +245,7 @@ class DeviceProfileCreationViewModel @Inject constructor( fun discardChanges() { log(TAG) { "discardChanges()" } - navUp() + exitScreen() } fun openKeyGuide() { From 75d2bf0169cc82457c3aa2dd7fb541010e6a609c Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 24 Feb 2026 23:08:16 +0100 Subject: [PATCH 3/3] fix(popup): Prevent false-positive popups from BLE signal oscillation Use profile-based identity matching and cooldown keys so two BLE signals for the same AirPods share one cooldown timer. Revert connection monitor to show-once-per-connection behavior. --- .../reaction/core/popup/PopUpReaction.kt | 73 +++++++------ .../capod/reaction/ui/popup/PopUpContent.kt | 103 +++++++++--------- .../capod/reaction/ui/popup/PopUpWindow.kt | 1 + 3 files changed, 92 insertions(+), 85 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt b/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt index 88d4a024..cee6ec66 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt @@ -33,7 +33,7 @@ class PopUpReaction @Inject constructor( private val bluetoothManager: BluetoothManager2, ) { - private val caseCoolDowns = mutableMapOf() + private val caseCoolDowns = mutableMapOf() private fun monitorCase(): Flow = reactionSettings.showPopUpOnCaseOpen.flow .flatMapLatest { isEnabled -> @@ -56,10 +56,10 @@ class PopUpReaction @Inject constructor( } log(TAG, VERBOSE) { "previous-id=${previous?.identifier}, current-id=${current.identifier}" } - val isSameDeviceWithCaseNowOpen = - previous?.identifier == current.identifier && previous.caseLidState != current.caseLidState - val isNewDeviceWithJustOpenedCase = - previous?.identifier != current.identifier && previous?.caseLidState != current.caseLidState + val isSameDeviceOrProfile = previous?.identifier == current.identifier || + (previous?.meta?.profile?.id != null && previous.meta.profile?.id == current.meta.profile?.id) + val isSameDeviceWithCaseNowOpen = isSameDeviceOrProfile && previous?.caseLidState != current.caseLidState + val isNewDeviceWithJustOpenedCase = !isSameDeviceOrProfile && previous?.caseLidState != current.caseLidState if (!isSameDeviceWithCaseNowOpen && !isNewDeviceWithJustOpenedCase) { return@mapNotNull null @@ -69,43 +69,46 @@ class PopUpReaction @Inject constructor( throttleCasePopUps(current) } - private fun throttleCasePopUps(current: DualApplePods): Event? = when { - current.caseLidState == DualApplePods.LidState.OPEN -> { - log(TAG, INFO) { "Show popup" } + private fun throttleCasePopUps(current: DualApplePods): Event? { + val cooldownKey = current.meta.profile?.id ?: current.identifier.toString() + return when { + current.caseLidState == DualApplePods.LidState.OPEN -> { + log(TAG, INFO) { "Show popup" } - val now = Instant.now() - val lastShown = caseCoolDowns[current.identifier] ?: Instant.MIN - val sinceLastPop = Duration.between(lastShown, now) - log(TAG) { "Time since last case popup: $sinceLastPop" } + val now = Instant.now() + val lastShown = caseCoolDowns[cooldownKey] ?: Instant.MIN + val sinceLastPop = Duration.between(lastShown, now) + log(TAG) { "Time since last case popup: $sinceLastPop" } - if (sinceLastPop >= Duration.ofSeconds(10)) { - caseCoolDowns[current.identifier] = Instant.now() - Event.PopupShow(device = current) - } else { - log(TAG, INFO) { "Case popup is still on cooldown: $sinceLastPop" } - null - } - } - - current.caseLidState != DualApplePods.LidState.OPEN -> { - when (current.caseLidState) { - DualApplePods.LidState.CLOSED -> { - log(TAG, INFO) { "Lid was actively closed, resetting cooldown." } - caseCoolDowns.remove(current.identifier) - } - - else -> { - log(TAG, WARN) { "Lid was was not actively closed, refreshing cooldown." } - caseCoolDowns[current.identifier] = Instant.now() + if (sinceLastPop >= Duration.ofSeconds(10)) { + caseCoolDowns[cooldownKey] = Instant.now() + Event.PopupShow(device = current) + } else { + log(TAG, INFO) { "Case popup is still on cooldown: $sinceLastPop" } + null } } - log(TAG, INFO) { "Hide popup" } + current.caseLidState != DualApplePods.LidState.OPEN -> { + when (current.caseLidState) { + DualApplePods.LidState.CLOSED -> { + log(TAG, INFO) { "Lid was actively closed, resetting cooldown." } + caseCoolDowns.remove(cooldownKey) + } - Event.PopupHide() + else -> { + log(TAG, WARN) { "Lid was was not actively closed, refreshing cooldown." } + caseCoolDowns[cooldownKey] = Instant.now() + } + } + + log(TAG, INFO) { "Hide popup" } + + Event.PopupHide() + } + + else -> null } - - else -> null } private val connectionCoolDowns = mutableMapOf() diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt index fe3c25af..825a9f1b 100644 --- a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt +++ b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt @@ -2,6 +2,7 @@ package eu.darken.capod.reaction.ui.popup import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -49,65 +50,67 @@ fun PopUpContent( ) { val context = LocalContext.current - Card( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(24.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - .padding(top = 20.dp, bottom = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, + Box(modifier = modifier.padding(start = 12.dp, top = 12.dp, end = 12.dp, bottom = 16.dp)) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), ) { - // Header: label + signal quality - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(top = 20.dp, bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Text( - text = device.getLabel(context), - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - - val signalText = device.getSignalQuality(context) - if (signalText.isNotBlank()) { - Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = Icons.TwoTone.SignalCellularAlt, - contentDescription = null, - modifier = Modifier.size(12.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) + // Header: label + signal quality + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { Text( - text = signalText, - style = MaterialTheme.typography.labelSmall, + text = device.getLabel(context), + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), ) + + val signalText = device.getSignalQuality(context) + if (signalText.isNotBlank()) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.TwoTone.SignalCellularAlt, + contentDescription = null, + modifier = Modifier.size(12.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = signalText, + style = MaterialTheme.typography.labelSmall, + ) + } } - } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - // Device-specific content - when (device) { - is DualPodDevice -> DualPodContent(device) - is SinglePodDevice -> SinglePodContent(device) - } + // Device-specific content + when (device) { + is DualPodDevice -> DualPodContent(device) + is SinglePodDevice -> SinglePodContent(device) + } - Spacer(modifier = Modifier.height(20.dp)) + Spacer(modifier = Modifier.height(20.dp)) - // Close button - Button( - onClick = onClose, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(24.dp), - ) { - Text(text = stringResource(R.string.general_close_action)) + // Close button + Button( + onClick = onClose, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + ) { + Text(text = stringResource(R.string.general_close_action)) + } } } } diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpWindow.kt b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpWindow.kt index 9bf8a824..7453dbfc 100644 --- a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpWindow.kt +++ b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpWindow.kt @@ -42,6 +42,7 @@ class PopUpWindow @Inject constructor( val dm = appContext.resources.displayMetrics val margin = (24 * dm.density).toInt() width = minOf(dm.widthPixels - margin * 2, (400 * dm.density).toInt()) + y = (8 * dm.density).toInt() } private var composeView: ComposeView? = null