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 @@ -3,16 +3,12 @@ package expo.modules.t3nativecontrols
import android.content.Context
import android.view.ViewGroup
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Icon
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.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
Expand All @@ -39,7 +35,6 @@ class T3WorkspaceNavigationModule : Module() {
Prop("selectedTab") { view: T3WorkspaceNavigationView, value: String ->
view.selectedTab = value
}
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("containerColor") { view: T3WorkspaceNavigationView, value: Int ->
Expand All @@ -59,7 +54,7 @@ class T3WorkspaceNavigationModule : Module() {
}
}

/** One Material3 selection surface moves from below the list to its leading edge. */
/** Material3 bottom navigation for the compact workspace. */
class T3WorkspaceNavigationView(
context: Context,
appContext: AppContext
Expand All @@ -69,7 +64,6 @@ class T3WorkspaceNavigationView(

private val onTabSelected by EventDispatcher()
var selectedTab by mutableStateOf("threads")
var rail by mutableStateOf(false)
var dark by mutableStateOf(false)
var textScale by mutableStateOf(1f)
var containerColor by mutableStateOf(0)
Expand Down Expand Up @@ -108,58 +102,29 @@ class T3WorkspaceNavigationView(
setContent {
MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme()) {
val tabs = listOf("threads", "pullRequests")
if (rail) {
NavigationRail(
modifier = Modifier.fillMaxSize(),
containerColor = Color(containerColor),
windowInsets = WindowInsets(0),
) {
Spacer(Modifier.weight(1f))
tabs.forEach { tab ->
NavigationRailItem(
selected = selectedTab == tab,
onClick = { onTabSelected(mapOf("tab" to tab)) },
icon = { tabIcon(tab) },
label = {
val title = if (tab == "threads") "Chats" else "PRs"
Text(title, fontSize = (12 * textScale).sp)
},
colors = NavigationRailItemDefaults.colors(
selectedIconColor = Color(selectedColor),
selectedTextColor = Color(selectedColor),
indicatorColor = Color(indicatorColor),
unselectedIconColor = Color(unselectedColor),
unselectedTextColor = Color(unselectedColor),
),
)
}
Spacer(Modifier.weight(1f))
}
} else {
NavigationBar(
modifier = Modifier.fillMaxSize(),
containerColor = Color(containerColor),
tonalElevation = androidx.compose.ui.unit.Dp(0f),
windowInsets = WindowInsets(0),
) {
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),
),
)
}
NavigationBar(
modifier = Modifier.fillMaxSize(),
containerColor = Color(containerColor),
tonalElevation = androidx.compose.ui.unit.Dp(0f),
windowInsets = WindowInsets(0),
) {
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),
),
)
}
}
}
Expand Down
9 changes: 6 additions & 3 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
import { useResolveClassNames } from "uniwind";

import { AppText as Text } from "./components/ui/AppText";
import { androidWorkspaceDestinationAnimation } from "./lib/android-workspace-navigation";
import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle";
import { WorkspaceRootScreen } from "./features/workspace/WorkspaceRootScreen";
import { AndroidWorkspaceTabsProvider } from "./features/workspace/AndroidWorkspaceTabsProvider";
Expand Down Expand Up @@ -1007,15 +1008,17 @@ function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName:
export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) {
const { width, height } = useWindowDimensions();
const nativeMetrics = useNativeLayoutMetrics();
const usesWorkspaceFlowScreens =
Platform.OS === "android" || deriveLayout({ width, height, nativeMetrics }).usesSplitView;
const usesSplitView = deriveLayout({ width, height, nativeMetrics }).usesSplitView;
const usesWorkspaceFlowScreens = Platform.OS === "android" || usesSplitView;

return (
<Navigator
screenLayout={GuardedScreenLayout}
screenOptions={({ route }) => {
if (route.name !== "SettingsSheet" && route.name !== "NewTaskSheet") {
return {};
return Platform.OS === "android"
? { animation: androidWorkspaceDestinationAnimation(route.name, usesSplitView) }
: {};
}

return usesWorkspaceFlowScreens
Expand Down
65 changes: 48 additions & 17 deletions apps/mobile/src/components/CompactBrandTitle.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../native/NativeWorkspaceColumns";
import Constants from "expo-constants";
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
import { useState } from "react";
import { Platform, View } from "react-native";

import { AppText as Text } from "./ui/AppText";
Expand All @@ -18,20 +19,37 @@ export function CompactBrandTitle(
props: {
readonly allowFontScaling?: boolean;
readonly accessibilityLabel?: string;
/**
* Fills the parent row and drops the stage badge when the full title does not fit.
* Narrow Android sidebar headers otherwise draw their actions over the badge.
*/
readonly fit?: boolean;
} = {},
) {
const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant);
const titleOffset = brandTitleOffset();
const { scale } = useAndroidControlSizing();
const [availableWidth, setAvailableWidth] = useState<number | null>(null);
// Measured only while the badge shows, so hiding it does not make it look like it fits.
const [fullWidth, setFullWidth] = useState<number | null>(null);
const showStage =
!props.fit || availableWidth === null || fullWidth === null || fullWidth <= availableWidth;

return (
const title = (
<View
onLayout={
props.fit && showStage ? (event) => setFullWidth(event.nativeEvent.layout.width) : undefined
}
aria-level={1}
accessibilityLabel={props.accessibilityLabel ?? "T3 Code, Threads"}
accessible
role="heading"
className="flex-row items-center gap-1.5"
style={[{ marginLeft: titleOffset }, Platform.OS === "android" && { gap: 5.25 * scale }]}
style={[
{ marginLeft: titleOffset },
Platform.OS === "android" && { gap: 5.25 * scale },
props.fit && { flexShrink: 0 },
]}
>
<T3Wordmark colorClassName="accent-icon" height={Math.round(15 * scale)} />
<Text
Expand All @@ -41,23 +59,36 @@ export function CompactBrandTitle(
>
Code
</Text>
<View
className="rounded-full bg-subtle px-1.5 py-0.5"
style={
Platform.OS === "android"
? { paddingHorizontal: 5.25 * scale, paddingVertical: 1.75 * scale }
: undefined
}
>
<Text
allowFontScaling={props.allowFontScaling}
className="font-t3-bold text-foreground-muted uppercase"
style={{ fontSize: 9 * scale, letterSpacing: 0.9 * scale }}
{showStage ? (
<View
className="rounded-full bg-subtle px-1.5 py-0.5"
style={
Platform.OS === "android"
? { paddingHorizontal: 5.25 * scale, paddingVertical: 1.75 * scale }
: undefined
}
>
{stageLabel}
</Text>
</View>
<Text
allowFontScaling={props.allowFontScaling}
className="font-t3-bold text-foreground-muted uppercase"
style={{ fontSize: 9 * scale, letterSpacing: 0.9 * scale }}
>
{stageLabel}
</Text>
</View>
) : null}
</View>
);

return props.fit ? (
<View
className="min-w-0 flex-1 flex-row items-center"
onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)}
>
{title}
</View>
) : (
title
);
}

Expand Down
23 changes: 11 additions & 12 deletions apps/mobile/src/components/ui/NativeSwitch.android.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,34 @@
import { NativeThemeHost } from "./NativeThemeHost.android";
import { Switch as ComposeSwitch } from "@expo/ui/jetpack-compose";
import { View } from "react-native";
import { Pressable, View } from "react-native";
import type { ThemedSwitchProps } from "./NativeSwitch.types";

import { useControlTheme } from "./useControlTheme";

/** Material's native switch, with the same palette and accessibility contract as our RN controls. */
/**
* Material's native switch, with the same palette and accessibility contract as our RN controls.
* Compose only draws it. The RN Pressable owns the touch so it wins over a pressable parent row;
* with Compose handling touches, a tap on the switch ran the row's onPress instead.
*/
export function NativeSwitch(props: ThemedSwitchProps) {
const { colors } = useControlTheme();
const toggle = () => {
if (!props.disabled) props.onValueChange?.(!props.value);
};

return (
<View
accessible
<Pressable
disabled={props.disabled}
onPress={() => props.onValueChange?.(!props.value)}
accessibilityLabel={props.accessibilityLabel}
accessibilityHint={props.accessibilityHint}
accessibilityRole="switch"
accessibilityState={{ checked: Boolean(props.value), disabled: Boolean(props.disabled) }}
accessibilityActions={[{ name: "activate", label: "Toggle" }]}
onAccessibilityAction={toggle}
style={props.style}
testID={props.testID}
>
<View importantForAccessibility="no-hide-descendants">
<View importantForAccessibility="no-hide-descendants" pointerEvents="none">
<NativeThemeHost ignoreSafeAreaKeyboardInsets style={{ width: 52, height: 48 }}>
<ComposeSwitch
value={Boolean(props.value)}
enabled={!props.disabled}
onCheckedChange={props.onValueChange ?? undefined}
colors={{
checkedTrackColor: colors["--color-switch-active-track"],
checkedThumbColor: colors["--color-switch-active-thumb"],
Expand All @@ -41,6 +40,6 @@ export function NativeSwitch(props: ThemedSwitchProps) {
/>
</NativeThemeHost>
</View>
</View>
</Pressable>
);
}
22 changes: 20 additions & 2 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { use, useCallback, useEffect, useMemo, useRef, useState, type ComponentP
import {
Platform,
ScrollView,
useWindowDimensions,
View,
type ScrollViewProps,
type GestureResponderEvent,
Expand All @@ -30,6 +31,10 @@ import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSw
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { cn } from "../../lib/cn";
import {
ANDROID_READING_MAX_WIDTH,
deriveCenteredContentHorizontalPadding,
} from "../../lib/layout";
import { EmptyState } from "../../components/ui/EmptyState";
import { FloatingActionButton } from "../../components/ui/FloatingActionButton";
import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel";
Expand Down Expand Up @@ -264,6 +269,17 @@ export function HomeScreen(props: HomeScreenProps) {
const contentSideInsets = usesNativeWorkspaceColumns
? (columnMetrics ?? screenMetrics)?.safeArea
: undefined;
// Android lists that span a wide window (foldables in portrait) keep the
// reading width, so rows stay clear of the edges and the camera cutout.
const { width: windowWidth } = useWindowDimensions();
const androidListGutter =
Platform.OS === "android"
? deriveCenteredContentHorizontalPadding({
viewportWidth: windowWidth,
maxContentWidth: ANDROID_READING_MAX_WIDTH,
minimumPadding: 0,
})
: 0;
const iosBottomClearance = Math.max(
columnMetrics?.safeArea.bottom ?? insets.bottom,
24,
Expand Down Expand Up @@ -1027,8 +1043,10 @@ export function HomeScreen(props: HomeScreenProps) {
{...scrollGateHandlers}
scrollEventThrottle={16}
contentContainerStyle={{
paddingLeft: (contentSideInsets?.left ?? 0) + (primaryColumn ? 8 : 0),
paddingRight: (contentSideInsets?.right ?? 0) + (primaryColumn ? 8 : 0),
paddingLeft:
(contentSideInsets?.left ?? 0) + (primaryColumn ? 8 : 0) + androidListGutter,
paddingRight:
(contentSideInsets?.right ?? 0) + (primaryColumn ? 8 : 0) + androidListGutter,
paddingBottom:
Platform.OS === "ios"
? iosBottomClearance
Expand Down
11 changes: 10 additions & 1 deletion apps/mobile/src/features/home/ThreadListToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react";
import { useNavigation, useNavigationState } from "@react-navigation/native";
import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";
import { useWorkspaceListNavigation } from "../workspace/useAndroidWorkspaceNavigation";
import {
BackHandler,
Keyboard,
Expand Down Expand Up @@ -47,6 +48,7 @@ export function ThreadListToolbar(props: {
const { height: toolbarHeight, ...headerPadding } = useToolbarLayout();
const { state } = useWorkspaceState();
const { onRequestVisibility, onSearchQueryChange } = props;
const showPullRequests = useWorkspaceListNavigation("pullRequests");
const searchRef = useRef<TextInputInstance>(null);
const [searchOpen, setSearchOpen] = useState(false);
const searching = searchOpen || props.searchQuery.length > 0;
Expand Down Expand Up @@ -121,9 +123,16 @@ export function ThreadListToolbar(props: {
<WorkspaceConnectionTitle
grow
onPress={props.onOpenEnvironments}
brand={<CompactBrandTitle allowFontScaling={false} />}
brand={<CompactBrandTitle allowFontScaling={false} fit />}
/>
</View>
{props.sidebar && workspace ? (
<AndroidHeaderIconButton
accessibilityLabel="Show pull requests"
icon="arrow.triangle.pull"
onPress={showPullRequests}
/>
) : null}
<AndroidHeaderIconButton
accessibilityLabel="Search threads"
icon="magnifyingglass"
Expand Down
Loading
Loading