| author | |
| committer | |
| log | 7a12aff471ffe2b22b9fee495b04b32c43f45a37 |
| tree | a18716507d47b3b0743e31a0cea43d484ab3d6c1 |
| parent | d06f0ff1048d42a156824765316c5a2dc54f5bca |
| signature | Signed by PGP key B5690EEEBB952194 |
30 files changed, 390 insertions(+), 179 deletions(-)
apps/mobile/src/components/AndroidAnchoredMenu.tsx+14-8| ... | ... | @@ -7,9 +7,9 @@ import { useKeyboardState } from "react-native-keyboard-controller"; |
| 7 | 7 | import Animated, { FadeIn } from "react-native-reanimated"; |
| 8 | 8 | |
| 9 | 9 | import { OverlayPortal } from "./OverlayPortal"; |
| 10 | import { useAndroidControlSizing } from "./useAndroidControlSizing"; | |
| 10 | 11 | import { MaterialMenuPopup } from "./MaterialMenuPopup"; |
| 11 | 12 | |
| 12 | const MENU_WIDTH = 250; | |
| 13 | 13 | const SCREEN_MARGIN = 12; |
| 14 | 14 | const ANCHOR_GAP = 6; |
| 15 | 15 | |
| ... | ... | @@ -55,6 +55,7 @@ export type AndroidAnchoredMenuProps = { |
| 55 | 55 | * menus use the native popup for placement, animation and dismissal. |
| 56 | 56 | */ |
| 57 | 57 | export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { |
| 58 | const { scale, menuWidth: desiredMenuWidth } = useAndroidControlSizing(); | |
| 58 | 59 | const [anchor, setAnchor] = useState<AnchorSnapshot | null>(null); |
| 59 | 60 | const [path, setPath] = useState<readonly MenuAction[]>([]); |
| 60 | 61 | // Height of the modal's root view, in the modal's own coordinate space. |
| ... | ... | @@ -68,6 +69,10 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { |
| 68 | 69 | // are converted into this frame, so the menu lands correctly no matter |
| 69 | 70 | // where the portal host sits (status bar, keyboard resize, etc.). |
| 70 | 71 | const [overlay, setOverlay] = useState<OverlayFrame | null>(null); |
| 72 | const menuWidth = | |
| 73 | overlay === null | |
| 74 | ? desiredMenuWidth | |
| 75 | : Math.min(desiredMenuWidth, Math.max(0, overlay.width - 2 * SCREEN_MARGIN)); | |
| 71 | 76 | const anchorRef = useRef<View>(null); |
| 72 | 77 | const overlayRef = useRef<View>(null); |
| 73 | 78 | |
| ... | ... | @@ -131,14 +136,11 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { |
| 131 | 136 | ? 0 |
| 132 | 137 | : local.x + local.width / 2 <= overlay.width / 2 |
| 133 | 138 | ? local.x |
| 134 | : local.x + local.width - MENU_WIDTH; | |
| 139 | : local.x + local.width - menuWidth; | |
| 135 | 140 | const left = |
| 136 | 141 | overlay === null |
| 137 | 142 | ? 0 |
| 138 | : Math.min( | |
| 139 | Math.max(preferredLeft, SCREEN_MARGIN), | |
| 140 | overlay.width - MENU_WIDTH - SCREEN_MARGIN, | |
| 141 | ); | |
| 143 | : Math.min(Math.max(preferredLeft, SCREEN_MARGIN), overlay.width - menuWidth - SCREEN_MARGIN); | |
| 142 | 144 | // The keyboard stays up while the menu is open (in-window overlay, no |
| 143 | 145 | // focus change), so the space it covers is not usable — without this the |
| 144 | 146 | // composer-pill menus "open down" into the IME and can't be tapped. |
| ... | ... | @@ -201,6 +203,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { |
| 201 | 203 | {!placeable || local === null ? null : !anchor.keyboardWasVisible ? ( |
| 202 | 204 | <MaterialMenuPopup |
| 203 | 205 | anchor={local} |
| 206 | menuWidth={menuWidth} | |
| 204 | 207 | actions={levelActions} |
| 205 | 208 | title={props.title} |
| 206 | 209 | parent={parent} |
| ... | ... | @@ -211,10 +214,12 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { |
| 211 | 214 | ) : ( |
| 212 | 215 | <Animated.View |
| 213 | 216 | entering={FadeIn.duration(120)} |
| 214 | className="absolute w-[250px] overflow-hidden rounded-[4px] bg-card-alt shadow-md" | |
| 217 | className="absolute overflow-hidden bg-card-alt shadow-md" | |
| 215 | 218 | style={{ |
| 216 | 219 | left, |
| 217 | 220 | maxHeight, |
| 221 | width: menuWidth, | |
| 222 | borderRadius: 4 * scale, | |
| 218 | 223 | ...(opensDown |
| 219 | 224 | ? { top: local.y + local.height + ANCHOR_GAP } |
| 220 | 225 | : { bottom: (rootHeight ?? 0) - local.y + ANCHOR_GAP }), |
| ... | ... | @@ -227,7 +232,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { |
| 227 | 232 | active editor; the first item tap must act, not just |
| 228 | 233 | dismiss the keyboard. */} |
| 229 | 234 | <ScrollView |
| 230 | contentContainerClassName="py-2" | |
| 235 | contentContainerStyle={{ paddingVertical: 7 * scale }} | |
| 231 | 236 | bounces={false} |
| 232 | 237 | keyboardShouldPersistTaps="always" |
| 233 | 238 | showsVerticalScrollIndicator={false} |
| ... | ... | @@ -235,6 +240,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { |
| 235 | 240 | <MaterialMenuPopup |
| 236 | 241 | inline |
| 237 | 242 | anchor={local} |
| 243 | menuWidth={menuWidth} | |
| 238 | 244 | actions={levelActions} |
| 239 | 245 | title={props.title} |
| 240 | 246 | parent={parent} |
apps/mobile/src/components/AndroidScreenHeader.tsx+7-10| ... | ... | @@ -1,6 +1,5 @@ |
| 1 | 1 | import { useState, type ReactNode } from "react"; |
| 2 | 2 | import { View } from "react-native"; |
| 3 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 4 | 3 | |
| 5 | 4 | import type { AppSymbolName } from "./AppSymbol"; |
| 6 | 5 | import { AppText as Text } from "./AppText"; |
| ... | ... | @@ -8,7 +7,7 @@ import { cn } from "../lib/cn"; |
| 8 | 7 | import { MaterialIconButton } from "./MaterialIconButton"; |
| 9 | 8 | import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; |
| 10 | 9 | import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; |
| 11 | import { useMaterialToolbarHeight } from "./useMaterialToolbarHeight"; | |
| 10 | import { useMaterialToolbarLayout } from "./useMaterialToolbarLayout"; | |
| 12 | 11 | |
| 13 | 12 | export interface AndroidHeaderAction { |
| 14 | 13 | readonly accessibilityLabel: string; |
| ... | ... | @@ -38,10 +37,11 @@ export function AndroidScreenHeader(props: { |
| 38 | 37 | readonly embedded?: boolean; |
| 39 | 38 | readonly hideBottomBorder?: boolean; |
| 40 | 39 | }) { |
| 41 | const insets = useSafeAreaInsets(); | |
| 42 | 40 | const titleTypography = useScaledTextRole("title"); |
| 43 | 41 | const subtitleTypography = useScaledTextRole("label"); |
| 44 | const materialToolbarHeight = useMaterialToolbarHeight(); | |
| 42 | const { height: materialToolbarHeight, ...headerPadding } = useMaterialToolbarLayout( | |
| 43 | props.embedded, | |
| 44 | ); | |
| 45 | 45 | const [headerWidth, setHeaderWidth] = useState(0); |
| 46 | 46 | const actions = props.actions ?? []; |
| 47 | 47 | const directCount = actions.length > 2 ? (headerWidth >= 600 ? 3 : 1) : actions.length; |
| ... | ... | @@ -51,16 +51,13 @@ export function AndroidScreenHeader(props: { |
| 51 | 51 | return ( |
| 52 | 52 | <View |
| 53 | 53 | onLayout={(event) => setHeaderWidth(event.nativeEvent.layout.width)} |
| 54 | className="border-b border-header-border bg-header px-2 pb-2" | |
| 54 | className="border-b border-header-border bg-header px-2" | |
| 55 | 55 | style={{ |
| 56 | paddingTop: props.embedded ? 8 : Math.max(insets.top, 12), | |
| 56 | ...headerPadding, | |
| 57 | 57 | borderBottomWidth: props.hideBottomBorder ? 0 : undefined, |
| 58 | 58 | }} |
| 59 | 59 | > |
| 60 | <View | |
| 61 | style={{ minHeight: materialToolbarHeight }} | |
| 62 | className="min-h-14 flex-row items-center gap-1" | |
| 63 | > | |
| 60 | <View style={{ minHeight: materialToolbarHeight }} className="flex-row items-center gap-1"> | |
| 64 | 61 | {props.onBack ? ( |
| 65 | 62 | <MaterialIconButton |
| 66 | 63 | accessibilityLabel="Navigate up" |
apps/mobile/src/components/CompactBrandTitle.tsx+16-5| ... | ... | @@ -6,6 +6,7 @@ import { AppText as Text } from "./AppText"; |
| 6 | 6 | import { T3Wordmark } from "./T3Wordmark"; |
| 7 | 7 | import { IPAD_HOME_TITLE_OFFSET } from "../lib/layoutMetrics"; |
| 8 | 8 | import { resolveMobileStageLabel } from "../lib/mobileBranding"; |
| 9 | import { useAndroidControlSizing } from "./useAndroidControlSizing"; | |
| 9 | 10 | |
| 10 | 11 | /** |
| 11 | 12 | * Horizontal correction applied to content rendered in the brand title slot, |
| ... | ... | @@ -26,6 +27,7 @@ export function CompactBrandTitle( |
| 26 | 27 | ) { |
| 27 | 28 | const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); |
| 28 | 29 | const titleOffset = brandTitleOffset(); |
| 30 | const { scale } = useAndroidControlSizing(); | |
| 29 | 31 | |
| 30 | 32 | return ( |
| 31 | 33 | <View |
| ... | ... | @@ -34,19 +36,28 @@ export function CompactBrandTitle( |
| 34 | 36 | accessible |
| 35 | 37 | role="heading" |
| 36 | 38 | className="flex-row items-center gap-1.5" |
| 37 | style={{ marginLeft: titleOffset }} | |
| 39 | style={[{ marginLeft: titleOffset }, Platform.OS === "android" && { gap: 5.25 * scale }]} | |
| 38 | 40 | > |
| 39 | <T3Wordmark colorClassName="accent-icon" height={15} /> | |
| 41 | <T3Wordmark colorClassName="accent-icon" height={Math.round(15 * scale)} /> | |
| 40 | 42 | <Text |
| 41 | 43 | allowFontScaling={props.allowFontScaling} |
| 42 | className="font-t3-medium text-[21px] tracking-[-0.5px] text-foreground-muted" | |
| 44 | className="font-t3-medium text-foreground-muted" | |
| 45 | style={{ fontSize: 21 * scale, letterSpacing: -0.5 * scale }} | |
| 43 | 46 | > |
| 44 | 47 | Code |
| 45 | 48 | </Text> |
| 46 | <View className="rounded-full bg-subtle px-1.5 py-0.5"> | |
| 49 | <View | |
| 50 | className="rounded-full bg-subtle px-1.5 py-0.5" | |
| 51 | style={ | |
| 52 | Platform.OS === "android" | |
| 53 | ? { paddingHorizontal: 5.25 * scale, paddingVertical: 1.75 * scale } | |
| 54 | : undefined | |
| 55 | } | |
| 56 | > | |
| 47 | 57 | <Text |
| 48 | 58 | allowFontScaling={props.allowFontScaling} |
| 49 | className="font-t3-bold text-[9px] tracking-[0.9px] text-foreground-muted uppercase" | |
| 59 | className="font-t3-bold text-foreground-muted uppercase" | |
| 60 | style={{ fontSize: 9 * scale, letterSpacing: 0.9 * scale }} | |
| 50 | 61 | > |
| 51 | 62 | {stageLabel} |
| 52 | 63 | </Text> |
apps/mobile/src/components/ComposerAttachmentButton.tsx+3-1| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import type { MenuAction } from "@react-native-menu/menu"; |
| 2 | 2 | import { Pressable } from "react-native"; |
| 3 | 3 | |
| 4 | import { useAndroidControlSizing } from "./useAndroidControlSizing"; | |
| 4 | 5 | import { SymbolView } from "./AppSymbol"; |
| 5 | 6 | import { ControlPillMenu } from "./ControlPill"; |
| 6 | 7 | |
| ... | ... | @@ -15,6 +16,7 @@ export function ComposerAttachmentButton(props: { |
| 15 | 16 | readonly onPickMedia: () => Promise<void>; |
| 16 | 17 | readonly onPickFiles: () => Promise<void>; |
| 17 | 18 | }) { |
| 19 | const { scale } = useAndroidControlSizing(); | |
| 18 | 20 | const button = ( |
| 19 | 21 | <Pressable |
| 20 | 22 | accessibilityLabel="Add attachment" |
| ... | ... | @@ -26,7 +28,7 @@ export function ComposerAttachmentButton(props: { |
| 26 | 28 | > |
| 27 | 29 | <SymbolView |
| 28 | 30 | name="plus" |
| 29 | size={20} | |
| 31 | size={Math.round(20 * scale)} | |
| 30 | 32 | weight="regular" |
| 31 | 33 | tintColorClassName="accent-icon" |
| 32 | 34 | type="monochrome" |
apps/mobile/src/components/ComposerToolbar.tsx+18-7| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import type { ComponentProps, ReactNode } from "react"; |
| 2 | 2 | import { useCallback, useMemo, useState } from "react"; |
| 3 | 3 | import { |
| 4 | Platform, | |
| 4 | 5 | Pressable, |
| 5 | 6 | ScrollView, |
| 6 | 7 | View, |
| ... | ... | @@ -14,6 +15,7 @@ import { |
| 14 | 15 | import { cn } from "../lib/cn"; |
| 15 | 16 | import { AppText as Text } from "./AppText"; |
| 16 | 17 | import { SymbolView } from "./AppSymbol"; |
| 18 | import { useAndroidControlSizing } from "./useAndroidControlSizing"; | |
| 17 | 19 | |
| 18 | 20 | const COMPOSER_TOOLBAR_GAP = 8; |
| 19 | 21 | const COMPOSER_TOOLBAR_FADE_WIDTH = 18; |
| ... | ... | @@ -30,7 +32,7 @@ export function ComposerInlineControl(props: { |
| 30 | 32 | readonly disabled?: boolean; |
| 31 | 33 | readonly emphasized?: boolean; |
| 32 | 34 | readonly icon?: ComponentProps<typeof SymbolView>["name"]; |
| 33 | readonly iconNode?: ReactNode; | |
| 35 | readonly renderIcon?: (size: number) => ReactNode; | |
| 34 | 36 | readonly label: string; |
| 35 | 37 | readonly maxWidth?: ViewStyle["maxWidth"]; |
| 36 | 38 | readonly onPress?: () => void; |
| ... | ... | @@ -39,6 +41,7 @@ export function ComposerInlineControl(props: { |
| 39 | 41 | readonly chevronDirection?: "down" | "right"; |
| 40 | 42 | readonly showChevron?: boolean; |
| 41 | 43 | }) { |
| 44 | const { scale, smallIconSize } = useAndroidControlSizing(); | |
| 42 | 45 | return ( |
| 43 | 46 | <Pressable |
| 44 | 47 | accessibilityLabel={props.accessibilityLabel ?? props.label} |
| ... | ... | @@ -52,12 +55,17 @@ export function ComposerInlineControl(props: { |
| 52 | 55 | onPress={props.onPress} |
| 53 | 56 | style={{ maxWidth: props.maxWidth ?? 190, opacity: props.disabled ? 0.45 : 1 }} |
| 54 | 57 | > |
| 55 | {props.iconNode ? ( | |
| 56 | <View className="size-4 shrink-0 items-center justify-center">{props.iconNode}</View> | |
| 58 | {props.renderIcon ? ( | |
| 59 | <View | |
| 60 | className="size-4 shrink-0 items-center justify-center" | |
| 61 | style={Platform.OS === "android" ? { width: 14 * scale, height: 14 * scale } : undefined} | |
| 62 | > | |
| 63 | {props.renderIcon(smallIconSize)} | |
| 64 | </View> | |
| 57 | 65 | ) : props.icon ? ( |
| 58 | 66 | <SymbolView |
| 59 | 67 | name={props.icon} |
| 60 | size={16} | |
| 68 | size={smallIconSize} | |
| 61 | 69 | tintColorClassName={ |
| 62 | 70 | props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted" |
| 63 | 71 | } |
| ... | ... | @@ -76,7 +84,7 @@ export function ComposerInlineControl(props: { |
| 76 | 84 | {props.showChevron === false ? null : ( |
| 77 | 85 | <SymbolView |
| 78 | 86 | name={props.chevronDirection === "right" ? "chevron.right" : "chevron.down"} |
| 79 | size={10} | |
| 87 | size={Math.round(10 * scale)} | |
| 80 | 88 | tintColorClassName={ |
| 81 | 89 | props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted" |
| 82 | 90 | } |
| ... | ... | @@ -224,6 +232,8 @@ export function ComposerActionButton(props: { |
| 224 | 232 | readonly onPress: () => void; |
| 225 | 233 | readonly variant?: "primary" | "danger"; |
| 226 | 234 | }) { |
| 235 | const { scale, smallIconSize } = useAndroidControlSizing(); | |
| 236 | const circleSize = Math.round(30 * scale); | |
| 227 | 237 | return ( |
| 228 | 238 | <Pressable |
| 229 | 239 | accessibilityLabel={props.accessibilityLabel} |
| ... | ... | @@ -234,8 +244,9 @@ export function ComposerActionButton(props: { |
| 234 | 244 | onPress={props.onPress} |
| 235 | 245 | > |
| 236 | 246 | <View |
| 247 | style={{ width: circleSize, height: circleSize }} | |
| 237 | 248 | className={cn( |
| 238 | "size-[30px] items-center justify-center rounded-full", | |
| 249 | "items-center justify-center rounded-full", | |
| 239 | 250 | props.variant === "danger" |
| 240 | 251 | ? "bg-danger" |
| 241 | 252 | : props.disabled |
| ... | ... | @@ -245,7 +256,7 @@ export function ComposerActionButton(props: { |
| 245 | 256 | > |
| 246 | 257 | <SymbolView |
| 247 | 258 | name={props.icon} |
| 248 | size={16} | |
| 259 | size={smallIconSize} | |
| 249 | 260 | weight="semibold" |
| 250 | 261 | tintColorClassName={ |
| 251 | 262 | props.variant === "danger" ? "accent-danger-foreground" : "accent-primary-foreground" |
apps/mobile/src/components/ControlPill.tsx+3-1| ... | ... | @@ -5,6 +5,7 @@ import { SymbolView } from "./AppSymbol"; |
| 5 | 5 | import { AppText as Text } from "./AppText"; |
| 6 | 6 | import { MaterialIconButton } from "./MaterialIconButton"; |
| 7 | 7 | import { MaterialButton } from "./MaterialButton"; |
| 8 | import { useAndroidControlSizing } from "./useAndroidControlSizing"; | |
| 8 | 9 | |
| 9 | 10 | export { ControlPillMenu } from "./ControlPillMenu"; |
| 10 | 11 | |
| ... | ... | @@ -20,6 +21,7 @@ export function ControlPill(props: { |
| 20 | 21 | readonly className?: string; |
| 21 | 22 | }) { |
| 22 | 23 | const variant = props.variant ?? "circle"; |
| 24 | const { smallIconSize } = useAndroidControlSizing(); | |
| 23 | 25 | const activatedOnPressInRef = useRef(false); |
| 24 | 26 | |
| 25 | 27 | const handlePressIn = () => { |
| ... | ... | @@ -133,7 +135,7 @@ export function ControlPill(props: { |
| 133 | 135 | ) : props.icon ? ( |
| 134 | 136 | <SymbolView |
| 135 | 137 | name={props.icon} |
| 136 | size={16} | |
| 138 | size={smallIconSize} | |
| 137 | 139 | tintColorClassName={iconTintClassName} |
| 138 | 140 | type="monochrome" |
| 139 | 141 | /> |
apps/mobile/src/components/MaterialButton.android.tsx+4-2| ... | ... | @@ -14,10 +14,12 @@ import { View } from "react-native"; |
| 14 | 14 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 15 | 15 | import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; |
| 16 | 16 | import type { MaterialButtonProps } from "./MaterialButton"; |
| 17 | import { useAndroidControlSizing } from "./useAndroidControlSizing"; | |
| 17 | 18 | |
| 18 | 19 | export function MaterialButton(props: MaterialButtonProps) { |
| 19 | 20 | const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); |
| 20 | 21 | const typography = useScaledTextRole("footnote"); |
| 22 | const { scale, mediumIconSize } = useAndroidControlSizing(); | |
| 21 | 23 | const tone = props.tone ?? "secondary"; |
| 22 | 24 | const Component = |
| 23 | 25 | tone === "text" ? TextButton : tone === "secondary" ? FilledTonalButton : Button; |
| ... | ... | @@ -77,11 +79,11 @@ export function MaterialButton(props: MaterialButtonProps) { |
| 77 | 79 | {props.loading ? ( |
| 78 | 80 | <> |
| 79 | 81 | <CircularProgressIndicator |
| 80 | modifiers={[size(18, 18)]} | |
| 82 | modifiers={[size(mediumIconSize, mediumIconSize)]} | |
| 81 | 83 | strokeWidth={2} |
| 82 | 84 | color={colors["--color-foreground-muted"]} |
| 83 | 85 | /> |
| 84 | <Box modifiers={[size(8, 1)]} /> | |
| 86 | <Box modifiers={[size(8 * scale, 1)]} /> | |
| 85 | 87 | </> |
| 86 | 88 | ) : null} |
| 87 | 89 | <Text style={{ ...typography, fontWeight: "500" }}>{props.label}</Text> |
apps/mobile/src/components/MaterialFloatingActionButton.android.tsx+10-2| ... | ... | @@ -6,8 +6,9 @@ import { |
| 6 | 6 | LargeFloatingActionButton, |
| 7 | 7 | Text, |
| 8 | 8 | } from "@expo/ui/jetpack-compose"; |
| 9 | import { size } from "@expo/ui/jetpack-compose/modifiers"; | |
| 9 | import { defaultMinSize, height, size, width } from "@expo/ui/jetpack-compose/modifiers"; | |
| 10 | 10 | import { View, type StyleProp, type ViewStyle } from "react-native"; |
| 11 | import { useAndroidControlSizing } from "./useAndroidControlSizing"; | |
| 11 | 12 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 12 | 13 | import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; |
| 13 | 14 | import { SymbolView, type AppSymbolName } from "./AppSymbol"; |
| ... | ... | @@ -24,6 +25,8 @@ export function MaterialFloatingActionButton(props: { |
| 24 | 25 | }) { |
| 25 | 26 | const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); |
| 26 | 27 | const typography = useScaledTextRole("footnote"); |
| 28 | const { scale, iconSize: standardIconSize, fabSize, largeFabSize } = useAndroidControlSizing(); | |
| 29 | const buttonSize = props.variant === "large" ? largeFabSize : fabSize; | |
| 27 | 30 | const primary = props.tone === "primary"; |
| 28 | 31 | const containerColor = colors[primary ? "--color-primary" : "--color-secondary"]; |
| 29 | 32 | const contentColor = |
| ... | ... | @@ -34,7 +37,7 @@ export function MaterialFloatingActionButton(props: { |
| 34 | 37 | : props.variant === "large" |
| 35 | 38 | ? LargeFloatingActionButton |
| 36 | 39 | : FloatingActionButton; |
| 37 | const iconSize = props.variant === "large" ? 36 : 24; | |
| 40 | const iconSize = props.variant === "large" ? Math.round(36 * scale) : standardIconSize; | |
| 38 | 41 | return ( |
| 39 | 42 | <View |
| 40 | 43 | accessible |
| ... | ... | @@ -48,6 +51,11 @@ export function MaterialFloatingActionButton(props: { |
| 48 | 51 | <View importantForAccessibility="no-hide-descendants"> |
| 49 | 52 | <Host matchContents colorScheme={themeAppearance} ignoreSafeAreaKeyboardInsets> |
| 50 | 53 | <Component |
| 54 | modifiers={[ | |
| 55 | defaultMinSize({ minWidth: buttonSize }), | |
| 56 | height(buttonSize), | |
| 57 | ...(props.variant === "extended" ? [] : [width(buttonSize)]), | |
| 58 | ]} | |
| 51 | 59 | containerColor={containerColor} |
| 52 | 60 | onClick={props.onPress} |
| 53 | 61 | expanded={props.expanded} |
apps/mobile/src/components/MaterialIconButton.android.tsx+6-4| ... | ... | @@ -8,6 +8,7 @@ import { size } from "@expo/ui/jetpack-compose/modifiers"; |
| 8 | 8 | import { View } from "react-native"; |
| 9 | 9 | |
| 10 | 10 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 11 | import { useAndroidControlSizing } from "./useAndroidControlSizing"; | |
| 11 | 12 | import { SymbolView, type AppSymbolName } from "./AppSymbol"; |
| 12 | 13 | |
| 13 | 14 | export function MaterialIconButton(props: { |
| ... | ... | @@ -20,6 +21,7 @@ export function MaterialIconButton(props: { |
| 20 | 21 | readonly tintColorClassName?: string; |
| 21 | 22 | }) { |
| 22 | 23 | const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); |
| 24 | const { iconSize, buttonSize } = useAndroidControlSizing(); | |
| 23 | 25 | const variant = props.variant ?? "standard"; |
| 24 | 26 | const Component = |
| 25 | 27 | variant === "standard" |
| ... | ... | @@ -52,18 +54,18 @@ export function MaterialIconButton(props: { |
| 52 | 54 | onAccessibilityAction={() => { |
| 53 | 55 | if (!props.disabled) props.onPress?.(); |
| 54 | 56 | }} |
| 55 | style={{ width: 48, height: 48 }} | |
| 57 | style={{ width: buttonSize, height: buttonSize }} | |
| 56 | 58 | > |
| 57 | 59 | <View importantForAccessibility="no-hide-descendants"> |
| 58 | 60 | <Host |
| 59 | 61 | colorScheme={themeAppearance} |
| 60 | 62 | ignoreSafeAreaKeyboardInsets |
| 61 | style={{ width: 48, height: 48 }} | |
| 63 | style={{ width: buttonSize, height: buttonSize }} | |
| 62 | 64 | > |
| 63 | 65 | <Component |
| 64 | 66 | onClick={props.onPress} |
| 65 | 67 | enabled={!props.disabled} |
| 66 | modifiers={[size(48, 48)]} | |
| 68 | modifiers={[size(buttonSize, buttonSize)]} | |
| 67 | 69 | colors={ |
| 68 | 70 | variant === "standard" |
| 69 | 71 | ? undefined |
| ... | ... | @@ -78,7 +80,7 @@ export function MaterialIconButton(props: { |
| 78 | 80 | <View pointerEvents="none" className="absolute inset-0 items-center justify-center"> |
| 79 | 81 | <SymbolView |
| 80 | 82 | name={props.icon === "ellipsis" ? { ios: "ellipsis", android: "more_vert" } : props.icon} |
| 81 | size={24} | |
| 83 | size={iconSize} | |
| 82 | 84 | tintColorClassName={iconTint} |
| 83 | 85 | type="monochrome" |
| 84 | 86 | /> |
apps/mobile/src/components/MaterialListRow.tsx+3-1| ... | ... | @@ -5,6 +5,7 @@ import { useAppearancePreferences } from "../features/settings/appearance/Appear |
| 5 | 5 | import { cn } from "../lib/cn"; |
| 6 | 6 | import { AppText } from "./AppText"; |
| 7 | 7 | import { SymbolView } from "./AppSymbol"; |
| 8 | import { useAndroidControlSizing } from "./useAndroidControlSizing"; | |
| 8 | 9 | |
| 9 | 10 | /** Shared geometry for Material navigation and selection lists. Group rows in one card. */ |
| 10 | 11 | export function MaterialListRow({ |
| ... | ... | @@ -23,6 +24,7 @@ export function MaterialListRow({ |
| 23 | 24 | readonly trailing?: ReactNode; |
| 24 | 25 | }) { |
| 25 | 26 | const { themeVariables } = useAppearancePreferences(); |
| 27 | const { smallIconSize } = useAndroidControlSizing(); | |
| 26 | 28 | return ( |
| 27 | 29 | <Pressable |
| 28 | 30 | accessibilityRole="button" |
| ... | ... | @@ -52,7 +54,7 @@ export function MaterialListRow({ |
| 52 | 54 | {trailing !== undefined ? ( |
| 53 | 55 | trailing |
| 54 | 56 | ) : !props.disabled ? ( |
| 55 | <SymbolView name="chevron.right" size={16} tintColorClassName="accent-chevron" /> | |
| 57 | <SymbolView name="chevron.right" size={smallIconSize} tintColorClassName="accent-chevron" /> | |
| 56 | 58 | ) : null} |
| 57 | 59 | </Pressable> |
| 58 | 60 | ); |
apps/mobile/src/components/MaterialMenuPopup.android.tsx+36-27| ... | ... | @@ -7,9 +7,11 @@ import { |
| 7 | 7 | RNHostView, |
| 8 | 8 | Text, |
| 9 | 9 | } from "@expo/ui/jetpack-compose"; |
| 10 | import { padding, size, width } from "@expo/ui/jetpack-compose/modifiers"; | |
| 10 | import { defaultMinSize, padding, size, width } from "@expo/ui/jetpack-compose/modifiers"; | |
| 11 | 11 | import { View } from "react-native"; |
| 12 | import { resolveScaledTextRole } from "../lib/appearancePreferences"; | |
| 12 | 13 | |
| 14 | import { useAndroidControlSizing } from "./useAndroidControlSizing"; | |
| 13 | 15 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 14 | 16 | import type { MaterialMenuPopupProps } from "./MaterialMenuPopup"; |
| 15 | 17 | import { isAppSymbolName, SymbolView, type AppSymbolName } from "./AppSymbol"; |
| ... | ... | @@ -19,12 +21,16 @@ function MenuIcon(props: { |
| 19 | 21 | readonly destructive?: boolean; |
| 20 | 22 | readonly disabled?: boolean; |
| 21 | 23 | }) { |
| 24 | const { iconSize } = useAndroidControlSizing(); | |
| 22 | 25 | return ( |
| 23 | <RNHostView matchContents modifiers={[size(24, 24)]}> | |
| 24 | <View style={{ width: 24, height: 24 }} importantForAccessibility="no-hide-descendants"> | |
| 26 | <RNHostView matchContents modifiers={[size(iconSize, iconSize)]}> | |
| 27 | <View | |
| 28 | style={{ width: iconSize, height: iconSize }} | |
| 29 | importantForAccessibility="no-hide-descendants" | |
| 30 | > | |
| 25 | 31 | <SymbolView |
| 26 | 32 | name={props.name} |
| 27 | size={24} | |
| 33 | size={iconSize} | |
| 28 | 34 | type="monochrome" |
| 29 | 35 | tintColorClassName={ |
| 30 | 36 | props.disabled |
| ... | ... | @@ -41,24 +47,33 @@ function MenuIcon(props: { |
| 41 | 47 | |
| 42 | 48 | /** Native popup positioned at the original trigger, outside virtualized rows. */ |
| 43 | 49 | export function MaterialMenuPopup(props: MaterialMenuPopupProps) { |
| 44 | const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); | |
| 50 | const { appearance, themeAppearance, themeVariables: colors } = useAppearancePreferences(); | |
| 51 | const { scale, menuItemHeight } = useAndroidControlSizing(); | |
| 52 | const body = resolveScaledTextRole("body", appearance.baseFontSize); | |
| 53 | const caption = resolveScaledTextRole("caption", appearance.baseFontSize); | |
| 45 | 54 | const foreground = colors["--color-foreground"]; |
| 46 | 55 | const muted = colors["--color-foreground-muted"]; |
| 56 | // A fixed native item height clips wrapped labels; a minimum lets each row grow. | |
| 57 | const itemModifiers = [width(props.menuWidth), defaultMinSize({ minHeight: menuItemHeight })]; | |
| 47 | 58 | const items = ( |
| 48 | 59 | <> |
| 49 | 60 | {props.parent ? ( |
| 50 | <DropdownMenuItem onClick={props.onBack} modifiers={[width(250)]}> | |
| 61 | <DropdownMenuItem onClick={props.onBack} modifiers={itemModifiers}> | |
| 51 | 62 | <DropdownMenuItem.LeadingIcon> |
| 52 | 63 | <MenuIcon name="arrow.left" /> |
| 53 | 64 | </DropdownMenuItem.LeadingIcon> |
| 54 | 65 | <DropdownMenuItem.Text> |
| 55 | <Text color={foreground} style={{ typography: "bodyLarge" }}> | |
| 66 | <Text color={foreground} style={{ typography: "bodyLarge", ...body }}> | |
| 56 | 67 | {props.parent.title} |
| 57 | 68 | </Text> |
| 58 | 69 | </DropdownMenuItem.Text> |
| 59 | 70 | </DropdownMenuItem> |
| 60 | 71 | ) : props.title ? ( |
| 61 | <Text color={muted} style={{ typography: "bodySmall" }} modifiers={[padding(16, 8, 16, 8)]}> | |
| 72 | <Text | |
| 73 | color={muted} | |
| 74 | style={{ typography: "bodySmall", ...caption }} | |
| 75 | modifiers={[padding(16 * scale, 8 * scale, 16 * scale, 8 * scale)]} | |
| 76 | > | |
| 62 | 77 | {props.title} |
| 63 | 78 | </Text> |
| 64 | 79 | ) : null} |
| ... | ... | @@ -66,19 +81,22 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) { |
| 66 | 81 | <DropdownMenuItem |
| 67 | 82 | key={action.id ?? `${index}-${action.title}`} |
| 68 | 83 | enabled={!action.attributes?.disabled} |
| 69 | modifiers={[width(250)]} | |
| 70 | elementColors={{ | |
| 71 | textColor: action.attributes?.destructive | |
| 72 | ? colors["--color-danger-foreground"] | |
| 73 | : foreground, | |
| 74 | disabledTextColor: muted, | |
| 75 | }} | |
| 84 | modifiers={itemModifiers} | |
| 76 | 85 | onClick={() => props.onPress(action)} |
| 77 | 86 | > |
| 87 | {action.image && isAppSymbolName(action.image) ? ( | |
| 88 | <DropdownMenuItem.LeadingIcon> | |
| 89 | <MenuIcon | |
| 90 | name={action.image} | |
| 91 | destructive={action.attributes?.destructive} | |
| 92 | disabled={action.attributes?.disabled} | |
| 93 | /> | |
| 94 | </DropdownMenuItem.LeadingIcon> | |
| 95 | ) : null} | |
| 78 | 96 | <DropdownMenuItem.Text> |
| 79 | 97 | <Column> |
| 80 | 98 | <Text |
| 81 | style={{ typography: "bodyLarge" }} | |
| 99 | style={{ typography: "bodyLarge", ...body }} | |
| 82 | 100 | color={ |
| 83 | 101 | action.attributes?.disabled |
| 84 | 102 | ? muted |
| ... | ... | @@ -90,21 +108,12 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) { |
| 90 | 108 | {action.title} |
| 91 | 109 | </Text> |
| 92 | 110 | {action.subtitle ? ( |
| 93 | <Text color={muted} style={{ typography: "bodySmall" }}> | |
| 111 | <Text color={muted} style={{ typography: "bodySmall", ...caption }}> | |
| 94 | 112 | {action.subtitle} |
| 95 | 113 | </Text> |
| 96 | 114 | ) : null} |
| 97 | 115 | </Column> |
| 98 | 116 | </DropdownMenuItem.Text> |
| 99 | {action.image && isAppSymbolName(action.image) ? ( | |
| 100 | <DropdownMenuItem.LeadingIcon> | |
| 101 | <MenuIcon | |
| 102 | name={action.image} | |
| 103 | destructive={action.attributes?.destructive} | |
| 104 | disabled={action.attributes?.disabled} | |
| 105 | /> | |
| 106 | </DropdownMenuItem.LeadingIcon> | |
| 107 | ) : null} | |
| 108 | 117 | {(action.subactions?.length ?? 0) > 0 ? ( |
| 109 | 118 | <DropdownMenuItem.TrailingIcon> |
| 110 | 119 | <MenuIcon name="chevron.right" disabled={action.attributes?.disabled} /> |
| ... | ... | @@ -124,7 +133,7 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) { |
| 124 | 133 | colorScheme={themeAppearance} |
| 125 | 134 | ignoreSafeAreaKeyboardInsets |
| 126 | 135 | matchContents |
| 127 | style={{ width: 250 }} | |
| 136 | style={{ width: props.menuWidth }} | |
| 128 | 137 | > |
| 129 | 138 | <Column>{items}</Column> |
| 130 | 139 | </Host> |
apps/mobile/src/components/MaterialMenuPopup.tsx+1| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import type { MenuAction } from "@react-native-menu/menu"; |
| 2 | 2 | |
| 3 | 3 | export interface MaterialMenuPopupProps { |
| 4 | readonly menuWidth: number; | |
| 4 | 5 | readonly anchor: { |
| 5 | 6 | readonly x: number; |
| 6 | 7 | readonly y: number; |
apps/mobile/src/components/MaterialScrollComposeButton.android.tsx+45-13| ... | ... | @@ -1,9 +1,17 @@ |
| 1 | 1 | import { Box, ExtendedFloatingActionButton, Host, Icon, Text } from "@expo/ui/jetpack-compose"; |
| 2 | import { fillMaxWidth, onSizeChanged, size } from "@expo/ui/jetpack-compose/modifiers"; | |
| 2 | import { | |
| 3 | defaultMinSize, | |
| 4 | fillMaxWidth, | |
| 5 | graphicsLayer, | |
| 6 | height, | |
| 7 | onSizeChanged, | |
| 8 | size, | |
| 9 | } from "@expo/ui/jetpack-compose/modifiers"; | |
| 3 | 10 | import { useCallback, useState } from "react"; |
| 4 | 11 | import { Pressable, View, type StyleProp, type ViewStyle } from "react-native"; |
| 12 | import { useAndroidControlSizing } from "./useAndroidControlSizing"; | |
| 5 | 13 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 6 | import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; | |
| 14 | import { resolveScaledTextRole } from "../lib/appearancePreferences"; | |
| 7 | 15 | |
| 8 | 16 | /** Keep the animated width and icon positioning entirely inside Compose, not Yoga. */ |
| 9 | 17 | export function MaterialScrollComposeButton(props: { |
| ... | ... | @@ -12,14 +20,23 @@ export function MaterialScrollComposeButton(props: { |
| 12 | 20 | readonly className?: string; |
| 13 | 21 | readonly style?: StyleProp<ViewStyle>; |
| 14 | 22 | }) { |
| 15 | const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); | |
| 16 | const typography = useScaledTextRole("footnote"); | |
| 17 | const [expandedWidth, setExpandedWidth] = useState(56); | |
| 23 | const { appearance, themeAppearance, themeVariables: colors } = useAppearancePreferences(); | |
| 24 | const typography = resolveScaledTextRole("footnote", appearance.baseFontSize); | |
| 25 | const { iconSize, fabSize } = useAndroidControlSizing(); | |
| 26 | // Scale the native 56dp minimum; keep text and icons at their requested sizes. | |
| 27 | const nativeSize = Math.max(56, fabSize); | |
| 28 | const scale = fabSize / nativeSize; | |
| 29 | const nativeIconSize = Math.round(iconSize / scale); | |
| 30 | const [buttonWidth, setButtonWidth] = useState(nativeSize); | |
| 18 | 31 | const rememberWidth = useCallback(({ width }: { width: number }) => { |
| 19 | setExpandedWidth((previous) => Math.max(previous, width)); | |
| 32 | setButtonWidth((previous) => Math.max(previous, width)); | |
| 20 | 33 | }, []); |
| 21 | 34 | return ( |
| 22 | <View pointerEvents="box-none" className={props.className} style={[props.style, { left: 20 }]}> | |
| 35 | <View | |
| 36 | pointerEvents="box-none" | |
| 37 | className={props.className} | |
| 38 | style={[props.style, { left: 20, height: fabSize }]} | |
| 39 | > | |
| 23 | 40 | <View pointerEvents="none" importantForAccessibility="no-hide-descendants"> |
| 24 | 41 | <Host |
| 25 | 42 | matchContents={{ vertical: true }} |
| ... | ... | @@ -31,13 +48,23 @@ export function MaterialScrollComposeButton(props: { |
| 31 | 48 | <ExtendedFloatingActionButton |
| 32 | 49 | expanded={props.expanded} |
| 33 | 50 | containerColor={colors["--color-primary"]} |
| 34 | modifiers={[onSizeChanged(rememberWidth)]} | |
| 51 | modifiers={[ | |
| 52 | defaultMinSize({ minWidth: nativeSize }), | |
| 53 | height(nativeSize), | |
| 54 | graphicsLayer({ | |
| 55 | scaleX: scale, | |
| 56 | scaleY: scale, | |
| 57 | transformOriginX: 1, | |
| 58 | transformOriginY: 0, | |
| 59 | }), | |
| 60 | onSizeChanged(rememberWidth), | |
| 61 | ]} | |
| 35 | 62 | > |
| 36 | 63 | <ExtendedFloatingActionButton.Icon> |
| 37 | <Box modifiers={[size(24, 24)]}> | |
| 64 | <Box modifiers={[size(nativeIconSize, nativeIconSize)]}> | |
| 38 | 65 | <Icon |
| 39 | 66 | source={require("../../assets/icons/compose.xml")} |
| 40 | size={24} | |
| 67 | size={nativeIconSize} | |
| 41 | 68 | tint={colors["--color-primary-foreground"]} |
| 42 | 69 | /> |
| 43 | 70 | </Box> |
| ... | ... | @@ -45,7 +72,11 @@ export function MaterialScrollComposeButton(props: { |
| 45 | 72 | <ExtendedFloatingActionButton.Text> |
| 46 | 73 | <Text |
| 47 | 74 | color={colors["--color-primary-foreground"]} |
| 48 | style={{ ...typography, fontWeight: "500" }} | |
| 75 | style={{ | |
| 76 | fontSize: typography.fontSize / scale, | |
| 77 | lineHeight: typography.lineHeight / scale, | |
| 78 | fontWeight: "500", | |
| 79 | }} | |
| 49 | 80 | > |
| 50 | 81 | New thread |
| 51 | 82 | </Text> |
| ... | ... | @@ -65,8 +96,9 @@ export function MaterialScrollComposeButton(props: { |
| 65 | 96 | right: 0, |
| 66 | 97 | top: 0, |
| 67 | 98 | bottom: 0, |
| 68 | width: props.expanded ? expandedWidth : 56, | |
| 69 | borderRadius: 16, | |
| 99 | // Release the label area as soon as collapse starts, before native measurements arrive. | |
| 100 | width: props.expanded ? buttonWidth * scale : fabSize, | |
| 101 | borderRadius: 16 * scale, | |
| 70 | 102 | overflow: "hidden", |
| 71 | 103 | }} |
| 72 | 104 | /> |
apps/mobile/src/components/MaterialSearchField.tsx+18-4| ... | ... | @@ -2,6 +2,7 @@ import type { RefObject } from "react"; |
| 2 | 2 | import { Pressable, TextInput, View } from "react-native"; |
| 3 | 3 | |
| 4 | 4 | import { SymbolView } from "./AppSymbol"; |
| 5 | import { useAndroidControlSizing } from "./useAndroidControlSizing"; | |
| 5 | 6 | |
| 6 | 7 | export function MaterialSearchField({ |
| 7 | 8 | inputRef, |
| ... | ... | @@ -18,9 +19,21 @@ export function MaterialSearchField({ |
| 18 | 19 | readonly value: string; |
| 19 | 20 | readonly onChangeText: (value: string) => void; |
| 20 | 21 | }) { |
| 22 | const { scale, mediumIconSize } = useAndroidControlSizing(); | |
| 21 | 23 | return ( |
| 22 | <View className="h-12 min-w-0 flex-1 flex-row items-center gap-2 rounded-full border border-input-border bg-input px-3"> | |
| 23 | <SymbolView name="magnifyingglass" size={18} tintColorClassName="accent-foreground-muted" /> | |
| 24 | <View | |
| 25 | className="min-w-0 flex-1 flex-row items-center rounded-full border border-input-border bg-input" | |
| 26 | style={{ | |
| 27 | minHeight: Math.max(48, 42 * scale), | |
| 28 | gap: 7 * scale, | |
| 29 | paddingHorizontal: 10.5 * scale, | |
| 30 | }} | |
| 31 | > | |
| 32 | <SymbolView | |
| 33 | name="magnifyingglass" | |
| 34 | size={mediumIconSize} | |
| 35 | tintColorClassName="accent-foreground-muted" | |
| 36 | /> | |
| 24 | 37 | <TextInput |
| 25 | 38 | ref={inputRef} |
| 26 | 39 | accessibilityLabel={accessibilityLabel} |
| ... | ... | @@ -33,7 +46,8 @@ export function MaterialSearchField({ |
| 33 | 46 | selectionColorClassName="accent-focus/32" |
| 34 | 47 | cursorColorClassName="accent-focus" |
| 35 | 48 | selectionHandleColorClassName="accent-focus" |
| 36 | className="min-w-0 flex-1 py-2 font-sans text-base text-foreground" | |
| 49 | className="min-w-0 flex-1 font-sans text-base text-foreground" | |
| 50 | style={{ paddingVertical: 7 * scale }} | |
| 37 | 51 | value={value} |
| 38 | 52 | onChangeText={onChangeText} |
| 39 | 53 | /> |
| ... | ... | @@ -49,7 +63,7 @@ export function MaterialSearchField({ |
| 49 | 63 | > |
| 50 | 64 | <SymbolView |
| 51 | 65 | name="xmark.circle.fill" |
| 52 | size={18} | |
| 66 | size={mediumIconSize} | |
| 53 | 67 | tintColorClassName="accent-foreground-muted" |
| 54 | 68 | /> |
| 55 | 69 | </Pressable> |
apps/mobile/src/components/ScreenHeader.android.tsx+36-18| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 2 | 2 | import { BackHandler, Keyboard, Pressable, TextInput, View } from "react-native"; |
| 3 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 3 | import { useMaterialToolbarLayout } from "./useMaterialToolbarLayout"; | |
| 4 | 4 | import { NativeStackScreenOptions } from "../native/StackHeader"; |
| 5 | 5 | import { AndroidWorkspaceSidebarButton } from "../features/layout/workspace-sidebar-toolbar"; |
| 6 | 6 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| ... | ... | @@ -11,10 +11,12 @@ import { ControlPillMenu } from "./ControlPill"; |
| 11 | 11 | import { MaterialSearchField } from "./MaterialSearchField"; |
| 12 | 12 | import { androidHeaderMenuActions, findHeaderMenuAction } from "./headerMenu.android"; |
| 13 | 13 | import type { ScreenHeaderProps } from "./ScreenHeader.types"; |
| 14 | import { useAndroidControlSizing } from "./useAndroidControlSizing"; | |
| 14 | 15 | |
| 15 | 16 | export function ScreenHeader(props: ScreenHeaderProps) { |
| 16 | 17 | const { search } = props; |
| 17 | const insets = useSafeAreaInsets(); | |
| 18 | const { paddingTop, paddingBottom } = useMaterialToolbarLayout(); | |
| 19 | const { scale, buttonSize, iconSize, smallIconSize } = useAndroidControlSizing(); | |
| 18 | 20 | const { themeVariables } = useAppearancePreferences(); |
| 19 | 21 | const inputRef = useRef<TextInput>(null); |
| 20 | 22 | const [searchOpen, setSearchOpen] = useState(false); |
| ... | ... | @@ -48,11 +50,12 @@ export function ScreenHeader(props: ScreenHeaderProps) { |
| 48 | 50 | <Pressable |
| 49 | 51 | accessibilityLabel={menu.title} |
| 50 | 52 | accessibilityRole="button" |
| 51 | className="size-11 items-center justify-center rounded-full bg-subtle" | |
| 53 | className="items-center justify-center rounded-full bg-subtle" | |
| 54 | style={{ width: buttonSize, height: buttonSize }} | |
| 52 | 55 | > |
| 53 | 56 | <SymbolView |
| 54 | 57 | name={menu.icon} |
| 55 | size={16} | |
| 58 | size={smallIconSize} | |
| 56 | 59 | tintColorClassName="accent-header-foreground" |
| 57 | 60 | type="monochrome" |
| 58 | 61 | /> |
| ... | ... | @@ -80,30 +83,43 @@ export function ScreenHeader(props: ScreenHeaderProps) { |
| 80 | 83 | <> |
| 81 | 84 | {options} |
| 82 | 85 | <View |
| 83 | className="border-b border-header-border bg-header px-3 pb-2.5" | |
| 84 | style={{ paddingTop: Math.max(insets.top, 12), borderBottomWidth: 0 }} | |
| 86 | className="border-b border-header-border bg-header px-3" | |
| 87 | style={{ | |
| 88 | paddingTop, | |
| 89 | paddingBottom: 8.75 * scale, | |
| 90 | borderBottomWidth: 0, | |
| 91 | }} | |
| 85 | 92 | > |
| 86 | <View className="min-h-12 flex-row items-center gap-2"> | |
| 93 | <View className="flex-row items-center gap-2" style={{ minHeight: buttonSize }}> | |
| 87 | 94 | {props.onBack ? ( |
| 88 | 95 | <Pressable |
| 89 | 96 | accessibilityLabel="Navigate up" |
| 90 | 97 | accessibilityRole="button" |
| 91 | 98 | hitSlop={8} |
| 92 | 99 | onPress={props.onBack} |
| 93 | className="size-11 items-center justify-center" | |
| 100 | className="items-center justify-center" | |
| 101 | style={{ width: buttonSize, height: buttonSize }} | |
| 94 | 102 | > |
| 95 | 103 | <SymbolView |
| 96 | 104 | name="chevron.left" |
| 97 | size={24} | |
| 105 | size={iconSize} | |
| 98 | 106 | tintColorClassName="accent-header-foreground" |
| 99 | 107 | type="monochrome" |
| 100 | 108 | /> |
| 101 | 109 | </Pressable> |
| 102 | 110 | ) : null} |
| 103 | <View className="min-h-11 flex-1 flex-row items-center gap-2.5 rounded-2xl bg-input px-3.5"> | |
| 111 | <View | |
| 112 | className="flex-1 flex-row items-center bg-input" | |
| 113 | style={{ | |
| 114 | minHeight: Math.max(48, 38.5 * scale), | |
| 115 | gap: 8.75 * scale, | |
| 116 | borderRadius: 14 * scale, | |
| 117 | paddingHorizontal: 12.25 * scale, | |
| 118 | }} | |
| 119 | > | |
| 104 | 120 | <SymbolView |
| 105 | 121 | name="magnifyingglass" |
| 106 | size={17} | |
| 122 | size={Math.round(17 * scale)} | |
| 107 | 123 | tintColorClassName="accent-header-foreground" |
| 108 | 124 | type="monochrome" |
| 109 | 125 | /> |
| ... | ... | @@ -112,9 +128,14 @@ export function ScreenHeader(props: ScreenHeaderProps) { |
| 112 | 128 | autoCapitalize="none" |
| 113 | 129 | onChangeText={search.onChangeText} |
| 114 | 130 | value={search.value} |
| 115 | placeholder={search.placeholder} | |
| 131 | placeholder={ | |
| 132 | search.compactToolbar | |
| 133 | ? (search.compactPlaceholder ?? search.placeholder) | |
| 134 | : search.placeholder | |
| 135 | } | |
| 116 | 136 | placeholderTextColorClassName="accent-placeholder" |
| 117 | className="flex-1 py-2 text-base font-sans text-header-foreground" | |
| 137 | className="flex-1 text-base font-sans text-header-foreground" | |
| 138 | style={{ paddingVertical: 7 * scale }} | |
| 118 | 139 | /> |
| 119 | 140 | </View> |
| 120 | 141 | {menuView} |
| ... | ... | @@ -173,11 +194,8 @@ export function ScreenHeader(props: ScreenHeaderProps) { |
| 173 | 194 | {header} |
| 174 | 195 | </View> |
| 175 | 196 | {searching ? ( |
| 176 | <View | |
| 177 | className="absolute inset-0 bg-header px-2 pb-2" | |
| 178 | style={{ paddingTop: Math.max(insets.top, 12) }} | |
| 179 | > | |
| 180 | <View className="min-h-14 flex-1 flex-row items-center gap-1"> | |
| 197 | <View className="absolute inset-0 bg-header px-2" style={{ paddingTop, paddingBottom }}> | |
| 198 | <View className="flex-1 flex-row items-center gap-1"> | |
| 181 | 199 | <ScreenHeaderButton |
| 182 | 200 | accessibilityLabel={search.closeAccessibilityLabel ?? "Close search"} |
| 183 | 201 | icon="arrow.left" |
apps/mobile/src/components/useAndroidControlSizing.ts created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | import { Platform } from "react-native"; | |
| 2 | ||
| 3 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | |
| 4 | import { DEFAULT_BASE_FONT_SIZE } from "../lib/appearancePreferences"; | |
| 5 | import { resolveAndroidControlSizing } from "../lib/androidControlSizing"; | |
| 6 | ||
| 7 | /** Shared layouts keep their existing dimensions on iOS. */ | |
| 8 | export function useAndroidControlSizing() { | |
| 9 | const { appearance } = useAppearancePreferences(); | |
| 10 | return resolveAndroidControlSizing( | |
| 11 | Platform.OS === "android" ? appearance.baseFontSize : DEFAULT_BASE_FONT_SIZE, | |
| 12 | ); | |
| 13 | } |
apps/mobile/src/components/useMaterialToolbarHeight.ts deleted-11| ... | ... | @@ -1,11 +0,0 @@ |
| 1 | import { useWindowDimensions } from "react-native"; | |
| 2 | ||
| 3 | import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; | |
| 4 | ||
| 5 | /** Reserve the same title/subtitle space in every pane, including icon-only and search headers. */ | |
| 6 | export function useMaterialToolbarHeight() { | |
| 7 | const title = useScaledTextRole("title"); | |
| 8 | const subtitle = useScaledTextRole("label"); | |
| 9 | const { fontScale } = useWindowDimensions(); | |
| 10 | return Math.ceil(Math.max(56, (title.lineHeight + subtitle.lineHeight) * fontScale + 1)); | |
| 11 | } |
apps/mobile/src/components/useMaterialToolbarLayout.ts created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | import { useWindowDimensions } from "react-native"; | |
| 2 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 3 | ||
| 4 | import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; | |
| 5 | import { useAndroidControlSizing } from "./useAndroidControlSizing"; | |
| 6 | ||
| 7 | /** Shared Android header geometry; class-based spacing uses the app's 14dp rem. */ | |
| 8 | export function useMaterialToolbarLayout(embedded = false) { | |
| 9 | const insets = useSafeAreaInsets(); | |
| 10 | const title = useScaledTextRole("title"); | |
| 11 | const subtitle = useScaledTextRole("label"); | |
| 12 | const { scale } = useAndroidControlSizing(); | |
| 13 | const { fontScale } = useWindowDimensions(); | |
| 14 | return { | |
| 15 | height: Math.ceil( | |
| 16 | Math.max(48, 56 * scale, (title.lineHeight + subtitle.lineHeight) * fontScale + 1), | |
| 17 | ), | |
| 18 | paddingTop: embedded ? 8 * scale : Math.max(insets.top, 12 * scale), | |
| 19 | paddingBottom: 7 * scale, | |
| 20 | }; | |
| 21 | } |
apps/mobile/src/features/files/MaterialFilesHeader.tsx+4-7| ... | ... | @@ -1,10 +1,10 @@ |
| 1 | 1 | import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; |
| 2 | 2 | import { BackHandler, Keyboard, type TextInput, View } from "react-native"; |
| 3 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 4 | 3 | |
| 5 | 4 | import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 6 | 5 | import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu"; |
| 7 | 6 | import { MaterialSearchField } from "../../components/MaterialSearchField"; |
| 7 | import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout"; | |
| 8 | 8 | |
| 9 | 9 | /** Keep Files search in the same header row on compact and expanded layouts. */ |
| 10 | 10 | export function MaterialFilesHeader(props: { |
| ... | ... | @@ -15,7 +15,7 @@ export function MaterialFilesHeader(props: { |
| 15 | 15 | readonly onBack?: () => void; |
| 16 | 16 | readonly leading?: ReactNode; |
| 17 | 17 | }) { |
| 18 | const insets = useSafeAreaInsets(); | |
| 18 | const { paddingTop, paddingBottom } = useMaterialToolbarLayout(); | |
| 19 | 19 | const searchRef = useRef<TextInput>(null); |
| 20 | 20 | const [searchOpen, setSearchOpen] = useState(false); |
| 21 | 21 | const searching = searchOpen || props.searchQuery.length > 0; |
| ... | ... | @@ -77,11 +77,8 @@ export function MaterialFilesHeader(props: { |
| 77 | 77 | /> |
| 78 | 78 | </View> |
| 79 | 79 | {searching ? ( |
| 80 | <View | |
| 81 | className="absolute inset-0 bg-header px-2 pb-2" | |
| 82 | style={{ paddingTop: Math.max(insets.top, 12) }} | |
| 83 | > | |
| 84 | <View className="min-h-14 flex-1 flex-row items-center gap-1"> | |
| 80 | <View className="absolute inset-0 bg-header px-2" style={{ paddingTop, paddingBottom }}> | |
| 81 | <View className="flex-1 flex-row items-center gap-1"> | |
| 85 | 82 | <AndroidHeaderIconButton |
| 86 | 83 | accessibilityLabel="Close file search" |
| 87 | 84 | icon="arrow.left" |
apps/mobile/src/features/home/AndroidHomeFab.android.tsx+14-3| ... | ... | @@ -1,14 +1,23 @@ |
| 1 | 1 | import { useCallback, useRef, useState, type ComponentProps } from "react"; |
| 2 | import { View, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native"; | |
| 2 | import { | |
| 3 | useWindowDimensions, | |
| 4 | View, | |
| 5 | type NativeScrollEvent, | |
| 6 | type NativeSyntheticEvent, | |
| 7 | } from "react-native"; | |
| 3 | 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 4 | 9 | import { MaterialNewThreadButton } from "../../components/MaterialNewThreadButton"; |
| 5 | 10 | import type { AndroidHomeFabLayout as SharedAndroidHomeFabLayout } from "./AndroidHomeFab.shared"; |
| 6 | 11 | import { useWorkspaceState } from "../../state/workspace"; |
| 7 | 12 | import { MaterialFabScrollContext } from "./MaterialFabScrollContext"; |
| 8 | 13 | import { updateMaterialFabScroll } from "./material-fab-scroll"; |
| 14 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | |
| 9 | 15 | |
| 10 | 16 | export function AndroidHomeFabLayout(props: ComponentProps<typeof SharedAndroidHomeFabLayout>) { |
| 11 | 17 | const insets = useSafeAreaInsets(); |
| 18 | const { appearance } = useAppearancePreferences(); | |
| 19 | const { fontScale } = useWindowDimensions(); | |
| 20 | const [layoutWidth, setLayoutWidth] = useState<number | null>(null); | |
| 12 | 21 | const { state } = useWorkspaceState(); |
| 13 | 22 | const [expanded, setExpanded] = useState(true); |
| 14 | 23 | const scrollState = useRef({ anchor: 0, expanded: true }); |
| ... | ... | @@ -23,11 +32,13 @@ export function AndroidHomeFabLayout(props: ComponentProps<typeof SharedAndroidH |
| 23 | 32 | scrollState.current = next; |
| 24 | 33 | }, []); |
| 25 | 34 | |
| 35 | // Remount only the FAB when its font or pane changes to clear the cached expanded width. | |
| 26 | 36 | return ( |
| 27 | <View className="flex-1"> | |
| 37 | <View className="flex-1" onLayout={(event) => setLayoutWidth(event.nativeEvent.layout.width)}> | |
| 28 | 38 | <MaterialFabScrollContext value={onScroll}>{props.children}</MaterialFabScrollContext> |
| 29 | {state.hasConnections ? ( | |
| 39 | {state.hasConnections && layoutWidth !== null ? ( | |
| 30 | 40 | <MaterialNewThreadButton |
| 41 | key={`${appearance.baseFontSize}:${fontScale}:${layoutWidth}`} | |
| 31 | 42 | extended |
| 32 | 43 | expanded={expanded} |
| 33 | 44 | onPress={props.onStartNewTask} |
apps/mobile/src/features/home/HomeScreen.tsx+3-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; | |
| 1 | 2 | import type { ThreadMoveDestination } from "../threads/threadOrder"; |
| 2 | 3 | import { computeThreadMoveAvailability } from "../threads/threadOrder"; |
| 3 | 4 | import { LegendList } from "@legendapp/list/react-native"; |
| ... | ... | @@ -214,6 +215,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 214 | 215 | const queuedThreadKeys = useQueuedThreadKeys(); |
| 215 | 216 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); |
| 216 | 217 | const insets = useSafeAreaInsets(); |
| 218 | const { fabClearance } = useAndroidControlSizing(); | |
| 217 | 219 | const iosBottomToolbarClearance = |
| 218 | 220 | Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED |
| 219 | 221 | ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT |
| ... | ... | @@ -983,7 +985,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 983 | 985 | paddingBottom: |
| 984 | 986 | Platform.OS === "ios" |
| 985 | 987 | ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance |
| 986 | : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88), | |
| 988 | : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? fabClearance : 88), | |
| 987 | 989 | }} |
| 988 | 990 | /> |
| 989 | 991 | </SwipeableScrollGateProvider> |
apps/mobile/src/features/home/MaterialThreadListToolbar.tsx+8-8| ... | ... | @@ -12,7 +12,8 @@ import { MaterialSearchField } from "../../components/MaterialSearchField"; |
| 12 | 12 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| 13 | 13 | import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle"; |
| 14 | 14 | import { useWorkspaceState } from "../../state/workspace"; |
| 15 | import { useMaterialToolbarHeight } from "../../components/useMaterialToolbarHeight"; | |
| 15 | import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; | |
| 16 | import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout"; | |
| 16 | 17 | |
| 17 | 18 | /** One toolbar height for the compact list and expanded sidebar, including search. */ |
| 18 | 19 | export function MaterialThreadListToolbar(props: { |
| ... | ... | @@ -28,7 +29,8 @@ export function MaterialThreadListToolbar(props: { |
| 28 | 29 | readonly onRequestVisibility?: () => void; |
| 29 | 30 | }) { |
| 30 | 31 | const insets = useSafeAreaInsets(); |
| 31 | const toolbarHeight = useMaterialToolbarHeight(); | |
| 32 | const { fabSize } = useAndroidControlSizing(); | |
| 33 | const { height: toolbarHeight, ...headerPadding } = useMaterialToolbarLayout(); | |
| 32 | 34 | const { state } = useWorkspaceState(); |
| 33 | 35 | const { onRequestVisibility, onSearchQueryChange } = props; |
| 34 | 36 | const searchRef = useRef<TextInput>(null); |
| ... | ... | @@ -76,11 +78,9 @@ export function MaterialThreadListToolbar(props: { |
| 76 | 78 | <View |
| 77 | 79 | onLayout={props.onLayout} |
| 78 | 80 | className={ |
| 79 | props.sidebar | |
| 80 | ? "absolute inset-x-0 top-0 z-[4] bg-header px-2 pb-2" | |
| 81 | : "bg-header px-2 pb-2" | |
| 81 | props.sidebar ? "absolute inset-x-0 top-0 z-[4] bg-header px-2" : "bg-header px-2" | |
| 82 | 82 | } |
| 83 | style={{ paddingTop: Math.max(insets.top, 12) }} | |
| 83 | style={headerPadding} | |
| 84 | 84 | > |
| 85 | 85 | <View className="flex-row items-center gap-1" style={{ minHeight: toolbarHeight }}> |
| 86 | 86 | {searching ? ( |
| ... | ... | @@ -116,14 +116,14 @@ export function MaterialThreadListToolbar(props: { |
| 116 | 116 | )} |
| 117 | 117 | </View> |
| 118 | 118 | </View> |
| 119 | {/* Sit 8dp above the 56dp extended New thread FAB. */} | |
| 119 | {/* Keep the filter above the New thread FAB at every text size. */} | |
| 120 | 120 | {state.hasConnections ? ( |
| 121 | 121 | <View |
| 122 | 122 | className="absolute right-5 z-[5]" |
| 123 | 123 | style={{ |
| 124 | 124 | bottom: |
| 125 | 125 | (props.sidebar ? Math.max(insets.bottom, 12) + 6 : Math.max(insets.bottom, 16) + 16) + |
| 126 | 56 + | |
| 126 | fabSize + | |
| 127 | 127 | 8, |
| 128 | 128 | }} |
| 129 | 129 | > |
apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx+14-10| ... | ... | @@ -1,9 +1,10 @@ |
| 1 | 1 | import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; |
| 2 | 2 | import { useEffect, useRef, useState, type ReactNode } from "react"; |
| 3 | import { ActivityIndicator, Animated, Pressable, View } from "react-native"; | |
| 3 | import { ActivityIndicator, Animated, Platform, Pressable, View } from "react-native"; | |
| 4 | 4 | |
| 5 | 5 | import { SymbolView } from "../../components/AppSymbol"; |
| 6 | 6 | import { AppText as Text } from "../../components/AppText"; |
| 7 | import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; | |
| 7 | 8 | import { |
| 8 | 9 | brandTitleOffset, |
| 9 | 10 | CompactBrandTitle, |
| ... | ... | @@ -105,6 +106,7 @@ export function WorkspaceConnectionTitle(props: { |
| 105 | 106 | }) { |
| 106 | 107 | const status = useDelayedConnectionStatus(); |
| 107 | 108 | const size = props.size ?? "navbar"; |
| 109 | const { scale } = useAndroidControlSizing(); | |
| 108 | 110 | |
| 109 | 111 | if (status === null) { |
| 110 | 112 | return props.grow ? ( |
| ... | ... | @@ -126,26 +128,28 @@ export function WorkspaceConnectionTitle(props: { |
| 126 | 128 | hitSlop={8} |
| 127 | 129 | onPress={props.onPress} |
| 128 | 130 | className="flex-row items-center gap-2" |
| 129 | style={{ flexShrink: 1, marginLeft: props.statusOffset ?? 0 }} | |
| 131 | style={[ | |
| 132 | { flexShrink: 1, marginLeft: props.statusOffset ?? 0 }, | |
| 133 | Platform.OS === "android" && { gap: 7 * scale }, | |
| 134 | ]} | |
| 130 | 135 | > |
| 131 | 136 | {status.showsProgress ? ( |
| 132 | <ActivityIndicator colorClassName={"accent-icon-muted"} size="small" /> | |
| 137 | <ActivityIndicator | |
| 138 | colorClassName={"accent-icon-muted"} | |
| 139 | size={Platform.OS === "android" ? Math.round(20 * scale) : "small"} | |
| 140 | /> | |
| 133 | 141 | ) : ( |
| 134 | 142 | <SymbolView |
| 135 | 143 | name="wifi.slash" |
| 136 | size={size === "pageTitle" ? 17 : 15} | |
| 144 | size={Math.round((size === "pageTitle" ? 17 : 15) * scale)} | |
| 137 | 145 | tintColorClassName={"accent-icon-muted"} |
| 138 | 146 | type="monochrome" |
| 139 | 147 | /> |
| 140 | 148 | )} |
| 141 | 149 | <Text |
| 142 | className={ | |
| 143 | size === "pageTitle" | |
| 144 | ? "text-[20px] font-t3-bold text-foreground-muted" | |
| 145 | : "text-[16px] font-t3-bold text-foreground-muted" | |
| 146 | } | |
| 150 | className="font-t3-bold text-foreground-muted" | |
| 147 | 151 | numberOfLines={1} |
| 148 | style={{ flexShrink: 1 }} | |
| 152 | style={{ flexShrink: 1, fontSize: (size === "pageTitle" ? 20 : 16) * scale }} | |
| 149 | 153 | > |
| 150 | 154 | {status.label} |
| 151 | 155 | </Text> |
apps/mobile/src/features/settings/appearance/useScaledTextRole.ts+5-16| ... | ... | @@ -1,10 +1,6 @@ |
| 1 | 1 | import { useMemo } from "react"; |
| 2 | 2 | |
| 3 | import { | |
| 4 | DEFAULT_BASE_FONT_SIZE, | |
| 5 | normalizeBaseFontSize, | |
| 6 | scaledTypographyLineHeight, | |
| 7 | } from "../../../lib/appearancePreferences"; | |
| 3 | import { resolveScaledTextRole } from "../../../lib/appearancePreferences"; | |
| 8 | 4 | import { MOBILE_TYPOGRAPHY } from "../../../lib/typography"; |
| 9 | 5 | import { useAppearancePreferences } from "./AppearancePreferencesProvider"; |
| 10 | 6 | |
| ... | ... | @@ -20,15 +16,8 @@ export interface ScaledTextRole { |
| 20 | 16 | */ |
| 21 | 17 | export function useScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY): ScaledTextRole { |
| 22 | 18 | const { appearance } = useAppearancePreferences(); |
| 23 | return useMemo(() => { | |
| 24 | const baseFontSize = normalizeBaseFontSize(appearance.baseFontSize); | |
| 25 | const typography = MOBILE_TYPOGRAPHY[role]; | |
| 26 | return { | |
| 27 | fontSize: Math.max( | |
| 28 | 8, | |
| 29 | Math.round(typography.fontSize * (baseFontSize / DEFAULT_BASE_FONT_SIZE)), | |
| 30 | ), | |
| 31 | lineHeight: scaledTypographyLineHeight(typography, baseFontSize), | |
| 32 | }; | |
| 33 | }, [appearance.baseFontSize, role]); | |
| 19 | return useMemo( | |
| 20 | () => resolveScaledTextRole(role, appearance.baseFontSize), | |
| 21 | [appearance.baseFontSize, role], | |
| 22 | ); | |
| 34 | 23 | } |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+24-13| ... | ... | @@ -132,30 +132,40 @@ import { fileRoutePathSegments } from "../files/filePath"; |
| 132 | 132 | function NewTaskWorkspaceIcon(props: { |
| 133 | 133 | readonly workspaceMode: "local" | "worktree"; |
| 134 | 134 | readonly worktreePath: string | null; |
| 135 | readonly size: number; | |
| 135 | 136 | }) { |
| 136 | 137 | if (props.workspaceMode === "local" && props.worktreePath === null) { |
| 137 | 138 | return ( |
| 138 | 139 | <SymbolView |
| 139 | 140 | name="folder" |
| 140 | size={16} | |
| 141 | size={props.size} | |
| 141 | 142 | tintColorClassName="accent-icon-muted" |
| 142 | 143 | type="monochrome" |
| 143 | 144 | /> |
| 144 | 145 | ); |
| 145 | 146 | } |
| 146 | 147 | |
| 148 | const boxSize = (14 * props.size) / 16; | |
| 147 | 149 | return ( |
| 148 | <View className="size-4"> | |
| 150 | <View | |
| 151 | className="size-4" | |
| 152 | style={Platform.OS === "android" ? { width: boxSize, height: boxSize } : undefined} | |
| 153 | > | |
| 149 | 154 | <SymbolView |
| 150 | 155 | name="folder" |
| 151 | size={16} | |
| 156 | size={props.size} | |
| 152 | 157 | tintColorClassName="accent-icon-muted" |
| 153 | 158 | type="monochrome" |
| 154 | 159 | /> |
| 155 | <View className="absolute -right-1 -bottom-1"> | |
| 160 | <View | |
| 161 | className="absolute -right-1 -bottom-1" | |
| 162 | style={ | |
| 163 | Platform.OS === "android" ? { right: -boxSize / 4, bottom: -boxSize / 4 } : undefined | |
| 164 | } | |
| 165 | > | |
| 156 | 166 | <SymbolView |
| 157 | 167 | name="arrow.triangle.branch" |
| 158 | size={9} | |
| 168 | size={Math.round((9 * props.size) / 16)} | |
| 159 | 169 | tintColorClassName="accent-icon-muted" |
| 160 | 170 | type="monochrome" |
| 161 | 171 | /> |
| ... | ... | @@ -1476,13 +1486,13 @@ export function NewTaskDraftScreen(props: { |
| 1476 | 1486 | accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`} |
| 1477 | 1487 | chevronDirection="right" |
| 1478 | 1488 | disabled={isComposerInteractionLocked || voiceInput.isBusy} |
| 1479 | iconNode={ | |
| 1489 | renderIcon={(size) => ( | |
| 1480 | 1490 | <EnvironmentMachineSymbol |
| 1481 | 1491 | kind={resolveEnvironmentMachineKind(selectedEnvironmentServerConfig)} |
| 1482 | size={16} | |
| 1492 | size={size} | |
| 1483 | 1493 | tintColorClassName="accent-icon-muted" |
| 1484 | 1494 | /> |
| 1485 | } | |
| 1495 | )} | |
| 1486 | 1496 | label={`on ${selectedEnvironmentLabel}`} |
| 1487 | 1497 | maxWidth={260} |
| 1488 | 1498 | onPress={ |
| ... | ... | @@ -1517,12 +1527,13 @@ export function NewTaskDraftScreen(props: { |
| 1517 | 1527 | accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`} |
| 1518 | 1528 | accessibilityLabel={workspaceLabel} |
| 1519 | 1529 | disabled={isComposerInteractionLocked || voiceInput.isBusy} |
| 1520 | iconNode={ | |
| 1530 | renderIcon={(size) => ( | |
| 1521 | 1531 | <NewTaskWorkspaceIcon |
| 1522 | 1532 | workspaceMode={flow.workspaceMode} |
| 1523 | 1533 | worktreePath={flow.selectedWorktreePath} |
| 1534 | size={size} | |
| 1524 | 1535 | /> |
| 1525 | } | |
| 1536 | )} | |
| 1526 | 1537 | label={workspaceLabel} |
| 1527 | 1538 | maxWidth={flow.workspaceMode === "local" ? 220 : 148} |
| 1528 | 1539 | onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")} |
| ... | ... | @@ -1681,12 +1692,12 @@ export function NewTaskDraftScreen(props: { |
| 1681 | 1692 | accessibilityLabel="Model and reasoning settings" |
| 1682 | 1693 | disabled={isComposerInteractionLocked} |
| 1683 | 1694 | emphasized |
| 1684 | iconNode={ | |
| 1695 | renderIcon={(size) => ( | |
| 1685 | 1696 | <ProviderIcon |
| 1686 | 1697 | provider={flow.selectedModelOption?.providerDriver} |
| 1687 | size={16} | |
| 1698 | size={size} | |
| 1688 | 1699 | /> |
| 1689 | } | |
| 1700 | )} | |
| 1690 | 1701 | label={flow.selectedModelOption?.label ?? "Choose model"} |
| 1691 | 1702 | maxWidth="100%" |
| 1692 | 1703 | onPress={settingsSheetPresentation.open} |
apps/mobile/src/features/threads/ThreadComposer.tsx+3-3| ... | ... | @@ -961,9 +961,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 961 | 961 | <ComposerInlineControl |
| 962 | 962 | accessibilityLabel="Model and reasoning settings" |
| 963 | 963 | emphasized |
| 964 | iconNode={ | |
| 965 | <ProviderIcon provider={currentModelOption?.providerDriver} size={16} /> | |
| 966 | } | |
| 964 | renderIcon={(size) => ( | |
| 965 | <ProviderIcon provider={currentModelOption?.providerDriver} size={size} /> | |
| 966 | )} | |
| 967 | 967 | label={currentModelOption?.label ?? currentModelSelection.model} |
| 968 | 968 | maxWidth="100%" |
| 969 | 969 | onPress={openSettings} |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+6-4| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; | |
| 1 | 2 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 2 | 3 | import { computeThreadMoveAvailability } from "./threadOrder"; |
| 3 | 4 | import type { |
| ... | ... | @@ -50,7 +51,7 @@ import { |
| 50 | 51 | } from "../home/WorkspaceConnectionTitle"; |
| 51 | 52 | import { SidebarHeaderActions } from "./sidebar-header-actions"; |
| 52 | 53 | import { MaterialThreadListToolbar } from "../home/MaterialThreadListToolbar"; |
| 53 | import { useMaterialToolbarHeight } from "../../components/useMaterialToolbarHeight"; | |
| 54 | import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout"; | |
| 54 | 55 | import { useMaterialFabScroll } from "../home/MaterialFabScrollContext"; |
| 55 | 56 | import { SidebarFilterButton } from "./sidebar-filter-button"; |
| 56 | 57 | import { createSidebarHeaderItems } from "./sidebar-native-header-items"; |
| ... | ... | @@ -133,6 +134,7 @@ function ThreadNavigationSidebarPane( |
| 133 | 134 | const drawerColor = materialTheme["--color-drawer"]; |
| 134 | 135 | |
| 135 | 136 | const insets = useSafeAreaInsets(); |
| 137 | const { fabClearance } = useAndroidControlSizing(); | |
| 136 | 138 | const projects = useProjects(); |
| 137 | 139 | const threads = useThreadShells(); |
| 138 | 140 | const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); |
| ... | ... | @@ -603,14 +605,14 @@ function ThreadNavigationSidebarPane( |
| 603 | 605 | ); |
| 604 | 606 | |
| 605 | 607 | const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null); |
| 606 | const materialToolbarHeight = useMaterialToolbarHeight(); | |
| 608 | const { height, paddingTop, paddingBottom } = useMaterialToolbarLayout(); | |
| 607 | 609 | // The sticky header (title row, search field, optional connection status) |
| 608 | 610 | // is measured so the list inset always matches its real height — no |
| 609 | 611 | // hardcoded per-variant constants. |
| 610 | 612 | const stickyHeaderHeight = |
| 611 | 613 | measuredHeaderHeight ?? |
| 612 | 614 | (Platform.OS === "android" |
| 613 | ? Math.max(insets.top, 12) + materialToolbarHeight + 8 | |
| 615 | ? paddingTop + height + paddingBottom | |
| 614 | 616 | : insets.top + SIDEBAR_STICKY_HEADER_HEIGHT); |
| 615 | 617 | const topListInset = stickyHeaderHeight + 6; |
| 616 | 618 | const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => { |
| ... | ... | @@ -1039,7 +1041,7 @@ function ThreadNavigationSidebarPane( |
| 1039 | 1041 | { |
| 1040 | 1042 | paddingBottom: |
| 1041 | 1043 | Platform.OS === "android" |
| 1042 | ? Math.max(insets.bottom, 16) + 148 - insets.bottom | |
| 1044 | ? Math.max(insets.bottom, 16) + fabClearance - insets.bottom | |
| 1043 | 1045 | : 16 + insets.bottom, |
| 1044 | 1046 | paddingTop: Platform.OS === "android" ? 6 : topListInset, |
| 1045 | 1047 | }, |
apps/mobile/src/lib/androidControlSizing.test.ts created+22| ... | ... | @@ -0,0 +1,22 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { resolveAndroidControlSizing } from "./androidControlSizing"; | |
| 4 | ||
| 5 | describe("Android control sizing", () => { | |
| 6 | it.each([ | |
| 7 | [11, 17, 48, 48, 172, 48], | |
| 8 | [16, 24, 48, 56, 250, 48], | |
| 9 | [22, 33, 66, 77, 344, 66], | |
| 10 | ])( | |
| 11 | "scales controls at %ipt", | |
| 12 | (fontSize, iconSize, buttonSize, fabSize, menuWidth, menuItemHeight) => { | |
| 13 | expect(resolveAndroidControlSizing(fontSize)).toMatchObject({ | |
| 14 | iconSize, | |
| 15 | buttonSize, | |
| 16 | fabSize, | |
| 17 | menuWidth, | |
| 18 | menuItemHeight, | |
| 19 | }); | |
| 20 | }, | |
| 21 | ); | |
| 22 | }); |
apps/mobile/src/lib/androidControlSizing.ts created+23| ... | ... | @@ -0,0 +1,23 @@ |
| 1 | import { DEFAULT_BASE_FONT_SIZE, normalizeBaseFontSize } from "./appearancePreferences"; | |
| 2 | ||
| 3 | /** Android controls follow the app's text size; buttons and menu rows retain a 48dp touch target. */ | |
| 4 | export function resolveAndroidControlSizing(baseFontSize: number) { | |
| 5 | const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; | |
| 6 | const iconSize = Math.round(24 * scale); | |
| 7 | const buttonSize = Math.max(48, Math.round(48 * scale)); | |
| 8 | const fabSize = Math.max(48, Math.round(56 * scale)); | |
| 9 | ||
| 10 | return { | |
| 11 | scale, | |
| 12 | iconSize, | |
| 13 | smallIconSize: Math.round(16 * scale), | |
| 14 | mediumIconSize: Math.round(18 * scale), | |
| 15 | buttonSize, | |
| 16 | fabSize, | |
| 17 | largeFabSize: Math.round(96 * scale), | |
| 18 | menuWidth: Math.round(250 * scale), | |
| 19 | menuItemHeight: Math.max(48, Math.round(48 * scale)), | |
| 20 | // Two floating actions, their gap, and the space below the lower action. | |
| 21 | fabClearance: fabSize * 2 + 36, | |
| 22 | }; | |
| 23 | } |
apps/mobile/src/lib/appearancePreferences.ts+10| ... | ... | @@ -219,6 +219,16 @@ export function scaledTypographyLineHeight( |
| 219 | 219 | return Math.max(10, Math.round(role.lineHeight * scale)); |
| 220 | 220 | } |
| 221 | 221 | |
| 222 | /** Text dimensions shared by React Native and Compose consumers of an appearance role. */ | |
| 223 | export function resolveScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY, baseFontSize: number) { | |
| 224 | const typography = MOBILE_TYPOGRAPHY[role]; | |
| 225 | const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; | |
| 226 | return { | |
| 227 | fontSize: Math.max(8, Math.round(typography.fontSize * scale)), | |
| 228 | lineHeight: scaledTypographyLineHeight(typography, baseFontSize), | |
| 229 | }; | |
| 230 | } | |
| 231 | ||
| 222 | 232 | export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMarkdownTypography { |
| 223 | 233 | const fontSizes = resolveMarkdownFontSizes(baseFontSize); |
| 224 | 234 | return { |