diff --git a/android/app/src/main/java/com/expensify/chat/MainApplication.kt b/android/app/src/main/java/com/expensify/chat/MainApplication.kt index 942304c80445..ec3ac41c76c4 100644 --- a/android/app/src/main/java/com/expensify/chat/MainApplication.kt +++ b/android/app/src/main/java/com/expensify/chat/MainApplication.kt @@ -8,6 +8,7 @@ import android.database.CursorWindow import android.os.Process import androidx.multidex.MultiDexApplication import com.expensify.chat.bootsplash.BootSplashPackage +import com.expensify.chat.navbar.NavBarManagerPackage import com.expensify.chat.shortcutManagerModule.ShortcutManagerPackage import com.facebook.react.PackageList import com.facebook.react.ReactApplication @@ -36,6 +37,7 @@ class MainApplication : MultiDexApplication(), ReactApplication { add(BootSplashPackage()) add(ExpensifyAppPackage()) add(RNTextInputResetPackage()) + add(NavBarManagerPackage()) } override fun getJSMainModuleName() = ".expo/.virtual-metro-entry" diff --git a/android/app/src/main/java/com/expensify/chat/navbar/NavBarManagerModule.kt b/android/app/src/main/java/com/expensify/chat/navbar/NavBarManagerModule.kt new file mode 100644 index 000000000000..5c566df606eb --- /dev/null +++ b/android/app/src/main/java/com/expensify/chat/navbar/NavBarManagerModule.kt @@ -0,0 +1,27 @@ +package com.expensify.chat.navbar + +import androidx.core.view.WindowInsetsControllerCompat +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.UiThreadUtil; + +class NavBarManagerModule( + private val mReactContext: ReactApplicationContext, +) : ReactContextBaseJavaModule(mReactContext) { + override fun getName(): String = "RNNavBarManager" + + @ReactMethod + fun setButtonStyle(style: String) { + UiThreadUtil.runOnUiThread { + mReactContext.currentActivity?.window?.let { + WindowInsetsControllerCompat(it, it.decorView).let { controller -> + when (style) { + "light" -> controller.isAppearanceLightNavigationBars = false + "dark" -> controller.isAppearanceLightNavigationBars = true + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/expensify/chat/navbar/NavBarManagerPackage.kt b/android/app/src/main/java/com/expensify/chat/navbar/NavBarManagerPackage.kt new file mode 100644 index 000000000000..33ee64d17769 --- /dev/null +++ b/android/app/src/main/java/com/expensify/chat/navbar/NavBarManagerPackage.kt @@ -0,0 +1,18 @@ +package com.expensify.chat.navbar + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class NavBarManagerPackage : ReactPackage { + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return emptyList() + } + + override fun createNativeModules(reactContext: ReactApplicationContext): List { + val modules: MutableList = ArrayList() + modules.add(NavBarManagerModule(reactContext)) + return modules + } +} diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 75126afbd407..42da35d7a493 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -7,6 +7,8 @@ diff --git a/patches/react-native+0.75.2+023+modal-navigation-bar-translucent.patch b/patches/react-native+0.75.2+023+modal-navigation-bar-translucent.patch new file mode 100644 index 000000000000..f8a98760b389 --- /dev/null +++ b/patches/react-native+0.75.2+023+modal-navigation-bar-translucent.patch @@ -0,0 +1,214 @@ +diff --git a/node_modules/react-native/Libraries/Modal/Modal.d.ts b/node_modules/react-native/Libraries/Modal/Modal.d.ts +index 4cc2df2..a501b27 100644 +--- a/node_modules/react-native/Libraries/Modal/Modal.d.ts ++++ b/node_modules/react-native/Libraries/Modal/Modal.d.ts +@@ -94,6 +94,11 @@ export interface ModalPropsAndroid { + * Determines whether your modal should go under the system statusbar. + */ + statusBarTranslucent?: boolean | undefined; ++ ++ /** ++ * Determines whether your modal should go under the system navigationbar. ++ */ ++ navigationBarTranslucent?: boolean | undefined; + } + + export type ModalProps = ModalBaseProps & +diff --git a/node_modules/react-native/Libraries/Modal/Modal.js b/node_modules/react-native/Libraries/Modal/Modal.js +index 1942d9e..1ffbe4c 100644 +--- a/node_modules/react-native/Libraries/Modal/Modal.js ++++ b/node_modules/react-native/Libraries/Modal/Modal.js +@@ -95,6 +95,14 @@ export type Props = $ReadOnly<{| + */ + statusBarTranslucent?: ?boolean, + ++ /** ++ * The `navigationBarTranslucent` prop determines whether your modal should go under ++ * the system navigationbar. ++ * ++ * See https://reactnative.dev/docs/modal.html#navigationbartranslucent-android ++ */ ++ navigationBarTranslucent?: ?boolean, ++ + /** + * The `hardwareAccelerated` prop controls whether to force hardware + * acceleration for the underlying window. +@@ -170,6 +178,14 @@ function confirmProps(props: Props) { + `Modal with '${props.presentationStyle}' presentation style and 'transparent' value is not supported.`, + ); + } ++ if ( ++ props.navigationBarTranslucent === true && ++ props.statusBarTranslucent !== true ++ ) { ++ console.warn( ++ 'Modal with translucent navigation bar and without translucent status bar is not supported.', ++ ); ++ } + } + } + +@@ -291,6 +307,7 @@ class Modal extends React.Component { + onDismiss={onDismiss} + visible={this.props.visible} + statusBarTranslucent={this.props.statusBarTranslucent} ++ navigationBarTranslucent={this.props.navigationBarTranslucent} + identifier={this._identifier} + style={styles.modal} + // $FlowFixMe[method-unbinding] added when improving typing for this parameters +diff --git a/node_modules/react-native/React/Views/RCTModalHostView.h b/node_modules/react-native/React/Views/RCTModalHostView.h +index 2fcdcae..0469c23 100644 +--- a/node_modules/react-native/React/Views/RCTModalHostView.h ++++ b/node_modules/react-native/React/Views/RCTModalHostView.h +@@ -27,6 +27,7 @@ + + // Android only + @property (nonatomic, assign) BOOL statusBarTranslucent; ++@property (nonatomic, assign) BOOL navigationBarTranslucent; + @property (nonatomic, assign) BOOL hardwareAccelerated; + @property (nonatomic, assign) BOOL animated; + +diff --git a/node_modules/react-native/React/Views/RCTModalHostViewManager.m b/node_modules/react-native/React/Views/RCTModalHostViewManager.m +index e2ae7e2..a694008 100644 +--- a/node_modules/react-native/React/Views/RCTModalHostViewManager.m ++++ b/node_modules/react-native/React/Views/RCTModalHostViewManager.m +@@ -118,6 +118,7 @@ - (void)invalidate + RCT_EXPORT_VIEW_PROPERTY(presentationStyle, UIModalPresentationStyle) + RCT_EXPORT_VIEW_PROPERTY(transparent, BOOL) + RCT_EXPORT_VIEW_PROPERTY(statusBarTranslucent, BOOL) ++RCT_EXPORT_VIEW_PROPERTY(navigationBarTranslucent, BOOL) + RCT_EXPORT_VIEW_PROPERTY(hardwareAccelerated, BOOL) + RCT_EXPORT_VIEW_PROPERTY(animated, BOOL) + RCT_EXPORT_VIEW_PROPERTY(onShow, RCTDirectEventBlock) +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostManager.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostManager.kt +index d5e053c..fddda45 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostManager.kt ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostManager.kt +@@ -59,6 +59,15 @@ public class ReactModalHostManager : + view.statusBarTranslucent = statusBarTranslucent + } + ++ ++ @ReactProp(name = "navigationBarTranslucent") ++ public override fun setNavigationBarTranslucent( ++ view: ReactModalHostView, ++ navigationBarTranslucent: Boolean ++ ) { ++ view.navigationBarTranslucent = navigationBarTranslucent ++ } ++ + @ReactProp(name = "hardwareAccelerated") + public override fun setHardwareAccelerated( + view: ReactModalHostView, +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt +index f6e0d82..03380cb 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt +@@ -46,6 +46,7 @@ import com.facebook.react.uimanager.UIManagerModule + import com.facebook.react.uimanager.events.EventDispatcher + import com.facebook.react.views.common.ContextUtils + import com.facebook.react.views.view.ReactViewGroup ++import com.facebook.react.views.view.setSystemBarsTranslucency + import java.util.Objects + import kotlin.math.abs + +@@ -78,6 +79,12 @@ public class ReactModalHostView(context: ThemedReactContext) : + createNewDialog = true + } + ++ public var navigationBarTranslucent: Boolean = false ++ set(value) { ++ field = value ++ createNewDialog = true ++ } ++ + public var animationType: String? = null + set(value) { + field = value +@@ -296,6 +303,7 @@ public class ReactModalHostView(context: ThemedReactContext) : + } else { + frameLayout.fitsSystemWindows = true + } ++ dialog?.window?.setSystemBarsTranslucency(navigationBarTranslucent) + return frameLayout + } + +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/WindowUtil.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/WindowUtil.kt +new file mode 100644 +index 0000000..24057c4 +--- /dev/null ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/WindowUtil.kt +@@ -0,0 +1,53 @@ ++/* ++ * Copyright (c) Meta Platforms, Inc. and affiliates. ++ * ++ * This source code is licensed under the MIT license found in the ++ * LICENSE file in the root directory of this source tree. ++ */ ++ ++package com.facebook.react.views.view ++ ++import android.content.res.Configuration ++import android.graphics.Color ++import android.os.Build ++import android.view.Window ++import android.view.WindowManager ++import androidx.core.view.ViewCompat ++import androidx.core.view.WindowCompat ++import androidx.core.view.WindowInsetsControllerCompat ++ ++@Suppress("DEPRECATION") ++public fun Window.setSystemBarsTranslucency(isTranslucent: Boolean) { ++ WindowCompat.setDecorFitsSystemWindows(this, !isTranslucent) ++ ++ if (isTranslucent) { ++ val isDarkMode = ++ context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == ++ Configuration.UI_MODE_NIGHT_YES ++ ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ++ isStatusBarContrastEnforced = false ++ isNavigationBarContrastEnforced = true ++ } ++ ++ statusBarColor = Color.TRANSPARENT ++ navigationBarColor = when { ++ Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Color.TRANSPARENT ++ Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 && !isDarkMode -> ++ Color.argb(0xe6, 0xFF, 0xFF, 0xFF) ++ else -> Color.argb(0x80, 0x1b, 0x1b, 0x1b) ++ } ++ ++ WindowInsetsControllerCompat(this, this.decorView).run { ++ isAppearanceLightNavigationBars = !isDarkMode ++ } ++ ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { ++ attributes.layoutInDisplayCutoutMode = when { ++ Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> ++ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS ++ else -> WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES ++ } ++ } ++ } ++} +\ No newline at end of file +diff --git a/node_modules/react-native/src/private/specs/components/RCTModalHostViewNativeComponent.js b/node_modules/react-native/src/private/specs/components/RCTModalHostViewNativeComponent.js +index 86bf895..58ec294 100644 +--- a/node_modules/react-native/src/private/specs/components/RCTModalHostViewNativeComponent.js ++++ b/node_modules/react-native/src/private/specs/components/RCTModalHostViewNativeComponent.js +@@ -58,6 +58,14 @@ type NativeProps = $ReadOnly<{| + */ + statusBarTranslucent?: WithDefault, + ++ /** ++ * The `navigationBarTranslucent` prop determines whether your modal should go under ++ * the system navigationbar. ++ * ++ * See https://reactnative.dev/docs/modal#navigationBarTranslucent ++ */ ++ navigationBarTranslucent?: WithDefault, ++ + /** + * The `hardwareAccelerated` prop controls whether to force hardware + * acceleration for the underlying window. diff --git a/patches/react-native-keyboard-controller+1.14.4+001+disable-android.patch b/patches/react-native-keyboard-controller+1.14.4+001+disable-android.patch deleted file mode 100644 index 8d2d81aab40a..000000000000 --- a/patches/react-native-keyboard-controller+1.14.4+001+disable-android.patch +++ /dev/null @@ -1,62 +0,0 @@ -diff --git a/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt b/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt -index 93c20d3..df1e846 100644 ---- a/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt -+++ b/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt -@@ -74,7 +74,7 @@ class EdgeToEdgeReactViewGroup( - } - - override fun onConfigurationChanged(newConfig: Configuration?) { -- this.reApplyWindowInsets() -+ // this.reApplyWindowInsets() - } - // endregion - -@@ -124,12 +124,12 @@ class EdgeToEdgeReactViewGroup( - } - - private fun goToEdgeToEdge(edgeToEdge: Boolean) { -- reactContext.currentActivity?.let { -- WindowCompat.setDecorFitsSystemWindows( -- it.window, -- !edgeToEdge, -- ) -- } -+ // reactContext.currentActivity?.let { -+ // WindowCompat.setDecorFitsSystemWindows( -+ // it.window, -+ // !edgeToEdge, -+ // ) -+ // } - } - - private fun setupKeyboardCallbacks() { -@@ -182,16 +182,16 @@ class EdgeToEdgeReactViewGroup( - // region State managers - private fun enable() { - this.goToEdgeToEdge(true) -- this.setupWindowInsets() -+ // this.setupWindowInsets() - this.setupKeyboardCallbacks() -- modalAttachedWatcher.enable() -+ // modalAttachedWatcher.enable() - } - - private fun disable() { - this.goToEdgeToEdge(false) -- this.setupWindowInsets() -+ // this.setupWindowInsets() - this.removeKeyboardCallbacks() -- modalAttachedWatcher.disable() -+ // modalAttachedWatcher.disable() - } - // endregion - -@@ -223,7 +223,7 @@ class EdgeToEdgeReactViewGroup( - fun forceStatusBarTranslucent(isStatusBarTranslucent: Boolean) { - if (active && this.isStatusBarTranslucent != isStatusBarTranslucent) { - this.isStatusBarTranslucent = isStatusBarTranslucent -- this.reApplyWindowInsets() -+ // this.reApplyWindowInsets() - } - } - // endregion diff --git a/patches/react-native-modal+13.0.1.patch b/patches/react-native-modal+13.0.1+001+initial.patch similarity index 100% rename from patches/react-native-modal+13.0.1.patch rename to patches/react-native-modal+13.0.1+001+initial.patch diff --git a/patches/react-native-modal+13.0.1+002+modal-navigation-bar-translucent.patch b/patches/react-native-modal+13.0.1+002+modal-navigation-bar-translucent.patch new file mode 100644 index 000000000000..a318627af02c --- /dev/null +++ b/patches/react-native-modal+13.0.1+002+modal-navigation-bar-translucent.patch @@ -0,0 +1,32 @@ +diff --git a/node_modules/react-native-modal/dist/modal.d.ts b/node_modules/react-native-modal/dist/modal.d.ts +index bd6419e..029762c 100644 +--- a/node_modules/react-native-modal/dist/modal.d.ts ++++ b/node_modules/react-native-modal/dist/modal.d.ts +@@ -46,6 +46,7 @@ declare const defaultProps: { + scrollOffsetMax: number; + scrollHorizontal: boolean; + statusBarTranslucent: boolean; ++ navigationBarTranslucent: boolean; + supportedOrientations: ("landscape" | "portrait" | "portrait-upside-down" | "landscape-left" | "landscape-right")[]; + }; + export declare type ModalProps = ViewProps & { +@@ -137,6 +138,7 @@ export declare class ReactNativeModal extends React.Component + scrollOffsetMax: number; + scrollHorizontal: boolean; + statusBarTranslucent: boolean; ++ navigationBarTranslucent: boolean; + supportedOrientations: ("landscape" | "portrait" | "portrait-upside-down" | "landscape-left" | "landscape-right")[]; + }; + state: State; +diff --git a/node_modules/react-native-modal/dist/modal.js b/node_modules/react-native-modal/dist/modal.js +index 46277ea..feec991 100644 +--- a/node_modules/react-native-modal/dist/modal.js ++++ b/node_modules/react-native-modal/dist/modal.js +@@ -38,6 +38,7 @@ const defaultProps = { + scrollOffsetMax: 0, + scrollHorizontal: false, + statusBarTranslucent: false, ++ navigationBarTranslucent: false, + supportedOrientations: ['portrait', 'landscape'], + }; + const extractAnimationFromProps = (props) => ({ diff --git a/src/App.tsx b/src/App.tsx index 643e2146e501..52904e0a06c4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,6 @@ import {PortalProvider} from '@gorhom/portal'; import React from 'react'; import {LogBox} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; -import {KeyboardProvider} from 'react-native-keyboard-controller'; import {PickerStateProvider} from 'react-native-picker-select'; import {SafeAreaProvider} from 'react-native-safe-area-context'; import '../wdyr'; @@ -15,6 +14,7 @@ import CustomStatusBarAndBackgroundContextProvider from './components/CustomStat import ErrorBoundary from './components/ErrorBoundary'; import HTMLEngineProvider from './components/HTMLEngineProvider'; import InitialURLContextProvider from './components/InitialURLContextProvider'; +import KeyboardProvider from './components/KeyboardProvider'; import {LocaleContextProvider} from './components/LocaleContextProvider'; import OnyxProvider from './components/OnyxProvider'; import PopoverContextProvider from './components/PopoverProvider'; diff --git a/src/CONST.ts b/src/CONST.ts index ed5f1837fe3b..ceb2419a2b7e 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1338,6 +1338,10 @@ const CONST = { LIGHT_CONTENT: 'light-content', DARK_CONTENT: 'dark-content', }, + NAVIGATION_BAR_BUTTONS_STYLE: { + LIGHT: 'light', + DARK: 'dark', + }, TRANSACTION: { DEFAULT_MERCHANT: 'Expense', UNKNOWN_MERCHANT: 'Unknown Merchant', diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index 5c5c28b82fb9..8baaf0c40576 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -14,6 +14,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Form} from '@src/types/form'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import KeyboardUtils from '@src/utils/keyboard'; import type {RegisterInput} from './FormContext'; import FormContext from './FormContext'; import FormWrapper from './FormWrapper'; @@ -206,7 +207,7 @@ function FormProvider( return; } - onSubmit(trimmedStringValues); + KeyboardUtils.dismiss().then(() => onSubmit(trimmedStringValues)); }, [enabledWhenOffline, formState?.isLoading, inputValues, network?.isOffline, onSubmit, onValidate, shouldTrimValues]); // Keep track of the focus state of the current screen. diff --git a/src/components/KeyboardAvoidingView/index.android.tsx b/src/components/KeyboardAvoidingView/index.android.tsx new file mode 100644 index 000000000000..e8eb79d18bbd --- /dev/null +++ b/src/components/KeyboardAvoidingView/index.android.tsx @@ -0,0 +1,135 @@ +import React, {forwardRef, useCallback, useMemo, useState} from 'react'; +import type {LayoutRectangle, View, ViewProps} from 'react-native'; +import {useKeyboardContext, useKeyboardHandler} from 'react-native-keyboard-controller'; +import Reanimated, {interpolate, runOnUI, useAnimatedStyle, useDerivedValue, useSharedValue} from 'react-native-reanimated'; +import {useSafeAreaFrame} from 'react-native-safe-area-context'; +import type {KeyboardAvoidingViewProps} from './types'; + +const useKeyboardAnimation = () => { + const {reanimated} = useKeyboardContext(); + + // calculate it only once on mount, to avoid `SharedValue` reads during a render + const [initialHeight] = useState(() => -reanimated.height.value); + const [initialProgress] = useState(() => reanimated.progress.value); + + const heightWhenOpened = useSharedValue(initialHeight); + const height = useSharedValue(initialHeight); + const progress = useSharedValue(initialProgress); + const isClosed = useSharedValue(initialProgress === 0); + + useKeyboardHandler( + { + onStart: (e) => { + 'worklet'; + + progress.value = e.progress; + height.value = e.height; + + if (e.height > 0) { + // eslint-disable-next-line react-compiler/react-compiler + isClosed.value = false; + heightWhenOpened.value = e.height; + } + }, + onEnd: (e) => { + 'worklet'; + + isClosed.value = e.height === 0; + + height.value = e.height; + progress.value = e.progress; + }, + }, + [], + ); + + return {height, progress, heightWhenOpened, isClosed}; +}; + +const defaultLayout: LayoutRectangle = { + x: 0, + y: 0, + width: 0, + height: 0, +}; + +/** + * View that moves out of the way when the keyboard appears by automatically + * adjusting its height, position, or bottom padding. + * + * This `KeyboardAvoidingView` acts as a backward compatible layer for the previous Android behavior (prior to edge-to-edge mode). + * We can use `KeyboardAvoidingView` directly from the `react-native-keyboard-controller` package, but in this case animations are stuttering and it's better to handle as a separate task. + */ +const KeyboardAvoidingView = forwardRef>( + ({behavior, children, contentContainerStyle, enabled = true, keyboardVerticalOffset = 0, style, onLayout: onLayoutProps, ...props}, ref) => { + const initialFrame = useSharedValue(null); + const frame = useDerivedValue(() => initialFrame.value ?? defaultLayout); + + const keyboard = useKeyboardAnimation(); + const {height: screenHeight} = useSafeAreaFrame(); + + const relativeKeyboardHeight = useCallback(() => { + 'worklet'; + + const keyboardY = screenHeight - keyboard.heightWhenOpened.value - keyboardVerticalOffset; + + return Math.max(frame.value.y + frame.value.height - keyboardY, 0); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [screenHeight, keyboardVerticalOffset]); + + const onLayoutWorklet = useCallback((layout: LayoutRectangle) => { + 'worklet'; + + if (keyboard.isClosed.value || initialFrame.value === null) { + // eslint-disable-next-line react-compiler/react-compiler + initialFrame.value = layout; + } + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, []); + const onLayout = useCallback>( + (e) => { + runOnUI(onLayoutWorklet)(e.nativeEvent.layout); + onLayoutProps?.(e); + }, + [onLayoutProps, onLayoutWorklet], + ); + + const animatedStyle = useAnimatedStyle(() => { + const bottom = interpolate(keyboard.progress.value, [0, 1], [0, relativeKeyboardHeight()]); + const bottomHeight = enabled ? bottom : 0; + + switch (behavior) { + case 'height': + if (!keyboard.isClosed.value) { + return { + height: frame.value.height - bottomHeight, + flex: 0, + }; + } + + return {}; + + case 'padding': + return {paddingBottom: bottomHeight}; + + default: + return {}; + } + }, [behavior, enabled, relativeKeyboardHeight]); + const combinedStyles = useMemo(() => [style, animatedStyle], [style, animatedStyle]); + + return ( + + {children} + + ); + }, +); + +export default KeyboardAvoidingView; diff --git a/src/components/KeyboardAvoidingView/index.tsx b/src/components/KeyboardAvoidingView/index.tsx index c0882ae1e9cc..7e2c7b8159a9 100644 --- a/src/components/KeyboardAvoidingView/index.tsx +++ b/src/components/KeyboardAvoidingView/index.tsx @@ -1,5 +1,5 @@ /* - * The KeyboardAvoidingView is only used on ios + * The KeyboardAvoidingView stub implementation for web and other platforms where the keyboard is handled automatically. */ import React from 'react'; import {View} from 'react-native'; diff --git a/src/components/KeyboardProvider/index.tsx b/src/components/KeyboardProvider/index.tsx new file mode 100644 index 000000000000..4dab6e435359 --- /dev/null +++ b/src/components/KeyboardProvider/index.tsx @@ -0,0 +1,16 @@ +import type {PropsWithChildren} from 'react'; +import React from 'react'; +import {KeyboardProvider} from 'react-native-keyboard-controller'; + +function KeyboardProviderWrapper({children}: PropsWithChildren>) { + return ( + + {children} + + ); +} + +export default KeyboardProviderWrapper; diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 39396795c557..70818eaf343a 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -43,6 +43,7 @@ function BaseModal( animationInTiming, animationOutTiming, statusBarTranslucent = true, + navigationBarTranslucent = true, onLayout, avoidKeyboard = false, children, @@ -256,6 +257,7 @@ function BaseModal( animationInTiming={animationInTiming} animationOutTiming={animationOutTiming} statusBarTranslucent={statusBarTranslucent} + navigationBarTranslucent={navigationBarTranslucent} onLayout={onLayout} avoidKeyboard={avoidKeyboard} customBackdrop={shouldUseCustomBackdrop ? : undefined} diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index 6ced829e93d6..42b1939413a7 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -56,6 +56,9 @@ type BaseModalProps = Partial & { /** Whether the modal should go under the system statusbar */ statusBarTranslucent?: boolean; + /** Whether the modal should go under the system navigation bar */ + navigationBarTranslucent?: boolean; + /** Whether the modal should avoid the keyboard */ avoidKeyboard?: boolean; diff --git a/src/components/PopoverWithMeasuredContent.tsx b/src/components/PopoverWithMeasuredContent.tsx index 8ad654588c5a..4fa58ac21ffa 100644 --- a/src/components/PopoverWithMeasuredContent.tsx +++ b/src/components/PopoverWithMeasuredContent.tsx @@ -49,6 +49,7 @@ function PopoverWithMeasuredContent({ shouldCloseOnOutsideClick = false, shouldSetModalVisibility = true, statusBarTranslucent = true, + navigationBarTranslucent = true, avoidKeyboard = false, hideModalContentWhileAnimating = false, anchorDimensions = { @@ -154,6 +155,7 @@ function PopoverWithMeasuredContent({ shouldCloseOnOutsideClick={shouldCloseOnOutsideClick} shouldSetModalVisibility={shouldSetModalVisibility} statusBarTranslucent={statusBarTranslucent} + navigationBarTranslucent={navigationBarTranslucent} avoidKeyboard={avoidKeyboard} hideModalContentWhileAnimating={hideModalContentWhileAnimating} modalId={modalId} diff --git a/src/components/SplashScreenHider/index.native.tsx b/src/components/SplashScreenHider/index.native.tsx index 7c579519c926..3586c7a88062 100644 --- a/src/components/SplashScreenHider/index.native.tsx +++ b/src/components/SplashScreenHider/index.native.tsx @@ -11,7 +11,6 @@ import type {SplashScreenHiderProps, SplashScreenHiderReturnType} from './types' function SplashScreenHider({onHide = () => {}}: SplashScreenHiderProps): SplashScreenHiderReturnType { const styles = useThemeStyles(); const logoSizeRatio = BootSplash.logoSizeRatio || 1; - const navigationBarHeight = BootSplash.navigationBarHeight || 0; const opacity = useSharedValue(1); const scale = useSharedValue(1); @@ -54,15 +53,7 @@ function SplashScreenHider({onHide = () => {}}: SplashScreenHiderProps): SplashS return ( { const styles = useThemeStyles(); const [willKeyboardShow, setWillKeyboardShow] = useState(false); useEffect(() => { - const keyboardWillShowListener = Keyboard.addListener('keyboardWillShow', () => { + const keyboardWillShowListener = KeyboardEvents.addListener('keyboardWillShow', () => { setWillKeyboardShow(true); }); - const keyboardWillHideListener = Keyboard.addListener('keyboardWillHide', () => { + const keyboardWillHideListener = KeyboardEvents.addListener('keyboardWillHide', () => { setWillKeyboardShow(false); }); return () => { diff --git a/src/hooks/useWindowDimensions/index.native.ts b/src/hooks/useWindowDimensions/index.native.ts index 9adfffe5faca..bb4b72442471 100644 --- a/src/hooks/useWindowDimensions/index.native.ts +++ b/src/hooks/useWindowDimensions/index.native.ts @@ -1,12 +1,13 @@ // eslint-disable-next-line no-restricted-imports -import {useWindowDimensions} from 'react-native'; +import {useSafeAreaFrame} from 'react-native-safe-area-context'; import type WindowDimensions from './types'; /** * A wrapper around React Native's useWindowDimensions hook. */ export default function (): WindowDimensions { - const {width: windowWidth, height: windowHeight} = useWindowDimensions(); + // we need to use `useSafeAreaFrame` instead of `useWindowDimensions` because of https://github.com/facebook/react-native/issues/41918 + const {width: windowWidth, height: windowHeight} = useSafeAreaFrame(); return { windowWidth, windowHeight, diff --git a/src/libs/NavBarManager/index.android.ts b/src/libs/NavBarManager/index.android.ts new file mode 100644 index 000000000000..81a4626bfb08 --- /dev/null +++ b/src/libs/NavBarManager/index.android.ts @@ -0,0 +1,11 @@ +import {NativeModules} from 'react-native'; +import type StartupTimer from './types'; +import type {NavBarButtonStyle} from './types'; + +const navBarManager: StartupTimer = { + setButtonStyle: (style: NavBarButtonStyle) => { + NativeModules.RNNavBarManager.setButtonStyle(style); + }, +}; + +export default navBarManager; diff --git a/src/libs/NavBarManager/index.ts b/src/libs/NavBarManager/index.ts new file mode 100644 index 000000000000..79c9ef85fdcd --- /dev/null +++ b/src/libs/NavBarManager/index.ts @@ -0,0 +1,7 @@ +import type NavBarManager from './types'; + +const navBarManager: NavBarManager = { + setButtonStyle: () => {}, +}; + +export default navBarManager; diff --git a/src/libs/NavBarManager/types.ts b/src/libs/NavBarManager/types.ts new file mode 100644 index 000000000000..443db391da9d --- /dev/null +++ b/src/libs/NavBarManager/types.ts @@ -0,0 +1,8 @@ +type NavBarButtonStyle = 'light' | 'dark'; + +type NavBarManager = { + setButtonStyle: (style: NavBarButtonStyle) => void; +}; + +export default NavBarManager; +export type {NavBarButtonStyle}; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 40b4742ca5de..53b8304effa0 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -16,11 +16,13 @@ import useOnboardingFlowRouter from '@hooks/useOnboardingFlow'; import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {READ_COMMANDS} from '@libs/API/types'; import HttpUtils from '@libs/HttpUtils'; import KeyboardShortcut from '@libs/KeyboardShortcut'; import Log from '@libs/Log'; +import NavBarManager from '@libs/NavBarManager'; import getCurrentUrl from '@libs/Navigation/currentUrl'; import getOnboardingModalScreenOptions from '@libs/Navigation/getOnboardingModalScreenOptions'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; @@ -225,6 +227,7 @@ const modalScreenListenersWithCancelSearch = { }; function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDAppliedToClient}: AuthScreensProps) { + const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); // We need to use isSmallScreenWidth for the root stack navigator @@ -254,6 +257,14 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie return initialReport?.reportID ?? ''; }); + useEffect(() => { + NavBarManager.setButtonStyle(theme.navigationBarButtonsStyle); + + return () => { + NavBarManager.setButtonStyle(CONST.NAVIGATION_BAR_BUTTONS_STYLE.LIGHT); + }; + }, [theme]); + useEffect(() => { const shortcutsOverviewShortcutConfig = CONST.KEYBOARD_SHORTCUTS.SHORTCUTS; const searchShortcutConfig = CONST.KEYBOARD_SHORTCUTS.SEARCH; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 06e4ed83d936..26806b0344a6 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -609,65 +609,63 @@ function IOURequestStepConfirmation({ shouldEnableMaxHeight={DeviceCapabilities.canUseTouchScreen()} testID={IOURequestStepConfirmation.displayName} > - {({safeAreaPaddingBottomStyle}) => ( - - - {isLoading && } - {!!gpsRequired && ( - setStartLocationPermissionFlow(false)} - onGrant={() => createTransaction(selectedParticipantList, true)} - onDeny={() => { - IOU.updateLastLocationPermissionPrompt(); - createTransaction(selectedParticipantList, false); - }} - /> - )} - + + {isLoading && } + {!!gpsRequired && ( + setStartLocationPermissionFlow(false)} + onGrant={() => createTransaction(selectedParticipantList, true)} + onDeny={() => { + IOU.updateLastLocationPermissionPrompt(); + createTransaction(selectedParticipantList, false); + }} /> - - )} + )} + + ); } diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index 91df4bd91bc0..223fc1c56818 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -152,6 +152,7 @@ const darkTheme = { }, statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, + navigationBarButtonsStyle: CONST.NAVIGATION_BAR_BUTTONS_STYLE.LIGHT, colorScheme: CONST.COLOR_SCHEME.DARK, } satisfies ThemeColors; diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index f73ac2d788ed..151388e77136 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -152,6 +152,7 @@ const lightTheme = { }, statusBarStyle: CONST.STATUS_BAR_STYLE.DARK_CONTENT, + navigationBarButtonsStyle: CONST.NAVIGATION_BAR_BUTTONS_STYLE.DARK, colorScheme: CONST.COLOR_SCHEME.LIGHT, } satisfies ThemeColors; diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts index 56de70b0fe5a..99c5b9d2ced8 100644 --- a/src/styles/theme/types.ts +++ b/src/styles/theme/types.ts @@ -1,4 +1,5 @@ import type {ValueOf} from 'type-fest'; +import type {NavBarButtonStyle} from '@libs/NavBarManager/types'; import type CONST from '@src/CONST'; import type {ColorScheme, StatusBarStyle} from '..'; @@ -106,6 +107,7 @@ type ThemeColors = { // Therefore, we need to define specific themes for these elements // e.g. the StatusBar displays either "light-content" or "dark-content" based on the theme statusBarStyle: StatusBarStyle; + navigationBarButtonsStyle: NavBarButtonStyle; colorScheme: ColorScheme; }; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index d196b3fdf09b..cd3bf67e1c13 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -5,6 +5,7 @@ import type {EdgeInsets} from 'react-native-safe-area-context'; import type {ValueOf} from 'type-fest'; import type ImageSVGProps from '@components/ImageSVG/types'; import * as Browser from '@libs/Browser'; +import getPlatform from '@libs/getPlatform'; import * as UserUtils from '@libs/UserUtils'; // eslint-disable-next-line no-restricted-imports import {defaultTheme} from '@styles/theme'; @@ -326,7 +327,7 @@ type SafeAreaPadding = { /** * Takes safe area insets and returns padding to use for a View */ -function getSafeAreaPadding(insets?: EdgeInsets, insetsPercentage: number = variables.safeInsertPercentage): SafeAreaPadding { +function getSafeAreaPadding(insets?: EdgeInsets, insetsPercentage: number = getPlatform() === CONST.PLATFORM.IOS ? variables.safeInsertPercentage : 1): SafeAreaPadding { return { paddingTop: insets?.top ?? 0, paddingBottom: (insets?.bottom ?? 0) * insetsPercentage, diff --git a/src/types/modules/react-native.d.ts b/src/types/modules/react-native.d.ts index a08e17c28f4a..c72d4bf2a653 100644 --- a/src/types/modules/react-native.d.ts +++ b/src/types/modules/react-native.d.ts @@ -16,6 +16,10 @@ type RNTextInputResetModule = { resetKeyboardInput: (nodeHandle: number | null) => void; }; +type RNNavBarManagerModule = { + setButtonStyle: (style: 'light' | 'dark') => void; +}; + declare module 'react-native' { interface TextInputFocusEventData extends TargetedEvent { text: string; @@ -43,6 +47,7 @@ declare module 'react-native' { HybridAppModule: HybridAppModule; StartupTimer: StartupTimer; RNTextInputReset: RNTextInputResetModule; + RNNavBarManager: RNNavBarManagerModule; EnvironmentChecker: EnvironmentCheckerModule; ShortcutManager: ShortcutManagerModule; } diff --git a/src/utils/keyboard.ts b/src/utils/keyboard.ts new file mode 100644 index 000000000000..ed1df0efc700 --- /dev/null +++ b/src/utils/keyboard.ts @@ -0,0 +1,33 @@ +import {KeyboardController, KeyboardEvents} from 'react-native-keyboard-controller'; + +let isVisible = false; + +KeyboardEvents.addListener('keyboardDidHide', () => { + isVisible = false; +}); + +KeyboardEvents.addListener('keyboardDidShow', () => { + isVisible = true; +}); + +// starting from react-native-keyboard-controller@1.15+ we can use `KeyboardController.dismiss()` directly +const dismiss = (): Promise => { + return new Promise((resolve) => { + if (!isVisible) { + resolve(); + + return; + } + + const subscription = KeyboardEvents.addListener('keyboardDidHide', () => { + resolve(undefined); + subscription.remove(); + }); + + KeyboardController.dismiss(); + }); +}; + +const utils = {dismiss}; + +export default utils;