From 33b6a261d7efbcd299c0c025b67a6644f77ca279 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 25 Aug 2026 08:35:59 -0400 Subject: [PATCH 1/3] fix(tipcard): take the keyboard down before handling a deeplink Tapping a tip link while resuming from a chat presented the tip card over a live keyboard. The chat's message input still holds focus across the background, so the window restores the IME as the link is routed, and the card resolves over a network round-trip with the keyboard up the whole time. Dismiss the keyboard in App once a deeplink dispatches to a real action, before anything is routed or presented. This covers every branch there, not just PresentTipCard: they all reroute what's on screen, including /tip/{self}, which AppRouter diverts to a Menu-sheet Navigate. The one path that doesn't navigate (an email code delivered in place via EmailCodeChannel) auto-verifies on arrival, so taking the keyboard down there is right too. BillOverlay catches an IME the platform restores after we've routed. It fires only on a false -> true transition: the overlay is hosted per navigation entry, so a screen opened while a bill is up composes a fresh copy with the bill already present, and firing there would stomp TipCardDecorator's LaunchChat, which opens the chat with the keyboard up on purpose. KeyboardController.dismiss() clears editor focus before hiding. hide() alone leaves the field focused, so the platform brings the keyboard straight back. --- .../com/flipcash/app/internal/ui/App.kt | 12 ++++++++++ .../com/flipcash/app/bills/BillOverlay.kt | 19 +++++++++++++++ .../kotlin/com/getcode/ui/utils/Keyboard.kt | 24 ++++++++++++++++--- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt index ca5cbbe1af..a09b8e3189 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt @@ -79,6 +79,7 @@ import com.getcode.ui.biometrics.rememberBiometricsState import com.getcode.ui.components.OnLifecycleEvent import com.getcode.ui.components.bars.rememberBarManager import com.getcode.ui.core.RestrictionType +import com.getcode.ui.utils.rememberKeyboardController import dev.bmcreations.tipkit.TipScaffold import dev.bmcreations.tipkit.engines.TipsEngine import dev.theolm.rinku.DeepLink @@ -261,6 +262,7 @@ internal fun App( val emailCodeChannel = LocalEmailCodeChannel.current val currentRoute = codeNavigator.currentRouteKey + val keyboard = rememberKeyboardController() LaunchedEffect(deepLink, currentRoute) { val link = deepLink ?: return@LaunchedEffect @@ -273,6 +275,16 @@ internal fun App( val action = router.dispatch(link) deeplinkHandled = action != DeeplinkAction.None + + // A link can land while a text field elsewhere in the app still + // holds focus — the common case is resuming from the background + // straight out of a chat, where the window restores the IME for + // the still-focused input as we route. Take the keyboard down + // (and clear focus, so it isn't restored again) before anything + // is presented, so a tip card doesn't come up over a keyboard, + // and none appears while the card is still resolving. + if (action != DeeplinkAction.None) keyboard.dismiss() + when (action) { is DeeplinkAction.Navigate -> { // If a verification code targets a screen already open, diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillOverlay.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillOverlay.kt index 5f00cce55a..ffbf9bcbad 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillOverlay.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillOverlay.kt @@ -41,6 +41,7 @@ import com.getcode.navigation.scrim.LocalScrimController import com.getcode.theme.CodeTheme import com.getcode.ui.utils.AnimationUtils import com.getcode.ui.utils.ModalAnimationSpeed +import com.getcode.ui.utils.rememberKeyboardController import kotlinx.coroutines.delay import kotlin.time.Duration.Companion.milliseconds @@ -64,6 +65,24 @@ fun BillOverlay(modifier: Modifier = Modifier) { // Tip affordability (min-tip balance check), surfaced through the shared selection state. val tipSelection by LocalTipCoordinator.current.selection.collectAsStateWithLifecycle() + // A bill is a focused modal — it must never share the screen with a keyboard. What makes that + // reachable is a tip-card deeplink handled while a chat input still holds focus: App takes the + // keyboard down before routing, and this catches an IME the platform restores afterwards, on + // the way back from the background. Clearing focus, not just hiding, so there's nothing left + // for the platform to restore it onto. + // + // Only on a false -> true transition. This overlay is hosted per navigation entry, so a screen + // opened *while* a bill is up composes a fresh copy with the bill already present; firing there + // would stomp the post-tip hand-off, which opens the chat with the keyboard up on purpose (see + // TipCardDecorator's LaunchChat). Seeding from the current value makes that first pass a no-op. + val keyboard = rememberKeyboardController() + val billPresented = billState.bill != null + var wasBillPresented by remember { mutableStateOf(billPresented) } + LaunchedEffect(billPresented) { + if (billPresented && !wasBillPresented) keyboard.dismiss() + wasBillPresented = billPresented + } + Box(modifier = Modifier.fillMaxSize().then(modifier)) { val updatedState by rememberUpdatedState(state) val updatedBillState by rememberUpdatedState(billState) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/utils/Keyboard.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/Keyboard.kt index 9b35f0a0a4..50c51175c9 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/utils/Keyboard.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/utils/Keyboard.kt @@ -13,6 +13,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.SoftwareKeyboardController @@ -44,6 +46,7 @@ fun keyboardAsState(): State { class KeyboardController( private val view: View, private val softwareController: SoftwareKeyboardController?, + private val focusManager: FocusManager, private val coroutineScope: CoroutineScope, ) { var visible by mutableStateOf(false) @@ -57,6 +60,19 @@ class KeyboardController( softwareController?.hide() } + /** + * Takes the keyboard down and *keeps* it down: clears editor focus first, then hides the IME. + * + * Prefer this over [hide] whenever the keyboard belongs to a screen the user is being routed + * away from. `hide()` alone leaves the text field focused, so the platform brings the keyboard + * straight back — most visibly when resuming from the background, where the window restores the + * IME for whatever still holds focus. Clearing focus removes that target. + */ + fun dismiss() { + focusManager.clearFocus(force = true) + hide() + } + fun restartInput() { val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager @@ -93,13 +109,15 @@ class KeyboardController( fun rememberKeyboardController(): KeyboardController { val view = LocalView.current val softwareController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current val scope = rememberCoroutineScope() - val keyboardController = remember(view, softwareController) { - KeyboardController(view, softwareController, scope) + val keyboardController = remember(view, softwareController, focusManager) { + KeyboardController(view, softwareController, focusManager, scope) } // Trigger visibility tracking keyboardController.setupVisibilityTracking() return keyboardController -} \ No newline at end of file +} + From 3a3db4db63b2e9c51d06318076ee4e0531e6376a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 25 Aug 2026 08:47:07 -0400 Subject: [PATCH 2/3] test(tipcard): cover the keyboard dismissal the tip-card fix relies on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two behaviours carry the fix and neither had a test. KeyboardControllerTest pins the hide/dismiss split: `hide` leaves the editor focused, `dismiss` doesn't. Focus is the mechanism — the window restores the IME for whatever still holds it on the way back from the background — so the tree needs a non-editor focusable beside the field. `clearFocus` hands focus to the next candidate rather than leaving none, and a tree whose only focusable is the editor takes it straight back. OnBillPresentedTest covers the transition guard, which is the part most likely to be wrong: the overlay is hosted per navigation entry, so a bill already up at first composition must not count as a presentation, or the fresh chat entry would take down the keyboard that the post-tip hand-off opened on purpose. The guard moves out of BillOverlay into an internal OnBillPresented composable to make it reachable from a test. --- apps/flipcash/shared/bills/build.gradle.kts | 4 + .../com/flipcash/app/bills/BillOverlay.kt | 33 ++++--- .../flipcash/app/bills/OnBillPresentedTest.kt | 85 ++++++++++++++++++ ui/components/build.gradle.kts | 3 + .../ui/utils/KeyboardControllerTest.kt | 86 +++++++++++++++++++ 5 files changed, 200 insertions(+), 11 deletions(-) create mode 100644 apps/flipcash/shared/bills/src/test/kotlin/com/flipcash/app/bills/OnBillPresentedTest.kt create mode 100644 ui/components/src/test/kotlin/com/getcode/ui/utils/KeyboardControllerTest.kt diff --git a/apps/flipcash/shared/bills/build.gradle.kts b/apps/flipcash/shared/bills/build.gradle.kts index b52f38598d..cc3dade87d 100644 --- a/apps/flipcash/shared/bills/build.gradle.kts +++ b/apps/flipcash/shared/bills/build.gradle.kts @@ -7,6 +7,10 @@ android { } dependencies { + testImplementation(kotlin("test")) + testImplementation(libs.bundles.unit.testing) + testImplementation(libs.bundles.compose.ui.testing) + implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillOverlay.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillOverlay.kt index ffbf9bcbad..df8b37f76f 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillOverlay.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillOverlay.kt @@ -70,18 +70,8 @@ fun BillOverlay(modifier: Modifier = Modifier) { // keyboard down before routing, and this catches an IME the platform restores afterwards, on // the way back from the background. Clearing focus, not just hiding, so there's nothing left // for the platform to restore it onto. - // - // Only on a false -> true transition. This overlay is hosted per navigation entry, so a screen - // opened *while* a bill is up composes a fresh copy with the bill already present; firing there - // would stomp the post-tip hand-off, which opens the chat with the keyboard up on purpose (see - // TipCardDecorator's LaunchChat). Seeding from the current value makes that first pass a no-op. val keyboard = rememberKeyboardController() - val billPresented = billState.bill != null - var wasBillPresented by remember { mutableStateOf(billPresented) } - LaunchedEffect(billPresented) { - if (billPresented && !wasBillPresented) keyboard.dismiss() - wasBillPresented = billPresented - } + OnBillPresented(billState.bill != null) { keyboard.dismiss() } Box(modifier = Modifier.fillMaxSize().then(modifier)) { val updatedState by rememberUpdatedState(state) @@ -215,3 +205,24 @@ fun BillOverlay(modifier: Modifier = Modifier) { } } } + +/** + * Runs [onPresented] when [presented] flips false -> true. + * + * Deliberately not a plain `LaunchedEffect(presented)`. [BillOverlay] is hosted per navigation + * entry, so a screen opened *while* a bill is up composes a fresh copy with the bill already + * present, and an unguarded effect would fire on that first pass. For the keyboard that would stomp + * the post-tip hand-off, which opens the chat with the keyboard up on purpose (see + * TipCardDecorator's LaunchChat). Seeding `wasPresented` from the value at first composition makes + * an already-presented bill a no-op, so only a bill that appears *while this copy is watching* + * counts as a presentation. + */ +@Composable +internal fun OnBillPresented(presented: Boolean, onPresented: () -> Unit) { + var wasPresented by remember { mutableStateOf(presented) } + val currentOnPresented by rememberUpdatedState(onPresented) + LaunchedEffect(presented) { + if (presented && !wasPresented) currentOnPresented() + wasPresented = presented + } +} diff --git a/apps/flipcash/shared/bills/src/test/kotlin/com/flipcash/app/bills/OnBillPresentedTest.kt b/apps/flipcash/shared/bills/src/test/kotlin/com/flipcash/app/bills/OnBillPresentedTest.kt new file mode 100644 index 0000000000..dbbccb9ed6 --- /dev/null +++ b/apps/flipcash/shared/bills/src/test/kotlin/com/flipcash/app/bills/OnBillPresentedTest.kt @@ -0,0 +1,85 @@ +package com.flipcash.app.bills + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.createComposeRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +/** + * [OnBillPresented] is the guard that keeps [BillOverlay]'s keyboard dismissal from firing on a + * copy of the overlay that mounts with a bill already up — which is what a screen opened *while* + * a bill is presented does, the overlay being hosted per navigation entry. + */ +@RunWith(RobolectricTestRunner::class) +class OnBillPresentedTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private var presented by mutableStateOf(false) + private var recomposeTrigger by mutableIntStateOf(0) + private var presentations = 0 + + private fun watch(initiallyPresented: Boolean) { + presented = initiallyPresented + composeTestRule.setContent { + // Read something unrelated so a test can force a recomposition without touching + // `presented` — assigning the same value to snapshot state wouldn't invalidate. + @Suppress("UNUSED_EXPRESSION") + recomposeTrigger + OnBillPresented(presented) { presentations++ } + } + composeTestRule.waitForIdle() + } + + @Test + fun `a bill already up at first composition is not a presentation`() { + watch(initiallyPresented = true) + + // The post-tip hand-off case: LaunchChat opens the chat with the keyboard up on purpose, + // and the chat's fresh overlay must not take it straight back down. + assertEquals(0, presentations) + } + + @Test + fun `a bill appearing while watching is a presentation`() { + watch(initiallyPresented = false) + + composeTestRule.runOnIdle { presented = true } + composeTestRule.waitForIdle() + + assertEquals(1, presentations) + } + + @Test + fun `recomposing while the bill stays up does not present again`() { + watch(initiallyPresented = false) + composeTestRule.runOnIdle { presented = true } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { recomposeTrigger++ } + composeTestRule.waitForIdle() + + assertEquals(1, presentations) + } + + @Test + fun `a bill dismissed and presented again is a second presentation`() { + watch(initiallyPresented = false) + + composeTestRule.runOnIdle { presented = true } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { presented = false } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { presented = true } + composeTestRule.waitForIdle() + + assertEquals(2, presentations) + } +} diff --git a/ui/components/build.gradle.kts b/ui/components/build.gradle.kts index 08cc1d2383..73aaab7188 100644 --- a/ui/components/build.gradle.kts +++ b/ui/components/build.gradle.kts @@ -13,6 +13,9 @@ android { dependencies { testImplementation(kotlin("test")) + testImplementation(libs.bundles.unit.testing) + testImplementation(libs.bundles.compose.ui.testing) + implementation(project(":libs:datetime")) implementation(project(":libs:encryption:ed25519")) implementation(project(":libs:encryption:utils")) diff --git a/ui/components/src/test/kotlin/com/getcode/ui/utils/KeyboardControllerTest.kt b/ui/components/src/test/kotlin/com/getcode/ui/utils/KeyboardControllerTest.kt new file mode 100644 index 0000000000..1ab8605d76 --- /dev/null +++ b/ui/components/src/test/kotlin/com/getcode/ui/utils/KeyboardControllerTest.kt @@ -0,0 +1,86 @@ +package com.getcode.ui.utils + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Covers the difference between [KeyboardController.hide] and [KeyboardController.dismiss]: only + * `dismiss` takes editor focus away, and that focus is what makes a hidden keyboard come back — + * the window restores the IME for whatever still holds it when the app returns from the background. + */ +@RunWith(RobolectricTestRunner::class) +class KeyboardControllerTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var keyboard: KeyboardController + private var editorFocused = false + + /** + * Renders a focused text field with one non-editor focusable beside it, and captures the + * [KeyboardController] alongside them. + * + * The second focusable is not padding. `clearFocus` hands focus to the next candidate rather + * than leaving the tree with none, so a tree whose only focusable is the editor takes focus + * straight back — an outcome no real screen produces, every one of them having buttons. + */ + private fun focusedEditor() { + composeTestRule.setContent { + keyboard = rememberKeyboardController() + val editor = remember { FocusRequester() } + Column { + Box(Modifier.size(20.dp).focusable()) + BasicTextField( + state = rememberTextFieldState(), + modifier = Modifier + .focusRequester(editor) + .onFocusChanged { editorFocused = it.isFocused }, + ) + } + LaunchedEffect(Unit) { editor.requestFocus() } + } + composeTestRule.waitForIdle() + assertTrue(editorFocused, "editor should start focused") + } + + @Test + fun `dismiss takes focus off the editor`() { + focusedEditor() + + composeTestRule.runOnUiThread { keyboard.dismiss() } + composeTestRule.waitForIdle() + + assertFalse(editorFocused) + } + + @Test + fun `hide leaves editor focus intact`() { + focusedEditor() + + composeTestRule.runOnUiThread { keyboard.hide() } + composeTestRule.waitForIdle() + + // The gap dismiss() exists to close: the IME goes down but the field stays focused, so the + // platform has something to restore it onto. + assertTrue(editorFocused) + } +} From 5f3131467abefe07d61c4aefb76efe20e8ea6036 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 25 Aug 2026 09:38:14 -0400 Subject: [PATCH 3/3] refactor(keyboard): make hide clear editor focus and drop dismiss dismiss() existed so a caller could opt into clearing editor focus before hiding the IME. Across the codebase no caller wants the other behaviour: hideIfVisible accounts for 29 sites and every one hides, waits out the IME animation, then pops, dismisses a sheet, or advances a flow step; the two direct hide() callers pop or dismiss on tap. Both show() callers request focus explicitly first, so nothing pairs a hide with a later show that depends on the field still holding focus. That makes the safe behaviour the one worth defaulting to, and a retainFocus parameter a flag with no false branch. Folding it into hide() also carries the fix to the 29 hideIfVisible sites, which had the same latent problem as the tip card: IME down, field still focused, keyboard restored on the way back from the background. Tapping the message list to put the keyboard away now deselects the composer too, matching the rest of the app's leave-the-field behaviour. --- .../com/flipcash/app/internal/ui/App.kt | 8 +++--- .../com/flipcash/app/bills/BillOverlay.kt | 5 ++-- .../kotlin/com/getcode/ui/utils/Keyboard.kt | 20 +++++++------- .../ui/utils/KeyboardControllerTest.kt | 27 +++++++------------ 4 files changed, 26 insertions(+), 34 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt index a09b8e3189..7ce3abc9fd 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt @@ -280,10 +280,10 @@ internal fun App( // holds focus — the common case is resuming from the background // straight out of a chat, where the window restores the IME for // the still-focused input as we route. Take the keyboard down - // (and clear focus, so it isn't restored again) before anything - // is presented, so a tip card doesn't come up over a keyboard, - // and none appears while the card is still resolving. - if (action != DeeplinkAction.None) keyboard.dismiss() + // before anything is presented, so a tip card doesn't come up + // over a keyboard, and none appears while the card is still + // resolving. + if (action != DeeplinkAction.None) keyboard.hide() when (action) { is DeeplinkAction.Navigate -> { diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillOverlay.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillOverlay.kt index df8b37f76f..ba81b38900 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillOverlay.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillOverlay.kt @@ -68,10 +68,9 @@ fun BillOverlay(modifier: Modifier = Modifier) { // A bill is a focused modal — it must never share the screen with a keyboard. What makes that // reachable is a tip-card deeplink handled while a chat input still holds focus: App takes the // keyboard down before routing, and this catches an IME the platform restores afterwards, on - // the way back from the background. Clearing focus, not just hiding, so there's nothing left - // for the platform to restore it onto. + // the way back from the background. val keyboard = rememberKeyboardController() - OnBillPresented(billState.bill != null) { keyboard.dismiss() } + OnBillPresented(billState.bill != null) { keyboard.hide() } Box(modifier = Modifier.fillMaxSize().then(modifier)) { val updatedState by rememberUpdatedState(state) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/utils/Keyboard.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/Keyboard.kt index 50c51175c9..8d06c3c683 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/utils/Keyboard.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/utils/Keyboard.kt @@ -56,21 +56,21 @@ class KeyboardController( softwareController?.show() } - fun hide() { - softwareController?.hide() - } - /** - * Takes the keyboard down and *keeps* it down: clears editor focus first, then hides the IME. + * Takes the keyboard down and *keeps* it down: clears editor focus, then hides the IME. * - * Prefer this over [hide] whenever the keyboard belongs to a screen the user is being routed - * away from. `hide()` alone leaves the text field focused, so the platform brings the keyboard + * Hiding on its own isn't enough. The field stays focused, so the platform brings the keyboard * straight back — most visibly when resuming from the background, where the window restores the - * IME for whatever still holds focus. Clearing focus removes that target. + * IME for whatever still holds focus. Clearing focus removes the target it would be restored + * onto. + * + * Unconditional because every caller is taking the keyboard down on the way somewhere else: a + * pop, a sheet dismissal, a flow step, a deeplink being routed. A screen that wants the IME + * down while the field stays armed needs its own FocusRequester rather than this. */ - fun dismiss() { + fun hide() { focusManager.clearFocus(force = true) - hide() + softwareController?.hide() } fun restartInput() { diff --git a/ui/components/src/test/kotlin/com/getcode/ui/utils/KeyboardControllerTest.kt b/ui/components/src/test/kotlin/com/getcode/ui/utils/KeyboardControllerTest.kt index 1ab8605d76..cfb09026a7 100644 --- a/ui/components/src/test/kotlin/com/getcode/ui/utils/KeyboardControllerTest.kt +++ b/ui/components/src/test/kotlin/com/getcode/ui/utils/KeyboardControllerTest.kt @@ -22,9 +22,14 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue /** - * Covers the difference between [KeyboardController.hide] and [KeyboardController.dismiss]: only - * `dismiss` takes editor focus away, and that focus is what makes a hidden keyboard come back — - * the window restores the IME for whatever still holds it when the app returns from the background. + * Covers the focus half of [KeyboardController.hide]. Hiding the IME without dropping editor focus + * leaves the platform a target to restore it onto — the window brings the keyboard back for + * whatever still holds focus when the app returns from the background — so taking focus away is + * what makes a hidden keyboard stay hidden. + * + * [KeyboardController.hideIfVisible] inherits this by delegating to `hide`, but isn't covered here: + * it gates on [KeyboardController.visible], which is read from window insets that Robolectric never + * reports for the IME. */ @RunWith(RobolectricTestRunner::class) class KeyboardControllerTest { @@ -63,24 +68,12 @@ class KeyboardControllerTest { } @Test - fun `dismiss takes focus off the editor`() { - focusedEditor() - - composeTestRule.runOnUiThread { keyboard.dismiss() } - composeTestRule.waitForIdle() - - assertFalse(editorFocused) - } - - @Test - fun `hide leaves editor focus intact`() { + fun `hide takes focus off the editor`() { focusedEditor() composeTestRule.runOnUiThread { keyboard.hide() } composeTestRule.waitForIdle() - // The gap dismiss() exists to close: the IME goes down but the field stays focused, so the - // platform has something to restore it onto. - assertTrue(editorFocused) + assertFalse(editorFocused) } }