Merge pull request #3870 from vitorpamplona/claude/thread-reply-ime-padding-j7c9rk

fix(ime): settle the keyboard in Nav so every screen stops stranding imePadding
This commit is contained in:
Vitor Pamplona
2026-08-05 20:45:43 -04:00
committed by GitHub
17 changed files with 247 additions and 135 deletions
@@ -20,25 +20,13 @@
*/
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,
@@ -68,95 +56,3 @@ fun keyboardAsState(): State<KeyboardState> {
}
}
}
/** How long to wait for the IME inset to reach zero before running the action anyway. */
private const val IME_SETTLE_TIMEOUT_MS = 700L
/**
* Returns a runner that defers an action until the soft keyboard is fully off 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).
*
* 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 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) }
}
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.navigation.navs
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.withTimeoutOrNull
/** How long to wait for the IME inset to reach zero before navigating anyway. */
const val IME_SETTLE_TIMEOUT_MS = 700L
/**
* Waits for the soft keyboard to be fully off screen. Installed on [Nav] so that every navigation
* in the app serializes the IME and window animations instead of overlapping them.
*
* Navigating while the keyboard is 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. `WindowInsets.ime` is a single app-wide holder, so it
* stays "animating" and every `Modifier.imePadding()` in the app — not just the screen being left —
* freezes at the keyboard height until some later inset pass happens to rebalance it.
*
* This is not a composer-screen problem, which is why it lives here rather than in the screens.
* Any destination that can hold focus in a text field can strand the padding on the way out, by any
* exit: a back gesture, a top-bar button, a bottom-nav tab, or tapping a result. Search is the
* clearest case — it focuses its field on arrival, so the keyboard is already up before the user
* has done anything, and every way out of it is a navigation.
*/
fun interface ImeSettler {
suspend fun settle()
companion object {
/** For [EmptyNav] and previews, where there is no window to read insets from. */
val None = ImeSettler { }
}
}
/**
* Reads the same animated `WindowInsets.ime` that drives `Modifier.imePadding()`, so the settler
* and the padding can never disagree about whether the keyboard is gone.
*
* Focus is cleared before hiding so nothing re-requests the IME as it retracts. The wait is bounded
* by [IME_SETTLE_TIMEOUT_MS] — if the inset never reports zero, which is precisely the failure this
* guards against, navigation still proceeds rather than stranding the user on the screen.
*/
@Composable
fun rememberImeSettler(): ImeSettler {
val density = LocalDensity.current
val imeInsets = WindowInsets.ime
val keyboard = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
return remember(density, imeInsets, keyboard, focusManager) {
ImeSettler {
if (imeInsets.getBottom(density) > 0) {
focusManager.clearFocus(true)
keyboard?.hide()
withTimeoutOrNull(IME_SETTLE_TIMEOUT_MS) {
snapshotFlow { imeInsets.getBottom(density) }.first { it <= 0 }
}
}
}
}
}
@@ -44,6 +44,13 @@ import kotlin.reflect.KClass
class Nav(
val controller: NavHostController,
override val navigationScope: CoroutineScope,
/**
* Awaited before every transition below. Leaving a screen while the soft keyboard is still
* animating strands `imePadding()` app-wide; see [ImeSettler]. Every in-app navigation goes
* through this class, so this is the one place that has to get it right — no screen, top bar
* or back handler needs to think about the keyboard on its way out.
*/
private val ime: ImeSettler = ImeSettler.None,
) : INav {
override val drawerState = DrawerState(DrawerValue.Closed)
@@ -63,6 +70,7 @@ class Nav(
override fun nav(route: Route) {
navigationScope.launch {
ime.settle()
if (getRouteWithArguments(route::class, controller) != route) {
controller.navigate(route)
}
@@ -71,6 +79,7 @@ class Nav(
override fun nav(computeRoute: suspend () -> Route?) {
navigationScope.launch {
ime.settle()
val route = computeRoute()
if (route != null && getRouteWithArguments(route::class, controller) != route) {
controller.navigate(route)
@@ -80,6 +89,7 @@ class Nav(
override fun newStack(route: Route) {
navigationScope.launch {
ime.settle()
controller.navigate(route) {
popUpTo(route) {
inclusive = true
@@ -91,6 +101,7 @@ class Nav(
override fun navBottomBar(route: Route) {
navigationScope.launch {
ime.settle()
controller.navigate(route) {
// Clear sibling bottom-nav entries but keep Home (the start
// destination) below, so back-swipe from any tab returns to
@@ -149,6 +160,7 @@ class Nav(
override fun popBack() {
navigationScope.launch {
ime.settle()
controller.navigateUp()
}
}
@@ -159,6 +171,7 @@ class Nav(
klass: KClass<T>,
) {
navigationScope.launch {
ime.settle()
controller.navigate(route) {
popUpTo(klass) { inclusive = true }
}
@@ -29,9 +29,10 @@ import androidx.navigation.compose.rememberNavController
fun rememberNav(): Nav {
val navController = rememberNavController()
val scope = rememberCoroutineScope()
val ime = rememberImeSettler()
return remember(navController, scope) {
Nav(navController, scope)
return remember(navController, scope, ime) {
Nav(navController, scope, ime)
}
}
@@ -31,7 +31,6 @@ 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
@@ -46,10 +45,6 @@ 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) {
@@ -65,7 +60,7 @@ fun ActionTopBar(
navigationIcon = {
CloseButton(
modifier = HalfHorzPadding,
onPress = { afterKeyboardCloses(onCancel) },
onPress = onCancel,
)
},
actions = {
@@ -75,7 +70,7 @@ fun ActionTopBar(
Button(
modifier = HalfHorzPadding,
enabled = isActive(),
onClick = { afterKeyboardCloses(onPost) },
onClick = onPost,
) {
Text(text = stringRes(postRes))
}
@@ -20,6 +20,7 @@
*/
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
@@ -66,7 +67,6 @@ 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)
KeyboardAwareBackHandler {
BackHandler {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
@@ -20,6 +20,7 @@
*/
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
@@ -51,7 +52,6 @@ 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() }
}
KeyboardAwareBackHandler {
BackHandler {
nav.popBack()
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
@@ -86,7 +87,6 @@ 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.components.ZoomableContentView
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.routes.routeToMessage
@@ -169,7 +169,7 @@ fun NewGroupDMScreen(
WatchAndLoadMyEmojiList(accountViewModel)
KeyboardAwareBackHandler {
BackHandler {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -59,7 +60,6 @@ import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
@@ -110,7 +110,7 @@ fun PrivateMessageEditFieldRow(
onSendNewMessage: () -> Unit,
nav: INav,
) {
KeyboardAwareBackHandler {
BackHandler {
if (channelScreenModel.message.text.isNotBlank()) {
accountViewModel.launchSigner {
channelScreenModel.sendDraftSync()
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -53,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
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.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -78,7 +78,7 @@ fun EditFieldRow(
onSendNewMessage: suspend () -> Unit,
nav: INav,
) {
KeyboardAwareBackHandler {
BackHandler {
accountViewModel.launchSigner {
channelScreenModel.sendDraftSync()
channelScreenModel.cancel()
@@ -20,6 +20,7 @@
*/
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
@@ -95,7 +96,6 @@ 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)
KeyboardAwareBackHandler {
BackHandler {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
@@ -20,6 +20,7 @@
*/
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
@@ -52,7 +53,6 @@ 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)
KeyboardAwareBackHandler {
BackHandler {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
@@ -23,6 +23,7 @@ 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
@@ -92,7 +93,6 @@ 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)
KeyboardAwareBackHandler {
BackHandler {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
@@ -20,6 +20,7 @@
*/
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
@@ -47,7 +48,6 @@ 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,
) {
KeyboardAwareBackHandler {
BackHandler {
goalViewModel.cancel()
nav.popBack()
}
@@ -20,6 +20,7 @@
*/
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
@@ -63,7 +64,6 @@ 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)
KeyboardAwareBackHandler {
BackHandler {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
@@ -20,6 +20,7 @@
*/
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
@@ -60,7 +61,6 @@ 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)
KeyboardAwareBackHandler {
BackHandler {
postViewModel.cancel()
nav.popBack()
}
@@ -0,0 +1,118 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.navigation
import androidx.navigation.NavHostController
import androidx.navigation.NavOptionsBuilder
import com.vitorpamplona.amethyst.ui.navigation.navs.ImeSettler
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Leaving a screen while the soft keyboard is still animating strands `imePadding()` at keyboard
* height for the whole app, because `WindowInsets.ime` is a single shared holder. The fix is that
* [Nav] waits for the IME to be gone before it moves, so these assert the ordering rather than any
* visual result: every transition must settle the keyboard *first*.
*
* Without the settle calls in [Nav] each of these records only "navigate" and fails.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class NavImeSettleTest {
private fun controllerRecording(order: MutableList<String>): NavHostController =
mockk<NavHostController>(relaxed = true) {
every { navigate(any<Route>(), any<NavOptionsBuilder.() -> Unit>()) } answers
{ order.add("navigate") }
every { navigate(any<Route>()) } answers { order.add("navigate") }
every { navigateUp() } answers {
order.add("navigate")
true
}
}
@Test
fun popBackSettlesTheKeyboardBeforeNavigating() =
runTest {
val order = mutableListOf<String>()
val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") })
nav.popBack()
advanceUntilIdle()
assertEquals(listOf("settle", "navigate"), order)
}
@Test
fun bottomBarSettlesTheKeyboardBeforeNavigating() =
runTest {
// The search tab focuses its field on arrival, so the keyboard is already up when the
// user taps another tab — the exit that has no BackHandler and no top bar to guard it.
val order = mutableListOf<String>()
val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") })
nav.navBottomBar(Route.Home)
advanceUntilIdle()
assertEquals(listOf("settle", "navigate"), order)
}
@Test
fun newStackSettlesTheKeyboardBeforeNavigating() =
runTest {
val order = mutableListOf<String>()
val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") })
nav.newStack(Route.Home)
advanceUntilIdle()
assertEquals(listOf("settle", "navigate"), order)
}
@Test
fun aSlowKeyboardStillHoldsTheNavigationBack() =
runTest {
// The real settler suspends for the length of the IME close animation. Navigation must
// wait for it, not fire alongside it — that overlap is the bug.
val order = mutableListOf<String>()
val nav =
Nav(
controllerRecording(order),
this,
ImeSettler {
delay(250)
order.add("settle")
},
)
nav.popBack()
assertEquals(emptyList<String>(), order)
advanceUntilIdle()
assertEquals(listOf("settle", "navigate"), order)
}
}