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..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 @@ -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,17 @@ 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) @@ -183,9 +195,23 @@ 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 -> + setSelectionHandleColor(reactTag, 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..81703090a3f0 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt @@ -0,0 +1,67 @@ +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/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/App.tsx b/apps/mobile/src/App.tsx index 7b2c0318f9fb..34a742f8f11b 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -1,8 +1,7 @@ -import { BlurTargetView } from "expo-blur"; import * as Linking from "expo-linking"; import * as SplashScreen from "expo-splash-screen"; import { useEffect } from "react"; -import { StatusBar } from "react-native"; +import { StatusBar, View } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; @@ -21,10 +20,8 @@ import { import { RootStack } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; import { OverlayPortalHost } from "./components/OverlayPortal"; -import { appBlurTargetRef } from "./lib/appBlurTarget"; import { shouldHandleAppLink } from "./lib/appLinking"; import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; - import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; import "../global.css"; @@ -88,14 +85,13 @@ function AppContent() { this, React Navigation defaults to its light theme and every native header (glass buttons, title, materials) is forced light even when the system is in dark mode. */} - {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */} - + - + {/* Anchored-menu overlays render here — in-window, so the keyboard stays up while a dropdown is open. */} diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx index 79ec95d0a014..dfa0dea8d05a 100644 --- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -6,12 +6,8 @@ import { BackHandler, Pressable, ScrollView, View } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; import Animated, { FadeIn } from "react-native-reanimated"; -import { appBlurTargetRef } from "../lib/appBlurTarget"; -import { cn } from "../lib/cn"; -import { type AppSymbolName, SymbolView } from "./AppSymbol"; -import { AppText as Text } from "./AppText"; import { OverlayPortal } from "./OverlayPortal"; -import { GlassBackdrop } from "./GlassBackdrop"; +import { MaterialMenuPopup } from "./MaterialMenuPopup"; const MENU_WIDTH = 250; const SCREEN_MARGIN = 12; @@ -27,6 +23,7 @@ type AnchorSnapshot = { readonly y: number; readonly width: number; readonly height: number; + readonly keyboardWasVisible: boolean; }; type OverlayFrame = { @@ -53,13 +50,9 @@ 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. */ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const [anchor, setAnchor] = useState(null); @@ -89,9 +82,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) => { @@ -100,18 +93,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 || !anchor.keyboardWasVisible) { return; } const subscription = BackHandler.addEventListener("hardwareBackPress", () => { @@ -212,10 +198,20 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { onLayout={measureOverlay} > - {!placeable || local === null ? null : ( + {!placeable || local === null ? null : !anchor.keyboardWasVisible ? ( + setPath((current) => current.slice(0, -1))} + onClose={close} + /> + ) : ( - + {/* Compose DropdownMenu takes popup focus in the pinned Expo UI version. + Keep editor menus in-window so opening one preserves the keyboard. */} + {/* keyboardShouldPersistTaps: the menu often opens over an active editor; the first item tap must act, not just dismiss the keyboard. */} - {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 ? ( - <> - - - {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} - - ); - })} + setPath((current) => current.slice(0, -1))} + onClose={close} + /> )} diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index f397c18a0a8f..2695eee5f01f 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -1,16 +1,21 @@ -import type { ReactNode } from "react"; -import { Pressable, View } from "react-native"; +import { useState, type ReactNode } from "react"; +import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { SymbolView, type AppSymbolName } from "./AppSymbol"; +import type { AppSymbolName } from "./AppSymbol"; import { AppText as Text } from "./AppText"; import { cn } from "../lib/cn"; +import { MaterialIconButton } from "./MaterialIconButton"; +import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; +import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; +import { useMaterialToolbarHeight } from "./useMaterialToolbarHeight"; export interface AndroidHeaderAction { readonly accessibilityLabel: string; readonly icon: AppSymbolName; readonly onPress: () => void; readonly disabled?: boolean; + readonly selected?: boolean; } export function AndroidHeaderIconButton(props: { @@ -18,73 +23,62 @@ export function AndroidHeaderIconButton(props: { readonly icon: AppSymbolName; readonly onPress?: () => void; readonly disabled?: boolean; + readonly selected?: boolean; }) { - return ( - - - - ); + return ; } export function AndroidScreenHeader(props: { readonly title: string; readonly subtitle?: string | null; readonly actions?: ReadonlyArray; + readonly leading?: ReactNode; readonly trailing?: ReactNode; readonly onBack?: () => void; readonly embedded?: boolean; readonly hideBottomBorder?: boolean; }) { const insets = useSafeAreaInsets(); + const titleTypography = useScaledTextRole("title"); + const subtitleTypography = useScaledTextRole("label"); + const materialToolbarHeight = useMaterialToolbarHeight(); + const [headerWidth, setHeaderWidth] = useState(0); + const actions = props.actions ?? []; + const directCount = actions.length > 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="border-b border-header-border bg-header px-2 pb-2" style={{ paddingTop: props.embedded ? 8 : Math.max(insets.top, 12), borderBottomWidth: props.hideBottomBorder ? 0 : undefined, }} > - + {props.onBack ? ( - - - + /> ) : null} + {props.leading} + - + {props.title} {props.subtitle ? ( {props.subtitle} @@ -92,15 +86,39 @@ 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), + state: action.selected ? "on" : undefined, + }, + }))} + 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..f3ad961ce842 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"; @@ -36,8 +37,10 @@ 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"; import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal"; import IconEdit from "@tabler/icons-react-native/IconEdit"; import IconExternalLink from "@tabler/icons-react-native/IconExternalLink"; @@ -97,6 +100,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, @@ -142,7 +146,10 @@ 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, "line.3.horizontal": IconMenu2, @@ -160,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, @@ -209,6 +217,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/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/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx index d7db39d39ae1..aa1653055b59 100644 --- a/apps/mobile/src/components/ConfirmDialogHost.tsx +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useState } from "react"; -import { Modal, Pressable, TextInput, View } from "react-native"; +import { Platform, Modal, Pressable, TextInput, View } from "react-native"; import { cn } from "../lib/cn"; import { AppText } from "./AppText"; +import { MaterialConfirmDialog } from "./MaterialConfirmDialog"; export type ConfirmDialogRequest = { readonly title: string; @@ -67,17 +68,35 @@ export function ConfirmDialogHost() { setPresented(null); }, [presented]); - const handleConfirm = useCallback(() => { - if (presented?.kind === "confirm") { - presented.request.onConfirm(); - } else if (presented?.kind === "text-input") { - presented.request.onConfirm(inputValue); - } - setPresented(null); - }, [inputValue, presented]); + const handleConfirm = useCallback( + (nativeInputValue?: string) => { + if (presented?.kind === "confirm") { + presented.request.onConfirm(); + } else if (presented?.kind === "text-input") { + presented.request.onConfirm(nativeInputValue ?? inputValue); + } + setPresented(null); + }, + [inputValue, presented], + ); const confirmDisabled = presented?.kind === "text-input" && inputValue.trim().length === 0; + if (Platform.OS === "android") + return presented ? ( + + ) : null; + return ( handleConfirm()} returnKeyType="done" selectTextOnFocus value={inputValue} @@ -125,7 +144,7 @@ export function ConfirmDialogHost() { accessibilityRole="button" disabled={confirmDisabled} className="min-h-10 items-center justify-center px-4 active:bg-subtle" - onPress={handleConfirm} + onPress={() => handleConfirm()} > + ); + } + + if ( + Platform.OS === "android" && + props.accessibilityLabel && + props.icon && + !props.iconNode && + !props.label && + !props.className && + !props.activateOnPressIn + ) { + return ( + + ); + } + return ( , "children" | "themeVariant"> & Pick & { 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 ? ( ; -}) { +export function GlassBackdrop(props: { readonly fallbackColor?: ColorValue }) { const { themeAppearance } = useAppearancePreferences(); - const inheritedBlurTarget = useContext(GlassBlurTargetContext); - const target = props.blurTarget ?? inheritedBlurTarget; - const supportsBlur = - Platform.OS === "ios" || - (Platform.OS === "android" && Platform.Version >= 31 && target !== undefined); + const supportsBlur = Platform.OS === "ios"; const colorStyle = props.fallbackColor === undefined ? undefined @@ -24,17 +15,9 @@ export function GlassBackdrop(props: { return ( <> - {/* Android samples a separate target. An opaque backing prevents any - transparent pixels in that sample from exposing the unblurred feed. - iOS samples its actual backdrop, so a backing there would hide it. */} - {Platform.OS === "android" ? ( - - ) : null} {supportsBlur ? ( ; /** Uniwind styling used only when native Liquid Glass is unavailable. */ readonly fallbackClassName?: string; } @@ -41,7 +40,6 @@ export function GlassSurface({ tintColor, tintColorClassName, fallbackColor, - blurTarget, fallbackClassName, className, style, @@ -49,23 +47,15 @@ export function GlassSurface({ }: GlassSurfaceProps) { const isDarkMode = useColorScheme() === "dark"; const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable(); + const hasShadow = chrome !== "none" && Platform.OS !== "android"; 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) { @@ -103,7 +93,7 @@ export function GlassSurface({ )} style={[surfaceStyle, style]} > - + {children} ); diff --git a/apps/mobile/src/components/MaterialButton.android.tsx b/apps/mobile/src/components/MaterialButton.android.tsx new file mode 100644 index 000000000000..b5a8417fc799 --- /dev/null +++ b/apps/mobile/src/components/MaterialButton.android.tsx @@ -0,0 +1,94 @@ +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"; +import type { MaterialButtonProps } from "./MaterialButton"; + +export function MaterialButton(props: MaterialButtonProps) { + const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const typography = useScaledTextRole("footnote"); + const tone = props.tone ?? "secondary"; + const Component = + tone === "text" ? TextButton : tone === "secondary" ? FilledTonalButton : Button; + const containerColor = + tone === "primary" + ? colors["--color-primary"] + : tone === "danger" + ? colors["--color-danger"] + : tone === "text" + ? "#00000000" + : colors["--color-secondary"]; + const contentColor = + tone === "primary" + ? colors["--color-primary-foreground"] + : tone === "danger" + ? colors["--color-danger-foreground"] + : tone === "text" + ? colors["--color-primary"] + : colors["--color-secondary-foreground"]; + return ( + { + if (!props.disabled && !props.loading) props.onPress(); + }} + style={props.fullWidth ? { width: "100%" } : { alignSelf: "flex-start" }} + > + + + + + {props.loading ? ( + <> + + + + ) : null} + {props.label} + + + + + + ); +} diff --git a/apps/mobile/src/components/MaterialButton.tsx b/apps/mobile/src/components/MaterialButton.tsx new file mode 100644 index 000000000000..f0ad4cb8f55c --- /dev/null +++ b/apps/mobile/src/components/MaterialButton.tsx @@ -0,0 +1,24 @@ +import { Pressable } from "react-native"; +import { AppText } from "./AppText"; + +export interface MaterialButtonProps { + readonly label: string; + readonly onPress: () => void; + readonly disabled?: boolean; + readonly loading?: 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..3e65d7185ecc --- /dev/null +++ b/apps/mobile/src/components/MaterialConfirmDialog.android.tsx @@ -0,0 +1,96 @@ +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"; +import type { MaterialConfirmDialogProps } from "./MaterialConfirmDialog"; + +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 = () => { + if (props.confirmDisabled) return; + const value = props.inputInitialValue === undefined ? undefined : inputState.get(); + if (value !== undefined && !value.trim()) return; + props.onConfirm(value); + }; + return ( + + + + {props.request.title} + + {props.inputInitialValue !== undefined ? ( + + + + {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..c21ad042c39c --- /dev/null +++ b/apps/mobile/src/components/MaterialConfirmDialog.tsx @@ -0,0 +1,17 @@ +import type { ConfirmDialogRequest } from "./ConfirmDialogHost"; + +export interface MaterialConfirmDialogProps { + 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: (inputValue?: string) => void; +} + +export function MaterialConfirmDialog(_props: MaterialConfirmDialogProps) { + return null; +} diff --git a/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx b/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx new file mode 100644 index 000000000000..3978712683c0 --- /dev/null +++ b/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx @@ -0,0 +1,86 @@ +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 expanded?: boolean; + 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} + + + + {/* The RN icon stays outside Compose so it cannot intercept native button taps. */} + + + + + ); +} diff --git a/apps/mobile/src/components/MaterialFloatingActionButton.tsx b/apps/mobile/src/components/MaterialFloatingActionButton.tsx new file mode 100644 index 000000000000..eeed5fd1d138 --- /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.expanded !== false ? ( + + {props.label} + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/MaterialIconButton.android.tsx b/apps/mobile/src/components/MaterialIconButton.android.tsx new file mode 100644 index 000000000000..9cc6f5dc6ce0 --- /dev/null +++ b/apps/mobile/src/components/MaterialIconButton.android.tsx @@ -0,0 +1,87 @@ +import { + FilledIconButton, + FilledTonalIconButton, + 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; + readonly selected?: boolean; + readonly variant?: "standard" | "primary" | "tonal" | "danger"; +}) { + 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 ( + { + if (!props.disabled) props.onPress?.(); + }} + style={{ width: 48, height: 48 }} + > + + + + {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 new file mode 100644 index 000000000000..852889140ad5 --- /dev/null +++ b/apps/mobile/src/components/MaterialIconButton.tsx @@ -0,0 +1,23 @@ +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; + readonly selected?: boolean; + readonly variant?: "standard" | "primary" | "tonal" | "danger"; +}) { + return ( + + + + ); +} 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/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; +} 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/components/MaterialNewThreadButton.shared.tsx b/apps/mobile/src/components/MaterialNewThreadButton.shared.tsx new file mode 100644 index 000000000000..2c887ce78a71 --- /dev/null +++ b/apps/mobile/src/components/MaterialNewThreadButton.shared.tsx @@ -0,0 +1,40 @@ +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 expanded?: boolean; + readonly className?: string; + readonly style?: StyleProp; +}) { + return ( + + + {props.extended && props.expanded !== false ? ( + New thread + ) : null} + + ); +} diff --git a/apps/mobile/src/components/MaterialNewThreadButton.tsx b/apps/mobile/src/components/MaterialNewThreadButton.tsx new file mode 100644 index 000000000000..d197cf97d62d --- /dev/null +++ b/apps/mobile/src/components/MaterialNewThreadButton.tsx @@ -0,0 +1 @@ +export { MaterialNewThreadButton } from "./MaterialNewThreadButton.shared"; diff --git a/apps/mobile/src/components/MaterialRadioIndicator.android.tsx b/apps/mobile/src/components/MaterialRadioIndicator.android.tsx new file mode 100644 index 000000000000..2484dc4536ba --- /dev/null +++ b/apps/mobile/src/components/MaterialRadioIndicator.android.tsx @@ -0,0 +1,55 @@ +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, 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 ( + + + + + + ); +} 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/components/MaterialScreenContent.tsx b/apps/mobile/src/components/MaterialScreenContent.tsx new file mode 100644 index 000000000000..44d4cc093b6d --- /dev/null +++ b/apps/mobile/src/components/MaterialScreenContent.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from "react"; +import { Platform, View } from "react-native"; + +/** 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; +}) { + if (Platform.OS !== "android") return children; + + return ( + + + {children} + + + ); +} 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 new file mode 100644 index 000000000000..16ec879821e1 --- /dev/null +++ b/apps/mobile/src/components/MaterialSegmentedControl.android.tsx @@ -0,0 +1,40 @@ +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 type { SegmentedControlProps } from "./SegmentedControl"; + +export function MaterialSegmentedControl( + props: SegmentedControlProps, +) { + const { themeAppearance } = useAppearancePreferences(); + return ( + + + + + + + + {props.options.map((option) => ( + props.onSelect(option.value)} + className="flex-1" + /> + ))} + + + ); +} 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 new file mode 100644 index 000000000000..2bc9d11130d7 --- /dev/null +++ b/apps/mobile/src/components/MaterialSwitch.android.tsx @@ -0,0 +1,49 @@ +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..509feeb39728 --- /dev/null +++ b/apps/mobile/src/components/ScreenScrollView.tsx @@ -0,0 +1,19 @@ +import type { ComponentProps } from "react"; +import { Platform, ScrollView } from "react-native"; + +/** Keeps forms and settings readable inside a wide pane while its surface fills the screen. */ +export function ScreenScrollView(props: ComponentProps) { + return ( + + ); +} diff --git a/apps/mobile/src/components/SegmentedControl.tsx b/apps/mobile/src/components/SegmentedControl.tsx index 04a562956c46..607062e0579e 100644 --- a/apps/mobile/src/components/SegmentedControl.tsx +++ b/apps/mobile/src/components/SegmentedControl.tsx @@ -2,8 +2,9 @@ import { Platform, Pressable, View } from "react-native"; import Animated, { Easing, LinearTransition, ReduceMotion } from "react-native-reanimated"; import { AppText as Text } from "./AppText"; import { cn } from "../lib/cn"; +import { MaterialSegmentedControl } from "./MaterialSegmentedControl"; -export function SegmentedControl(props: { +export interface SegmentedControlProps { readonly options: readonly { readonly value: Value; readonly label: string; @@ -11,18 +12,26 @@ export function SegmentedControl(props: { }[]; 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"; readonly className?: string; -}) { +} + +export function SegmentedControl( + props: SegmentedControlProps, +) { const compact = props.size === "compact"; + if (Platform.OS === "android") { + return ; + } return ( @@ -31,7 +40,7 @@ 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="absolute inset-y-0 rounded-full bg-subtle-strong" style={{ width: `${100 / props.options.length}%`, start: `${ diff --git a/apps/mobile/src/components/ThemedSwitch.tsx b/apps/mobile/src/components/ThemedSwitch.tsx index 08cef56f3ec0..f0cf8701e50c 100644 --- a/apps/mobile/src/components/ThemedSwitch.tsx +++ b/apps/mobile/src/components/ThemedSwitch.tsx @@ -1,63 +1,27 @@ -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) { - const { materialYouStyleLayoutActive } = useAppearancePreferences(); - if (materialYouStyleLayoutActive) { - return ( - props.onValueChange?.(!props.value)} - style={props.style} - testID={props.testID} - className={props.disabled ? "opacity-40" : "active:opacity-70"} - > - - - - - - - ); +export type ThemedSwitchProps = Pick< + SwitchProps, + | "accessibilityHint" + | "accessibilityLabel" + | "disabled" + | "onValueChange" + | "style" + | "testID" + | "value" +>; + +export function ThemedSwitch(props: ThemedSwitchProps) { + if (Platform.OS === "android") { + return ; } return ( diff --git a/apps/mobile/src/components/useMaterialToolbarHeight.ts b/apps/mobile/src/components/useMaterialToolbarHeight.ts new file mode 100644 index 000000000000..26e8510a82ff --- /dev/null +++ b/apps/mobile/src/components/useMaterialToolbarHeight.ts @@ -0,0 +1,11 @@ +import { useWindowDimensions } from "react-native"; + +import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; + +/** Reserve the same title/subtitle space in every pane, including icon-only and search headers. */ +export function useMaterialToolbarHeight() { + const title = useScaledTextRole("title"); + const subtitle = useScaledTextRole("label"); + const { fontScale } = useWindowDimensions(); + return Math.ceil(Math.max(56, (title.lineHeight + subtitle.lineHeight) * fontScale + 1)); +} diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 97f9c5b69d0f..78b3b62a3c1d 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -40,6 +40,7 @@ import { NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; +import { SettingsScreenContent } from "../settings/components/SettingsScreen"; export interface ArchivedThreadsHeaderEnvironment { readonly environmentId: EnvironmentId; @@ -146,6 +147,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: 0, }} > @@ -159,7 +161,7 @@ function ArchivedThreadsHeader(props: { @@ -167,7 +169,7 @@ function ArchivedThreadsHeader(props: { @@ -455,7 +457,7 @@ function ArchivedThreadRow(props: { @@ -477,7 +479,7 @@ function ArchivedThreadRow(props: { - + Loading archive... ); @@ -646,37 +648,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/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/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 42d9cdddd0e4..a42db147c17d 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -6,11 +6,13 @@ import { useAtomValue } from "@effect/atom-react"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useState } from "react"; -import { Alert, Pressable, View } from "react-native"; +import { Platform, Alert, Pressable, View } from "react-native"; import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; 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 { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; @@ -142,7 +144,7 @@ export function ConnectionEnvironmentRow(props: { )} - - {props.environment.isRelayManaged ? null : ( + {Platform.OS === "android" ? ( + + {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/ConnectionSheetButton.tsx b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx index fe26c66a355e..e9ac17b665ab 100644 --- a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx +++ b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx @@ -3,6 +3,7 @@ import { Platform, Pressable } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; +import { MaterialButton } from "../../components/MaterialButton"; const CARD_SHADOW = Platform.select({ ios: { @@ -32,8 +33,19 @@ export function ConnectionSheetButton(props: { readonly disabled?: boolean; readonly tone?: "primary" | "secondary" | "danger"; readonly compact?: boolean; + readonly fullWidth?: boolean; readonly onPress: () => void; }) { + if (Platform.OS === "android") + return ( + + ); const tone = props.tone ?? "secondary"; const textColorClassName = diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index 1645844d86b3..ea4f1cf69712 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -1,13 +1,18 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; 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"; +import { Alert, Linking, Platform, 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"; @@ -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. @@ -181,33 +187,27 @@ 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} : null} - { - void handleSubmit(); - }} - /> + + { + void handleSubmit(); + }} + /> + )} - + ); } 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 a55bb8f859e6..ff21a6c31a90 100644 --- a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx +++ b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx @@ -1,12 +1,14 @@ +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"; 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 +73,7 @@ export function SettingsDiagnosticsRouteScreen() { }; return ( - + @@ -131,7 +133,7 @@ export function SettingsDiagnosticsRouteScreen() { - + ); } @@ -145,7 +147,7 @@ function EmptyState(props: { diff --git a/apps/mobile/src/features/files/MaterialFilesHeader.tsx b/apps/mobile/src/features/files/MaterialFilesHeader.tsx new file mode 100644 index 000000000000..2ce953569b68 --- /dev/null +++ b/apps/mobile/src/features/files/MaterialFilesHeader.tsx @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode } 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; + readonly leading?: ReactNode; +}) { + 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]); + + return ( + + {/* Keep the title/subtitle's natural height, including larger text, while searching. */} + + setSearchOpen(true), + }, + { + accessibilityLabel: "Refresh files", + icon: "arrow.clockwise", + onPress: props.onRefresh, + }, + ]} + /> + + {searching ? ( + + + + + + + {props.searchQuery.length > 0 ? ( + { + onSearchQueryChange(""); + searchRef.current?.focus(); + }} + > + + + ) : null} + + + + ) : 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 ? ( <> - - - } + searchQuery={searchQuery} + onSearchQueryChange={setSearchQuery} + onRefresh={entriesQuery.refresh} + onBack={handleReturnToThread} /> - - + } ) : ( <> @@ -548,26 +521,28 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { )} )} - - + + + + ); - return materialYouStyleLayoutActive ? ( - + return Platform.OS === "android" ? ( + {content} ) : ( @@ -933,6 +908,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { } + hideBottomBorder onBack={handleBack} trailing={ <> @@ -942,6 +919,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator" } icon="sidebar.right" + selected={panes.auxiliaryPaneVisible} onPress={toggleAuxiliaryPane} /> ) : null} @@ -1007,25 +985,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..f62ba6b7abba 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"; @@ -149,59 +151,68 @@ export function ThreadFileNavigatorPane(props: { } return ( - - - - - Files - - {props.projectName} - + + + {Platform.OS === "android" ? ( + + ) : ( + + + Files + + {props.projectName} + + + + + - + )} + {Platform.OS !== "android" ? ( + - - - - - - - - - + + + ) : null} - {fileTree} + {fileTree} ); } 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..012feb5ff68b --- /dev/null +++ b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx @@ -0,0 +1,44 @@ +import { useCallback, useRef, useState, type ComponentProps } from "react"; +import { View, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { MaterialNewThreadButton } from "../../components/MaterialNewThreadButton"; +import type { AndroidHomeFabLayout as SharedAndroidHomeFabLayout } from "./AndroidHomeFab.shared"; +import { useWorkspaceState } from "../../state/workspace"; +import { MaterialFabScrollContext } from "./MaterialFabScrollContext"; +import { updateMaterialFabScroll } from "./material-fab-scroll"; + +export function AndroidHomeFabLayout(props: ComponentProps) { + const insets = useSafeAreaInsets(); + const { state } = useWorkspaceState(); + const [expanded, setExpanded] = useState(true); + const scrollState = useRef({ anchor: 0, expanded: true }); + const onScroll = useCallback((event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + const next = updateMaterialFabScroll( + scrollState.current, + contentOffset.y, + contentSize.height - layoutMeasurement.height, + ); + if (next.expanded !== scrollState.current.expanded) setExpanded(next.expanded); + scrollState.current = next; + }, []); + + return ( + + {props.children} + {state.hasConnections ? ( + + ) : null} + + ); +} 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..7962371c1e15 --- /dev/null +++ b/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; + +/** Other platforms render the list without Android's floating action button. */ +export function AndroidHomeFabLayout(props: { + readonly onStartNewTask: () => void; + readonly children: ReactNode; + readonly sidebar?: boolean; +}) { + 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..bf4c28aa5474 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,18 +1,11 @@ -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; -import Constants from "expo-constants"; + import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useCallback, useMemo, useRef } from "react"; -import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native"; +import { Platform } from "react-native"; import type { SearchBarCommands } from "react-native-screens"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { ControlPillMenu } from "../../components/ControlPill"; -import { SymbolView } from "../../components/AppSymbol"; -import { T3Wordmark } from "../../components/T3Wordmark"; -import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; -import { resolveMobileStageLabel } from "../../lib/mobileBranding"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; @@ -22,7 +15,7 @@ import { NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; import type { HomeProjectSortOrder } from "./homeThreadList"; -import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle"; +import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar"; import { buildHomeListFilterMenu, type HomeListFilterMenuEnvironment, @@ -67,9 +60,6 @@ function checkedMenuState(checked: boolean) { } function AndroidHomeHeader(props: HomeHeaderProps) { - const { materialYouStyleLayoutActive } = useAppearancePreferences(); - const insets = useSafeAreaInsets(); - const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); // Thread List v2 lays the list out in fixed creation order, so the // sort/group filter controls would be silently ignored — hide them and // key the "customized" icon state off the environment filter alone. @@ -200,120 +190,15 @@ function AndroidHomeHeader(props: HomeHeaderProps) { return ( <> - - - - {/* 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). */} - - {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */} - - - Code - - - - {stageLabel} - - - - } - /> - - - - - - - {/* Built identically to the filter button so the two circles - match exactly (ControlPill sizes via Tailwind classes and - resolves to a different box). */} - - - - - - - - - {props.searchQuery.length > 0 ? ( - props.onSearchQueryChange("")} - > - - - ) : null} - - - + ); } diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index b2da5af8ed26..00060668ba5d 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"; @@ -27,7 +31,7 @@ import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle export function HomeRouteScreen() { const { width: windowWidth } = useWindowDimensions(); - const { layout } = useAdaptiveWorkspaceLayout(); + const { layout, panes } = useAdaptiveWorkspaceLayout(); const projects = useProjects(); const threads = useThreadShells(); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); @@ -126,8 +130,24 @@ export function HomeRouteScreen() { /> } /> + {Platform.OS === "android" ? ( + } /> + ) : null} navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onAddConnection={ + Platform.OS === "android" && !catalogState.hasConnections + ? () => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironmentNew" }, + }) + : undefined + } + onStartNewTask={ + Platform.OS === "android" && panes.primarySidebarVisible + ? undefined + : () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }) + } /> ); diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index a0d7bf7433e5..80642147052f 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -30,13 +30,13 @@ 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"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { usePendingThreadOrder } from "../../state/thread-order"; @@ -83,6 +83,7 @@ import { type HomeProjectSortOrder, } from "./homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; +import { useMaterialFabScroll } from "./MaterialFabScrollContext"; /* ─── Types ──────────────────────────────────────────────────────────── */ @@ -219,7 +220,6 @@ function HomeTopContentSpacer() { /* ─── Main screen ────────────────────────────────────────────────────── */ export function HomeScreen(props: HomeScreenProps) { - const { materialYouStyleLayoutActive } = useAppearancePreferences(); const [groupDisplayStates, setGroupDisplayStates] = useState< ReadonlyMap >(() => new Map()); @@ -315,7 +315,9 @@ export function HomeScreen(props: HomeScreenProps) { const handleScrollBeginDrag = useCallback(() => { openSwipeableRef.current?.close(); }, []); + const onMaterialFabScroll = useMaterialFabScroll(); const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ + onScroll: onMaterialFabScroll, onScrollBeginDrag: handleScrollBeginDrag, }); @@ -1104,11 +1106,11 @@ export function HomeScreen(props: HomeScreenProps) { if (!hasAnyThreads) { return ( - + + ) : undefined + } variant="plain" /> {emptyState.loading ? ( - + ) : null} @@ -1142,41 +1155,72 @@ 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 ( + Platform.OS === "android" && + (threadListV2Enabled ? threadListV2Items.length === 0 : listLayout.items.length === 0) + ) { + return ( + + + {threadListV2Enabled ? v2ListEmpty : listEmpty} + + + ); + } + if (threadListV2Enabled) { return ( - + @@ -1226,10 +1270,10 @@ export function HomeScreen(props: HomeScreenProps) { } return ( - + ) => void) | undefined +>(undefined); + +export function useMaterialFabScroll() { + return useContext(MaterialFabScrollContext); +} diff --git a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx new file mode 100644 index 000000000000..397e6984f3af --- /dev/null +++ b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx @@ -0,0 +1,178 @@ +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 { 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"; +import { useMaterialToolbarHeight } from "../../components/useMaterialToolbarHeight"; + +/** 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 toolbarHeight = useMaterialToolbarHeight(); + const { state } = useWorkspaceState(); + 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} + + ) : ( + <> + {/* Match the visible inset of the trailing 48dp icon button. */} + + } + /> + + + + + )} + + + {/* Sit 8dp above the 56dp extended New thread FAB. */} + {state.hasConnections ? ( + + + {(open) => ( + + )} + + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/home/material-fab-scroll.test.ts b/apps/mobile/src/features/home/material-fab-scroll.test.ts new file mode 100644 index 000000000000..c68613ed2999 --- /dev/null +++ b/apps/mobile/src/features/home/material-fab-scroll.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { updateMaterialFabScroll, type MaterialFabScrollState } from "./material-fab-scroll"; + +describe("Material FAB scroll direction", () => { + const initial: MaterialFabScrollState = { anchor: 0, expanded: true }; + + it("shrinks scrolling down and expands scrolling up without returning to the top", () => { + const collapsed = updateMaterialFabScroll(initial, 80, 500); + expect(collapsed.expanded).toBe(false); + const lower = updateMaterialFabScroll(collapsed, 180, 500); + expect(lower.expanded).toBe(false); + expect(updateMaterialFabScroll(lower, 160, 500).expanded).toBe(true); + }); + + it("accumulates travel but ignores jitter around a direction change", () => { + let state = updateMaterialFabScroll(initial, 10, 500); + expect(state.expanded).toBe(true); + state = updateMaterialFabScroll(state, 14, 500); + expect(state.expanded).toBe(false); + state = updateMaterialFabScroll(state, 100, 500); + state = updateMaterialFabScroll(state, 95, 500); + state = updateMaterialFabScroll(state, 98, 500); + expect(state.expanded).toBe(false); + expect(updateMaterialFabScroll(state, 88, 500).expanded).toBe(true); + }); + + it("does not expand from bottom bounce or collapse from top bounce", () => { + const bottom = updateMaterialFabScroll(initial, 500, 500); + const bounce = updateMaterialFabScroll(bottom, 560, 500); + expect(updateMaterialFabScroll(bounce, 500, 500).expanded).toBe(false); + expect(updateMaterialFabScroll(initial, -50, 500)).toEqual(initial); + }); + + it("expands near the top and when filtering leaves a non-scrollable list", () => { + const collapsed = updateMaterialFabScroll(initial, 80, 500); + expect(updateMaterialFabScroll(collapsed, 5, 500).expanded).toBe(true); + expect(updateMaterialFabScroll(collapsed, 80, -20)).toEqual(initial); + }); +}); diff --git a/apps/mobile/src/features/home/material-fab-scroll.ts b/apps/mobile/src/features/home/material-fab-scroll.ts new file mode 100644 index 000000000000..b89946803982 --- /dev/null +++ b/apps/mobile/src/features/home/material-fab-scroll.ts @@ -0,0 +1,17 @@ +export interface MaterialFabScrollState { + readonly anchor: number; + readonly expanded: boolean; +} + +/** Ignore small direction changes and overscroll; always show the label at the top. */ +export function updateMaterialFabScroll( + state: MaterialFabScrollState, + offset: number, + maxOffset: number, +): MaterialFabScrollState { + const y = Math.max(0, Math.min(offset, Math.max(0, maxOffset))); + if (y <= 8) return { anchor: y, expanded: true }; + const anchor = state.expanded ? Math.min(state.anchor, y) : Math.max(state.anchor, y); + if (Math.abs(y - anchor) >= 12) return { anchor: y, expanded: !state.expanded }; + return { anchor, expanded: state.expanded }; +} diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 185a3b157fc2..834301b80894 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -22,7 +22,7 @@ import { useState, type ReactNode, } from "react"; -import { useWindowDimensions, View } from "react-native"; +import { Platform, useWindowDimensions, View } from "react-native"; import Animated, { useAnimatedStyle, useDerivedValue, @@ -56,7 +56,6 @@ import { } from "../keyboard/hardwareKeyboardCommands"; import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; import { HomeListOptionsProvider } from "../home/home-list-options"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; @@ -234,7 +233,6 @@ function AdaptiveWorkspaceLayoutContent( }, ) { const projectGroupingMode = props.projectGroupingMode; - const { materialYouStyleLayoutActive } = useAppearancePreferences(); const { width, height } = useWindowDimensions(); const pathname = props.pathname; const navigation = useNavigation(); @@ -582,7 +580,7 @@ function AdaptiveWorkspaceLayoutContent( style={sidebarAnimatedStyle} > - + void }) { - const { materialYouStyleLayoutActive } = useAppearancePreferences(); +export function WorkspaceEmptyDetail(props: { + readonly onStartNewTask?: () => void; + readonly onAddConnection?: () => void; +}) { return ( - - - Select a thread - - Choose a thread from the sidebar or start a new task. - - {props.onStartNewTask ? ( - - New Task - - ) : null} - + {props.onAddConnection ? ( + + + } + /> + + ) : ( + + + Select a thread + + {Platform.OS === "android" + ? "Choose a thread from the sidebar or start a new thread." + : "Choose a thread from the sidebar or start a new task."} + + {props.onStartNewTask ? ( + Platform.OS === "android" ? ( + + ) : ( + + 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 cf640f19106a..01d9646e9c8b 100644 --- a/apps/mobile/src/features/layout/workspace-pane-divider.tsx +++ b/apps/mobile/src/features/layout/workspace-pane-divider.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useRef, useState } from "react"; -import { Pressable, StyleSheet, View, type AccessibilityActionEvent } from "react-native"; +import { Platform, Pressable, StyleSheet, View, type AccessibilityActionEvent } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { runOnJS } from "react-native-reanimated"; import { cn } from "../../lib/cn"; @@ -79,6 +79,7 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { className={cn( "h-full self-center bg-border opacity-70", dragging ? "w-0.5 bg-primary opacity-100" : "w-px", + Platform.OS === "android" && !dragging && "opacity-0", )} style={[styles.line, dragging && styles.activeLine]} /> 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/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 5724abb138c8..4da58b3309ef 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -1,3 +1,7 @@ +import { MaterialListRow } from "../../components/MaterialListRow"; +import { SettingsScreen } from "../settings/components/SettingsScreen"; +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; +import { MaterialButton } from "../../components/MaterialButton"; import { addProjectRemoteSourceLabel, addProjectRemoteSourcePathHint, @@ -41,14 +45,13 @@ 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 { Platform, 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"; import * as Order from "effect/Order"; import { AsyncResult } from "effect/unstable/reactivity"; import { cn } from "../../lib/cn"; - import { useProjects, useServerConfigs, waitForProject } from "../../state/entities"; import { filesystemEnvironment } from "../../state/filesystem"; import { projectEnvironment } from "../../state/projects"; @@ -123,13 +126,19 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote function SectionTitle(props: { readonly children: string }) { return ( - + {props.children} ); } -function AddProjectShell(props: { readonly children: ReactNode }) { +function AddProjectShell(props: { readonly children: ReactNode; readonly title: string }) { const insets = useSafeAreaInsets(); return ( @@ -138,25 +147,35 @@ 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}; + return ( + + {props.children} + + ); } function ListRow(props: { @@ -169,6 +188,20 @@ function ListRow(props: { readonly right?: ReactNode; readonly onPress?: () => void; }) { + if (Platform.OS === "android") { + return ( + + ); + } return ( ) : null} @@ -218,6 +251,7 @@ function PrimaryActionButton(props: { readonly loading?: boolean; readonly onPress: () => void; }) { + if (Platform.OS === "android") return ; return ( + ) : ( - + ); if (!props.ready) { @@ -482,7 +525,7 @@ export function AddProjectSourceScreen() { ); return ( - + {selectedEnvironment === null ? : null} {environmentOptions.length > 1 ? ( @@ -495,7 +538,7 @@ export function AddProjectSourceScreen() { title={environment.label} subtitle={ canCreateProjectInEnvironment(environment.connectionState) - ? environment.environmentId + ? undefined : connectionStatusText({ phase: environment.connectionState, error: environment.connectionError, @@ -505,7 +548,7 @@ export function AddProjectSourceScreen() { icon={ } @@ -516,8 +559,8 @@ export function AddProjectSourceScreen() { environment.environmentId === selectedEnvironment?.environmentId ? ( ) : null @@ -538,8 +581,8 @@ export function AddProjectSourceScreen() { icon={ } @@ -570,7 +613,7 @@ export function AddProjectSourceScreen() { )} {discoveryState.isPending ? ( - + ) : null} ) : null} @@ -723,7 +766,7 @@ export function AddProjectRepositoryScreen(props: { }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]); return ( - + {error ? : null} {environment ? ( <> @@ -800,7 +843,7 @@ function FolderBrowser(props: { {browseState.isPending && browseState.data === null ? ( - + ) : null} {browsePath.canBrowseUp ? ( @@ -809,8 +852,8 @@ function FolderBrowser(props: { icon={ } @@ -832,8 +875,8 @@ function FolderBrowser(props: { icon={ } @@ -882,7 +925,7 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st }, [createProject, environment, isBrowseNavigating, isSubmitting, pathInput]); return ( - + {error ? : null} {environment ? ( <> @@ -1025,7 +1068,7 @@ export function AddProjectDestinationScreen(props: { ]); return ( - + {error ? : null} {repositoryTitle ? ( diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 22fa1bc5d506..0035f91e2d18 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"; @@ -54,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 { @@ -81,7 +86,12 @@ const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { return ( - + Partial diff {props.notice} @@ -103,7 +113,7 @@ function ReviewSelectionActionBar(props: { {props.title} @@ -143,7 +153,7 @@ function ReviewSelectionActionBar(props: { @@ -174,9 +184,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" + Platform.OS === "android" + ? 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} > @@ -323,16 +338,28 @@ function ReviewFileNavigator({ } return ( - - - - Changed files - - {files.length} {files.length === 1 ? "file" : "files"} - + + {Platform.OS === "android" ? ( + + ) : ( + + + Changed files + + {files.length} {files.length === 1 ? "file" : "files"} + + - - {fileList} + )} + {fileList} ); } @@ -632,23 +659,21 @@ 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[] = []; if (error) { children.push( - + Review unavailable {error} , @@ -699,21 +724,35 @@ export function ReviewSheet(props: ReviewSheetProps) { {isAndroid ? ( } + hideBottomBorder subtitle={androidHeaderSubtitle || "Select a diff"} onBack={handleReturnToThread} trailing={ - showSectionToolbar ? ( - + <> + {showChangedFilesToggle ? ( - - ) : null + ) : null} + {showSectionToolbar ? ( + + + + ) : null} + } /> ) : null} @@ -805,141 +844,187 @@ export function ReviewSheet(props: ReviewSheetProps) { ) : 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/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") { diff --git a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx index a97193d6b6a5..4fbfb98bfdd2 100644 --- a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx @@ -1,26 +1,17 @@ -import { useNavigation } from "@react-navigation/native"; -import { Platform, ScrollView, View } from "react-native"; +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { SettingsScreen } from "./components/SettingsScreen"; import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection"; import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection"; import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection"; import { ThemeAppearanceSection } from "./appearance/sections/ThemeAppearanceSection"; export function SettingsAppearanceRouteScreen() { - const navigation = useNavigation(); const insets = useSafeAreaInsets(); return ( - - {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..5c36b16cac20 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"; @@ -16,6 +17,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 +73,7 @@ export function SettingsClientStorageRouteScreen() { }; return ( - + @@ -123,7 +125,7 @@ export function SettingsClientStorageRouteScreen() { @@ -146,16 +148,14 @@ export function SettingsClientStorageRouteScreen() { {summary ? `Clear ${formatBytes(summary.payloadBytes)}` : "Clear caches"} - {isClearing ? ( - - ) : null} + {isClearing ? : null} @@ -169,7 +169,7 @@ export function SettingsClientStorageRouteScreen() { ) : null} - + ); } diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 653107d22bb3..1ca43b5a73cf 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -1,13 +1,14 @@ -import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +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"; -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 +80,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 16cb39e4b8cb..efeca40e0ff1 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,18 +9,19 @@ import { type ThirdPartyLicenseEntry, } from "@t3tools/shared/thirdPartyLicenses"; import { useCallback, useMemo, useState } from "react"; -import { Linking, Platform, Pressable, ScrollView, View } from "react-native"; +import { Linking, Platform, Pressable, 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 { SettingsScreen } from "./components/SettingsScreen"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { createNativeMailSearchToolbarItem, NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; + import { getMobileThirdPartyLicenses } from "./mobileThirdPartyLicenses"; function useMobileThirdPartyLicenses() { @@ -97,24 +99,18 @@ export function SettingsOpenSourceLicensesRouteScreen() { if (!manifest) { return ( - - {Platform.OS === "android" ? ( - <> - - navigation.goBack()} /> - - ) : null} + License notices are unavailable in this build. - + ); } return ( - + {Platform.OS === "ios" ? ( ) : null} - {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 @@ -212,30 +202,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..5eb815098aaa 100644 --- a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx @@ -1,14 +1,13 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; 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, 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 +38,6 @@ const GROUPING_OPTIONS: ReadonlyArray<{ ]; export function SettingsProjectGroupingRouteScreen() { - const navigation = useNavigation(); const insets = useSafeAreaInsets(); const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); @@ -49,13 +47,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 dc4f1d2ee32b..d32dcb3e6828 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -1,3 +1,5 @@ +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"; import Constants from "expo-constants"; @@ -8,7 +10,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 { @@ -18,8 +20,7 @@ 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 { AppText as Text } from "../../components/AppText"; import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; import { openAndroidLiveUpdateSettings, @@ -37,15 +38,12 @@ 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 { 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 { @@ -58,6 +56,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 +78,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() ? : } ); } @@ -134,6 +136,7 @@ function LocalSettingsRouteScreen() { icon="desktopcomputer" label="Environments" value={`${environmentCount}`} + valuePosition="trailing" target="SettingsEnvironments" /> @@ -516,6 +519,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( @@ -647,21 +653,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 ( <> @@ -674,28 +665,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 @@ -705,14 +702,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" > @@ -831,7 +821,7 @@ function AppSettingsSection() { diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index e6622e3269e9..5e0426daaa0b 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -51,9 +51,6 @@ interface AppearancePreferencesContextValue { readonly themeIds: MobileThemeIds; readonly themeMode: MobileThemeMode; readonly themeAppearance: MobileThemeAppearance; - readonly materialYouStyleLayoutEnabled: boolean; - readonly materialYouStyleLayoutActive: boolean; - readonly setMaterialYouStyleLayoutEnabled: (value: boolean) => void; readonly systemColorsAvailable: boolean; readonly systemColorsActive: boolean; readonly themeVariables: MobileThemeVariables; @@ -97,8 +94,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [resolvedThemeIds.dark, resolvedThemeIds.light], ); const themeId = themeIds[themeAppearance]; - const materialYouStyleLayoutEnabled = storedPreferences?.materialYouStyleLayoutEnabled ?? false; - const materialYouStyleLayoutActive = Platform.OS === "android" && materialYouStyleLayoutEnabled; const systemColorsActive = themeId === "material-you" && isSystemColorsAvailable; const [systemColorPalettes, setSystemColorPalettes] = useState(readSystemColorPalettes); useEffect(() => { @@ -120,7 +115,7 @@ 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, Platform.OS); return themeIds[appearance] === "material-you" && systemColorPalettes ? materialYouPaletteToMobileThemeVariables( systemColorPalettes[appearance], @@ -247,13 +242,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [runtimeState, syncThemeRuntime, updateThemePreferences], ); - const setMaterialYouStyleLayoutEnabled = useCallback( - (value: boolean) => { - updatePreferences({ materialYouStyleLayoutEnabled: value }); - }, - [updatePreferences], - ); - const setBaseFontSize = useCallback( (value: number) => { const current = appliedRuntimeStateRef.current ?? runtimeState; @@ -293,9 +281,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeAppearance, systemColorsAvailable: isSystemColorsAvailable, systemColorsActive, - materialYouStyleLayoutEnabled, - materialYouStyleLayoutActive, - setMaterialYouStyleLayoutEnabled, themeVariables, themeVariablesByAppearance, systemColorPalettes, @@ -315,9 +300,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeMode, themeAppearance, systemColorsActive, - materialYouStyleLayoutEnabled, - materialYouStyleLayoutActive, - setMaterialYouStyleLayoutEnabled, themeVariables, themeVariablesByAppearance, systemColorPalettes, 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..6588895f73a4 --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.android.tsx @@ -0,0 +1,81 @@ +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 type { FontSizeSliderRow as SharedFontSizeSliderRow } from "./FontSizeSliderRow.shared"; + +export function FontSizeSliderRow(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..98a17388485c --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.shared.tsx @@ -0,0 +1,211 @@ +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 (current.disabled || 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/appearance/sections/ThemeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx index a677cc5707d2..788d04b047ba 100644 --- a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx @@ -1,5 +1,5 @@ import { memo, useId } from "react"; -import { Platform, Pressable, View } from "react-native"; +import { Pressable, View } from "react-native"; import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; import { ScopedTheme, ScopedVariables } from "uniwind"; @@ -19,9 +19,6 @@ import { getMobileUniwindThemeName } from "../../../../lib/mobileThemeRuntime"; import { cn } from "../../../../lib/cn"; import { useAppearancePreferences } from "../AppearancePreferencesProvider"; -import { SettingsSection } from "../../components/SettingsSection"; -import { SettingsSwitchRow } from "../../components/SettingsSwitchRow"; - const APPEARANCE_MODES: ReadonlyArray<{ readonly id: MobileThemeMode; readonly label: string; @@ -293,25 +290,11 @@ export function ThemeAppearanceSection() { setThemeMode, themeIds, themeMode, - materialYouStyleLayoutEnabled, - setMaterialYouStyleLayoutEnabled, systemColorsAvailable, } = useAppearancePreferences(); return ( - {Platform.OS === "android" ? ( - - - - ) : null} Color scheme 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"}`} + + ))} + + + + + + + + + + + ); +} + +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)} - - ))} - - - - - - -