From 6fed7229d91d9b4a42ace19014626a5e1d860db4 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 14 Sep 2026 17:48:14 -0700 Subject: [PATCH 01/39] fix(mobile): unify Material You pane framing on foldables --- .../archive/ArchivedThreadsScreen.tsx | 68 ++++++++++--------- .../connection/ConnectionsNewRouteScreen.tsx | 49 ++++++------- .../SettingsDiagnosticsRouteScreen.tsx | 5 +- .../layout/AdaptiveWorkspaceLayout.tsx | 14 +++- .../SettingsAppearanceRouteScreen.tsx | 17 ++--- .../SettingsClientStorageRouteScreen.tsx | 5 +- .../SettingsEnvironmentsRouteScreen.tsx | 45 ++++++------ .../SettingsOpenSourceLicensesRouteScreen.tsx | 46 +++---------- .../SettingsProjectGroupingRouteScreen.tsx | 17 ++--- .../features/settings/SettingsRouteScreen.tsx | 21 +++--- .../AppearancePreferencesProvider.tsx | 8 ++- .../settings/components/SettingsScreen.tsx | 51 ++++++++++++++ .../threads/ThreadNavigationSidebar.tsx | 12 +++- .../features/threads/ThreadRouteScreen.tsx | 1 - .../src/features/usage/UsageLimitsPooled.tsx | 16 ++--- .../src/features/usage/UsageRouteScreen.tsx | 17 +---- apps/mobile/src/lib/materialYouTheme.test.ts | 14 ++++ .../src/lib/mobileThemeVariables.test.ts | 18 ++++- apps/mobile/src/lib/mobileThemeVariables.ts | 18 ++++- 19 files changed, 245 insertions(+), 197 deletions(-) create mode 100644 apps/mobile/src/features/settings/components/SettingsScreen.tsx diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 97f9c5b69d0f..90dd54d29694 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -40,6 +40,8 @@ import { NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; +import { SettingsScreenContent } from "../settings/components/SettingsScreen"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; export interface ArchivedThreadsHeaderEnvironment { readonly environmentId: EnvironmentId; @@ -74,6 +76,7 @@ function ArchivedThreadsHeader(props: { readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; }) { const { width } = useWindowDimensions(); + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest"; @@ -146,6 +149,7 @@ function ArchivedThreadsHeader(props: { className="border-b border-header-border bg-header px-3 pb-2.5" style={{ paddingTop: Math.max(insets.top, 12), + borderBottomWidth: materialYouStyleLayoutActive ? 0 : undefined, }} > @@ -646,37 +650,39 @@ export function ArchivedThreadsScreen(props: { sortOrder={props.sortOrder} /> - - item.kind} - keyboardDismissMode="on-drag" - keyboardShouldPersistTaps="handled" - keyExtractor={(item) => item.key} - ListEmptyComponent={listEmptyComponent} - ListHeaderComponent={ - props.error ? : null - } - onScrollBeginDrag={() => openSwipeableRef.current?.close()} - refreshControl={ - - } - renderItem={renderListItem} - showsVerticalScrollIndicator={false} - /> - + + + item.kind} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + keyExtractor={(item) => item.key} + ListEmptyComponent={listEmptyComponent} + ListHeaderComponent={ + props.error ? : null + } + onScrollBeginDrag={() => openSwipeableRef.current?.close()} + refreshControl={ + + } + renderItem={renderListItem} + showsVerticalScrollIndicator={false} + /> + + ); } diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index 1645844d86b3..213791c6337d 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -7,7 +7,7 @@ import { Alert, Linking, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { SettingsScreen } from "../settings/components/SettingsScreen"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ErrorBanner } from "../../components/ErrorBanner"; import { ConnectionSheetButton } from "./ConnectionSheetButton"; @@ -181,33 +181,26 @@ export function ConnectionsNewRouteScreen({ }, [connectAndClose, routePairingUrl, shouldAutoConnect]); return ( - + { + if (showScanner) { + closeScanner(); + } else { + void openScanner(); + } + }, + }, + ]} + > - {Platform.OS === "android" ? ( - navigation.goBack()} - actions={[ - { - accessibilityLabel: showScanner ? "Close scanner" : "Scan QR code", - icon: showScanner ? "xmark" : "camera", - onPress: () => { - if (showScanner) { - closeScanner(); - } else { - void openScanner(); - } - }, - }, - ]} - /> - ) : ( + {Platform.OS !== "android" ? ( - )} + ) : null} - + ); } diff --git a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx index a55bb8f859e6..b10b6d631216 100644 --- a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx +++ b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx @@ -7,6 +7,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { SettingsScreen } from "../settings/components/SettingsScreen"; import { SettingsSection } from "../settings/components/SettingsSection"; import { formatStartupCrashReport, @@ -71,7 +72,7 @@ export function SettingsDiagnosticsRouteScreen() { }; return ( - + - + ); } diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index c030e49c2b77..7d7be487005a 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -511,9 +511,13 @@ function AdaptiveWorkspaceLayoutContent( const contentSettledWidth = layout.usesSplitView ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth) : null; + const contentLeadingInset = materialYouStyleLayoutActive && panes.primarySidebarVisible ? 8 : 0; const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth); const renderedContentWidth = useDerivedValue(() => - Math.max(0, width - renderedSidebarWidth.value - renderedInspectorWidth.value), + Math.max( + 0, + width - renderedSidebarWidth.value - renderedInspectorWidth.value - contentLeadingInset, + ), ); const handleSelectThread = useCallback( @@ -590,7 +594,13 @@ function AdaptiveWorkspaceLayoutContent( - {Platform.OS === "android" ? ( - <> - - navigation.goBack()} /> - - ) : null} + - + ); } diff --git a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx index bf66574717eb..7122fdb1f344 100644 --- a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx @@ -16,6 +16,7 @@ import { import { useServerConfigs } from "../../state/entities"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsSection } from "./components/SettingsSection"; +import { SettingsScreen } from "./components/SettingsScreen"; export function SettingsClientStorageRouteScreen() { const insets = useSafeAreaInsets(); @@ -71,7 +72,7 @@ export function SettingsClientStorageRouteScreen() { }; return ( - + - + ); } diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 653107d22bb3..4b353faa701b 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -1,4 +1,4 @@ -import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { NativeHeaderToolbar } from "../../native/StackHeader"; import { useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentId } from "@t3tools/contracts"; @@ -7,7 +7,7 @@ import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; -import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { SettingsScreen } from "./components/SettingsScreen"; import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings"; @@ -79,28 +79,21 @@ export function SettingsEnvironmentsRouteScreen() { ); return ( - - {Platform.OS === "android" ? ( - <> - {/* Android renders its own in-screen header instead of the native bar. */} - - navigation.goBack()} - actions={[ - { - accessibilityLabel: "Add environment", - icon: "plus", - onPress: () => - navigation.navigate("SettingsSheet", { - screen: "SettingsContent", - params: { screen: "SettingsEnvironmentNew" }, - }), - }, - ]} - /> - - ) : ( + + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironmentNew" }, + }), + }, + ]} + > + {Platform.OS !== "android" ? ( - )} + ) : null} - + ); } diff --git a/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx index 48be8d4408dc..e5d18c57a4a9 100644 --- a/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx @@ -8,13 +8,12 @@ import { type ThirdPartyLicenseEntry, } from "@t3tools/shared/thirdPartyLicenses"; import { useCallback, useMemo, useState } from "react"; -import { Linking, Platform, Pressable, ScrollView, View } from "react-native"; +import { Linking, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { SettingsScreen } from "./components/SettingsScreen"; import { getMobileThirdPartyLicenses } from "./mobileThirdPartyLicenses"; function useMobileThirdPartyLicenses() { @@ -91,30 +90,18 @@ export function SettingsOpenSourceLicensesRouteScreen() { if (!manifest) { return ( - - {Platform.OS === "android" ? ( - <> - - navigation.goBack()} /> - - ) : null} + License notices are unavailable in this build. - + ); } return ( - - {Platform.OS === "android" ? ( - <> - - navigation.goBack()} /> - - ) : null} + - + ); } type LicenseDetailProps = StaticScreenProps<{ readonly entryKey: string }>; export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailProps) { - const navigation = useNavigation(); const insets = useSafeAreaInsets(); const manifest = useMobileThirdPartyLicenses(); const entry = manifest @@ -173,30 +159,18 @@ export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailPro if (!entry) { return ( - - {Platform.OS === "android" ? ( - <> - - navigation.goBack()} /> - - ) : null} + This license notice is unavailable. - + ); } return ( - - {Platform.OS === "android" ? ( - <> - - navigation.goBack()} /> - - ) : null} + - + ); } diff --git a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx index 951168fefcf6..ef5de5223618 100644 --- a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx @@ -1,14 +1,12 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import { useNavigation } from "@react-navigation/native"; import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; -import { Platform, Pressable, ScrollView, View } from "react-native"; +import { Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { SettingsScreen } from "./components/SettingsScreen"; import { mobileProjectGroupingModePatch, resolveMobileProjectGroupingSettings, @@ -39,7 +37,6 @@ const GROUPING_OPTIONS: ReadonlyArray<{ ]; export function SettingsProjectGroupingRouteScreen() { - const navigation = useNavigation(); const insets = useSafeAreaInsets(); const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); @@ -49,13 +46,7 @@ export function SettingsProjectGroupingRouteScreen() { : null; return ( - - {Platform.OS === "android" ? ( - <> - - navigation.goBack()} /> - - ) : null} + - + ); } diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index e67350f3d0f1..b022fa224c65 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -18,7 +18,6 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; import { @@ -58,6 +57,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; +import { SettingsScreen } from "./components/SettingsScreen"; import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; @@ -79,17 +79,16 @@ function useDeviceRegistered(): boolean { export function SettingsRouteScreen() { const navigation = useNavigation(); + const content = hasCloudPublicConfig() ? ( + + ) : ( + + ); return ( <> - {Platform.OS === "android" ? ( - <> - {/* Android renders its own in-screen header instead of the native bar. */} - - navigation.goBack()} /> - - ) : ( + {Platform.OS !== "android" ? ( + ) : null} + {Platform.OS === "android" ? ( + {content} + ) : ( + content )} - {hasCloudPublicConfig() ? : } ); } diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index e6622e3269e9..12f58094ce93 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -120,7 +120,11 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN }, []); const themeVariablesByAppearance = useMemo(() => { const resolve = (appearance: MobileThemeAppearance) => { - const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance); + const base = getMobileThemeRuntimeVariables( + themeIds[appearance], + appearance, + materialYouStyleLayoutActive, + ); return themeIds[appearance] === "material-you" && systemColorPalettes ? materialYouPaletteToMobileThemeVariables( systemColorPalettes[appearance], @@ -130,7 +134,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN : base; }; return { light: resolve("light"), dark: resolve("dark") }; - }, [themeIds, systemColorPalettes]); + }, [themeIds, systemColorPalettes, materialYouStyleLayoutActive]); const themeVariables = themeVariablesByAppearance[themeAppearance]; const activeThemeName = getMobileUniwindThemeName(themeId, themeAppearance); const { baseFontSize, codeFontSize, codeWordBreak, terminalFontSize } = preferences; diff --git a/apps/mobile/src/features/settings/components/SettingsScreen.tsx b/apps/mobile/src/features/settings/components/SettingsScreen.tsx new file mode 100644 index 000000000000..036a8fb78138 --- /dev/null +++ b/apps/mobile/src/features/settings/components/SettingsScreen.tsx @@ -0,0 +1,51 @@ +import { useNavigation } from "@react-navigation/native"; +import type { ComponentProps, ReactNode } from "react"; +import { Platform, View } from "react-native"; + +import { AndroidScreenHeader } from "../../../components/AndroidScreenHeader"; +import { NativeStackScreenOptions } from "../../../native/StackHeader"; +import { useAppearancePreferences } from "../appearance/AppearancePreferencesProvider"; + +/** Clips scrolling settings content below the header, leaving the shared frame behind its corners. */ +export function SettingsScreenContent({ children }: { readonly children: ReactNode }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + if (!materialYouStyleLayoutActive) return children; + + return ( + + {children} + + ); +} + +export function SettingsScreen( + props: Pick, "title" | "actions" | "trailing"> & { + readonly children: ReactNode; + /** Preserve this route's native Android header when Material You layout is off. */ + readonly nativeAndroidHeader?: boolean; + }, +) { + const navigation = useNavigation(); + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + const showAndroidHeader = materialYouStyleLayoutActive || !props.nativeAndroidHeader; + + return ( + + {Platform.OS === "android" ? ( + <> + + {showAndroidHeader ? ( + navigation.goBack()} + hideBottomBorder={materialYouStyleLayoutActive} + /> + ) : null} + + ) : null} + {props.children} + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index dce02cac1d4b..35213b4d9ad4 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1251,7 +1251,11 @@ function ThreadNavigationSidebarPane( return ( candidate.member.account.key === accountKey); const [revealed, setRevealed] = useState(false); return ( - - {Platform.OS === "android" ? ( - <> - - navigation.goBack()} /> - - ) : null} + )} - + ); } diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 59910c295547..2a7eb6b1c7a5 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -23,10 +23,9 @@ import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SegmentedControl } from "../../components/SegmentedControl"; -import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { SettingsScreen } from "../settings/components/SettingsScreen"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; import { SettingsSection } from "../settings/components/SettingsSection"; import { UsageDailyChart } from "./UsageDailyChart"; @@ -235,17 +234,7 @@ export function UsageRouteScreen() { }, [navigation, environmentFilter]); return ( - - {Platform.OS === "android" ? ( - <> - - navigation.goBack()} - trailing={environmentFilter} - /> - - ) : null} + - + ); } diff --git a/apps/mobile/src/lib/materialYouTheme.test.ts b/apps/mobile/src/lib/materialYouTheme.test.ts index af52c2635e36..7d397c6a3a5d 100644 --- a/apps/mobile/src/lib/materialYouTheme.test.ts +++ b/apps/mobile/src/lib/materialYouTheme.test.ts @@ -31,6 +31,20 @@ const palette: MaterialYouPalette = { }; describe("Material You system colors", () => { + it.each(["light", "dark"] as const)( + "keeps the system frame in %s with either layout", + (appearance) => { + const resolve = (rounded: boolean) => + materialYouPaletteToMobileThemeVariables( + palette, + appearance, + getMobileThemeRuntimeVariables("material-you", appearance, rounded), + ); + expect(resolve(true)).toEqual(resolve(false)); + expect(resolve(true)["--color-header"]).toBe(palette.surfaceContainerHigh); + }, + ); + it("overrides the selected theme without mutating its base variables", () => { const base = getMobileThemeRuntimeVariables("t3-code", "dark"); const snapshot = { ...base }; diff --git a/apps/mobile/src/lib/mobileThemeVariables.test.ts b/apps/mobile/src/lib/mobileThemeVariables.test.ts index 78c002b2d5ad..7d99b821ead2 100644 --- a/apps/mobile/src/lib/mobileThemeVariables.test.ts +++ b/apps/mobile/src/lib/mobileThemeVariables.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support"; -import { getMobileThemeVariables } from "./mobileTheme"; +import { getMobileThemeVariables, MOBILE_THEME_IDS, themeColorWithAlpha } from "./mobileTheme"; import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; describe("mobile theme runtime variables", () => { @@ -22,4 +22,20 @@ describe("mobile theme runtime variables", () => { getMobileThemeVariables("iris", "dark"), ); }); + + it.each(MOBILE_THEME_IDS)( + "keeps %s colors except for an opaque Material layout frame", + (themeId) => { + for (const appearance of ["light", "dark"] as const) { + const stock = getMobileThemeRuntimeVariables(themeId, appearance); + const rounded = getMobileThemeRuntimeVariables(themeId, appearance, true); + expect(rounded).toEqual({ + ...stock, + "--color-header": themeColorWithAlpha(stock["--color-drawer"], 1), + }); + expect(rounded["--color-header"]).toMatch(/^rgba\(\d+, \d+, \d+, 1\)$/); + expect(getMobileThemeRuntimeVariables(themeId, appearance, false)).toEqual(stock); + } + }, + ); }); diff --git a/apps/mobile/src/lib/mobileThemeVariables.ts b/apps/mobile/src/lib/mobileThemeVariables.ts index 30c38e6bf425..90c83dfdbe89 100644 --- a/apps/mobile/src/lib/mobileThemeVariables.ts +++ b/apps/mobile/src/lib/mobileThemeVariables.ts @@ -3,6 +3,7 @@ import defaultThemeVariables from "../../generated-uniwind-default-theme-variabl import { DEFAULT_MOBILE_THEME_ID, getMobileThemeVariables, + themeColorWithAlpha, type MobileThemeAppearance, type MobileThemeId, type MobileThemeVariables, @@ -20,8 +21,19 @@ const defaults = defaultThemeVariables as Readonly< export function getMobileThemeRuntimeVariables( themeId: MobileThemeId, appearance: MobileThemeAppearance, + materialYouStyleLayoutActive = false, ): MobileThemeVariables { - return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you" - ? defaults[appearance] - : getMobileThemeVariables(themeId, appearance); + const variables = + themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you" + ? defaults[appearance] + : getMobileThemeVariables(themeId, appearance); + if (!materialYouStyleLayoutActive) return variables; + + // Rounded panes share one opaque frame. Reuse the stock theme's sidebar tone + // so nested headers cannot accumulate translucency or flatten its surfaces. + // System colors replace this with their own surfaceContainerHigh afterwards. + return { + ...variables, + "--color-header": themeColorWithAlpha(variables["--color-drawer"], 1), + }; } From 8af949faf1d57c7e04d4c4e8a62fa98a1cafa14b Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 14 Sep 2026 18:16:44 -0700 Subject: [PATCH 02/39] fix(mobile): preserve contrast and inspector spacing in Material frames --- .../connection/ConnectionsNewRouteScreen.tsx | 9 ++++++++- .../layout/AdaptiveWorkspaceLayout.tsx | 14 +++++++++++-- .../settings/components/SettingsScreen.tsx | 10 ++++++++-- .../src/lib/mobileThemeVariables.test.ts | 20 ++++++++++++++++++- apps/mobile/src/lib/mobileThemeVariables.ts | 17 +++++++++------- 5 files changed, 57 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index 213791c6337d..44510772cd6d 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -1,6 +1,11 @@ import { CameraView, useCameraPermissions } from "expo-camera"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { + StackActions, + useNavigation, + useRoute, + type StaticScreenProps, +} from "@react-navigation/native"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Linking, Platform, ScrollView, View } from "react-native"; @@ -30,6 +35,7 @@ export function ConnectionsNewRouteScreen({ pairingConnectionError, } = useRemoteConnections(); const navigation = useNavigation(); + const routeName = useRoute().name; const params = route.params ?? {}; // Deep-link prefill exists for development automation only. A production // link must not arrive with attacker-chosen host and token already filled. @@ -182,6 +188,7 @@ export function ConnectionsNewRouteScreen({ return ( 0 ? 8 : 0; const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth); const renderedContentWidth = useDerivedValue(() => Math.max( 0, - width - renderedSidebarWidth.value - renderedInspectorWidth.value - contentLeadingInset, + width - + renderedSidebarWidth.value - + renderedInspectorWidth.value - + contentLeadingInset - + contentTrailingInset, ), ); @@ -598,7 +604,11 @@ function AdaptiveWorkspaceLayoutContent( ? { flex: 1, marginLeft: contentLeadingInset, - width: Math.max(0, contentSettledWidth - contentLeadingInset), + marginRight: contentTrailingInset, + width: Math.max( + 0, + contentSettledWidth - contentLeadingInset - contentTrailingInset, + ), } : { flex: 1 } } diff --git a/apps/mobile/src/features/settings/components/SettingsScreen.tsx b/apps/mobile/src/features/settings/components/SettingsScreen.tsx index 036a8fb78138..a0b317879cdc 100644 --- a/apps/mobile/src/features/settings/components/SettingsScreen.tsx +++ b/apps/mobile/src/features/settings/components/SettingsScreen.tsx @@ -23,6 +23,8 @@ export function SettingsScreen( readonly children: ReactNode; /** Preserve this route's native Android header when Material You layout is off. */ readonly nativeAndroidHeader?: boolean; + /** A native form sheet already owns its rounded outer frame. */ + readonly formSheet?: boolean; }, ) { const navigation = useNavigation(); @@ -40,12 +42,16 @@ export function SettingsScreen( actions={props.actions} trailing={props.trailing} onBack={() => navigation.goBack()} - hideBottomBorder={materialYouStyleLayoutActive} + hideBottomBorder={materialYouStyleLayoutActive && !props.formSheet} /> ) : null} ) : null} - {props.children} + {props.formSheet ? ( + props.children + ) : ( + {props.children} + )} ); } diff --git a/apps/mobile/src/lib/mobileThemeVariables.test.ts b/apps/mobile/src/lib/mobileThemeVariables.test.ts index 7d99b821ead2..6a507111d601 100644 --- a/apps/mobile/src/lib/mobileThemeVariables.test.ts +++ b/apps/mobile/src/lib/mobileThemeVariables.test.ts @@ -31,11 +31,29 @@ describe("mobile theme runtime variables", () => { const rounded = getMobileThemeRuntimeVariables(themeId, appearance, true); expect(rounded).toEqual({ ...stock, - "--color-header": themeColorWithAlpha(stock["--color-drawer"], 1), + "--color-header": themeColorWithAlpha( + stock[ + themeId === "t3-code" || themeId === "material-you" + ? "--color-card" + : "--color-drawer" + ], + 1, + ), }); expect(rounded["--color-header"]).toMatch(/^rgba\(\d+, \d+, \d+, 1\)$/); expect(getMobileThemeRuntimeVariables(themeId, appearance, false)).toEqual(stock); } }, ); + + it.each(["t3-code", "material-you"] as const)( + "keeps the %s default dark frame distinct from the rounded settings body", + (themeId) => { + const variables = getMobileThemeRuntimeVariables(themeId, "dark", true); + expect(variables["--color-header"]).toBe("rgba(23, 23, 23, 1)"); + expect(variables["--color-header"]).not.toBe( + themeColorWithAlpha(variables["--color-sheet-solid"], 1), + ); + }, + ); }); diff --git a/apps/mobile/src/lib/mobileThemeVariables.ts b/apps/mobile/src/lib/mobileThemeVariables.ts index 90c83dfdbe89..347a324833b3 100644 --- a/apps/mobile/src/lib/mobileThemeVariables.ts +++ b/apps/mobile/src/lib/mobileThemeVariables.ts @@ -23,17 +23,20 @@ export function getMobileThemeRuntimeVariables( appearance: MobileThemeAppearance, materialYouStyleLayoutActive = false, ): MobileThemeVariables { - const variables = - themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you" - ? defaults[appearance] - : getMobileThemeVariables(themeId, appearance); + const usesDefaultPalette = themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you"; + const variables = usesDefaultPalette + ? defaults[appearance] + : getMobileThemeVariables(themeId, appearance); if (!materialYouStyleLayoutActive) return variables; - // Rounded panes share one opaque frame. Reuse the stock theme's sidebar tone - // so nested headers cannot accumulate translucency or flatten its surfaces. + // Rounded panes share one opaque frame. The default dark drawer matches the + // settings body, so use its card tone to keep the rounded edge visible. // System colors replace this with their own surfaceContainerHigh afterwards. return { ...variables, - "--color-header": themeColorWithAlpha(variables["--color-drawer"], 1), + "--color-header": themeColorWithAlpha( + variables[usesDefaultPalette ? "--color-card" : "--color-drawer"], + 1, + ), }; } From d062650bfdbb605c36949e3d74e949685535c2ec Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Tue, 15 Sep 2026 01:16:47 -0700 Subject: [PATCH 03/39] feat(mobile): prototype search FAB toolbar --- apps/mobile/src/features/home/HomeHeader.tsx | 18 ++ apps/mobile/src/features/home/HomeScreen.tsx | 2 +- .../home/MaterialThreadListToolbar.tsx | 165 ++++++++++++++++++ .../threads/ThreadNavigationSidebar.tsx | 153 ++++++++-------- .../features/threads/thread-list-items.tsx | 19 +- .../features/threads/thread-list-v2-items.tsx | 22 ++- 6 files changed, 286 insertions(+), 93 deletions(-) create mode 100644 apps/mobile/src/features/home/MaterialThreadListToolbar.tsx diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index f67c2bff0f68..ec2d7afae7d3 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -23,6 +23,7 @@ import { } from "../layout/native-mail-search-toolbar"; import type { HomeProjectSortOrder } from "./homeThreadList"; import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle"; +import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar"; import { buildHomeListFilterMenu, type HomeListFilterMenuEnvironment, @@ -197,6 +198,23 @@ function AndroidHomeHeader(props: HomeHeaderProps) { [props], ); + if (materialYouStyleLayoutActive) { + return ( + <> + + + + ); + } + return ( <> diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 4e01ef6077da..5634ad9597d9 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1251,7 +1251,7 @@ export function HomeScreen(props: HomeScreenProps) { paddingBottom: Platform.OS === "ios" ? Math.max(insets.bottom, 24) + 24 + iosBottomToolbarClearance - : Math.max(insets.bottom, 16) + 88, + : Math.max(insets.bottom, 16) + (materialYouStyleLayoutActive ? 148 : 88), }} scrollIndicatorInsets={ Platform.OS === "ios" diff --git a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx new file mode 100644 index 000000000000..02fc0d42d50e --- /dev/null +++ b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx @@ -0,0 +1,165 @@ +import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react"; +import { + BackHandler, + Keyboard, + Pressable, + TextInput, + View, + type LayoutChangeEvent, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { MenuAction } from "@react-native-menu/menu"; + +import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader"; +import { CompactBrandTitle } from "../../components/CompactBrandTitle"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { SymbolView } from "../../components/AppSymbol"; +import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; +import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle"; + +/** One toolbar height for the compact list and expanded sidebar, including search. */ +export function MaterialThreadListToolbar(props: { + readonly searchQuery: string; + readonly onSearchQueryChange: (query: string) => void; + readonly filterActions: MenuAction[]; + readonly filterCustomized: boolean; + readonly onFilterAction: NonNullable["onPressAction"]>; + readonly onOpenSettings: () => void; + readonly onOpenEnvironments: () => void; + readonly sidebar?: boolean; + readonly onLayout?: (event: LayoutChangeEvent) => void; + readonly onRequestVisibility?: () => void; +}) { + const insets = useSafeAreaInsets(); + const { onRequestVisibility, onSearchQueryChange } = props; + const searchRef = useRef(null); + const [searchOpen, setSearchOpen] = useState(false); + const searching = searchOpen || props.searchQuery.length > 0; + const openSearch = useCallback(() => { + onRequestVisibility?.(); + setSearchOpen(true); + searchRef.current?.focus(); + return true; + }, [onRequestVisibility]); + useHardwareKeyboardCommand("focusSearch", openSearch); + + const closeSearch = useCallback(() => { + onSearchQueryChange(""); + setSearchOpen(false); + Keyboard.dismiss(); + }, [onSearchQueryChange]); + + useEffect(() => { + if (!searching) return; + const subscription = BackHandler.addEventListener("hardwareBackPress", () => { + closeSearch(); + return true; + }); + return () => subscription.remove(); + }, [closeSearch, searching]); + + const filterIcon = props.filterCustomized + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle"; + const searchField = ( + + + + {props.searchQuery.length > 0 ? ( + { + props.onSearchQueryChange(""); + searchRef.current?.focus(); + }} + > + + + ) : null} + + ); + + return ( + <> + + + {searching ? ( + <> + + {searchField} + + ) : ( + <> + } + /> + + + + + + )} + + + + {!searching ? ( + + + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 35213b4d9ad4..0cbfbb1b45e2 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -64,6 +64,7 @@ import { WorkspaceConnectionTitle, } from "../home/WorkspaceConnectionTitle"; import { SidebarHeaderActions } from "./sidebar-header-actions"; +import { MaterialThreadListToolbar } from "../home/MaterialThreadListToolbar"; import { SidebarFilterButton } from "./sidebar-filter-button"; import { createSidebarHeaderItems } from "./sidebar-native-header-items"; import { SidebarNavigationShell } from "./sidebar-navigation-shell"; @@ -149,7 +150,6 @@ function ThreadNavigationSidebarPane( const { materialYouStyleLayoutActive, themeVariables: materialTheme } = useAppearancePreferences(); const screenColor = materialTheme["--color-screen"]; - const mutedColor = materialTheme["--color-foreground-muted"]; const insets = useSafeAreaInsets(); const projects = useProjects(); @@ -748,7 +748,11 @@ function ThreadNavigationSidebarPane( // The sticky header (title row, search field, optional connection status) // is measured so the list inset always matches its real height — no // hardcoded per-variant constants. - const stickyHeaderHeight = measuredHeaderHeight ?? insets.top + SIDEBAR_STICKY_HEADER_HEIGHT; + const stickyHeaderHeight = + measuredHeaderHeight ?? + (materialYouStyleLayoutActive + ? Math.max(insets.top, 12) + 58 + : insets.top + SIDEBAR_STICKY_HEADER_HEIGHT); const topListInset = stickyHeaderHeight + 6; const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => { const nextHeight = Math.round(event.nativeEvent.layout.height); @@ -847,6 +851,7 @@ function ThreadNavigationSidebarPane( [], ); const focusSearch = useCallback(() => { + if (materialYouStyleLayoutActive) return false; const focus = () => { if (props.nativeChrome) { searchBarRef.current?.focus(); @@ -861,7 +866,7 @@ function ThreadNavigationSidebarPane( focus(); } return true; - }, [props.nativeChrome, props.onRequestVisibility, props.visible]); + }, [materialYouStyleLayoutActive, props.nativeChrome, props.onRequestVisibility, props.visible]); useHardwareKeyboardCommand("focusSearch", focusSearch); const renderListItem = useCallback( ({ item }: { readonly item: SidebarListItem }) => { @@ -1290,7 +1295,9 @@ function ThreadNavigationSidebarPane( { paddingBottom: Platform.OS === "android" - ? Math.max(insets.bottom, 16) + 88 - insets.bottom + ? Math.max(insets.bottom, 16) + + (materialYouStyleLayoutActive ? 148 : 88) - + insets.bottom : 16 + insets.bottom, paddingTop: materialYouStyleLayoutActive ? 6 : topListInset, }, @@ -1308,85 +1315,75 @@ function ThreadNavigationSidebarPane( - - - {/* Title slot doubles as the connection status surface: while an + {materialYouStyleLayoutActive ? ( + + ) : ( + + + {/* Title slot doubles as the connection status surface: while an environment reconnects, the brand fades to a status label in place (no layout shift in the list below). */} - - - - } - /> - - - - - + + + + } + /> + + + + + + - - - - - {materialYouStyleLayoutActive && props.searchQuery.length > 0 ? ( - props.onSearchQueryChange("")} - > - - - ) : null} + + + + - + )} ); } diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index cad6181e9623..095e3d933b7a 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -674,7 +674,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { materialYouStyleLayoutActive ? { backgroundColor: visuallySelected ? effectiveSelectedBackground : backgroundColor, - borderRadius: SIDEBAR_ROW_RADIUS, + borderRadius: 28, } : undefined } @@ -684,7 +684,12 @@ export const ThreadListRow = memo(function ThreadListRow(props: { }} > - + ({ opacity: pressed ? 0.7 : 1 }) @@ -965,8 +965,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { separates rows. The opaque screen background stays so swipe actions reveal behind the row. */ - {cardContent} - {props.showTrailingDivider !== false ? ( + + {cardContent} + + {!materialYouStyleLayoutActive && props.showTrailingDivider !== false ? ( ) : null} @@ -992,10 +994,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ? selectedBackgroundColor : pressed ? pressedBackgroundColor - : sidebarPane + : sidebarPane && !materialYouStyleLayoutActive ? drawerColor : screenColor, - borderRadius: SIDEBAR_V2_ROW_RADIUS, + borderRadius: materialYouStyleLayoutActive ? 28 : SIDEBAR_V2_ROW_RADIUS, }) : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) } @@ -1069,10 +1071,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { )} Date: Tue, 15 Sep 2026 08:48:10 -0700 Subject: [PATCH 04/39] feat(mobile): prototype extended new-thread FAB --- .../src/features/home/AndroidHomeFab.tsx | 14 +++++- .../home/MaterialThreadListToolbar.tsx | 44 +++++++++---------- 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/apps/mobile/src/features/home/AndroidHomeFab.tsx b/apps/mobile/src/features/home/AndroidHomeFab.tsx index 6957a6dab043..976060d73fe8 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.tsx @@ -3,6 +3,8 @@ import { Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../components/AppSymbol"; +import { AppText } from "../../components/AppText"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; /** * Android-only wrapper that overlays a bottom-right new-task FAB on a thread @@ -24,14 +26,19 @@ function AndroidHomeFab(props: { readonly children: ReactNode; }) { const insets = useSafeAreaInsets(); + const { materialYouStyleLayoutActive } = useAppearancePreferences(); return ( {props.children} + {materialYouStyleLayoutActive ? ( + New thread + ) : null} ); diff --git a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx index 02fc0d42d50e..a3c216f08685 100644 --- a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx @@ -125,16 +125,11 @@ export function MaterialThreadListToolbar(props: { onPress={props.onOpenEnvironments} brand={} /> - - - + - + {!searching ? ( - - - + + + + ) : null} From 764fd80aa5c1cde08a6fad809e761b1eb5bdad57 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Tue, 15 Sep 2026 09:46:06 -0700 Subject: [PATCH 05/39] fix(mobile): tighten extended preview FAB spacing --- apps/mobile/src/features/home/MaterialThreadListToolbar.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx index a3c216f08685..d58c45083177 100644 --- a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx @@ -139,7 +139,11 @@ export function MaterialThreadListToolbar(props: { )} - + {/* Match New thread's h-14, plus its 16dp bottom offset and an 8dp gap. */} + {!searching ? ( Date: Tue, 15 Sep 2026 10:03:18 -0700 Subject: [PATCH 06/39] fix(mobile): size extended preview FABs to 56dp --- apps/mobile/src/features/home/AndroidHomeFab.tsx | 8 +++++--- .../src/features/home/MaterialThreadListToolbar.tsx | 10 +++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/home/AndroidHomeFab.tsx b/apps/mobile/src/features/home/AndroidHomeFab.tsx index 976060d73fe8..0cf1acc42ee9 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.tsx @@ -36,7 +36,7 @@ function AndroidHomeFab(props: { onPress={props.onStartNewTask} className={ materialYouStyleLayoutActive - ? "absolute right-5 h-14 flex-row items-center justify-center gap-3 rounded-2xl bg-primary px-5 shadow-lg" + ? "absolute right-5 h-[56px] flex-row items-center justify-center gap-[8px] rounded-[16px] bg-primary px-[16px] shadow-lg" : "absolute right-5 size-14 items-center justify-center rounded-full bg-primary shadow-lg" } style={{ @@ -45,12 +45,14 @@ function AndroidHomeFab(props: { > {materialYouStyleLayoutActive ? ( - New thread + + New thread + ) : null} diff --git a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx index d58c45083177..5975b09a3f8a 100644 --- a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx @@ -139,10 +139,10 @@ export function MaterialThreadListToolbar(props: { )} - {/* Match New thread's h-14, plus its 16dp bottom offset and an 8dp gap. */} + {/* Sit 8dp above the 56dp extended New thread FAB. */} {!searching ? ( From 673d3fe7d65168bbb32a7bc3fdcd5157655e9f69 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 16 Sep 2026 00:05:44 -0700 Subject: [PATCH 07/39] fix(mobile): use native Material controls and adaptive settings --- .../src/components/AndroidScreenHeader.tsx | 103 +++++++-- apps/mobile/src/components/AppSymbol.tsx | 2 + .../components/MaterialIconButton.android.tsx | 44 ++++ .../src/components/MaterialIconButton.tsx | 20 ++ .../src/components/MaterialSwitch.android.tsx | 45 ++++ apps/mobile/src/components/MaterialSwitch.tsx | 1 + .../src/components/ScreenScrollView.tsx | 22 ++ apps/mobile/src/components/ThemedSwitch.tsx | 57 ++--- .../cloud/ConnectOnboardingRouteScreen.tsx | 3 +- .../connection/ConnectionsNewRouteScreen.tsx | 3 +- .../connection/ConnectionsRouteScreen.tsx | 3 +- .../SettingsDiagnosticsRouteScreen.tsx | 3 +- .../features/projects/AddProjectScreen.tsx | 3 +- .../SettingsAppearanceRouteScreen.tsx | 2 +- .../SettingsClientStorageRouteScreen.tsx | 3 +- .../SettingsEnvironmentsRouteScreen.tsx | 3 +- .../SettingsOpenSourceLicensesRouteScreen.tsx | 3 +- .../SettingsProjectGroupingRouteScreen.tsx | 3 +- .../features/settings/SettingsRouteScreen.tsx | 3 +- .../components/FontSizeSliderRow.android.tsx | 90 ++++++++ .../components/FontSizeSliderRow.shared.tsx | 210 +++++++++++++++++ .../components/FontSizeSliderRow.tsx | 211 +----------------- .../settings/components/SettingsRow.tsx | 65 ++++-- .../settings/components/SettingsSection.tsx | 20 +- .../settings/components/SettingsSwitchRow.tsx | 10 +- .../src/features/usage/UsageRouteScreen.tsx | 3 +- 26 files changed, 628 insertions(+), 307 deletions(-) create mode 100644 apps/mobile/src/components/MaterialIconButton.android.tsx create mode 100644 apps/mobile/src/components/MaterialIconButton.tsx create mode 100644 apps/mobile/src/components/MaterialSwitch.android.tsx create mode 100644 apps/mobile/src/components/MaterialSwitch.tsx create mode 100644 apps/mobile/src/components/ScreenScrollView.tsx create mode 100644 apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.android.tsx create mode 100644 apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.shared.tsx diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index f397c18a0a8f..01270e959987 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -1,10 +1,14 @@ -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView, type AppSymbolName } from "./AppSymbol"; import { AppText as Text } from "./AppText"; import { cn } from "../lib/cn"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { MaterialIconButton } from "./MaterialIconButton"; +import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; +import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; export interface AndroidHeaderAction { readonly accessibilityLabel: string; @@ -19,6 +23,8 @@ export function AndroidHeaderIconButton(props: { readonly onPress?: () => void; readonly disabled?: boolean; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + if (materialYouStyleLayoutActive) return ; return ( 2 + ? headerWidth >= 600 + ? 3 + : 1 + : actions.length; + const visibleActions = actions.slice(0, directCount); + const overflowActions = actions.slice(directCount); return ( setHeaderWidth(event.nativeEvent.layout.width)} + className={ + materialYouStyleLayoutActive + ? "border-b border-header-border bg-header px-2 pb-2" + : "border-b border-header-border bg-header px-3 pb-2.5" + } style={{ paddingTop: props.embedded ? 8 : Math.max(insets.top, 12), borderBottomWidth: props.hideBottomBorder ? 0 : undefined, }} > - + {props.onBack ? ( - - - + ) : ( + + + + ) ) : null} - + {props.title} {props.subtitle ? ( {props.subtitle} @@ -92,7 +139,7 @@ export function AndroidScreenHeader(props: { ) : null} - {props.actions?.map((action) => ( + {visibleActions.map((action) => ( ))} + {overflowActions.length > 0 ? ( + ({ + id: String(index), + title: action.accessibilityLabel, + attributes: { disabled: Boolean(action.disabled) }, + }))} + onPressAction={({ nativeEvent }) => + overflowActions[Number(nativeEvent.event)]?.onPress() + } + > + {(open) => ( + + )} + + ) : null} {props.trailing} diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index e5d0137ee406..8bc6254d7c0a 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -9,6 +9,7 @@ import IconAlertTriangle from "@tabler/icons-react-native/IconAlertTriangle"; import IconApps from "@tabler/icons-react-native/IconApps"; import IconArchive from "@tabler/icons-react-native/IconArchive"; import IconArrowBackUp from "@tabler/icons-react-native/IconArrowBackUp"; +import IconArrowLeft from "@tabler/icons-react-native/IconArrowLeft"; import IconArrowDownCircle from "@tabler/icons-react-native/IconArrowDownCircle"; import IconArrowRightCircle from "@tabler/icons-react-native/IconArrowRightCircle"; import IconArrowUp from "@tabler/icons-react-native/IconArrowUp"; @@ -97,6 +98,7 @@ import { withUniwind } from "uniwind"; const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "arrow.branch": IconGitBranch, + "arrow.left": IconArrowLeft, "arrow.clockwise": IconRefresh, "arrow.down.circle": IconArrowDownCircle, "arrow.right.circle": IconArrowRightCircle, diff --git a/apps/mobile/src/components/MaterialIconButton.android.tsx b/apps/mobile/src/components/MaterialIconButton.android.tsx new file mode 100644 index 000000000000..dfa83786e141 --- /dev/null +++ b/apps/mobile/src/components/MaterialIconButton.android.tsx @@ -0,0 +1,44 @@ +import { Host, IconButton } from "@expo/ui/jetpack-compose"; +import { size } from "@expo/ui/jetpack-compose/modifiers"; +import { View } from "react-native"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { SymbolView, type AppSymbolName } from "./AppSymbol"; + +export function MaterialIconButton(props: { + readonly accessibilityLabel: string; + readonly icon: AppSymbolName; + readonly onPress?: () => void; + readonly disabled?: boolean; +}) { + const { themeAppearance } = useAppearancePreferences(); + return ( + { + if (!props.disabled) props.onPress?.(); + }} + style={{ width: 48, height: 48 }} + > + + + + {null} + + + + + + + + ); +} diff --git a/apps/mobile/src/components/MaterialIconButton.tsx b/apps/mobile/src/components/MaterialIconButton.tsx new file mode 100644 index 000000000000..4cc19885d01b --- /dev/null +++ b/apps/mobile/src/components/MaterialIconButton.tsx @@ -0,0 +1,20 @@ +import { Pressable } from "react-native"; + +import { SymbolView, type AppSymbolName } from "./AppSymbol"; + +export function MaterialIconButton(props: { + readonly accessibilityLabel: string; + readonly icon: AppSymbolName; + readonly onPress?: () => void; + readonly disabled?: boolean; +}) { + return ( + + + + ); +} diff --git a/apps/mobile/src/components/MaterialSwitch.android.tsx b/apps/mobile/src/components/MaterialSwitch.android.tsx new file mode 100644 index 000000000000..b5aa6e11e15e --- /dev/null +++ b/apps/mobile/src/components/MaterialSwitch.android.tsx @@ -0,0 +1,45 @@ +import { Host, Switch as ComposeSwitch } from "@expo/ui/jetpack-compose"; +import { View } from "react-native"; +import type { ThemedSwitchProps } from "./ThemedSwitch"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; + +/** Material's native switch, with the same palette and accessibility contract as our RN controls. */ +export function MaterialSwitch(props: ThemedSwitchProps) { + const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const toggle = () => { + if (!props.disabled) props.onValueChange?.(!props.value); + }; + + return ( + + + + + + + + ); +} diff --git a/apps/mobile/src/components/MaterialSwitch.tsx b/apps/mobile/src/components/MaterialSwitch.tsx new file mode 100644 index 000000000000..b0e8346ced51 --- /dev/null +++ b/apps/mobile/src/components/MaterialSwitch.tsx @@ -0,0 +1 @@ +export { Switch as MaterialSwitch } from "react-native"; diff --git a/apps/mobile/src/components/ScreenScrollView.tsx b/apps/mobile/src/components/ScreenScrollView.tsx new file mode 100644 index 000000000000..e4db85df7969 --- /dev/null +++ b/apps/mobile/src/components/ScreenScrollView.tsx @@ -0,0 +1,22 @@ +import type { ComponentProps } from "react"; +import { ScrollView } from "react-native"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; + +/** Keeps forms and settings readable inside a wide pane while its surface fills the screen. */ +export function ScreenScrollView(props: ComponentProps) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + return ( + + ); +} diff --git a/apps/mobile/src/components/ThemedSwitch.tsx b/apps/mobile/src/components/ThemedSwitch.tsx index 08cef56f3ec0..60bf59841816 100644 --- a/apps/mobile/src/components/ThemedSwitch.tsx +++ b/apps/mobile/src/components/ThemedSwitch.tsx @@ -1,50 +1,23 @@ -import { Platform, Pressable, Switch, View, type SwitchProps } from "react-native"; +import { Platform, Switch, type SwitchProps } from "react-native"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; -import { SymbolView } from "./AppSymbol"; +import { MaterialSwitch } from "./MaterialSwitch"; -export function ThemedSwitch(props: SwitchProps) { +export type ThemedSwitchProps = Pick< + SwitchProps, + | "accessibilityHint" + | "accessibilityLabel" + | "disabled" + | "onValueChange" + | "style" + | "testID" + | "value" +>; + +export function ThemedSwitch(props: ThemedSwitchProps) { const { materialYouStyleLayoutActive } = useAppearancePreferences(); if (materialYouStyleLayoutActive) { - return ( - props.onValueChange?.(!props.value)} - style={props.style} - testID={props.testID} - className={props.disabled ? "opacity-40" : "active:opacity-70"} - > - - - - - - - ); + return ; } return ( diff --git a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx index 8138d288957c..df6f19835591 100644 --- a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx +++ b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx @@ -1,8 +1,9 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { NativeHeaderToolbar } from "../../native/StackHeader"; import { useAuth } from "@clerk/expo"; import { StackActions, useNavigation } from "@react-navigation/native"; import { useCallback, useEffect, useState } from "react"; -import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; +import { Platform, Pressable, RefreshControl, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime"; diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index 44510772cd6d..477f3f64c1d9 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -1,3 +1,4 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { CameraView, useCameraPermissions } from "expo-camera"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { @@ -8,7 +9,7 @@ import { } from "@react-navigation/native"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Alert, Linking, Platform, ScrollView, View } from "react-native"; +import { Alert, Linking, Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; diff --git a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx index bfab389a3a64..28bb53270434 100644 --- a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx @@ -1,9 +1,10 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { NativeHeaderToolbar } from "../../native/StackHeader"; import { useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useState } from "react"; -import { Platform, ScrollView, View } from "react-native"; +import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; diff --git a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx index b10b6d631216..619131490268 100644 --- a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx +++ b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx @@ -1,7 +1,8 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import Constants from "expo-constants"; import * as Updates from "expo-updates"; import { useEffect, useState } from "react"; -import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native"; +import { ActivityIndicator, Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../components/AppSymbol"; diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 5724abb138c8..f1f25d3135e0 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -1,3 +1,4 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { addProjectRemoteSourceLabel, addProjectRemoteSourcePathHint, @@ -41,7 +42,7 @@ import { import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; +import { ActivityIndicator, Alert, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as Arr from "effect/Array"; import * as Cause from "effect/Cause"; diff --git a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx index 106e4b429981..4fbfb98bfdd2 100644 --- a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx @@ -1,4 +1,4 @@ -import { ScrollView } from "react-native"; +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SettingsScreen } from "./components/SettingsScreen"; diff --git a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx index 7122fdb1f344..333ae098cda9 100644 --- a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx @@ -1,8 +1,9 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { type EnvironmentMachineKind, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; import { useMemo } from "react"; -import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; +import { ActivityIndicator, Alert, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 4b353faa701b..1ca43b5a73cf 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -1,9 +1,10 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { NativeHeaderToolbar } from "../../native/StackHeader"; import { useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useState } from "react"; -import { Platform, ScrollView, View } from "react-native"; +import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; diff --git a/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx index e5d18c57a4a9..2c7ebc60369c 100644 --- a/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx @@ -1,3 +1,4 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { LegendList } from "@legendapp/list/react-native"; import { type StaticScreenProps, useNavigation } from "@react-navigation/native"; import { @@ -8,7 +9,7 @@ import { type ThirdPartyLicenseEntry, } from "@t3tools/shared/thirdPartyLicenses"; import { useCallback, useMemo, useState } from "react"; -import { Linking, Pressable, ScrollView, View } from "react-native"; +import { Linking, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../components/AppSymbol"; diff --git a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx index ef5de5223618..5eb815098aaa 100644 --- a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx @@ -1,7 +1,8 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; -import { Pressable, ScrollView, View } from "react-native"; +import { Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index b022fa224c65..a2eeb2a0ed64 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -1,3 +1,4 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { useAuth, useUser } from "@clerk/expo"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import Constants from "expo-constants"; @@ -8,7 +9,7 @@ import { SymbolView } from "../../components/AppSymbol"; import * as Effect from "effect/Effect"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { Alert, Linking, Platform, Pressable, ScrollView, View } from "react-native"; +import { Alert, Linking, Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { diff --git a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.android.tsx b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.android.tsx new file mode 100644 index 000000000000..4a75e359d465 --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.android.tsx @@ -0,0 +1,90 @@ +import { Host, Slider } from "@expo/ui/jetpack-compose"; +import { fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; +import * as Haptics from "expo-haptics"; +import { useRef, type ComponentProps } from "react"; +import { View } from "react-native"; + +import { AppText as Text } from "../../../../components/AppText"; +import { SymbolView } from "../../../../components/AppSymbol"; +import { useAppearancePreferences } from "../AppearancePreferencesProvider"; +import { FontSizeSliderRow as SharedFontSizeSliderRow } from "./FontSizeSliderRow.shared"; + +export function FontSizeSliderRow(props: ComponentProps) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + return materialYouStyleLayoutActive ? ( + + ) : ( + + ); +} + +function MaterialFontSizeSliderRow(props: ComponentProps) { + const { + themeAppearance, + systemColorsActive, + themeVariables: colors, + } = useAppearancePreferences(); + const draft = useRef(props.value); + const commit = (value: number) => { + if (props.disabled) return; + const next = Math.min( + props.max, + Math.max(props.min, props.min + Math.round((value - props.min) / props.step) * props.step), + ); + if (next === props.value) return; + void Haptics.selectionAsync().catch(() => undefined); + props.onChange(next); + }; + return ( + + + + {props.label} + {props.valueLabel} + + { + if (nativeEvent.actionName === "increment") commit(props.value + props.step); + else if (nativeEvent.actionName === "decrement") commit(props.value - props.step); + }} + > + + + { + draft.current = value; + }} + onValueChangeFinished={() => commit(draft.current)} + /> + + + + + ); +} diff --git a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.shared.tsx b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.shared.tsx new file mode 100644 index 000000000000..7f33f9226d10 --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.shared.tsx @@ -0,0 +1,210 @@ +import * as Haptics from "expo-haptics"; +import { SymbolView } from "../../../../components/AppSymbol"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { View, type AccessibilityActionEvent } from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import type { ComponentProps } from "react"; + +import { AppText as Text } from "../../../../components/AppText"; + +type SymbolName = ComponentProps["name"]; + +const THUMB_SIZE = 26; +const TRACK_HEIGHT = 4; +const SNAP_ANIMATION = { duration: 120 } as const; + +function clampFraction(value: number): number { + "worklet"; + return Math.min(1, Math.max(0, value)); +} + +export function FontSizeSliderRow(props: { + readonly disabled?: boolean; + readonly icon: SymbolName; + readonly label: string; + readonly valueLabel: string; + readonly min: number; + readonly max: number; + readonly step: number; + readonly value: number; + readonly onChange: (value: number) => void; +}) { + const latest = useRef(props); + latest.current = props; + + const { min, max, step, value, disabled } = props; + const fraction = (value - min) / (max - min); + + const progress = useSharedValue(clampFraction(fraction)); + const trackWidth = useSharedValue(0); + const dragging = useSharedValue(false); + + useEffect(() => { + if (!dragging.value) { + progress.value = withTiming(clampFraction(fraction), SNAP_ANIMATION); + } + }, [dragging, fraction, progress]); + + const commit = useCallback((next: number) => { + const current = latest.current; + if (next === current.value) { + return; + } + Haptics.selectionAsync().catch(() => undefined); + current.onChange(next); + }, []); + + const gesture = useMemo(() => { + const snapValue = (raw: number): number => { + "worklet"; + const stepped = Math.round((raw - min) / step) * step + min; + return Math.min(max, Math.max(min, stepped)); + }; + const fractionAt = (x: number): number => { + "worklet"; + const usable = trackWidth.value - THUMB_SIZE; + if (usable <= 0) { + return 0; + } + return clampFraction((x - THUMB_SIZE / 2) / usable); + }; + const valueAtFraction = (f: number): number => { + "worklet"; + return snapValue(min + f * (max - min)); + }; + const fractionOfValue = (v: number): number => { + "worklet"; + return clampFraction((v - min) / (max - min)); + }; + + const pan = Gesture.Pan() + .enabled(!disabled) + .activeOffsetX([-8, 8]) + .failOffsetY([-12, 12]) + .onUpdate((event) => { + dragging.value = true; + const f = fractionAt(event.x); + progress.value = f; + }) + .onFinalize((_event, success) => { + if (!dragging.value) { + return; + } + dragging.value = false; + if (!success) { + progress.value = withTiming(fractionOfValue(value), SNAP_ANIMATION); + return; + } + const next = valueAtFraction(progress.value); + progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION); + runOnJS(commit)(next); + }); + + const tap = Gesture.Tap() + .enabled(!disabled) + .onEnd((event) => { + const next = valueAtFraction(fractionAt(event.x)); + progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION); + runOnJS(commit)(next); + }); + + return Gesture.Race(pan, tap); + }, [commit, disabled, dragging, max, min, progress, step, trackWidth, value]); + + const fillStyle = useAnimatedStyle(() => ({ + width: THUMB_SIZE / 2 + progress.value * Math.max(0, trackWidth.value - THUMB_SIZE), + })); + const thumbStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: progress.value * Math.max(0, trackWidth.value - THUMB_SIZE) }], + })); + + const handleAccessibilityAction = (event: AccessibilityActionEvent) => { + if (event.nativeEvent.actionName === "increment") { + commit(Math.min(max, value + step)); + } else if (event.nativeEvent.actionName === "decrement") { + commit(Math.max(min, value - step)); + } + }; + + return ( + + + + {props.label} + {props.valueLabel} + + + + + { + trackWidth.value = event.nativeEvent.layout.width; + }} + > + + + + + + + + + + ); +} diff --git a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx index 7f33f9226d10..76f18645c0bd 100644 --- a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx +++ b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx @@ -1,210 +1 @@ -import * as Haptics from "expo-haptics"; -import { SymbolView } from "../../../../components/AppSymbol"; -import { useCallback, useEffect, useMemo, useRef } from "react"; -import { View, type AccessibilityActionEvent } from "react-native"; -import { Gesture, GestureDetector } from "react-native-gesture-handler"; -import Animated, { - runOnJS, - useAnimatedStyle, - useSharedValue, - withTiming, -} from "react-native-reanimated"; -import type { ComponentProps } from "react"; - -import { AppText as Text } from "../../../../components/AppText"; - -type SymbolName = ComponentProps["name"]; - -const THUMB_SIZE = 26; -const TRACK_HEIGHT = 4; -const SNAP_ANIMATION = { duration: 120 } as const; - -function clampFraction(value: number): number { - "worklet"; - return Math.min(1, Math.max(0, value)); -} - -export function FontSizeSliderRow(props: { - readonly disabled?: boolean; - readonly icon: SymbolName; - readonly label: string; - readonly valueLabel: string; - readonly min: number; - readonly max: number; - readonly step: number; - readonly value: number; - readonly onChange: (value: number) => void; -}) { - const latest = useRef(props); - latest.current = props; - - const { min, max, step, value, disabled } = props; - const fraction = (value - min) / (max - min); - - const progress = useSharedValue(clampFraction(fraction)); - const trackWidth = useSharedValue(0); - const dragging = useSharedValue(false); - - useEffect(() => { - if (!dragging.value) { - progress.value = withTiming(clampFraction(fraction), SNAP_ANIMATION); - } - }, [dragging, fraction, progress]); - - const commit = useCallback((next: number) => { - const current = latest.current; - if (next === current.value) { - return; - } - Haptics.selectionAsync().catch(() => undefined); - current.onChange(next); - }, []); - - const gesture = useMemo(() => { - const snapValue = (raw: number): number => { - "worklet"; - const stepped = Math.round((raw - min) / step) * step + min; - return Math.min(max, Math.max(min, stepped)); - }; - const fractionAt = (x: number): number => { - "worklet"; - const usable = trackWidth.value - THUMB_SIZE; - if (usable <= 0) { - return 0; - } - return clampFraction((x - THUMB_SIZE / 2) / usable); - }; - const valueAtFraction = (f: number): number => { - "worklet"; - return snapValue(min + f * (max - min)); - }; - const fractionOfValue = (v: number): number => { - "worklet"; - return clampFraction((v - min) / (max - min)); - }; - - const pan = Gesture.Pan() - .enabled(!disabled) - .activeOffsetX([-8, 8]) - .failOffsetY([-12, 12]) - .onUpdate((event) => { - dragging.value = true; - const f = fractionAt(event.x); - progress.value = f; - }) - .onFinalize((_event, success) => { - if (!dragging.value) { - return; - } - dragging.value = false; - if (!success) { - progress.value = withTiming(fractionOfValue(value), SNAP_ANIMATION); - return; - } - const next = valueAtFraction(progress.value); - progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION); - runOnJS(commit)(next); - }); - - const tap = Gesture.Tap() - .enabled(!disabled) - .onEnd((event) => { - const next = valueAtFraction(fractionAt(event.x)); - progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION); - runOnJS(commit)(next); - }); - - return Gesture.Race(pan, tap); - }, [commit, disabled, dragging, max, min, progress, step, trackWidth, value]); - - const fillStyle = useAnimatedStyle(() => ({ - width: THUMB_SIZE / 2 + progress.value * Math.max(0, trackWidth.value - THUMB_SIZE), - })); - const thumbStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: progress.value * Math.max(0, trackWidth.value - THUMB_SIZE) }], - })); - - const handleAccessibilityAction = (event: AccessibilityActionEvent) => { - if (event.nativeEvent.actionName === "increment") { - commit(Math.min(max, value + step)); - } else if (event.nativeEvent.actionName === "decrement") { - commit(Math.max(min, value - step)); - } - }; - - return ( - - - - {props.label} - {props.valueLabel} - - - - - { - trackWidth.value = event.nativeEvent.layout.width; - }} - > - - - - - - - - - - ); -} +export { FontSizeSliderRow } from "./FontSizeSliderRow.shared"; diff --git a/apps/mobile/src/features/settings/components/SettingsRow.tsx b/apps/mobile/src/features/settings/components/SettingsRow.tsx index f15f21b9ac8a..373af9ad1b8b 100644 --- a/apps/mobile/src/features/settings/components/SettingsRow.tsx +++ b/apps/mobile/src/features/settings/components/SettingsRow.tsx @@ -6,6 +6,8 @@ import { SymbolView } from "../../../components/AppSymbol"; import { AppText as Text } from "../../../components/AppText"; import type { SettingsLegalDocumentTarget, SettingsSheetTarget } from "./settings-sheet-targets"; +import { useAppearancePreferences } from "../appearance/AppearancePreferencesProvider"; +import { cn } from "../../../lib/cn"; type SymbolName = ComponentProps["name"]; @@ -19,35 +21,50 @@ export function SettingsRow(props: { readonly onPress?: () => void; }) { const navigation = useNavigation(); + const { materialYouStyleLayoutActive, themeVariables } = useAppearancePreferences(); + const ripple = materialYouStyleLayoutActive + ? { color: themeVariables["--color-subtle-strong"] } + : undefined; const content = ( - - {props.label} - - - {props.value ? ( - - {props.value} + {materialYouStyleLayoutActive ? ( + + {props.label} + {props.value ? ( + {props.value} + ) : null} + + ) : ( + <> + + {props.label} - ) : null} - + + {props.value ? ( + + {props.value} + + ) : null} + + + )} + {content} ); diff --git a/apps/mobile/src/features/settings/components/SettingsSection.tsx b/apps/mobile/src/features/settings/components/SettingsSection.tsx index 6bea4ffe24a4..7e71e031c75e 100644 --- a/apps/mobile/src/features/settings/components/SettingsSection.tsx +++ b/apps/mobile/src/features/settings/components/SettingsSection.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { View } from "react-native"; import { AppText as Text } from "../../../components/AppText"; +import { useAppearancePreferences } from "../appearance/AppearancePreferencesProvider"; export function SettingsSection(props: { readonly title?: string; @@ -9,16 +10,27 @@ export function SettingsSection(props: { /** Force the grouped card background; Android otherwise lists options flat. */ readonly card?: boolean; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); return ( {props.title ? ( - {props.title} + + {props.title} + ) : null} {props.children} diff --git a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx index baf00321bd9c..8440fb1e5c13 100644 --- a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx +++ b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx @@ -4,6 +4,7 @@ import { View } from "react-native"; import { SymbolView } from "../../../components/AppSymbol"; import { AppText as Text } from "../../../components/AppText"; import { ThemedSwitch } from "../../../components/ThemedSwitch"; +import { useAppearancePreferences } from "../appearance/AppearancePreferencesProvider"; type SymbolName = ComponentProps["name"]; @@ -15,6 +16,7 @@ export function SettingsSwitchRow(props: { readonly value: boolean; readonly onValueChange: (value: boolean) => void; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); return ( - {props.label} + + {props.label} + {props.subtitle ? ( {props.subtitle} ) : null} diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 2a7eb6b1c7a5..65d8dd58d300 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,3 +1,4 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { type RouteProp, useNavigation, useRoute } from "@react-navigation/native"; import { @@ -18,7 +19,7 @@ import { makeWindow, } from "@t3tools/shared/usageFormat"; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; +import { Platform, Pressable, RefreshControl, View } from "react-native"; import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; From daf2d01ed79f4cd866173e9729300e4f3d1da23c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 16 Sep 2026 00:24:38 -0700 Subject: [PATCH 08/39] fix(mobile): extend Material controls across home and tool panes --- .../src/components/AndroidAnchoredMenu.tsx | 28 +++++- .../src/components/ConfirmDialogHost.tsx | 8 ++ apps/mobile/src/components/ControlPill.tsx | 22 +++++ .../src/components/MaterialButton.android.tsx | 55 +++++++++++ apps/mobile/src/components/MaterialButton.tsx | 23 +++++ .../MaterialConfirmDialog.android.tsx | 53 ++++++++++ .../src/components/MaterialConfirmDialog.tsx | 11 +++ .../components/MaterialIconButton.android.tsx | 59 ++++++++--- .../src/components/MaterialIconButton.tsx | 1 + .../MaterialSegmentedControl.android.tsx | 52 ++++++++++ .../components/MaterialSegmentedControl.tsx | 7 ++ .../src/components/MaterialSwitch.android.tsx | 6 +- .../src/components/SegmentedControl.tsx | 41 ++++++-- .../connection/ConnectionSheetButton.tsx | 14 +++ .../features/home/AndroidHomeFab.android.tsx | 57 +++++++++++ .../features/home/AndroidHomeFab.shared.tsx | 48 +++++++++ .../src/features/home/AndroidHomeFab.tsx | 49 +-------- apps/mobile/src/features/home/HomeHeader.tsx | 99 ++++++++++++------- .../terminal/ThreadTerminalRouteScreen.tsx | 36 ++++++- .../src/features/usage/UsageRouteScreen.tsx | 10 +- 20 files changed, 567 insertions(+), 112 deletions(-) create mode 100644 apps/mobile/src/components/MaterialButton.android.tsx create mode 100644 apps/mobile/src/components/MaterialButton.tsx create mode 100644 apps/mobile/src/components/MaterialConfirmDialog.android.tsx create mode 100644 apps/mobile/src/components/MaterialConfirmDialog.tsx create mode 100644 apps/mobile/src/components/MaterialSegmentedControl.android.tsx create mode 100644 apps/mobile/src/components/MaterialSegmentedControl.tsx create mode 100644 apps/mobile/src/features/home/AndroidHomeFab.android.tsx create mode 100644 apps/mobile/src/features/home/AndroidHomeFab.shared.tsx diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx index 79ec95d0a014..323770eddd0c 100644 --- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -12,6 +12,7 @@ import { type AppSymbolName, SymbolView } from "./AppSymbol"; import { AppText as Text } from "./AppText"; import { OverlayPortal } from "./OverlayPortal"; import { GlassBackdrop } from "./GlassBackdrop"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; const MENU_WIDTH = 250; const SCREEN_MARGIN = 12; @@ -62,6 +63,7 @@ export type AndroidAnchoredMenuProps = { * trailing check glyph); submenus drill in under a muted parent-title header. */ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { + const { materialYouStyleLayoutActive, themeVariables } = useAppearancePreferences(); const [anchor, setAnchor] = useState(null); const [path, setPath] = useState([]); // Height of the modal's root view, in the modal's own coordinate space. @@ -215,7 +217,10 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { {!placeable || local === null ? null : ( - + {/* Compose DropdownMenu takes popup focus in the pinned Expo UI version. + Keep editor menus in-window so opening one preserves the keyboard. */} + {!materialYouStyleLayoutActive ? ( + + ) : null} {/* keyboardShouldPersistTaps: the menu often opens over an active editor; the first item tap must act, not just dismiss the keyboard. */} @@ -262,8 +271,19 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { onPressItem(action)} @@ -272,7 +292,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx index 521c5e36c32f..8fbbda0aa3f4 100644 --- a/apps/mobile/src/components/ConfirmDialogHost.tsx +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -3,6 +3,8 @@ import { Modal, Pressable, View } from "react-native"; import { cn } from "../lib/cn"; import { AppText } from "./AppText"; +import { MaterialConfirmDialog } from "./MaterialConfirmDialog"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; export type ConfirmDialogRequest = { readonly title: string; @@ -33,6 +35,7 @@ export function showConfirmDialog(request: ConfirmDialogRequest): void { * button color and a dimmer message than the title. */ export function ConfirmDialogHost() { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const [request, setRequest] = useState(null); useEffect(() => { presentRequest = setRequest; @@ -51,6 +54,11 @@ export function ConfirmDialogHost() { setRequest(null); }, [request]); + if (materialYouStyleLayoutActive) + return request ? ( + + ) : null; + return ( { @@ -121,6 +123,26 @@ export function ControlPill(props: { : "", ); + if ( + materialYouStyleLayoutActive && + props.accessibilityLabel && + props.icon && + !props.iconNode && + !props.label && + !props.className && + !props.activateOnPressIn + ) { + return ( + + ); + } + return ( + + {props.label} + + + ); +} diff --git a/apps/mobile/src/components/MaterialButton.tsx b/apps/mobile/src/components/MaterialButton.tsx new file mode 100644 index 000000000000..5746f274e22f --- /dev/null +++ b/apps/mobile/src/components/MaterialButton.tsx @@ -0,0 +1,23 @@ +import { Pressable } from "react-native"; +import { AppText } from "./AppText"; + +export interface MaterialButtonProps { + readonly label: string; + readonly onPress: () => void; + readonly disabled?: boolean; + readonly tone?: "primary" | "secondary" | "danger" | "text"; + readonly fullWidth?: boolean; +} + +export function MaterialButton(props: MaterialButtonProps) { + return ( + + {props.label} + + ); +} diff --git a/apps/mobile/src/components/MaterialConfirmDialog.android.tsx b/apps/mobile/src/components/MaterialConfirmDialog.android.tsx new file mode 100644 index 000000000000..209e62f87505 --- /dev/null +++ b/apps/mobile/src/components/MaterialConfirmDialog.android.tsx @@ -0,0 +1,53 @@ +import { AlertDialog, Host, Text, TextButton } from "@expo/ui/jetpack-compose"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; +import type { MaterialConfirmDialogProps } from "./MaterialConfirmDialog"; + +export function MaterialConfirmDialog(props: MaterialConfirmDialogProps) { + const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const titleTypography = useScaledTextRole("title"); + const bodyTypography = useScaledTextRole("footnote"); + return ( + + + + {props.request.title} + + {props.request.message ? ( + + {props.request.message} + + ) : null} + + + {props.request.cancelText ?? "Cancel"} + + + + + {props.request.confirmText} + + + + + ); +} diff --git a/apps/mobile/src/components/MaterialConfirmDialog.tsx b/apps/mobile/src/components/MaterialConfirmDialog.tsx new file mode 100644 index 000000000000..cad305343482 --- /dev/null +++ b/apps/mobile/src/components/MaterialConfirmDialog.tsx @@ -0,0 +1,11 @@ +import type { ConfirmDialogRequest } from "./ConfirmDialogHost"; + +export interface MaterialConfirmDialogProps { + readonly request: ConfirmDialogRequest; + readonly onCancel: () => void; + readonly onConfirm: () => void; +} + +export function MaterialConfirmDialog(_props: MaterialConfirmDialogProps) { + return null; +} diff --git a/apps/mobile/src/components/MaterialIconButton.android.tsx b/apps/mobile/src/components/MaterialIconButton.android.tsx index dfa83786e141..adc541d34830 100644 --- a/apps/mobile/src/components/MaterialIconButton.android.tsx +++ b/apps/mobile/src/components/MaterialIconButton.android.tsx @@ -1,4 +1,9 @@ -import { Host, IconButton } from "@expo/ui/jetpack-compose"; +import { + FilledIconButton, + FilledTonalIconButton, + Host, + IconButton, +} from "@expo/ui/jetpack-compose"; import { size } from "@expo/ui/jetpack-compose/modifiers"; import { View } from "react-native"; @@ -10,8 +15,31 @@ export function MaterialIconButton(props: { readonly icon: AppSymbolName; readonly onPress?: () => void; readonly disabled?: boolean; + readonly variant?: "standard" | "primary" | "tonal" | "danger"; }) { - const { themeAppearance } = useAppearancePreferences(); + const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const variant = props.variant ?? "standard"; + const Component = + variant === "standard" + ? IconButton + : variant === "tonal" + ? FilledTonalIconButton + : FilledIconButton; + const containerColor = + variant === "primary" + ? colors["--color-primary"] + : variant === "danger" + ? colors["--color-danger"] + : colors["--color-secondary"]; + const iconTint = props.disabled + ? "accent-icon-subtle" + : variant === "primary" + ? "accent-primary-foreground" + : variant === "danger" + ? "accent-danger-foreground" + : variant === "tonal" + ? "accent-secondary-foreground" + : "accent-foreground"; return ( - - + + {null} - + + {/* Keep RN SVG measurement outside Compose; the native button owns touch and ripple. */} - + ); diff --git a/apps/mobile/src/components/MaterialIconButton.tsx b/apps/mobile/src/components/MaterialIconButton.tsx index 4cc19885d01b..771943200459 100644 --- a/apps/mobile/src/components/MaterialIconButton.tsx +++ b/apps/mobile/src/components/MaterialIconButton.tsx @@ -7,6 +7,7 @@ export function MaterialIconButton(props: { readonly icon: AppSymbolName; readonly onPress?: () => void; readonly disabled?: boolean; + readonly variant?: "standard" | "primary" | "tonal" | "danger"; }) { return ( ( + props: SegmentedControlProps, +) { + const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const typography = useScaledTextRole("footnote"); + return ( + + + + {props.options.map((option) => ( + props.onSelect(option.value)} + modifiers={[defaultMinSize({ minHeight: 48 })]} + colors={{ + activeContainerColor: colors["--color-secondary"], + activeContentColor: colors["--color-secondary-foreground"], + inactiveContainerColor: colors["--color-card"], + inactiveContentColor: colors["--color-foreground"], + activeBorderColor: colors["--color-border"], + inactiveBorderColor: colors["--color-border"], + }} + > + + {option.label} + + + ))} + + + + ); +} diff --git a/apps/mobile/src/components/MaterialSegmentedControl.tsx b/apps/mobile/src/components/MaterialSegmentedControl.tsx new file mode 100644 index 000000000000..eca51d2d3f26 --- /dev/null +++ b/apps/mobile/src/components/MaterialSegmentedControl.tsx @@ -0,0 +1,7 @@ +import type { SegmentedControlProps } from "./SegmentedControl"; + +export function MaterialSegmentedControl( + _props: SegmentedControlProps, +) { + return null; +} diff --git a/apps/mobile/src/components/MaterialSwitch.android.tsx b/apps/mobile/src/components/MaterialSwitch.android.tsx index b5aa6e11e15e..2bc9d11130d7 100644 --- a/apps/mobile/src/components/MaterialSwitch.android.tsx +++ b/apps/mobile/src/components/MaterialSwitch.android.tsx @@ -24,7 +24,11 @@ export function MaterialSwitch(props: ThemedSwitchProps) { testID={props.testID} > - + (props: { +export interface SegmentedControlProps { readonly options: readonly { readonly value: Value; readonly label: string; @@ -16,13 +18,29 @@ export function SegmentedControl(props: { /** "tab" for the view switcher; filters stay plain buttons. */ readonly role?: "tab" | "button"; readonly className?: string; -}) { +} + +export function SegmentedControl( + props: SegmentedControlProps, +) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const compact = props.size === "compact"; + const tabs = materialYouStyleLayoutActive && props.role === "tab"; + // Compose owns selection semantics; keep the RN control when an abbreviated + // label needs a separate spoken label that the pinned native API cannot set. + if ( + materialYouStyleLayoutActive && + !tabs && + props.options.every((option) => !option.accessibilityLabel) + ) { + return ; + } return ( @@ -31,7 +49,14 @@ export function SegmentedControl(props: { layout={LinearTransition.duration(200) .easing(Easing.out(Easing.cubic)) .reduceMotion(ReduceMotion.System)} - className="absolute bottom-0 top-0 rounded-full bg-subtle-strong" + className={cn( + "absolute bottom-0", + tabs + ? "h-[3px] rounded-t-full bg-primary" + : materialYouStyleLayoutActive + ? "top-0 rounded-full bg-secondary" + : "top-0 rounded-full bg-subtle-strong", + )} style={{ width: `${100 / props.options.length}%`, start: `${ @@ -55,13 +80,17 @@ export function SegmentedControl(props: { onPress={() => props.onSelect(option.value)} className={cn( "flex-1 items-center justify-center rounded-full", - compact ? "h-9" : "h-11", + materialYouStyleLayoutActive ? "min-h-12 px-2 py-2" : compact ? "h-9" : "h-11", )} > {option.label} diff --git a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx index fe26c66a355e..26fef7b893b7 100644 --- a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx +++ b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx @@ -3,6 +3,8 @@ import { Platform, Pressable } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; +import { MaterialButton } from "../../components/MaterialButton"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; const CARD_SHADOW = Platform.select({ ios: { @@ -32,8 +34,20 @@ export function ConnectionSheetButton(props: { readonly disabled?: boolean; readonly tone?: "primary" | "secondary" | "danger"; readonly compact?: boolean; + readonly fullWidth?: boolean; readonly onPress: () => void; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + if (materialYouStyleLayoutActive) + return ( + + ); const tone = props.tone ?? "secondary"; const textColorClassName = diff --git a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx new file mode 100644 index 000000000000..b9979fa71c80 --- /dev/null +++ b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx @@ -0,0 +1,57 @@ +import { Box, ExtendedFloatingActionButton, Host, Text } from "@expo/ui/jetpack-compose"; +import { size } from "@expo/ui/jetpack-compose/modifiers"; +import type { ComponentProps } from "react"; +import { View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { SymbolView } from "../../components/AppSymbol"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; +import { AndroidHomeFabLayout as SharedAndroidHomeFabLayout } from "./AndroidHomeFab.shared"; + +export function AndroidHomeFabLayout(props: ComponentProps) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + return materialYouStyleLayoutActive ? ( + + ) : ( + + ); +} + +function MaterialHomeFab(props: ComponentProps) { + const insets = useSafeAreaInsets(); + const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const typography = useScaledTextRole("footnote"); + return ( + + {props.children} + + + + + + + + + New task + + + + + + + + + + ); +} diff --git a/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx b/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx new file mode 100644 index 000000000000..6957a6dab043 --- /dev/null +++ b/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from "react"; +import { Platform, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { SymbolView } from "../../components/AppSymbol"; + +/** + * Android-only wrapper that overlays a bottom-right new-task FAB on a thread + * list. Other platforms render children unchanged. + */ +export function AndroidHomeFabLayout(props: { + readonly onStartNewTask: () => void; + readonly children: ReactNode; +}) { + if (Platform.OS !== "android") { + return <>{props.children}; + } + + return ; +} + +function AndroidHomeFab(props: { + readonly onStartNewTask: () => void; + readonly children: ReactNode; +}) { + const insets = useSafeAreaInsets(); + return ( + + {props.children} + + + + + ); +} diff --git a/apps/mobile/src/features/home/AndroidHomeFab.tsx b/apps/mobile/src/features/home/AndroidHomeFab.tsx index 6957a6dab043..89bbb2a26a95 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.tsx @@ -1,48 +1 @@ -import type { ReactNode } from "react"; -import { Platform, Pressable, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; - -import { SymbolView } from "../../components/AppSymbol"; - -/** - * Android-only wrapper that overlays a bottom-right new-task FAB on a thread - * list. Other platforms render children unchanged. - */ -export function AndroidHomeFabLayout(props: { - readonly onStartNewTask: () => void; - readonly children: ReactNode; -}) { - if (Platform.OS !== "android") { - return <>{props.children}; - } - - return ; -} - -function AndroidHomeFab(props: { - readonly onStartNewTask: () => void; - readonly children: ReactNode; -}) { - const insets = useSafeAreaInsets(); - return ( - - {props.children} - - - - - ); -} +export { AndroidHomeFabLayout } from "./AndroidHomeFab.shared"; diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index f67c2bff0f68..b191776d9449 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -10,6 +10,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; +import { MaterialIconButton } from "../../components/MaterialIconButton"; +import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu"; import { T3Wordmark } from "../../components/T3Wordmark"; import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; import { resolveMobileStageLabel } from "../../lib/mobileBranding"; @@ -235,50 +237,75 @@ function AndroidHomeHeader(props: HomeHeaderProps) { } /> - - - + + {(open) => ( + + )} + + - - - {/* Built identically to the filter button so the two circles + + ) : ( + <> + + + + + + {/* Built identically to the filter button so the two circles match exactly (ControlPill sizes via Tailwind classes and resolves to a different box). */} - - - + + + + + )} diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index e1928f297e6e..117d8f9dd7ac 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -7,6 +7,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Platform, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { TerminalContextSheet } from "./TerminalContextSheet"; import { hasNativeTerminalSurface } from "./nativeTerminalModule"; @@ -29,6 +30,8 @@ import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; import { GlassSurface } from "../../components/GlassSurface"; import { LoadingScreen } from "../../components/LoadingScreen"; +import { MaterialButton } from "../../components/MaterialButton"; +import { MaterialIconButton } from "../../components/MaterialIconButton"; import { environmentCatalog } from "../../connection/catalog"; import { useEnvironmentPresentation } from "../../state/presentation"; import { terminalEnvironment } from "../../state/terminal"; @@ -158,6 +161,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{ }>; export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { + const insets = useSafeAreaInsets(); const terminalBlurTarget = useRef(null); const navigation = useNavigation(); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); @@ -190,6 +194,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) themeAppearance: appearanceScheme, themeId, setTerminalFontSize, + materialYouStyleLayoutActive, + themeVariables, } = useAppearancePreferences(); const fontSize = appearance.terminalFontSize; const cachedRouteGridSize = @@ -1280,7 +1286,15 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) ) : null} - + {!isEnvironmentReady ? ( - {selectedThread && hasNativeTerminalSurface() ? ( + {materialYouStyleLayoutActive && !keyboardState.isVisible ? ( + + {selectedThread && hasNativeTerminalSurface() ? ( + setCaptureRequest((value) => value + 1)} + /> + ) : null} + + + + ) : !materialYouStyleLayoutActive && selectedThread && hasNativeTerminalSurface() ? ( { @@ -1396,7 +1426,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) - ) : !keyboardState.isVisible ? ( + ) : !keyboardState.isVisible && !materialYouStyleLayoutActive ? ( >(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); + const { materialYouStyleLayoutActive } = useAppearancePreferences(); // Preserve the Limits default while honoring explicit widget/navigation links. const [selection, setSelection] = useState(() => ({ params: route.params, @@ -266,20 +268,22 @@ export function UsageRouteScreen() { <> {/* Period and metric together: neither applies to Limits, and both change every number below, so they share one bar. */} - + {merged.duplicateSources.length > 0 ? ( From 40615b7b587a3bad71920113aa5a32d1b5072599 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Wed, 16 Sep 2026 00:27:06 -0700 Subject: [PATCH 09/39] fix(mobile): finish Material pane styling and themed inputs --- .../T3MarkdownTextSelectionModule.kt | 18 + .../MarkdownSelectionColorTest.kt | 63 +++ .../src/MarkdownTextPrimitive.tsx | 53 ++- .../src/NativeMarkdownBlock.tsx | 20 +- .../src/NativeMarkdownSelectableText.tsx | 2 + .../src/SelectableMarkdownText.types.ts | 2 + .../src/T3MarkdownTextSelectionModule.ts | 5 + .../src/components/AndroidAnchoredMenu.tsx | 23 +- apps/mobile/src/components/AppText.tsx | 18 +- apps/mobile/src/components/GlassBackdrop.tsx | 7 +- apps/mobile/src/components/GlassSurface.tsx | 22 +- .../components/MaterialNewThreadButton.tsx | 39 ++ .../src/components/MaterialScreenContent.tsx | 36 ++ .../features/files/MaterialFilesHeader.tsx | 110 +++++ .../src/features/files/SourceFileSurface.tsx | 6 + .../features/files/ThreadFilesRouteScreen.tsx | 160 +++---- .../files/thread-file-navigator-pane.tsx | 108 +++-- .../src/features/home/AndroidHomeFab.tsx | 55 +-- apps/mobile/src/features/home/HomeHeader.tsx | 5 + apps/mobile/src/features/home/HomeScreen.tsx | 37 +- .../home/MaterialThreadListToolbar.tsx | 44 +- .../layout/AdaptiveWorkspaceLayout.tsx | 21 +- .../features/layout/WorkspaceEmptyDetail.tsx | 23 +- .../layout/workspace-pane-divider.tsx | 3 + .../src/features/review/ReviewSheet.tsx | 369 +++++++++------ .../settings/components/SettingsScreen.tsx | 13 +- .../terminal/ThreadTerminalRouteScreen.tsx | 322 +++++++------ .../features/threads/CustomSnoozeSheet.tsx | 14 +- .../threads/NewTaskContextPickerScreens.tsx | 124 +++-- .../features/threads/NewTaskDraftScreen.tsx | 38 +- .../features/threads/NewTaskRouteScreen.tsx | 215 +++++---- .../src/features/threads/ThreadComposer.tsx | 10 +- .../src/features/threads/ThreadFeed.tsx | 1 + .../threads/ThreadNavigationSidebar.tsx | 88 ++-- .../features/threads/ThreadSettingsSheet.tsx | 56 ++- .../features/threads/git/GitBranchesSheet.tsx | 327 +++++++++----- .../features/threads/git/GitCommitSheet.tsx | 422 ++++++++++++------ .../features/threads/git/GitConfirmSheet.tsx | 115 +++-- .../features/threads/git/GitOverviewSheet.tsx | 120 +++-- .../threads/git/gitSheetComponents.tsx | 71 ++- .../features/threads/thread-list-items.tsx | 4 +- .../features/threads/thread-list-v2-items.tsx | 6 +- .../native/SelectableMarkdownText.android.tsx | 18 +- .../src/native/T3ComposerEditor.native.tsx | 4 +- vite.config.ts | 2 + 45 files changed, 2148 insertions(+), 1071 deletions(-) create mode 100644 apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt create mode 100644 apps/mobile/src/components/MaterialNewThreadButton.tsx create mode 100644 apps/mobile/src/components/MaterialScreenContent.tsx create mode 100644 apps/mobile/src/features/files/MaterialFilesHeader.tsx diff --git a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt index 26ceb2023235..43b3eef64c83 100644 --- a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt +++ b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt @@ -8,6 +8,7 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Typeface +import android.os.Build import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned @@ -164,6 +165,13 @@ private class SanitizingSelectionActionModeCallback( } } +internal fun applySelectionHandleColor(textView: TextView, color: Int) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return + textView.textSelectHandle?.mutate()?.apply { setTint(color) }?.let(textView::setTextSelectHandle) + textView.textSelectHandleLeft?.mutate()?.apply { setTint(color) }?.let(textView::setTextSelectHandleLeft) + textView.textSelectHandleRight?.mutate()?.apply { setTint(color) }?.let(textView::setTextSelectHandleRight) +} + class T3MarkdownTextSelectionModule : Module() { private val chipImages = LruCache>(128) @@ -186,6 +194,16 @@ class T3MarkdownTextSelectionModule : Module() { override fun definition() = ModuleDefinition { Name("T3MarkdownTextSelection") + Function("setSelectionHandleColor") { reactTag: Int, color: Int -> + val reactContext = appContext.reactContext as? ReactContext ?: return@Function + reactContext.runOnUiQueueThread { + val textView = runCatching { + UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)?.resolveView(reactTag) + }.getOrNull() as? TextView ?: return@runOnUiQueueThread + applySelectionHandleColor(textView, color) + } + } + Function("renderContextChip") { payloadJson: String -> val resources = appContext.reactContext?.resources ?: return@Function null val metrics = resources.displayMetrics diff --git a/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt b/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt new file mode 100644 index 000000000000..37b4aaba55bb --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt @@ -0,0 +1,63 @@ +package expo.modules.t3markdowntext + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.widget.TextView +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36], manifest = Config.NONE) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class MarkdownSelectionColorTest { + private fun textView() = TextView(RuntimeEnvironment.getApplication()).apply { + setTextSelectHandle(ColorDrawable(Color.WHITE)) + setTextSelectHandleLeft(ColorDrawable(Color.WHITE)) + setTextSelectHandleRight(ColorDrawable(Color.WHITE)) + } + + private fun renderedColor(drawable: Drawable?): Int { + requireNotNull(drawable) + val bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888) + drawable.setBounds(0, 0, 4, 4) + drawable.draw(Canvas(bitmap)) + val color = bitmap.getPixel(2, 2) + bitmap.recycle() + return color + } + + @Test + fun retintsAllHandlesWhenTheThemeChangesWithoutChangingTheHighlight() { + val text = textView() + val highlight = 0x52FF0088 + text.highlightColor = highlight + + for (color in listOf(Color.MAGENTA, Color.GREEN, Color.MAGENTA)) { + applySelectionHandleColor(text, color) + assertEquals(color, renderedColor(text.textSelectHandle)) + assertEquals(color, renderedColor(text.textSelectHandleLeft)) + assertEquals(color, renderedColor(text.textSelectHandleRight)) + assertEquals(highlight, text.highlightColor) + } + } + + @Test + fun doesNotTintOtherTextViewsSharingDrawableState() { + val original = ColorDrawable(Color.WHITE) + val first = textView().apply { setTextSelectHandleLeft(original.constantState!!.newDrawable()) } + val second = textView().apply { setTextSelectHandleLeft(original.constantState!!.newDrawable()) } + + applySelectionHandleColor(first, Color.MAGENTA) + + assertEquals(Color.MAGENTA, renderedColor(first.textSelectHandleLeft)) + assertEquals(Color.WHITE, renderedColor(second.textSelectHandleLeft)) + } +} diff --git a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx index c1e2df490e8f..1e9394bd6eca 100644 --- a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx @@ -1,5 +1,15 @@ import React, { type Ref } from "react"; -import { Platform, StyleSheet, Text as RNText, type TextProps, type ViewStyle } from "react-native"; +import { + findNodeHandle, + Platform, + processColor, + StyleSheet, + Text as RNText, + type ColorValue, + type TextProps, + type ViewStyle, +} from "react-native"; +import { setMarkdownSelectionHandleColor } from "./T3MarkdownTextSelectionModule"; import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent"; import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent"; import { flattenStyles } from "./util"; @@ -34,6 +44,7 @@ export type ContextMenuActionEvent = { */ export type MarkdownTextPrimitiveProps = Omit & { nativeTextRef?: Ref; + selectionHandleColor?: ColorValue; uiTextView?: boolean; contextMenuConfig?: string; contextClipboardConfig?: string; @@ -116,7 +127,45 @@ function MarkdownTextPrimitiveInner({ nativeTextRef, ...props }: MarkdownTextPri return ; } -export function MarkdownTextPrimitive(props: MarkdownTextPrimitiveProps) { +function AndroidMarkdownText({ + nativeTextRef, + selectionHandleColor, + onLayout, + contextClipboardConfig: _contextClipboardConfig, + ...props +}: MarkdownTextPrimitiveProps) { + const textRef = React.useRef(null); + React.useImperativeHandle(nativeTextRef, () => textRef.current, []); + const color = processColor(selectionHandleColor); + const applyHandleColor = React.useCallback(() => { + if (!textRef.current || typeof color !== "number") return; + const reactTag = findNodeHandle(textRef.current); + if (reactTag !== null) setMarkdownSelectionHandleColor(reactTag, color); + }, [color]); + + // RN's selectionColor only sets the highlight. Retint mounted handles when + // the theme changes, and after layout when the native view first exists. + React.useEffect(applyHandleColor, [applyHandleColor]); + + return ( + { + applyHandleColor(); + onLayout?.(event); + }} + /> + ); +} + +export function MarkdownTextPrimitive({ + selectionHandleColor, + ...props +}: MarkdownTextPrimitiveProps) { + if (Platform.OS === "android" && selectionHandleColor !== undefined) { + return ; + } if (Platform.OS !== "ios") { const { nativeTextRef, contextClipboardConfig: _contextClipboardConfig, ...textProps } = props; return ; diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx index a4485ede4705..b57a182dbc56 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx @@ -133,7 +133,13 @@ function HighlightedCodeText(props: { } } return ( - + {props.highlighted ? lines : props.content} ); @@ -174,8 +180,10 @@ function NativeCodeBlock(props: { justifyContent: "space-between", }} > - {languageLabel} - + {props.node.alt ? ( - {props.node.alt} - + ) : null} ); diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx index 50a381bae288..1d9c2b93ede8 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx @@ -338,6 +338,8 @@ export function NativeMarkdownSelectableText(props: { } uiTextView selectable + selectionColor={props.textStyle.selectionColor} + selectionHandleColor={props.textStyle.selectionHandleColor} style={{ flexShrink: 1, minWidth: 0, diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts index d67dcc5950de..adfa34365af6 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -1,4 +1,6 @@ export interface NativeMarkdownTextStyle { + readonly selectionColor?: string; + readonly selectionHandleColor?: string; readonly color: string; readonly strongColor: string; readonly mutedColor: string; diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts index 9f1d676179a8..56d8f6d4e0f8 100644 --- a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts +++ b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts @@ -1,6 +1,7 @@ import { requireOptionalNativeModule } from "expo"; interface T3MarkdownTextSelectionNativeModule { + readonly setSelectionHandleColor?: (reactTag: number, color: number) => void; readonly installCopySanitizer: (reactTag: number, contextClipboardConfig: string) => void; readonly renderContextChip?: (payloadJson: string) => { readonly uri: string; @@ -20,6 +21,10 @@ export function installMarkdownCopySanitizer(reactTag: number, contextClipboardC nativeModule?.installCopySanitizer(reactTag, contextClipboardConfig); } +export function setMarkdownSelectionHandleColor(reactTag: number, color: number): void { + nativeModule?.setSelectionHandleColor?.(reactTag, color); +} + export function renderAndroidContextChip(payloadJson: string) { return nativeModule?.renderContextChip?.(payloadJson) ?? null; } diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx index 79ec95d0a014..ae51f522f9af 100644 --- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -12,6 +12,7 @@ import { type AppSymbolName, SymbolView } from "./AppSymbol"; import { AppText as Text } from "./AppText"; import { OverlayPortal } from "./OverlayPortal"; import { GlassBackdrop } from "./GlassBackdrop"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; const MENU_WIDTH = 250; const SCREEN_MARGIN = 12; @@ -62,6 +63,7 @@ export type AndroidAnchoredMenuProps = { * trailing check glyph); submenus drill in under a muted parent-title header. */ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const [anchor, setAnchor] = useState(null); const [path, setPath] = useState([]); // Height of the modal's root view, in the modal's own coordinate space. @@ -215,7 +217,12 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { {!placeable || local === null ? null : ( - + {!materialYouStyleLayoutActive ? ( + + ) : null} {/* keyboardShouldPersistTaps: the menu often opens over an active editor; the first item tap must act, not just dismiss the keyboard. */} onPressItem(action)} @@ -272,7 +285,9 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { diff --git a/apps/mobile/src/components/AppText.tsx b/apps/mobile/src/components/AppText.tsx index 6501d2044083..805cdb66a3b4 100644 --- a/apps/mobile/src/components/AppText.tsx +++ b/apps/mobile/src/components/AppText.tsx @@ -1,4 +1,5 @@ import { + Platform, Text as RNText, TextInput as RNTextInput, type TextInputProps as RNTextInputProps, @@ -14,7 +15,13 @@ export type AppTextProps = RNTextProps & { readonly className?: string }; * Uses Uniwind className — no manual style parsing. */ export function AppText({ className, ...props }: AppTextProps) { - return ; + return ( + + ); } export type AppTextInputProps = Omit & { @@ -35,8 +42,13 @@ export function AppTextInput({ className, ref, ...props }: AppTextInputProps) { className, )} placeholderTextColorClassName="accent-placeholder" - selectionColorClassName="accent-foreground-secondary" - cursorColorClassName="accent-foreground-secondary" + selectionColorClassName={ + Platform.OS === "android" ? "accent-primary/32" : "accent-foreground-secondary" + } + cursorColorClassName={ + Platform.OS === "android" ? "accent-primary" : "accent-foreground-secondary" + } + selectionHandleColorClassName={Platform.OS === "android" ? "accent-primary" : undefined} {...props} /> ); diff --git a/apps/mobile/src/components/GlassBackdrop.tsx b/apps/mobile/src/components/GlassBackdrop.tsx index f18ee7908db6..80f7e1f32e23 100644 --- a/apps/mobile/src/components/GlassBackdrop.tsx +++ b/apps/mobile/src/components/GlassBackdrop.tsx @@ -11,12 +11,13 @@ export function GlassBackdrop(props: { readonly fallbackColor?: ColorValue; readonly blurTarget?: RefObject; }) { - const { themeAppearance } = useAppearancePreferences(); + const { themeAppearance, materialYouStyleLayoutActive } = useAppearancePreferences(); const inheritedBlurTarget = useContext(GlassBlurTargetContext); const target = props.blurTarget ?? inheritedBlurTarget; const supportsBlur = - Platform.OS === "ios" || - (Platform.OS === "android" && Platform.Version >= 31 && target !== undefined); + !materialYouStyleLayoutActive && + (Platform.OS === "ios" || + (Platform.OS === "android" && Platform.Version >= 31 && target !== undefined)); const colorStyle = props.fallbackColor === undefined ? undefined diff --git a/apps/mobile/src/components/GlassSurface.tsx b/apps/mobile/src/components/GlassSurface.tsx index 014a760588ab..a0d0f6d526ce 100644 --- a/apps/mobile/src/components/GlassSurface.tsx +++ b/apps/mobile/src/components/GlassSurface.tsx @@ -10,6 +10,7 @@ import { } from "react-native"; import { withUniwind } from "uniwind"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { cn } from "../lib/cn"; import { GlassBackdrop } from "./GlassBackdrop"; @@ -47,25 +48,18 @@ export function GlassSurface({ style, ...props }: GlassSurfaceProps) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const isDarkMode = useColorScheme() === "dark"; const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable(); + const hasShadow = chrome !== "none" && !materialYouStyleLayoutActive; const surfaceStyle: ViewStyle = { borderRadius: 32, overflow: "hidden", - shadowColor: chrome === "none" ? "transparent" : "#000000", - shadowOpacity: chrome === "none" ? 0 : isDarkMode ? 0.22 : 0.08, - shadowRadius: chrome === "none" ? 0 : 28, - shadowOffset: - chrome === "none" - ? { - width: 0, - height: 0, - } - : { - width: 0, - height: 14, - }, - elevation: chrome === "none" ? 0 : 12, + shadowColor: hasShadow ? "#000000" : "transparent", + shadowOpacity: hasShadow ? (isDarkMode ? 0.22 : 0.08) : 0, + shadowRadius: hasShadow ? 28 : 0, + shadowOffset: { width: 0, height: hasShadow ? 14 : 0 }, + elevation: hasShadow ? 12 : 0, }; if (supportsGlass) { diff --git a/apps/mobile/src/components/MaterialNewThreadButton.tsx b/apps/mobile/src/components/MaterialNewThreadButton.tsx new file mode 100644 index 000000000000..7a5bc01d54aa --- /dev/null +++ b/apps/mobile/src/components/MaterialNewThreadButton.tsx @@ -0,0 +1,39 @@ +import { Pressable, type StyleProp, type ViewStyle } from "react-native"; + +import { cn } from "../lib/cn"; +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; + +/** Shared compose action for the floating button and empty workspace. */ +export function MaterialNewThreadButton(props: { + readonly onPress: () => void; + readonly extended?: boolean; + readonly className?: string; + readonly style?: StyleProp; +}) { + return ( + + + {props.extended ? ( + New thread + ) : null} + + ); +} diff --git a/apps/mobile/src/components/MaterialScreenContent.tsx b/apps/mobile/src/components/MaterialScreenContent.tsx new file mode 100644 index 000000000000..fecae85372ce --- /dev/null +++ b/apps/mobile/src/components/MaterialScreenContent.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; +import { View } from "react-native"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; + +/** Keeps the header surface visible behind rounded Android content corners. */ +export function MaterialScreenContent({ + children, + insetHorizontal = false, + fitToContents = false, +}: { + readonly children: ReactNode; + /** Match the master-list gutters for secondary panes, not full-width content. */ + readonly insetHorizontal?: boolean; + /** Allow native form sheets to measure their content instead of filling a fixed detent. */ + readonly fitToContents?: boolean; +}) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + if (!materialYouStyleLayoutActive) return children; + + return ( + + + {children} + + + ); +} diff --git a/apps/mobile/src/features/files/MaterialFilesHeader.tsx b/apps/mobile/src/features/files/MaterialFilesHeader.tsx new file mode 100644 index 000000000000..26f7ada8872d --- /dev/null +++ b/apps/mobile/src/features/files/MaterialFilesHeader.tsx @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { BackHandler, Keyboard, Pressable, TextInput, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { SymbolView } from "../../components/AppSymbol"; + +/** Keep Files search in the same header row on compact and expanded layouts. */ +export function MaterialFilesHeader(props: { + readonly projectName: string; + readonly searchQuery: string; + readonly onSearchQueryChange: (query: string) => void; + readonly onRefresh: () => void; + readonly onBack?: () => void; +}) { + const insets = useSafeAreaInsets(); + const searchRef = useRef(null); + const [searchOpen, setSearchOpen] = useState(false); + const searching = searchOpen || props.searchQuery.length > 0; + const { onSearchQueryChange } = props; + const closeSearch = useCallback(() => { + onSearchQueryChange(""); + setSearchOpen(false); + Keyboard.dismiss(); + }, [onSearchQueryChange]); + + useEffect(() => { + if (!searching) return; + const subscription = BackHandler.addEventListener("hardwareBackPress", () => { + closeSearch(); + return true; + }); + return () => subscription.remove(); + }, [closeSearch, searching]); + + if (!searching) { + return ( + setSearchOpen(true), + }, + { + accessibilityLabel: "Refresh files", + icon: "arrow.clockwise", + onPress: props.onRefresh, + }, + ]} + /> + ); + } + + return ( + + + + + + + {props.searchQuery.length > 0 ? ( + { + onSearchQueryChange(""); + searchRef.current?.focus(); + }} + > + + + ) : null} + + + + ); +} diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 82d69b28e841..761849725839 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -4,6 +4,7 @@ import type { ComponentType } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FlatList, + Platform, RefreshControl, ScrollView, Text as NativeText, @@ -73,6 +74,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { {isAndroid ? ( <> - - - - - + )} + {!materialYouStyleLayoutActive ? ( + + + + + ) : null} ) : ( <> @@ -548,26 +551,28 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { )} )} - - + + + + ); return materialYouStyleLayoutActive ? ( - + {content} ) : ( @@ -579,7 +584,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { useAdaptiveWorkspacePaneRole("inspector"); const navigation = useNavigation(); const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); - const { appearance, setCodeWordBreak } = useAppearancePreferences(); + const { appearance, setCodeWordBreak, materialYouStyleLayoutActive } = useAppearancePreferences(); const iconColor = useUniwindTheme()["--color-icon"]; const isAndroid = Platform.OS === "android"; const params = props.route.params; @@ -933,6 +938,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { @@ -1007,25 +1013,27 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { ))} - fileQuery.refresh()} - /> + + fileQuery.refresh()} + /> + setFullScreenPreview(null)} diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index 4bb847dc546a..dae6306ba1a7 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -1,5 +1,6 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; +import { MaterialScreenContent } from "../../components/MaterialScreenContent"; import { useCallback, useMemo, useState, type ComponentProps } from "react"; import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native"; import { @@ -11,6 +12,7 @@ import { } from "react-native-screens"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { MaterialFilesHeader } from "./MaterialFilesHeader"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -29,7 +31,8 @@ export function ThreadFileNavigatorPane(props: { }) { const [searchQuery, setSearchQuery] = useState(""); const { toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); - const { themeAppearance: highlightTheme } = useAppearancePreferences(); + const { themeAppearance: highlightTheme, materialYouStyleLayoutActive } = + useAppearancePreferences(); const theme = useUniwindTheme(); const foregroundColor = theme["--color-foreground"]; const sheetColor = theme["--color-sheet"]; @@ -149,59 +152,68 @@ export function ThreadFileNavigatorPane(props: { } return ( - - - - - Files - - {props.projectName} - + + + {materialYouStyleLayoutActive ? ( + + ) : ( + + + Files + + {props.projectName} + + + + + - + )} + {!materialYouStyleLayoutActive ? ( + - - - - - - - - - + + + ) : null} - {fileTree} + {fileTree} ); } diff --git a/apps/mobile/src/features/home/AndroidHomeFab.tsx b/apps/mobile/src/features/home/AndroidHomeFab.tsx index 0cf1acc42ee9..5edf6b33aa81 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.tsx @@ -3,7 +3,7 @@ import { Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../components/AppSymbol"; -import { AppText } from "../../components/AppText"; +import { MaterialNewThreadButton } from "../../components/MaterialNewThreadButton"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; /** @@ -13,6 +13,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe export function AndroidHomeFabLayout(props: { readonly onStartNewTask: () => void; readonly children: ReactNode; + readonly sidebar?: boolean; }) { if (Platform.OS !== "android") { return <>{props.children}; @@ -24,37 +25,41 @@ export function AndroidHomeFabLayout(props: { function AndroidHomeFab(props: { readonly onStartNewTask: () => void; readonly children: ReactNode; + readonly sidebar?: boolean; }) { const insets = useSafeAreaInsets(); const { materialYouStyleLayoutActive } = useAppearancePreferences(); return ( {props.children} - - - {materialYouStyleLayoutActive ? ( - - New thread - - ) : null} - + ) : ( + + + + )} ); } diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index ec2d7afae7d3..d9daff78842d 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -312,6 +312,11 @@ function AndroidHomeHeader(props: HomeHeaderProps) { onChangeText={props.onSearchQueryChange} placeholder="Search threads" placeholderTextColorClassName="accent-placeholder" + selectionColorClassName={Platform.OS === "android" ? "accent-primary/32" : undefined} + cursorColorClassName={Platform.OS === "android" ? "accent-primary" : undefined} + selectionHandleColorClassName={ + Platform.OS === "android" ? "accent-primary" : undefined + } className="flex-1 py-2.5 text-base font-sans text-foreground" value={props.searchQuery} /> diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 5634ad9597d9..1fca44b67ddc 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1123,35 +1123,66 @@ export function HomeScreen(props: HomeScreenProps) { const listEmpty = !hasResults ? ( hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( - + ) : selectedProjectScope !== null ? ( ) : selectedEnvironmentLabel ? ( ) : ( - + ) ) : null; // Use the v2 project scope for its empty state. Snoozed threads need no // special empty state: their shelf header is a list row even while collapsed. const v2ListEmpty = hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( - + ) : v2ScopedProjectGroup !== null ? ( ) : ( listEmpty ); + if ( + materialYouStyleLayoutActive && + (threadListV2Enabled ? threadListV2Items.length === 0 : listLayout.items.length === 0) + ) { + return ( + + + {threadListV2Enabled ? v2ListEmpty : listEmpty} + + + ); + } + if (threadListV2Enabled) { return ( diff --git a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx index 5975b09a3f8a..969d7407242d 100644 --- a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx @@ -73,6 +73,9 @@ export function MaterialThreadListToolbar(props: { returnKeyType="search" placeholder="Search" placeholderTextColorClassName="accent-placeholder" + selectionColorClassName="accent-primary/32" + cursorColorClassName="accent-primary" + selectionHandleColorClassName="accent-primary" className="min-w-0 flex-1 py-2 font-sans text-base text-foreground" value={props.searchQuery} onChangeText={onSearchQueryChange} @@ -142,27 +145,30 @@ export function MaterialThreadListToolbar(props: { {/* Sit 8dp above the 56dp extended New thread FAB. */} - {!searching ? ( - + - - - - - ) : null} + + + ); diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 0d19eb815d2f..401f38024c27 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -511,19 +511,9 @@ function AdaptiveWorkspaceLayoutContent( const contentSettledWidth = layout.usesSplitView ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth) : null; - const contentLeadingInset = materialYouStyleLayoutActive && panes.primarySidebarVisible ? 8 : 0; - const contentTrailingInset = - materialYouStyleLayoutActive && inspectorColumnTargetWidth > 0 ? 8 : 0; const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth); const renderedContentWidth = useDerivedValue(() => - Math.max( - 0, - width - - renderedSidebarWidth.value - - renderedInspectorWidth.value - - contentLeadingInset - - contentTrailingInset, - ), + Math.max(0, width - renderedSidebarWidth.value - renderedInspectorWidth.value), ); const handleSelectThread = useCallback( @@ -571,7 +561,7 @@ function AdaptiveWorkspaceLayoutContent( style={sidebarAnimatedStyle} > - + void }) { const { materialYouStyleLayoutActive } = useAppearancePreferences(); @@ -23,16 +24,22 @@ export function WorkspaceEmptyDetail(props: { readonly onStartNewTask?: () => vo /> Select a thread - Choose a thread from the sidebar or start a new task. + {materialYouStyleLayoutActive + ? "Choose a thread from the sidebar or start a new thread." + : "Choose a thread from the sidebar or start a new task."} {props.onStartNewTask ? ( - - New Task - + materialYouStyleLayoutActive ? ( + + ) : ( + + New Task + + ) ) : null} diff --git a/apps/mobile/src/features/layout/workspace-pane-divider.tsx b/apps/mobile/src/features/layout/workspace-pane-divider.tsx index 63966282266f..a7cb243c2096 100644 --- a/apps/mobile/src/features/layout/workspace-pane-divider.tsx +++ b/apps/mobile/src/features/layout/workspace-pane-divider.tsx @@ -3,6 +3,7 @@ import { Pressable, StyleSheet, View, type AccessibilityActionEvent } from "reac import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { runOnJS } from "react-native-reanimated"; import { cn } from "../../lib/cn"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; const ACCESSIBILITY_RESIZE_STEP = 24; @@ -18,6 +19,7 @@ interface WorkspacePaneDividerProps { /** A forgiving divider target for touch, pointer, and VoiceOver users. */ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const latestProps = useRef(props); latestProps.current = props; const [hovered, setHovered] = useState(false); @@ -82,6 +84,7 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { className={cn( "h-full self-center bg-border opacity-70", hovered || dragging ? "w-0.5 bg-primary opacity-100" : "w-px", + materialYouStyleLayoutActive && !hovered && !dragging && "opacity-0", )} style={[styles.line, (hovered || dragging) && styles.activeLine]} /> diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index b4f57653fb6f..63ab10904433 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -35,6 +35,8 @@ import { AppText as Text } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ControlPillMenu } from "../../components/ControlPill"; +import { MaterialScreenContent } from "../../components/MaterialScreenContent"; +import { cn } from "../../lib/cn"; import { environmentCatalog } from "../../connection/catalog"; import { useEnvironmentPresentation } from "../../state/presentation"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -80,8 +82,14 @@ const REVIEW_HEADER_SPACING = 0; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); return ( - + Partial diff {props.notice} @@ -164,6 +172,7 @@ const ReviewFileNavigatorRow = memo(function ReviewFileNavigatorRow(props: { readonly onSelectFile: (fileId: string | null) => void; }) { const { file, selected, onSelectFile } = props; + const { materialYouStyleLayoutActive } = useAppearancePreferences(); // Tapping the selected file again returns to the all-files diff. const handlePress = useCallback(() => { onSelectFile(selected ? null : file.id); @@ -174,9 +183,14 @@ const ReviewFileNavigatorRow = memo(function ReviewFileNavigatorRow(props: { accessibilityRole="button" accessibilityState={{ selected }} className={ - selected - ? "mt-1 min-h-12 justify-center rounded-xl bg-subtle-strong px-3 py-2" - : "mt-1 min-h-12 justify-center rounded-xl px-3 py-2 active:bg-subtle" + materialYouStyleLayoutActive + ? cn( + "mt-1 min-h-12 justify-center rounded-[20px] px-3 py-2 active:bg-subtle", + selected && "bg-thread-selected", + ) + : selected + ? "mt-1 min-h-12 justify-center rounded-xl bg-subtle-strong px-3 py-2" + : "mt-1 min-h-12 justify-center rounded-xl px-3 py-2 active:bg-subtle" } onPress={handlePress} > @@ -219,6 +233,7 @@ function ReviewFileNavigator({ }: ReviewFileNavigatorProps) { const insets = useSafeAreaInsets(); const theme = useUniwindTheme(); + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const sheetColor = theme["--color-sheet"]; const foregroundColor = theme["--color-foreground"]; const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -323,16 +338,28 @@ function ReviewFileNavigator({ } return ( - - - - Changed files - - {files.length} {files.length === 1 ? "file" : "files"} - + + {materialYouStyleLayoutActive ? ( + + ) : ( + + + Changed files + + {files.length} {files.length === 1 ? "file" : "files"} + + - - {fileList} + )} + {fileList} ); } @@ -349,7 +376,8 @@ export function ReviewSheet(props: ReviewSheetProps) { const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const { themeAppearance: selectedTheme } = useAppearancePreferences(); + const { themeAppearance: selectedTheme, materialYouStyleLayoutActive } = + useAppearancePreferences(); const headerIcon = String(useUniwindTheme()["--color-icon"]); const { environmentId, threadId } = props.route.params; const environment = useEnvironmentPresentation(environmentId); @@ -629,7 +657,13 @@ export function ReviewSheet(props: ReviewSheetProps) { if (error) { children.push( - + Review unavailable {error} , @@ -645,7 +679,7 @@ export function ReviewSheet(props: ReviewSheetProps) { } return <>{children}; - }, [error, parsedDiffNotice]); + }, [error, parsedDiffNotice, materialYouStyleLayoutActive]); const headerSubtitle = [ headerDiffSummary.additions, headerDiffSummary.deletions, @@ -680,6 +714,7 @@ export function ReviewSheet(props: ReviewSheetProps) { {isAndroid ? ( ) : null} - - {showConnectionNotice ? ( - - + + {showConnectionNotice ? ( + + - - ) : selectedSection && parsedDiff.kind === "files" && NativeReviewDiffView ? ( - + resourceName="review" + onRetry={handleRetryEnvironment} + /> + + ) : selectedSection && parsedDiff.kind === "files" && NativeReviewDiffView ? ( - {listHeader} - - void handlePullToRefresh()} - style={StyleSheet.absoluteFill} - appearanceScheme={selectedTheme} - collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson} - collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson} - contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`} - contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} - nativeViewRef={nativeReviewDiffViewRef} - rowHeight={nativeReviewDiffStyle.rowHeight} - rowsJson={nativeBridge.rowsJson} - selectedRowIdsJson={nativeBridge.selectedRowIdsJson} - styleJson={nativeBridge.styleJson} - themeJson={nativeBridge.themeJson} - tokensPatchJson={nativeBridge.tokensPatchJson} - tokensResetKey={nativeBridge.tokensResetKey} - viewedFileIdsJson={nativeBridge.viewedFileIdsJson} - onDebug={handleNativeDebug} - onPressLine={commentSelection.onPressLine} - onVisibleFileChange={handleVisibleFileChange} - onToggleComment={nativeBridge.onToggleComment} - onToggleFile={handleNativeToggleFile} - onToggleViewedFile={handleNativeToggleViewedFile} - /> + + {listHeader} + + void handlePullToRefresh()} + style={StyleSheet.absoluteFill} + appearanceScheme={selectedTheme} + collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson} + collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson} + contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`} + contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} + nativeViewRef={nativeReviewDiffViewRef} + rowHeight={nativeReviewDiffStyle.rowHeight} + rowsJson={nativeBridge.rowsJson} + selectedRowIdsJson={nativeBridge.selectedRowIdsJson} + styleJson={nativeBridge.styleJson} + themeJson={nativeBridge.themeJson} + tokensPatchJson={nativeBridge.tokensPatchJson} + tokensResetKey={nativeBridge.tokensResetKey} + viewedFileIdsJson={nativeBridge.viewedFileIdsJson} + onDebug={handleNativeDebug} + onPressLine={commentSelection.onPressLine} + onVisibleFileChange={handleVisibleFileChange} + onToggleComment={nativeBridge.onToggleComment} + onToggleFile={handleNativeToggleFile} + onToggleViewedFile={handleNativeToggleViewedFile} + /> + - - ) : ( - void handlePullToRefresh()} - /> - } - > - {listHeader} - {!selectedSection ? ( - - No review diffs - - This thread has no ready turn diffs and the worktree diff is empty. - - - ) : selectedSection.isLoading && selectedSection.diff === null ? ( - - - Loading diff… - - ) : parsedDiff.kind === "empty" ? ( - - No changes - - {selectedSection.subtitle ?? "This diff is empty."} - - - ) : parsedDiff.kind === "raw" ? ( - - - {parsedDiff.reason} - - - - {parsedDiff.text} + ) : ( + void handlePullToRefresh()} + /> + } + > + {listHeader} + {!selectedSection ? ( + + No review diffs + + This thread has no ready turn diffs and the worktree diff is empty. - - - ) : parsedDiff.kind === "files" ? ( - // The native diff surface could not be resolved on this binary; - // degrade to the raw patch instead of crashing the app. - - - Native diff view unavailable. Showing the raw patch. - - - - {selectedSection?.diff ?? ""} + + ) : selectedSection.isLoading && selectedSection.diff === null ? ( + + + Loading diff… + + ) : parsedDiff.kind === "empty" ? ( + + No changes + + {selectedSection.subtitle ?? "This diff is empty."} - - - ) : null} - - )} - - + + ) : parsedDiff.kind === "raw" ? ( + + + {parsedDiff.reason} + + + + {parsedDiff.text} + + + + ) : parsedDiff.kind === "files" ? ( + // The native diff surface could not be resolved on this binary; + // degrade to the raw patch instead of crashing the app. + + + Native diff view unavailable. Showing the raw patch. + + + + {selectedSection?.diff ?? ""} + + + + ) : null} + + )} + + + ); } diff --git a/apps/mobile/src/features/settings/components/SettingsScreen.tsx b/apps/mobile/src/features/settings/components/SettingsScreen.tsx index a0b317879cdc..40d978d81ebb 100644 --- a/apps/mobile/src/features/settings/components/SettingsScreen.tsx +++ b/apps/mobile/src/features/settings/components/SettingsScreen.tsx @@ -3,20 +3,11 @@ import type { ComponentProps, ReactNode } from "react"; import { Platform, View } from "react-native"; import { AndroidScreenHeader } from "../../../components/AndroidScreenHeader"; +import { MaterialScreenContent as SettingsScreenContent } from "../../../components/MaterialScreenContent"; import { NativeStackScreenOptions } from "../../../native/StackHeader"; import { useAppearancePreferences } from "../appearance/AppearancePreferencesProvider"; -/** Clips scrolling settings content below the header, leaving the shared frame behind its corners. */ -export function SettingsScreenContent({ children }: { readonly children: ReactNode }) { - const { materialYouStyleLayoutActive } = useAppearancePreferences(); - if (!materialYouStyleLayoutActive) return children; - - return ( - - {children} - - ); -} +export { SettingsScreenContent }; export function SettingsScreen( props: Pick, "title" | "actions" | "trailing"> & { diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index e1928f297e6e..b7d16e0a2fa2 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -20,6 +20,9 @@ import { } from "react-native-keyboard-controller"; import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { MaterialScreenContent } from "../../components/MaterialScreenContent"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ComposerToolbarButton, ComposerToolbarRow, @@ -158,6 +161,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{ }>; export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { + const insets = useSafeAreaInsets(); const terminalBlurTarget = useRef(null); const navigation = useNavigation(); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); @@ -190,6 +194,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) themeAppearance: appearanceScheme, themeId, setTerminalFontSize, + materialYouStyleLayoutActive, } = useAppearancePreferences(); const fontSize = appearance.terminalFontSize; const cachedRouteGridSize = @@ -477,7 +482,20 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) [hostOs, selectedEnvironmentConnection?.environmentLabel], ); - const terminalTheme = getMobileTerminalTheme(themeId, appearanceScheme); + const colors = useUniwindTheme(); + const baseTerminalTheme = getMobileTerminalTheme(themeId, appearanceScheme); + // Keep ANSI output colors, but let Material surfaces and text follow the live palette. + const terminalTheme = materialYouStyleLayoutActive + ? { + ...baseTerminalTheme, + background: colors["--color-sheet-solid"], + foreground: colors["--color-foreground"], + mutedForeground: colors["--color-foreground-muted"], + border: colors["--color-border"], + cursorForeground: colors["--color-primary"], + cursorBackground: colors["--color-sheet-solid"], + } + : baseTerminalTheme; const usesNativeHeaderGlass = Platform.OS === "ios"; const pendingModifier = pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; @@ -1175,6 +1193,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) @@ -1280,162 +1299,171 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) ) : null} - - {!isEnvironmentReady ? ( - + + {!isEnvironmentReady ? ( + - ) : ( - <> - - + ) : ( + <> + - { - if (text.trim()) setCapturedOutput(text); - else Alert.alert("No terminal output", "There is no visible output to attach."); + flex: 1, + paddingBottom: terminalBottomInset, }} - onInput={handleInput} - onResize={handleResize} - style={{ flex: 1 }} - terminalKey={terminalKey} - theme={terminalTheme} - /> - - - {selectedThread && hasNativeTerminalSurface() ? ( - { - KeyboardController.dismiss(); - setCaptureRequest((value) => value + 1); - }} - className="px-4 py-2" - > - Attach visible output - - ) : null} - {isAccessoryVisible ? ( - - - - {terminalToolbarActions.map((action) => { - const active = - action.kind === "modifier" && pendingModifier === action.modifier; - - return ( - 1 ? 56 : 44} - onPress={() => handleToolbarActionPress(action)} - showChevron={false} - textTransform={ - action.kind === "modifier" || action.kind === "clear" - ? "uppercase" - : "none" - } - /> - ); - })} - - - - - - ) : !keyboardState.isVisible ? ( - ({ - bottom: 16, - borderRadius: 28, - opacity: pressed ? 0.72 : 1, - position: "absolute", - right: 16, - })} - > - + { + if (text.trim()) setCapturedOutput(text); + else Alert.alert("No terminal output", "There is no visible output to attach."); }} - pointerEvents="none" + onInput={handleInput} + onResize={handleResize} + style={{ flex: 1 }} + terminalKey={terminalKey} + theme={terminalTheme} + /> + + + {selectedThread && hasNativeTerminalSurface() ? ( + { + KeyboardController.dismiss(); + setCaptureRequest((value) => value + 1); + }} + className="px-4 py-2" > - - - - ) : null} - - )} - + Attach visible output + + ) : null} + {isAccessoryVisible ? ( + + + + + {terminalToolbarActions.map((action) => { + const active = + action.kind === "modifier" && pendingModifier === action.modifier; + + return ( + 1 ? 56 : 44} + onPress={() => handleToolbarActionPress(action)} + showChevron={false} + textTransform={ + action.kind === "modifier" || action.kind === "clear" + ? "uppercase" + : "none" + } + /> + ); + })} + + + + + + ) : !keyboardState.isVisible ? ( + ({ + bottom: materialYouStyleLayoutActive ? insets.bottom + 16 : 16, + borderRadius: 28, + opacity: pressed ? 0.72 : 1, + position: "absolute", + right: 16, + })} + > + + + + + ) : null} + + )} + + ); } diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.tsx index 6d35adb06240..e2632b2df8d8 100644 --- a/apps/mobile/src/features/threads/CustomSnoozeSheet.tsx +++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.tsx @@ -17,11 +17,13 @@ import { } from "react-native"; import { AppText } from "../../components/AppText"; import { SegmentedControl } from "../../components/SegmentedControl"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; export function CustomSnoozeSheet(props: { readonly onClose: () => void; readonly onSnooze: (snoozedUntil: string) => void; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const [mode, setMode] = useState("date"); const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000)); const [picker, setPicker] = useState<"date" | "time" | null>(null); @@ -68,7 +70,11 @@ export function CustomSnoozeSheet(props: { key={value} accessibilityRole="button" accessibilityLabel={value === "date" ? "Choose date" : "Choose time"} - className="min-h-12 flex-row items-center justify-between rounded-xl bg-subtle px-3" + className={ + materialYouStyleLayoutActive + ? "min-h-12 flex-row items-center justify-between rounded-xl border border-input-border bg-input px-3" + : "min-h-12 flex-row items-center justify-between rounded-xl bg-subtle px-3" + } onPress={() => setPicker(value)} > {value === "date" ? "Date" : "Time"} @@ -106,7 +112,11 @@ export function CustomSnoozeSheet(props: { Snooze for { diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index ef066feca5b5..37994cb95ae3 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -20,6 +20,8 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { MaterialScreenContent } from "../../components/MaterialScreenContent"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; @@ -46,6 +48,7 @@ function SelectionRow(props: { readonly subtitle?: string; readonly title: string; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); return ( {props.children}; + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + return ( + + {props.children} + + ); } export function NewTaskEnvironmentPickerRouteScreen() { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const flow = useNewTaskFlow(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); @@ -161,47 +176,54 @@ export function NewTaskEnvironmentPickerRouteScreen() { }} /> {Platform.OS === "android" ? ( - navigation.goBack()} /> + navigation.goBack()} + /> ) : null} - - - {flow.environments.map((environment, index) => ( - - } - isLast={index === flow.environments.length - 1} - onPress={() => { - void Haptics.selectionAsync(); - flow.selectEnvironment(environment.environmentId); - navigation.goBack(); - }} - selected={flow.selectedEnvironmentId === environment.environmentId} - title={environment.environmentLabel} - /> - ))} - - + + + + {flow.environments.map((environment, index) => ( + + } + isLast={index === flow.environments.length - 1} + onPress={() => { + void Haptics.selectionAsync(); + flow.selectEnvironment(environment.environmentId); + navigation.goBack(); + }} + selected={flow.selectedEnvironmentId === environment.environmentId} + title={environment.environmentLabel} + /> + ))} + + + ); } export function NewTaskBranchPickerRouteScreen() { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const flow = useNewTaskFlow(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); @@ -339,7 +361,7 @@ export function NewTaskBranchPickerRouteScreen() { const branchContent = flow.filteredBranches.length === 0 ? ( - navigation.goBack()} /> - + navigation.goBack()} + /> + - {branchContent} + {branchContent} ); } diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index d242b80157bb..bf8ab7897fc7 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -51,6 +51,8 @@ import { ComposerToolbarRow, } from "../../components/ComposerToolbar"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { MaterialScreenContent } from "../../components/MaterialScreenContent"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; import { composerStripAttachments } from "../../lib/composerImages"; @@ -183,6 +185,7 @@ export function NewTaskDraftScreen(props: { readonly incomingShareId?: string; }) { const projects = useProjects(); + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const flow = useNewTaskFlow(); const navigation = useNavigation(); const { @@ -1322,7 +1325,11 @@ export function NewTaskDraftScreen(props: { {Platform.OS === "android" ? ( <> - navigation.goBack()} /> + navigation.goBack()} + /> ) : ( @@ -1537,7 +1544,12 @@ export function NewTaskDraftScreen(props: { ); const composerDock = ( - + {!voiceInput.isBusy && composerMenu.trigger && (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request") ? ( @@ -1751,15 +1763,21 @@ export function NewTaskDraftScreen(props: { return ( - - {heroViewport} + + + {heroViewport} - - {composerDock} - + + {composerDock} + + ); } diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index e9bcb1291e39..2f8edd9e7394 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -13,6 +13,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { cn } from "../../lib/cn"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { MaterialScreenContent } from "../../components/MaterialScreenContent"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { AppText as Text } from "../../components/AppText"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { useProjects } from "../../state/entities"; @@ -83,6 +85,7 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): { } export function NewTaskRouteScreen({ route }: StaticScreenProps) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const projects = useProjects(); const { projectScopes, selectedEnvironmentId, setProject } = useNewTaskFlow(); const { state: catalogState } = useWorkspaceState(); @@ -183,6 +186,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps navigation.goBack() : undefined} actions={ @@ -226,97 +230,132 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps )} - - {projectScopes.length === 0 ? ( - - {projectEmptyState.loading ? ( - - ) : null} - - {projectEmptyState.title} - - - {projectEmptyState.detail} - - {!catalogState.hasReadyEnvironment ? ( - navigation.navigate("ConnectionsNew")} - > - - Add environment - - - ) : ( - navigation.dispatch(StackActions.push("AddProject"))} - > - - Add new project - - - )} - - ) : ( - - {projectScopes.map((scope, scopeIndex) => { - const hasMultipleProjects = scope.projects.length > 1; - const selectionTarget = getProjectScopeSelectionTarget(scope, selectedEnvironmentId); - return ( - 0 && "border-t border-border-subtle")} + + + {projectScopes.length === 0 ? ( + + {projectEmptyState.loading ? ( + + ) : null} + + {projectEmptyState.title} + + + {projectEmptyState.detail} + + {!catalogState.hasReadyEnvironment ? ( + navigation.navigate("ConnectionsNew")} + > + + Add environment + + + ) : ( + navigation.dispatch(StackActions.push("AddProject"))} > - void selectProject(selectionTarget)} - className="flex-row items-center gap-3 bg-card px-4 py-3.5" + + Add new project + + + )} + + ) : ( + + {projectScopes.map((scope, scopeIndex) => { + const hasMultipleProjects = scope.projects.length > 1; + const selectionTarget = getProjectScopeSelectionTarget( + scope, + selectedEnvironmentId, + ); + return ( + 0 && + "border-t border-border-subtle", + )} > - - void selectProject(selectionTarget)} + className={cn( + "flex-row items-center gap-3 bg-card px-4 py-3.5", + materialYouStyleLayoutActive && "min-h-16 rounded-[20px] active:bg-subtle", + )} + > + + + + + + {scope.title} + + + {hasMultipleProjects + ? `${scope.projects.length} workspaces` + : selectionTarget.workspaceRoot} + + + - - - {scope.title} - - {hasMultipleProjects - ? `${scope.projects.length} workspaces` - : selectionTarget.workspaceRoot} - - - - - - ); - })} - - )} - + + + ); + })} + + )} + + ); } diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 680c9cf0babe..7a8369419256 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -49,6 +49,7 @@ import Animated, { withTiming, } from "react-native-reanimated"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; +import { themeColorWithAlpha } from "../../lib/mobileTheme"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { @@ -242,7 +243,10 @@ export function ComposerSurface(props: { { overflow: "hidden", // Android versions before 9 do not support outset box shadows. - elevation: Platform.OS === "android" && Platform.Version < 28 ? 10 : undefined, + elevation: + !materialYouStyleLayoutActive && Platform.OS === "android" && Platform.Version < 28 + ? 10 + : undefined, }, ]} > @@ -627,7 +631,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer style={{ paddingTop: isExpanded ? 8 : 6, paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6), - backgroundColor: materialYouStyleLayoutActive ? composerPanel : undefined, + backgroundColor: materialYouStyleLayoutActive + ? themeColorWithAlpha(composerPanel, 1) + : undefined, }} > {/* The backdrop gradient lives on a plain View: Reanimated's Animated.View diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 66c4359b8190..4499f14096f7 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -877,6 +877,7 @@ function MarkdownCodeBlock(props: { > + {catalogState.isLoadingConnections ? "Loading threads…" : props.searchQuery.trim().length > 0 @@ -1269,6 +1275,7 @@ function ThreadNavigationSidebarPane( materialYouStyleLayoutActive ? { marginTop: stickyHeaderHeight, + marginHorizontal: 4, paddingBottom: insets.bottom, backgroundColor: screenColor, borderTopLeftRadius: 28, @@ -1278,41 +1285,45 @@ function ThreadNavigationSidebarPane( : { paddingBottom: insets.bottom } } > - - - item.type} - itemsAreEqual={sidebarItemsAreEqual} - keyExtractor={(item) => item.key} - renderItem={renderListItem} - contentContainerStyle={[ - styles.threadListContent, - materialYouStyleLayoutActive ? { paddingHorizontal: 0 } : null, - { - paddingBottom: - Platform.OS === "android" - ? Math.max(insets.bottom, 16) + - (materialYouStyleLayoutActive ? 148 : 88) - - insets.bottom - : 16 + insets.bottom, - paddingTop: materialYouStyleLayoutActive ? 6 : topListInset, - }, - ]} - keyboardDismissMode="on-drag" - keyboardShouldPersistTaps="handled" - {...scrollGateHandlers} - recycleItems - scrollEventThrottle={16} - showsVerticalScrollIndicator={false} - style={styles.threadList} - ListEmptyComponent={listEmpty} - /> - - + {materialYouStyleLayoutActive && listItems.length === 0 ? ( + {listEmpty} + ) : ( + + + item.type} + itemsAreEqual={sidebarItemsAreEqual} + keyExtractor={(item) => item.key} + renderItem={renderListItem} + contentContainerStyle={[ + styles.threadListContent, + materialYouStyleLayoutActive ? { paddingHorizontal: 0 } : null, + { + paddingBottom: + Platform.OS === "android" + ? Math.max(insets.bottom, 16) + + (materialYouStyleLayoutActive ? 148 : 88) - + insets.bottom + : 16 + insets.bottom, + paddingTop: materialYouStyleLayoutActive ? 6 : topListInset, + }, + ]} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + {...scrollGateHandlers} + recycleItems + scrollEventThrottle={16} + showsVerticalScrollIndicator={false} + style={styles.threadList} + ListEmptyComponent={listEmpty} + /> + + + )} {materialYouStyleLayoutActive ? ( @@ -1377,6 +1388,11 @@ function ThreadNavigationSidebarPane( onChangeText={props.onSearchQueryChange} placeholder="Search" placeholderTextColorClassName={"accent-placeholder"} + selectionColorClassName={Platform.OS === "android" ? "accent-primary/32" : undefined} + cursorColorClassName={Platform.OS === "android" ? "accent-primary" : undefined} + selectionHandleColorClassName={ + Platform.OS === "android" ? "accent-primary" : undefined + } returnKeyType="search" className="h-[34px] flex-1 px-0 py-0 font-sans text-base text-foreground" value={props.searchQuery} diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 2d4276606ec3..8bcf8e4bb158 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -53,6 +53,7 @@ import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { useNewTaskFlow } from "./new-task-flow-provider"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { MaterialScreenContent } from "../../components/MaterialScreenContent"; import { createProviderCatalogRefreshRunner, providerCatalogRefreshError, @@ -790,6 +791,7 @@ function ThreadSettingsOptionsItem(props: { function ThreadSettingsMainContent(props: { readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const session = useThreadSettingsSession(); const catalogItems = useThreadSettingsCatalogItems(session); const [animationsReady, setAnimationsReady] = useState(false); @@ -873,10 +875,22 @@ function ThreadSettingsMainContent(props: { accessibilityLabel="Find a model" autoCapitalize="none" autoCorrect={false} - className="h-11 rounded-xl bg-card px-4 text-base text-foreground" + className={ + materialYouStyleLayoutActive + ? "h-12 min-h-12 rounded-full border border-input-border bg-input px-4 py-0 text-base text-foreground" + : "h-11 rounded-xl bg-card px-4 text-base text-foreground" + } + style={ + materialYouStyleLayoutActive + ? { includeFontPadding: false, textAlignVertical: "center" } + : undefined + } onChangeText={session.setSearchQuery} placeholder="Find a model" placeholderTextColorClassName="accent-placeholder" + selectionColorClassName="accent-primary/32" + cursorColorClassName="accent-primary" + selectionHandleColorClassName="accent-primary" value={session.searchQuery} /> @@ -993,6 +1007,7 @@ function useThreadSettingsPickerPresentation() { } function ThreadSettingsModelsScreen() { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const session = useThreadSettingsSession(); const presentation = useThreadSettingsPickerPresentation(); const navigation = useNavigation>(); @@ -1076,6 +1091,7 @@ function ThreadSettingsModelsScreen() { ]} onBack={presentation.onClose} title="Thread settings" + hideBottomBorder={materialYouStyleLayoutActive} /> ) : null} - { - const title = - submenu.kind === "runtime" - ? "Runtime" - : (session.displayedDescriptors.find( - (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id, - )?.label ?? "Option"); - navigation.navigate("ThreadSettingsChoice", { ...submenu, title }); - }} - /> + + { + const title = + submenu.kind === "runtime" + ? "Runtime" + : (session.displayedDescriptors.find( + (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id, + )?.label ?? "Option"); + navigation.navigate("ThreadSettingsChoice", { ...submenu, title }); + }} + /> + >(); const route = useRoute>(); @@ -1199,9 +1218,18 @@ function ThreadSettingsChoiceScreen() { <> {Platform.OS === "android" ? ( - navigation.goBack()} /> + navigation.goBack()} + hideBottomBorder={materialYouStyleLayoutActive} + /> ) : null} - navigation.goBack()} /> + + navigation.goBack()} + /> + ); } diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index c66fc7887624..3fb5c79af4aa 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -1,10 +1,13 @@ import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useState } from "react"; -import { Platform, Pressable, ScrollView, View } from "react-native"; +import { Platform, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; +import { MaterialScreenContent } from "../../../components/MaterialScreenContent"; +import { NativeStackScreenOptions } from "../../../native/StackHeader"; +import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; import { cn } from "../../../lib/cn"; import { useEnvironmentQuery } from "../../../state/query"; @@ -22,7 +25,9 @@ type GitBranchesSheetProps = StaticScreenProps<{ export function GitBranchesSheet(_props: GitBranchesSheetProps) { const navigation = useNavigation(); + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const insets = useSafeAreaInsets(); + const { height: windowHeight } = useWindowDimensions(); const { selectedThread } = useThreadSelection(); const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); @@ -58,125 +63,225 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { const disabledExistingBranches = new Set(disabledExistingBranchNames); return ( - + {Platform.OS === "android" ? ( - navigation.goBack()} /> + ) : null} - - - - New branch - - - { - const branch = sanitizeFeatureBranchName(newBranchName.trim()); - if (branch.length === 0) return; - void gitActions.onCreateSelectedThreadBranch(branch).then(() => { - setNewBranchName(""); - navigation.goBack(); - }); - }} - /> - - - - - New worktree - - - - navigation.goBack()} + hideBottomBorder={materialYouStyleLayoutActive} + /> + ) : null} + + + { - const baseBranch = worktreeBaseBranch.trim(); - const newBranch = worktreeBranchName.trim(); - if (baseBranch.length === 0 || newBranch.length === 0) return; - void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => { - setWorktreeBranchName(""); - navigation.goBack(); - }); - }} - /> - - - - - Existing branches - - {branchesLoading ? ( - - Loading branches... + > + + New branch - ) : null} - {!branchesLoading && availableBranches.length === 0 ? ( - - No local branches found. + + { + const branch = sanitizeFeatureBranchName(newBranchName.trim()); + if (branch.length === 0) return; + void gitActions.onCreateSelectedThreadBranch(branch).then(() => { + setNewBranchName(""); + navigation.goBack(); + }); + }} + /> + + + + + New worktree - ) : null} - {availableBranches.map((branch) => { - const disabled = disabledExistingBranches.has(branch.name); - const subtitle = branch.worktreePath - ? branch.worktreePath === currentWorktreePath - ? "Checked out in this thread" - : "Checked out in another worktree" - : branch.isDefault - ? "Default branch" - : "Local branch"; + {materialYouStyleLayoutActive ? ( + Base branch + ) : null} + + {materialYouStyleLayoutActive ? ( + New branch + ) : null} + + { + const baseBranch = worktreeBaseBranch.trim(); + const newBranch = worktreeBranchName.trim(); + if (baseBranch.length === 0 || newBranch.length === 0) return; + void gitActions + .onCreateSelectedThreadWorktree({ baseBranch, newBranch }) + .then(() => { + setWorktreeBranchName(""); + navigation.goBack(); + }); + }} + /> + - return ( - + + Existing branches + + {branchesLoading ? ( + { - void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { - navigation.goBack(); - }); - }} > - - {branch.name} - {subtitle} - - ); - })} - - + Loading branches... + + ) : null} + {!branchesLoading && availableBranches.length === 0 ? ( + + No local branches found. + + ) : null} + {availableBranches.map((branch) => { + const disabled = disabledExistingBranches.has(branch.name); + const subtitle = branch.worktreePath + ? branch.worktreePath === currentWorktreePath + ? "Checked out in this thread" + : "Checked out in another worktree" + : branch.isDefault + ? "Default branch" + : "Local branch"; + + return ( + { + void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { + navigation.goBack(); + }); + }} + > + {!materialYouStyleLayoutActive ? ( + + ) : null} + + {branch.name} + + {subtitle} + + ); + })} + + + ); } diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index e76672de20f1..adfb1991f743 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -1,9 +1,13 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useState } from "react"; -import { Platform, Pressable, ScrollView, View } from "react-native"; +import { Platform, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; +import { SymbolView } from "../../../components/AppSymbol"; +import { MaterialScreenContent } from "../../../components/MaterialScreenContent"; +import { NativeStackScreenOptions } from "../../../native/StackHeader"; +import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; import { cn } from "../../../lib/cn"; import { useEnvironmentQuery } from "../../../state/query"; @@ -21,7 +25,9 @@ type GitCommitSheetProps = StaticScreenProps<{ export function GitCommitSheet(_props: GitCommitSheetProps) { const navigation = useNavigation(); + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const insets = useSafeAreaInsets(); + const { height: windowHeight } = useWindowDimensions(); const { selectedThread } = useThreadSelection(); const { selectedThreadCwd } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); @@ -66,168 +72,290 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { ); return ( - + {Platform.OS === "android" ? ( - navigation.goBack()} /> + ) : null} - - - - Branch - - {gitStatus.data?.refName ?? "(detached HEAD)"} - - - {isDefaultRef ? ( - - Warning: this is the default branch. - - ) : null} - - - - - - Files - - {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} - - - - {!allSelected && isEditingFiles ? ( - setExcludedFiles(new Set())} - > - Reset - - ) : null} - setIsEditingFiles((current) => !current)} + {Platform.OS === "android" ? ( + navigation.goBack()} + hideBottomBorder={materialYouStyleLayoutActive} + /> + ) : null} + + + + + Branch + - - {isEditingFiles ? "Done" : "Edit"} - - + {gitStatus.data?.refName ?? "(detached HEAD)"} + + {isDefaultRef ? ( + + Warning: this is the default branch. + + ) : null} - {allFiles.length === 0 ? ( - - No changed files are available to commit. - - ) : !isEditingFiles ? ( - - {selectedFilePreview.map((file) => ( - - - {file.path} - - +{file.insertions} - -{file.deletions} - - ))} - {selectedFiles.length > selectedFilePreview.length ? ( - - +{selectedFiles.length - selectedFilePreview.length} more files + + + + + Files - ) : null} - - ) : ( - - {allFiles.map((file) => { - const included = !excludedFiles.has(file.path); - return ( + + {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} + + + + {!allSelected && isEditingFiles ? ( { - setExcludedFiles((current) => { - const next = new Set(current); - if (next.has(file.path)) { - next.delete(file.path); - } else { - next.add(file.path); - } - return next; - }); - }} + className={ + materialYouStyleLayoutActive + ? "min-h-12 justify-center rounded-full px-3 active:bg-subtle" + : "bg-subtle rounded-full px-3 py-2" + } + onPress={() => setExcludedFiles(new Set())} > - - - - - {file.path} - - {!included ? ( - - Excluded from this commit - - ) : null} - - - - +{file.insertions} - - - -{file.deletions} - - - + + Reset + - ); - })} + ) : null} + setIsEditingFiles((current) => !current)} + > + + {isEditingFiles ? "Done" : "Edit"} + + + - )} - - - Commit message - - + {allFiles.length === 0 ? ( + + No changed files are available to commit. + + ) : !isEditingFiles ? ( + + {selectedFilePreview.map((file) => ( + + + {file.path} + + + +{file.insertions} + + -{file.deletions} + + ))} + {selectedFiles.length > selectedFilePreview.length ? ( + + +{selectedFiles.length - selectedFilePreview.length} more files + + ) : null} + + ) : ( + + {allFiles.map((file) => { + const included = !excludedFiles.has(file.path); + return ( + { + setExcludedFiles((current) => { + const next = new Set(current); + if (next.has(file.path)) { + next.delete(file.path); + } else { + next.add(file.path); + } + return next; + }); + }} + > + {!materialYouStyleLayoutActive ? ( + + ) : null} + + {materialYouStyleLayoutActive ? ( + + {included ? ( + + ) : null} + + ) : null} + + + {file.path} + + {!included ? ( + + Excluded from this commit + + ) : null} + + + + +{file.insertions} + + + -{file.deletions} + + + + + ); + })} + + )} + - - - void runCommitAction(true)} + + + Commit message + + - - void runCommitAction(false)} - /> + + + + void runCommitAction(true)} + /> + + + void runCommitAction(false)} + /> + - - + + ); } diff --git a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx index cddf1c614bd0..3e70fd396909 100644 --- a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx @@ -4,11 +4,14 @@ import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useMemo } from "react"; -import { Platform, View } from "react-native"; +import { Platform, ScrollView, useWindowDimensions, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; +import { MaterialScreenContent } from "../../../components/MaterialScreenContent"; +import { NativeStackScreenOptions } from "../../../native/StackHeader"; +import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; import { AppText as Text } from "../../../components/AppText"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; @@ -26,7 +29,9 @@ type GitConfirmSheetProps = StaticScreenProps<{ export function GitConfirmSheet(props: GitConfirmSheetProps) { const navigation = useNavigation(); + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const insets = useSafeAreaInsets(); + const { height: windowHeight } = useWindowDimensions(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); @@ -102,38 +107,94 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { ]); return ( - + {Platform.OS === "android" ? ( - navigation.goBack()} /> + + ) : null} + {Platform.OS === "android" ? ( + navigation.goBack()} + hideBottomBorder={materialYouStyleLayoutActive} + /> ) : ( )} - - - Confirm - - - {copy?.title ?? "Run action on default branch?"} - - - {copy?.description ?? "Choose how to continue."} - - + + + + {!materialYouStyleLayoutActive ? ( + + Confirm + + ) : null} + + {copy?.title ?? "Run action on default branch?"} + + + {copy?.description ?? "Choose how to continue."} + + - - void continuePendingAction()} - /> - void movePendingActionToFeatureBranch()} - /> - + + void continuePendingAction()} + /> + void movePendingActionToFeatureBranch()} + /> + + + ); } diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 881f461f29c6..63aa23477eb1 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -23,9 +23,18 @@ import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-scree import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useUniwindTheme } from "../../../lib/useUniwindTheme"; -import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; +import { + AndroidHeaderIconButton, + AndroidSheetHeader, +} from "../../../components/AndroidScreenHeader"; +import { MaterialScreenContent } from "../../../components/MaterialScreenContent"; +import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; +import { useAdaptiveWorkspaceLayout } from "../../layout/AdaptiveWorkspaceLayout"; import { AppText as Text } from "../../../components/AppText"; -import { nativeHeaderScrollEdgeEffects } from "../../../native/StackHeader"; +import { + NativeStackScreenOptions, + nativeHeaderScrollEdgeEffects, +} from "../../../native/StackHeader"; import { tryOpenExternalUrl } from "../../../lib/openExternalUrl"; import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; @@ -47,6 +56,8 @@ type GitOverviewSheetProps = StaticScreenProps<{ }; export function GitOverviewSheet(props: GitOverviewSheetProps) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + const { layout } = useAdaptiveWorkspaceLayout(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); const presentation = props.presentation ?? "sheet"; @@ -224,14 +235,14 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { const content = ( void handlePullRefresh()} /> @@ -239,14 +250,18 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { > {sheetMenuItems.map(({ item, disabledReason }, index) => ( - {index > 0 ? : null} + {index > 0 && !materialYouStyleLayoutActive ? ( + + ) : null} 0 ? ( <> - + {!materialYouStyleLayoutActive ? : null} ) : null} - + {!materialYouStyleLayoutActive ? : null} - + {!materialYouStyleLayoutActive ? : null} ( {chain.layers.length > 1 ? ( @@ -323,7 +342,9 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { ) : null} {chain.layers.map((link, index) => ( - {index > 0 ? : null} + {index > 0 && !materialYouStyleLayoutActive ? ( + + ) : null} + {!isInspector ? ( + + ) : null} {isInspector ? ( - void gitActions.refreshSelectedThreadGitStatus()} - > - - + + {materialYouStyleLayoutActive ? ( + + void gitActions.refreshSelectedThreadGitStatus()} + /> + + ) : ( + void gitActions.refreshSelectedThreadGitStatus()} + > + + + )} Repository @@ -457,6 +506,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { ) : ( navigation.goBack()} actions={[ @@ -470,7 +520,9 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { /> )} - {content} + + {content} + ); } diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx index 285c3414a9e9..87d151b59fe2 100644 --- a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx +++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx @@ -3,6 +3,7 @@ import type { ComponentProps } from "react"; import { Pressable, View } from "react-native"; import { AppText as Text } from "../../../components/AppText"; import { cn } from "../../../lib/cn"; +import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; /* ─── Shared sheet components ──────────────────────────────────────── */ @@ -13,6 +14,7 @@ export function SheetActionButton(props: { readonly tone?: "primary" | "secondary" | "danger"; readonly onPress: () => void; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const tone = props.tone ?? "secondary"; const textColorClassName = tone === "primary" @@ -24,25 +26,28 @@ export function SheetActionButton(props: { return ( + {props.label} @@ -76,32 +88,53 @@ export function SheetListRow(props: { readonly disabled?: boolean; readonly onPress: () => void; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); return ( - + - {props.title} + + {props.title} + {props.subtitle ? ( {props.subtitle} ) : null} - + {!materialYouStyleLayoutActive ? ( + + ) : null} ); } diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 095e3d933b7a..03b9e25ded72 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -759,7 +759,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { : pressed || hovered ? effectivePressedBackground : backgroundColor, - borderRadius: materialYouStyleLayoutActive ? 28 : SIDEBAR_ROW_RADIUS, + borderRadius: materialYouStyleLayoutActive ? 20 : SIDEBAR_ROW_RADIUS, cursor: "pointer", minHeight: 64, justifyContent: "center", @@ -820,7 +820,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { backgroundColor={backgroundColor} containerStyle={ materialYouStyleLayoutActive - ? { borderRadius: 28, overflow: "hidden", marginHorizontal: 8, marginVertical: 2 } + ? { borderRadius: 20, overflow: "hidden", marginHorizontal: 8, marginVertical: 2 } : compact ? undefined : { borderRadius: SIDEBAR_ROW_RADIUS, overflow: "hidden" } diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 91db5f7133cf..cc38661ba42f 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -951,7 +951,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : sidebarPane && !materialYouStyleLayoutActive ? drawerColor : screenColor, - borderRadius: materialYouStyleLayoutActive ? 28 : SIDEBAR_V2_ROW_RADIUS, + borderRadius: materialYouStyleLayoutActive ? 20 : SIDEBAR_V2_ROW_RADIUS, ...(sidebarPane ? { paddingHorizontal: 12, paddingVertical: 10 } : null), }) : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) @@ -997,7 +997,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : sidebarPane && !materialYouStyleLayoutActive ? drawerColor : screenColor, - borderRadius: materialYouStyleLayoutActive ? 28 : SIDEBAR_V2_ROW_RADIUS, + borderRadius: materialYouStyleLayoutActive ? 20 : SIDEBAR_V2_ROW_RADIUS, }) : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) } @@ -1075,7 +1075,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { compactActions={variant === "slim"} containerStyle={ materialYouStyleLayoutActive - ? { borderRadius: 28, overflow: "hidden", marginHorizontal: 8, marginVertical: 2 } + ? { borderRadius: 20, overflow: "hidden", marginHorizontal: 8, marginVertical: 2 } : sidebarPane ? { borderRadius: SIDEBAR_V2_ROW_RADIUS, overflow: "hidden" } : undefined diff --git a/apps/mobile/src/native/SelectableMarkdownText.android.tsx b/apps/mobile/src/native/SelectableMarkdownText.android.tsx index a59a039cbc92..35586489623d 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.android.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.android.tsx @@ -2,8 +2,11 @@ import { SelectableMarkdownText as T3SelectableMarkdownText, type SelectableMarkdownTextProps, } from "@t3tools/mobile-markdown-text/renderer"; +import { useMemo } from "react"; import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"; +import { themeColorWithAlpha } from "../lib/mobileTheme"; +import { useUniwindTheme } from "../lib/useUniwindTheme"; type MobileSelectableMarkdownTextProps = Omit; @@ -20,5 +23,18 @@ export function hasNativeSelectableMarkdownText(): boolean { } export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) { - return ; + const theme = useUniwindTheme(); + const selectionColor = themeColorWithAlpha(theme["--color-primary"], 0.32); + const selectionHandleColor = theme["--color-primary"]; + const textStyle = useMemo( + () => ({ selectionColor, selectionHandleColor, ...props.textStyle }), + [props.textStyle, selectionColor, selectionHandleColor], + ); + return ( + + ); } diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 92a4e1e2c180..490f47bd8a25 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -14,7 +14,7 @@ import { type Ref, } from "react"; import type { NativeSyntheticEvent, ViewProps } from "react-native"; -import { Image, StyleSheet } from "react-native"; +import { Image, Platform, StyleSheet } from "react-native"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { @@ -259,7 +259,7 @@ export function ComposerEditor({ ); const { systemColorsActive } = useAppearancePreferences(); const themeJson = JSON.stringify({ - selection: systemColorsActive ? theme["--color-primary"] : null, + selection: Platform.OS === "android" || systemColorsActive ? theme["--color-primary"] : null, text: theme["--color-foreground"], placeholder: theme["--color-placeholder"], chipBackground: theme["--color-subtle"], diff --git a/vite.config.ts b/vite.config.ts index b9f2c9cc2c4b..65add50d1686 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -155,6 +155,7 @@ export default defineConfig({ "apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx", "apps/mobile/src/features/files/FileMarkdownPreview.tsx", "apps/mobile/src/features/files/SourceFileSurface.tsx", + "apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx", "apps/mobile/src/features/files/AttachmentFileScreen.tsx", "apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx", "apps/mobile/src/features/files/thread-file-navigator-pane.tsx", @@ -175,6 +176,7 @@ export default defineConfig({ "apps/mobile/src/lib/useMobileNavigationTheme.ts", "apps/mobile/src/native/T3ComposerEditor.ios.tsx", "apps/mobile/src/native/T3ComposerEditor.native.tsx", + "apps/mobile/src/native/SelectableMarkdownText.android.tsx", ], rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }], From c2e1ec98840bdc82fc9dc588d690192c11167812 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Wed, 16 Sep 2026 00:39:27 -0700 Subject: [PATCH 10/39] fix(mobile): align Material search with native headers --- apps/mobile/src/features/files/MaterialFilesHeader.tsx | 6 +++--- .../src/features/home/MaterialThreadListToolbar.tsx | 8 ++++---- .../appearance/components/FontSizeSliderRow.shared.tsx | 3 ++- apps/mobile/src/features/threads/thread-list-items.tsx | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/features/files/MaterialFilesHeader.tsx b/apps/mobile/src/features/files/MaterialFilesHeader.tsx index 26f7ada8872d..a67c7e5a237d 100644 --- a/apps/mobile/src/features/files/MaterialFilesHeader.tsx +++ b/apps/mobile/src/features/files/MaterialFilesHeader.tsx @@ -57,11 +57,11 @@ export function MaterialFilesHeader(props: { } return ( - - + + diff --git a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx index 969d7407242d..cc5ff393f85d 100644 --- a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx @@ -106,17 +106,17 @@ export function MaterialThreadListToolbar(props: { onLayout={props.onLayout} className={ props.sidebar - ? "absolute inset-x-0 top-0 z-[4] bg-header px-3 pb-2.5" - : "bg-header px-3 pb-2.5" + ? "absolute inset-x-0 top-0 z-[4] bg-header px-2 pb-2" + : "bg-header px-2 pb-2" } style={{ paddingTop: Math.max(insets.top, 12) }} > - + {searching ? ( <> {searchField} diff --git a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.shared.tsx b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.shared.tsx index 7f33f9226d10..98a17388485c 100644 --- a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.shared.tsx +++ b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.shared.tsx @@ -53,7 +53,7 @@ export function FontSizeSliderRow(props: { const commit = useCallback((next: number) => { const current = latest.current; - if (next === current.value) { + if (current.disabled || next === current.value) { return; } Haptics.selectionAsync().catch(() => undefined); @@ -162,6 +162,7 @@ export function FontSizeSliderRow(props: { ]} accessibilityLabel={props.label} accessibilityRole="adjustable" + accessibilityState={{ disabled: Boolean(disabled) }} accessibilityValue={{ min, max, now: value, text: props.valueLabel }} className="h-11 flex-1 justify-center" onAccessibilityAction={handleAccessibilityAction} diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 6309b4d81b58..6d6b12792d87 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -679,7 +679,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { materialYouStyleLayoutActive ? { backgroundColor: visuallySelected ? effectiveSelectedBackground : backgroundColor, - borderRadius: 28, + borderRadius: 20, } : undefined } From fec0a811b3af6fe5efea7437781e1859ed1690b3 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Wed, 16 Sep 2026 00:46:39 -0700 Subject: [PATCH 11/39] style(mobile): format selection handle tint helpers --- .../t3markdowntext/T3MarkdownTextSelectionModule.kt | 8 ++++++-- .../modules/t3markdowntext/MarkdownSelectionColorTest.kt | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt index 43b3eef64c83..1936a4bb7dcb 100644 --- a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt +++ b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt @@ -168,8 +168,12 @@ private class SanitizingSelectionActionModeCallback( internal fun applySelectionHandleColor(textView: TextView, color: Int) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return textView.textSelectHandle?.mutate()?.apply { setTint(color) }?.let(textView::setTextSelectHandle) - textView.textSelectHandleLeft?.mutate()?.apply { setTint(color) }?.let(textView::setTextSelectHandleLeft) - textView.textSelectHandleRight?.mutate()?.apply { setTint(color) }?.let(textView::setTextSelectHandleRight) + textView.textSelectHandleLeft?.mutate()?.apply { + setTint(color) + }?.let(textView::setTextSelectHandleLeft) + textView.textSelectHandleRight?.mutate()?.apply { + setTint(color) + }?.let(textView::setTextSelectHandleRight) } class T3MarkdownTextSelectionModule : Module() { diff --git a/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt b/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt index 37b4aaba55bb..81703090a3f0 100644 --- a/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt +++ b/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt @@ -52,8 +52,12 @@ class MarkdownSelectionColorTest { @Test fun doesNotTintOtherTextViewsSharingDrawableState() { val original = ColorDrawable(Color.WHITE) - val first = textView().apply { setTextSelectHandleLeft(original.constantState!!.newDrawable()) } - val second = textView().apply { setTextSelectHandleLeft(original.constantState!!.newDrawable()) } + val first = textView().apply { + setTextSelectHandleLeft(original.constantState!!.newDrawable()) + } + val second = textView().apply { + setTextSelectHandleLeft(original.constantState!!.newDrawable()) + } applySelectionHandleColor(first, Color.MAGENTA) From f4483eef9eb5126aa8823f7e5e38c2269c2b6f9e Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Wed, 16 Sep 2026 00:55:31 -0700 Subject: [PATCH 12/39] fix(mobile): reserve space for Material FABs in thread list v2 --- .../T3MarkdownTextSelectionModule.kt | 18 +++++++++++------- apps/mobile/src/features/home/HomeScreen.tsx | 2 +- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt index 1936a4bb7dcb..bca3c6430304 100644 --- a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt +++ b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt @@ -195,17 +195,21 @@ class T3MarkdownTextSelectionModule : Module() { } }.fontMetricsInt + private fun setSelectionHandleColor(reactTag: Int, color: Int) { + val reactContext = appContext.reactContext as? ReactContext ?: return + reactContext.runOnUiQueueThread { + val textView = runCatching { + UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)?.resolveView(reactTag) + }.getOrNull() as? TextView ?: return@runOnUiQueueThread + applySelectionHandleColor(textView, color) + } + } + override fun definition() = ModuleDefinition { Name("T3MarkdownTextSelection") Function("setSelectionHandleColor") { reactTag: Int, color: Int -> - val reactContext = appContext.reactContext as? ReactContext ?: return@Function - reactContext.runOnUiQueueThread { - val textView = runCatching { - UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)?.resolveView(reactTag) - }.getOrNull() as? TextView ?: return@runOnUiQueueThread - applySelectionHandleColor(textView, color) - } + setSelectionHandleColor(reactTag, color) } Function("renderContextChip") { payloadJson: String -> diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 43270158b073..fcef5354ae90 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1247,7 +1247,7 @@ export function HomeScreen(props: HomeScreenProps) { paddingBottom: Platform.OS === "ios" ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance - : Math.max(insets.bottom, 16) + 88, + : Math.max(insets.bottom, 16) + (materialYouStyleLayoutActive ? 148 : 88), }} /> From df430b46610883f8b9d6c3ee3a8818560fa08b14 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 16 Sep 2026 00:59:07 -0700 Subject: [PATCH 13/39] fix(mobile): align Material navigation and accessible native actions --- apps/mobile/src/components/AppSymbol.tsx | 2 + apps/mobile/src/components/ControlPill.tsx | 22 +++ .../src/components/MaterialButton.android.tsx | 85 +++++++--- apps/mobile/src/components/MaterialButton.tsx | 1 + .../MaterialConfirmDialog.android.tsx | 50 +++++- .../components/MaterialIconButton.android.tsx | 7 +- .../MaterialSegmentedControl.android.tsx | 74 +++++---- .../src/components/SegmentedControl.tsx | 8 +- .../features/home/AndroidHomeFab.android.tsx | 54 ++++--- apps/mobile/src/features/home/HomeHeader.tsx | 139 ++++++++++------- .../features/projects/AddProjectScreen.tsx | 4 + .../AppearancePreferencesProvider.tsx | 3 +- .../threads/ThreadNavigationSidebar.tsx | 146 +++++++++++------- .../threads/git/gitSheetComponents.tsx | 15 ++ docs/user/appearance.md | 3 +- 15 files changed, 424 insertions(+), 189 deletions(-) diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 8bc6254d7c0a..db3bf5f73134 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -39,6 +39,7 @@ import IconCopy from "@tabler/icons-react-native/IconCopy"; import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop"; import IconDeviceLaptop from "@tabler/icons-react-native/IconDeviceLaptop"; import IconDots from "@tabler/icons-react-native/IconDots"; +import IconDotsVertical from "@tabler/icons-react-native/IconDotsVertical"; import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal"; import IconEdit from "@tabler/icons-react-native/IconEdit"; import IconExternalLink from "@tabler/icons-react-native/IconExternalLink"; @@ -211,6 +212,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME: Record = { keyboard_arrow_down: IconChevronDown, keyboard_arrow_up: IconChevronUp, keyboard_hide: IconKeyboardHide, + more_vert: IconDotsVertical, public: IconWorld, remove: IconMinus, terminal: IconTerminal2, diff --git a/apps/mobile/src/components/ControlPill.tsx b/apps/mobile/src/components/ControlPill.tsx index 4985d97cb533..36d373486599 100644 --- a/apps/mobile/src/components/ControlPill.tsx +++ b/apps/mobile/src/components/ControlPill.tsx @@ -26,6 +26,7 @@ import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; import { SymbolView } from "./AppSymbol"; import { AppText as Text } from "./AppText"; import { MaterialIconButton } from "./MaterialIconButton"; +import { MaterialButton } from "./MaterialButton"; const ThemedMenuView = withUniwind( function NativeMenuView({ @@ -123,6 +124,27 @@ export function ControlPill(props: { : "", ); + if ( + materialYouStyleLayoutActive && + (variant === "pill" || variant === "primary") && + props.label && + props.onPress && + !props.icon && + !props.iconNode && + !props.className && + !props.activateOnPressIn && + (!props.accessibilityLabel || props.accessibilityLabel === props.label) + ) { + return ( + + ); + } + if ( materialYouStyleLayoutActive && props.accessibilityLabel && diff --git a/apps/mobile/src/components/MaterialButton.android.tsx b/apps/mobile/src/components/MaterialButton.android.tsx index 3ca48fd10cc5..b5a8417fc799 100644 --- a/apps/mobile/src/components/MaterialButton.android.tsx +++ b/apps/mobile/src/components/MaterialButton.android.tsx @@ -1,5 +1,15 @@ -import { Button, FilledTonalButton, Host, Text, TextButton } from "@expo/ui/jetpack-compose"; -import { defaultMinSize, fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; +import { + Box, + Button, + CircularProgressIndicator, + FilledTonalButton, + Host, + Row, + Text, + TextButton, +} from "@expo/ui/jetpack-compose"; +import { defaultMinSize, fillMaxWidth, size } from "@expo/ui/jetpack-compose/modifiers"; +import { View } from "react-native"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; @@ -28,28 +38,57 @@ export function MaterialButton(props: MaterialButtonProps) { ? colors["--color-primary"] : colors["--color-secondary-foreground"]; return ( - { + if (!props.disabled && !props.loading) props.onPress(); + }} style={props.fullWidth ? { width: "100%" } : { alignSelf: "flex-start" }} > - - {props.label} - - + + + + + {props.loading ? ( + <> + + + + ) : null} + {props.label} + + + + + ); } diff --git a/apps/mobile/src/components/MaterialButton.tsx b/apps/mobile/src/components/MaterialButton.tsx index 5746f274e22f..f0ad4cb8f55c 100644 --- a/apps/mobile/src/components/MaterialButton.tsx +++ b/apps/mobile/src/components/MaterialButton.tsx @@ -5,6 +5,7 @@ export interface MaterialButtonProps { readonly label: string; readonly onPress: () => void; readonly disabled?: boolean; + readonly loading?: boolean; readonly tone?: "primary" | "secondary" | "danger" | "text"; readonly fullWidth?: boolean; } diff --git a/apps/mobile/src/components/MaterialConfirmDialog.android.tsx b/apps/mobile/src/components/MaterialConfirmDialog.android.tsx index 209e62f87505..ec4c05f9cb69 100644 --- a/apps/mobile/src/components/MaterialConfirmDialog.android.tsx +++ b/apps/mobile/src/components/MaterialConfirmDialog.android.tsx @@ -1,4 +1,11 @@ -import { AlertDialog, Host, Text, TextButton } from "@expo/ui/jetpack-compose"; +import { + AlertDialog, + Host, + OutlinedTextField, + Text, + TextButton, + useNativeState, +} from "@expo/ui/jetpack-compose"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; @@ -8,6 +15,14 @@ export function MaterialConfirmDialog(props: MaterialConfirmDialogProps) { const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); const titleTypography = useScaledTextRole("title"); const bodyTypography = useScaledTextRole("footnote"); + const inputTypography = useScaledTextRole("body"); + const inputState = useNativeState(props.inputInitialValue ?? ""); + const inputSelection = useNativeState({ start: 0, end: props.inputInitialValue?.length ?? 0 }); + const confirm = () => { + const value = props.inputInitialValue === undefined ? undefined : inputState.get(); + if (value !== undefined && !value.trim()) return; + props.onConfirm(value); + }; return ( {props.request.title} - {props.request.message ? ( + {props.inputInitialValue !== undefined ? ( + + { + if (value.trim()) props.onConfirm(value); + }, + }} + colors={{ + focusedTextColor: colors["--color-foreground"], + unfocusedTextColor: colors["--color-foreground"], + focusedIndicatorColor: colors["--color-primary"], + unfocusedIndicatorColor: colors["--color-border"], + cursorColor: colors["--color-primary"], + }} + > + + {props.request.title} + + + + ) : props.request.message ? ( {props.request.message} @@ -38,7 +81,8 @@ export function MaterialConfirmDialog(props: MaterialConfirmDialogProps) { {/* Keep RN SVG measurement outside Compose; the native button owns touch and ripple. */} - + ); diff --git a/apps/mobile/src/components/MaterialSegmentedControl.android.tsx b/apps/mobile/src/components/MaterialSegmentedControl.android.tsx index 3a12af2ead3a..ecff79e08c09 100644 --- a/apps/mobile/src/components/MaterialSegmentedControl.android.tsx +++ b/apps/mobile/src/components/MaterialSegmentedControl.android.tsx @@ -18,35 +18,51 @@ export function MaterialSegmentedControl( const typography = useScaledTextRole("footnote"); return ( - - - {props.options.map((option) => ( - props.onSelect(option.value)} - modifiers={[defaultMinSize({ minHeight: 48 })]} - colors={{ - activeContainerColor: colors["--color-secondary"], - activeContentColor: colors["--color-secondary-foreground"], - inactiveContainerColor: colors["--color-card"], - inactiveContentColor: colors["--color-foreground"], - activeBorderColor: colors["--color-border"], - inactiveBorderColor: colors["--color-border"], - }} - > - - {option.label} - - - ))} - - + + + + {props.options.map((option) => ( + props.onSelect(option.value)} + modifiers={[defaultMinSize({ minHeight: 48 })]} + colors={{ + activeContainerColor: colors["--color-secondary"], + activeContentColor: colors["--color-secondary-foreground"], + inactiveContainerColor: colors["--color-card"], + inactiveContentColor: colors["--color-foreground"], + activeBorderColor: colors["--color-border"], + inactiveBorderColor: colors["--color-border"], + }} + > + + {option.label} + + + ))} + + + + + {props.options.map((option) => ( + props.onSelect(option.value)} + className="flex-1" + /> + ))} + ); } diff --git a/apps/mobile/src/components/SegmentedControl.tsx b/apps/mobile/src/components/SegmentedControl.tsx index 632462d022bc..6fd6b621a381 100644 --- a/apps/mobile/src/components/SegmentedControl.tsx +++ b/apps/mobile/src/components/SegmentedControl.tsx @@ -26,13 +26,7 @@ export function SegmentedControl( const { materialYouStyleLayoutActive } = useAppearancePreferences(); const compact = props.size === "compact"; const tabs = materialYouStyleLayoutActive && props.role === "tab"; - // Compose owns selection semantics; keep the RN control when an abbreviated - // label needs a separate spoken label that the pinned native API cannot set. - if ( - materialYouStyleLayoutActive && - !tabs && - props.options.every((option) => !option.accessibilityLabel) - ) { + if (materialYouStyleLayoutActive && !tabs) { return ; } return ( diff --git a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx index b9979fa71c80..69823bf046ba 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx @@ -25,26 +25,40 @@ function MaterialHomeFab(props: ComponentProps {props.children} - - - - - - - - - New task - - - - - + + + + + + + + + + New task + + + + + + (null); + const focusSearch = useCallback(() => { + setSearchExpanded(true); + searchInputRef.current?.focus(); + return true; + }, []); + useHardwareKeyboardCommand("focusSearch", focusSearch); const insets = useSafeAreaInsets(); const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); // Thread List v2 lays the list out in fixed creation order, so the @@ -153,6 +161,10 @@ function AndroidHomeHeader(props: HomeHeaderProps) { const handleMenuAction = useCallback( (event: { nativeEvent: { event: string } }) => { const id = event.nativeEvent.event; + if (id === "settings") { + props.onOpenSettings(); + return; + } if (id === "environment:all") { props.onEnvironmentChange(null); return; @@ -205,7 +217,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { - + {/* Brand slot doubles as the connection status surface: while an environment reconnects, the lockup fades to a status label in place (no layout shift in the list below). */} @@ -239,24 +257,27 @@ function AndroidHomeHeader(props: HomeHeaderProps) { {materialYouStyleLayoutActive ? ( <> - + + {(open) => ( )} - ) : ( <> @@ -302,43 +323,57 @@ function AndroidHomeHeader(props: HomeHeaderProps) { )} - - - - {props.searchQuery.length > 0 ? ( - props.onSearchQueryChange("")} - > - 0 ? ( + + + + {materialYouStyleLayoutActive ? ( + { + props.onSearchQueryChange(""); + setSearchExpanded(false); + Keyboard.dismiss(); + }} /> - - ) : null} - + ) : props.searchQuery.length > 0 ? ( + props.onSearchQueryChange("")} + > + + + ) : null} + + ) : null} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index f1f25d3135e0..ac64fbb4b8ca 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -1,4 +1,6 @@ import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; +import { MaterialButton } from "../../components/MaterialButton"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { addProjectRemoteSourceLabel, addProjectRemoteSourcePathHint, @@ -219,6 +221,8 @@ function PrimaryActionButton(props: { readonly loading?: boolean; readonly onPress: () => void; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + if (materialYouStyleLayoutActive) return ; return ( (null); + const [searchExpanded, setSearchExpanded] = useState(false); const searchBarRef = useRef(null); const openSwipeableRef = useRef(null); const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); @@ -700,6 +702,10 @@ function ThreadNavigationSidebarPane( const handleListMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { const event = nativeEvent.event; + if (event === "settings") { + props.onOpenSettings(); + return; + } if (event === "environment:all") { setSelectedEnvironmentId(null); return; @@ -743,6 +749,7 @@ function ThreadNavigationSidebarPane( setProjectSortOrder, setSelectedEnvironmentId, setThreadSortOrder, + props.onOpenSettings, ], ); @@ -850,6 +857,7 @@ function ThreadNavigationSidebarPane( [], ); const focusSearch = useCallback(() => { + if (materialYouStyleLayoutActive) setSearchExpanded(true); const focus = () => { if (props.nativeChrome) { searchBarRef.current?.focus(); @@ -864,7 +872,7 @@ function ThreadNavigationSidebarPane( focus(); } return true; - }, [props.nativeChrome, props.onRequestVisibility, props.visible]); + }, [materialYouStyleLayoutActive, props.nativeChrome, props.onRequestVisibility, props.visible]); useHardwareKeyboardCommand("focusSearch", focusSearch); const renderListItem = useCallback( ({ item }: { readonly item: SidebarListItem }) => { @@ -1317,7 +1325,7 @@ function ThreadNavigationSidebarPane( - + {/* Title slot doubles as the connection status surface: while an environment reconnects, the brand fades to a status label in place (no layout shift in the list below). */} @@ -1339,59 +1353,87 @@ function ThreadNavigationSidebarPane( } /> - - - - - - + {materialYouStyleLayoutActive ? ( + + + + {(open) => ( + + )} + + + ) : ( + + + + + + + )} - - - 0 ? ( + - {materialYouStyleLayoutActive && props.searchQuery.length > 0 ? ( - props.onSearchQueryChange("")} - > - + + + {materialYouStyleLayoutActive ? ( + { + props.onSearchQueryChange(""); + setSearchExpanded(false); + Keyboard.dismiss(); + }} /> - - ) : null} - + ) : null} + + ) : null} ); diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx index 285c3414a9e9..4b69411df125 100644 --- a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx +++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx @@ -3,6 +3,8 @@ import type { ComponentProps } from "react"; import { Pressable, View } from "react-native"; import { AppText as Text } from "../../../components/AppText"; import { cn } from "../../../lib/cn"; +import { MaterialButton } from "../../../components/MaterialButton"; +import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; /* ─── Shared sheet components ──────────────────────────────────────── */ @@ -13,6 +15,19 @@ export function SheetActionButton(props: { readonly tone?: "primary" | "secondary" | "danger"; readonly onPress: () => void; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + if (materialYouStyleLayoutActive) + return ( + + + + ); const tone = props.tone ?? "secondary"; const textColorClassName = tone === "primary" diff --git a/docs/user/appearance.md b/docs/user/appearance.md index f682902586f1..b2fdd05b08f9 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -10,7 +10,8 @@ themes or defaults. On Android 12 or newer, choose the **Material You** theme in Appearance to use colors from your wallpaper. Selecting another theme replaces those colors. Like other themes, Material You can be selected separately for light and dark appearances. -Android also offers **Material You Layout**, which changes shapes and spacing independently +Android uses **Material You Layout** by default unless you have turned it off in Appearance. +It changes shapes, spacing, and controls independently of the selected theme. ## Motion From df1ffb37b53d55bc3d6042dedcfaffae9883d74b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 16 Sep 2026 01:12:10 -0700 Subject: [PATCH 14/39] fix(mobile): show Material pane state and match provider badge surfaces --- apps/mobile/src/components/AndroidScreenHeader.tsx | 6 +++++- apps/mobile/src/components/MaterialIconButton.android.tsx | 3 ++- apps/mobile/src/components/MaterialIconButton.tsx | 1 + apps/mobile/src/features/threads/ThreadRouteScreen.tsx | 6 +++++- apps/mobile/src/features/threads/thread-list-v2-items.tsx | 4 +++- 5 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index 01270e959987..897bcc9529c0 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -15,6 +15,7 @@ export interface AndroidHeaderAction { readonly icon: AppSymbolName; readonly onPress: () => void; readonly disabled?: boolean; + readonly selected?: boolean; } export function AndroidHeaderIconButton(props: { @@ -22,9 +23,11 @@ export function AndroidHeaderIconButton(props: { readonly icon: AppSymbolName; readonly onPress?: () => void; readonly disabled?: boolean; + readonly selected?: boolean; }) { const { materialYouStyleLayoutActive } = useAppearancePreferences(); - if (materialYouStyleLayoutActive) return ; + if (materialYouStyleLayoutActive) + return ; return ( diff --git a/apps/mobile/src/components/MaterialIconButton.android.tsx b/apps/mobile/src/components/MaterialIconButton.android.tsx index d403402edfa6..9cc6f5dc6ce0 100644 --- a/apps/mobile/src/components/MaterialIconButton.android.tsx +++ b/apps/mobile/src/components/MaterialIconButton.android.tsx @@ -15,6 +15,7 @@ export function MaterialIconButton(props: { readonly icon: AppSymbolName; readonly onPress?: () => void; readonly disabled?: boolean; + readonly selected?: boolean; readonly variant?: "standard" | "primary" | "tonal" | "danger"; }) { const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); @@ -45,7 +46,7 @@ export function MaterialIconButton(props: { accessible accessibilityLabel={props.accessibilityLabel} accessibilityRole="button" - accessibilityState={{ disabled: Boolean(props.disabled) }} + accessibilityState={{ disabled: Boolean(props.disabled), selected: props.selected }} accessibilityActions={[{ name: "activate" }]} onAccessibilityAction={() => { if (!props.disabled) props.onPress?.(); diff --git a/apps/mobile/src/components/MaterialIconButton.tsx b/apps/mobile/src/components/MaterialIconButton.tsx index 771943200459..7fe6fa244ecf 100644 --- a/apps/mobile/src/components/MaterialIconButton.tsx +++ b/apps/mobile/src/components/MaterialIconButton.tsx @@ -7,6 +7,7 @@ export function MaterialIconButton(props: { readonly icon: AppSymbolName; readonly onPress?: () => void; readonly disabled?: boolean; + readonly selected?: boolean; readonly variant?: "standard" | "primary" | "tonal" | "danger"; }) { return ( diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index fdca8e632381..90a5d5b1a093 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -746,8 +746,10 @@ function ThreadRouteContent( }); } if (selectedThreadCwd !== null) { + const filesVisible = inspectorMode === "files" && panes.auxiliaryPaneVisible; actions.push({ - accessibilityLabel: "Open files", + accessibilityLabel: filesVisible ? "Close files" : "Open files", + selected: filesVisible, icon: "folder", onPress: handleOpenFilesInspector, }); @@ -774,6 +776,8 @@ function ThreadRouteContent( return actions; }, [ fileInspector.supported, + inspectorMode, + panes.auxiliaryPaneVisible, handleOpenFilesInspector, handleOpenTerminal, handleOpenGitInspector, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index bfab4134d58c..c452c9d866a5 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -456,7 +456,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const providerIconSurfaceColor = sidebarPane ? selected ? selectedBackgroundColor - : drawerColor + : materialYouStyleLayoutActive + ? screenColor + : drawerColor : screenColor; const status = resolveThreadListV2Status(thread); From 5f462e8d15ed9e6b4ac77fc44db4014b69e1e6ee Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 16 Sep 2026 01:21:39 -0700 Subject: [PATCH 15/39] fix(mobile): preserve native dialog guards and action states --- apps/mobile/src/components/AndroidScreenHeader.tsx | 5 ++++- .../src/components/MaterialConfirmDialog.android.tsx | 7 +++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index 897bcc9529c0..3ec8ea21cbc9 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -157,7 +157,10 @@ export function AndroidScreenHeader(props: { actions={overflowActions.map((action, index) => ({ id: String(index), title: action.accessibilityLabel, - attributes: { disabled: Boolean(action.disabled) }, + attributes: { + disabled: Boolean(action.disabled), + state: action.selected ? "on" : undefined, + }, }))} onPressAction={({ nativeEvent }) => overflowActions[Number(nativeEvent.event)]?.onPress() diff --git a/apps/mobile/src/components/MaterialConfirmDialog.android.tsx b/apps/mobile/src/components/MaterialConfirmDialog.android.tsx index ec4c05f9cb69..3e65d7185ecc 100644 --- a/apps/mobile/src/components/MaterialConfirmDialog.android.tsx +++ b/apps/mobile/src/components/MaterialConfirmDialog.android.tsx @@ -19,6 +19,7 @@ export function MaterialConfirmDialog(props: MaterialConfirmDialogProps) { const inputState = useNativeState(props.inputInitialValue ?? ""); const inputSelection = useNativeState({ start: 0, end: props.inputInitialValue?.length ?? 0 }); const confirm = () => { + if (props.confirmDisabled) return; const value = props.inputInitialValue === undefined ? undefined : inputState.get(); if (value !== undefined && !value.trim()) return; props.onConfirm(value); @@ -52,9 +53,7 @@ export function MaterialConfirmDialog(props: MaterialConfirmDialogProps) { textStyle={inputTypography} keyboardOptions={{ imeAction: "done" }} keyboardActions={{ - onDone: (value) => { - if (value.trim()) props.onConfirm(value); - }, + onDone: confirm, }} colors={{ focusedTextColor: colors["--color-foreground"], @@ -85,7 +84,7 @@ export function MaterialConfirmDialog(props: MaterialConfirmDialogProps) { enabled={!props.confirmDisabled} colors={{ contentColor: - colors[props.request.destructive ? "--color-danger-foreground" : "--color-primary"], + colors[props.request.destructive ? "--color-danger" : "--color-primary"], }} > {props.request.confirmText} From 102afb5568ce246da744859caffa572164010250 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Wed, 16 Sep 2026 01:25:48 -0700 Subject: [PATCH 16/39] fix(mobile): restore Material brand inset and FAB hierarchy --- .../MaterialFloatingActionButton.android.tsx | 80 +++++++++++++++++++ .../MaterialFloatingActionButton.tsx | 46 +++++++++++ .../MaterialNewThreadButton.android.tsx | 17 ++++ .../home/MaterialThreadListToolbar.tsx | 12 +-- 4 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/src/components/MaterialFloatingActionButton.android.tsx create mode 100644 apps/mobile/src/components/MaterialFloatingActionButton.tsx create mode 100644 apps/mobile/src/components/MaterialNewThreadButton.android.tsx diff --git a/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx b/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx new file mode 100644 index 000000000000..d7116b234350 --- /dev/null +++ b/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx @@ -0,0 +1,80 @@ +import { + Box, + ExtendedFloatingActionButton, + FloatingActionButton, + Host, + LargeFloatingActionButton, + Text, +} from "@expo/ui/jetpack-compose"; +import { size } from "@expo/ui/jetpack-compose/modifiers"; +import { View, type StyleProp, type ViewStyle } from "react-native"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; +import { SymbolView, type AppSymbolName } from "./AppSymbol"; + +export function MaterialFloatingActionButton(props: { + readonly onPress: () => void; + readonly label: string; + readonly icon: AppSymbolName; + readonly variant?: "extended" | "large"; + readonly tone?: "primary" | "secondary"; + readonly className?: string; + readonly style?: StyleProp; +}) { + const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const typography = useScaledTextRole("footnote"); + const primary = props.tone === "primary"; + const containerColor = colors[primary ? "--color-primary" : "--color-thread-selected"]; + const contentColor = + colors[primary ? "--color-primary-foreground" : "--color-thread-selected-foreground"]; + const Component = + props.variant === "extended" + ? ExtendedFloatingActionButton + : props.variant === "large" + ? LargeFloatingActionButton + : FloatingActionButton; + const iconSize = props.variant === "large" ? 36 : 24; + return ( + + + + + + + + {props.variant === "extended" ? ( + + + {props.label} + + + ) : null} + + + + + + + + ); +} diff --git a/apps/mobile/src/components/MaterialFloatingActionButton.tsx b/apps/mobile/src/components/MaterialFloatingActionButton.tsx new file mode 100644 index 000000000000..143e7b4e99a5 --- /dev/null +++ b/apps/mobile/src/components/MaterialFloatingActionButton.tsx @@ -0,0 +1,46 @@ +import type { ComponentProps } from "react"; +import { Pressable } from "react-native"; +import type { MaterialFloatingActionButton as AndroidMaterialFloatingActionButton } from "./MaterialFloatingActionButton.android"; +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; +import { cn } from "../lib/cn"; + +export function MaterialFloatingActionButton( + props: ComponentProps, +) { + return ( + + + {props.variant === "extended" ? ( + + {props.label} + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/MaterialNewThreadButton.android.tsx b/apps/mobile/src/components/MaterialNewThreadButton.android.tsx new file mode 100644 index 000000000000..8d2caf704f42 --- /dev/null +++ b/apps/mobile/src/components/MaterialNewThreadButton.android.tsx @@ -0,0 +1,17 @@ +import type { ComponentProps } from "react"; +import { MaterialFloatingActionButton } from "./MaterialFloatingActionButton.android"; +import type { MaterialNewThreadButton as SharedMaterialNewThreadButton } from "./MaterialNewThreadButton.shared"; + +export function MaterialNewThreadButton( + props: ComponentProps, +) { + return ( + + ); +} diff --git a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx index cc5ff393f85d..e0ce8a8d809a 100644 --- a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx @@ -123,11 +123,13 @@ export function MaterialThreadListToolbar(props: { ) : ( <> - } - /> + + } + /> + Date: Wed, 16 Sep 2026 01:32:47 -0700 Subject: [PATCH 17/39] fix(mobile): bound Material project selection on tablets --- .../src/features/threads/NewTaskRouteScreen.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 2f8edd9e7394..268d2a1ffa7e 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -8,12 +8,14 @@ import { import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { useEffect, useRef } from "react"; -import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native"; +import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { cn } from "../../lib/cn"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { MaterialScreenContent } from "../../components/MaterialScreenContent"; +import { MaterialButton } from "../../components/MaterialButton"; +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { AppText as Text } from "../../components/AppText"; import { ProjectFavicon } from "../../components/ProjectFavicon"; @@ -262,7 +264,17 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps {projectEmptyState.detail} - {!catalogState.hasReadyEnvironment ? ( + {materialYouStyleLayoutActive ? ( + + catalogState.hasReadyEnvironment + ? navigation.dispatch(StackActions.push("AddProject")) + : navigation.navigate("ConnectionsNew") + } + /> + ) : !catalogState.hasReadyEnvironment ? ( navigation.navigate("ConnectionsNew")} From de3e08eb882372fede1ccc49fca5cbb32ef3af1a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 16 Sep 2026 02:42:12 -0700 Subject: [PATCH 18/39] fix(mobile): close Files from the active Android header action --- apps/mobile/src/features/threads/ThreadRouteScreen.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 90a5d5b1a093..9f904e35643a 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -751,7 +751,7 @@ function ThreadRouteContent( accessibilityLabel: filesVisible ? "Close files" : "Open files", selected: filesVisible, icon: "folder", - onPress: handleOpenFilesInspector, + onPress: filesVisible ? toggleAuxiliaryPane : handleOpenFilesInspector, }); } if (selectedThreadProject?.workspaceRoot) { @@ -782,6 +782,7 @@ function ThreadRouteContent( handleOpenTerminal, handleOpenGitInspector, handleToggleInspector, + toggleAuxiliaryPane, props.onReturnToThread, selectedThreadCwd, selectedThreadProject?.workspaceRoot, From d41e19d343fbc0c7b9302bce27e5482e20c53503 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 16 Sep 2026 03:42:54 -0700 Subject: [PATCH 19/39] fix(mobile): expose selected state in icon button fallbacks --- apps/mobile/src/components/MaterialIconButton.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/src/components/MaterialIconButton.tsx b/apps/mobile/src/components/MaterialIconButton.tsx index 7fe6fa244ecf..852889140ad5 100644 --- a/apps/mobile/src/components/MaterialIconButton.tsx +++ b/apps/mobile/src/components/MaterialIconButton.tsx @@ -14,6 +14,7 @@ export function MaterialIconButton(props: { From 90162dc325e453a15eacba231f993af492d55531 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 16 Sep 2026 10:22:47 -0700 Subject: [PATCH 20/39] fix(mobile): show the Material terminal keyboard icon --- apps/mobile/src/components/AppSymbol.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index db3bf5f73134..8a214392a2a2 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -146,6 +146,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "folder.fill": IconFolder, gearshape: IconSettings, "info.circle": IconInfoCircle, + keyboard: IconKeyboard, laptopcomputer: IconDeviceLaptop, link: IconLink, "line.3.horizontal": IconMenu2, From 7e4c6fceeb2b5053f43da210c97202ce0b851273 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 16 Sep 2026 10:33:48 -0700 Subject: [PATCH 21/39] fix(mobile): dismiss the native Android terminal keyboard --- .../src/features/terminal/ThreadTerminalRouteScreen.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index d15e5f94bd44..960f8a110887 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -522,6 +522,12 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) isVisible: state.isVisible, })); const isAccessoryVisible = keyboardState.isVisible && !isAccessoryDismissed; + // Android's terminal owns an EditText; turning off autoFocus also clears its native focus. + const terminalAutoFocus = + Platform.OS === "android" + ? !isAccessoryDismissed && + (!SHOWCASE_ENABLED || keyboardFocusRequest > 0 || keyboardState.isVisible) + : !SHOWCASE_ENABLED; const terminalBottomInset = (keyboardState.isVisible ? keyboardState.height : 0) + (isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0); @@ -1110,6 +1116,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }, []); const handleShowKeyboard = useCallback(() => { + setIsAccessoryDismissed(false); setKeyboardFocusRequest((current) => current + 1); }, []); const handleRetryEnvironment = useCallback(() => { @@ -1333,7 +1340,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }} /> Date: Wed, 16 Sep 2026 10:57:16 -0700 Subject: [PATCH 22/39] fix(mobile): expose wide Android workspace pane controls --- .../src/components/AndroidScreenHeader.tsx | 4 ++ .../features/files/MaterialFilesHeader.tsx | 4 +- .../features/files/ThreadFilesRouteScreen.tsx | 9 +++- .../src/features/home/HomeRouteScreen.tsx | 9 +++- .../layout/workspace-sidebar-toolbar.tsx | 18 +++++++ .../src/features/review/ReviewSheet.tsx | 50 +++++++++++-------- .../settings/components/SettingsScreen.tsx | 2 + .../terminal/ThreadTerminalRouteScreen.tsx | 15 +----- .../features/threads/ThreadRouteScreen.tsx | 26 ++++++---- 9 files changed, 91 insertions(+), 46 deletions(-) diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index 3ec8ea21cbc9..5fab8efa935d 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -32,6 +32,7 @@ export function AndroidHeaderIconButton(props: { ; + readonly leading?: ReactNode; readonly trailing?: ReactNode; readonly onBack?: () => void; readonly embedded?: boolean; @@ -119,6 +121,8 @@ export function AndroidScreenHeader(props: { ) ) : null} + {props.leading} + void; readonly onRefresh: () => void; readonly onBack?: () => void; + readonly leading?: ReactNode; }) { const insets = useSafeAreaInsets(); const searchRef = useRef(null); @@ -40,6 +41,7 @@ export function MaterialFilesHeader(props: { subtitle={props.projectName} hideBottomBorder onBack={props.onBack} + leading={props.leading} actions={[ { accessibilityLabel: "Search files", diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 2c822256e3cd..e074e455fd81 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -44,7 +44,10 @@ import { createNativeMailSearchToolbarItem, NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; -import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; +import { + AndroidWorkspaceSidebarButton, + WorkspaceSidebarToolbar, +} from "../layout/workspace-sidebar-toolbar"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; @@ -489,6 +492,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { {materialYouStyleLayoutActive ? ( } searchQuery={searchQuery} onSearchQueryChange={setSearchQuery} onRefresh={entriesQuery.refresh} @@ -498,6 +502,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { } onBack={handleReturnToThread} actions={[ { @@ -938,6 +943,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { } hideBottomBorder={materialYouStyleLayoutActive} onBack={handleBack} trailing={ @@ -948,6 +954,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator" } icon="sidebar.right" + selected={panes.auxiliaryPaneVisible} onPress={toggleAuxiliaryPane} /> ) : null} diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index b2da5af8ed26..514b145e85ea 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -11,7 +11,11 @@ import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; -import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; +import { + AndroidWorkspaceSidebarButton, + WorkspaceSidebarToolbar, +} from "../layout/workspace-sidebar-toolbar"; +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates"; import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; @@ -126,6 +130,9 @@ export function HomeRouteScreen() { /> } /> + {Platform.OS === "android" ? ( + } /> + ) : null} navigation.navigate("NewTaskSheet", { screen: "NewTask" })} /> diff --git a/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx b/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx index d07a003361ee..26677327575a 100644 --- a/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx +++ b/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx @@ -2,8 +2,26 @@ import { NativeHeaderToolbar } from "../../native/StackHeader"; import type { ReactNode } from "react"; import { Platform } from "react-native"; +import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader"; + import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; +export function AndroidWorkspaceSidebarButton() { + const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); + if (Platform.OS !== "android" || !layout.usesSplitView) return null; + + return ( + + ); +} + export function WorkspaceSidebarToolbar( props: { readonly children?: ReactNode; diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 43752365746b..77cf1d0e7228 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -56,7 +56,10 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useThreadSelection } from "../../state/use-thread-selection"; import { vcsEnvironment } from "../../state/vcs"; -import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; +import { + AndroidWorkspaceSidebarButton, + WorkspaceSidebarToolbar, +} from "../layout/workspace-sidebar-toolbar"; import { ThreadGitMenu } from "../threads/ThreadGitControls"; import { useReviewCacheForThread } from "./reviewState"; import { @@ -660,16 +663,8 @@ export function ReviewSheet(props: ReviewSheetProps) { parsedDiff.kind === "files" && NativeReviewDiffView !== null; useRegisterWorkspaceInspector(showChangedFilesPane ? renderInspector : undefined); - // Raw fallback renders the patch inline with no inspector content, so the - // pane toggle would open an empty column — hide it in exactly that case. - const showChangedFilesToggle = - panes.supportsAuxiliaryPane && - !( - !showConnectionNotice && - selectedSection !== null && - parsedDiff.kind === "files" && - NativeReviewDiffView === null - ); + // A toggle needs registered content; loading, errors and raw patches have no navigator pane. + const showChangedFilesToggle = panes.supportsAuxiliaryPane && showChangedFilesPane; const listHeader = useMemo(() => { const children: ReactElement[] = []; @@ -733,22 +728,35 @@ export function ReviewSheet(props: ReviewSheetProps) { {isAndroid ? ( } hideBottomBorder={materialYouStyleLayoutActive} subtitle={androidHeaderSubtitle || "Select a diff"} onBack={handleReturnToThread} trailing={ - showSectionToolbar ? ( - + <> + {showChangedFilesToggle ? ( - - ) : null + ) : null} + {showSectionToolbar ? ( + + + + ) : null} + } /> ) : null} diff --git a/apps/mobile/src/features/settings/components/SettingsScreen.tsx b/apps/mobile/src/features/settings/components/SettingsScreen.tsx index 40d978d81ebb..4aa1412d473c 100644 --- a/apps/mobile/src/features/settings/components/SettingsScreen.tsx +++ b/apps/mobile/src/features/settings/components/SettingsScreen.tsx @@ -6,6 +6,7 @@ import { AndroidScreenHeader } from "../../../components/AndroidScreenHeader"; import { MaterialScreenContent as SettingsScreenContent } from "../../../components/MaterialScreenContent"; import { NativeStackScreenOptions } from "../../../native/StackHeader"; import { useAppearancePreferences } from "../appearance/AppearancePreferencesProvider"; +import { AndroidWorkspaceSidebarButton } from "../../layout/workspace-sidebar-toolbar"; export { SettingsScreenContent }; @@ -30,6 +31,7 @@ export function SettingsScreen( {showAndroidHeader ? ( } actions={props.actions} trailing={props.trailing} onBack={() => navigation.goBack()} diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 960f8a110887..db063ab154c0 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -54,6 +54,7 @@ import { useThreadSelection } from "../../state/use-thread-selection"; import { useSelectedThreadDetail } from "../../state/use-thread-detail"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar"; import { TerminalSurface } from "./NativeTerminalSurface"; import { getMobileTerminalTheme } from "./terminalTheme"; import { terminalDebugLog } from "./terminalDebugLog"; @@ -1189,22 +1190,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) } onBack={handleCloseTerminal} trailing={ <> - {layout.usesSplitView ? ( - - ) : null} {isEnvironmentReady ? ( } + trailing={ + fileInspector.supported && selectedThreadCwd !== null ? ( + + ) : null + } onBack={ layout.usesSplitView ? undefined From 0cd7373b447b33436e2d4706b4256d5216bde881 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 16 Sep 2026 11:13:17 -0700 Subject: [PATCH 23/39] fix(mobile): preserve Material You colors in native code previews --- .../review/nativeReviewDiffAdapter.test.ts | 25 +++++++++++++++++++ .../review/nativeReviewDiffAdapter.ts | 20 ++++++++++----- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts index 7387adb567e7..21c86a2beab4 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts @@ -255,6 +255,31 @@ describe("createNativeReviewDiffTheme", () => { } }); + it.each(["light", "dark"] as const)( + "preserves Material You RGBA hex channels and composites alpha in %s", + (appearance) => { + const variables = { + ...appTheme("material-you", appearance), + "--color-screen": "#101214FF", + "--color-sheet": "#20222480", + "--color-md-code-text": "#E3E2E6FF", + "--color-foreground-muted": "#C7C5D080", + "--color-border": "#44464F80", + "--color-primary": "#A8C7FAFF", + }; + const theme = createNativeReviewDiffTheme(appearance, "material-you", variables); + expect(theme.background).toBe("#181a1c"); + expect(theme.headerBackground).toBe(theme.background); + expect(theme.text).toBe("#e3e2e6"); + expect(theme.mutedText).toBe("#707076"); + expect(theme.border).toBe("#2e3036"); + expect(theme.hunkText).toBe("#a8c7fa"); + for (const color of Object.values(theme)) { + expect(color).toMatch(/^#[\da-f]{6}$/i); + } + }, + ); + it("uses the selected app palette for native code surfaces", () => { const standard = createNativeReviewDiffTheme("dark", "t3-code", appTheme("t3-code", "dark")); const iris = createNativeReviewDiffTheme("dark", "iris", appTheme("iris", "dark")); diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index 6cc56e8ceab9..b35d561a249d 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -19,7 +19,7 @@ import type { ReviewInlineComment } from "./reviewCommentSelection"; const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4; const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45; -const NATIVE_HEX_COLOR = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i; +const NATIVE_HEX_COLOR = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})([\da-f]{2})?$/i; const NATIVE_RGBA_COLOR = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/; @@ -44,15 +44,19 @@ export function buildNativeReviewSnippetRows( function opaqueNativeHexColor(color: string, background: string): string { const hex = NATIVE_HEX_COLOR.exec(color); - if (hex) return color; + if (hex && !hex[4]) return color; const rgba = NATIVE_RGBA_COLOR.exec(color); const backgroundHex = NATIVE_HEX_COLOR.exec(background); - if (!rgba || !backgroundHex) return background; + if ((!hex && !rgba) || !backgroundHex) return background; - const alpha = rgba[4] === undefined ? 1 : Math.min(1, Math.max(0, Number(rgba[4]))); + const alpha = hex + ? Number.parseInt(hex[4] ?? "ff", 16) / 255 + : rgba?.[4] === undefined + ? 1 + : Math.min(1, Math.max(0, Number(rgba[4]))); const channels = [1, 2, 3].map((index) => { - const foreground = Number(rgba[index]); + const foreground = hex ? Number.parseInt(hex[index] ?? "0", 16) : Number(rgba?.[index]); const behind = Number.parseInt(backgroundHex[index] ?? "0", 16); return Math.round(foreground * alpha + behind * (1 - alpha)); }); @@ -169,7 +173,11 @@ export function createNativeReviewDiffTheme( // Swift expects #RRGGBB/#RRGGBBAA while Android expects #RRGGBB/#AARRGGBB. // Flatten translucent app tokens onto the code surface so both native // implementations receive the one unambiguous shared format. - const background = opaqueNativeHexColor(appTheme["--color-sheet"], appTheme["--color-screen"]); + const screen = opaqueNativeHexColor( + appTheme["--color-screen"], + scheme === "dark" ? "#000000" : "#ffffff", + ); + const background = opaqueNativeHexColor(appTheme["--color-sheet"], screen); const nativeColor = (color: string) => opaqueNativeHexColor(color, background); if (scheme === "dark") { From ddca508890c0bde26400f3d3a92a6a8bd9806149 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 16 Sep 2026 11:56:42 -0700 Subject: [PATCH 24/39] fix(mobile): theme the Android custom snooze dialog --- .../threads/CustomSnoozeSheet.android.tsx | 316 ++++++++++++++++++ .../threads/CustomSnoozeSheet.shared.tsx | 203 +++++++++++ .../features/threads/CustomSnoozeSheet.tsx | 193 +---------- .../features/threads/customSnoozeDate.test.ts | 36 ++ .../src/features/threads/customSnoozeDate.ts | 16 + 5 files changed, 572 insertions(+), 192 deletions(-) create mode 100644 apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx create mode 100644 apps/mobile/src/features/threads/CustomSnoozeSheet.shared.tsx create mode 100644 apps/mobile/src/features/threads/customSnoozeDate.test.ts create mode 100644 apps/mobile/src/features/threads/customSnoozeDate.ts diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx new file mode 100644 index 000000000000..df4fbad55ce5 --- /dev/null +++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx @@ -0,0 +1,316 @@ +import { + AlertDialog, + Column, + DatePickerDialog, + DateTimePicker, + Host, + OutlinedTextField, + SegmentedButton, + SingleChoiceSegmentedButtonRow, + Text, + TextButton, + useNativeState, +} from "@expo/ui/jetpack-compose"; +import { + defaultMinSize, + fillMaxWidth, + testID, + verticalScroll, +} from "@expo/ui/jetpack-compose/modifiers"; +import { + localSnoozeDate, + localSnoozeTime, + resolveCustomSnooze, + type CustomSnoozeInput, +} from "@t3tools/client-runtime/state/thread-settled"; +import { useState } from "react"; + +import { OverlayPortal } from "../../components/OverlayPortal"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; +import { CustomSnoozeSheet as LegacyCustomSnoozeSheet } from "./CustomSnoozeSheet.shared"; +import { + applySnoozePickerDate, + applySnoozePickerTime, + snoozeDateToPickerDate, +} from "./customSnoozeDate"; + +type Props = Parameters[0]; + +const modes = [ + { value: "date", label: "Date and time" }, + { value: "duration", label: "Duration" }, +] as const; +const units = [ + { value: "minutes", label: "Minutes" }, + { value: "hours", label: "Hours" }, + { value: "days", label: "Days" }, +] as const; + +export function CustomSnoozeSheet(props: Props) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + return materialYouStyleLayoutActive ? ( + + ) : ( + + ); +} + +function MaterialCustomSnoozeDialog(props: Props) { + const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const titleTypography = useScaledTextRole("title"); + const bodyTypography = useScaledTextRole("footnote"); + const inputTypography = useScaledTextRole("body"); + const [mode, setMode] = useState("date"); + const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000)); + const [picker, setPicker] = useState<"date" | "time" | null>(null); + const [pendingTime, setPendingTime] = useState(null); + const amount = useNativeState("2"); + const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); + const [error, setError] = useState(null); + const submit = () => { + const input: CustomSnoozeInput = + mode === "date" + ? { mode, date: localSnoozeDate(date), time: localSnoozeTime(date) } + : { mode, amount: amount.get().replace(",", "."), unit }; + const snoozedUntil = resolveCustomSnooze(input, new Date()); + if (!snoozedUntil) { + setError( + mode === "date" ? "Choose a date and time in the future." : "Enter a positive duration.", + ); + return; + } + props.onSnooze(snoozedUntil); + props.onClose(); + }; + const segmentedColors = { + activeContainerColor: colors["--color-secondary"], + activeContentColor: colors["--color-secondary-foreground"], + inactiveContainerColor: colors["--color-card-alt"], + inactiveContentColor: colors["--color-foreground"], + activeBorderColor: colors["--color-border"], + inactiveBorderColor: colors["--color-border"], + }; + const pickerColors = { + containerColor: colors["--color-card-alt"], + titleContentColor: colors["--color-foreground-secondary"], + headlineContentColor: colors["--color-foreground"], + weekdayContentColor: colors["--color-foreground-secondary"], + subheadContentColor: colors["--color-foreground"], + navigationContentColor: colors["--color-foreground"], + yearContentColor: colors["--color-foreground"], + dayContentColor: colors["--color-foreground"], + currentYearContentColor: colors["--color-primary"], + selectedYearContainerColor: colors["--color-primary"], + selectedYearContentColor: colors["--color-primary-foreground"], + selectedDayContainerColor: colors["--color-primary"], + selectedDayContentColor: colors["--color-primary-foreground"], + todayContentColor: colors["--color-primary"], + todayDateBorderColor: colors["--color-primary"], + dividerColor: colors["--color-border"], + clockDialColor: colors["--color-secondary"], + clockDialSelectedContentColor: colors["--color-primary-foreground"], + clockDialUnselectedContentColor: colors["--color-secondary-foreground"], + selectorColor: colors["--color-primary"], + periodSelectorSelectedContainerColor: colors["--color-secondary"], + periodSelectorSelectedContentColor: colors["--color-secondary-foreground"], + periodSelectorUnselectedContentColor: colors["--color-foreground"], + timeSelectorSelectedContainerColor: colors["--color-secondary"], + timeSelectorSelectedContentColor: colors["--color-secondary-foreground"], + timeSelectorUnselectedContainerColor: colors["--color-card"], + timeSelectorUnselectedContentColor: colors["--color-foreground"], + }; + return ( + // Recycled thread rows can detach a zero-sized native dialog host. + + + + + Custom snooze + + + + Choose when snoozed threads return to your inbox. + + {modes.map((option) => ( + { + setMode(option.value); + setPicker(null); + setError(null); + }} + > + + {option.label} + + + ))} + + {mode === "date" ? ( + <> + setPicker("date")} + > + {`Date: ${date.toLocaleDateString()}`} + + { + setPendingTime(date); + setPicker("time"); + }} + > + {`Time: ${date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`} + + + ) : ( + <> + setError(null)} + keyboardOptions={{ keyboardType: "decimal", imeAction: "done" }} + keyboardActions={{ onDone: submit }} + modifiers={[fillMaxWidth(), testID("snooze-duration")]} + colors={{ + focusedTextColor: colors["--color-foreground"], + unfocusedTextColor: colors["--color-foreground"], + focusedIndicatorColor: colors["--color-primary"], + unfocusedIndicatorColor: colors["--color-border"], + cursorColor: colors["--color-primary"], + }} + > + + Duration + + + + {units.map((option) => ( + { + setUnit(option.value); + setError(null); + }} + > + + {option.label} + + + ))} + + + )} + {error ? ( + + {error} + + ) : null} + + + + + Cancel + + + + + Snooze + + + + {picker === "date" ? ( + setPicker(null)} + onDateSelected={(selected) => { + setDate(applySnoozePickerDate(date, selected)); + setError(null); + setPicker(null); + }} + /> + ) : null} + {picker === "time" ? ( + setPicker(null)} + tonalElevation={0} + colors={{ + containerColor: colors["--color-card-alt"], + titleContentColor: colors["--color-foreground"], + }} + > + + Choose time + + + + + + setPicker(null)} + colors={{ contentColor: colors["--color-primary"] }} + > + Cancel + + + + { + setDate(applySnoozePickerTime(date, pendingTime ?? date)); + setError(null); + setPicker(null); + }} + > + OK + + + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.shared.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.shared.tsx new file mode 100644 index 000000000000..d1f17f514cde --- /dev/null +++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.shared.tsx @@ -0,0 +1,203 @@ +import { DateTimePicker } from "@expo/ui/community/datetime-picker"; +import { + localSnoozeDate, + localSnoozeTime, + resolveCustomSnooze, + type CustomSnoozeInput, +} from "@t3tools/client-runtime/state/thread-settled"; +import { useState } from "react"; +import { + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + ScrollView, + TextInput, + View, +} from "react-native"; +import { AppText } from "../../components/AppText"; +import { SegmentedControl } from "../../components/SegmentedControl"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { applySnoozePickerDate, snoozeDateToPickerDate } from "./customSnoozeDate"; + +export function CustomSnoozeSheet(props: { + readonly onClose: () => void; + readonly onSnooze: (snoozedUntil: string) => void; +}) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + const [mode, setMode] = useState("date"); + const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000)); + const [picker, setPicker] = useState<"date" | "time" | null>(null); + const [amount, setAmount] = useState("2"); + const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); + const [error, setError] = useState(null); + return ( + + + + + Custom snooze + + + Choose when snoozed threads return to your inbox. + + { + setMode(value); + setPicker(null); + setError(null); + }} + role="tab" + /> + {mode === "date" ? ( + + {(["date", "time"] as const).map((value) => ( + setPicker(value)} + > + {value === "date" ? "Date" : "Time"} + + {value === "date" + ? date.toLocaleDateString() + : date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} + + + ))} + {picker && ( + setPicker(null)} + onValueChange={(_, selected) => { + if (Platform.OS === "android" && picker === "date") { + setDate(applySnoozePickerDate(date, selected)); + setError(null); + setPicker(null); + return; + } + const next = new Date(date); + if (picker === "date") + next.setFullYear( + selected.getFullYear(), + selected.getMonth(), + selected.getDate(), + ); + else next.setHours(selected.getHours(), selected.getMinutes(), 0, 0); + setDate(next); + setError(null); + if (Platform.OS === "android") setPicker(null); + }} + /> + )} + + ) : ( + + Snooze for + { + setAmount(value); + setError(null); + }} + /> + + {(["minutes", "hours", "days"] as const).map((value) => ( + { + setUnit(value); + setError(null); + }} + > + + {value === "minutes" ? "Minutes" : value === "hours" ? "Hours" : "Days"} + + + ))} + + + )} + {error && ( + + {error} + + )} + + + Cancel + + { + const input: CustomSnoozeInput = + mode === "date" + ? { mode, date: localSnoozeDate(date), time: localSnoozeTime(date) } + : { mode, amount: amount.replace(",", "."), unit }; + const snoozedUntil = resolveCustomSnooze(input, new Date()); + if (!snoozedUntil) { + setError( + mode === "date" + ? "Choose a date and time in the future." + : "Enter a positive duration.", + ); + return; + } + props.onSnooze(snoozedUntil); + props.onClose(); + }} + > + Snooze + + + + + + ); +} diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.tsx index e2632b2df8d8..6ffc8054c5cb 100644 --- a/apps/mobile/src/features/threads/CustomSnoozeSheet.tsx +++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.tsx @@ -1,192 +1 @@ -import { DateTimePicker } from "@expo/ui/community/datetime-picker"; -import { - localSnoozeDate, - localSnoozeTime, - resolveCustomSnooze, - type CustomSnoozeInput, -} from "@t3tools/client-runtime/state/thread-settled"; -import { useState } from "react"; -import { - KeyboardAvoidingView, - Modal, - Platform, - Pressable, - ScrollView, - TextInput, - View, -} from "react-native"; -import { AppText } from "../../components/AppText"; -import { SegmentedControl } from "../../components/SegmentedControl"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; - -export function CustomSnoozeSheet(props: { - readonly onClose: () => void; - readonly onSnooze: (snoozedUntil: string) => void; -}) { - const { materialYouStyleLayoutActive } = useAppearancePreferences(); - const [mode, setMode] = useState("date"); - const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000)); - const [picker, setPicker] = useState<"date" | "time" | null>(null); - const [amount, setAmount] = useState("2"); - const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); - const [error, setError] = useState(null); - return ( - - - - - Custom snooze - - - Choose when snoozed threads return to your inbox. - - { - setMode(value); - setPicker(null); - setError(null); - }} - role="tab" - /> - {mode === "date" ? ( - - {(["date", "time"] as const).map((value) => ( - setPicker(value)} - > - {value === "date" ? "Date" : "Time"} - - {value === "date" - ? date.toLocaleDateString() - : date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} - - - ))} - {picker && ( - setPicker(null)} - onValueChange={(_, selected) => { - const next = new Date(date); - if (picker === "date") - next.setFullYear( - selected.getFullYear(), - selected.getMonth(), - selected.getDate(), - ); - else next.setHours(selected.getHours(), selected.getMinutes(), 0, 0); - setDate(next); - setError(null); - if (Platform.OS === "android") setPicker(null); - }} - /> - )} - - ) : ( - - Snooze for - { - setAmount(value); - setError(null); - }} - /> - - {(["minutes", "hours", "days"] as const).map((value) => ( - { - setUnit(value); - setError(null); - }} - > - - {value === "minutes" ? "Minutes" : value === "hours" ? "Hours" : "Days"} - - - ))} - - - )} - {error && ( - - {error} - - )} - - - Cancel - - { - const input: CustomSnoozeInput = - mode === "date" - ? { mode, date: localSnoozeDate(date), time: localSnoozeTime(date) } - : { mode, amount: amount.replace(",", "."), unit }; - const snoozedUntil = resolveCustomSnooze(input, new Date()); - if (!snoozedUntil) { - setError( - mode === "date" - ? "Choose a date and time in the future." - : "Enter a positive duration.", - ); - return; - } - props.onSnooze(snoozedUntil); - props.onClose(); - }} - > - Snooze - - - - - - ); -} +export { CustomSnoozeSheet } from "./CustomSnoozeSheet.shared"; diff --git a/apps/mobile/src/features/threads/customSnoozeDate.test.ts b/apps/mobile/src/features/threads/customSnoozeDate.test.ts new file mode 100644 index 000000000000..4486d2b7c7b5 --- /dev/null +++ b/apps/mobile/src/features/threads/customSnoozeDate.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + applySnoozePickerDate, + applySnoozePickerTime, + snoozeDateToPickerDate, +} from "./customSnoozeDate"; + +describe("custom snooze calendar conversion", () => { + it("passes the local calendar day to Compose without shifting it across time zones", () => { + const date = new Date(2026, 8, 17, 0, 30); + expect(snoozeDateToPickerDate(date)).toBe("2026-09-17T00:00:00.000Z"); + }); + + it("applies Compose's UTC calendar day while retaining the local time", () => { + const date = new Date(2026, 8, 16, 23, 45); + const next = applySnoozePickerDate(date, new Date("2026-09-20T00:00:00Z")); + expect([ + next.getFullYear(), + next.getMonth(), + next.getDate(), + next.getHours(), + next.getMinutes(), + ]).toEqual([2026, 8, 20, 23, 45]); + expect(date.getDate()).toBe(16); + }); + + it("applies a picked local time without changing the chosen calendar day", () => { + const date = new Date(2026, 8, 20, 23, 45); + const next = applySnoozePickerTime(date, new Date(2026, 8, 16, 8, 15, 30)); + expect([next.getDate(), next.getHours(), next.getMinutes(), next.getSeconds()]).toEqual([ + 20, 8, 15, 0, + ]); + expect(date.getHours()).toBe(23); + }); +}); diff --git a/apps/mobile/src/features/threads/customSnoozeDate.ts b/apps/mobile/src/features/threads/customSnoozeDate.ts new file mode 100644 index 000000000000..7e193401af9c --- /dev/null +++ b/apps/mobile/src/features/threads/customSnoozeDate.ts @@ -0,0 +1,16 @@ +/** Compose calendars exchange calendar days at UTC midnight, not local instants. */ +export function snoozeDateToPickerDate(date: Date): string { + return new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())).toISOString(); +} + +export function applySnoozePickerDate(date: Date, selected: Date): Date { + const next = new Date(date); + next.setFullYear(selected.getUTCFullYear(), selected.getUTCMonth(), selected.getUTCDate()); + return next; +} + +export function applySnoozePickerTime(date: Date, selected: Date): Date { + const next = new Date(date); + next.setHours(selected.getHours(), selected.getMinutes(), 0, 0); + return next; +} From 87058c2b70770442b971c07026746363df5552d1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 16 Sep 2026 12:24:21 -0700 Subject: [PATCH 25/39] fix(mobile): align Android thread settings with Material --- .../MaterialRadioIndicator.android.tsx | 22 ++++ .../src/components/MaterialRadioIndicator.tsx | 8 ++ .../features/threads/ThreadSettingsSheet.tsx | 120 ++++++++++++++---- 3 files changed, 122 insertions(+), 28 deletions(-) create mode 100644 apps/mobile/src/components/MaterialRadioIndicator.android.tsx create mode 100644 apps/mobile/src/components/MaterialRadioIndicator.tsx diff --git a/apps/mobile/src/components/MaterialRadioIndicator.android.tsx b/apps/mobile/src/components/MaterialRadioIndicator.android.tsx new file mode 100644 index 000000000000..8099181633a0 --- /dev/null +++ b/apps/mobile/src/components/MaterialRadioIndicator.android.tsx @@ -0,0 +1,22 @@ +import { Host, RadioButton } from "@expo/ui/jetpack-compose"; +import { size } from "@expo/ui/jetpack-compose/modifiers"; +import { View } from "react-native"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; + +/** The enclosing radio row owns selection, touch and accessibility. */ +export function MaterialRadioIndicator({ selected }: { readonly selected: boolean }) { + const { themeAppearance, themeVariables } = useAppearancePreferences(); + return ( + + + + + + ); +} diff --git a/apps/mobile/src/components/MaterialRadioIndicator.tsx b/apps/mobile/src/components/MaterialRadioIndicator.tsx new file mode 100644 index 000000000000..fb70e90b3439 --- /dev/null +++ b/apps/mobile/src/components/MaterialRadioIndicator.tsx @@ -0,0 +1,8 @@ +import { SymbolView } from "./AppSymbol"; + +/** The enclosing radio row owns selection, touch and accessibility. */ +export function MaterialRadioIndicator({ selected }: { readonly selected: boolean }) { + return selected ? ( + + ) : null; +} diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 8bcf8e4bb158..49c52ee4b5d7 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -36,6 +36,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { MaterialButton } from "../../components/MaterialButton"; +import { MaterialIconButton } from "../../components/MaterialIconButton"; +import { MaterialRadioIndicator } from "../../components/MaterialRadioIndicator"; import { ProviderIcon } from "../../components/ProviderIcon"; import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; @@ -118,6 +121,7 @@ function ModelRow(props: { }} disabled={props.option.isUnavailable} onPress={props.onPress} + style={materialYouStyleLayoutActive ? { minHeight: 56, paddingVertical: 12 } : undefined} className={cn( "mx-4 min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle", selectedMaterialRow && "bg-thread-selected", @@ -125,11 +129,12 @@ function ModelRow(props: { props.isLast ? "rounded-b-2xl" : "border-b border-border-subtle", )} > + {materialYouStyleLayoutActive ? : null} {props.option.label} @@ -148,12 +153,15 @@ function ModelRow(props: { ) : null} {props.option.subtitle ? ( - + {props.option.subtitle} ) : null} - {props.selected ? ( + {props.selected && !materialYouStyleLayoutActive ? ( void; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const content = ( <> @@ -206,6 +215,7 @@ function ProviderHeader(props: { accessibilityState={{ expanded: !props.collapsed }} className="mx-4 mt-1 min-h-11 flex-row items-center gap-2 rounded-xl px-1 pt-2 active:opacity-60" onPress={props.onToggle} + style={materialYouStyleLayoutActive ? { minHeight: 48 } : undefined} > {content} @@ -226,10 +236,12 @@ function DisclosureRow(props: { readonly onPress: () => void; readonly isLast?: boolean; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); return ( void; readonly isLast: boolean; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); return ( + {materialYouStyleLayoutActive ? : null} {props.label} {props.description ? ( {props.description} ) : null} - {props.selected ? ( + {props.selected && !materialYouStyleLayoutActive ? ( item.kind} itemLayoutAnimation={THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION} @@ -871,28 +891,53 @@ function ThreadSettingsMainContent(props: { {usesTransparentNativeHeader ? : null} {Platform.OS === "android" ? ( - + style={materialYouStyleLayoutActive ? { minHeight: 56 } : undefined} + > + {materialYouStyleLayoutActive ? ( + + + + ) : null} + + {materialYouStyleLayoutActive && session.searchQuery.length > 0 ? ( + session.setSearchQuery("")} + /> + ) : null} + ) : null} @@ -910,6 +955,7 @@ function ThreadSettingsChoiceContent(props: { readonly submenu: ThreadSettingsSubmenuPage; readonly onSelected: () => void; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const insets = useSafeAreaInsets(); const session = useThreadSettingsSession(); const descriptorId = props.submenu.kind === "descriptor" ? props.submenu.id : null; @@ -959,6 +1005,11 @@ function ThreadSettingsChoiceContent(props: { return ( + ) : undefined + } onBack={presentation.onClose} title="Thread settings" hideBottomBorder={materialYouStyleLayoutActive} From 8ee90b95051a3d13576d043f4d10e1d123f23916 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 16 Sep 2026 12:35:08 -0700 Subject: [PATCH 26/39] fix(mobile): keep picker selection colors on custom themes --- .../MaterialRadioIndicator.android.tsx | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/MaterialRadioIndicator.android.tsx b/apps/mobile/src/components/MaterialRadioIndicator.android.tsx index 8099181633a0..2484dc4536ba 100644 --- a/apps/mobile/src/components/MaterialRadioIndicator.android.tsx +++ b/apps/mobile/src/components/MaterialRadioIndicator.android.tsx @@ -6,12 +6,45 @@ import { useAppearancePreferences } from "../features/settings/appearance/Appear /** The enclosing radio row owns selection, touch and accessibility. */ export function MaterialRadioIndicator({ selected }: { readonly selected: boolean }) { - const { themeAppearance, themeVariables } = useAppearancePreferences(); + const { themeAppearance, themeVariables, systemColorsActive } = useAppearancePreferences(); + // Expo's radio button cannot override colors in the pinned SDK. Custom + // themes need their exact accent rather than a generated Material palette. + if (!systemColorsActive) { + return ( + + + {selected ? ( + + ) : null} + + + ); + } return ( From 96e9486b27ead7c4481e3b7702e460675082837e Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Wed, 16 Sep 2026 12:17:23 -0700 Subject: [PATCH 27/39] fix(mobile): respect Android clock format in custom snooze --- .../t3nativecontrols/T3NativeControlsModule.kt | 6 ++++++ .../threads/CustomSnoozeSheet.android.tsx | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt index baea4a9590e7..7a5e11319288 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt @@ -1,6 +1,7 @@ package expo.modules.t3nativecontrols import android.content.Intent +import android.text.format.DateFormat import androidx.core.content.FileProvider import expo.modules.kotlin.Promise import expo.modules.kotlin.modules.Module @@ -15,6 +16,11 @@ class T3NativeControlsModule : Module() { override fun definition() = ModuleDefinition { Name("T3NativeControls") + Function("is24HourFormat") { + val context = appContext.reactContext ?: error("The app is not active.") + DateFormat.is24HourFormat(context) + } + AsyncFunction("openFile") { uri: String, mimeType: String, promise: Promise -> check(filePreviewPromise == null) { "A document viewer is already open." } val activity = appContext.currentActivity ?: error("The app is not active.") diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx index df4fbad55ce5..c99e8f81da10 100644 --- a/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx +++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx @@ -23,7 +23,9 @@ import { resolveCustomSnooze, type CustomSnoozeInput, } from "@t3tools/client-runtime/state/thread-settled"; -import { useState } from "react"; +import { requireNativeModule } from "expo"; +import { useEffect, useState } from "react"; +import { AppState } from "react-native"; import { OverlayPortal } from "../../components/OverlayPortal"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -47,6 +49,10 @@ const units = [ { value: "days", label: "Days" }, ] as const; +function systemUses24HourClock() { + return requireNativeModule<{ is24HourFormat(): boolean }>("T3NativeControls").is24HourFormat(); +} + export function CustomSnoozeSheet(props: Props) { const { materialYouStyleLayoutActive } = useAppearancePreferences(); return materialYouStyleLayoutActive ? ( @@ -58,6 +64,13 @@ export function CustomSnoozeSheet(props: Props) { function MaterialCustomSnoozeDialog(props: Props) { const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const [is24Hour, setIs24Hour] = useState(systemUses24HourClock); + useEffect(() => { + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") setIs24Hour(systemUses24HourClock()); + }); + return () => subscription.remove(); + }, []); const titleTypography = useScaledTextRole("title"); const bodyTypography = useScaledTextRole("footnote"); const inputTypography = useScaledTextRole("body"); @@ -188,7 +201,7 @@ function MaterialCustomSnoozeDialog(props: Props) { > {`Time: ${date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`} + >{`Time: ${date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit", hourCycle: is24Hour ? "h23" : "h12" })}`} ) : ( @@ -284,6 +297,7 @@ function MaterialCustomSnoozeDialog(props: Props) { From 7ad1567d783824265e8874eac22200d4e54a14e5 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Wed, 16 Sep 2026 12:27:18 -0700 Subject: [PATCH 28/39] fix(mobile): simplify the Material empty environment state --- apps/mobile/src/components/EmptyState.tsx | 10 ++- .../features/home/AndroidHomeFab.android.tsx | 72 ++++------------- .../src/features/home/HomeRouteScreen.tsx | 11 +++ apps/mobile/src/features/home/HomeScreen.tsx | 12 +++ .../home/MaterialThreadListToolbar.tsx | 50 ++++++------ .../features/layout/WorkspaceEmptyDetail.tsx | 80 ++++++++++++------- .../threads/ThreadNavigationSidebar.tsx | 16 ++-- 7 files changed, 132 insertions(+), 119 deletions(-) diff --git a/apps/mobile/src/components/EmptyState.tsx b/apps/mobile/src/components/EmptyState.tsx index ce068bc46132..5fb4ce08f2f7 100644 --- a/apps/mobile/src/components/EmptyState.tsx +++ b/apps/mobile/src/components/EmptyState.tsx @@ -1,4 +1,5 @@ import { Pressable, View } from "react-native"; +import type { ReactNode } from "react"; import { AppText as Text } from "./AppText"; @@ -7,6 +8,7 @@ export function EmptyState(props: { readonly detail: string; readonly actionLabel?: string; readonly onAction?: () => void; + readonly action?: ReactNode; readonly variant?: "card" | "plain"; }) { if (props.variant === "plain") { @@ -16,7 +18,9 @@ export function EmptyState(props: { {props.detail} - {props.actionLabel && props.onAction ? ( + {props.action ? ( + {props.action} + ) : props.actionLabel && props.onAction ? ( {props.detail} - {props.actionLabel && props.onAction ? ( + {props.action ? ( + {props.action} + ) : props.actionLabel && props.onAction ? ( ) { const { materialYouStyleLayoutActive } = useAppearancePreferences(); - return materialYouStyleLayoutActive ? ( - - ) : ( - - ); -} - -function MaterialHomeFab(props: ComponentProps) { const insets = useSafeAreaInsets(); - const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); - const typography = useScaledTextRole("footnote"); + const { state } = useWorkspaceState(); + if (!materialYouStyleLayoutActive) return ; return ( {props.children} - - - - - - - - - - New task - - - - - - - - - + {state.hasConnections ? ( + + ) : null} ); } diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 514b145e85ea..f826638db44d 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -26,12 +26,14 @@ import { buildHomeProjectScopes } from "./homeThreadList"; import { usePendingTaskListActions } from "./usePendingTaskListActions"; import { useThreadListActions } from "./useThreadListActions"; import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; /* ─── Route screen ───────────────────────────────────────────────────── */ export function HomeRouteScreen() { const { width: windowWidth } = useWindowDimensions(); const { layout } = useAdaptiveWorkspaceLayout(); + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const projects = useProjects(); const threads = useThreadShells(); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); @@ -134,6 +136,15 @@ export function HomeRouteScreen() { } /> ) : null} + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironmentNew" }, + }) + : undefined + } onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} /> diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index fcef5354ae90..50836744f286 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -30,6 +30,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { cn } from "../../lib/cn"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; +import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; @@ -1121,6 +1122,17 @@ export function HomeScreen(props: HomeScreenProps) { detail={emptyState.detail} actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined} onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} + action={ + materialYouStyleLayoutActive && !props.catalogState.hasReadyEnvironment ? ( + + ) : undefined + } variant="plain" /> {emptyState.loading ? ( diff --git a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx index e0ce8a8d809a..8bad3faf2dcd 100644 --- a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx @@ -12,10 +12,13 @@ import type { MenuAction } from "@react-native-menu/menu"; import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader"; import { CompactBrandTitle } from "../../components/CompactBrandTitle"; +import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; +import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu"; import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle"; +import { useWorkspaceState } from "../../state/workspace"; /** One toolbar height for the compact list and expanded sidebar, including search. */ export function MaterialThreadListToolbar(props: { @@ -31,6 +34,7 @@ export function MaterialThreadListToolbar(props: { readonly onRequestVisibility?: () => void; }) { const insets = useSafeAreaInsets(); + const { state } = useWorkspaceState(); const { onRequestVisibility, onSearchQueryChange } = props; const searchRef = useRef(null); const [searchOpen, setSearchOpen] = useState(false); @@ -145,33 +149,27 @@ export function MaterialThreadListToolbar(props: { {/* Sit 8dp above the 56dp extended New thread FAB. */} - - - - - - - + + {(open) => ( + + )} + + + ) : null} ); } diff --git a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx index 716102273c37..e447e80d4fc1 100644 --- a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx +++ b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx @@ -4,8 +4,13 @@ import { Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { MaterialNewThreadButton } from "../../components/MaterialNewThreadButton"; +import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; +import { EmptyState } from "../../components/EmptyState"; -export function WorkspaceEmptyDetail(props: { readonly onStartNewTask?: () => void }) { +export function WorkspaceEmptyDetail(props: { + readonly onStartNewTask?: () => void; + readonly onAddConnection?: () => void; +}) { const { materialYouStyleLayoutActive } = useAppearancePreferences(); return ( vo : "flex-1 items-center justify-center bg-screen px-10" } > - - - Select a thread - - {materialYouStyleLayoutActive - ? "Choose a thread from the sidebar or start a new thread." - : "Choose a thread from the sidebar or start a new task."} - - {props.onStartNewTask ? ( - materialYouStyleLayoutActive ? ( - - ) : ( - - New Task - - ) - ) : null} - + {props.onAddConnection ? ( + + + } + /> + + ) : ( + + + Select a thread + + {materialYouStyleLayoutActive + ? "Choose a thread from the sidebar or start a new thread." + : "Choose a thread from the sidebar or start a new task."} + + {props.onStartNewTask ? ( + materialYouStyleLayoutActive ? ( + + ) : ( + + New Task + + ) + ) : null} + + )} ); } diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 97fb82cdbf74..13d8708f248b 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1180,13 +1180,15 @@ function ThreadNavigationSidebarPane( > {catalogState.isLoadingConnections ? "Loading threads…" - : props.searchQuery.trim().length > 0 - ? threadSearch.isPending - ? "Searching thread messages…" - : "No matching threads" - : selectedProjectScope !== null - ? `No threads in ${selectedProjectScope.title}` - : "No threads yet"} + : materialYouStyleLayoutActive && !catalogState.hasConnections + ? "No environments connected" + : props.searchQuery.trim().length > 0 + ? threadSearch.isPending + ? "Searching thread messages…" + : "No matching threads" + : selectedProjectScope !== null + ? `No threads in ${selectedProjectScope.title}` + : "No threads yet"} ); From c55bf52145b279a4926e00edecc8aa9e9e3f0b31 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Wed, 16 Sep 2026 12:36:21 -0700 Subject: [PATCH 29/39] fix(mobile): align Material header insets and avoid duplicate compose actions --- apps/mobile/src/features/home/HomeRouteScreen.tsx | 8 ++++++-- .../src/features/home/MaterialThreadListToolbar.tsx | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index f826638db44d..8571712d9097 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -32,7 +32,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe export function HomeRouteScreen() { const { width: windowWidth } = useWindowDimensions(); - const { layout } = useAdaptiveWorkspaceLayout(); + const { layout, panes } = useAdaptiveWorkspaceLayout(); const { materialYouStyleLayoutActive } = useAppearancePreferences(); const projects = useProjects(); const threads = useThreadShells(); @@ -145,7 +145,11 @@ export function HomeRouteScreen() { }) : undefined } - onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onStartNewTask={ + materialYouStyleLayoutActive && panes.primarySidebarVisible + ? undefined + : () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }) + } /> ); diff --git a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx index 8bad3faf2dcd..2f2c87edd140 100644 --- a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx @@ -127,7 +127,8 @@ export function MaterialThreadListToolbar(props: { ) : ( <> - + {/* Match the visible inset of the trailing 48dp icon button. */} + Date: Wed, 16 Sep 2026 13:04:44 -0700 Subject: [PATCH 30/39] fix(mobile): theme environment actions in Material layouts --- .../connection/ConnectionEnvironmentRow.tsx | 106 ++++++++++++------ .../connection/ConnectionsNewRouteScreen.tsx | 22 ++-- 2 files changed, 83 insertions(+), 45 deletions(-) diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 42d9cdddd0e4..a15520b8e091 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -11,7 +11,10 @@ import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanim import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; +import { MaterialButton } from "../../components/MaterialButton"; +import { MaterialIconButton } from "../../components/MaterialIconButton"; import { ThemedSwitch } from "../../components/ThemedSwitch"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; @@ -41,6 +44,7 @@ export function ConnectionEnvironmentRow(props: { updates: { readonly label: string; readonly displayUrl: string }, ) => Promise>; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const [label, setLabel] = useState(props.environment.environmentLabel); const [url, setUrl] = useState(props.environment.displayUrl); const serverConfig = useAtomValue( @@ -193,49 +197,79 @@ export function ConnectionEnvironmentRow(props: { )} - - {props.environment.isRelayManaged ? null : ( + {materialYouStyleLayoutActive ? ( + + {props.environment.isRelayManaged ? null : ( + + { + void handleSave(); + }} + /> + + )} + props.onReconnect(props.environment.environmentId)} + /> + props.onRemove(props.environment.environmentId)} + /> + + ) : ( + + {props.environment.isRelayManaged ? null : ( + + + + Save + + + )} + props.onReconnect(props.environment.environmentId)} > - - Save - - )} - - props.onReconnect(props.environment.environmentId)} - > - - - props.onRemove(props.environment.environmentId)} - > - - - + props.onRemove(props.environment.environmentId)} + > + + + + )} ) : null} diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index 477f3f64c1d9..3f817b0f3ebc 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -14,6 +14,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { SettingsScreen } from "../settings/components/SettingsScreen"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ErrorBanner } from "../../components/ErrorBanner"; import { ConnectionSheetButton } from "./ConnectionSheetButton"; @@ -36,6 +37,7 @@ export function ConnectionsNewRouteScreen({ pairingConnectionError, } = useRemoteConnections(); const navigation = useNavigation(); + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const routeName = useRoute().name; const params = route.params ?? {}; // Deep-link prefill exists for development automation only. A production @@ -294,15 +296,17 @@ export function ConnectionsNewRouteScreen({ {pairingConnectionError ? : null} - { - void handleSubmit(); - }} - /> + + { + void handleSubmit(); + }} + /> + )} From 38ac145dbeb3b11cc1ef0785f3a1dd9c28e2ce90 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Wed, 16 Sep 2026 14:03:43 -0700 Subject: [PATCH 31/39] fix(mobile): preserve Files header height while searching --- .../features/files/MaterialFilesHeader.tsx | 142 ++++++++++-------- 1 file changed, 76 insertions(+), 66 deletions(-) diff --git a/apps/mobile/src/features/files/MaterialFilesHeader.tsx b/apps/mobile/src/features/files/MaterialFilesHeader.tsx index 206315dad525..2ce953569b68 100644 --- a/apps/mobile/src/features/files/MaterialFilesHeader.tsx +++ b/apps/mobile/src/features/files/MaterialFilesHeader.tsx @@ -34,79 +34,89 @@ export function MaterialFilesHeader(props: { return () => subscription.remove(); }, [closeSearch, searching]); - if (!searching) { - return ( - setSearchOpen(true), - }, - { - accessibilityLabel: "Refresh files", - icon: "arrow.clockwise", - onPress: props.onRefresh, - }, - ]} - /> - ); - } - return ( - - - + {/* Keep the title/subtitle's natural height, including larger text, while searching. */} + + setSearchOpen(true), + }, + { + accessibilityLabel: "Refresh files", + icon: "arrow.clockwise", + onPress: props.onRefresh, + }, + ]} /> - - - - {props.searchQuery.length > 0 ? ( - { - onSearchQueryChange(""); - searchRef.current?.focus(); - }} - > + + {searching ? ( + + + + - - ) : null} + + {props.searchQuery.length > 0 ? ( + { + onSearchQueryChange(""); + searchRef.current?.focus(); + }} + > + + + ) : null} + + - + ) : null} ); } From b08ad565b321318e12f188029363b74fc545d66a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 16 Sep 2026 14:04:10 -0700 Subject: [PATCH 32/39] fix(mobile): restore Android creation and picker navigation --- .../src/features/threads/NewTaskDraftScreen.tsx | 4 ++++ .../src/features/threads/NewTaskRouteScreen.tsx | 2 +- .../src/features/threads/ThreadSettingsSheet.tsx | 11 ++++------- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index f70ae3b4e4c7..f0f9b1800e50 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1426,6 +1426,10 @@ export function NewTaskDraftScreen(props: { const closeNewTask = () => { void KeyboardController.dismiss({ animated: true }); + if (isAndroid) { + navigation.goBack(); + return; + } const parentNavigation = navigation.getParent(); if (parentNavigation) { parentNavigation.goBack(); diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 268d2a1ffa7e..986c2e7e29ff 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -190,7 +190,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps navigation.goBack() : undefined} + onBack={() => navigation.goBack()} actions={ catalogState.hasReadyEnvironment ? [ diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 49c52ee4b5d7..b5788a14853e 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -1145,12 +1145,8 @@ function ThreadSettingsModelsScreen() { : []), ]} trailing={ - materialYouStyleLayoutActive ? ( - + materialYouStyleLayoutActive && session.pendingModel ? ( + ) : undefined } onBack={presentation.onClose} @@ -1314,11 +1310,12 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) Date: Wed, 16 Sep 2026 14:08:20 -0700 Subject: [PATCH 33/39] fix(mobile): use native Material dropdown menus --- .../src/components/AndroidAnchoredMenu.tsx | 232 +++++++++--------- apps/mobile/src/components/ControlPill.tsx | 6 +- .../components/MaterialMenuPopup.android.tsx | 153 ++++++++++++ .../src/components/MaterialMenuPopup.tsx | 22 ++ 4 files changed, 299 insertions(+), 114 deletions(-) create mode 100644 apps/mobile/src/components/MaterialMenuPopup.android.tsx create mode 100644 apps/mobile/src/components/MaterialMenuPopup.tsx diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx index ed20ca1b7f20..49e2af813a21 100644 --- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -12,6 +12,7 @@ import { type AppSymbolName, SymbolView } from "./AppSymbol"; import { AppText as Text } from "./AppText"; import { OverlayPortal } from "./OverlayPortal"; import { GlassBackdrop } from "./GlassBackdrop"; +import { MaterialMenuPopup } from "./MaterialMenuPopup"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; const MENU_WIDTH = 250; @@ -28,6 +29,7 @@ type AnchorSnapshot = { readonly y: number; readonly width: number; readonly height: number; + readonly keyboardWasVisible: boolean; }; type OverlayFrame = { @@ -54,16 +56,13 @@ export type AndroidAnchoredMenuProps = { }; /** - * Token-styled anchored dropdown for Android, drop-in for the subset of the - * MenuView contract the app uses (actions with state/subtitle/image/ - * attributes, one level of subactions). The native AppCompat PopupMenu caps - * out on theming — stock animation, item metrics, and submenu chrome — so - * ControlPillMenu renders this instead on Android while iOS keeps the native - * UIMenu. Styling follows the themed native popup (12dp radius, plain rows, - * trailing check glyph); submenus drill in under a muted parent-title header. + * Adapts the app's MenuView actions to Material dropdowns on Android. Editor + * menus render native Material rows in-window to retain keyboard focus; other + * menus use the native popup for placement, animation and dismissal. The + * non-Material appearance keeps the glass overlay. */ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { - const { materialYouStyleLayoutActive, themeVariables } = useAppearancePreferences(); + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const [anchor, setAnchor] = useState(null); const [path, setPath] = useState([]); // Height of the modal's root view, in the modal's own coordinate space. @@ -91,9 +90,9 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const open = useCallback(() => { anchorRef.current?.measureInWindow((x, y, width, height) => { - setAnchor({ x, y, width, height }); + setAnchor({ x, y, width, height, keyboardWasVisible: keyboardVisible }); }); - }, []); + }, [keyboardVisible]); const measureOverlay = useCallback(() => { overlayRef.current?.measureInWindow((x, y, width, height) => { @@ -102,18 +101,11 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { }); }, []); - // The dropdown renders in-window (no Modal takes focus), so the hardware - // back gesture needs explicit handling while it is open. Back steps out of - // a drilled-in submenu one level at a time (mirroring the tappable parent - // header) before closing the menu. Under predictive back - // (enableOnBackInvokedCallback) this stays correct: back reaches JS - // through always-registered OnBackPressedDispatcher callbacks (react-native - // core on Android 16+, withAndroidPredictiveBackCompat on 13-15), which - // also keeps the system from playing a "leave app" preview while the menu - // merely closes. + // The native popup owns back dismissal. In-window menus need a handler; + // back returns to the parent submenu before closing the overlay. const submenuDepth = path.length; useEffect(() => { - if (anchor === null) { + if (anchor === null || (materialYouStyleLayoutActive && !anchor.keyboardWasVisible)) { return; } const subscription = BackHandler.addEventListener("hardwareBackPress", () => { @@ -125,7 +117,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { return true; }); return () => subscription.remove(); - }, [anchor, close, submenuDepth]); + }, [anchor, close, materialYouStyleLayoutActive, submenuDepth]); const parent = path[path.length - 1] ?? null; const levelActions = (parent?.subactions ?? props.actions).filter( @@ -214,14 +206,25 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { onLayout={measureOverlay} > - {!placeable || local === null ? null : ( + {!placeable || local === null ? null : materialYouStyleLayoutActive && + !anchor.keyboardWasVisible ? ( + setPath((current) => current.slice(0, -1))} + onClose={close} + /> + ) : ( - {parent !== null ? ( - // Muted parent title as the submenu header; tapping it - // steps back, but it reads as a label, not a button. - setPath((current) => current.slice(0, -1))} - > - - {parent.title} - - - ) : props.title ? ( + {materialYouStyleLayoutActive ? ( + setPath((current) => current.slice(0, -1))} + onClose={close} + /> + ) : ( <> - - - {props.title} - - - - - ) : null} - {levelActions.map((action, index) => { - const destructive = action.attributes?.destructive ?? false; - const disabled = action.attributes?.disabled ?? false; - const hasSubmenu = (action.subactions?.length ?? 0) > 0; - return ( - onPressItem(action)} - > - - setPath((current) => current.slice(0, -1))} + > + + {parent.title} + + + ) : props.title ? ( + <> + + + {props.title} + + + + + ) : null} + {levelActions.map((action, index) => { + const destructive = action.attributes?.destructive ?? false; + const disabled = action.attributes?.disabled ?? false; + const hasSubmenu = (action.subactions?.length ?? 0) > 0; + return ( + onPressItem(action)} > - {action.title} - - {action.subtitle ? ( - - {action.subtitle} - - ) : null} - - {hasSubmenu ? ( - - ) : action.state === "on" ? ( - - ) : action.image ? ( - - ) : null} - - ); - })} + + + {action.title} + + {action.subtitle ? ( + + {action.subtitle} + + ) : null} + + {hasSubmenu ? ( + + ) : action.state === "on" ? ( + + ) : action.image ? ( + + ) : null} + + ); + })} + + )} )} diff --git a/apps/mobile/src/components/ControlPill.tsx b/apps/mobile/src/components/ControlPill.tsx index 36d373486599..b87596e91c2c 100644 --- a/apps/mobile/src/components/ControlPill.tsx +++ b/apps/mobile/src/components/ControlPill.tsx @@ -190,10 +190,8 @@ export function ControlPill(props: { ); } -// iOS renders the native UIMenu (standard checkmark for `state: "on"`); -// Android renders the token-styled AndroidAnchoredMenu, since the native -// AppCompat popup can't be themed past its stock animation, metrics, and -// submenu chrome. +// iOS renders UIMenu; AndroidAnchoredMenu adapts the same actions to the +// selected Android appearance and keeps editor menus above the keyboard. export function ControlPillMenu( props: Omit, "children" | "themeVariant"> & Pick & { diff --git a/apps/mobile/src/components/MaterialMenuPopup.android.tsx b/apps/mobile/src/components/MaterialMenuPopup.android.tsx new file mode 100644 index 000000000000..87b467af9675 --- /dev/null +++ b/apps/mobile/src/components/MaterialMenuPopup.android.tsx @@ -0,0 +1,153 @@ +import { + Box, + Column, + DropdownMenu, + DropdownMenuItem, + Host, + RNHostView, + Text, +} from "@expo/ui/jetpack-compose"; +import { padding, size, width } from "@expo/ui/jetpack-compose/modifiers"; +import { View } from "react-native"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import type { MaterialMenuPopupProps } from "./MaterialMenuPopup"; +import { SymbolView, type AppSymbolName } from "./AppSymbol"; + +function MenuIcon(props: { + readonly name: AppSymbolName; + readonly destructive?: boolean; + readonly disabled?: boolean; +}) { + return ( + + + + + + ); +} + +/** Native popup positioned at the original trigger, outside virtualized rows. */ +export function MaterialMenuPopup(props: MaterialMenuPopupProps) { + const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const foreground = colors["--color-foreground"]; + const muted = colors["--color-foreground-muted"]; + const items = ( + <> + {props.parent ? ( + + + + + + + {props.parent.title} + + + + ) : props.title ? ( + + {props.title} + + ) : null} + {props.actions.map((action, index) => ( + props.onPress(action)} + > + + + + {action.title} + + {action.subtitle ? ( + + {action.subtitle} + + ) : null} + + + {action.image ? ( + + + + ) : null} + {(action.subactions?.length ?? 0) > 0 ? ( + + + + ) : action.state === "on" ? ( + + + + ) : null} + + ))} + + ); + if (props.inline) { + return ( + + {items} + + ); + } + return ( + + + + + + {items} + + + ); +} diff --git a/apps/mobile/src/components/MaterialMenuPopup.tsx b/apps/mobile/src/components/MaterialMenuPopup.tsx new file mode 100644 index 000000000000..3a23e5de7ab4 --- /dev/null +++ b/apps/mobile/src/components/MaterialMenuPopup.tsx @@ -0,0 +1,22 @@ +import type { MenuAction } from "@react-native-menu/menu"; + +export interface MaterialMenuPopupProps { + readonly anchor: { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + }; + readonly actions: readonly MenuAction[]; + readonly title?: string; + readonly parent: MenuAction | null; + readonly onPress: (action: MenuAction) => void; + readonly onBack: () => void; + readonly onClose: () => void; + /** Keep the editor's window focus and keyboard while showing native menu rows. */ + readonly inline?: boolean; +} + +export function MaterialMenuPopup(_props: MaterialMenuPopupProps) { + return null; +} From ed81c4ae6fde8e8d539b3f0ca3704da99a05b56c Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Wed, 16 Sep 2026 16:00:38 -0700 Subject: [PATCH 34/39] fix(mobile): retain integrated Material controls after rebase --- .../src/components/MaterialConfirmDialog.tsx | 10 ++++- .../MaterialNewThreadButton.shared.tsx | 39 ++++++++++++++++++ .../components/MaterialNewThreadButton.tsx | 40 +------------------ .../features/home/AndroidHomeFab.shared.tsx | 2 + 4 files changed, 50 insertions(+), 41 deletions(-) create mode 100644 apps/mobile/src/components/MaterialNewThreadButton.shared.tsx diff --git a/apps/mobile/src/components/MaterialConfirmDialog.tsx b/apps/mobile/src/components/MaterialConfirmDialog.tsx index cad305343482..c21ad042c39c 100644 --- a/apps/mobile/src/components/MaterialConfirmDialog.tsx +++ b/apps/mobile/src/components/MaterialConfirmDialog.tsx @@ -1,9 +1,15 @@ import type { ConfirmDialogRequest } from "./ConfirmDialogHost"; export interface MaterialConfirmDialogProps { - readonly request: ConfirmDialogRequest; + readonly request: Pick< + ConfirmDialogRequest, + "title" | "message" | "cancelText" | "confirmText" | "destructive" + >; + readonly inputInitialValue?: string; + readonly onInputChange?: (value: string) => void; + readonly confirmDisabled?: boolean; readonly onCancel: () => void; - readonly onConfirm: () => void; + readonly onConfirm: (inputValue?: string) => void; } export function MaterialConfirmDialog(_props: MaterialConfirmDialogProps) { diff --git a/apps/mobile/src/components/MaterialNewThreadButton.shared.tsx b/apps/mobile/src/components/MaterialNewThreadButton.shared.tsx new file mode 100644 index 000000000000..7a5bc01d54aa --- /dev/null +++ b/apps/mobile/src/components/MaterialNewThreadButton.shared.tsx @@ -0,0 +1,39 @@ +import { Pressable, type StyleProp, type ViewStyle } from "react-native"; + +import { cn } from "../lib/cn"; +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; + +/** Shared compose action for the floating button and empty workspace. */ +export function MaterialNewThreadButton(props: { + readonly onPress: () => void; + readonly extended?: boolean; + readonly className?: string; + readonly style?: StyleProp; +}) { + return ( + + + {props.extended ? ( + New thread + ) : null} + + ); +} diff --git a/apps/mobile/src/components/MaterialNewThreadButton.tsx b/apps/mobile/src/components/MaterialNewThreadButton.tsx index 7a5bc01d54aa..d197cf97d62d 100644 --- a/apps/mobile/src/components/MaterialNewThreadButton.tsx +++ b/apps/mobile/src/components/MaterialNewThreadButton.tsx @@ -1,39 +1 @@ -import { Pressable, type StyleProp, type ViewStyle } from "react-native"; - -import { cn } from "../lib/cn"; -import { AppText } from "./AppText"; -import { SymbolView } from "./AppSymbol"; - -/** Shared compose action for the floating button and empty workspace. */ -export function MaterialNewThreadButton(props: { - readonly onPress: () => void; - readonly extended?: boolean; - readonly className?: string; - readonly style?: StyleProp; -}) { - return ( - - - {props.extended ? ( - New thread - ) : null} - - ); -} +export { MaterialNewThreadButton } from "./MaterialNewThreadButton.shared"; diff --git a/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx b/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx index 6957a6dab043..75b24fd636ba 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx @@ -11,6 +11,7 @@ import { SymbolView } from "../../components/AppSymbol"; export function AndroidHomeFabLayout(props: { readonly onStartNewTask: () => void; readonly children: ReactNode; + readonly sidebar?: boolean; }) { if (Platform.OS !== "android") { return <>{props.children}; @@ -22,6 +23,7 @@ export function AndroidHomeFabLayout(props: { function AndroidHomeFab(props: { readonly onStartNewTask: () => void; readonly children: ReactNode; + readonly sidebar?: boolean; }) { const insets = useSafeAreaInsets(); return ( From 3d8135f6470ee0c09716368a3ba5bee46310985d Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Wed, 16 Sep 2026 16:00:39 -0700 Subject: [PATCH 35/39] fix(mobile): dismiss the new-thread flow when cancelling on Android --- apps/mobile/src/features/threads/NewTaskDraftScreen.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index f0f9b1800e50..f70ae3b4e4c7 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1426,10 +1426,6 @@ export function NewTaskDraftScreen(props: { const closeNewTask = () => { void KeyboardController.dismiss({ animated: true }); - if (isAndroid) { - navigation.goBack(); - return; - } const parentNavigation = navigation.getParent(); if (parentNavigation) { parentNavigation.goBack(); From e961ffd2c6858bc380729b9231836a055cd4ccd8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 16 Sep 2026 16:13:13 -0700 Subject: [PATCH 36/39] fix(mobile): unify Material rows and settings controls --- apps/mobile/src/components/AppSymbol.tsx | 4 + .../mobile/src/components/MaterialListRow.tsx | 57 +++++++++++ .../MaterialSegmentedButtons.android.tsx | 37 ++++++++ .../MaterialSegmentedControl.android.tsx | 38 +------- .../src/components/SegmentedControl.tsx | 24 ++--- .../features/projects/AddProjectScreen.tsx | 86 +++++++++++++---- .../features/settings/SettingsRouteScreen.tsx | 82 +++++++--------- .../AutoSettleDaysField.android.tsx | 50 ++++++++++ .../components/AutoSettleDaysField.ios.tsx | 94 +++++++++++++++++++ .../components/AutoSettleDaysField.tsx | 42 +++++++++ .../settings/components/SettingsRow.tsx | 91 ++++++++++-------- .../settings/components/SettingsSwitchRow.tsx | 16 ++-- .../threads/CustomSnoozeSheet.android.tsx | 65 ++++--------- .../threads/NewTaskContextPickerScreens.tsx | 68 ++++++++++++-- .../features/threads/NewTaskRouteScreen.tsx | 33 ++++++- 15 files changed, 566 insertions(+), 221 deletions(-) create mode 100644 apps/mobile/src/components/MaterialListRow.tsx create mode 100644 apps/mobile/src/components/MaterialSegmentedButtons.android.tsx create mode 100644 apps/mobile/src/features/settings/components/AutoSettleDaysField.android.tsx create mode 100644 apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx create mode 100644 apps/mobile/src/features/settings/components/AutoSettleDaysField.tsx diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 8a214392a2a2..f3ad961ce842 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -37,6 +37,7 @@ import IconClock from "@tabler/icons-react-native/IconClock"; import IconCode from "@tabler/icons-react-native/IconCode"; import IconCopy from "@tabler/icons-react-native/IconCopy"; import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop"; +import IconDatabase from "@tabler/icons-react-native/IconDatabase"; import IconDeviceLaptop from "@tabler/icons-react-native/IconDeviceLaptop"; import IconDots from "@tabler/icons-react-native/IconDots"; import IconDotsVertical from "@tabler/icons-react-native/IconDotsVertical"; @@ -145,7 +146,9 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "folder.badge.plus": IconFolderPlus, "folder.fill": IconFolder, gearshape: IconSettings, + hammer: IconHammer, "info.circle": IconInfoCircle, + internaldrive: IconDatabase, keyboard: IconKeyboard, laptopcomputer: IconDeviceLaptop, link: IconLink, @@ -164,6 +167,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "pin.slash": IconPinnedOff, play: IconPlayerPlay, plus: IconPlus, + minus: IconMinus, "qrcode.viewfinder": IconQrcode, "point.3.connected.trianglepath.dotted": IconNetwork, "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge, diff --git a/apps/mobile/src/components/MaterialListRow.tsx b/apps/mobile/src/components/MaterialListRow.tsx new file mode 100644 index 000000000000..31d6f7a6ffd1 --- /dev/null +++ b/apps/mobile/src/components/MaterialListRow.tsx @@ -0,0 +1,57 @@ +import type { ComponentProps, ReactNode } from "react"; +import { Platform, Pressable, View } from "react-native"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { cn } from "../lib/cn"; +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; + +/** Shared geometry for Material navigation and selection lists. Group rows in one card. */ +export function MaterialListRow({ + title, + subtitle, + leading, + trailing, + className, + ...props +}: Omit, "children"> & { + readonly title: string; + readonly subtitle?: string | null; + readonly leading?: ReactNode; + readonly trailing?: ReactNode; +}) { + const { themeVariables } = useAppearancePreferences(); + return ( + + {leading ? {leading} : null} + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {trailing !== undefined ? ( + trailing + ) : !props.disabled ? ( + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx b/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx new file mode 100644 index 000000000000..ceea91b24447 --- /dev/null +++ b/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx @@ -0,0 +1,37 @@ +import { SegmentedButton, SingleChoiceSegmentedButtonRow, Text } from "@expo/ui/jetpack-compose"; +import { defaultMinSize, fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; +import type { SegmentedControlProps } from "./SegmentedControl"; + +/** Compose content shared by screen controls and native dialogs, inside their existing Host. */ +export function MaterialSegmentedButtons( + props: SegmentedControlProps, +) { + const { themeVariables: colors } = useAppearancePreferences(); + const typography = useScaledTextRole("footnote"); + return ( + + {props.options.map((option) => ( + props.onSelect(option.value)} + modifiers={[defaultMinSize({ minHeight: 48 })]} + colors={{ + activeContainerColor: colors["--color-secondary"], + activeContentColor: colors["--color-secondary-foreground"], + inactiveContainerColor: "transparent", + inactiveContentColor: colors["--color-foreground"], + activeBorderColor: colors["--color-border"], + inactiveBorderColor: colors["--color-border"], + }} + > + + {option.label} + + + ))} + + ); +} diff --git a/apps/mobile/src/components/MaterialSegmentedControl.android.tsx b/apps/mobile/src/components/MaterialSegmentedControl.android.tsx index ecff79e08c09..16ec879821e1 100644 --- a/apps/mobile/src/components/MaterialSegmentedControl.android.tsx +++ b/apps/mobile/src/components/MaterialSegmentedControl.android.tsx @@ -1,21 +1,14 @@ -import { - Host, - SegmentedButton, - SingleChoiceSegmentedButtonRow, - Text, -} from "@expo/ui/jetpack-compose"; -import { defaultMinSize, fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; +import { Host } from "@expo/ui/jetpack-compose"; +import { MaterialSegmentedButtons } from "./MaterialSegmentedButtons.android"; import { View } from "react-native"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; -import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import type { SegmentedControlProps } from "./SegmentedControl"; export function MaterialSegmentedControl( props: SegmentedControlProps, ) { - const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); - const typography = useScaledTextRole("footnote"); + const { themeAppearance } = useAppearancePreferences(); return ( @@ -25,28 +18,7 @@ export function MaterialSegmentedControl( ignoreSafeAreaKeyboardInsets style={{ width: "100%" }} > - - {props.options.map((option) => ( - props.onSelect(option.value)} - modifiers={[defaultMinSize({ minHeight: 48 })]} - colors={{ - activeContainerColor: colors["--color-secondary"], - activeContentColor: colors["--color-secondary-foreground"], - inactiveContainerColor: colors["--color-card"], - inactiveContentColor: colors["--color-foreground"], - activeBorderColor: colors["--color-border"], - inactiveBorderColor: colors["--color-border"], - }} - > - - {option.label} - - - ))} - + @@ -54,7 +26,7 @@ export function MaterialSegmentedControl( { }[]; readonly selected: Value; readonly onSelect: (value: Value) => void; - /** The tab bar is full height; filters under it are shorter so it stays primary. */ + /** Compact sizing applies to the non-Material control. */ readonly size?: "default" | "compact"; /** "tab" for the view switcher; filters stay plain buttons. */ readonly role?: "tab" | "button"; @@ -25,8 +25,7 @@ export function SegmentedControl( ) { const { materialYouStyleLayoutActive } = useAppearancePreferences(); const compact = props.size === "compact"; - const tabs = materialYouStyleLayoutActive && props.role === "tab"; - if (materialYouStyleLayoutActive && !tabs) { + if (materialYouStyleLayoutActive) { return ; } return ( @@ -34,7 +33,7 @@ export function SegmentedControl( accessible={false} className={cn( "flex-row overflow-hidden", - tabs ? "border-b border-border" : "rounded-full border-continuous bg-card", + "rounded-full border-continuous bg-card", props.className, )} > @@ -43,14 +42,7 @@ export function SegmentedControl( layout={LinearTransition.duration(200) .easing(Easing.out(Easing.cubic)) .reduceMotion(ReduceMotion.System)} - className={cn( - "absolute bottom-0", - tabs - ? "h-[3px] rounded-t-full bg-primary" - : materialYouStyleLayoutActive - ? "top-0 rounded-full bg-secondary" - : "top-0 rounded-full bg-subtle-strong", - )} + className="absolute inset-y-0 rounded-full bg-subtle-strong" style={{ width: `${100 / props.options.length}%`, start: `${ @@ -74,17 +66,13 @@ export function SegmentedControl( onPress={() => props.onSelect(option.value)} className={cn( "flex-1 items-center justify-center rounded-full", - materialYouStyleLayoutActive ? "min-h-12 px-2 py-2" : compact ? "h-9" : "h-11", + compact ? "h-9" : "h-11", )} > {option.label} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index ac64fbb4b8ca..9c4195e20906 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -1,3 +1,5 @@ +import { MaterialListRow } from "../../components/MaterialListRow"; +import { SettingsScreen } from "../settings/components/SettingsScreen"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { MaterialButton } from "../../components/MaterialButton"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -125,14 +127,22 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote } function SectionTitle(props: { readonly children: string }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); return ( - + {props.children} ); } -function AddProjectShell(props: { readonly children: ReactNode }) { +function AddProjectShell(props: { readonly children: ReactNode; readonly title: string }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const insets = useSafeAreaInsets(); return ( @@ -141,25 +151,36 @@ function AddProjectShell(props: { readonly children: ReactNode }) { // scroll-view frame correction mistakes this full-height wrapper for a // "header" sibling, coercing the ScrollView to zero height (blank sheet // as soon as the sheet re-lays-out, e.g. when the keyboard opens). - + {props.children} - + ); } function ListSection(props: { readonly children: ReactNode }) { - return {props.children}; + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + return ( + + {props.children} + + ); } function ListRow(props: { @@ -172,6 +193,21 @@ function ListRow(props: { readonly right?: ReactNode; readonly onPress?: () => void; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + if (materialYouStyleLayoutActive) { + return ( + + ); + } return ( + ) : ( - + ); if (!props.ready) { @@ -470,6 +516,7 @@ function SourceControlRow(props: { } export function AddProjectSourceScreen() { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const navigation = useNavigation(); const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = useSelectedEnvironment(); @@ -487,7 +534,7 @@ export function AddProjectSourceScreen() { ); return ( - + {selectedEnvironment === null ? : null} {environmentOptions.length > 1 ? ( @@ -500,7 +547,7 @@ export function AddProjectSourceScreen() { title={environment.label} subtitle={ canCreateProjectInEnvironment(environment.connectionState) - ? environment.environmentId + ? undefined : connectionStatusText({ phase: environment.connectionState, error: environment.connectionError, @@ -510,7 +557,7 @@ export function AddProjectSourceScreen() { icon={ } @@ -521,7 +568,7 @@ export function AddProjectSourceScreen() { environment.environmentId === selectedEnvironment?.environmentId ? ( @@ -543,7 +590,7 @@ export function AddProjectSourceScreen() { icon={ @@ -728,7 +775,7 @@ export function AddProjectRepositoryScreen(props: { }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]); return ( - + {error ? : null} {environment ? ( <> @@ -770,6 +817,7 @@ function FolderBrowser(props: { }) => Promise; readonly pinnedDirectoryName?: string; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const browsePath = useMemo( () => getFilesystemBrowsePath(props.pathInput, props.environment.platform), [props.environment.platform, props.pathInput], @@ -814,7 +862,7 @@ function FolderBrowser(props: { icon={ @@ -837,7 +885,7 @@ function FolderBrowser(props: { icon={ @@ -887,7 +935,7 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st }, [createProject, environment, isBrowseNavigating, isSubmitting, pathInput]); return ( - + {error ? : null} {environment ? ( <> @@ -1030,7 +1078,7 @@ export function AddProjectDestinationScreen(props: { ]); return ( - + {error ? : null} {repositoryTitle ? ( diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 6c23bb3c3c8d..b5c59229756d 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -1,3 +1,4 @@ +import { AutoSettleDaysField } from "./components/AutoSettleDaysField"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { useAuth, useUser } from "@clerk/expo"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; @@ -19,7 +20,7 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { AppText as Text } from "../../components/AppText"; import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; import { openAndroidLiveUpdateSettings, @@ -37,15 +38,13 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; +import { cn } from "../../lib/cn"; +import { useAppearancePreferences } from "./appearance/AppearancePreferencesProvider"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { useEnvironments } from "../../state/environments"; -import { - DEFAULT_SERVER_SETTINGS, - MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, - MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, -} from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { @@ -138,6 +137,7 @@ function LocalSettingsRouteScreen() { icon="desktopcomputer" label="Environments" value={`${environmentCount}`} + valuePosition="trailing" target="SettingsEnvironments" /> @@ -520,6 +520,7 @@ function ConfiguredSettingsRouteScreen() { icon="desktopcomputer" label="Environments" value={`${environmentCount}`} + valuePosition="trailing" target="SettingsEnvironments" /> (null); - if (reference === null || referenceSettings === null) { return null; } const writeToAll = (patch: Partial) => { - for (const environment of syncTargets) { - void updateSettings({ environmentId: environment.environmentId, input: { patch } }); - } + setPendingWrites((count) => count + 1); + void Promise.allSettled( + syncTargets.map((environment) => + updateSettings({ environmentId: environment.environmentId, input: { patch } }), + ), + ).finally(() => setPendingWrites((count) => count - 1)); }; const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync( @@ -651,21 +655,6 @@ function AutoSettleSettingsRows() { ); const afterDays = referenceSettings.sidebarAutoSettleAfterDays; - const commitDays = () => { - const draft = (daysDraft ?? "").trim(); - setDaysDraft(null); - // Whole-string check so "3.5" and "3days" are rejected instead of - // silently becoming 3 on every eligible sync target. - const parsed = /^\d+$/.test(draft) ? Number(draft) : Number.NaN; - if ( - Number.isInteger(parsed) && - parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && - parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && - parsed !== afterDays - ) { - writeToAll({ sidebarAutoSettleAfterDays: parsed }); - } - }; return ( <> @@ -678,28 +667,34 @@ function AutoSettleSettingsRows() { writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null }) } /> {afterDays !== null ? ( - - Days before auto-settle - + + + Inactive days + + writeToAll({ sidebarAutoSettleAfterDays: value })} /> ) : null} - {mismatches.length > 0 ? ( + {pendingWrites === 0 && mismatches.length > 0 ? ( Auto-settle defaults differ @@ -709,14 +704,7 @@ function AutoSettleSettingsRows() { { - for (const mismatch of mismatches) { - void updateSettings({ - environmentId: mismatch.environmentId, - input: { patch: autoSettlePatch }, - }); - } - }} + onPress={() => writeToAll(autoSettlePatch)} className="rounded-full bg-subtle px-4 py-2 active:opacity-70" > diff --git a/apps/mobile/src/features/settings/components/AutoSettleDaysField.android.tsx b/apps/mobile/src/features/settings/components/AutoSettleDaysField.android.tsx new file mode 100644 index 000000000000..bc9ccafecf7e --- /dev/null +++ b/apps/mobile/src/features/settings/components/AutoSettleDaysField.android.tsx @@ -0,0 +1,50 @@ +import { + MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, +} from "@t3tools/contracts"; +import { View } from "react-native"; + +import { AppText } from "../../../components/AppText"; +import { MaterialIconButton } from "../../../components/MaterialIconButton"; +import type { AutoSettleDaysFieldProps } from "./AutoSettleDaysField"; + +export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { + const adjust = (amount: number) => { + const next = Math.max( + MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + Math.min(MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, props.value + amount), + ); + if (next !== props.value) props.onValueChange(next); + }; + + return ( + + {/* Match the 32dp switch track while retaining 48dp button touch targets. */} + + adjust(-1)} + /> + + {props.value} + + = MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS} + onPress={() => adjust(1)} + /> + + ); +} diff --git a/apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx b/apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx new file mode 100644 index 000000000000..19a8998084c6 --- /dev/null +++ b/apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx @@ -0,0 +1,94 @@ +import { Button, Host, HStack, Picker, Popover, Text, VStack } from "@expo/ui/swift-ui"; +import { + accessibilityLabel, + buttonStyle, + font, + foregroundStyle, + frame, + padding, + pickerStyle, + presentationBackground, + tag, +} from "@expo/ui/swift-ui/modifiers"; +import { + MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, +} from "@t3tools/contracts"; +import { useState } from "react"; + +import { useAppearancePreferences } from "../appearance/AppearancePreferencesProvider"; +import type { AutoSettleDaysFieldProps } from "./AutoSettleDaysField"; + +const days = Array.from( + { length: MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS - MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS + 1 }, + (_, index) => MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS + index, +); + +export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { + const { themeAppearance, themeVariables: colors, appearance } = useAppearancePreferences(); + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(props.value); + return ( + + + + + + + + + {days.map((value) => ( + + {`${value} ${value === 1 ? "day" : "days"}`} + + ))} + + + + + - - - - Cancel - - - - - Snooze - - - - {picker === "date" ? ( - setPicker(null)} - onDateSelected={(selected) => { - setDate(applySnoozePickerDate(date, selected)); - setError(null); - setPicker(null); - }} - /> - ) : null} - {picker === "time" ? ( - setPicker(null)} - tonalElevation={0} - colors={{ - containerColor: colors["--color-card-alt"], - titleContentColor: colors["--color-foreground"], - }} - > - - Choose time - - - - - - setPicker(null)} - colors={{ contentColor: colors["--color-primary"] }} - > - Cancel - - - - { - setDate(applySnoozePickerTime(date, pendingTime ?? date)); - setError(null); - setPicker(null); - }} - > - OK - - - - ) : null} + + ); } + +function SnoozeDateTimePicker(props: { + readonly date: Date; + readonly picker: "date" | "time"; + readonly is24Hour: boolean; + readonly colors: React.ComponentProps["elementColors"]; + readonly onChange: (date: Date) => void; +}) { + // Changing initialDate resets Compose's picker state, including its active clock dial. + const [initialDate] = useState(() => + props.picker === "date" ? snoozeDateToPickerDate(props.date) : props.date.toISOString(), + ); + return ( + + ); +} diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx index 7f5b69ed224a..20176064be52 100644 --- a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx +++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx @@ -3,7 +3,6 @@ import { DatePicker, Host, HStack, - Menu, Picker, Popover, Spacer, @@ -11,16 +10,20 @@ import { VStack, } from "@expo/ui/swift-ui"; import { - background, + accessibilityAddTraits, + accessibilityHidden, + buttonBorderShape, buttonStyle, - datePickerStyle, + clipped, + controlSize, font, + datePickerStyle, foregroundStyle, frame, + labelsHidden, + labelStyle, padding, pickerStyle, - presentationBackground, - shapes, tag, } from "@expo/ui/swift-ui/modifiers"; import { @@ -30,9 +33,8 @@ import { type CustomSnoozeInput, } from "@t3tools/client-runtime/state/thread-settled"; import { useState } from "react"; -import { Modal, Pressable, ScrollView, View } from "react-native"; -import { AppText } from "../../components/AppText"; -import { SegmentedControl } from "../../components/SegmentedControl"; +import { useWindowDimensions } from "react-native"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1); @@ -50,13 +52,13 @@ export function CustomSnoozeSheet(props: { readonly onClose: () => void; readonly onSnooze: (snoozedUntil: string) => void; }) { + const { width } = useWindowDimensions(); const [mode, setMode] = useState("date"); const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000)); const [amount, setAmount] = useState(2); - const [amountOpen, setAmountOpen] = useState(false); const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); const [error, setError] = useState(null); - const { themeVariables: colors, themeAppearance, appearance } = useAppearancePreferences(); + const { themeVariables: colors, themeAppearance } = useAppearancePreferences(); const updateDate = (value: Date) => { setDate(value); setError(null); @@ -79,190 +81,150 @@ export function CustomSnoozeSheet(props: { }; return ( - - - - - - Cancel - - - Custom snooze - - - Snooze - - - { - setMode(value); - setError(null); - }} - role="tab" + + { + if (!presented) props.onClose(); + }} + > + + - + + - - {mode === "date" ? "Until" : "Snooze for"} - - {mode === "date" ? ( - <> - - - - ) : ( - <> - - - - - - - { - setAmount(value); - setError(null); - }} - modifiers={[pickerStyle("wheel"), frame({ height: 160 })]} - > - {durationAmounts.map((value) => ( - - {String(value)} - - ))} - - - - - - -