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..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 @@ -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 + // 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 -> { // If a verification code targets a screen already open, 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 5f00cce55a..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 @@ -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,13 @@ 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. + val keyboard = rememberKeyboardController() + OnBillPresented(billState.bill != null) { keyboard.hide() } + Box(modifier = Modifier.fillMaxSize().then(modifier)) { val updatedState by rememberUpdatedState(state) val updatedBillState by rememberUpdatedState(billState) @@ -196,3 +204,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/main/kotlin/com/getcode/ui/utils/Keyboard.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/Keyboard.kt index 9b35f0a0a4..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 @@ -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) @@ -53,7 +56,20 @@ class KeyboardController( softwareController?.show() } + /** + * Takes the keyboard down and *keeps* it down: clears editor focus, then hides the IME. + * + * 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 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 hide() { + focusManager.clearFocus(force = true) softwareController?.hide() } @@ -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 +} + 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..cfb09026a7 --- /dev/null +++ b/ui/components/src/test/kotlin/com/getcode/ui/utils/KeyboardControllerTest.kt @@ -0,0 +1,79 @@ +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 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 { + + @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 `hide takes focus off the editor`() { + focusedEditor() + + composeTestRule.runOnUiThread { keyboard.hide() } + composeTestRule.waitForIdle() + + assertFalse(editorFocused) + } +}