Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions apps/flipcash/shared/bills/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions ui/components/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
24 changes: 21 additions & 3 deletions ui/components/src/main/kotlin/com/getcode/ui/utils/Keyboard.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -44,6 +46,7 @@ fun keyboardAsState(): State<Boolean> {
class KeyboardController(
private val view: View,
private val softwareController: SoftwareKeyboardController?,
private val focusManager: FocusManager,
private val coroutineScope: CoroutineScope,
) {
var visible by mutableStateOf(false)
Expand All @@ -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()
}

Expand Down Expand Up @@ -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
}
}

Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading