diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt index e13c1fbbae501..ac27a69783077 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt @@ -2,17 +2,25 @@ package expo.modules.t3nativecontrols import android.content.Context import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.NavigationRailItemDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme @@ -20,11 +28,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import expo.modules.kotlin.AppContext import expo.modules.kotlin.modules.Module @@ -42,6 +54,7 @@ class T3WorkspaceNavigationModule : Module() { Prop("rail") { view: T3WorkspaceNavigationView, value: Boolean -> view.rail = value } Prop("dark") { view: T3WorkspaceNavigationView, value: Boolean -> view.dark = value } Prop("textScale") { view: T3WorkspaceNavigationView, value: Float -> view.textScale = value } + Prop("iconSize") { view: T3WorkspaceNavigationView, value: Float -> view.iconSize = value } Prop("containerColor") { view: T3WorkspaceNavigationView, value: Int -> view.containerColor = value } @@ -59,7 +72,7 @@ class T3WorkspaceNavigationModule : Module() { } } -/** One Material3 selection surface moves from below the list to its leading edge. */ +/** Compact windows use an icon pill; expanded windows retain the Material navigation rail. */ class T3WorkspaceNavigationView( context: Context, appContext: AppContext @@ -72,6 +85,7 @@ class T3WorkspaceNavigationView( var rail by mutableStateOf(false) var dark by mutableStateOf(false) var textScale by mutableStateOf(1f) + var iconSize by mutableStateOf(24f) var containerColor by mutableStateOf(0) var selectedColor by mutableStateOf(0) var indicatorColor by mutableStateOf(0) @@ -100,8 +114,12 @@ class T3WorkspaceNavigationView( } init { + clipChildren = false + clipToPadding = false addView( ComposeView(context).apply { + clipChildren = false + clipToPadding = false setViewCompositionStrategy( ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool ) @@ -136,29 +154,43 @@ class T3WorkspaceNavigationView( Spacer(Modifier.weight(1f)) } } else { - NavigationBar( + Surface( modifier = Modifier.fillMaxSize(), - containerColor = Color(containerColor), - tonalElevation = androidx.compose.ui.unit.Dp(0f), - windowInsets = WindowInsets(0), + shape = CircleShape, + color = Color(containerColor), + // Match the resting elevation of the Compose New thread FAB. + shadowElevation = 6.dp, ) { - tabs.forEach { tab -> - NavigationBarItem( - selected = selectedTab == tab, - onClick = { onTabSelected(mapOf("tab" to tab)) }, - icon = { tabIcon(tab) }, - label = { - val title = if (tab == "threads") "Chats" else "Pull requests" - Text(title, fontSize = (12 * textScale).sp) - }, - colors = NavigationBarItemDefaults.colors( - selectedIconColor = Color(selectedColor), - selectedTextColor = Color(selectedColor), - indicatorColor = Color(indicatorColor), - unselectedIconColor = Color(unselectedColor), - unselectedTextColor = Color(unselectedColor), - ), - ) + Row( + modifier = Modifier.fillMaxSize().selectableGroup(), + verticalAlignment = Alignment.CenterVertically, + ) { + tabs.forEach { tab -> + val selected = selectedTab == tab + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clip(CircleShape) + .selectable( + selected = selected, + role = Role.Tab, + onClick = { onTabSelected(mapOf("tab" to tab)) }, + ) + .padding(4.dp) + .background( + if (selected) Color(indicatorColor) else Color.Transparent, + CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + tabIcon( + tab, + Color(if (selected) selectedColor else unselectedColor), + Modifier.size(iconSize.dp), + ) + } + } } } } @@ -170,7 +202,11 @@ class T3WorkspaceNavigationView( } @Composable - private fun tabIcon(tab: String) { + private fun tabIcon( + tab: String, + tint: Color = LocalContentColor.current, + modifier: Modifier = Modifier + ) { val drawable = if (tab == "threads") { R.drawable.t3_chats } else { @@ -179,6 +215,8 @@ class T3WorkspaceNavigationView( Icon( painter = painterResource(drawable), contentDescription = if (tab == "threads") "Chats" else "Pull requests", + modifier = modifier, + tint = tint, ) } } diff --git a/apps/mobile/src/components/MaterialNewThreadButton.shared.tsx b/apps/mobile/src/components/MaterialNewThreadButton.shared.tsx index 2c887ce78a71c..a3fb535db8d15 100644 --- a/apps/mobile/src/components/MaterialNewThreadButton.shared.tsx +++ b/apps/mobile/src/components/MaterialNewThreadButton.shared.tsx @@ -9,6 +9,7 @@ export function MaterialNewThreadButton(props: { readonly onPress: () => void; readonly extended?: boolean; readonly expanded?: boolean; + readonly maxWidth?: number; readonly className?: string; readonly style?: StyleProp; }) { diff --git a/apps/mobile/src/components/MaterialScrollComposeButton.android.tsx b/apps/mobile/src/components/MaterialScrollComposeButton.android.tsx index 8c653e67ee9aa..a3262c46e3a7a 100644 --- a/apps/mobile/src/components/MaterialScrollComposeButton.android.tsx +++ b/apps/mobile/src/components/MaterialScrollComposeButton.android.tsx @@ -2,7 +2,6 @@ import { Box, ExtendedFloatingActionButton, Host, Icon, Text } from "@expo/ui/je import { defaultMinSize, fillMaxWidth, - graphicsLayer, height, onSizeChanged, size, @@ -16,6 +15,7 @@ import { resolveScaledTextRole } from "../lib/appearancePreferences"; /** Keep the animated width and icon positioning entirely inside Compose, not Yoga. */ export function MaterialScrollComposeButton(props: { readonly expanded: boolean; + readonly maxWidth?: number; readonly onPress: () => void; readonly className?: string; readonly style?: StyleProp; @@ -23,11 +23,11 @@ export function MaterialScrollComposeButton(props: { const { appearance, themeAppearance, themeVariables: colors } = useAppearancePreferences(); const typography = resolveScaledTextRole("footnote", appearance.baseFontSize); const { iconSize, fabSize } = useAndroidControlSizing(); - // Scale the native 56dp minimum; keep text and icons at their requested sizes. - const nativeSize = Math.max(56, fabSize); - const scale = fabSize / nativeSize; - const nativeIconSize = Math.round(iconSize / scale); - const [buttonWidth, setButtonWidth] = useState(nativeSize); + const [buttonWidth, setButtonWidth] = useState(fabSize); + // Treat measurements at the width limit as overflow, allowing for native pixel rounding. + const expanded = + props.expanded && + (props.maxWidth === undefined || Math.ceil(buttonWidth) < Math.floor(props.maxWidth)); const rememberWidth = useCallback(({ width }: { width: number }) => { setButtonWidth((previous) => Math.max(previous, width)); }, []); @@ -35,7 +35,11 @@ export function MaterialScrollComposeButton(props: { - + @@ -73,8 +71,8 @@ export function MaterialScrollComposeButton(props: { @@ -97,8 +95,8 @@ export function MaterialScrollComposeButton(props: { top: 0, bottom: 0, // Release the label area as soon as collapse starts, before native measurements arrive. - width: props.expanded ? buttonWidth * scale : fabSize, - borderRadius: 16 * scale, + width: expanded ? buttonWidth : fabSize, + borderRadius: 16, overflow: "hidden", }} /> diff --git a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx index 423c76d91ea3c..4a7f2425e9fb1 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx @@ -7,16 +7,21 @@ import { } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { MaterialNewThreadButton } from "../../components/MaterialNewThreadButton"; +import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; import type { AndroidHomeFabLayout as SharedAndroidHomeFabLayout } from "./AndroidHomeFab.shared"; import { useWorkspaceState } from "../../state/workspace"; import { MaterialFabScrollContext } from "./MaterialFabScrollContext"; import { updateMaterialFabScroll } from "./material-fab-scroll"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; +import { ANDROID_WORKSPACE_DOCK } from "../workspace/workspace-tabs-state"; export function AndroidHomeFabLayout(props: ComponentProps) { const insets = useSafeAreaInsets(); const { appearance } = useAppearancePreferences(); const { fontScale } = useWindowDimensions(); + const { scale } = useAndroidControlSizing(); + const workspace = useWorkspaceTabs(); const [layoutWidth, setLayoutWidth] = useState(null); const { state } = useWorkspaceState(); const [expanded, setExpanded] = useState(true); @@ -41,6 +46,15 @@ export function AndroidHomeFabLayout(props: ComponentProps {v2ListEmpty} diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx index b6cddfbd46391..74a9ecad5b41a 100644 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx +++ b/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx @@ -15,7 +15,7 @@ import { useAndroidControlSizing } from "../../components/useAndroidControlSizin import { androidWorkspaceNavigationColors } from "./android-workspace-navigation-colors"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; -import type { WorkspaceTab } from "./workspace-tabs-state"; +import { ANDROID_WORKSPACE_DOCK, type WorkspaceTab } from "./workspace-tabs-state"; export const ANDROID_WORKSPACE_RAIL_WIDTH = 80; @@ -24,6 +24,7 @@ interface NativeNavigationProps extends ViewProps { readonly selectedTab: WorkspaceTab; readonly dark: boolean; readonly textScale: number; + readonly iconSize: number; readonly containerColor: number; readonly selectedColor: number; readonly indicatorColor: number; @@ -47,7 +48,7 @@ export function AndroidWorkspaceNavigation(props: { const insets = useSafeAreaInsets(); const keyboardVisible = useKeyboardState((state) => state.isVisible); const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); - const { scale } = useAndroidControlSizing(); + const { scale, iconSize, fabSize } = useAndroidControlSizing(); const rail = Boolean(props.rail); const { containerColor, indicatorColor, selectedColor, unselectedColor } = androidWorkspaceNavigationColors(colors, rail); @@ -64,7 +65,7 @@ export function AndroidWorkspaceNavigation(props: { paddingTop: insets.top, paddingBottom: insets.bottom, } - : { paddingBottom: insets.bottom }), + : { borderRadius: 999 }), }} > ); diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx index 3e6f31cb59024..8003e5f0acf64 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { View } from "react-native"; -import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; +import { useKeyboardState } from "react-native-keyboard-controller"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { EmptyState } from "../../components/EmptyState"; @@ -12,19 +13,17 @@ import { MaterialPullRequestToolbar } from "../pull-requests/MaterialPullRequest import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { AndroidWorkspaceNavigation } from "./AndroidWorkspaceNavigation"; import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext"; +import { ANDROID_WORKSPACE_DOCK } from "./workspace-tabs-state"; export function WorkspaceRootScreen() { const { layout } = useAdaptiveWorkspaceLayout(); const workspace = useWorkspaceTabs()!; const insets = useSafeAreaInsets(); - const { fabClearance } = useAndroidControlSizing(); - const context = useMemo( - () => ({ - ...workspace, - bottomClearance: workspace.state.selectedTab === "threads" ? fabClearance + 16 : 16, - }), - [workspace, fabClearance], - ); + const keyboardVisible = useKeyboardState((keyboard) => keyboard.isVisible); + const { fabSize } = useAndroidControlSizing(); + const bottom = Math.max(insets.bottom, 16) + 16; + const bottomClearance = bottom + fabSize + 16; + const context = useMemo(() => ({ ...workspace, bottomClearance }), [workspace, bottomClearance]); // Split lists live beside the navigator; Home remains an empty detail pane. if (layout.usesSplitView) { @@ -49,21 +48,30 @@ export function WorkspaceRootScreen() { - - - {workspace.state.selectedTab === "threads" ? ( - - ) : ( - <> - - - - - - )} + + {workspace.state.selectedTab === "threads" ? ( + + ) : ( + <> + + + + + + )} + + {!keyboardVisible ? ( + + - - + ) : null} ); diff --git a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts b/apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts index 2216ed6b4d631..533fe79b42c47 100644 --- a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts +++ b/apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts @@ -27,7 +27,7 @@ function contrast(first: string, second: string) { function expectVisibleSelection(colors: ReturnType) { expect(contrast(colors.indicatorColor, colors.containerColor)).toBeGreaterThanOrEqual(1.5); - // Native labels sit below the indicator, while their icons sit within it. + // Rail labels sit outside the indicator; both layouts place selected icons within it. for (const surface of [colors.containerColor, colors.indicatorColor]) { expect(contrast(colors.selectedColor, surface)).toBeGreaterThanOrEqual(4.5); } diff --git a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts b/apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts index a43f7e574af2c..5890a9f376ef9 100644 --- a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts +++ b/apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts @@ -1,6 +1,5 @@ import { flattenThemeColor, - themeColorWithAlpha, themeColorWithContrast, type MobileThemeVariables, } from "../../lib/mobileTheme"; @@ -10,7 +9,7 @@ export function androidWorkspaceNavigationColors(colors: MobileThemeVariables, r const header = flattenThemeColor(colors["--color-header"], colors["--color-screen"]); const containerColor = rail ? header - : flattenThemeColor(themeColorWithAlpha(colors["--color-header-foreground"], 0.05), header); + : flattenThemeColor(colors["--color-secondary"], colors["--color-screen"]); const indicatorColor = themeColorWithContrast( flattenThemeColor(colors["--color-secondary"], containerColor), containerColor, @@ -23,6 +22,11 @@ export function androidWorkspaceNavigationColors(colors: MobileThemeVariables, r flattenThemeColor(colors["--color-secondary-foreground"], indicatorColor), [indicatorColor, containerColor], ), - unselectedColor: flattenThemeColor(colors["--color-header-foreground"], containerColor), + unselectedColor: rail + ? flattenThemeColor(colors["--color-header-foreground"], containerColor) + : themeColorWithContrast( + flattenThemeColor(colors["--color-secondary-foreground"], containerColor), + containerColor, + ), }; } diff --git a/apps/mobile/src/features/workspace/workspace-tabs-state.ts b/apps/mobile/src/features/workspace/workspace-tabs-state.ts index 6cc70362a1ff9..050e2dfa96d51 100644 --- a/apps/mobile/src/features/workspace/workspace-tabs-state.ts +++ b/apps/mobile/src/features/workspace/workspace-tabs-state.ts @@ -84,6 +84,7 @@ export function workspaceTabsReducer( export const WORKSPACE_TOOLBAR = { height: 48, expandedGap: 10, edge: 16, gap: 8 }; export const WORKSPACE_DOCK = { height: 48, tabsWidth: 176, gap: 12, bottomGap: 6 }; +export const ANDROID_WORKSPACE_DOCK = { tabsWidth: 128, edge: 20, gap: 12 }; export function workspaceToolbarHeight(mode: WorkspaceSearchMode): number { return mode === "expanded"