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 68e9ba6c66..1457927832 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 @@ -20,52 +20,67 @@ */ package com.vitorpamplona.amethyst.ui.navigation.bottombars -import android.graphics.Rect -import android.view.View -import android.view.ViewTreeObserver +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalDensity enum class KeyboardState { Opened, Closed, } +/** + * Whether the soft keyboard is currently on screen, derived from [WindowInsets.ime]. + * + * This intentionally reads the same animated IME inset that drives `Modifier.imePadding()` + * everywhere else in the app, so the two can never disagree. The previous implementation + * measured `View.getWindowVisibleDisplayFrame` from a `ViewTreeObserver` global-layout + * listener — a pre-edge-to-edge heuristic. Under `enableEdgeToEdge()` + * (`decorFitsSystemWindows = false`) the window content no longer resizes for the IME, so + * that listener fired when the keyboard appeared but frequently never fired again when it + * closed, latching the state at [Opened] even though the keyboard was gone (leaving the + * bottom navigation bar hidden and a stranded gap at the bottom). The IME inset always + * animates back to zero on the persistent root view, so this reading can't get stuck. + */ @Composable fun keyboardAsState(): State { - val view = LocalView.current - - val keyboardState = remember(view) { mutableStateOf(isKeyboardOpen(view)) } - - DisposableEffect(view) { - val onGlobalListener = - ViewTreeObserver.OnGlobalLayoutListener { - val newKeyboardValue = isKeyboardOpen(view) - - if (newKeyboardValue != keyboardState.value) { - keyboardState.value = newKeyboardValue - } - } - view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener) - onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) } - } - - return keyboardState -} - -fun isKeyboardOpen(view: View): KeyboardState { - val rect = Rect() - view.getWindowVisibleDisplayFrame(rect) - val screenHeight = view.rootView.height - val keypadHeight = screenHeight - rect.bottom - - return if (keypadHeight > screenHeight * 0.15) { - KeyboardState.Opened - } else { - KeyboardState.Closed + val density = LocalDensity.current + val imeInsets = WindowInsets.ime + return remember(density, imeInsets) { + derivedStateOf { + if (imeInsets.getBottom(density) > 0) KeyboardState.Opened else KeyboardState.Closed + } } } + +/** + * A [BackHandler] that steps aside while the soft keyboard is on 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). + * + * 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. + */ +@Composable +fun KeyboardAwareBackHandler( + enabled: Boolean = true, + onBack: () -> Unit, +) { + val keyboardState by keyboardAsState() + BackHandler(enabled = enabled && keyboardState == KeyboardState.Closed, onBack = onBack) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt index 6f15e93574..8263fa2a2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -174,7 +175,9 @@ fun MinichatScreen( ) }, ) { padding -> - Column(Modifier.fillMaxHeight().padding(padding)) { + // imePadding so the reply composer rides above the soft keyboard — the bare Material3 + // Scaffold's content insets cover the system bars but not the IME. + Column(Modifier.fillMaxHeight().imePadding().padding(padding)) { // Every reply here is rooted at [rootNote], which is already pinned at the top — so // suppress the redundant reply-to-root preview each reply would otherwise render. CompositionLocalProvider(LocalSuppressReplyToNoteId provides rootId) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index d87f54987d..ce87e19328 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -21,7 +21,6 @@ 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 @@ -87,6 +86,7 @@ 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) - BackHandler { + KeyboardAwareBackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index fc64efe0ab..390d718d9b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -20,7 +20,6 @@ */ 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 @@ -60,6 +59,7 @@ 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, ) { - BackHandler { + KeyboardAwareBackHandler { if (channelScreenModel.message.text.isNotBlank()) { accountViewModel.launchSigner { channelScreenModel.sendDraftSync() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 160acdf42a..761bd36f17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -26,6 +26,7 @@ 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.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.ExperimentalMaterial3Api @@ -185,7 +186,10 @@ fun ConcordChannelScreen( ) }, ) { padding -> - Column(Modifier.fillMaxHeight().padding(padding)) { + // imePadding so the composer rides above the soft keyboard. The bare Material3 Scaffold's + // content insets cover the system bars but not the IME, so without this the message field + // sat behind the keyboard while typing. + Column(Modifier.fillMaxHeight().imePadding().padding(padding)) { Column(Modifier.fillMaxHeight().weight(1f, true)) { RefreshingChatroomFeedView( feedContentState = feedViewModel.feedState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index 8176dc4d5b..f14cd1d59e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -20,7 +20,6 @@ */ 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 @@ -54,6 +53,7 @@ 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, ) { - BackHandler { + KeyboardAwareBackHandler { accountViewModel.launchSigner { channelScreenModel.sendDraftSync() channelScreenModel.cancel()