From 9ac033effcf5964457984561f53cf34be22ce7fd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 15:41:29 +0000 Subject: [PATCH 1/2] fix(ime): use the keyboard-aware back handler in the post composers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread reply composer (and every other full-screen draft-saving editor) still consumed back with a raw `BackHandler`, so `KeyboardAwareBackHandler` — added for exactly this case — only protected the three chat composers. Popping the screen while the keyboard is still up races the predictive-back window animation against the IME close animation. When the window animation wins, the IME `WindowInsetsAnimationCompat` is cancelled before its terminal zero frame reaches Compose, the shared `WindowInsets.ime` holder stays "animating", and every `Modifier.imePadding()` freezes at keyboard height — the keyboard vanishes but its padding stays behind, even after leaving the screen. Switching these composers to `KeyboardAwareBackHandler` lets the first back (or back-swipe) fall through to the system, which dismisses the keyboard with its own animation that completes cleanly; the next back saves the draft and pops as before. The top bar's cancel arrow remains an always-available exit. Covers `ShortNotePostScreen` (which also backs `PollPostScreen`), `GenericCommentPostScreen`, `LongFormPostScreen`, `NewProductScreen`, `NewPublicMessageScreen`, `NewGoalScreen`, `NewWorkoutScreen` and `AwardBadgeScreen`. `VoiceReplyScreen` keeps the plain handler — it has no text input or `imePadding()`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LfUMGWYu2uTSyh17JJonfN --- .../ui/note/nip22Comments/GenericCommentPostScreen.kt | 4 ++-- .../ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt | 4 ++-- .../loggedIn/discover/nip23LongForm/LongFormPostScreen.kt | 4 ++-- .../loggedIn/discover/nip99Classifieds/NewProductScreen.kt | 4 ++-- .../amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt | 4 ++-- .../ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt | 4 ++-- .../notifications/publicMessages/NewPublicMessageScreen.kt | 4 ++-- .../amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt | 4 ++-- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 6baef9f1a2..aec22cb911 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.nip22Comments -import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box @@ -67,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton +import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.BaseUserPicture @@ -177,7 +177,7 @@ fun GenericCommentPostScreen( StrippingFailureDialog(postViewModel.strippingFailureConfirmation) - BackHandler { + KeyboardAwareBackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt index 2172e18945..bdc768cc3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award -import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -52,6 +51,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.ui.components.Nip05OrPubkeyLine import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.note.UserPicture @@ -88,7 +88,7 @@ fun AwardBadgeScreen( onDispose { userSuggestions.reset() } } - BackHandler { + KeyboardAwareBackHandler { nav.popBack() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 2465929d40..12cba33c60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm -import androidx.activity.compose.BackHandler import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -96,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown +import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer @@ -149,7 +149,7 @@ fun LongFormPostScreen( StrippingFailureDialog(postViewModel.strippingFailureConfirmation) - BackHandler { + KeyboardAwareBackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index 2e95142599..8cd67d7b46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds -import androidx.activity.compose.BackHandler import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -53,6 +52,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton +import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -141,7 +141,7 @@ fun NewProductScreen( StrippingFailureDialog(postViewModel.strippingFailureConfirmation) - BackHandler { + KeyboardAwareBackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index db7e95f3be..a4bc2c6bfb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home import android.annotation.SuppressLint import android.content.Intent import android.net.Uri -import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -93,6 +92,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.getActivity +import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -235,7 +235,7 @@ internal fun NewPostScreenInner( StrippingFailureDialog(postViewModel.strippingFailureConfirmation) - BackHandler { + KeyboardAwareBackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt index 5a6348cb6d..f7aabcd6f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.nip75Goals -import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -48,6 +47,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -81,7 +81,7 @@ fun NewGoalScreen( accountViewModel: AccountViewModel, nav: INav, ) { - BackHandler { + KeyboardAwareBackHandler { goalViewModel.cancel() nav.popBack() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index 56e1382b6a..f9e277665c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages -import androidx.activity.compose.BackHandler import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Column @@ -64,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -134,7 +134,7 @@ fun NewPublicMessageScreen( StrippingFailureDialog(postViewModel.strippingFailureConfirmation) - BackHandler { + KeyboardAwareBackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt index a3de2013d0..5bc98f4cdb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts -import androidx.activity.compose.BackHandler import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -61,6 +60,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -80,7 +80,7 @@ fun NewWorkoutScreen( postViewModel.init(accountViewModel) postViewModel.prefill(prefill) - BackHandler { + KeyboardAwareBackHandler { postViewModel.cancel() nav.popBack() } From 8b4220019afbde5938a56010846b8bdbf3d65b68 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 17:40:03 +0000 Subject: [PATCH 2/2] fix(ime): close the two remaining stuck-padding paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two exits still popped a composer while the IME was mid-animation — the race that strands imePadding() at keyboard height app-wide. 1. Top-bar X and Post. KeyboardAwareBackHandler only guards the back gesture; ActionTopBar wired both buttons straight to nav.popBack(), with nothing dismissing the keyboard first. Tapping either while typing reproduced the original bug exactly. The earlier fix leaned on the back arrow as the "always-available exit" without noticing it was also a race source. 2. A ~250ms hole in the back gate. It read the animated WindowInsets.ime, which stays above zero for the whole close animation — a window in which the IME had already stopped consuming back but the handler was still disabled, so a second back fell through to the NavController and popped without ever running onBack. That silently dropped the draft the handler exists to save: nothing else saves it, onCleared() only closes the writing assistant and there is no autosave. Both are the same underlying requirement — serialize the IME and window animations instead of overlapping them — so both now route through one helper, rememberAfterKeyboardCloses(): keyboard down, the action runs inline and nothing changes; keyboard up, clear focus, hide, wait for the inset to actually reach zero, then act. The wait is bounded so a stale inset (the very failure being guarded) can never trap the user on screen, and re-entrant calls are dropped since the deferral widens the window for a double-tap on Post to fire twice. The back gate now reads WindowInsets.imeAnimationTarget, which flips to zero the moment the hide begins, so back keeps reaching onBack throughout the animation. Re-enabling that early means onBack can fire mid-animation, which is exactly what the helper absorbs. Not covered: this is verified by compile and the unit suite only. The race reproduces on release builds on a device, which this environment cannot run. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LfUMGWYu2uTSyh17JJonfN --- .../ui/navigation/bottombars/KeyboardState.kt | 106 +++++++++++++++--- .../ui/navigation/topbars/ActionTopBar.kt | 9 +- 2 files changed, 98 insertions(+), 17 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt index 1457927832..f576a3556c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt @@ -21,14 +21,24 @@ package com.vitorpamplona.amethyst.ui.navigation.bottombars import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imeAnimationTarget import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.atomic.AtomicBoolean enum class KeyboardState { Opened, @@ -59,28 +69,94 @@ fun keyboardAsState(): State { } } +/** How long to wait for the IME inset to reach zero before running the action anyway. */ +private const val IME_SETTLE_TIMEOUT_MS = 700L + /** - * A [BackHandler] that steps aside while the soft keyboard is on screen. + * Returns a runner that defers an action until the soft keyboard is fully off screen. * - * Chat composers (and draft-saving editors) intercept back to flush a draft and pop the screen. - * When that pop happens while the keyboard is still up, it races the predictive-back window - * animation against the IME's close animation. On release builds — fast enough that the window - * animation wins — the IME [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat] - * is cancelled before its terminal (zero) frame reaches Compose, so the shared `WindowInsets.ime` - * holder stays "animating" and every `Modifier.imePadding()` in the app freezes at the keyboard - * height until a later inset pass rebalances it (the "stuck IME padding" that survives leaving the - * screen). + * Popping a screen while the keyboard is still up races the window animation against the IME's + * close animation. On release builds — fast enough that the window animation wins — the IME + * [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat] is cancelled before + * its terminal (zero) frame reaches Compose, so the shared `WindowInsets.ime` holder stays + * "animating" and every `Modifier.imePadding()` in the app freezes at the keyboard height until a + * later inset pass rebalances it (the "stuck IME padding" that survives leaving the screen). * - * Gating on [keyboardAsState] fixes it: while the keyboard is visible we do NOT consume back, so the - * system dismisses the keyboard first with its own animation (which completes cleanly). The next - * back — keyboard already down — runs [onBack] as before. The top bar's back arrow stays an - * always-available exit, so this can never trap the user even if the inset reading were itself stale. + * Any exit that leaves a keyboard-bearing screen has to serialize the two animations rather than + * overlap them. With the keyboard already down the action runs inline — same frame, no behavior + * change. With it up we dismiss the keyboard, wait for the inset to actually reach zero, and only + * then act, so the IME animation always completes before the window animation begins. + * + * Re-entrant calls while an action is pending are dropped: the deferral widens the window in which + * a second tap on a Post/Save button would fire the action twice. + * + * [IME_SETTLE_TIMEOUT_MS] bounds the wait — if the inset never reports zero (precisely the failure + * this guards against) the action still runs, so a stale reading can never trap the user on screen. */ @Composable +fun rememberAfterKeyboardCloses(): (() -> Unit) -> Unit { + val density = LocalDensity.current + val imeInsets = WindowInsets.ime + val keyboard = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val scope = rememberCoroutineScope() + val pending = remember { AtomicBoolean(false) } + + return remember(density, imeInsets, keyboard, focusManager, scope, pending) { + { action: () -> Unit -> + if (imeInsets.getBottom(density) <= 0) { + action() + } else if (pending.compareAndSet(false, true)) { + // Clear focus first so nothing re-requests the IME as it retracts. + focusManager.clearFocus(true) + keyboard?.hide() + scope.launch { + try { + withTimeoutOrNull(IME_SETTLE_TIMEOUT_MS) { + snapshotFlow { imeInsets.getBottom(density) }.first { it <= 0 } + } + action() + } finally { + pending.set(false) + } + } + } + } + } +} + +/** + * A [BackHandler] that lets the system dismiss the soft keyboard before it consumes back. + * + * Chat composers (and draft-saving editors) intercept back to flush a draft and pop the screen, + * which is the pop-during-IME-animation race described on [rememberAfterKeyboardCloses]. While the + * keyboard is up we do NOT consume back, so the system dismisses it first with its own animation + * (which completes cleanly, and on recent Android follows the back gesture). The next back runs + * [onBack] as before. + * + * The gate reads [WindowInsets.imeAnimationTarget] — where the IME is *heading* — not the animated + * [WindowInsets.ime]. Gating on the animated value left a hole: it stays above zero for the whole + * close animation, ~250ms in which the IME has already stopped consuming back but this handler was + * still disabled, so a second back fell through to the NavController and popped the screen without + * ever running [onBack] — silently dropping the draft it exists to save. The target flips to zero + * the moment the hide begins, so back keeps reaching [onBack] throughout. + * + * Re-enabling that early means [onBack] can now fire mid-animation, so it is routed through + * [rememberAfterKeyboardCloses] to wait for the inset to settle before popping. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable fun KeyboardAwareBackHandler( enabled: Boolean = true, onBack: () -> Unit, ) { - val keyboardState by keyboardAsState() - BackHandler(enabled = enabled && keyboardState == KeyboardState.Closed, onBack = onBack) + val density = LocalDensity.current + val imeTarget = WindowInsets.imeAnimationTarget + val afterKeyboardCloses = rememberAfterKeyboardCloses() + + val keyboardIsStaying by remember(density, imeTarget) { + derivedStateOf { imeTarget.getBottom(density) > 0 } + } + + BackHandler(enabled = enabled && !keyboardIsStaying) { afterKeyboardCloses(onBack) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt index 84b19c084a..4bd0aba287 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.bottombars.rememberAfterKeyboardCloses import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding @@ -45,6 +46,10 @@ fun ActionTopBar( onPost: () -> Unit, additionalActions: @Composable (() -> Unit)? = null, ) { + // Both exits pop the screen, and on a composer the keyboard is up while typing — the same + // pop-during-IME-animation race the back gesture avoids, just reached by a tap instead. + val afterKeyboardCloses = rememberAfterKeyboardCloses() + ShorterTopAppBar( title = { if (titleRes != null) { @@ -60,7 +65,7 @@ fun ActionTopBar( navigationIcon = { CloseButton( modifier = HalfHorzPadding, - onPress = onCancel, + onPress = { afterKeyboardCloses(onCancel) }, ) }, actions = { @@ -70,7 +75,7 @@ fun ActionTopBar( Button( modifier = HalfHorzPadding, enabled = isActive(), - onClick = onPost, + onClick = { afterKeyboardCloses(onPost) }, ) { Text(text = stringRes(postRes)) }