From b212896d361b8f1a8dfaf6cb1e4df107e26e4041 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 11 Oct 2026 22:37:54 -0700 Subject: [PATCH 01/10] refactor(mobile): share filter and label chips --- apps/mobile/src/components/Chip.tsx | 90 +++++++++++++++++++ .../PullRequestOverviewLoading.tsx | 6 +- .../pull-requests/PullRequestRouteScreen.tsx | 5 +- .../src/features/workspace/ListFilterMenu.tsx | 64 +++---------- 4 files changed, 105 insertions(+), 60 deletions(-) create mode 100644 apps/mobile/src/components/Chip.tsx diff --git a/apps/mobile/src/components/Chip.tsx b/apps/mobile/src/components/Chip.tsx new file mode 100644 index 000000000000..9f46e7ffd3b1 --- /dev/null +++ b/apps/mobile/src/components/Chip.tsx @@ -0,0 +1,90 @@ +import { GlassView } from "expo-glass-effect"; +import { Pressable, View } from "react-native"; +import { withUniwind } from "uniwind"; + +import { cn } from "../lib/cn"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass"; +import { AppText } from "./AppText"; +import { SymbolView, type AppSymbolName } from "./AppSymbol"; + +const ChipGlass = withUniwind(GlassView, { + style: { fromClassName: "className" }, + tintColor: { fromClassName: "tintColorClassName", styleProperty: "accentColor" }, +}); + +/** Compact labels and filter triggers. Menu owners can wrap the non-pressable form. */ +export function Chip(props: { + readonly label: string; + readonly selected?: boolean; + readonly icon?: AppSymbolName; + readonly trailingIcon?: AppSymbolName; + readonly size?: "small" | "default"; + readonly glass?: boolean; + readonly colorScheme?: "light" | "dark"; + readonly onPress?: () => void; + readonly accessibilityLabel?: string; + readonly disabled?: boolean; +}) { + const small = props.size === "small"; + const layout = cn( + "flex-row items-center gap-1.5 rounded-2xl px-3", + small ? "py-1" : "min-h-8 py-1.5", + ); + const iconColor = props.selected ? "accent-primary-foreground" : "accent-icon-muted"; + const content = ( + <> + {props.icon ? ( + + ) : null} + + {props.label} + + {props.trailingIcon ? ( + + ) : null} + + ); + const body = + props.glass && NATIVE_LIQUID_GLASS_SUPPORTED ? ( + + {content} + + ) : ( + + {content} + + ); + return props.onPress ? ( + + {body} + + ) : ( + body + ); +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestOverviewLoading.tsx b/apps/mobile/src/features/pull-requests/PullRequestOverviewLoading.tsx index 7e6e4f490ae9..7bb8631e1b9b 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestOverviewLoading.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestOverviewLoading.tsx @@ -1,7 +1,7 @@ +import { Chip } from "../../components/Chip"; import { PullRequestSectionHeading } from "./PullRequestSectionHeading"; import type { PullRequestListEntry } from "@t3tools/contracts"; import { View } from "react-native"; -import { AppText as Text } from "../../components/AppText"; import { PullRequestCardGroup, PullRequestCardRow } from "./PullRequestControls"; import { PullRequestStackCard } from "./PullRequestStackCard"; import type { PullRequestParams } from "./usePullRequestDiff"; @@ -91,9 +91,7 @@ export function PullRequestOverviewLoading({ {summary.labels.length ? ( {summary.labels.map((label) => ( - - {label.name} - + ))} ) : null} diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index 5afd705aae8c..5459cb77c619 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -1,3 +1,4 @@ +import { Chip } from "../../components/Chip"; import { pullRequestTabOffset } from "./pullRequestTabScroll"; import { guideFocusedRows, type GuideDiffFocus } from "./guide/guideDiffFocus"; import { PullRequestGuideContent } from "./guide/PullRequestGuideScreen"; @@ -259,9 +260,7 @@ function PullRequestOverview(props: { {pr.labels.length ? ( {pr.labels.map((label) => ( - - {label.name} - + ))} ) : null} diff --git a/apps/mobile/src/features/workspace/ListFilterMenu.tsx b/apps/mobile/src/features/workspace/ListFilterMenu.tsx index b645722f53dd..ad08ee45a761 100644 --- a/apps/mobile/src/features/workspace/ListFilterMenu.tsx +++ b/apps/mobile/src/features/workspace/ListFilterMenu.tsx @@ -1,21 +1,9 @@ -import { GlassView } from "expo-glass-effect"; -import { Pressable, View } from "react-native"; -import { withUniwind } from "uniwind"; -import { AppText as Text } from "../../components/AppText"; -import { SymbolView } from "../../components/AppSymbol"; +import { Pressable } from "react-native"; +import { Chip } from "../../components/Chip"; import { ControlPillMenu } from "../../components/ControlPillMenu"; import type { ControlPillMenuProps } from "../../components/ControlPillMenu.types"; -import { cn } from "../../lib/cn"; -import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -const FilterGlass = withUniwind(GlassView, { - style: { fromClassName: "className" }, - tintColor: { fromClassName: "tintColorClassName", styleProperty: "accentColor" }, -}); - -const PILL = "h-8 flex-row items-center gap-1.5 rounded-full px-3"; - /** A visible filter with a native menu containing only that filter's choices. */ export function ListFilterMenu(props: { readonly label: string; @@ -26,33 +14,6 @@ export function ListFilterMenu(props: { readonly onSelect: (id: string) => void; }) { const { themeAppearance } = useAppearancePreferences(); - const content = ( - <> - {props.icon ? ( - - ) : null} - - {props.label} - - {!props.icon ? ( - - ) : null} - - ); return ( - {NATIVE_LIQUID_GLASS_SUPPORTED ? ( - - {content} - - ) : ( - {content} - )} + ); From 520f78c0168ab4f711bf5b3a2c2279556223f00c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 11 Oct 2026 22:39:32 -0700 Subject: [PATCH 02/10] refactor(mobile): share sliders and bounded number controls --- apps/mobile/src/components/Button.android.tsx | 6 +- .../components/MaterialIconButton.android.tsx | 6 +- .../src/components/MaterialSwitch.android.tsx | 6 +- .../src/components/NumberField.android.tsx | 44 ++++ .../mobile/src/components/NumberField.ios.tsx | 91 ++++++++ apps/mobile/src/components/NumberField.tsx | 47 ++++ .../src/components/SliderRow.android.tsx | 77 +++++++ apps/mobile/src/components/SliderRow.tsx | 217 ++++++++++++++++++ apps/mobile/src/components/useControlTheme.ts | 13 ++ apps/mobile/src/dependency-graph.test.ts | 2 +- .../components/FontSizeSliderRow.tsx | 15 +- .../components/AutoSettleDaysField.tsx | 49 +--- 12 files changed, 525 insertions(+), 48 deletions(-) create mode 100644 apps/mobile/src/components/NumberField.android.tsx create mode 100644 apps/mobile/src/components/NumberField.ios.tsx create mode 100644 apps/mobile/src/components/NumberField.tsx create mode 100644 apps/mobile/src/components/SliderRow.android.tsx create mode 100644 apps/mobile/src/components/SliderRow.tsx create mode 100644 apps/mobile/src/components/useControlTheme.ts diff --git a/apps/mobile/src/components/Button.android.tsx b/apps/mobile/src/components/Button.android.tsx index 1d8919034219..7c0fcf45bcbc 100644 --- a/apps/mobile/src/components/Button.android.tsx +++ b/apps/mobile/src/components/Button.android.tsx @@ -13,13 +13,13 @@ import { import { defaultMinSize, fillMaxWidth, size } from "@expo/ui/jetpack-compose/modifiers"; import { View } from "react-native"; -import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { useControlTheme } from "./useControlTheme"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import type { ButtonProps } from "./Button"; import { useAndroidControlSizing } from "./useAndroidControlSizing"; export function Button(props: ButtonProps) { - const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const { colorScheme, colors } = useControlTheme(); const typography = useScaledTextRole("footnote"); const { scale, mediumIconSize } = useAndroidControlSizing(); const tone = props.tone ?? "secondary"; @@ -64,7 +64,7 @@ export function Button(props: ButtonProps) { diff --git a/apps/mobile/src/components/MaterialIconButton.android.tsx b/apps/mobile/src/components/MaterialIconButton.android.tsx index 64387e3a4f48..0c7e6daa5e88 100644 --- a/apps/mobile/src/components/MaterialIconButton.android.tsx +++ b/apps/mobile/src/components/MaterialIconButton.android.tsx @@ -7,7 +7,7 @@ import { import { size } from "@expo/ui/jetpack-compose/modifiers"; import { View } from "react-native"; -import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { useControlTheme } from "./useControlTheme"; import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { SymbolView, type AppSymbolName } from "./AppSymbol"; @@ -20,7 +20,7 @@ export function MaterialIconButton(props: { readonly variant?: "standard" | "primary" | "tonal" | "danger"; readonly tintColorClassName?: string; }) { - const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const { colorScheme, colors } = useControlTheme(); const { iconSize, buttonSize } = useAndroidControlSizing(); const variant = props.variant ?? "standard"; const Component = @@ -58,7 +58,7 @@ export function MaterialIconButton(props: { > diff --git a/apps/mobile/src/components/MaterialSwitch.android.tsx b/apps/mobile/src/components/MaterialSwitch.android.tsx index f08fa78690f7..1c1478f0b502 100644 --- a/apps/mobile/src/components/MaterialSwitch.android.tsx +++ b/apps/mobile/src/components/MaterialSwitch.android.tsx @@ -2,11 +2,11 @@ import { Host, Switch as ComposeSwitch } from "@expo/ui/jetpack-compose"; import { View } from "react-native"; import type { ThemedSwitchProps } from "./MaterialSwitch.types"; -import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { useControlTheme } from "./useControlTheme"; /** 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 { colorScheme, colors } = useControlTheme(); const toggle = () => { if (!props.disabled) props.onValueChange?.(!props.value); }; @@ -25,7 +25,7 @@ export function MaterialSwitch(props: ThemedSwitchProps) { > diff --git a/apps/mobile/src/components/NumberField.android.tsx b/apps/mobile/src/components/NumberField.android.tsx new file mode 100644 index 000000000000..23095ee862fb --- /dev/null +++ b/apps/mobile/src/components/NumberField.android.tsx @@ -0,0 +1,44 @@ +import { View } from "react-native"; + +import { AppText } from "./AppText"; +import { MaterialIconButton } from "./MaterialIconButton"; +import type { NumberFieldProps } from "./NumberField"; + +export function NumberField(props: NumberFieldProps) { + const adjust = (amount: number) => { + if (props.disabled) return; + const next = Math.max(props.min, Math.min(props.max, 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} + + = props.max} + onPress={() => adjust(1)} + /> + + ); +} diff --git a/apps/mobile/src/components/NumberField.ios.tsx b/apps/mobile/src/components/NumberField.ios.tsx new file mode 100644 index 000000000000..958698b65a99 --- /dev/null +++ b/apps/mobile/src/components/NumberField.ios.tsx @@ -0,0 +1,91 @@ +import { Button, Host, HStack, Picker, Popover, Text, VStack } from "@expo/ui/swift-ui"; +import { + accessibilityLabel, + buttonStyle, + disabled, + font, + foregroundStyle, + frame, + padding, + pickerStyle, + presentationBackground, + tag, +} from "@expo/ui/swift-ui/modifiers"; +import { useState } from "react"; + +import { useControlTheme } from "./useControlTheme"; +import type { NumberFieldProps } from "./NumberField"; + +export function NumberField(props: NumberFieldProps) { + const { colorScheme, colors, fontSize } = useControlTheme(); + const values = Array.from({ length: props.max - props.min + 1 }, (_, index) => props.min + index); + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(props.value); + return ( + + + + + + + + + {values.map((value) => ( + + {props.formatValue?.(value) ?? String(value)} + + ))} + + + - - - - - {days.map((value) => ( - - {`${value} ${value === 1 ? "day" : "days"}`} - - ))} - - -