authorgravatar for me@pixp.ccAlex <me@pixp.cc> 2026-09-16 17:25:37-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 17:25:37-07:00
loge106fe95cc814797cfb3dad6d78c062e96d0060f
treec17f6cb4e22c9d61a68357f39920551d4e28041c
parent394af73e02238e551db14f44d57f1469dd2942aa
signature Signed by PGP key B5690EEEBB952194

design(mobile): unify Android Material layouts and native controls (#11841)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

128 files changed, 5811 insertions(+), 2824 deletions(-)

apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt+26
...@@ -8,6 +8,7 @@ import android.graphics.Canvas...@@ -8,6 +8,7 @@ import android.graphics.Canvas
8import android.graphics.Color8import android.graphics.Color
9import android.graphics.Paint9import android.graphics.Paint
10import android.graphics.Typeface10import android.graphics.Typeface
11import android.os.Build
11import android.text.Spannable12import android.text.Spannable
12import android.text.SpannableStringBuilder13import android.text.SpannableStringBuilder
13import android.text.Spanned14import android.text.Spanned
...@@ -164,6 +165,17 @@ private class SanitizingSelectionActionModeCallback(...@@ -164,6 +165,17 @@ private class SanitizingSelectionActionModeCallback(
164 }165 }
165}166}
166167
168internal fun applySelectionHandleColor(textView: TextView, color: Int) {
169 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return
170 textView.textSelectHandle?.mutate()?.apply { setTint(color) }?.let(textView::setTextSelectHandle)
171 textView.textSelectHandleLeft?.mutate()?.apply {
172 setTint(color)
173 }?.let(textView::setTextSelectHandleLeft)
174 textView.textSelectHandleRight?.mutate()?.apply {
175 setTint(color)
176 }?.let(textView::setTextSelectHandleRight)
177}
178
167class T3MarkdownTextSelectionModule : Module() {179class T3MarkdownTextSelectionModule : Module() {
168 private val chipImages = LruCache<String, Map<String, Any>>(128)180 private val chipImages = LruCache<String, Map<String, Any>>(128)
169181
...@@ -183,9 +195,23 @@ class T3MarkdownTextSelectionModule : Module() {...@@ -183,9 +195,23 @@ class T3MarkdownTextSelectionModule : Module() {
183 }195 }
184 }.fontMetricsInt196 }.fontMetricsInt
185197
198 private fun setSelectionHandleColor(reactTag: Int, color: Int) {
199 val reactContext = appContext.reactContext as? ReactContext ?: return
200 reactContext.runOnUiQueueThread {
201 val textView = runCatching {
202 UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)?.resolveView(reactTag)
203 }.getOrNull() as? TextView ?: return@runOnUiQueueThread
204 applySelectionHandleColor(textView, color)
205 }
206 }
207
186 override fun definition() = ModuleDefinition {208 override fun definition() = ModuleDefinition {
187 Name("T3MarkdownTextSelection")209 Name("T3MarkdownTextSelection")
188210
211 Function("setSelectionHandleColor") { reactTag: Int, color: Int ->
212 setSelectionHandleColor(reactTag, color)
213 }
214
189 Function("renderContextChip") { payloadJson: String ->215 Function("renderContextChip") { payloadJson: String ->
190 val resources = appContext.reactContext?.resources ?: return@Function null216 val resources = appContext.reactContext?.resources ?: return@Function null
191 val metrics = resources.displayMetrics217 val metrics = resources.displayMetrics
apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt created+67
...@@ -0,0 +1,67 @@
1package expo.modules.t3markdowntext
2
3import android.graphics.Bitmap
4import android.graphics.Canvas
5import android.graphics.Color
6import android.graphics.drawable.ColorDrawable
7import android.graphics.drawable.Drawable
8import android.widget.TextView
9import org.junit.Assert.assertEquals
10import org.junit.Test
11import org.junit.runner.RunWith
12import org.robolectric.RobolectricTestRunner
13import org.robolectric.RuntimeEnvironment
14import org.robolectric.annotation.Config
15import org.robolectric.annotation.GraphicsMode
16
17@RunWith(RobolectricTestRunner::class)
18@Config(sdk = [36], manifest = Config.NONE)
19@GraphicsMode(GraphicsMode.Mode.NATIVE)
20class MarkdownSelectionColorTest {
21 private fun textView() = TextView(RuntimeEnvironment.getApplication()).apply {
22 setTextSelectHandle(ColorDrawable(Color.WHITE))
23 setTextSelectHandleLeft(ColorDrawable(Color.WHITE))
24 setTextSelectHandleRight(ColorDrawable(Color.WHITE))
25 }
26
27 private fun renderedColor(drawable: Drawable?): Int {
28 requireNotNull(drawable)
29 val bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888)
30 drawable.setBounds(0, 0, 4, 4)
31 drawable.draw(Canvas(bitmap))
32 val color = bitmap.getPixel(2, 2)
33 bitmap.recycle()
34 return color
35 }
36
37 @Test
38 fun retintsAllHandlesWhenTheThemeChangesWithoutChangingTheHighlight() {
39 val text = textView()
40 val highlight = 0x52FF0088
41 text.highlightColor = highlight
42
43 for (color in listOf(Color.MAGENTA, Color.GREEN, Color.MAGENTA)) {
44 applySelectionHandleColor(text, color)
45 assertEquals(color, renderedColor(text.textSelectHandle))
46 assertEquals(color, renderedColor(text.textSelectHandleLeft))
47 assertEquals(color, renderedColor(text.textSelectHandleRight))
48 assertEquals(highlight, text.highlightColor)
49 }
50 }
51
52 @Test
53 fun doesNotTintOtherTextViewsSharingDrawableState() {
54 val original = ColorDrawable(Color.WHITE)
55 val first = textView().apply {
56 setTextSelectHandleLeft(original.constantState!!.newDrawable())
57 }
58 val second = textView().apply {
59 setTextSelectHandleLeft(original.constantState!!.newDrawable())
60 }
61
62 applySelectionHandleColor(first, Color.MAGENTA)
63
64 assertEquals(Color.MAGENTA, renderedColor(first.textSelectHandleLeft))
65 assertEquals(Color.WHITE, renderedColor(second.textSelectHandleLeft))
66 }
67}
apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx+51-2
...@@ -1,5 +1,15 @@...@@ -1,5 +1,15 @@
1import React, { type Ref } from "react";1import React, { type Ref } from "react";
2import { Platform, StyleSheet, Text as RNText, type TextProps, type ViewStyle } from "react-native";2import {
3 findNodeHandle,
4 Platform,
5 processColor,
6 StyleSheet,
7 Text as RNText,
8 type ColorValue,
9 type TextProps,
10 type ViewStyle,
11} from "react-native";
12import { setMarkdownSelectionHandleColor } from "./T3MarkdownTextSelectionModule";
3import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent";13import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent";
4import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent";14import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent";
5import { flattenStyles } from "./util";15import { flattenStyles } from "./util";
...@@ -34,6 +44,7 @@ export type ContextMenuActionEvent = {...@@ -34,6 +44,7 @@ export type ContextMenuActionEvent = {
34 */44 */
35export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & {45export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & {
36 nativeTextRef?: Ref<RNText>;46 nativeTextRef?: Ref<RNText>;
47 selectionHandleColor?: ColorValue;
37 uiTextView?: boolean;48 uiTextView?: boolean;
38 contextMenuConfig?: string;49 contextMenuConfig?: string;
39 contextClipboardConfig?: string;50 contextClipboardConfig?: string;
...@@ -116,7 +127,45 @@ function MarkdownTextPrimitiveInner({ nativeTextRef, ...props }: MarkdownTextPri...@@ -116,7 +127,45 @@ function MarkdownTextPrimitiveInner({ nativeTextRef, ...props }: MarkdownTextPri
116 return <MarkdownTextPrimitiveChild {...props} />;127 return <MarkdownTextPrimitiveChild {...props} />;
117}128}
118129
119export function MarkdownTextPrimitive(props: MarkdownTextPrimitiveProps) {130function AndroidMarkdownText({
131 nativeTextRef,
132 selectionHandleColor,
133 onLayout,
134 contextClipboardConfig: _contextClipboardConfig,
135 ...props
136}: MarkdownTextPrimitiveProps) {
137 const textRef = React.useRef<RNText | null>(null);
138 React.useImperativeHandle<RNText | null, RNText | null>(nativeTextRef, () => textRef.current, []);
139 const color = processColor(selectionHandleColor);
140 const applyHandleColor = React.useCallback(() => {
141 if (!textRef.current || typeof color !== "number") return;
142 const reactTag = findNodeHandle(textRef.current);
143 if (reactTag !== null) setMarkdownSelectionHandleColor(reactTag, color);
144 }, [color]);
145
146 // RN's selectionColor only sets the highlight. Retint mounted handles when
147 // the theme changes, and after layout when the native view first exists.
148 React.useEffect(applyHandleColor, [applyHandleColor]);
149
150 return (
151 <RNText
152 ref={textRef}
153 {...props}
154 onLayout={(event) => {
155 applyHandleColor();
156 onLayout?.(event);
157 }}
158 />
159 );
160}
161
162export function MarkdownTextPrimitive({
163 selectionHandleColor,
164 ...props
165}: MarkdownTextPrimitiveProps) {
166 if (Platform.OS === "android" && selectionHandleColor !== undefined) {
167 return <AndroidMarkdownText {...props} selectionHandleColor={selectionHandleColor} />;
168 }
120 if (Platform.OS !== "ios") {169 if (Platform.OS !== "ios") {
121 const { nativeTextRef, contextClipboardConfig: _contextClipboardConfig, ...textProps } = props;170 const { nativeTextRef, contextClipboardConfig: _contextClipboardConfig, ...textProps } = props;
122 return <RNText ref={nativeTextRef} {...textProps} />;171 return <RNText ref={nativeTextRef} {...textProps} />;
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx+15-5
...@@ -133,7 +133,13 @@ function HighlightedCodeText(props: {...@@ -133,7 +133,13 @@ function HighlightedCodeText(props: {
133 }133 }
134 }134 }
135 return (135 return (
136 <MarkdownTextPrimitive uiTextView selectable style={style}>136 <MarkdownTextPrimitive
137 uiTextView
138 selectable
139 selectionColor={props.textStyle.selectionColor}
140 selectionHandleColor={props.textStyle.selectionHandleColor}
141 style={style}
142 >
137 {props.highlighted ? lines : props.content}143 {props.highlighted ? lines : props.content}
138 </MarkdownTextPrimitive>144 </MarkdownTextPrimitive>
139 );145 );
...@@ -174,8 +180,10 @@ function NativeCodeBlock(props: {...@@ -174,8 +180,10 @@ function NativeCodeBlock(props: {
174 justifyContent: "space-between",180 justifyContent: "space-between",
175 }}181 }}
176 >182 >
177 <Text183 <MarkdownTextPrimitive
178 selectable184 selectable
185 selectionColor={props.textStyle.selectionColor}
186 selectionHandleColor={props.textStyle.selectionHandleColor}
179 style={{187 style={{
180 flex: 1,188 flex: 1,
181 color: props.textStyle.mutedColor,189 color: props.textStyle.mutedColor,
...@@ -184,7 +192,7 @@ function NativeCodeBlock(props: {...@@ -184,7 +192,7 @@ function NativeCodeBlock(props: {
184 }}192 }}
185 >193 >
186 {languageLabel}194 {languageLabel}
187 </Text>195 </MarkdownTextPrimitive>
188 <CopyTextButton196 <CopyTextButton
189 accessibilityLabel={`Copy ${languageLabel.toLowerCase()} code`}197 accessibilityLabel={`Copy ${languageLabel.toLowerCase()} code`}
190 text={content}198 text={content}
...@@ -332,8 +340,10 @@ function NativeMarkdownImage(props: {...@@ -332,8 +340,10 @@ function NativeMarkdownImage(props: {
332 }}340 }}
333 />341 />
334 {props.node.alt ? (342 {props.node.alt ? (
335 <Text343 <MarkdownTextPrimitive
336 selectable344 selectable
345 selectionColor={props.textStyle.selectionColor}
346 selectionHandleColor={props.textStyle.selectionHandleColor}
337 style={{347 style={{
338 color: props.textStyle.mutedColor,348 color: props.textStyle.mutedColor,
339 fontFamily: props.textStyle.fontFamily,349 fontFamily: props.textStyle.fontFamily,
...@@ -342,7 +352,7 @@ function NativeMarkdownImage(props: {...@@ -342,7 +352,7 @@ function NativeMarkdownImage(props: {
342 }}352 }}
343 >353 >
344 {props.node.alt}354 {props.node.alt}
345 </Text>355 </MarkdownTextPrimitive>
346 ) : null}356 ) : null}
347 </View>357 </View>
348 );358 );
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx+2
...@@ -338,6 +338,8 @@ export function NativeMarkdownSelectableText(props: {...@@ -338,6 +338,8 @@ export function NativeMarkdownSelectableText(props: {
338 }338 }
339 uiTextView339 uiTextView
340 selectable340 selectable
341 selectionColor={props.textStyle.selectionColor}
342 selectionHandleColor={props.textStyle.selectionHandleColor}
341 style={{343 style={{
342 flexShrink: 1,344 flexShrink: 1,
343 minWidth: 0,345 minWidth: 0,
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts+2
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1export interface NativeMarkdownTextStyle {1export interface NativeMarkdownTextStyle {
2 readonly selectionColor?: string;
3 readonly selectionHandleColor?: string;
2 readonly color: string;4 readonly color: string;
3 readonly strongColor: string;5 readonly strongColor: string;
4 readonly mutedColor: string;6 readonly mutedColor: string;
apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts+5
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { requireOptionalNativeModule } from "expo";1import { requireOptionalNativeModule } from "expo";
22
3interface T3MarkdownTextSelectionNativeModule {3interface T3MarkdownTextSelectionNativeModule {
4 readonly setSelectionHandleColor?: (reactTag: number, color: number) => void;
4 readonly installCopySanitizer: (reactTag: number, contextClipboardConfig: string) => void;5 readonly installCopySanitizer: (reactTag: number, contextClipboardConfig: string) => void;
5 readonly renderContextChip?: (payloadJson: string) => {6 readonly renderContextChip?: (payloadJson: string) => {
6 readonly uri: string;7 readonly uri: string;
...@@ -20,6 +21,10 @@ export function installMarkdownCopySanitizer(reactTag: number, contextClipboardC...@@ -20,6 +21,10 @@ export function installMarkdownCopySanitizer(reactTag: number, contextClipboardC
20 nativeModule?.installCopySanitizer(reactTag, contextClipboardConfig);21 nativeModule?.installCopySanitizer(reactTag, contextClipboardConfig);
21}22}
2223
24export function setMarkdownSelectionHandleColor(reactTag: number, color: number): void {
25 nativeModule?.setSelectionHandleColor?.(reactTag, color);
26}
27
23export function renderAndroidContextChip(payloadJson: string) {28export function renderAndroidContextChip(payloadJson: string) {
24 return nativeModule?.renderContextChip?.(payloadJson) ?? null;29 return nativeModule?.renderContextChip?.(payloadJson) ?? null;
25}30}
apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt+6
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1package expo.modules.t3nativecontrols1package expo.modules.t3nativecontrols
22
3import android.content.Intent3import android.content.Intent
4import android.text.format.DateFormat
4import androidx.core.content.FileProvider5import androidx.core.content.FileProvider
5import expo.modules.kotlin.Promise6import expo.modules.kotlin.Promise
6import expo.modules.kotlin.modules.Module7import expo.modules.kotlin.modules.Module
...@@ -15,6 +16,11 @@ class T3NativeControlsModule : Module() {...@@ -15,6 +16,11 @@ class T3NativeControlsModule : Module() {
15 override fun definition() = ModuleDefinition {16 override fun definition() = ModuleDefinition {
16 Name("T3NativeControls")17 Name("T3NativeControls")
1718
19 Function("is24HourFormat") {
20 val context = appContext.reactContext ?: error("The app is not active.")
21 DateFormat.is24HourFormat(context)
22 }
23
18 AsyncFunction("openFile") { uri: String, mimeType: String, promise: Promise ->24 AsyncFunction("openFile") { uri: String, mimeType: String, promise: Promise ->
19 check(filePreviewPromise == null) { "A document viewer is already open." }25 check(filePreviewPromise == null) { "A document viewer is already open." }
20 val activity = appContext.currentActivity ?: error("The app is not active.")26 val activity = appContext.currentActivity ?: error("The app is not active.")
apps/mobile/src/App.tsx+3-7
...@@ -1,8 +1,7 @@...@@ -1,8 +1,7 @@
1import { BlurTargetView } from "expo-blur";
2import * as Linking from "expo-linking";1import * as Linking from "expo-linking";
3import * as SplashScreen from "expo-splash-screen";2import * as SplashScreen from "expo-splash-screen";
4import { useEffect } from "react";3import { useEffect } from "react";
5import { StatusBar } from "react-native";4import { StatusBar, View } from "react-native";
6import { GestureHandlerRootView } from "react-native-gesture-handler";5import { GestureHandlerRootView } from "react-native-gesture-handler";
7import { KeyboardProvider } from "react-native-keyboard-controller";6import { KeyboardProvider } from "react-native-keyboard-controller";
8import { SafeAreaProvider } from "react-native-safe-area-context";7import { SafeAreaProvider } from "react-native-safe-area-context";
...@@ -21,10 +20,8 @@ import {...@@ -21,10 +20,8 @@ import {
21import { RootStack } from "./Stack";20import { RootStack } from "./Stack";
22import { appAtomRegistry } from "./state/atom-registry";21import { appAtomRegistry } from "./state/atom-registry";
23import { OverlayPortalHost } from "./components/OverlayPortal";22import { OverlayPortalHost } from "./components/OverlayPortal";
24import { appBlurTargetRef } from "./lib/appBlurTarget";
25import { shouldHandleAppLink } from "./lib/appLinking";23import { shouldHandleAppLink } from "./lib/appLinking";
26import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";24import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";
27
28import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";25import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";
2926
30import "../global.css";27import "../global.css";
...@@ -88,14 +85,13 @@ function AppContent() {...@@ -88,14 +85,13 @@ function AppContent() {
88 this, React Navigation defaults to its light theme and every native85 this, React Navigation defaults to its light theme and every native
89 header (glass buttons, title, materials) is forced light even when86 header (glass buttons, title, materials) is forced light even when
90 the system is in dark mode. */}87 the system is in dark mode. */}
91 {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */}88 <View style={{ flex: 1 }}>
92 <BlurTargetView ref={appBlurTargetRef} style={{ flex: 1 }}>
93 <IncomingShareProvider>89 <IncomingShareProvider>
94 <Navigation linking={appLinking} theme={navigationTheme} />90 <Navigation linking={appLinking} theme={navigationTheme} />
95 </IncomingShareProvider>91 </IncomingShareProvider>
96 <ConfirmDialogHost />92 <ConfirmDialogHost />
97 <ThreadArrangementHost />93 <ThreadArrangementHost />
98 </BlurTargetView>94 </View>
99 {/* Anchored-menu overlays render here — in-window, so the95 {/* Anchored-menu overlays render here — in-window, so the
100 keyboard stays up while a dropdown is open. */}96 keyboard stays up while a dropdown is open. */}
101 <OverlayPortalHost />97 <OverlayPortalHost />
apps/mobile/src/components/AndroidAnchoredMenu.tsx+36-105
...@@ -6,12 +6,8 @@ import { BackHandler, Pressable, ScrollView, View } from "react-native";...@@ -6,12 +6,8 @@ import { BackHandler, Pressable, ScrollView, View } from "react-native";
6import { useKeyboardState } from "react-native-keyboard-controller";6import { useKeyboardState } from "react-native-keyboard-controller";
7import Animated, { FadeIn } from "react-native-reanimated";7import Animated, { FadeIn } from "react-native-reanimated";
88
9import { appBlurTargetRef } from "../lib/appBlurTarget";
10import { cn } from "../lib/cn";
11import { type AppSymbolName, SymbolView } from "./AppSymbol";
12import { AppText as Text } from "./AppText";
13import { OverlayPortal } from "./OverlayPortal";9import { OverlayPortal } from "./OverlayPortal";
14import { GlassBackdrop } from "./GlassBackdrop";10import { MaterialMenuPopup } from "./MaterialMenuPopup";
1511
16const MENU_WIDTH = 250;12const MENU_WIDTH = 250;
17const SCREEN_MARGIN = 12;13const SCREEN_MARGIN = 12;
...@@ -27,6 +23,7 @@ type AnchorSnapshot = {...@@ -27,6 +23,7 @@ type AnchorSnapshot = {
27 readonly y: number;23 readonly y: number;
28 readonly width: number;24 readonly width: number;
29 readonly height: number;25 readonly height: number;
26 readonly keyboardWasVisible: boolean;
30};27};
3128
32type OverlayFrame = {29type OverlayFrame = {
...@@ -53,13 +50,9 @@ export type AndroidAnchoredMenuProps = {...@@ -53,13 +50,9 @@ export type AndroidAnchoredMenuProps = {
53};50};
5451
55/**52/**
56 * Token-styled anchored dropdown for Android, drop-in for the subset of the53 * Adapts the app's MenuView actions to Material dropdowns on Android. Editor
57 * MenuView contract the app uses (actions with state/subtitle/image/54 * menus render native Material rows in-window to retain keyboard focus; other
58 * attributes, one level of subactions). The native AppCompat PopupMenu caps55 * menus use the native popup for placement, animation and dismissal.
59 * out on theming — stock animation, item metrics, and submenu chrome — so
60 * ControlPillMenu renders this instead on Android while iOS keeps the native
61 * UIMenu. Styling follows the themed native popup (12dp radius, plain rows,
62 * trailing check glyph); submenus drill in under a muted parent-title header.
63 */56 */
64export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {57export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
65 const [anchor, setAnchor] = useState<AnchorSnapshot | null>(null);58 const [anchor, setAnchor] = useState<AnchorSnapshot | null>(null);
...@@ -89,9 +82,9 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {...@@ -89,9 +82,9 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
8982
90 const open = useCallback(() => {83 const open = useCallback(() => {
91 anchorRef.current?.measureInWindow((x, y, width, height) => {84 anchorRef.current?.measureInWindow((x, y, width, height) => {
92 setAnchor({ x, y, width, height });85 setAnchor({ x, y, width, height, keyboardWasVisible: keyboardVisible });
93 });86 });
94 }, []);87 }, [keyboardVisible]);
9588
96 const measureOverlay = useCallback(() => {89 const measureOverlay = useCallback(() => {
97 overlayRef.current?.measureInWindow((x, y, width, height) => {90 overlayRef.current?.measureInWindow((x, y, width, height) => {
...@@ -100,18 +93,11 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {...@@ -100,18 +93,11 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
100 });93 });
101 }, []);94 }, []);
10295
103 // The dropdown renders in-window (no Modal takes focus), so the hardware96 // The native popup owns back dismissal. In-window menus need a handler;
104 // back gesture needs explicit handling while it is open. Back steps out of97 // back returns to the parent submenu before closing the overlay.
105 // a drilled-in submenu one level at a time (mirroring the tappable parent
106 // header) before closing the menu. Under predictive back
107 // (enableOnBackInvokedCallback) this stays correct: back reaches JS
108 // through always-registered OnBackPressedDispatcher callbacks (react-native
109 // core on Android 16+, withAndroidPredictiveBackCompat on 13-15), which
110 // also keeps the system from playing a "leave app" preview while the menu
111 // merely closes.
112 const submenuDepth = path.length;98 const submenuDepth = path.length;
113 useEffect(() => {99 useEffect(() => {
114 if (anchor === null) {100 if (anchor === null || !anchor.keyboardWasVisible) {
115 return;101 return;
116 }102 }
117 const subscription = BackHandler.addEventListener("hardwareBackPress", () => {103 const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
...@@ -212,10 +198,20 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {...@@ -212,10 +198,20 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
212 onLayout={measureOverlay}198 onLayout={measureOverlay}
213 >199 >
214 <Pressable accessible={false} className="absolute inset-0" onPress={close} />200 <Pressable accessible={false} className="absolute inset-0" onPress={close} />
215 {!placeable || local === null ? null : (201 {!placeable || local === null ? null : !anchor.keyboardWasVisible ? (
202 <MaterialMenuPopup
203 anchor={local}
204 actions={levelActions}
205 title={props.title}
206 parent={parent}
207 onPress={onPressItem}
208 onBack={() => setPath((current) => current.slice(0, -1))}
209 onClose={close}
210 />
211 ) : (
216 <Animated.View212 <Animated.View
217 entering={FadeIn.duration(120)}213 entering={FadeIn.duration(120)}
218 className="absolute w-[250px] overflow-hidden rounded-[12px] border border-border shadow-2xl"214 className="absolute w-[250px] overflow-hidden rounded-[4px] bg-card-alt shadow-md"
219 style={{215 style={{
220 left,216 left,
221 maxHeight,217 maxHeight,
...@@ -224,93 +220,28 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {...@@ -224,93 +220,28 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
224 : { bottom: (rootHeight ?? 0) - local.y + ANCHOR_GAP }),220 : { bottom: (rootHeight ?? 0) - local.y + ANCHOR_GAP }),
225 }}221 }}
226 >222 >
227 <GlassBackdrop blurTarget={appBlurTargetRef} />223 {/* Compose DropdownMenu takes popup focus in the pinned Expo UI version.
224 Keep editor menus in-window so opening one preserves the keyboard. */}
225
228 {/* keyboardShouldPersistTaps: the menu often opens over an226 {/* keyboardShouldPersistTaps: the menu often opens over an
229 active editor; the first item tap must act, not just227 active editor; the first item tap must act, not just
230 dismiss the keyboard. */}228 dismiss the keyboard. */}
231 <ScrollView229 <ScrollView
230 contentContainerClassName="py-2"
232 bounces={false}231 bounces={false}
233 keyboardShouldPersistTaps="always"232 keyboardShouldPersistTaps="always"
234 showsVerticalScrollIndicator={false}233 showsVerticalScrollIndicator={false}
235 >234 >
236 {parent !== null ? (235 <MaterialMenuPopup
237 // Muted parent title as the submenu header; tapping it236 inline
238 // steps back, but it reads as a label, not a button.237 anchor={local}
239 <Pressable238 actions={levelActions}
240 className="px-3.5 pb-1 pt-2.5"239 title={props.title}
241 onPress={() => setPath((current) => current.slice(0, -1))}240 parent={parent}
242 >241 onPress={onPressItem}
243 <Text className="text-xs font-t3-bold text-foreground-muted">242 onBack={() => setPath((current) => current.slice(0, -1))}
244 {parent.title}243 onClose={close}
245 </Text>244 />
246 </Pressable>
247 ) : props.title ? (
248 <>
249 <View className="px-3.5 py-2">
250 <Text className="text-center text-xs text-foreground-muted">
251 {props.title}
252 </Text>
253 </View>
254 <View className="h-px bg-border" />
255 </>
256 ) : null}
257 {levelActions.map((action, index) => {
258 const destructive = action.attributes?.destructive ?? false;
259 const disabled = action.attributes?.disabled ?? false;
260 const hasSubmenu = (action.subactions?.length ?? 0) > 0;
261 return (
262 <Pressable
263 key={action.id ?? `${index}-${action.title}`}
264 disabled={disabled}
265 className={cn(
266 "min-h-11 flex-row items-center gap-2.5 px-3.5 py-2.5 active:bg-subtle",
267 disabled && "opacity-45",
268 )}
269 onPress={() => onPressItem(action)}
270 >
271 <View className="flex-1 gap-0.5">
272 <Text
273 className={cn(
274 // Same face as the pill labels that open these menus.
275 "text-sm font-t3-bold",
276 destructive && "text-danger-foreground",
277 )}
278 >
279 {action.title}
280 </Text>
281 {action.subtitle ? (
282 <Text className="text-xs leading-snug text-foreground-muted">
283 {action.subtitle}
284 </Text>
285 ) : null}
286 </View>
287 {hasSubmenu ? (
288 <SymbolView
289 name="chevron.right"
290 size={13}
291 tintColorClassName={"accent-icon-subtle"}
292 type="monochrome"
293 />
294 ) : action.state === "on" ? (
295 <SymbolView
296 name="checkmark"
297 size={15}
298 tintColorClassName={"accent-icon"}
299 type="monochrome"
300 />
301 ) : action.image ? (
302 <SymbolView
303 name={action.image as AppSymbolName}
304 size={15}
305 tintColorClassName={
306 destructive ? "accent-danger-foreground" : "accent-icon"
307 }
308 type="monochrome"
309 />
310 ) : null}
311 </Pressable>
312 );
313 })}
314 </ScrollView>245 </ScrollView>
315 </Animated.View>246 </Animated.View>
316 )}247 )}
apps/mobile/src/components/AndroidScreenHeader.tsx+57-39
...@@ -1,16 +1,21 @@...@@ -1,16 +1,21 @@
1import type { ReactNode } from "react";1import { useState, type ReactNode } from "react";
2import { Pressable, View } from "react-native";2import { View } from "react-native";
3import { useSafeAreaInsets } from "react-native-safe-area-context";3import { useSafeAreaInsets } from "react-native-safe-area-context";
44
5import { SymbolView, type AppSymbolName } from "./AppSymbol";5import type { AppSymbolName } from "./AppSymbol";
6import { AppText as Text } from "./AppText";6import { AppText as Text } from "./AppText";
7import { cn } from "../lib/cn";7import { cn } from "../lib/cn";
8import { MaterialIconButton } from "./MaterialIconButton";
9import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";
10import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
11import { useMaterialToolbarHeight } from "./useMaterialToolbarHeight";
812
9export interface AndroidHeaderAction {13export interface AndroidHeaderAction {
10 readonly accessibilityLabel: string;14 readonly accessibilityLabel: string;
11 readonly icon: AppSymbolName;15 readonly icon: AppSymbolName;
12 readonly onPress: () => void;16 readonly onPress: () => void;
13 readonly disabled?: boolean;17 readonly disabled?: boolean;
18 readonly selected?: boolean;
14}19}
1520
16export function AndroidHeaderIconButton(props: {21export function AndroidHeaderIconButton(props: {
...@@ -18,73 +23,62 @@ export function AndroidHeaderIconButton(props: {...@@ -18,73 +23,62 @@ export function AndroidHeaderIconButton(props: {
18 readonly icon: AppSymbolName;23 readonly icon: AppSymbolName;
19 readonly onPress?: () => void;24 readonly onPress?: () => void;
20 readonly disabled?: boolean;25 readonly disabled?: boolean;
26 readonly selected?: boolean;
21}) {27}) {
22 return (28 return <MaterialIconButton {...props} variant={props.selected ? "tonal" : "standard"} />;
23 <Pressable
24 accessibilityLabel={props.accessibilityLabel}
25 accessibilityRole="button"
26 disabled={props.disabled}
27 hitSlop={8}
28 onPress={props.onPress}
29 className={cn(
30 "size-11 items-center justify-center rounded-full bg-subtle",
31 props.disabled && "opacity-55",
32 )}
33 >
34 <SymbolView
35 name={props.icon}
36 size={20}
37 tintColorClassName={props.disabled ? "accent-icon-subtle" : "accent-foreground"}
38 type="monochrome"
39 />
40 </Pressable>
41 );
42}29}
4330
44export function AndroidScreenHeader(props: {31export function AndroidScreenHeader(props: {
45 readonly title: string;32 readonly title: string;
46 readonly subtitle?: string | null;33 readonly subtitle?: string | null;
47 readonly actions?: ReadonlyArray<AndroidHeaderAction>;34 readonly actions?: ReadonlyArray<AndroidHeaderAction>;
35 readonly leading?: ReactNode;
48 readonly trailing?: ReactNode;36 readonly trailing?: ReactNode;
49 readonly onBack?: () => void;37 readonly onBack?: () => void;
50 readonly embedded?: boolean;38 readonly embedded?: boolean;
51 readonly hideBottomBorder?: boolean;39 readonly hideBottomBorder?: boolean;
52}) {40}) {
53 const insets = useSafeAreaInsets();41 const insets = useSafeAreaInsets();
42 const titleTypography = useScaledTextRole("title");
43 const subtitleTypography = useScaledTextRole("label");
44 const materialToolbarHeight = useMaterialToolbarHeight();
45 const [headerWidth, setHeaderWidth] = useState(0);
46 const actions = props.actions ?? [];
47 const directCount = actions.length > 2 ? (headerWidth >= 600 ? 3 : 1) : actions.length;
48 const visibleActions = actions.slice(0, directCount);
49 const overflowActions = actions.slice(directCount);
5450
55 return (51 return (
56 <View52 <View
57 className="border-b border-header-border bg-header px-3 pb-2.5"53 onLayout={(event) => setHeaderWidth(event.nativeEvent.layout.width)}
54 className="border-b border-header-border bg-header px-2 pb-2"
58 style={{55 style={{
59 paddingTop: props.embedded ? 8 : Math.max(insets.top, 12),56 paddingTop: props.embedded ? 8 : Math.max(insets.top, 12),
60 borderBottomWidth: props.hideBottomBorder ? 0 : undefined,57 borderBottomWidth: props.hideBottomBorder ? 0 : undefined,
61 }}58 }}
62 >59 >
63 <View className="min-h-12 flex-row items-center gap-2">60 <View
61 style={{ minHeight: materialToolbarHeight }}
62 className="min-h-14 flex-row items-center gap-1"
63 >
64 {props.onBack ? (64 {props.onBack ? (
65 <Pressable65 <MaterialIconButton
66 accessibilityLabel="Navigate up"66 accessibilityLabel="Navigate up"
67 accessibilityRole="button"67 icon="arrow.left"
68 hitSlop={8}
69 onPress={props.onBack}68 onPress={props.onBack}
70 className="-mr-2 size-11 items-center justify-center"69 />
71 >
72 <SymbolView
73 name="chevron.left"
74 size={24}
75 tintColorClassName={"accent-foreground"}
76 type="monochrome"
77 />
78 </Pressable>
79 ) : null}70 ) : null}
8071
72 {props.leading}
73
81 <View className={cn("min-w-0 flex-1", !props.onBack && "pl-1")}>74 <View className={cn("min-w-0 flex-1", !props.onBack && "pl-1")}>
82 <Text numberOfLines={1} className="text-lg font-t3-bold text-foreground">75 <Text numberOfLines={1} style={titleTypography} className="text-foreground">
83 {props.title}76 {props.title}
84 </Text>77 </Text>
85 {props.subtitle ? (78 {props.subtitle ? (
86 <Text79 <Text
87 numberOfLines={1}80 numberOfLines={1}
81 style={subtitleTypography}
88 className="mt-px text-[13px] font-t3-medium text-foreground-muted"82 className="mt-px text-[13px] font-t3-medium text-foreground-muted"
89 >83 >
90 {props.subtitle}84 {props.subtitle}
...@@ -92,15 +86,39 @@ export function AndroidScreenHeader(props: {...@@ -92,15 +86,39 @@ export function AndroidScreenHeader(props: {
92 ) : null}86 ) : null}
93 </View>87 </View>
9488
95 {props.actions?.map((action) => (89 {visibleActions.map((action) => (
96 <AndroidHeaderIconButton90 <AndroidHeaderIconButton
97 key={action.accessibilityLabel}91 key={action.accessibilityLabel}
98 accessibilityLabel={action.accessibilityLabel}92 accessibilityLabel={action.accessibilityLabel}
99 disabled={action.disabled}93 disabled={action.disabled}
94 selected={action.selected}
100 icon={action.icon}95 icon={action.icon}
101 onPress={action.onPress}96 onPress={action.onPress}
102 />97 />
103 ))}98 ))}
99 {overflowActions.length > 0 ? (
100 <AndroidAnchoredMenu
101 actions={overflowActions.map((action, index) => ({
102 id: String(index),
103 title: action.accessibilityLabel,
104 attributes: {
105 disabled: Boolean(action.disabled),
106 state: action.selected ? "on" : undefined,
107 },
108 }))}
109 onPressAction={({ nativeEvent }) =>
110 overflowActions[Number(nativeEvent.event)]?.onPress()
111 }
112 >
113 {(open) => (
114 <MaterialIconButton
115 accessibilityLabel="More actions"
116 icon="ellipsis"
117 onPress={open}
118 />
119 )}
120 </AndroidAnchoredMenu>
121 ) : null}
104 {props.trailing}122 {props.trailing}
105 </View>123 </View>
106 </View>124 </View>
apps/mobile/src/components/AppSymbol.tsx+9
...@@ -9,6 +9,7 @@ import IconAlertTriangle from "@tabler/icons-react-native/IconAlertTriangle";...@@ -9,6 +9,7 @@ import IconAlertTriangle from "@tabler/icons-react-native/IconAlertTriangle";
9import IconApps from "@tabler/icons-react-native/IconApps";9import IconApps from "@tabler/icons-react-native/IconApps";
10import IconArchive from "@tabler/icons-react-native/IconArchive";10import IconArchive from "@tabler/icons-react-native/IconArchive";
11import IconArrowBackUp from "@tabler/icons-react-native/IconArrowBackUp";11import IconArrowBackUp from "@tabler/icons-react-native/IconArrowBackUp";
12import IconArrowLeft from "@tabler/icons-react-native/IconArrowLeft";
12import IconArrowDownCircle from "@tabler/icons-react-native/IconArrowDownCircle";13import IconArrowDownCircle from "@tabler/icons-react-native/IconArrowDownCircle";
13import IconArrowRightCircle from "@tabler/icons-react-native/IconArrowRightCircle";14import IconArrowRightCircle from "@tabler/icons-react-native/IconArrowRightCircle";
14import IconArrowUp from "@tabler/icons-react-native/IconArrowUp";15import IconArrowUp from "@tabler/icons-react-native/IconArrowUp";
...@@ -36,8 +37,10 @@ import IconClock from "@tabler/icons-react-native/IconClock";...@@ -36,8 +37,10 @@ import IconClock from "@tabler/icons-react-native/IconClock";
36import IconCode from "@tabler/icons-react-native/IconCode";37import IconCode from "@tabler/icons-react-native/IconCode";
37import IconCopy from "@tabler/icons-react-native/IconCopy";38import IconCopy from "@tabler/icons-react-native/IconCopy";
38import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop";39import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop";
40import IconDatabase from "@tabler/icons-react-native/IconDatabase";
39import IconDeviceLaptop from "@tabler/icons-react-native/IconDeviceLaptop";41import IconDeviceLaptop from "@tabler/icons-react-native/IconDeviceLaptop";
40import IconDots from "@tabler/icons-react-native/IconDots";42import IconDots from "@tabler/icons-react-native/IconDots";
43import IconDotsVertical from "@tabler/icons-react-native/IconDotsVertical";
41import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal";44import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal";
42import IconEdit from "@tabler/icons-react-native/IconEdit";45import IconEdit from "@tabler/icons-react-native/IconEdit";
43import IconExternalLink from "@tabler/icons-react-native/IconExternalLink";46import IconExternalLink from "@tabler/icons-react-native/IconExternalLink";
...@@ -97,6 +100,7 @@ import { withUniwind } from "uniwind";...@@ -97,6 +100,7 @@ import { withUniwind } from "uniwind";
97100
98const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {101const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
99 "arrow.branch": IconGitBranch,102 "arrow.branch": IconGitBranch,
103 "arrow.left": IconArrowLeft,
100 "arrow.clockwise": IconRefresh,104 "arrow.clockwise": IconRefresh,
101 "arrow.down.circle": IconArrowDownCircle,105 "arrow.down.circle": IconArrowDownCircle,
102 "arrow.right.circle": IconArrowRightCircle,106 "arrow.right.circle": IconArrowRightCircle,
...@@ -142,7 +146,10 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {...@@ -142,7 +146,10 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
142 "folder.badge.plus": IconFolderPlus,146 "folder.badge.plus": IconFolderPlus,
143 "folder.fill": IconFolder,147 "folder.fill": IconFolder,
144 gearshape: IconSettings,148 gearshape: IconSettings,
149 hammer: IconHammer,
145 "info.circle": IconInfoCircle,150 "info.circle": IconInfoCircle,
151 internaldrive: IconDatabase,
152 keyboard: IconKeyboard,
146 laptopcomputer: IconDeviceLaptop,153 laptopcomputer: IconDeviceLaptop,
147 link: IconLink,154 link: IconLink,
148 "line.3.horizontal": IconMenu2,155 "line.3.horizontal": IconMenu2,
...@@ -160,6 +167,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {...@@ -160,6 +167,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
160 "pin.slash": IconPinnedOff,167 "pin.slash": IconPinnedOff,
161 play: IconPlayerPlay,168 play: IconPlayerPlay,
162 plus: IconPlus,169 plus: IconPlus,
170 minus: IconMinus,
163 "qrcode.viewfinder": IconQrcode,171 "qrcode.viewfinder": IconQrcode,
164 "point.3.connected.trianglepath.dotted": IconNetwork,172 "point.3.connected.trianglepath.dotted": IconNetwork,
165 "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge,173 "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge,
...@@ -209,6 +217,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME: Record<string, Icon> = {...@@ -209,6 +217,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME: Record<string, Icon> = {
209 keyboard_arrow_down: IconChevronDown,217 keyboard_arrow_down: IconChevronDown,
210 keyboard_arrow_up: IconChevronUp,218 keyboard_arrow_up: IconChevronUp,
211 keyboard_hide: IconKeyboardHide,219 keyboard_hide: IconKeyboardHide,
220 more_vert: IconDotsVertical,
212 public: IconWorld,221 public: IconWorld,
213 remove: IconMinus,222 remove: IconMinus,
214 terminal: IconTerminal2,223 terminal: IconTerminal2,
apps/mobile/src/components/AppText.tsx+15-3
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 Platform,
2 Text as RNText,3 Text as RNText,
3 TextInput as RNTextInput,4 TextInput as RNTextInput,
4 type TextInputProps as RNTextInputProps,5 type TextInputProps as RNTextInputProps,
...@@ -14,7 +15,13 @@ export type AppTextProps = RNTextProps & { readonly className?: string };...@@ -14,7 +15,13 @@ export type AppTextProps = RNTextProps & { readonly className?: string };
14 * Uses Uniwind className — no manual style parsing.15 * Uses Uniwind className — no manual style parsing.
15 */16 */
16export function AppText({ className, ...props }: AppTextProps) {17export function AppText({ className, ...props }: AppTextProps) {
17 return <RNText className={cn("font-sans text-foreground", className)} {...props} />;18 return (
19 <RNText
20 className={cn("font-sans text-foreground", className)}
21 selectionColorClassName={Platform.OS === "android" ? "accent-primary/32" : undefined}
22 {...props}
23 />
24 );
18}25}
1926
20export type AppTextInputProps = Omit<RNTextInputProps, "placeholderTextColor"> & {27export type AppTextInputProps = Omit<RNTextInputProps, "placeholderTextColor"> & {
...@@ -35,8 +42,13 @@ export function AppTextInput({ className, ref, ...props }: AppTextInputProps) {...@@ -35,8 +42,13 @@ export function AppTextInput({ className, ref, ...props }: AppTextInputProps) {
35 className,42 className,
36 )}43 )}
37 placeholderTextColorClassName="accent-placeholder"44 placeholderTextColorClassName="accent-placeholder"
38 selectionColorClassName="accent-foreground-secondary"45 selectionColorClassName={
39 cursorColorClassName="accent-foreground-secondary"46 Platform.OS === "android" ? "accent-primary/32" : "accent-foreground-secondary"
47 }
48 cursorColorClassName={
49 Platform.OS === "android" ? "accent-primary" : "accent-foreground-secondary"
50 }
51 selectionHandleColorClassName={Platform.OS === "android" ? "accent-primary" : undefined}
40 {...props}52 {...props}
41 />53 />
42 );54 );
apps/mobile/src/components/ConfirmDialogHost.tsx+30-11
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1import { useCallback, useEffect, useState } from "react";1import { useCallback, useEffect, useState } from "react";
2import { Modal, Pressable, TextInput, View } from "react-native";2import { Platform, Modal, Pressable, TextInput, View } from "react-native";
33
4import { cn } from "../lib/cn";4import { cn } from "../lib/cn";
5import { AppText } from "./AppText";5import { AppText } from "./AppText";
6import { MaterialConfirmDialog } from "./MaterialConfirmDialog";
67
7export type ConfirmDialogRequest = {8export type ConfirmDialogRequest = {
8 readonly title: string;9 readonly title: string;
...@@ -67,17 +68,35 @@ export function ConfirmDialogHost() {...@@ -67,17 +68,35 @@ export function ConfirmDialogHost() {
67 setPresented(null);68 setPresented(null);
68 }, [presented]);69 }, [presented]);
6970
70 const handleConfirm = useCallback(() => {71 const handleConfirm = useCallback(
71 if (presented?.kind === "confirm") {72 (nativeInputValue?: string) => {
72 presented.request.onConfirm();73 if (presented?.kind === "confirm") {
73 } else if (presented?.kind === "text-input") {74 presented.request.onConfirm();
74 presented.request.onConfirm(inputValue);75 } else if (presented?.kind === "text-input") {
75 }76 presented.request.onConfirm(nativeInputValue ?? inputValue);
76 setPresented(null);77 }
77 }, [inputValue, presented]);78 setPresented(null);
79 },
80 [inputValue, presented],
81 );
7882
79 const confirmDisabled = presented?.kind === "text-input" && inputValue.trim().length === 0;83 const confirmDisabled = presented?.kind === "text-input" && inputValue.trim().length === 0;
8084
85 if (Platform.OS === "android")
86 return presented ? (
87 <MaterialConfirmDialog
88 key={`${presented.kind}:${presented.request.title}:${presented.kind === "text-input" ? presented.request.initialValue : ""}`}
89 request={presented.request}
90 inputInitialValue={
91 presented.kind === "text-input" ? presented.request.initialValue : undefined
92 }
93 onInputChange={setInputValue}
94 confirmDisabled={confirmDisabled}
95 onCancel={handleCancel}
96 onConfirm={handleConfirm}
97 />
98 ) : null;
99
81 return (100 return (
82 <Modal101 <Modal
83 visible={presented !== null}102 visible={presented !== null}
...@@ -102,7 +121,7 @@ export function ConfirmDialogHost() {...@@ -102,7 +121,7 @@ export function ConfirmDialogHost() {
102 autoFocus121 autoFocus
103 className="mt-4 rounded-xl border border-border bg-screen px-3 py-2.5 text-base text-foreground"122 className="mt-4 rounded-xl border border-border bg-screen px-3 py-2.5 text-base text-foreground"
104 onChangeText={setInputValue}123 onChangeText={setInputValue}
105 onSubmitEditing={confirmDisabled ? undefined : handleConfirm}124 onSubmitEditing={confirmDisabled ? undefined : () => handleConfirm()}
106 returnKeyType="done"125 returnKeyType="done"
107 selectTextOnFocus126 selectTextOnFocus
108 value={inputValue}127 value={inputValue}
...@@ -125,7 +144,7 @@ export function ConfirmDialogHost() {...@@ -125,7 +144,7 @@ export function ConfirmDialogHost() {
125 accessibilityRole="button"144 accessibilityRole="button"
126 disabled={confirmDisabled}145 disabled={confirmDisabled}
127 className="min-h-10 items-center justify-center px-4 active:bg-subtle"146 className="min-h-10 items-center justify-center px-4 active:bg-subtle"
128 onPress={handleConfirm}147 onPress={() => handleConfirm()}
129 >148 >
130 <AppText149 <AppText
131 className={cn(150 className={cn(
apps/mobile/src/components/ControlPill.tsx+45-5
...@@ -19,12 +19,13 @@ import {...@@ -19,12 +19,13 @@ import {
19} from "react-native";19} from "react-native";
20import { withUniwind } from "uniwind";20import { withUniwind } from "uniwind";
21import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";21import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
22
23import { cn } from "../lib/cn";22import { cn } from "../lib/cn";
24import { withMenuActionIconColors } from "../lib/menu-action-colors";23import { withMenuActionIconColors } from "../lib/menu-action-colors";
25import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";24import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";
26import { SymbolView } from "./AppSymbol";25import { SymbolView } from "./AppSymbol";
27import { AppText as Text } from "./AppText";26import { AppText as Text } from "./AppText";
27import { MaterialIconButton } from "./MaterialIconButton";
28import { MaterialButton } from "./MaterialButton";
2829
29const ThemedMenuView = withUniwind(30const ThemedMenuView = withUniwind(
30 function NativeMenuView({31 function NativeMenuView({
...@@ -121,6 +122,47 @@ export function ControlPill(props: {...@@ -121,6 +122,47 @@ export function ControlPill(props: {
121 : "",122 : "",
122 );123 );
123124
125 if (
126 Platform.OS === "android" &&
127 (variant === "pill" || variant === "primary") &&
128 props.label &&
129 props.onPress &&
130 !props.icon &&
131 !props.iconNode &&
132 !props.className &&
133 !props.activateOnPressIn &&
134 (!props.accessibilityLabel || props.accessibilityLabel === props.label)
135 ) {
136 return (
137 <MaterialButton
138 label={props.label}
139 onPress={props.onPress}
140 disabled={props.disabled}
141 tone={variant === "primary" ? "primary" : "secondary"}
142 />
143 );
144 }
145
146 if (
147 Platform.OS === "android" &&
148 props.accessibilityLabel &&
149 props.icon &&
150 !props.iconNode &&
151 !props.label &&
152 !props.className &&
153 !props.activateOnPressIn
154 ) {
155 return (
156 <MaterialIconButton
157 accessibilityLabel={props.accessibilityLabel}
158 icon={props.icon}
159 onPress={props.onPress}
160 disabled={props.disabled}
161 variant={variant === "primary" ? "primary" : variant === "danger" ? "danger" : "tonal"}
162 />
163 );
164 }
165
124 return (166 return (
125 <Pressable167 <Pressable
126 accessibilityLabel={props.accessibilityLabel ?? props.label}168 accessibilityLabel={props.accessibilityLabel ?? props.label}
...@@ -146,10 +188,8 @@ export function ControlPill(props: {...@@ -146,10 +188,8 @@ export function ControlPill(props: {
146 );188 );
147}189}
148190
149// iOS renders the native UIMenu (standard checkmark for `state: "on"`);191// iOS renders UIMenu; AndroidAnchoredMenu adapts the same actions to the
150// Android renders the token-styled AndroidAnchoredMenu, since the native192// selected Android appearance and keeps editor menus above the keyboard.
151// AppCompat popup can't be themed past its stock animation, metrics, and
152// submenu chrome.
153export function ControlPillMenu(193export function ControlPillMenu(
154 props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> &194 props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> &
155 Pick<AccessibilityProps, "accessible" | "accessibilityLabel" | "accessibilityRole"> & {195 Pick<AccessibilityProps, "accessible" | "accessibilityLabel" | "accessibilityRole"> & {
apps/mobile/src/components/EmptyState.tsx+8-2
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { Pressable, View } from "react-native";1import { Pressable, View } from "react-native";
2import type { ReactNode } from "react";
23
3import { AppText as Text } from "./AppText";4import { AppText as Text } from "./AppText";
45
...@@ -7,6 +8,7 @@ export function EmptyState(props: {...@@ -7,6 +8,7 @@ export function EmptyState(props: {
7 readonly detail: string;8 readonly detail: string;
8 readonly actionLabel?: string;9 readonly actionLabel?: string;
9 readonly onAction?: () => void;10 readonly onAction?: () => void;
11 readonly action?: ReactNode;
10 readonly variant?: "card" | "plain";12 readonly variant?: "card" | "plain";
11}) {13}) {
12 if (props.variant === "plain") {14 if (props.variant === "plain") {
...@@ -16,7 +18,9 @@ export function EmptyState(props: {...@@ -16,7 +18,9 @@ export function EmptyState(props: {
16 <Text className="mt-2 text-center font-sans text-base leading-normal text-foreground-muted">18 <Text className="mt-2 text-center font-sans text-base leading-normal text-foreground-muted">
17 {props.detail}19 {props.detail}
18 </Text>20 </Text>
19 {props.actionLabel && props.onAction ? (21 {props.action ? (
22 <View className="mt-5">{props.action}</View>
23 ) : props.actionLabel && props.onAction ? (
20 <Pressable24 <Pressable
21 className="mt-5 rounded-full bg-primary px-5 py-3 active:opacity-70"25 className="mt-5 rounded-full bg-primary px-5 py-3 active:opacity-70"
22 onPress={props.onAction}26 onPress={props.onAction}
...@@ -36,7 +40,9 @@ export function EmptyState(props: {...@@ -36,7 +40,9 @@ export function EmptyState(props: {
36 <Text className="mt-2 font-sans text-sm leading-relaxed text-foreground-muted">40 <Text className="mt-2 font-sans text-sm leading-relaxed text-foreground-muted">
37 {props.detail}41 {props.detail}
38 </Text>42 </Text>
39 {props.actionLabel && props.onAction ? (43 {props.action ? (
44 <View className="mt-4 self-start">{props.action}</View>
45 ) : props.actionLabel && props.onAction ? (
40 <Pressable46 <Pressable
41 className="mt-4 self-start rounded-full bg-primary px-4 py-2.5 active:opacity-70"47 className="mt-4 self-start rounded-full bg-primary px-4 py-2.5 active:opacity-70"
42 onPress={props.onAction}48 onPress={props.onAction}
apps/mobile/src/components/GlassBackdrop.tsx+2-19
...@@ -1,22 +1,13 @@...@@ -1,22 +1,13 @@
1import { BlurView } from "expo-blur";1import { BlurView } from "expo-blur";
2import { useContext, type RefObject } from "react";
3import { Platform, StyleSheet, View, type ColorValue } from "react-native";2import { Platform, StyleSheet, View, type ColorValue } from "react-native";
43
5import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";4import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
6import { GlassBlurTargetContext } from "../lib/glassBlurTarget";
7import { themeColorWithAlpha } from "../lib/mobileTheme";5import { themeColorWithAlpha } from "../lib/mobileTheme";
86
9/** Frosted backdrop for containers that clip their children to their shape. */7/** Frosted backdrop for containers that clip their children to their shape. */
10export function GlassBackdrop(props: {8export function GlassBackdrop(props: { readonly fallbackColor?: ColorValue }) {
11 readonly fallbackColor?: ColorValue;
12 readonly blurTarget?: RefObject<View | null>;
13}) {
14 const { themeAppearance } = useAppearancePreferences();9 const { themeAppearance } = useAppearancePreferences();
15 const inheritedBlurTarget = useContext(GlassBlurTargetContext);10 const supportsBlur = Platform.OS === "ios";
16 const target = props.blurTarget ?? inheritedBlurTarget;
17 const supportsBlur =
18 Platform.OS === "ios" ||
19 (Platform.OS === "android" && Platform.Version >= 31 && target !== undefined);
20 const colorStyle =11 const colorStyle =
21 props.fallbackColor === undefined12 props.fallbackColor === undefined
22 ? undefined13 ? undefined
...@@ -24,17 +15,9 @@ export function GlassBackdrop(props: {...@@ -24,17 +15,9 @@ export function GlassBackdrop(props: {
2415
25 return (16 return (
26 <>17 <>
27 {/* Android samples a separate target. An opaque backing prevents any
28 transparent pixels in that sample from exposing the unblurred feed.
29 iOS samples its actual backdrop, so a backing there would hide it. */}
30 {Platform.OS === "android" ? (
31 <View pointerEvents="none" className="absolute inset-0 bg-card" style={colorStyle} />
32 ) : null}
33 {supportsBlur ? (18 {supportsBlur ? (
34 <BlurView19 <BlurView
35 pointerEvents="none"20 pointerEvents="none"
36 blurTarget={target}
37 blurMethod="dimezisBlurViewSdk31Plus"
38 intensity={80}21 intensity={80}
39 tint={themeAppearance === "dark" ? "dark" : "default"}22 tint={themeAppearance === "dark" ? "dark" : "default"}
40 style={StyleSheet.absoluteFill}23 style={StyleSheet.absoluteFill}
apps/mobile/src/components/GlassSurface.tsx+8-18
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect";1import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect";
2import type { ReactNode, Ref, RefObject } from "react";2import type { ReactNode, Ref } from "react";
3import {3import {
4 Platform,4 Platform,
5 useColorScheme,5 useColorScheme,
...@@ -28,7 +28,6 @@ interface GlassSurfaceProps extends ViewProps {...@@ -28,7 +28,6 @@ interface GlassSurfaceProps extends ViewProps {
28 readonly chrome?: "default" | "none";28 readonly chrome?: "default" | "none";
29 /** Base color for the frosted tint, or solid fill when blur is unavailable. */29 /** Base color for the frosted tint, or solid fill when blur is unavailable. */
30 readonly fallbackColor?: ColorValue;30 readonly fallbackColor?: ColorValue;
31 readonly blurTarget?: RefObject<View | null>;
32 /** Uniwind styling used only when native Liquid Glass is unavailable. */31 /** Uniwind styling used only when native Liquid Glass is unavailable. */
33 readonly fallbackClassName?: string;32 readonly fallbackClassName?: string;
34}33}
...@@ -41,7 +40,6 @@ export function GlassSurface({...@@ -41,7 +40,6 @@ export function GlassSurface({
41 tintColor,40 tintColor,
42 tintColorClassName,41 tintColorClassName,
43 fallbackColor,42 fallbackColor,
44 blurTarget,
45 fallbackClassName,43 fallbackClassName,
46 className,44 className,
47 style,45 style,
...@@ -49,23 +47,15 @@ export function GlassSurface({...@@ -49,23 +47,15 @@ export function GlassSurface({
49}: GlassSurfaceProps) {47}: GlassSurfaceProps) {
50 const isDarkMode = useColorScheme() === "dark";48 const isDarkMode = useColorScheme() === "dark";
51 const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable();49 const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable();
50 const hasShadow = chrome !== "none" && Platform.OS !== "android";
52 const surfaceStyle: ViewStyle = {51 const surfaceStyle: ViewStyle = {
53 borderRadius: 32,52 borderRadius: 32,
54 overflow: "hidden",53 overflow: "hidden",
55 shadowColor: chrome === "none" ? "transparent" : "#000000",54 shadowColor: hasShadow ? "#000000" : "transparent",
56 shadowOpacity: chrome === "none" ? 0 : isDarkMode ? 0.22 : 0.08,55 shadowOpacity: hasShadow ? (isDarkMode ? 0.22 : 0.08) : 0,
57 shadowRadius: chrome === "none" ? 0 : 28,56 shadowRadius: hasShadow ? 28 : 0,
58 shadowOffset:57 shadowOffset: { width: 0, height: hasShadow ? 14 : 0 },
59 chrome === "none"58 elevation: hasShadow ? 12 : 0,
60 ? {
61 width: 0,
62 height: 0,
63 }
64 : {
65 width: 0,
66 height: 14,
67 },
68 elevation: chrome === "none" ? 0 : 12,
69 };59 };
7060
71 if (supportsGlass) {61 if (supportsGlass) {
...@@ -103,7 +93,7 @@ export function GlassSurface({...@@ -103,7 +93,7 @@ export function GlassSurface({
103 )}93 )}
104 style={[surfaceStyle, style]}94 style={[surfaceStyle, style]}
105 >95 >
106 <GlassBackdrop blurTarget={blurTarget} fallbackColor={fallbackColor} />96 <GlassBackdrop fallbackColor={fallbackColor} />
107 {children}97 {children}
108 </View>98 </View>
109 );99 );
apps/mobile/src/components/MaterialButton.android.tsx created+94
...@@ -0,0 +1,94 @@
1import {
2 Box,
3 Button,
4 CircularProgressIndicator,
5 FilledTonalButton,
6 Host,
7 Row,
8 Text,
9 TextButton,
10} from "@expo/ui/jetpack-compose";
11import { defaultMinSize, fillMaxWidth, size } from "@expo/ui/jetpack-compose/modifiers";
12import { View } from "react-native";
13
14import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
15import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
16import type { MaterialButtonProps } from "./MaterialButton";
17
18export function MaterialButton(props: MaterialButtonProps) {
19 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
20 const typography = useScaledTextRole("footnote");
21 const tone = props.tone ?? "secondary";
22 const Component =
23 tone === "text" ? TextButton : tone === "secondary" ? FilledTonalButton : Button;
24 const containerColor =
25 tone === "primary"
26 ? colors["--color-primary"]
27 : tone === "danger"
28 ? colors["--color-danger"]
29 : tone === "text"
30 ? "#00000000"
31 : colors["--color-secondary"];
32 const contentColor =
33 tone === "primary"
34 ? colors["--color-primary-foreground"]
35 : tone === "danger"
36 ? colors["--color-danger-foreground"]
37 : tone === "text"
38 ? colors["--color-primary"]
39 : colors["--color-secondary-foreground"];
40 return (
41 <View
42 accessible
43 accessibilityRole="button"
44 accessibilityLabel={props.label}
45 accessibilityState={{
46 disabled: Boolean(props.disabled || props.loading),
47 busy: Boolean(props.loading),
48 }}
49 accessibilityActions={[{ name: "activate" }]}
50 onAccessibilityAction={() => {
51 if (!props.disabled && !props.loading) props.onPress();
52 }}
53 style={props.fullWidth ? { width: "100%" } : { alignSelf: "flex-start" }}
54 >
55 <View importantForAccessibility="no-hide-descendants">
56 <Host
57 matchContents={props.fullWidth ? { vertical: true } : true}
58 colorScheme={themeAppearance}
59 ignoreSafeAreaKeyboardInsets
60 style={props.fullWidth ? { width: "100%" } : { alignSelf: "flex-start" }}
61 >
62 <Component
63 enabled={!props.disabled && !props.loading}
64 onClick={props.onPress}
65 modifiers={[
66 defaultMinSize({ minHeight: 48 }),
67 ...(props.fullWidth ? [fillMaxWidth()] : []),
68 ]}
69 colors={{
70 containerColor,
71 contentColor,
72 disabledContainerColor: colors["--color-subtle-strong"],
73 disabledContentColor: colors["--color-foreground-muted"],
74 }}
75 >
76 <Row verticalAlignment="center">
77 {props.loading ? (
78 <>
79 <CircularProgressIndicator
80 modifiers={[size(18, 18)]}
81 strokeWidth={2}
82 color={colors["--color-foreground-muted"]}
83 />
84 <Box modifiers={[size(8, 1)]} />
85 </>
86 ) : null}
87 <Text style={{ ...typography, fontWeight: "500" }}>{props.label}</Text>
88 </Row>
89 </Component>
90 </Host>
91 </View>
92 </View>
93 );
94}
apps/mobile/src/components/MaterialButton.tsx created+24
...@@ -0,0 +1,24 @@
1import { Pressable } from "react-native";
2import { AppText } from "./AppText";
3
4export interface MaterialButtonProps {
5 readonly label: string;
6 readonly onPress: () => void;
7 readonly disabled?: boolean;
8 readonly loading?: boolean;
9 readonly tone?: "primary" | "secondary" | "danger" | "text";
10 readonly fullWidth?: boolean;
11}
12
13export function MaterialButton(props: MaterialButtonProps) {
14 return (
15 <Pressable
16 accessibilityRole="button"
17 disabled={props.disabled}
18 onPress={props.onPress}
19 className="min-h-12 justify-center rounded-full bg-secondary px-6"
20 >
21 <AppText>{props.label}</AppText>
22 </Pressable>
23 );
24}
apps/mobile/src/components/MaterialConfirmDialog.android.tsx created+96
...@@ -0,0 +1,96 @@
1import {
2 AlertDialog,
3 Host,
4 OutlinedTextField,
5 Text,
6 TextButton,
7 useNativeState,
8} from "@expo/ui/jetpack-compose";
9
10import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
11import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
12import type { MaterialConfirmDialogProps } from "./MaterialConfirmDialog";
13
14export function MaterialConfirmDialog(props: MaterialConfirmDialogProps) {
15 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
16 const titleTypography = useScaledTextRole("title");
17 const bodyTypography = useScaledTextRole("footnote");
18 const inputTypography = useScaledTextRole("body");
19 const inputState = useNativeState(props.inputInitialValue ?? "");
20 const inputSelection = useNativeState({ start: 0, end: props.inputInitialValue?.length ?? 0 });
21 const confirm = () => {
22 if (props.confirmDisabled) return;
23 const value = props.inputInitialValue === undefined ? undefined : inputState.get();
24 if (value !== undefined && !value.trim()) return;
25 props.onConfirm(value);
26 };
27 return (
28 <Host
29 colorScheme={themeAppearance}
30 ignoreSafeAreaKeyboardInsets
31 style={{ height: 0, width: 0 }}
32 >
33 <AlertDialog
34 onDismissRequest={props.onCancel}
35 tonalElevation={0}
36 colors={{
37 containerColor: colors["--color-card-alt"],
38 titleContentColor: colors["--color-foreground"],
39 textContentColor: colors["--color-foreground-secondary"],
40 }}
41 >
42 <AlertDialog.Title>
43 <Text style={titleTypography}>{props.request.title}</Text>
44 </AlertDialog.Title>
45 {props.inputInitialValue !== undefined ? (
46 <AlertDialog.Text>
47 <OutlinedTextField
48 autoFocus
49 singleLine
50 value={inputState}
51 selection={inputSelection}
52 onValueChange={props.onInputChange}
53 textStyle={inputTypography}
54 keyboardOptions={{ imeAction: "done" }}
55 keyboardActions={{
56 onDone: confirm,
57 }}
58 colors={{
59 focusedTextColor: colors["--color-foreground"],
60 unfocusedTextColor: colors["--color-foreground"],
61 focusedIndicatorColor: colors["--color-primary"],
62 unfocusedIndicatorColor: colors["--color-border"],
63 cursorColor: colors["--color-primary"],
64 }}
65 >
66 <OutlinedTextField.Label>
67 <Text style={bodyTypography}>{props.request.title}</Text>
68 </OutlinedTextField.Label>
69 </OutlinedTextField>
70 </AlertDialog.Text>
71 ) : props.request.message ? (
72 <AlertDialog.Text>
73 <Text style={bodyTypography}>{props.request.message}</Text>
74 </AlertDialog.Text>
75 ) : null}
76 <AlertDialog.DismissButton>
77 <TextButton onClick={props.onCancel} colors={{ contentColor: colors["--color-primary"] }}>
78 <Text style={bodyTypography}>{props.request.cancelText ?? "Cancel"}</Text>
79 </TextButton>
80 </AlertDialog.DismissButton>
81 <AlertDialog.ConfirmButton>
82 <TextButton
83 onClick={confirm}
84 enabled={!props.confirmDisabled}
85 colors={{
86 contentColor:
87 colors[props.request.destructive ? "--color-danger" : "--color-primary"],
88 }}
89 >
90 <Text style={bodyTypography}>{props.request.confirmText}</Text>
91 </TextButton>
92 </AlertDialog.ConfirmButton>
93 </AlertDialog>
94 </Host>
95 );
96}
apps/mobile/src/components/MaterialConfirmDialog.tsx created+17
...@@ -0,0 +1,17 @@
1import type { ConfirmDialogRequest } from "./ConfirmDialogHost";
2
3export interface MaterialConfirmDialogProps {
4 readonly request: Pick<
5 ConfirmDialogRequest,
6 "title" | "message" | "cancelText" | "confirmText" | "destructive"
7 >;
8 readonly inputInitialValue?: string;
9 readonly onInputChange?: (value: string) => void;
10 readonly confirmDisabled?: boolean;
11 readonly onCancel: () => void;
12 readonly onConfirm: (inputValue?: string) => void;
13}
14
15export function MaterialConfirmDialog(_props: MaterialConfirmDialogProps) {
16 return null;
17}
apps/mobile/src/components/MaterialFloatingActionButton.android.tsx created+86
...@@ -0,0 +1,86 @@
1import {
2 Box,
3 ExtendedFloatingActionButton,
4 FloatingActionButton,
5 Host,
6 LargeFloatingActionButton,
7 Text,
8} from "@expo/ui/jetpack-compose";
9import { size } from "@expo/ui/jetpack-compose/modifiers";
10import { View, type StyleProp, type ViewStyle } from "react-native";
11import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
12import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
13import { SymbolView, type AppSymbolName } from "./AppSymbol";
14
15export function MaterialFloatingActionButton(props: {
16 readonly onPress: () => void;
17 readonly label: string;
18 readonly icon: AppSymbolName;
19 readonly variant?: "extended" | "large";
20 readonly expanded?: boolean;
21 readonly tone?: "primary" | "secondary";
22 readonly className?: string;
23 readonly style?: StyleProp<ViewStyle>;
24}) {
25 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
26 const typography = useScaledTextRole("footnote");
27 const primary = props.tone === "primary";
28 const containerColor = colors[primary ? "--color-primary" : "--color-thread-selected"];
29 const contentColor =
30 colors[primary ? "--color-primary-foreground" : "--color-thread-selected-foreground"];
31 const Component =
32 props.variant === "extended"
33 ? ExtendedFloatingActionButton
34 : props.variant === "large"
35 ? LargeFloatingActionButton
36 : FloatingActionButton;
37 const iconSize = props.variant === "large" ? 36 : 24;
38 return (
39 <View
40 accessible
41 accessibilityRole="button"
42 accessibilityLabel={props.label}
43 accessibilityActions={[{ name: "activate" }]}
44 onAccessibilityAction={props.onPress}
45 className={props.className}
46 style={props.style}
47 >
48 <View importantForAccessibility="no-hide-descendants">
49 <Host matchContents colorScheme={themeAppearance} ignoreSafeAreaKeyboardInsets>
50 <Component
51 containerColor={containerColor}
52 onClick={props.onPress}
53 expanded={props.expanded}
54 >
55 <Component.Icon>
56 <Box modifiers={[size(iconSize, iconSize)]} />
57 </Component.Icon>
58 {props.variant === "extended" ? (
59 <ExtendedFloatingActionButton.Text>
60 <Text color={contentColor} style={{ ...typography, fontWeight: "500" }}>
61 {props.label}
62 </Text>
63 </ExtendedFloatingActionButton.Text>
64 ) : null}
65 </Component>
66 </Host>
67 </View>
68 {/* The RN icon stays outside Compose so it cannot intercept native button taps. */}
69 <View
70 pointerEvents="none"
71 className="absolute inset-y-0 justify-center"
72 style={
73 props.variant === "extended" ? { left: 16 } : { left: 0, right: 0, alignItems: "center" }
74 }
75 >
76 <SymbolView
77 name={props.icon}
78 size={iconSize}
79 tintColorClassName={
80 primary ? "accent-primary-foreground" : "accent-thread-selected-foreground"
81 }
82 />
83 </View>
84 </View>
85 );
86}
apps/mobile/src/components/MaterialFloatingActionButton.tsx created+46
...@@ -0,0 +1,46 @@
1import type { ComponentProps } from "react";
2import { Pressable } from "react-native";
3import type { MaterialFloatingActionButton as AndroidMaterialFloatingActionButton } from "./MaterialFloatingActionButton.android";
4import { AppText } from "./AppText";
5import { SymbolView } from "./AppSymbol";
6import { cn } from "../lib/cn";
7
8export function MaterialFloatingActionButton(
9 props: ComponentProps<typeof AndroidMaterialFloatingActionButton>,
10) {
11 return (
12 <Pressable
13 accessibilityRole="button"
14 accessibilityLabel={props.label}
15 onPress={props.onPress}
16 className={cn(
17 "min-h-14 min-w-14 flex-row items-center justify-center gap-2 rounded-2xl px-4",
18 props.tone === "primary" ? "bg-primary" : "bg-thread-selected",
19 props.className,
20 )}
21 style={props.style}
22 >
23 <SymbolView
24 name={props.icon}
25 size={24}
26 tintColorClassName={
27 props.tone === "primary"
28 ? "accent-primary-foreground"
29 : "accent-thread-selected-foreground"
30 }
31 />
32 {props.variant === "extended" && props.expanded !== false ? (
33 <AppText
34 className={cn(
35 "text-sm",
36 props.tone === "primary"
37 ? "text-primary-foreground"
38 : "text-thread-selected-foreground",
39 )}
40 >
41 {props.label}
42 </AppText>
43 ) : null}
44 </Pressable>
45 );
46}
apps/mobile/src/components/MaterialIconButton.android.tsx created+87
...@@ -0,0 +1,87 @@
1import {
2 FilledIconButton,
3 FilledTonalIconButton,
4 Host,
5 IconButton,
6} from "@expo/ui/jetpack-compose";
7import { size } from "@expo/ui/jetpack-compose/modifiers";
8import { View } from "react-native";
9
10import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
11import { SymbolView, type AppSymbolName } from "./AppSymbol";
12
13export function MaterialIconButton(props: {
14 readonly accessibilityLabel: string;
15 readonly icon: AppSymbolName;
16 readonly onPress?: () => void;
17 readonly disabled?: boolean;
18 readonly selected?: boolean;
19 readonly variant?: "standard" | "primary" | "tonal" | "danger";
20}) {
21 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
22 const variant = props.variant ?? "standard";
23 const Component =
24 variant === "standard"
25 ? IconButton
26 : variant === "tonal"
27 ? FilledTonalIconButton
28 : FilledIconButton;
29 const containerColor =
30 variant === "primary"
31 ? colors["--color-primary"]
32 : variant === "danger"
33 ? colors["--color-danger"]
34 : colors["--color-secondary"];
35 const iconTint = props.disabled
36 ? "accent-icon-subtle"
37 : variant === "primary"
38 ? "accent-primary-foreground"
39 : variant === "danger"
40 ? "accent-danger-foreground"
41 : variant === "tonal"
42 ? "accent-secondary-foreground"
43 : "accent-foreground";
44 return (
45 <View
46 accessible
47 accessibilityLabel={props.accessibilityLabel}
48 accessibilityRole="button"
49 accessibilityState={{ disabled: Boolean(props.disabled), selected: props.selected }}
50 accessibilityActions={[{ name: "activate" }]}
51 onAccessibilityAction={() => {
52 if (!props.disabled) props.onPress?.();
53 }}
54 style={{ width: 48, height: 48 }}
55 >
56 <View importantForAccessibility="no-hide-descendants">
57 <Host
58 colorScheme={themeAppearance}
59 ignoreSafeAreaKeyboardInsets
60 style={{ width: 48, height: 48 }}
61 >
62 <Component
63 onClick={props.onPress}
64 enabled={!props.disabled}
65 modifiers={[size(48, 48)]}
66 colors={
67 variant === "standard"
68 ? undefined
69 : { containerColor, disabledContainerColor: colors["--color-subtle-strong"] }
70 }
71 >
72 {null}
73 </Component>
74 </Host>
75 </View>
76 {/* Keep RN SVG measurement outside Compose; the native button owns touch and ripple. */}
77 <View pointerEvents="none" className="absolute inset-0 items-center justify-center">
78 <SymbolView
79 name={props.icon === "ellipsis" ? { ios: "ellipsis", android: "more_vert" } : props.icon}
80 size={24}
81 tintColorClassName={iconTint}
82 type="monochrome"
83 />
84 </View>
85 </View>
86 );
87}
apps/mobile/src/components/MaterialIconButton.tsx created+23
...@@ -0,0 +1,23 @@
1import { Pressable } from "react-native";
2
3import { SymbolView, type AppSymbolName } from "./AppSymbol";
4
5export function MaterialIconButton(props: {
6 readonly accessibilityLabel: string;
7 readonly icon: AppSymbolName;
8 readonly onPress?: () => void;
9 readonly disabled?: boolean;
10 readonly selected?: boolean;
11 readonly variant?: "standard" | "primary" | "tonal" | "danger";
12}) {
13 return (
14 <Pressable
15 {...props}
16 accessibilityRole="button"
17 accessibilityState={{ disabled: Boolean(props.disabled), selected: props.selected }}
18 className="size-12 items-center justify-center"
19 >
20 <SymbolView name={props.icon} size={24} tintColorClassName="accent-foreground" />
21 </Pressable>
22 );
23}
apps/mobile/src/components/MaterialListRow.tsx created+57
...@@ -0,0 +1,57 @@
1import type { ComponentProps, ReactNode } from "react";
2import { Platform, Pressable, View } from "react-native";
3
4import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
5import { cn } from "../lib/cn";
6import { AppText } from "./AppText";
7import { SymbolView } from "./AppSymbol";
8
9/** Shared geometry for Material navigation and selection lists. Group rows in one card. */
10export function MaterialListRow({
11 title,
12 subtitle,
13 leading,
14 trailing,
15 className,
16 ...props
17}: Omit<ComponentProps<typeof Pressable>, "children"> & {
18 readonly title: string;
19 readonly subtitle?: string | null;
20 readonly leading?: ReactNode;
21 readonly trailing?: ReactNode;
22}) {
23 const { themeVariables } = useAppearancePreferences();
24 return (
25 <Pressable
26 accessibilityRole="button"
27 accessibilityLabel={[title, subtitle].filter(Boolean).join(", ")}
28 android_ripple={{ color: themeVariables["--color-subtle-strong"] }}
29 // Give the scroll view time to claim drags before starting the ripple.
30 unstable_pressDelay={Platform.OS === "android" ? 50 : undefined}
31 {...props}
32 className={cn(
33 "flex-row items-center gap-4 overflow-hidden bg-card px-4 py-3",
34 subtitle ? "min-h-18" : "min-h-14",
35 props.disabled && "opacity-45",
36 className,
37 )}
38 >
39 {leading ? <View className="size-6 items-center justify-center">{leading}</View> : null}
40 <View className="min-w-0 flex-1 gap-1">
41 <AppText className="text-base text-foreground" numberOfLines={2}>
42 {title}
43 </AppText>
44 {subtitle ? (
45 <AppText className="text-sm text-foreground-muted" numberOfLines={2}>
46 {subtitle}
47 </AppText>
48 ) : null}
49 </View>
50 {trailing !== undefined ? (
51 trailing
52 ) : !props.disabled ? (
53 <SymbolView name="chevron.right" size={16} tintColorClassName="accent-chevron" />
54 ) : null}
55 </Pressable>
56 );
57}
apps/mobile/src/components/MaterialMenuPopup.android.tsx created+153
...@@ -0,0 +1,153 @@
1import {
2 Box,
3 Column,
4 DropdownMenu,
5 DropdownMenuItem,
6 Host,
7 RNHostView,
8 Text,
9} from "@expo/ui/jetpack-compose";
10import { padding, size, width } from "@expo/ui/jetpack-compose/modifiers";
11import { View } from "react-native";
12
13import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
14import type { MaterialMenuPopupProps } from "./MaterialMenuPopup";
15import { SymbolView, type AppSymbolName } from "./AppSymbol";
16
17function MenuIcon(props: {
18 readonly name: AppSymbolName;
19 readonly destructive?: boolean;
20 readonly disabled?: boolean;
21}) {
22 return (
23 <RNHostView matchContents modifiers={[size(24, 24)]}>
24 <View style={{ width: 24, height: 24 }} importantForAccessibility="no-hide-descendants">
25 <SymbolView
26 name={props.name}
27 size={24}
28 type="monochrome"
29 tintColorClassName={
30 props.disabled
31 ? "accent-icon-subtle"
32 : props.destructive
33 ? "accent-danger-foreground"
34 : "accent-foreground"
35 }
36 />
37 </View>
38 </RNHostView>
39 );
40}
41
42/** Native popup positioned at the original trigger, outside virtualized rows. */
43export function MaterialMenuPopup(props: MaterialMenuPopupProps) {
44 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
45 const foreground = colors["--color-foreground"];
46 const muted = colors["--color-foreground-muted"];
47 const items = (
48 <>
49 {props.parent ? (
50 <DropdownMenuItem onClick={props.onBack} modifiers={[width(250)]}>
51 <DropdownMenuItem.LeadingIcon>
52 <MenuIcon name="arrow.left" />
53 </DropdownMenuItem.LeadingIcon>
54 <DropdownMenuItem.Text>
55 <Text color={foreground} style={{ typography: "bodyLarge" }}>
56 {props.parent.title}
57 </Text>
58 </DropdownMenuItem.Text>
59 </DropdownMenuItem>
60 ) : props.title ? (
61 <Text color={muted} style={{ typography: "bodySmall" }} modifiers={[padding(16, 8, 16, 8)]}>
62 {props.title}
63 </Text>
64 ) : null}
65 {props.actions.map((action, index) => (
66 <DropdownMenuItem
67 key={action.id ?? `${index}-${action.title}`}
68 enabled={!action.attributes?.disabled}
69 modifiers={[width(250)]}
70 elementColors={{
71 textColor: action.attributes?.destructive
72 ? colors["--color-danger-foreground"]
73 : foreground,
74 disabledTextColor: muted,
75 }}
76 onClick={() => props.onPress(action)}
77 >
78 <DropdownMenuItem.Text>
79 <Column>
80 <Text
81 style={{ typography: "bodyLarge" }}
82 color={
83 action.attributes?.disabled
84 ? muted
85 : action.attributes?.destructive
86 ? colors["--color-danger-foreground"]
87 : foreground
88 }
89 >
90 {action.title}
91 </Text>
92 {action.subtitle ? (
93 <Text color={muted} style={{ typography: "bodySmall" }}>
94 {action.subtitle}
95 </Text>
96 ) : null}
97 </Column>
98 </DropdownMenuItem.Text>
99 {action.image ? (
100 <DropdownMenuItem.LeadingIcon>
101 <MenuIcon
102 name={action.image as AppSymbolName}
103 destructive={action.attributes?.destructive}
104 disabled={action.attributes?.disabled}
105 />
106 </DropdownMenuItem.LeadingIcon>
107 ) : null}
108 {(action.subactions?.length ?? 0) > 0 ? (
109 <DropdownMenuItem.TrailingIcon>
110 <MenuIcon name="chevron.right" disabled={action.attributes?.disabled} />
111 </DropdownMenuItem.TrailingIcon>
112 ) : action.state === "on" ? (
113 <DropdownMenuItem.TrailingIcon>
114 <MenuIcon name="checkmark" disabled={action.attributes?.disabled} />
115 </DropdownMenuItem.TrailingIcon>
116 ) : null}
117 </DropdownMenuItem>
118 ))}
119 </>
120 );
121 if (props.inline) {
122 return (
123 <Host
124 colorScheme={themeAppearance}
125 ignoreSafeAreaKeyboardInsets
126 matchContents
127 style={{ width: 250 }}
128 >
129 <Column>{items}</Column>
130 </Host>
131 );
132 }
133 return (
134 <Host
135 colorScheme={themeAppearance}
136 ignoreSafeAreaKeyboardInsets
137 style={{
138 position: "absolute",
139 left: props.anchor.x,
140 top: props.anchor.y,
141 width: props.anchor.width,
142 height: props.anchor.height,
143 }}
144 >
145 <DropdownMenu expanded onDismissRequest={props.onClose} color={colors["--color-card-alt"]}>
146 <DropdownMenu.Trigger>
147 <Box modifiers={[size(props.anchor.width, props.anchor.height)]} />
148 </DropdownMenu.Trigger>
149 <DropdownMenu.Items>{items}</DropdownMenu.Items>
150 </DropdownMenu>
151 </Host>
152 );
153}
apps/mobile/src/components/MaterialMenuPopup.tsx created+22
...@@ -0,0 +1,22 @@
1import type { MenuAction } from "@react-native-menu/menu";
2
3export interface MaterialMenuPopupProps {
4 readonly anchor: {
5 readonly x: number;
6 readonly y: number;
7 readonly width: number;
8 readonly height: number;
9 };
10 readonly actions: readonly MenuAction[];
11 readonly title?: string;
12 readonly parent: MenuAction | null;
13 readonly onPress: (action: MenuAction) => void;
14 readonly onBack: () => void;
15 readonly onClose: () => void;
16 /** Keep the editor's window focus and keyboard while showing native menu rows. */
17 readonly inline?: boolean;
18}
19
20export function MaterialMenuPopup(_props: MaterialMenuPopupProps) {
21 return null;
22}
apps/mobile/src/components/MaterialNewThreadButton.android.tsx created+17
...@@ -0,0 +1,17 @@
1import type { ComponentProps } from "react";
2import { MaterialFloatingActionButton } from "./MaterialFloatingActionButton.android";
3import type { MaterialNewThreadButton as SharedMaterialNewThreadButton } from "./MaterialNewThreadButton.shared";
4
5export function MaterialNewThreadButton(
6 props: ComponentProps<typeof SharedMaterialNewThreadButton>,
7) {
8 return (
9 <MaterialFloatingActionButton
10 {...props}
11 icon="square.and.pencil"
12 label="New thread"
13 tone="primary"
14 variant={props.extended ? "extended" : "large"}
15 />
16 );
17}
apps/mobile/src/components/MaterialNewThreadButton.shared.tsx created+40
...@@ -0,0 +1,40 @@
1import { Pressable, type StyleProp, type ViewStyle } from "react-native";
2
3import { cn } from "../lib/cn";
4import { AppText } from "./AppText";
5import { SymbolView } from "./AppSymbol";
6
7/** Shared compose action for the floating button and empty workspace. */
8export function MaterialNewThreadButton(props: {
9 readonly onPress: () => void;
10 readonly extended?: boolean;
11 readonly expanded?: boolean;
12 readonly className?: string;
13 readonly style?: StyleProp<ViewStyle>;
14}) {
15 return (
16 <Pressable
17 accessibilityLabel="New thread"
18 accessibilityRole="button"
19 onPress={props.onPress}
20 className={cn(
21 "items-center justify-center bg-primary shadow-lg active:opacity-70",
22 props.extended
23 ? "h-[56px] flex-row gap-[8px] rounded-[16px] px-[16px]"
24 : "size-[80px] rounded-[20px]",
25 props.className,
26 )}
27 style={props.style}
28 >
29 <SymbolView
30 name="square.and.pencil"
31 size={props.extended ? 24 : 28}
32 tintColorClassName="accent-primary-foreground"
33 type="monochrome"
34 />
35 {props.extended && props.expanded !== false ? (
36 <AppText className="text-[16px] font-t3-medium text-primary-foreground">New thread</AppText>
37 ) : null}
38 </Pressable>
39 );
40}
apps/mobile/src/components/MaterialNewThreadButton.tsx created+1
...@@ -0,0 +1 @@
1export { MaterialNewThreadButton } from "./MaterialNewThreadButton.shared";
apps/mobile/src/components/MaterialRadioIndicator.android.tsx created+55
...@@ -0,0 +1,55 @@
1import { Host, RadioButton } from "@expo/ui/jetpack-compose";
2import { size } from "@expo/ui/jetpack-compose/modifiers";
3import { View } from "react-native";
4
5import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
6
7/** The enclosing radio row owns selection, touch and accessibility. */
8export function MaterialRadioIndicator({ selected }: { readonly selected: boolean }) {
9 const { themeAppearance, themeVariables, systemColorsActive } = useAppearancePreferences();
10 // Expo's radio button cannot override colors in the pinned SDK. Custom
11 // themes need their exact accent rather than a generated Material palette.
12 if (!systemColorsActive) {
13 return (
14 <View
15 pointerEvents="none"
16 importantForAccessibility="no-hide-descendants"
17 style={{ width: 24, height: 24, alignItems: "center", justifyContent: "center" }}
18 >
19 <View
20 style={{
21 width: 20,
22 height: 20,
23 borderRadius: 10,
24 borderWidth: 2,
25 borderColor: themeVariables[selected ? "--color-primary" : "--color-foreground-muted"],
26 alignItems: "center",
27 justifyContent: "center",
28 }}
29 >
30 {selected ? (
31 <View
32 style={{
33 width: 10,
34 height: 10,
35 borderRadius: 5,
36 backgroundColor: themeVariables["--color-primary"],
37 }}
38 />
39 ) : null}
40 </View>
41 </View>
42 );
43 }
44 return (
45 <View pointerEvents="none" importantForAccessibility="no-hide-descendants">
46 <Host
47 colorScheme={themeAppearance}
48 ignoreSafeAreaKeyboardInsets
49 style={{ width: 24, height: 24 }}
50 >
51 <RadioButton selected={selected} modifiers={[size(24, 24)]} />
52 </Host>
53 </View>
54 );
55}
apps/mobile/src/components/MaterialRadioIndicator.tsx created+8
...@@ -0,0 +1,8 @@
1import { SymbolView } from "./AppSymbol";
2
3/** The enclosing radio row owns selection, touch and accessibility. */
4export function MaterialRadioIndicator({ selected }: { readonly selected: boolean }) {
5 return selected ? (
6 <SymbolView name="checkmark" size={16} tintColorClassName="accent-icon" />
7 ) : null;
8}
apps/mobile/src/components/MaterialScreenContent.tsx created+33
...@@ -0,0 +1,33 @@
1import type { ReactNode } from "react";
2import { Platform, View } from "react-native";
3
4/** Keeps the header surface visible behind rounded Android content corners. */
5export function MaterialScreenContent({
6 children,
7 insetHorizontal = false,
8 fitToContents = false,
9}: {
10 readonly children: ReactNode;
11 /** Match the master-list gutters for secondary panes, not full-width content. */
12 readonly insetHorizontal?: boolean;
13 /** Allow native form sheets to measure their content instead of filling a fixed detent. */
14 readonly fitToContents?: boolean;
15}) {
16 if (Platform.OS !== "android") return children;
17
18 return (
19 <View className={fitToContents ? "shrink bg-header" : "flex-1 bg-header"}>
20 <View
21 className="overflow-hidden rounded-t-[28px] bg-sheet-solid"
22 style={{
23 flexShrink: 1,
24 flexGrow: fitToContents ? 0 : 1,
25 flexBasis: fitToContents ? "auto" : 0,
26 marginHorizontal: insetHorizontal ? 4 : 0,
27 }}
28 >
29 {children}
30 </View>
31 </View>
32 );
33}
apps/mobile/src/components/MaterialSegmentedButtons.android.tsx created+37
...@@ -0,0 +1,37 @@
1import { SegmentedButton, SingleChoiceSegmentedButtonRow, Text } from "@expo/ui/jetpack-compose";
2import { defaultMinSize, fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers";
3import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
4import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
5import type { SegmentedControlProps } from "./SegmentedControl";
6
7/** Compose content shared by screen controls and native dialogs, inside their existing Host. */
8export function MaterialSegmentedButtons<Value extends number | string>(
9 props: SegmentedControlProps<Value>,
10) {
11 const { themeVariables: colors } = useAppearancePreferences();
12 const typography = useScaledTextRole("footnote");
13 return (
14 <SingleChoiceSegmentedButtonRow modifiers={[fillMaxWidth()]}>
15 {props.options.map((option) => (
16 <SegmentedButton
17 key={String(option.value)}
18 selected={option.value === props.selected}
19 onClick={() => props.onSelect(option.value)}
20 modifiers={[defaultMinSize({ minHeight: 48 })]}
21 colors={{
22 activeContainerColor: colors["--color-secondary"],
23 activeContentColor: colors["--color-secondary-foreground"],
24 inactiveContainerColor: "transparent",
25 inactiveContentColor: colors["--color-foreground"],
26 activeBorderColor: colors["--color-border"],
27 inactiveBorderColor: colors["--color-border"],
28 }}
29 >
30 <SegmentedButton.Label>
31 <Text style={typography}>{option.label}</Text>
32 </SegmentedButton.Label>
33 </SegmentedButton>
34 ))}
35 </SingleChoiceSegmentedButtonRow>
36 );
37}
apps/mobile/src/components/MaterialSegmentedControl.android.tsx created+40
...@@ -0,0 +1,40 @@
1import { Host } from "@expo/ui/jetpack-compose";
2import { MaterialSegmentedButtons } from "./MaterialSegmentedButtons.android";
3import { View } from "react-native";
4
5import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
6import type { SegmentedControlProps } from "./SegmentedControl";
7
8export function MaterialSegmentedControl<Value extends number | string>(
9 props: SegmentedControlProps<Value>,
10) {
11 const { themeAppearance } = useAppearancePreferences();
12 return (
13 <View className={props.className}>
14 <View importantForAccessibility="no-hide-descendants">
15 <Host
16 matchContents={{ vertical: true }}
17 colorScheme={themeAppearance}
18 ignoreSafeAreaKeyboardInsets
19 style={{ width: "100%" }}
20 >
21 <MaterialSegmentedButtons {...props} />
22 </Host>
23 </View>
24 <View pointerEvents="none" className="absolute inset-0 flex-row">
25 {props.options.map((option) => (
26 <View
27 key={String(option.value)}
28 accessible
29 accessibilityRole={props.role ?? "button"}
30 accessibilityLabel={option.accessibilityLabel ?? option.label}
31 accessibilityState={{ selected: option.value === props.selected }}
32 accessibilityActions={[{ name: "activate" }]}
33 onAccessibilityAction={() => props.onSelect(option.value)}
34 className="flex-1"
35 />
36 ))}
37 </View>
38 </View>
39 );
40}
apps/mobile/src/components/MaterialSegmentedControl.tsx created+7
...@@ -0,0 +1,7 @@
1import type { SegmentedControlProps } from "./SegmentedControl";
2
3export function MaterialSegmentedControl<Value extends number | string>(
4 _props: SegmentedControlProps<Value>,
5) {
6 return null;
7}
apps/mobile/src/components/MaterialSwitch.android.tsx created+49
...@@ -0,0 +1,49 @@
1import { Host, Switch as ComposeSwitch } from "@expo/ui/jetpack-compose";
2import { View } from "react-native";
3import type { ThemedSwitchProps } from "./ThemedSwitch";
4
5import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
6
7/** Material's native switch, with the same palette and accessibility contract as our RN controls. */
8export function MaterialSwitch(props: ThemedSwitchProps) {
9 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
10 const toggle = () => {
11 if (!props.disabled) props.onValueChange?.(!props.value);
12 };
13
14 return (
15 <View
16 accessible
17 accessibilityLabel={props.accessibilityLabel}
18 accessibilityHint={props.accessibilityHint}
19 accessibilityRole="switch"
20 accessibilityState={{ checked: Boolean(props.value), disabled: Boolean(props.disabled) }}
21 accessibilityActions={[{ name: "activate", label: "Toggle" }]}
22 onAccessibilityAction={toggle}
23 style={props.style}
24 testID={props.testID}
25 >
26 <View importantForAccessibility="no-hide-descendants">
27 <Host
28 colorScheme={themeAppearance}
29 ignoreSafeAreaKeyboardInsets
30 style={{ width: 52, height: 48 }}
31 >
32 <ComposeSwitch
33 value={Boolean(props.value)}
34 enabled={!props.disabled}
35 onCheckedChange={props.onValueChange ?? undefined}
36 colors={{
37 checkedTrackColor: colors["--color-switch-active-track"],
38 checkedThumbColor: colors["--color-switch-active-thumb"],
39 checkedBorderColor: colors["--color-switch-active-track"],
40 uncheckedTrackColor: colors["--color-switch-inactive-track"],
41 uncheckedThumbColor: colors["--color-switch-inactive-thumb"],
42 uncheckedBorderColor: colors["--color-switch-inactive-thumb"],
43 }}
44 />
45 </Host>
46 </View>
47 </View>
48 );
49}
apps/mobile/src/components/MaterialSwitch.tsx created+1
...@@ -0,0 +1 @@
1export { Switch as MaterialSwitch } from "react-native";
apps/mobile/src/components/ScreenScrollView.tsx created+19
...@@ -0,0 +1,19 @@
1import type { ComponentProps } from "react";
2import { Platform, ScrollView } from "react-native";
3
4/** Keeps forms and settings readable inside a wide pane while its surface fills the screen. */
5export function ScreenScrollView(props: ComponentProps<typeof ScrollView>) {
6 return (
7 <ScrollView
8 {...props}
9 contentContainerStyle={[
10 props.contentContainerStyle,
11 Platform.OS === "android" && {
12 width: "100%",
13 maxWidth: 720,
14 alignSelf: "center",
15 },
16 ]}
17 />
18 );
19}
apps/mobile/src/components/SegmentedControl.tsx+14-5
...@@ -2,8 +2,9 @@ import { Platform, Pressable, View } from "react-native";...@@ -2,8 +2,9 @@ import { Platform, Pressable, View } from "react-native";
2import Animated, { Easing, LinearTransition, ReduceMotion } from "react-native-reanimated";2import Animated, { Easing, LinearTransition, ReduceMotion } from "react-native-reanimated";
3import { AppText as Text } from "./AppText";3import { AppText as Text } from "./AppText";
4import { cn } from "../lib/cn";4import { cn } from "../lib/cn";
5import { MaterialSegmentedControl } from "./MaterialSegmentedControl";
56
6export function SegmentedControl<Value extends number | string>(props: {7export interface SegmentedControlProps<Value extends number | string> {
7 readonly options: readonly {8 readonly options: readonly {
8 readonly value: Value;9 readonly value: Value;
9 readonly label: string;10 readonly label: string;
...@@ -11,18 +12,26 @@ export function SegmentedControl<Value extends number | string>(props: {...@@ -11,18 +12,26 @@ export function SegmentedControl<Value extends number | string>(props: {
11 }[];12 }[];
12 readonly selected: Value;13 readonly selected: Value;
13 readonly onSelect: (value: Value) => void;14 readonly onSelect: (value: Value) => void;
14 /** The tab bar is full height; filters under it are shorter so it stays primary. */15 /** Compact sizing applies to the non-Material control. */
15 readonly size?: "default" | "compact";16 readonly size?: "default" | "compact";
16 /** "tab" for the view switcher; filters stay plain buttons. */17 /** "tab" for the view switcher; filters stay plain buttons. */
17 readonly role?: "tab" | "button";18 readonly role?: "tab" | "button";
18 readonly className?: string;19 readonly className?: string;
19}) {20}
21
22export function SegmentedControl<Value extends number | string>(
23 props: SegmentedControlProps<Value>,
24) {
20 const compact = props.size === "compact";25 const compact = props.size === "compact";
26 if (Platform.OS === "android") {
27 return <MaterialSegmentedControl {...props} />;
28 }
21 return (29 return (
22 <View30 <View
23 accessible={false}31 accessible={false}
24 className={cn(32 className={cn(
25 "flex-row overflow-hidden rounded-full border-continuous bg-card",33 "flex-row overflow-hidden",
34 "rounded-full border-continuous bg-card",
26 props.className,35 props.className,
27 )}36 )}
28 >37 >
...@@ -31,7 +40,7 @@ export function SegmentedControl<Value extends number | string>(props: {...@@ -31,7 +40,7 @@ export function SegmentedControl<Value extends number | string>(props: {
31 layout={LinearTransition.duration(200)40 layout={LinearTransition.duration(200)
32 .easing(Easing.out(Easing.cubic))41 .easing(Easing.out(Easing.cubic))
33 .reduceMotion(ReduceMotion.System)}42 .reduceMotion(ReduceMotion.System)}
34 className="absolute bottom-0 top-0 rounded-full bg-subtle-strong"43 className="absolute inset-y-0 rounded-full bg-subtle-strong"
35 style={{44 style={{
36 width: `${100 / props.options.length}%`,45 width: `${100 / props.options.length}%`,
37 start: `${46 start: `${
apps/mobile/src/components/ThemedSwitch.tsx+16-52
...@@ -1,63 +1,27 @@...@@ -1,63 +1,27 @@
1import { Platform, Pressable, Switch, View, type SwitchProps } from "react-native";1import { Platform, Switch, type SwitchProps } from "react-native";
22
3import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";3import { MaterialSwitch } from "./MaterialSwitch";
4import { SymbolView } from "./AppSymbol";
54
6export function ThemedSwitch(props: SwitchProps) {5export type ThemedSwitchProps = Pick<
7 const { materialYouStyleLayoutActive } = useAppearancePreferences();6 SwitchProps,
8 if (materialYouStyleLayoutActive) {7 | "accessibilityHint"
9 return (8 | "accessibilityLabel"
10 <Pressable9 | "disabled"
11 accessibilityHint={props.accessibilityHint}10 | "onValueChange"
12 accessibilityLabel={props.accessibilityLabel}11 | "style"
13 accessibilityRole="switch"12 | "testID"
14 accessibilityState={{ checked: Boolean(props.value), disabled: props.disabled }}13 | "value"
15 disabled={props.disabled}14>;
16 hitSlop={8}15
17 onPress={() => props.onValueChange?.(!props.value)}16export function ThemedSwitch(props: ThemedSwitchProps) {
18 style={props.style}17 if (Platform.OS === "android") {
19 testID={props.testID}18 return <MaterialSwitch {...props} />;
20 className={props.disabled ? "opacity-40" : "active:opacity-70"}
21 >
22 <View
23 className={
24 props.value
25 ? "h-8 w-[52px] items-end justify-center rounded-full border-2 border-switch-active-track bg-switch-active-track px-0.5"
26 : "h-8 w-[52px] items-start justify-center rounded-full border-2 border-switch-inactive-thumb bg-switch-inactive-track px-0.5"
27 }
28 >
29 <View
30 className={
31 props.value
32 ? "size-6 items-center justify-center rounded-full bg-switch-active-thumb"
33 : "size-6 items-center justify-center rounded-full bg-switch-inactive-thumb"
34 }
35 >
36 <SymbolView
37 name={props.value ? "checkmark" : "xmark"}
38 size={13}
39 tintColorClassName={
40 props.value ? "accent-switch-active-track" : "accent-switch-inactive-track"
41 }
42 type="monochrome"
43 />
44 </View>
45 </View>
46 </Pressable>
47 );
48 }19 }
4920
50 return (21 return (
51 <Switch22 <Switch
52 {...props}23 {...props}
53 ios_backgroundColorClassName="accent-switch-inactive-track"24 ios_backgroundColorClassName="accent-switch-inactive-track"
54 thumbColorClassName={
55 Platform.OS === "android"
56 ? props.value
57 ? "accent-switch-active-thumb"
58 : "accent-switch-inactive-thumb"
59 : undefined
60 }
61 trackColorOffClassName="accent-switch-inactive-track"25 trackColorOffClassName="accent-switch-inactive-track"
62 trackColorOnClassName="accent-switch-active-track"26 trackColorOnClassName="accent-switch-active-track"
63 />27 />
apps/mobile/src/components/useMaterialToolbarHeight.ts created+11
...@@ -0,0 +1,11 @@
1import { useWindowDimensions } from "react-native";
2
3import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
4
5/** Reserve the same title/subtitle space in every pane, including icon-only and search headers. */
6export function useMaterialToolbarHeight() {
7 const title = useScaledTextRole("title");
8 const subtitle = useScaledTextRole("label");
9 const { fontScale } = useWindowDimensions();
10 return Math.ceil(Math.max(56, (title.lineHeight + subtitle.lineHeight) * fontScale + 1));
11}
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+41-37
...@@ -40,6 +40,7 @@ import {...@@ -40,6 +40,7 @@ import {
40 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,40 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
41} from "../layout/native-mail-search-toolbar";41} from "../layout/native-mail-search-toolbar";
42import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";42import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";
43import { SettingsScreenContent } from "../settings/components/SettingsScreen";
4344
44export interface ArchivedThreadsHeaderEnvironment {45export interface ArchivedThreadsHeaderEnvironment {
45 readonly environmentId: EnvironmentId;46 readonly environmentId: EnvironmentId;
...@@ -146,6 +147,7 @@ function ArchivedThreadsHeader(props: {...@@ -146,6 +147,7 @@ function ArchivedThreadsHeader(props: {
146 className="border-b border-header-border bg-header px-3 pb-2.5"147 className="border-b border-header-border bg-header px-3 pb-2.5"
147 style={{148 style={{
148 paddingTop: Math.max(insets.top, 12),149 paddingTop: Math.max(insets.top, 12),
150 borderBottomWidth: 0,
149 }}151 }}
150 >152 >
151 <View className="min-h-12 flex-row items-center gap-2">153 <View className="min-h-12 flex-row items-center gap-2">
...@@ -159,7 +161,7 @@ function ArchivedThreadsHeader(props: {...@@ -159,7 +161,7 @@ function ArchivedThreadsHeader(props: {
159 <SymbolView161 <SymbolView
160 name="chevron.left"162 name="chevron.left"
161 size={24}163 size={24}
162 tintColorClassName={"accent-foreground"}164 tintColorClassName="accent-foreground"
163 type="monochrome"165 type="monochrome"
164 />166 />
165 </Pressable>167 </Pressable>
...@@ -167,7 +169,7 @@ function ArchivedThreadsHeader(props: {...@@ -167,7 +169,7 @@ function ArchivedThreadsHeader(props: {
167 <SymbolView169 <SymbolView
168 name="magnifyingglass"170 name="magnifyingglass"
169 size={17}171 size={17}
170 tintColorClassName={"accent-icon"}172 tintColorClassName="accent-icon"
171 type="monochrome"173 type="monochrome"
172 />174 />
173 <TextInput175 <TextInput
...@@ -197,7 +199,7 @@ function ArchivedThreadsHeader(props: {...@@ -197,7 +199,7 @@ function ArchivedThreadsHeader(props: {
197 : "line.3.horizontal.decrease.circle"199 : "line.3.horizontal.decrease.circle"
198 }200 }
199 size={16}201 size={16}
200 tintColorClassName={"accent-icon"}202 tintColorClassName="accent-icon"
201 type="monochrome"203 type="monochrome"
202 />204 />
203 </Pressable>205 </Pressable>
...@@ -455,7 +457,7 @@ function ArchivedThreadRow(props: {...@@ -455,7 +457,7 @@ function ArchivedThreadRow(props: {
455 <SymbolView457 <SymbolView
456 name="archivebox.fill"458 name="archivebox.fill"
457 size={15}459 size={15}
458 tintColorClassName={"accent-icon-subtle"}460 tintColorClassName="accent-icon-subtle"
459 type="monochrome"461 type="monochrome"
460 />462 />
461 </View>463 </View>
...@@ -477,7 +479,7 @@ function ArchivedThreadRow(props: {...@@ -477,7 +479,7 @@ function ArchivedThreadRow(props: {
477 <SymbolView479 <SymbolView
478 name="arrow.triangle.branch"480 name="arrow.triangle.branch"
479 size={10}481 size={10}
480 tintColorClassName={"accent-icon-subtle"}482 tintColorClassName="accent-icon-subtle"
481 type="monochrome"483 type="monochrome"
482 />484 />
483 <Text485 <Text
...@@ -615,7 +617,7 @@ export function ArchivedThreadsScreen(props: {...@@ -615,7 +617,7 @@ export function ArchivedThreadsScreen(props: {
615 if (isInitialLoad) {617 if (isInitialLoad) {
616 return (618 return (
617 <View className="items-center py-16">619 <View className="items-center py-16">
618 <ActivityIndicator colorClassName={"accent-icon"} />620 <ActivityIndicator colorClassName="accent-icon" />
619 <Text className="mt-3 text-sm text-foreground-muted">Loading archive...</Text>621 <Text className="mt-3 text-sm text-foreground-muted">Loading archive...</Text>
620 </View>622 </View>
621 );623 );
...@@ -646,37 +648,39 @@ export function ArchivedThreadsScreen(props: {...@@ -646,37 +648,39 @@ export function ArchivedThreadsScreen(props: {
646 sortOrder={props.sortOrder}648 sortOrder={props.sortOrder}
647 />649 />
648650
649 <GestureDetector gesture={archiveScrollGesture}>651 <SettingsScreenContent>
650 <LegendList652 <GestureDetector gesture={archiveScrollGesture}>
651 className="flex-1"653 <LegendList
652 contentContainerStyle={{654 className="flex-1"
653 paddingBottom: 32,655 contentContainerStyle={{
654 paddingHorizontal: 16,656 paddingBottom: 32,
655 paddingTop: 4,657 paddingHorizontal: 16,
656 }}658 paddingTop: 4,
657 contentInsetAdjustmentBehavior="automatic"659 }}
658 data={listItems}660 contentInsetAdjustmentBehavior="automatic"
659 estimatedItemSize={62}661 data={listItems}
660 getItemType={(item) => item.kind}662 estimatedItemSize={62}
661 keyboardDismissMode="on-drag"663 getItemType={(item) => item.kind}
662 keyboardShouldPersistTaps="handled"664 keyboardDismissMode="on-drag"
663 keyExtractor={(item) => item.key}665 keyboardShouldPersistTaps="handled"
664 ListEmptyComponent={listEmptyComponent}666 keyExtractor={(item) => item.key}
665 ListHeaderComponent={667 ListEmptyComponent={listEmptyComponent}
666 props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null668 ListHeaderComponent={
667 }669 props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null
668 onScrollBeginDrag={() => openSwipeableRef.current?.close()}670 }
669 refreshControl={671 onScrollBeginDrag={() => openSwipeableRef.current?.close()}
670 <RefreshControl672 refreshControl={
671 onRefresh={props.onRefresh}673 <RefreshControl
672 refreshing={props.isLoading && !isInitialLoad}674 onRefresh={props.onRefresh}
673 tintColorClassName={String("accent-icon")}675 refreshing={props.isLoading && !isInitialLoad}
674 />676 tintColorClassName={String("accent-icon")}
675 }677 />
676 renderItem={renderListItem}678 }
677 showsVerticalScrollIndicator={false}679 renderItem={renderListItem}
678 />680 showsVerticalScrollIndicator={false}
679 </GestureDetector>681 />
682 </GestureDetector>
683 </SettingsScreenContent>
680 </View>684 </View>
681 );685 );
682}686}
apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx+2-1
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
1import { NativeHeaderToolbar } from "../../native/StackHeader";2import { NativeHeaderToolbar } from "../../native/StackHeader";
2import { useAuth } from "@clerk/expo";3import { useAuth } from "@clerk/expo";
3import { StackActions, useNavigation } from "@react-navigation/native";4import { StackActions, useNavigation } from "@react-navigation/native";
4import { useCallback, useEffect, useState } from "react";5import { useCallback, useEffect, useState } from "react";
5import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";6import { Platform, Pressable, RefreshControl, View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";7import { useSafeAreaInsets } from "react-native-safe-area-context";
78
8import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime";9import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime";
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+70-38
...@@ -6,11 +6,13 @@ import { useAtomValue } from "@effect/atom-react";...@@ -6,11 +6,13 @@ import { useAtomValue } from "@effect/atom-react";
6import * as Cause from "effect/Cause";6import * as Cause from "effect/Cause";
7import { AsyncResult } from "effect/unstable/reactivity";7import { AsyncResult } from "effect/unstable/reactivity";
8import { useCallback, useState } from "react";8import { useCallback, useState } from "react";
9import { Alert, Pressable, View } from "react-native";9import { Platform, Alert, Pressable, View } from "react-native";
10import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";10import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
1111
12import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";12import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
13import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";13import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
14import { MaterialButton } from "../../components/MaterialButton";
15import { MaterialIconButton } from "../../components/MaterialIconButton";
14import { ThemedSwitch } from "../../components/ThemedSwitch";16import { ThemedSwitch } from "../../components/ThemedSwitch";
15import { cn } from "../../lib/cn";17import { cn } from "../../lib/cn";
16import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";18import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
...@@ -142,7 +144,7 @@ export function ConnectionEnvironmentRow(props: {...@@ -142,7 +144,7 @@ export function ConnectionEnvironmentRow(props: {
142 <SymbolView144 <SymbolView
143 name="chevron.down"145 name="chevron.down"
144 size={12}146 size={12}
145 tintColorClassName={"accent-icon-subtle"}147 tintColorClassName="accent-icon-subtle"
146 type="monochrome"148 type="monochrome"
147 style={{149 style={{
148 transform: [{ rotate: props.expanded ? "180deg" : "0deg" }],150 transform: [{ rotate: props.expanded ? "180deg" : "0deg" }],
...@@ -193,49 +195,79 @@ export function ConnectionEnvironmentRow(props: {...@@ -193,49 +195,79 @@ export function ConnectionEnvironmentRow(props: {
193 </>195 </>
194 )}196 )}
195197
196 <View className="flex-row justify-end gap-2">198 {Platform.OS === "android" ? (
197 {props.environment.isRelayManaged ? null : (199 <View className="flex-row items-center justify-end gap-2">
200 {props.environment.isRelayManaged ? null : (
201 <View className="flex-1">
202 <MaterialButton
203 label="Save"
204 tone="primary"
205 fullWidth
206 onPress={() => {
207 void handleSave();
208 }}
209 />
210 </View>
211 )}
212 <MaterialIconButton
213 accessibilityLabel="Reconnect environment"
214 icon="arrow.clockwise"
215 variant="tonal"
216 disabled={!enabled}
217 onPress={() => props.onReconnect(props.environment.environmentId)}
218 />
219 <MaterialIconButton
220 accessibilityLabel="Remove environment"
221 icon="trash"
222 variant="danger"
223 onPress={() => props.onRemove(props.environment.environmentId)}
224 />
225 </View>
226 ) : (
227 <View className="flex-row justify-end gap-2">
228 {props.environment.isRelayManaged ? null : (
229 <Pressable
230 className="min-h-[42px] flex-1 flex-row items-center justify-center gap-1.5 rounded-[14px] bg-primary px-3.5 py-2.5 active:opacity-70"
231 onPress={handleSave}
232 >
233 <SymbolView
234 name="checkmark"
235 size={13}
236 tintColorClassName="accent-primary-foreground"
237 type="monochrome"
238 />
239 <Text className="text-xs font-t3-bold tracking-[0.8px] uppercase text-primary-foreground">
240 Save
241 </Text>
242 </Pressable>
243 )}
244
198 <Pressable245 <Pressable
199 className="min-h-[42px] flex-1 flex-row items-center justify-center gap-1.5 rounded-[14px] bg-primary px-3.5 py-2.5 active:opacity-70"246 className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-input-border bg-input active:opacity-70 disabled:opacity-40"
200 onPress={handleSave}247 disabled={!enabled}
248 onPress={() => props.onReconnect(props.environment.environmentId)}
201 >249 >
202 <SymbolView250 <SymbolView
203 name="checkmark"251 name="arrow.clockwise"
204 size={13}252 size={14}
205 tintColorClassName={"accent-primary-foreground"}253 tintColorClassName="accent-icon-subtle"
206 type="monochrome"254 type="monochrome"
207 />255 />
208 <Text className="text-xs font-t3-bold tracking-[0.8px] uppercase text-primary-foreground">
209 Save
210 </Text>
211 </Pressable>256 </Pressable>
212 )}
213
214 <Pressable
215 className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-input-border bg-input active:opacity-70 disabled:opacity-40"
216 disabled={!enabled}
217 onPress={() => props.onReconnect(props.environment.environmentId)}
218 >
219 <SymbolView
220 name="arrow.clockwise"
221 size={14}
222 tintColorClassName={"accent-icon-subtle"}
223 type="monochrome"
224 />
225 </Pressable>
226257
227 <Pressable258 <Pressable
228 className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-danger-border bg-danger active:opacity-70"259 className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-danger-border bg-danger active:opacity-70"
229 onPress={() => props.onRemove(props.environment.environmentId)}260 onPress={() => props.onRemove(props.environment.environmentId)}
230 >261 >
231 <SymbolView262 <SymbolView
232 name="trash"263 name="trash"
233 size={14}264 size={14}
234 tintColorClassName={"accent-danger-foreground"}265 tintColorClassName="accent-danger-foreground"
235 type="monochrome"266 type="monochrome"
236 />267 />
237 </Pressable>268 </Pressable>
238 </View>269 </View>
270 )}
239 </Animated.View>271 </Animated.View>
240 ) : null}272 ) : null}
241 </Animated.View>273 </Animated.View>
apps/mobile/src/features/connection/ConnectionSheetButton.tsx+12
...@@ -3,6 +3,7 @@ import { Platform, Pressable } from "react-native";...@@ -3,6 +3,7 @@ import { Platform, Pressable } from "react-native";
33
4import { AppText as Text } from "../../components/AppText";4import { AppText as Text } from "../../components/AppText";
5import { cn } from "../../lib/cn";5import { cn } from "../../lib/cn";
6import { MaterialButton } from "../../components/MaterialButton";
67
7const CARD_SHADOW = Platform.select({8const CARD_SHADOW = Platform.select({
8 ios: {9 ios: {
...@@ -32,8 +33,19 @@ export function ConnectionSheetButton(props: {...@@ -32,8 +33,19 @@ export function ConnectionSheetButton(props: {
32 readonly disabled?: boolean;33 readonly disabled?: boolean;
33 readonly tone?: "primary" | "secondary" | "danger";34 readonly tone?: "primary" | "secondary" | "danger";
34 readonly compact?: boolean;35 readonly compact?: boolean;
36 readonly fullWidth?: boolean;
35 readonly onPress: () => void;37 readonly onPress: () => void;
36}) {38}) {
39 if (Platform.OS === "android")
40 return (
41 <MaterialButton
42 label={props.label}
43 disabled={props.disabled}
44 onPress={props.onPress}
45 tone={props.tone}
46 fullWidth={props.fullWidth}
47 />
48 );
37 const tone = props.tone ?? "secondary";49 const tone = props.tone ?? "secondary";
3850
39 const textColorClassName =51 const textColorClassName =
apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx+42-40
...@@ -1,13 +1,18 @@...@@ -1,13 +1,18 @@
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
1import { CameraView, useCameraPermissions } from "expo-camera";2import { CameraView, useCameraPermissions } from "expo-camera";
2import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";3import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
3import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";4import {
5 StackActions,
6 useNavigation,
7 useRoute,
8 type StaticScreenProps,
9} from "@react-navigation/native";
4import { AsyncResult } from "effect/unstable/reactivity";10import { AsyncResult } from "effect/unstable/reactivity";
5import { useCallback, useEffect, useRef, useState } from "react";11import { useCallback, useEffect, useRef, useState } from "react";
6import { Alert, Linking, Platform, ScrollView, View } from "react-native";12import { Alert, Linking, Platform, View } from "react-native";
7import { useSafeAreaInsets } from "react-native-safe-area-context";13import { useSafeAreaInsets } from "react-native-safe-area-context";
8import { useUniwindTheme } from "../../lib/useUniwindTheme";14import { useUniwindTheme } from "../../lib/useUniwindTheme";
915import { SettingsScreen } from "../settings/components/SettingsScreen";
10import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
11import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";16import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
12import { ErrorBanner } from "../../components/ErrorBanner";17import { ErrorBanner } from "../../components/ErrorBanner";
13import { ConnectionSheetButton } from "./ConnectionSheetButton";18import { ConnectionSheetButton } from "./ConnectionSheetButton";
...@@ -30,6 +35,7 @@ export function ConnectionsNewRouteScreen({...@@ -30,6 +35,7 @@ export function ConnectionsNewRouteScreen({
30 pairingConnectionError,35 pairingConnectionError,
31 } = useRemoteConnections();36 } = useRemoteConnections();
32 const navigation = useNavigation();37 const navigation = useNavigation();
38 const routeName = useRoute().name;
33 const params = route.params ?? {};39 const params = route.params ?? {};
34 // Deep-link prefill exists for development automation only. A production40 // Deep-link prefill exists for development automation only. A production
35 // link must not arrive with attacker-chosen host and token already filled.41 // link must not arrive with attacker-chosen host and token already filled.
...@@ -181,33 +187,27 @@ export function ConnectionsNewRouteScreen({...@@ -181,33 +187,27 @@ export function ConnectionsNewRouteScreen({
181 }, [connectAndClose, routePairingUrl, shouldAutoConnect]);187 }, [connectAndClose, routePairingUrl, shouldAutoConnect]);
182188
183 return (189 return (
184 <View collapsable={false} className="flex-1 bg-sheet">190 <SettingsScreen
191 formSheet={routeName === "ConnectionsNew"}
192 title={showScanner ? "Scan QR Code" : "Add Environment"}
193 actions={[
194 {
195 accessibilityLabel: showScanner ? "Close scanner" : "Scan QR code",
196 icon: showScanner ? "xmark" : "camera",
197 onPress: () => {
198 if (showScanner) {
199 closeScanner();
200 } else {
201 void openScanner();
202 }
203 },
204 },
205 ]}
206 >
185 <NativeStackScreenOptions207 <NativeStackScreenOptions
186 options={{208 options={{ title: showScanner ? "Scan QR Code" : "Add Environment" }}
187 // Android renders its own in-screen header below instead of the native bar.
188 ...(Platform.OS === "android" ? { headerShown: false } : null),
189 title: showScanner ? "Scan QR Code" : "Add Environment",
190 }}
191 />209 />
192 {Platform.OS === "android" ? (210 {Platform.OS !== "android" ? (
193 <AndroidScreenHeader
194 title={showScanner ? "Scan QR Code" : "Add Environment"}
195 onBack={() => navigation.goBack()}
196 actions={[
197 {
198 accessibilityLabel: showScanner ? "Close scanner" : "Scan QR code",
199 icon: showScanner ? "xmark" : "camera",
200 onPress: () => {
201 if (showScanner) {
202 closeScanner();
203 } else {
204 void openScanner();
205 }
206 },
207 },
208 ]}
209 />
210 ) : (
211 <NativeHeaderToolbar placement="right">211 <NativeHeaderToolbar placement="right">
212 <NativeHeaderToolbar.Button212 <NativeHeaderToolbar.Button
213 icon={showScanner ? "xmark" : "qrcode.viewfinder"}213 icon={showScanner ? "xmark" : "qrcode.viewfinder"}
...@@ -222,7 +222,7 @@ export function ConnectionsNewRouteScreen({...@@ -222,7 +222,7 @@ export function ConnectionsNewRouteScreen({
222 tintColor={headerIconColor}222 tintColor={headerIconColor}
223 />223 />
224 </NativeHeaderToolbar>224 </NativeHeaderToolbar>
225 )}225 ) : null}
226226
227 <ScrollView227 <ScrollView
228 contentInsetAdjustmentBehavior="automatic"228 contentInsetAdjustmentBehavior="automatic"
...@@ -293,19 +293,21 @@ export function ConnectionsNewRouteScreen({...@@ -293,19 +293,21 @@ export function ConnectionsNewRouteScreen({
293293
294 {pairingConnectionError ? <ErrorBanner message={pairingConnectionError} /> : null}294 {pairingConnectionError ? <ErrorBanner message={pairingConnectionError} /> : null}
295295
296 <ConnectionSheetButton296 <View className={Platform.OS === "android" ? "flex-row justify-end" : undefined}>
297 icon="plus"297 <ConnectionSheetButton
298 label={isSubmitting ? "Pairing..." : "Add environment"}298 icon="plus"
299 disabled={connectDisabled}299 label={isSubmitting ? "Pairing..." : "Add environment"}
300 tone="primary"300 disabled={connectDisabled}
301 onPress={() => {301 tone="primary"
302 void handleSubmit();302 onPress={() => {
303 }}303 void handleSubmit();
304 />304 }}
305 />
306 </View>
305 </View>307 </View>
306 )}308 )}
307 </View>309 </View>
308 </ScrollView>310 </ScrollView>
309 </View>311 </SettingsScreen>
310 );312 );
311}313}
apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx+2-1
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
1import { NativeHeaderToolbar } from "../../native/StackHeader";2import { NativeHeaderToolbar } from "../../native/StackHeader";
2import { useNavigation } from "@react-navigation/native";3import { useNavigation } from "@react-navigation/native";
3import { SymbolView } from "../../components/AppSymbol";4import { SymbolView } from "../../components/AppSymbol";
4import type { EnvironmentId } from "@t3tools/contracts";5import type { EnvironmentId } from "@t3tools/contracts";
5import { useCallback, useState } from "react";6import { useCallback, useState } from "react";
6import { Platform, ScrollView, View } from "react-native";7import { Platform, View } from "react-native";
7import { useSafeAreaInsets } from "react-native-safe-area-context";8import { useSafeAreaInsets } from "react-native-safe-area-context";
89
9import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";10import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx+7-5
...@@ -1,12 +1,14 @@...@@ -1,12 +1,14 @@
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
1import Constants from "expo-constants";2import Constants from "expo-constants";
2import * as Updates from "expo-updates";3import * as Updates from "expo-updates";
3import { useEffect, useState } from "react";4import { useEffect, useState } from "react";
4import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native";5import { ActivityIndicator, Platform, Pressable, View } from "react-native";
5import { useSafeAreaInsets } from "react-native-safe-area-context";6import { useSafeAreaInsets } from "react-native-safe-area-context";
67
7import { SymbolView } from "../../components/AppSymbol";8import { SymbolView } from "../../components/AppSymbol";
8import { AppText as Text } from "../../components/AppText";9import { AppText as Text } from "../../components/AppText";
9import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";10import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
11import { SettingsScreen } from "../settings/components/SettingsScreen";
10import { SettingsSection } from "../settings/components/SettingsSection";12import { SettingsSection } from "../settings/components/SettingsSection";
11import {13import {
12 formatStartupCrashReport,14 formatStartupCrashReport,
...@@ -71,7 +73,7 @@ export function SettingsDiagnosticsRouteScreen() {...@@ -71,7 +73,7 @@ export function SettingsDiagnosticsRouteScreen() {
71 };73 };
7274
73 return (75 return (
74 <View collapsable={false} className="flex-1 bg-sheet">76 <SettingsScreen title="Diagnostics">
75 <ScrollView77 <ScrollView
76 contentInsetAdjustmentBehavior="automatic"78 contentInsetAdjustmentBehavior="automatic"
77 contentInset={{ bottom: Math.max(insets.bottom, 18) }}79 contentInset={{ bottom: Math.max(insets.bottom, 18) }}
...@@ -115,7 +117,7 @@ export function SettingsDiagnosticsRouteScreen() {...@@ -115,7 +117,7 @@ export function SettingsDiagnosticsRouteScreen() {
115 <SymbolView117 <SymbolView
116 name={copied ? "checkmark" : "doc.on.doc"}118 name={copied ? "checkmark" : "doc.on.doc"}
117 size={22}119 size={22}
118 tintColorClassName={"accent-icon"}120 tintColorClassName="accent-icon"
119 type="monochrome"121 type="monochrome"
120 weight="regular"122 weight="regular"
121 />123 />
...@@ -131,7 +133,7 @@ export function SettingsDiagnosticsRouteScreen() {...@@ -131,7 +133,7 @@ export function SettingsDiagnosticsRouteScreen() {
131 </Text>133 </Text>
132 </View>134 </View>
133 </ScrollView>135 </ScrollView>
134 </View>136 </SettingsScreen>
135 );137 );
136}138}
137139
...@@ -145,7 +147,7 @@ function EmptyState(props: {...@@ -145,7 +147,7 @@ function EmptyState(props: {
145 <SymbolView147 <SymbolView
146 name={props.icon}148 name={props.icon}
147 size={28}149 size={28}
148 tintColorClassName={"accent-icon"}150 tintColorClassName="accent-icon"
149 type="monochrome"151 type="monochrome"
150 weight="regular"152 weight="regular"
151 />153 />
apps/mobile/src/features/files/MaterialFilesHeader.tsx created+122
...@@ -0,0 +1,122 @@
1import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
2import { BackHandler, Keyboard, Pressable, TextInput, View } from "react-native";
3import { useSafeAreaInsets } from "react-native-safe-area-context";
4
5import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
6import { SymbolView } from "../../components/AppSymbol";
7
8/** Keep Files search in the same header row on compact and expanded layouts. */
9export function MaterialFilesHeader(props: {
10 readonly projectName: string;
11 readonly searchQuery: string;
12 readonly onSearchQueryChange: (query: string) => void;
13 readonly onRefresh: () => void;
14 readonly onBack?: () => void;
15 readonly leading?: ReactNode;
16}) {
17 const insets = useSafeAreaInsets();
18 const searchRef = useRef<TextInput>(null);
19 const [searchOpen, setSearchOpen] = useState(false);
20 const searching = searchOpen || props.searchQuery.length > 0;
21 const { onSearchQueryChange } = props;
22 const closeSearch = useCallback(() => {
23 onSearchQueryChange("");
24 setSearchOpen(false);
25 Keyboard.dismiss();
26 }, [onSearchQueryChange]);
27
28 useEffect(() => {
29 if (!searching) return;
30 const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
31 closeSearch();
32 return true;
33 });
34 return () => subscription.remove();
35 }, [closeSearch, searching]);
36
37 return (
38 <View>
39 {/* Keep the title/subtitle's natural height, including larger text, while searching. */}
40 <View
41 pointerEvents={searching ? "none" : "auto"}
42 accessibilityElementsHidden={searching}
43 importantForAccessibility={searching ? "no-hide-descendants" : "auto"}
44 style={{ opacity: searching ? 0 : 1 }}
45 >
46 <AndroidScreenHeader
47 title="Files"
48 subtitle={props.projectName}
49 hideBottomBorder
50 onBack={props.onBack}
51 leading={props.leading}
52 actions={[
53 {
54 accessibilityLabel: "Search files",
55 icon: "magnifyingglass",
56 onPress: () => setSearchOpen(true),
57 },
58 {
59 accessibilityLabel: "Refresh files",
60 icon: "arrow.clockwise",
61 onPress: props.onRefresh,
62 },
63 ]}
64 />
65 </View>
66 {searching ? (
67 <View
68 className="absolute inset-0 bg-header px-2 pb-2"
69 style={{ paddingTop: Math.max(insets.top, 12) }}
70 >
71 <View className="min-h-14 flex-1 flex-row items-center gap-1">
72 <AndroidHeaderIconButton
73 accessibilityLabel="Close file search"
74 icon="arrow.left"
75 onPress={closeSearch}
76 />
77 <View className="h-12 min-w-0 flex-1 flex-row items-center gap-2 rounded-full border border-input-border bg-input px-3">
78 <SymbolView
79 name="magnifyingglass"
80 size={18}
81 tintColorClassName="accent-foreground-muted"
82 />
83 <TextInput
84 ref={searchRef}
85 accessibilityLabel="Search files"
86 autoFocus
87 autoCapitalize="none"
88 autoCorrect={false}
89 returnKeyType="search"
90 placeholder="Search files"
91 placeholderTextColorClassName="accent-placeholder"
92 selectionColorClassName="accent-primary/32"
93 cursorColorClassName="accent-primary"
94 selectionHandleColorClassName="accent-primary"
95 className="min-w-0 flex-1 py-2 font-sans text-base text-foreground"
96 value={props.searchQuery}
97 onChangeText={onSearchQueryChange}
98 />
99 {props.searchQuery.length > 0 ? (
100 <Pressable
101 accessibilityLabel="Clear file search"
102 accessibilityRole="button"
103 hitSlop={10}
104 onPress={() => {
105 onSearchQueryChange("");
106 searchRef.current?.focus();
107 }}
108 >
109 <SymbolView
110 name="xmark.circle.fill"
111 size={18}
112 tintColorClassName="accent-foreground-muted"
113 />
114 </Pressable>
115 ) : null}
116 </View>
117 </View>
118 </View>
119 ) : null}
120 </View>
121 );
122}
apps/mobile/src/features/files/SourceFileSurface.tsx+6
...@@ -4,6 +4,7 @@ import type { ComponentType } from "react";...@@ -4,6 +4,7 @@ import type { ComponentType } from "react";
4import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";4import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
5import {5import {
6 FlatList,6 FlatList,
7 Platform,
7 RefreshControl,8 RefreshControl,
8 ScrollView,9 ScrollView,
9 Text as NativeText,10 Text as NativeText,
...@@ -73,6 +74,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {...@@ -73,6 +74,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {
73 </NativeText>74 </NativeText>
74 <NativeText75 <NativeText
75 selectable76 selectable
77 selectionColorClassName={Platform.OS === "android" ? "accent-primary/32" : undefined}
76 numberOfLines={props.wordBreak ? undefined : 1}78 numberOfLines={props.wordBreak ? undefined : 1}
77 className="flex-1 font-normal text-foreground"79 className="flex-1 font-normal text-foreground"
78 style={{80 style={{
...@@ -102,6 +104,9 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {...@@ -102,6 +104,9 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {
102 <NativeText104 <NativeText
103 key={`${start}:${token.content.length}:${token.color ?? ""}`}105 key={`${start}:${token.content.length}:${token.color ?? ""}`}
104 selectable106 selectable
107 selectionColorClassName={
108 Platform.OS === "android" ? "accent-primary/32" : undefined
109 }
105 style={{110 style={{
106 color: token.color ?? undefined,111 color: token.color ?? undefined,
107 fontFamily: REVIEW_MONO_FONT_FAMILY,112 fontFamily: REVIEW_MONO_FONT_FAMILY,
...@@ -274,6 +279,7 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {...@@ -274,6 +279,7 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
274 <MarkdownTextPrimitive279 <MarkdownTextPrimitive
275 uiTextView280 uiTextView
276 selectable281 selectable
282 selectionColorClassName={Platform.OS === "android" ? "accent-primary/32" : undefined}
277 style={{283 style={{
278 color: foreground,284 color: foreground,
279 fontFamily: REVIEW_MONO_FONT_FAMILY,285 fontFamily: REVIEW_MONO_FONT_FAMILY,
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+62-82
...@@ -15,8 +15,8 @@ import {...@@ -15,8 +15,8 @@ import {
15import { mediaFileReference } from "@t3tools/client-runtime/media-reference";15import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
1616
17import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";17import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
18import { SymbolView } from "../../components/AppSymbol";18import { MaterialScreenContent } from "../../components/MaterialScreenContent";
19import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";19import { AppText as Text } from "../../components/AppText";
20import { AudioFilePreview } from "../../components/AudioFilePreview";20import { AudioFilePreview } from "../../components/AudioFilePreview";
21import { ControlPillMenu } from "../../components/ControlPill";21import { ControlPillMenu } from "../../components/ControlPill";
22import { EmptyState } from "../../components/EmptyState";22import { EmptyState } from "../../components/EmptyState";
...@@ -43,7 +43,10 @@ import {...@@ -43,7 +43,10 @@ import {
43 createNativeMailSearchToolbarItem,43 createNativeMailSearchToolbarItem,
44 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,44 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
45} from "../layout/native-mail-search-toolbar";45} from "../layout/native-mail-search-toolbar";
46import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";46import {
47 AndroidWorkspaceSidebarButton,
48 WorkspaceSidebarToolbar,
49} from "../layout/workspace-sidebar-toolbar";
47import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";50import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
48import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";51import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
49import { FileMarkdownPreview } from "./FileMarkdownPreview";52import { FileMarkdownPreview } from "./FileMarkdownPreview";
...@@ -52,6 +55,7 @@ import { useFileTreeEntries } from "./useFileTreeEntries";...@@ -52,6 +55,7 @@ import { useFileTreeEntries } from "./useFileTreeEntries";
52import { preloadWorkspaceFileContents } from "./preload-workspace-file";55import { preloadWorkspaceFileContents } from "./preload-workspace-file";
53import { SourceFileSurface } from "./SourceFileSurface";56import { SourceFileSurface } from "./SourceFileSurface";
54import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";57import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";
58import { MaterialFilesHeader } from "./MaterialFilesHeader";
55import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";59import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";
56import { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError";60import { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError";
57import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview";61import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview";
...@@ -328,10 +332,9 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {...@@ -328,10 +332,9 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
328 useAdaptiveWorkspaceLayout();332 useAdaptiveWorkspaceLayout();
329 const [searchQuery, setSearchQuery] = useState("");333 const [searchQuery, setSearchQuery] = useState("");
330 const isAndroid = Platform.OS === "android";334 const isAndroid = Platform.OS === "android";
331 const { themeAppearance: highlightTheme, materialYouStyleLayoutActive } =335 const { themeAppearance: highlightTheme } = useAppearancePreferences();
332 useAppearancePreferences();
333 const theme = useUniwindTheme();336 const theme = useUniwindTheme();
334 const screenColor = theme["--color-screen"];337 const headerColor = theme["--color-header"];
335 const sheetSurfaceColor = theme["--color-sheet-solid"];338 const sheetSurfaceColor = theme["--color-sheet-solid"];
336 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(339 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
337 props.route.params,340 props.route.params,
...@@ -451,7 +454,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {...@@ -451,7 +454,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
451 <NativeStackScreenOptions454 <NativeStackScreenOptions
452 options={{455 options={{
453 contentStyle: {456 contentStyle: {
454 backgroundColor: materialYouStyleLayoutActive ? screenColor : sheetSurfaceColor,457 backgroundColor: Platform.OS === "android" ? headerColor : sheetSurfaceColor,
455 },458 },
456 headerShown: !isAndroid,459 headerShown: !isAndroid,
457 unstable_headerSubtitle:460 unstable_headerSubtitle:
...@@ -484,46 +487,16 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {...@@ -484,46 +487,16 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
484 />487 />
485 {isAndroid ? (488 {isAndroid ? (
486 <>489 <>
487 <AndroidScreenHeader490 {
488 title="Files"491 <MaterialFilesHeader
489 subtitle={projectName}492 projectName={projectName}
490 onBack={handleReturnToThread}493 leading={<AndroidWorkspaceSidebarButton />}
491 hideBottomBorder={materialYouStyleLayoutActive}494 searchQuery={searchQuery}
492 actions={[495 onSearchQueryChange={setSearchQuery}
493 {496 onRefresh={entriesQuery.refresh}
494 accessibilityLabel: "Refresh files",497 onBack={handleReturnToThread}
495 icon: "arrow.clockwise",
496 onPress: entriesQuery.refresh,
497 },
498 ]}
499 />
500 <View
501 className={
502 materialYouStyleLayoutActive
503 ? "mx-4 my-2 min-h-12 flex-row items-center gap-2 rounded-full border border-input-border bg-input px-3.5"
504 : "flex-row items-center gap-2 border-b border-border px-3 py-2"
505 }
506 >
507 <SymbolView
508 name="magnifyingglass"
509 size={17}
510 tintColorClassName={"accent-icon-muted"}
511 type="monochrome"
512 />498 />
513 <TextInput499 }
514 accessibilityLabel="Search files"
515 autoCapitalize="none"
516 autoCorrect={false}
517 className={
518 materialYouStyleLayoutActive
519 ? "min-h-10 flex-1 py-2 text-sm text-foreground"
520 : "min-h-10 flex-1 rounded-xl py-2 text-sm"
521 }
522 placeholder="Search files"
523 value={searchQuery}
524 onChangeText={setSearchQuery}
525 />
526 </View>
527 </>500 </>
528 ) : (501 ) : (
529 <>502 <>
...@@ -548,26 +521,28 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {...@@ -548,26 +521,28 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
548 )}521 )}
549 </>522 </>
550 )}523 )}
551 <FileTreeBrowser524 <MaterialScreenContent insetHorizontal={layout.usesSplitView}>
552 key={JSON.stringify([environmentId, cwd])}525 <FileTreeBrowser
553 entries={entriesQuery.entries}526 key={JSON.stringify([environmentId, cwd])}
554 loadedDirectories={entriesQuery.loadedDirectories}527 entries={entriesQuery.entries}
555 onLoadDirectory={entriesQuery.loadDirectory}528 loadedDirectories={entriesQuery.loadedDirectories}
556 error={entriesQuery.error}529 onLoadDirectory={entriesQuery.loadDirectory}
557 isPending={entriesQuery.isPending}530 error={entriesQuery.error}
558 searchQuery={searchQuery}531 isPending={entriesQuery.isPending}
559 searchTruncated={entriesQuery.searchTruncated}532 searchQuery={searchQuery}
560 selectedPath={null}533 searchTruncated={entriesQuery.searchTruncated}
561 onPreviewFile={handlePreviewFile}534 selectedPath={null}
562 onRefresh={entriesQuery.refresh}535 onPreviewFile={handlePreviewFile}
563 onSelectFile={handleSelectFile}536 onRefresh={entriesQuery.refresh}
564 />537 onSelectFile={handleSelectFile}
565 <FilesToolbarBottomFade />538 />
539 <FilesToolbarBottomFade />
540 </MaterialScreenContent>
566 </>541 </>
567 );542 );
568543
569 return materialYouStyleLayoutActive ? (544 return Platform.OS === "android" ? (
570 <View className="flex-1" style={{ backgroundColor: screenColor }}>545 <View className="flex-1" style={{ backgroundColor: headerColor }}>
571 {content}546 {content}
572 </View>547 </View>
573 ) : (548 ) : (
...@@ -933,6 +908,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -933,6 +908,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
933 <AndroidScreenHeader908 <AndroidScreenHeader
934 title={basename(relativePath)}909 title={basename(relativePath)}
935 subtitle={headerSubtitle}910 subtitle={headerSubtitle}
911 leading={<AndroidWorkspaceSidebarButton />}
912 hideBottomBorder
936 onBack={handleBack}913 onBack={handleBack}
937 trailing={914 trailing={
938 <>915 <>
...@@ -942,6 +919,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -942,6 +919,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
942 panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator"919 panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator"
943 }920 }
944 icon="sidebar.right"921 icon="sidebar.right"
922 selected={panes.auxiliaryPaneVisible}
945 onPress={toggleAuxiliaryPane}923 onPress={toggleAuxiliaryPane}
946 />924 />
947 ) : null}925 ) : null}
...@@ -1007,25 +985,27 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -1007,25 +985,27 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
1007 ))}985 ))}
1008 </NativeHeaderToolbar.Menu>986 </NativeHeaderToolbar.Menu>
1009 </NativeHeaderToolbar>987 </NativeHeaderToolbar>
1010 <FileContent988 <MaterialScreenContent>
1011 key={previewKey}989 <FileContent
1012 activeMode={resolvedActiveMode}990 key={previewKey}
1013 cwd={cwd}991 activeMode={resolvedActiveMode}
1014 environmentId={environmentId}992 cwd={cwd}
1015 previewUri={previewUri}993 environmentId={environmentId}
1016 previewFailure={assetPreview._tag === "Failure" ? assetPreview.reason : null}994 previewUri={previewUri}
1017 onRetryPreview={handleRetryPreview}995 previewFailure={assetPreview._tag === "Failure" ? assetPreview.reason : null}
1018 videoSource={videoSource}996 onRetryPreview={handleRetryPreview}
1019 mediaSource={mediaSource}997 videoSource={videoSource}
1020 resolveVideoUri={assetPreview.refresh}998 mediaSource={mediaSource}
1021 fileContents={fileData?.contents ?? null}999 resolveVideoUri={assetPreview.refresh}
1022 fileError={fileQuery.error}1000 fileContents={fileData?.contents ?? null}
1023 initialLine={targetLine}1001 fileError={fileQuery.error}
1024 relativePath={relativePath}1002 initialLine={targetLine}
1025 threadId={threadId}1003 relativePath={relativePath}
1026 truncated={fileData?.truncated ?? false}1004 threadId={threadId}
1027 onRefresh={() => fileQuery.refresh()}1005 truncated={fileData?.truncated ?? false}
1028 />1006 onRefresh={() => fileQuery.refresh()}
1007 />
1008 </MaterialScreenContent>
1029 <FilePreviewModal1009 <FilePreviewModal
1030 source={fullScreenPreview}1010 source={fullScreenPreview}
1031 onRequestClose={() => setFullScreenPreview(null)}1011 onRequestClose={() => setFullScreenPreview(null)}
apps/mobile/src/features/files/thread-file-navigator-pane.tsx+59-48
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import type { EnvironmentId } from "@t3tools/contracts";1import type { EnvironmentId } from "@t3tools/contracts";
2import { SymbolView } from "../../components/AppSymbol";2import { SymbolView } from "../../components/AppSymbol";
3import { MaterialScreenContent } from "../../components/MaterialScreenContent";
3import { useCallback, useMemo, useState, type ComponentProps } from "react";4import { useCallback, useMemo, useState, type ComponentProps } from "react";
4import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native";5import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native";
5import {6import {
...@@ -11,6 +12,7 @@ import {...@@ -11,6 +12,7 @@ import {
11} from "react-native-screens";12} from "react-native-screens";
1213
13import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";14import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
15import { MaterialFilesHeader } from "./MaterialFilesHeader";
14import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";16import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
15import { useUniwindTheme } from "../../lib/useUniwindTheme";17import { useUniwindTheme } from "../../lib/useUniwindTheme";
16import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";18import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
...@@ -149,59 +151,68 @@ export function ThreadFileNavigatorPane(props: {...@@ -149,59 +151,68 @@ export function ThreadFileNavigatorPane(props: {
149 }151 }
150152
151 return (153 return (
152 <View className="flex-1 border-l border-border bg-sheet">154 <View
153 <View className="border-b border-border" style={{ paddingTop: props.headerInset }}>155 className={
154 <View className="h-12 flex-row items-center gap-2 px-3">156 Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 border-l border-border bg-sheet"
155 <View className="min-w-0 flex-1">157 }
156 <Text className="text-sm font-t3-bold text-foreground">Files</Text>158 >
157 <Text className="text-xs text-foreground-muted" numberOfLines={1}>159 <View
158 {props.projectName}160 className={Platform.OS === "android" ? "bg-header" : "border-b border-border"}
159 </Text>161 style={{ paddingTop: Platform.OS === "android" ? 0 : props.headerInset }}
162 >
163 {Platform.OS === "android" ? (
164 <MaterialFilesHeader
165 projectName={props.projectName}
166 searchQuery={searchQuery}
167 onSearchQueryChange={setSearchQuery}
168 onRefresh={entriesQuery.refresh}
169 />
170 ) : (
171 <View className="h-12 flex-row items-center gap-2 px-3">
172 <View className="min-w-0 flex-1">
173 <Text className="text-sm font-t3-bold text-foreground">Files</Text>
174 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
175 {props.projectName}
176 </Text>
177 </View>
178 <Pressable
179 accessibilityRole="button"
180 accessibilityLabel="Refresh files"
181 hitSlop={8}
182 className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"
183 onPress={entriesQuery.refresh}
184 >
185 <SymbolView
186 name="arrow.clockwise"
187 size={14}
188 tintColorClassName="accent-icon-muted"
189 type="monochrome"
190 />
191 </Pressable>
160 </View>192 </View>
161 <Pressable193 )}
162 accessibilityRole="button"194 {Platform.OS !== "android" ? (
163 accessibilityLabel="Refresh files"195 <View className="flex-row items-center gap-2 border-t border-border px-3 py-2">
164 hitSlop={8}
165 className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"
166 onPress={entriesQuery.refresh}
167 >
168 <SymbolView196 <SymbolView
169 name="arrow.clockwise"197 name="magnifyingglass"
170 size={14}198 size={15}
171 tintColorClassName={"accent-icon-muted"}199 tintColorClassName="accent-icon-muted"
172 type="monochrome"200 type="monochrome"
173 />201 />
174 </Pressable>202 <TextInput
175 <Pressable203 accessibilityLabel="Search files"
176 accessibilityRole="button"204 autoCapitalize="none"
177 accessibilityLabel="Close files"205 autoCorrect={false}
178 hitSlop={8}206 clearButtonMode="while-editing"
179 className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"207 className="min-h-10 flex-1 rounded-xl py-2 text-sm"
180 onPress={toggleAuxiliaryPane}208 placeholder="Search files"
181 >209 value={searchQuery}
182 <SymbolView name="xmark" size={14} tintColorClassName="accent-icon-muted" />210 onChangeText={setSearchQuery}
183 </Pressable>211 />
184 </View>212 </View>
185 <View className="flex-row items-center gap-2 border-t border-border px-3 py-2">213 ) : null}
186 <SymbolView
187 name="magnifyingglass"
188 size={15}
189 tintColorClassName={"accent-icon-muted"}
190 type="monochrome"
191 />
192 <TextInput
193 accessibilityLabel="Search files"
194 autoCapitalize="none"
195 autoCorrect={false}
196 clearButtonMode="while-editing"
197 className="min-h-10 flex-1 rounded-xl py-2 text-sm"
198 placeholder="Search files"
199 value={searchQuery}
200 onChangeText={setSearchQuery}
201 />
202 </View>
203 </View>214 </View>
204 {fileTree}215 <MaterialScreenContent insetHorizontal>{fileTree}</MaterialScreenContent>
205 </View>216 </View>
206 );217 );
207}218}
apps/mobile/src/features/home/AndroidHomeFab.android.tsx created+44
...@@ -0,0 +1,44 @@
1import { useCallback, useRef, useState, type ComponentProps } from "react";
2import { View, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native";
3import { useSafeAreaInsets } from "react-native-safe-area-context";
4import { MaterialNewThreadButton } from "../../components/MaterialNewThreadButton";
5import type { AndroidHomeFabLayout as SharedAndroidHomeFabLayout } from "./AndroidHomeFab.shared";
6import { useWorkspaceState } from "../../state/workspace";
7import { MaterialFabScrollContext } from "./MaterialFabScrollContext";
8import { updateMaterialFabScroll } from "./material-fab-scroll";
9
10export function AndroidHomeFabLayout(props: ComponentProps<typeof SharedAndroidHomeFabLayout>) {
11 const insets = useSafeAreaInsets();
12 const { state } = useWorkspaceState();
13 const [expanded, setExpanded] = useState(true);
14 const scrollState = useRef({ anchor: 0, expanded: true });
15 const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
16 const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
17 const next = updateMaterialFabScroll(
18 scrollState.current,
19 contentOffset.y,
20 contentSize.height - layoutMeasurement.height,
21 );
22 if (next.expanded !== scrollState.current.expanded) setExpanded(next.expanded);
23 scrollState.current = next;
24 }, []);
25
26 return (
27 <View className="flex-1">
28 <MaterialFabScrollContext value={onScroll}>{props.children}</MaterialFabScrollContext>
29 {state.hasConnections ? (
30 <MaterialNewThreadButton
31 extended
32 expanded={expanded}
33 onPress={props.onStartNewTask}
34 className="absolute right-5"
35 style={{
36 bottom: props.sidebar
37 ? Math.max(insets.bottom, 12) + 6
38 : Math.max(insets.bottom, 16) + 16,
39 }}
40 />
41 ) : null}
42 </View>
43 );
44}
apps/mobile/src/features/home/AndroidHomeFab.shared.tsx created+10
...@@ -0,0 +1,10 @@
1import type { ReactNode } from "react";
2
3/** Other platforms render the list without Android's floating action button. */
4export function AndroidHomeFabLayout(props: {
5 readonly onStartNewTask: () => void;
6 readonly children: ReactNode;
7 readonly sidebar?: boolean;
8}) {
9 return <>{props.children}</>;
10}
apps/mobile/src/features/home/AndroidHomeFab.tsx+1-48
...@@ -1,48 +1 @@...@@ -1,48 +1 @@
1import type { ReactNode } from "react";1export { AndroidHomeFabLayout } from "./AndroidHomeFab.shared";
2import { Platform, Pressable, View } from "react-native";
3import { useSafeAreaInsets } from "react-native-safe-area-context";
4
5import { SymbolView } from "../../components/AppSymbol";
6
7/**
8 * Android-only wrapper that overlays a bottom-right new-task FAB on a thread
9 * list. Other platforms render children unchanged.
10 */
11export function AndroidHomeFabLayout(props: {
12 readonly onStartNewTask: () => void;
13 readonly children: ReactNode;
14}) {
15 if (Platform.OS !== "android") {
16 return <>{props.children}</>;
17 }
18
19 return <AndroidHomeFab {...props} />;
20}
21
22function AndroidHomeFab(props: {
23 readonly onStartNewTask: () => void;
24 readonly children: ReactNode;
25}) {
26 const insets = useSafeAreaInsets();
27 return (
28 <View className="flex-1">
29 {props.children}
30 <Pressable
31 accessibilityLabel="New task"
32 accessibilityRole="button"
33 onPress={props.onStartNewTask}
34 className="absolute right-5 size-14 items-center justify-center rounded-full bg-primary shadow-lg"
35 style={{
36 bottom: Math.max(insets.bottom, 16) + 16,
37 }}
38 >
39 <SymbolView
40 name="square.and.pencil"
41 size={22}
42 tintColorClassName={"accent-primary-foreground"}
43 type="monochrome"
44 />
45 </Pressable>
46 </View>
47 );
48}
apps/mobile/src/features/home/HomeHeader.tsx+12-127
...@@ -1,18 +1,11 @@...@@ -1,18 +1,11 @@
1import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
2import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts";1import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts";
3import type { MenuAction } from "@react-native-menu/menu";2import type { MenuAction } from "@react-native-menu/menu";
4import Constants from "expo-constants";3
5import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";4import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
6import { useCallback, useMemo, useRef } from "react";5import { useCallback, useMemo, useRef } from "react";
7import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native";6import { Platform } from "react-native";
8import type { SearchBarCommands } from "react-native-screens";7import type { SearchBarCommands } from "react-native-screens";
9import { useSafeAreaInsets } from "react-native-safe-area-context";
108
11import { ControlPillMenu } from "../../components/ControlPill";
12import { SymbolView } from "../../components/AppSymbol";
13import { T3Wordmark } from "../../components/T3Wordmark";
14import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics";
15import { resolveMobileStageLabel } from "../../lib/mobileBranding";
16import { useUniwindTheme } from "../../lib/useUniwindTheme";9import { useUniwindTheme } from "../../lib/useUniwindTheme";
17import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";10import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
18import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";11import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
...@@ -22,7 +15,7 @@ import {...@@ -22,7 +15,7 @@ import {
22 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,15 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
23} from "../layout/native-mail-search-toolbar";16} from "../layout/native-mail-search-toolbar";
24import type { HomeProjectSortOrder } from "./homeThreadList";17import type { HomeProjectSortOrder } from "./homeThreadList";
25import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle";18import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar";
26import {19import {
27 buildHomeListFilterMenu,20 buildHomeListFilterMenu,
28 type HomeListFilterMenuEnvironment,21 type HomeListFilterMenuEnvironment,
...@@ -67,9 +60,6 @@ function checkedMenuState(checked: boolean) {...@@ -67,9 +60,6 @@ function checkedMenuState(checked: boolean) {
67}60}
6861
69function AndroidHomeHeader(props: HomeHeaderProps) {62function AndroidHomeHeader(props: HomeHeaderProps) {
70 const { materialYouStyleLayoutActive } = useAppearancePreferences();
71 const insets = useSafeAreaInsets();
72 const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant);
73 // Thread List v2 lays the list out in fixed creation order, so the63 // Thread List v2 lays the list out in fixed creation order, so the
74 // sort/group filter controls would be silently ignored — hide them and64 // sort/group filter controls would be silently ignored — hide them and
75 // key the "customized" icon state off the environment filter alone.65 // key the "customized" icon state off the environment filter alone.
...@@ -200,120 +190,15 @@ function AndroidHomeHeader(props: HomeHeaderProps) {...@@ -200,120 +190,15 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
200 return (190 return (
201 <>191 <>
202 <NativeStackScreenOptions options={{ headerShown: false }} />192 <NativeStackScreenOptions options={{ headerShown: false }} />
203 <View193 <MaterialThreadListToolbar
204 className={194 searchQuery={props.searchQuery}
205 materialYouStyleLayoutActive195 onSearchQueryChange={props.onSearchQueryChange}
206 ? "bg-header pb-3"196 filterActions={menuActions}
207 : "border-b border-header-border bg-header pb-3"197 filterCustomized={hasCustomListOptions}
208 }198 onFilterAction={handleMenuAction}
209 style={{199 onOpenSettings={props.onOpenSettings}
210 paddingHorizontal: HOME_HORIZONTAL_INSET,200 onOpenEnvironments={props.onOpenEnvironments}
211 paddingTop: Math.max(insets.top, 12),201 />
212 }}
213 >
214 <View className="w-full max-w-[720px] self-center gap-3">
215 <View className="flex-row items-center gap-2.5">
216 {/* Brand slot doubles as the connection status surface: while an
217 environment reconnects, the lockup fades to a status label in
218 place (no layout shift in the list below). */}
219 <WorkspaceConnectionTitle
220 grow
221 onPress={props.onOpenEnvironments}
222 brand={
223 <View className="flex-row items-center gap-2">
224 {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */}
225 <T3Wordmark colorClassName="accent-icon" height={15} />
226 <RNText className="-ml-0.5 text-[21px] font-t3-medium tracking-[-0.5px] text-foreground-muted">
227 Code
228 </RNText>
229 <View className="rounded-full bg-subtle px-2 py-0.75">
230 <RNText className="text-[11px] font-t3-bold tracking-[1.1px] text-foreground-muted uppercase">
231 {stageLabel}
232 </RNText>
233 </View>
234 </View>
235 }
236 />
237
238 <ControlPillMenu
239 actions={menuActions}
240 isAnchoredToRight
241 onPressAction={handleMenuAction}
242 >
243 <Pressable
244 accessibilityLabel="Filter and sort threads"
245 accessibilityRole="button"
246 className="size-11 items-center justify-center rounded-full bg-subtle"
247 >
248 <SymbolView
249 name={
250 hasCustomListOptions
251 ? "line.3.horizontal.decrease.circle.fill"
252 : "line.3.horizontal.decrease.circle"
253 }
254 size={16}
255 tintColorClassName={"accent-icon"}
256 type="monochrome"
257 />
258 </Pressable>
259 </ControlPillMenu>
260 {/* Built identically to the filter button so the two circles
261 match exactly (ControlPill sizes via Tailwind classes and
262 resolves to a different box). */}
263 <Pressable
264 accessibilityLabel="Open settings"
265 accessibilityRole="button"
266 onPress={props.onOpenSettings}
267 className="size-11 items-center justify-center rounded-full bg-subtle"
268 >
269 <SymbolView
270 name="gearshape"
271 size={18}
272 tintColorClassName={"accent-icon"}
273 type="monochrome"
274 />
275 </Pressable>
276 </View>
277
278 <View
279 className={
280 materialYouStyleLayoutActive
281 ? "min-h-12 flex-row items-center gap-2.5 rounded-full border border-input-border bg-input px-3.5"
282 : "min-h-12 flex-row items-center gap-2.5 rounded-2xl border border-input-border bg-input px-3.5"
283 }
284 >
285 <SymbolView
286 name="magnifyingglass"
287 size={17}
288 tintColorClassName={"accent-foreground-muted"}
289 type="monochrome"
290 />
291 <TextInput
292 accessibilityLabel="Search threads"
293 autoCapitalize="none"
294 onChangeText={props.onSearchQueryChange}
295 placeholder="Search threads"
296 placeholderTextColorClassName="accent-placeholder"
297 className="flex-1 py-2.5 text-base font-sans text-foreground"
298 value={props.searchQuery}
299 />
300 {props.searchQuery.length > 0 ? (
301 <Pressable
302 accessibilityLabel="Clear search"
303 hitSlop={10}
304 onPress={() => props.onSearchQueryChange("")}
305 >
306 <SymbolView
307 name="xmark.circle.fill"
308 size={17}
309 tintColorClassName={"accent-foreground-muted"}
310 type="monochrome"
311 />
312 </Pressable>
313 ) : null}
314 </View>
315 </View>
316 </View>
317 </>202 </>
318 );203 );
319}204}
apps/mobile/src/features/home/HomeRouteScreen.tsx+23-3
...@@ -11,7 +11,11 @@ import { useWorkspaceState } from "../../state/workspace";...@@ -11,7 +11,11 @@ import { useWorkspaceState } from "../../state/workspace";
11import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";11import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
12import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";12import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
13import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail";13import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail";
14import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";14import {
15 AndroidWorkspaceSidebarButton,
16 WorkspaceSidebarToolbar,
17} from "../layout/workspace-sidebar-toolbar";
18import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
15import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates";19import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates";
16import { AndroidHomeFabLayout } from "./AndroidHomeFab";20import { AndroidHomeFabLayout } from "./AndroidHomeFab";
17import { HomeScreen } from "./HomeScreen";21import { HomeScreen } from "./HomeScreen";
...@@ -27,7 +31,7 @@ import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle...@@ -27,7 +31,7 @@ import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle
2731
28export function HomeRouteScreen() {32export function HomeRouteScreen() {
29 const { width: windowWidth } = useWindowDimensions();33 const { width: windowWidth } = useWindowDimensions();
30 const { layout } = useAdaptiveWorkspaceLayout();34 const { layout, panes } = useAdaptiveWorkspaceLayout();
31 const projects = useProjects();35 const projects = useProjects();
32 const threads = useThreadShells();36 const threads = useThreadShells();
33 const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();37 const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
...@@ -126,8 +130,24 @@ export function HomeRouteScreen() {...@@ -126,8 +130,24 @@ export function HomeRouteScreen() {
126 />130 />
127 }131 }
128 />132 />
133 {Platform.OS === "android" ? (
134 <AndroidScreenHeader title="Threads" leading={<AndroidWorkspaceSidebarButton />} />
135 ) : null}
129 <WorkspaceEmptyDetail136 <WorkspaceEmptyDetail
130 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}137 onAddConnection={
138 Platform.OS === "android" && !catalogState.hasConnections
139 ? () =>
140 navigation.navigate("SettingsSheet", {
141 screen: "SettingsContent",
142 params: { screen: "SettingsEnvironmentNew" },
143 })
144 : undefined
145 }
146 onStartNewTask={
147 Platform.OS === "android" && panes.primarySidebarVisible
148 ? undefined
149 : () => navigation.navigate("NewTaskSheet", { screen: "NewTask" })
150 }
131 />151 />
132 </>152 </>
133 );153 );
apps/mobile/src/features/home/HomeScreen.tsx+58-14
...@@ -30,13 +30,13 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";...@@ -30,13 +30,13 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
30import { cn } from "../../lib/cn";30import { cn } from "../../lib/cn";
31import { AppText as Text } from "../../components/AppText";31import { AppText as Text } from "../../components/AppText";
32import { EmptyState } from "../../components/EmptyState";32import { EmptyState } from "../../components/EmptyState";
33import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton";
33import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel";34import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel";
34import type { SavedRemoteConnection } from "../../lib/connection";35import type { SavedRemoteConnection } from "../../lib/connection";
35import { scopedProjectKey } from "../../lib/scopedEntities";36import { scopedProjectKey } from "../../lib/scopedEntities";
36import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";37import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
37import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";38import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
38import { useThreadSearch } from "../../state/queries";39import { useThreadSearch } from "../../state/queries";
39import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
40import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";40import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
41import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";41import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
42import { usePendingThreadOrder } from "../../state/thread-order";42import { usePendingThreadOrder } from "../../state/thread-order";
...@@ -83,6 +83,7 @@ import {...@@ -83,6 +83,7 @@ import {
83 type HomeProjectSortOrder,83 type HomeProjectSortOrder,
84} from "./homeThreadList";84} from "./homeThreadList";
85import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions";85import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions";
86import { useMaterialFabScroll } from "./MaterialFabScrollContext";
8687
87/* ─── Types ──────────────────────────────────────────────────────────── */88/* ─── Types ──────────────────────────────────────────────────────────── */
8889
...@@ -219,7 +220,6 @@ function HomeTopContentSpacer() {...@@ -219,7 +220,6 @@ function HomeTopContentSpacer() {
219/* ─── Main screen ────────────────────────────────────────────────────── */220/* ─── Main screen ────────────────────────────────────────────────────── */
220221
221export function HomeScreen(props: HomeScreenProps) {222export function HomeScreen(props: HomeScreenProps) {
222 const { materialYouStyleLayoutActive } = useAppearancePreferences();
223 const [groupDisplayStates, setGroupDisplayStates] = useState<223 const [groupDisplayStates, setGroupDisplayStates] = useState<
224 ReadonlyMap<string, HomeGroupDisplayState>224 ReadonlyMap<string, HomeGroupDisplayState>
225 >(() => new Map());225 >(() => new Map());
...@@ -315,7 +315,9 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -315,7 +315,9 @@ export function HomeScreen(props: HomeScreenProps) {
315 const handleScrollBeginDrag = useCallback(() => {315 const handleScrollBeginDrag = useCallback(() => {
316 openSwipeableRef.current?.close();316 openSwipeableRef.current?.close();
317 }, []);317 }, []);
318 const onMaterialFabScroll = useMaterialFabScroll();
318 const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({319 const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({
320 onScroll: onMaterialFabScroll,
319 onScrollBeginDrag: handleScrollBeginDrag,321 onScrollBeginDrag: handleScrollBeginDrag,
320 });322 });
321323
...@@ -1104,11 +1106,11 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1104,11 +1106,11 @@ export function HomeScreen(props: HomeScreenProps) {
11041106
1105 if (!hasAnyThreads) {1107 if (!hasAnyThreads) {
1106 return (1108 return (
1107 <View className={materialYouStyleLayoutActive ? "flex-1 bg-header" : "flex-1 bg-screen"}>1109 <View className={Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 bg-screen"}>
1108 <View1110 <View
1109 className={cn(1111 className={cn(
1110 "flex-1 items-center justify-center bg-screen px-8",1112 "flex-1 items-center justify-center bg-screen px-8",
1111 materialYouStyleLayoutActive && "overflow-hidden rounded-t-[28px]",1113 Platform.OS === "android" && "overflow-hidden rounded-t-[28px]",
1112 )}1114 )}
1113 style={{1115 style={{
1114 paddingBottom: Math.max(insets.bottom, 24) + iosBottomToolbarClearance,1116 paddingBottom: Math.max(insets.bottom, 24) + iosBottomToolbarClearance,
...@@ -1121,11 +1123,22 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1121,11 +1123,22 @@ export function HomeScreen(props: HomeScreenProps) {
1121 detail={emptyState.detail}1123 detail={emptyState.detail}
1122 actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined}1124 actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined}
1123 onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined}1125 onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined}
1126 action={
1127 Platform.OS === "android" && !props.catalogState.hasReadyEnvironment ? (
1128 <MaterialFloatingActionButton
1129 label="Add environment"
1130 icon="plus"
1131 variant="extended"
1132 tone="primary"
1133 onPress={props.onAddConnection}
1134 />
1135 ) : undefined
1136 }
1124 variant="plain"1137 variant="plain"
1125 />1138 />
1126 {emptyState.loading ? (1139 {emptyState.loading ? (
1127 <View className="mt-4 items-center">1140 <View className="mt-4 items-center">
1128 <ActivityIndicator colorClassName={"accent-icon-muted"} />1141 <ActivityIndicator colorClassName="accent-icon-muted" />
1129 </View>1142 </View>
1130 ) : null}1143 ) : null}
1131 </View>1144 </View>
...@@ -1142,41 +1155,72 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1142,41 +1155,72 @@ export function HomeScreen(props: HomeScreenProps) {
11421155
1143 const listEmpty = !hasResults ? (1156 const listEmpty = !hasResults ? (
1144 hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? (1157 hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? (
1145 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />1158 <EmptyState
1159 title="No results"
1160 detail={`No threads matching "${props.searchQuery}".`}
1161 variant={Platform.OS === "android" ? "plain" : undefined}
1162 />
1146 ) : selectedProjectScope !== null ? (1163 ) : selectedProjectScope !== null ? (
1147 <EmptyState1164 <EmptyState
1148 title={`No threads in ${selectedProjectScope.title}`}1165 title={`No threads in ${selectedProjectScope.title}`}
1149 detail="Choose another project or create a new task."1166 detail="Choose another project or create a new task."
1167 variant={Platform.OS === "android" ? "plain" : undefined}
1150 />1168 />
1151 ) : selectedEnvironmentLabel ? (1169 ) : selectedEnvironmentLabel ? (
1152 <EmptyState1170 <EmptyState
1153 title={`No threads in ${selectedEnvironmentLabel}`}1171 title={`No threads in ${selectedEnvironmentLabel}`}
1154 detail="Choose another environment or create a new task."1172 detail="Choose another environment or create a new task."
1173 variant={Platform.OS === "android" ? "plain" : undefined}
1155 />1174 />
1156 ) : (1175 ) : (
1157 <EmptyState title="No threads yet" detail="Create a task to start a new coding session." />1176 <EmptyState
1177 title="No threads yet"
1178 detail="Create a task to start a new coding session."
1179 variant={Platform.OS === "android" ? "plain" : undefined}
1180 />
1158 )1181 )
1159 ) : null;1182 ) : null;
1160 // Use the v2 project scope for its empty state. Snoozed threads need no1183 // Use the v2 project scope for its empty state. Snoozed threads need no
1161 // special empty state: their shelf header is a list row even while collapsed.1184 // special empty state: their shelf header is a list row even while collapsed.
1162 const v2ListEmpty =1185 const v2ListEmpty =
1163 hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? (1186 hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? (
1164 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />1187 <EmptyState
1188 title="No results"
1189 detail={`No threads matching "${props.searchQuery}".`}
1190 variant={Platform.OS === "android" ? "plain" : undefined}
1191 />
1165 ) : v2ScopedProjectGroup !== null ? (1192 ) : v2ScopedProjectGroup !== null ? (
1166 <EmptyState1193 <EmptyState
1167 title={`No threads in ${v2ScopedProjectGroup.title}`}1194 title={`No threads in ${v2ScopedProjectGroup.title}`}
1168 detail="Choose another project or create a new task."1195 detail="Choose another project or create a new task."
1196 variant={Platform.OS === "android" ? "plain" : undefined}
1169 />1197 />
1170 ) : (1198 ) : (
1171 listEmpty1199 listEmpty
1172 );1200 );
11731201
1202 if (
1203 Platform.OS === "android" &&
1204 (threadListV2Enabled ? threadListV2Items.length === 0 : listLayout.items.length === 0)
1205 ) {
1206 return (
1207 <View className="flex-1 bg-header">
1208 <View
1209 className="flex-1 items-center justify-center overflow-hidden rounded-t-[28px] bg-screen px-4"
1210 style={{ paddingBottom: insets.bottom }}
1211 >
1212 {threadListV2Enabled ? v2ListEmpty : listEmpty}
1213 </View>
1214 </View>
1215 );
1216 }
1217
1174 if (threadListV2Enabled) {1218 if (threadListV2Enabled) {
1175 return (1219 return (
1176 <View className={materialYouStyleLayoutActive ? "flex-1 bg-header" : "flex-1 bg-screen"}>1220 <View className={Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 bg-screen"}>
1177 <View1221 <View
1178 className={1222 className={
1179 materialYouStyleLayoutActive1223 Platform.OS === "android"
1180 ? "flex-1 overflow-hidden rounded-t-[28px] bg-screen"1224 ? "flex-1 overflow-hidden rounded-t-[28px] bg-screen"
1181 : "flex-1 bg-screen"1225 : "flex-1 bg-screen"
1182 }1226 }
...@@ -1216,7 +1260,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1216,7 +1260,7 @@ export function HomeScreen(props: HomeScreenProps) {
1216 paddingBottom:1260 paddingBottom:
1217 Platform.OS === "ios"1261 Platform.OS === "ios"
1218 ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance1262 ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance
1219 : Math.max(insets.bottom, 16) + 88,1263 : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88),
1220 }}1264 }}
1221 />1265 />
1222 </SwipeableScrollGateProvider>1266 </SwipeableScrollGateProvider>
...@@ -1226,10 +1270,10 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1226,10 +1270,10 @@ export function HomeScreen(props: HomeScreenProps) {
1226 }1270 }
12271271
1228 return (1272 return (
1229 <View className={materialYouStyleLayoutActive ? "flex-1 bg-header" : "flex-1 bg-screen"}>1273 <View className={Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 bg-screen"}>
1230 <View1274 <View
1231 className={1275 className={
1232 materialYouStyleLayoutActive1276 Platform.OS === "android"
1233 ? "flex-1 overflow-hidden rounded-t-[28px] bg-screen"1277 ? "flex-1 overflow-hidden rounded-t-[28px] bg-screen"
1234 : "flex-1 bg-screen"1278 : "flex-1 bg-screen"
1235 }1279 }
...@@ -1270,7 +1314,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1270,7 +1314,7 @@ export function HomeScreen(props: HomeScreenProps) {
1270 paddingBottom:1314 paddingBottom:
1271 Platform.OS === "ios"1315 Platform.OS === "ios"
1272 ? Math.max(insets.bottom, 24) + 24 + iosBottomToolbarClearance1316 ? Math.max(insets.bottom, 24) + 24 + iosBottomToolbarClearance
1273 : Math.max(insets.bottom, 16) + 88,1317 : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88),
1274 }}1318 }}
1275 scrollIndicatorInsets={1319 scrollIndicatorInsets={
1276 Platform.OS === "ios"1320 Platform.OS === "ios"
apps/mobile/src/features/home/MaterialFabScrollContext.ts created+10
...@@ -0,0 +1,10 @@
1import { createContext, useContext } from "react";
2import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native";
3
4export const MaterialFabScrollContext = createContext<
5 ((event: NativeSyntheticEvent<NativeScrollEvent>) => void) | undefined
6>(undefined);
7
8export function useMaterialFabScroll() {
9 return useContext(MaterialFabScrollContext);
10}
apps/mobile/src/features/home/MaterialThreadListToolbar.tsx created+178
...@@ -0,0 +1,178 @@
1import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react";
2import {
3 BackHandler,
4 Keyboard,
5 Pressable,
6 TextInput,
7 View,
8 type LayoutChangeEvent,
9} from "react-native";
10import { useSafeAreaInsets } from "react-native-safe-area-context";
11import type { MenuAction } from "@react-native-menu/menu";
12
13import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader";
14import { CompactBrandTitle } from "../../components/CompactBrandTitle";
15import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton";
16import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu";
17import { ControlPillMenu } from "../../components/ControlPill";
18import { SymbolView } from "../../components/AppSymbol";
19import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
20import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle";
21import { useWorkspaceState } from "../../state/workspace";
22import { useMaterialToolbarHeight } from "../../components/useMaterialToolbarHeight";
23
24/** One toolbar height for the compact list and expanded sidebar, including search. */
25export function MaterialThreadListToolbar(props: {
26 readonly searchQuery: string;
27 readonly onSearchQueryChange: (query: string) => void;
28 readonly filterActions: MenuAction[];
29 readonly filterCustomized: boolean;
30 readonly onFilterAction: NonNullable<ComponentProps<typeof ControlPillMenu>["onPressAction"]>;
31 readonly onOpenSettings: () => void;
32 readonly onOpenEnvironments: () => void;
33 readonly sidebar?: boolean;
34 readonly onLayout?: (event: LayoutChangeEvent) => void;
35 readonly onRequestVisibility?: () => void;
36}) {
37 const insets = useSafeAreaInsets();
38 const toolbarHeight = useMaterialToolbarHeight();
39 const { state } = useWorkspaceState();
40 const { onRequestVisibility, onSearchQueryChange } = props;
41 const searchRef = useRef<TextInput>(null);
42 const [searchOpen, setSearchOpen] = useState(false);
43 const searching = searchOpen || props.searchQuery.length > 0;
44 const openSearch = useCallback(() => {
45 onRequestVisibility?.();
46 setSearchOpen(true);
47 searchRef.current?.focus();
48 return true;
49 }, [onRequestVisibility]);
50 useHardwareKeyboardCommand("focusSearch", openSearch);
51
52 const closeSearch = useCallback(() => {
53 onSearchQueryChange("");
54 setSearchOpen(false);
55 Keyboard.dismiss();
56 }, [onSearchQueryChange]);
57
58 useEffect(() => {
59 if (!searching) return;
60 const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
61 closeSearch();
62 return true;
63 });
64 return () => subscription.remove();
65 }, [closeSearch, searching]);
66
67 const filterIcon = props.filterCustomized
68 ? "line.3.horizontal.decrease.circle.fill"
69 : "line.3.horizontal.decrease.circle";
70 const searchField = (
71 <View className="h-12 min-w-0 flex-1 flex-row items-center gap-2 rounded-full border border-input-border bg-input px-3">
72 <SymbolView name="magnifyingglass" size={18} tintColorClassName="accent-foreground-muted" />
73 <TextInput
74 ref={searchRef}
75 accessibilityLabel="Search threads"
76 autoFocus={true}
77 autoCapitalize="none"
78 autoCorrect={false}
79 returnKeyType="search"
80 placeholder="Search"
81 placeholderTextColorClassName="accent-placeholder"
82 selectionColorClassName="accent-primary/32"
83 cursorColorClassName="accent-primary"
84 selectionHandleColorClassName="accent-primary"
85 className="min-w-0 flex-1 py-2 font-sans text-base text-foreground"
86 value={props.searchQuery}
87 onChangeText={onSearchQueryChange}
88 />
89 {props.searchQuery.length > 0 ? (
90 <Pressable
91 accessibilityLabel="Clear search"
92 accessibilityRole="button"
93 hitSlop={10}
94 onPress={() => {
95 props.onSearchQueryChange("");
96 searchRef.current?.focus();
97 }}
98 >
99 <SymbolView
100 name="xmark.circle.fill"
101 size={18}
102 tintColorClassName="accent-foreground-muted"
103 />
104 </Pressable>
105 ) : null}
106 </View>
107 );
108
109 return (
110 <>
111 <View
112 onLayout={props.onLayout}
113 className={
114 props.sidebar
115 ? "absolute inset-x-0 top-0 z-[4] bg-header px-2 pb-2"
116 : "bg-header px-2 pb-2"
117 }
118 style={{ paddingTop: Math.max(insets.top, 12) }}
119 >
120 <View className="flex-row items-center gap-1" style={{ minHeight: toolbarHeight }}>
121 {searching ? (
122 <>
123 <AndroidHeaderIconButton
124 accessibilityLabel="Close search"
125 icon="arrow.left"
126 onPress={closeSearch}
127 />
128 {searchField}
129 </>
130 ) : (
131 <>
132 {/* Match the visible inset of the trailing 48dp icon button. */}
133 <View className="min-w-0 flex-1 pl-4">
134 <WorkspaceConnectionTitle
135 grow
136 onPress={props.onOpenEnvironments}
137 brand={<CompactBrandTitle allowFontScaling={false} />}
138 />
139 </View>
140 <AndroidHeaderIconButton
141 accessibilityLabel="Search threads"
142 icon="magnifyingglass"
143 onPress={openSearch}
144 />
145 <AndroidHeaderIconButton
146 accessibilityLabel="Open settings"
147 icon="gearshape"
148 onPress={props.onOpenSettings}
149 />
150 </>
151 )}
152 </View>
153 </View>
154 {/* Sit 8dp above the 56dp extended New thread FAB. */}
155 {state.hasConnections ? (
156 <View
157 className="absolute right-5 z-[5]"
158 style={{
159 bottom:
160 (props.sidebar ? Math.max(insets.bottom, 12) + 6 : Math.max(insets.bottom, 16) + 16) +
161 56 +
162 8,
163 }}
164 >
165 <AndroidAnchoredMenu actions={props.filterActions} onPressAction={props.onFilterAction}>
166 {(open) => (
167 <MaterialFloatingActionButton
168 label="Filter and sort threads"
169 icon={filterIcon}
170 onPress={open}
171 />
172 )}
173 </AndroidAnchoredMenu>
174 </View>
175 ) : null}
176 </>
177 );
178}
apps/mobile/src/features/home/material-fab-scroll.test.ts created+40
...@@ -0,0 +1,40 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { updateMaterialFabScroll, type MaterialFabScrollState } from "./material-fab-scroll";
4
5describe("Material FAB scroll direction", () => {
6 const initial: MaterialFabScrollState = { anchor: 0, expanded: true };
7
8 it("shrinks scrolling down and expands scrolling up without returning to the top", () => {
9 const collapsed = updateMaterialFabScroll(initial, 80, 500);
10 expect(collapsed.expanded).toBe(false);
11 const lower = updateMaterialFabScroll(collapsed, 180, 500);
12 expect(lower.expanded).toBe(false);
13 expect(updateMaterialFabScroll(lower, 160, 500).expanded).toBe(true);
14 });
15
16 it("accumulates travel but ignores jitter around a direction change", () => {
17 let state = updateMaterialFabScroll(initial, 10, 500);
18 expect(state.expanded).toBe(true);
19 state = updateMaterialFabScroll(state, 14, 500);
20 expect(state.expanded).toBe(false);
21 state = updateMaterialFabScroll(state, 100, 500);
22 state = updateMaterialFabScroll(state, 95, 500);
23 state = updateMaterialFabScroll(state, 98, 500);
24 expect(state.expanded).toBe(false);
25 expect(updateMaterialFabScroll(state, 88, 500).expanded).toBe(true);
26 });
27
28 it("does not expand from bottom bounce or collapse from top bounce", () => {
29 const bottom = updateMaterialFabScroll(initial, 500, 500);
30 const bounce = updateMaterialFabScroll(bottom, 560, 500);
31 expect(updateMaterialFabScroll(bounce, 500, 500).expanded).toBe(false);
32 expect(updateMaterialFabScroll(initial, -50, 500)).toEqual(initial);
33 });
34
35 it("expands near the top and when filtering leaves a non-scrollable list", () => {
36 const collapsed = updateMaterialFabScroll(initial, 80, 500);
37 expect(updateMaterialFabScroll(collapsed, 5, 500).expanded).toBe(true);
38 expect(updateMaterialFabScroll(collapsed, 80, -20)).toEqual(initial);
39 });
40});
apps/mobile/src/features/home/material-fab-scroll.ts created+17
...@@ -0,0 +1,17 @@
1export interface MaterialFabScrollState {
2 readonly anchor: number;
3 readonly expanded: boolean;
4}
5
6/** Ignore small direction changes and overscroll; always show the label at the top. */
7export function updateMaterialFabScroll(
8 state: MaterialFabScrollState,
9 offset: number,
10 maxOffset: number,
11): MaterialFabScrollState {
12 const y = Math.max(0, Math.min(offset, Math.max(0, maxOffset)));
13 if (y <= 8) return { anchor: y, expanded: true };
14 const anchor = state.expanded ? Math.min(state.anchor, y) : Math.max(state.anchor, y);
15 if (Math.abs(y - anchor) >= 12) return { anchor: y, expanded: !state.expanded };
16 return { anchor, expanded: state.expanded };
17}
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+9-6
...@@ -22,7 +22,7 @@ import {...@@ -22,7 +22,7 @@ import {
22 useState,22 useState,
23 type ReactNode,23 type ReactNode,
24} from "react";24} from "react";
25import { useWindowDimensions, View } from "react-native";25import { Platform, useWindowDimensions, View } from "react-native";
26import Animated, {26import Animated, {
27 useAnimatedStyle,27 useAnimatedStyle,
28 useDerivedValue,28 useDerivedValue,
...@@ -56,7 +56,6 @@ import {...@@ -56,7 +56,6 @@ import {
56} from "../keyboard/hardwareKeyboardCommands";56} from "../keyboard/hardwareKeyboardCommands";
57import { AndroidHomeFabLayout } from "../home/AndroidHomeFab";57import { AndroidHomeFabLayout } from "../home/AndroidHomeFab";
58import { HomeListOptionsProvider } from "../home/home-list-options";58import { HomeListOptionsProvider } from "../home/home-list-options";
59import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
60import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";59import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";
61import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";60import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
62import { WorkspaceInspectorPane } from "./workspace-inspector-pane";61import { WorkspaceInspectorPane } from "./workspace-inspector-pane";
...@@ -234,7 +233,6 @@ function AdaptiveWorkspaceLayoutContent(...@@ -234,7 +233,6 @@ function AdaptiveWorkspaceLayoutContent(
234 },233 },
235) {234) {
236 const projectGroupingMode = props.projectGroupingMode;235 const projectGroupingMode = props.projectGroupingMode;
237 const { materialYouStyleLayoutActive } = useAppearancePreferences();
238 const { width, height } = useWindowDimensions();236 const { width, height } = useWindowDimensions();
239 const pathname = props.pathname;237 const pathname = props.pathname;
240 const navigation = useNavigation();238 const navigation = useNavigation();
...@@ -582,7 +580,7 @@ function AdaptiveWorkspaceLayoutContent(...@@ -582,7 +580,7 @@ function AdaptiveWorkspaceLayoutContent(
582 style={sidebarAnimatedStyle}580 style={sidebarAnimatedStyle}
583 >581 >
584 <View className="flex-1" style={{ width: layout.listPaneWidth }}>582 <View className="flex-1" style={{ width: layout.listPaneWidth }}>
585 <AndroidHomeFabLayout onStartNewTask={handleStartNewTask}>583 <AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}>
586 <ThreadNavigationSidebar584 <ThreadNavigationSidebar
587 width={layout.listPaneWidth}585 width={layout.listPaneWidth}
588 visible={panes.primarySidebarVisible}586 visible={panes.primarySidebarVisible}
...@@ -602,7 +600,7 @@ function AdaptiveWorkspaceLayoutContent(...@@ -602,7 +600,7 @@ function AdaptiveWorkspaceLayoutContent(
602 ) : null}600 ) : null}
603 <View601 <View
604 className={602 className={
605 materialYouStyleLayoutActive603 Platform.OS === "android"
606 ? "flex-1 overflow-hidden bg-header"604 ? "flex-1 overflow-hidden bg-header"
607 : "flex-1 overflow-hidden bg-screen"605 : "flex-1 overflow-hidden bg-screen"
608 }606 }
...@@ -611,7 +609,12 @@ function AdaptiveWorkspaceLayoutContent(...@@ -611,7 +609,12 @@ function AdaptiveWorkspaceLayoutContent(
611 <View609 <View
612 collapsable={false}610 collapsable={false}
613 style={611 style={
614 contentSettledWidth !== null ? { flex: 1, width: contentSettledWidth } : { flex: 1 }612 contentSettledWidth !== null
613 ? {
614 flex: 1,
615 width: contentSettledWidth,
616 }
617 : { flex: 1 }
615 }618 }
616 >619 >
617 <WorkspaceContentWidthContext620 <WorkspaceContentWidthContext
apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx+55-26
...@@ -1,40 +1,69 @@...@@ -1,40 +1,69 @@
1import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
2import { SymbolView } from "../../components/AppSymbol";1import { SymbolView } from "../../components/AppSymbol";
3import { Pressable, View } from "react-native";2import { Platform, Pressable, View } from "react-native";
43
5import { AppText as Text } from "../../components/AppText";4import { AppText as Text } from "../../components/AppText";
5import { MaterialNewThreadButton } from "../../components/MaterialNewThreadButton";
6import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton";
7import { EmptyState } from "../../components/EmptyState";
68
7export function WorkspaceEmptyDetail(props: { readonly onStartNewTask?: () => void }) {9export function WorkspaceEmptyDetail(props: {
8 const { materialYouStyleLayoutActive } = useAppearancePreferences();10 readonly onStartNewTask?: () => void;
11 readonly onAddConnection?: () => void;
12}) {
9 return (13 return (
10 <View14 <View
11 className={15 className={
12 materialYouStyleLayoutActive16 Platform.OS === "android"
13 ? "flex-1 items-center justify-center px-10"17 ? "flex-1 items-center justify-center px-10"
14 : "flex-1 items-center justify-center bg-screen px-10"18 : "flex-1 items-center justify-center bg-screen px-10"
15 }19 }
16 >20 >
17 <View className="max-w-[360px] items-center gap-3">21 {props.onAddConnection ? (
18 <SymbolView22 <View className="w-full max-w-[430px]">
19 name="sidebar.left"23 <EmptyState
20 size={34}24 title="No environments connected"
21 tintColorClassName={"accent-icon-subtle"}25 detail="Add an environment to load projects and start coding sessions."
22 type="hierarchical"26 variant="plain"
23 />27 action={
24 <Text className="text-center text-xl font-t3-bold">Select a thread</Text>28 <MaterialFloatingActionButton
25 <Text className="text-center text-base text-foreground-muted">29 label="Add environment"
26 Choose a thread from the sidebar or start a new task.30 icon="plus"
27 </Text>31 variant="extended"
28 {props.onStartNewTask ? (32 tone="primary"
29 <Pressable33 onPress={props.onAddConnection}
30 accessibilityRole="button"34 />
31 className="mt-2 flex-row items-center gap-2 rounded-full bg-primary px-5 py-3 active:opacity-70"35 }
32 onPress={props.onStartNewTask}36 />
33 >37 </View>
34 <Text className="text-base font-t3-bold text-primary-foreground">New Task</Text>38 ) : (
35 </Pressable>39 <View className="max-w-[360px] items-center gap-3">
36 ) : null}40 <SymbolView
37 </View>41 name="sidebar.left"
42 size={34}
43 tintColorClassName="accent-icon-subtle"
44 type="hierarchical"
45 />
46 <Text className="text-center text-xl font-t3-bold">Select a thread</Text>
47 <Text className="text-center text-base text-foreground-muted">
48 {Platform.OS === "android"
49 ? "Choose a thread from the sidebar or start a new thread."
50 : "Choose a thread from the sidebar or start a new task."}
51 </Text>
52 {props.onStartNewTask ? (
53 Platform.OS === "android" ? (
54 <MaterialNewThreadButton extended className="mt-2" onPress={props.onStartNewTask} />
55 ) : (
56 <Pressable
57 accessibilityRole="button"
58 className="mt-2 flex-row items-center gap-2 rounded-full bg-primary px-5 py-3 active:opacity-70"
59 onPress={props.onStartNewTask}
60 >
61 <Text className="text-base font-t3-bold text-primary-foreground">New Task</Text>
62 </Pressable>
63 )
64 ) : null}
65 </View>
66 )}
38 </View>67 </View>
39 );68 );
40}69}
apps/mobile/src/features/layout/workspace-pane-divider.tsx+2-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { useCallback, useMemo, useRef, useState } from "react";1import { useCallback, useMemo, useRef, useState } from "react";
2import { Pressable, StyleSheet, View, type AccessibilityActionEvent } from "react-native";2import { Platform, Pressable, StyleSheet, View, type AccessibilityActionEvent } from "react-native";
3import { Gesture, GestureDetector } from "react-native-gesture-handler";3import { Gesture, GestureDetector } from "react-native-gesture-handler";
4import { runOnJS } from "react-native-reanimated";4import { runOnJS } from "react-native-reanimated";
5import { cn } from "../../lib/cn";5import { cn } from "../../lib/cn";
...@@ -79,6 +79,7 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {...@@ -79,6 +79,7 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
79 className={cn(79 className={cn(
80 "h-full self-center bg-border opacity-70",80 "h-full self-center bg-border opacity-70",
81 dragging ? "w-0.5 bg-primary opacity-100" : "w-px",81 dragging ? "w-0.5 bg-primary opacity-100" : "w-px",
82 Platform.OS === "android" && !dragging && "opacity-0",
82 )}83 )}
83 style={[styles.line, dragging && styles.activeLine]}84 style={[styles.line, dragging && styles.activeLine]}
84 />85 />
apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx+18
...@@ -2,8 +2,26 @@ import { NativeHeaderToolbar } from "../../native/StackHeader";...@@ -2,8 +2,26 @@ import { NativeHeaderToolbar } from "../../native/StackHeader";
2import type { ReactNode } from "react";2import type { ReactNode } from "react";
3import { Platform } from "react-native";3import { Platform } from "react-native";
44
5import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader";
6
5import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout";7import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout";
68
9export function AndroidWorkspaceSidebarButton() {
10 const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
11 if (Platform.OS !== "android" || !layout.usesSplitView) return null;
12
13 return (
14 <AndroidHeaderIconButton
15 accessibilityLabel={
16 panes.primarySidebarVisible ? "Hide thread sidebar" : "Show thread sidebar"
17 }
18 icon="sidebar.left"
19 selected={panes.primarySidebarVisible}
20 onPress={togglePrimarySidebar}
21 />
22 );
23}
24
7export function WorkspaceSidebarToolbar(25export function WorkspaceSidebarToolbar(
8 props: {26 props: {
9 readonly children?: ReactNode;27 readonly children?: ReactNode;
apps/mobile/src/features/projects/AddProjectScreen.tsx+71-28
...@@ -1,3 +1,7 @@...@@ -1,3 +1,7 @@
1import { MaterialListRow } from "../../components/MaterialListRow";
2import { SettingsScreen } from "../settings/components/SettingsScreen";
3import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
4import { MaterialButton } from "../../components/MaterialButton";
1import {5import {
2 addProjectRemoteSourceLabel,6 addProjectRemoteSourceLabel,
3 addProjectRemoteSourcePathHint,7 addProjectRemoteSourcePathHint,
...@@ -41,14 +45,13 @@ import {...@@ -41,14 +45,13 @@ import {
41import { CommonActions, StackActions, useNavigation } from "@react-navigation/native";45import { CommonActions, StackActions, useNavigation } from "@react-navigation/native";
42import { SymbolView } from "../../components/AppSymbol";46import { SymbolView } from "../../components/AppSymbol";
43import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";47import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
44import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";48import { Platform, ActivityIndicator, Alert, Pressable, View } from "react-native";
45import { useSafeAreaInsets } from "react-native-safe-area-context";49import { useSafeAreaInsets } from "react-native-safe-area-context";
46import * as Arr from "effect/Array";50import * as Arr from "effect/Array";
47import * as Cause from "effect/Cause";51import * as Cause from "effect/Cause";
48import * as Order from "effect/Order";52import * as Order from "effect/Order";
49import { AsyncResult } from "effect/unstable/reactivity";53import { AsyncResult } from "effect/unstable/reactivity";
50import { cn } from "../../lib/cn";54import { cn } from "../../lib/cn";
51
52import { useProjects, useServerConfigs, waitForProject } from "../../state/entities";55import { useProjects, useServerConfigs, waitForProject } from "../../state/entities";
53import { filesystemEnvironment } from "../../state/filesystem";56import { filesystemEnvironment } from "../../state/filesystem";
54import { projectEnvironment } from "../../state/projects";57import { projectEnvironment } from "../../state/projects";
...@@ -123,13 +126,19 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote...@@ -123,13 +126,19 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote
123126
124function SectionTitle(props: { readonly children: string }) {127function SectionTitle(props: { readonly children: string }) {
125 return (128 return (
126 <Text className="px-1 text-2xs font-t3-bold tracking-[0.7px] uppercase text-foreground-muted">129 <Text
130 className={
131 Platform.OS === "android"
132 ? "px-4 text-sm font-t3-medium text-primary"
133 : "px-1 text-2xs font-t3-bold tracking-[0.7px] uppercase text-foreground-muted"
134 }
135 >
127 {props.children}136 {props.children}
128 </Text>137 </Text>
129 );138 );
130}139}
131140
132function AddProjectShell(props: { readonly children: ReactNode }) {141function AddProjectShell(props: { readonly children: ReactNode; readonly title: string }) {
133 const insets = useSafeAreaInsets();142 const insets = useSafeAreaInsets();
134143
135 return (144 return (
...@@ -138,25 +147,35 @@ function AddProjectShell(props: { readonly children: ReactNode }) {...@@ -138,25 +147,35 @@ function AddProjectShell(props: { readonly children: ReactNode }) {
138 // scroll-view frame correction mistakes this full-height wrapper for a147 // scroll-view frame correction mistakes this full-height wrapper for a
139 // "header" sibling, coercing the ScrollView to zero height (blank sheet148 // "header" sibling, coercing the ScrollView to zero height (blank sheet
140 // as soon as the sheet re-lays-out, e.g. when the keyboard opens).149 // as soon as the sheet re-lays-out, e.g. when the keyboard opens).
141 <View collapsable={false} className="flex-1 bg-sheet">150 <SettingsScreen title={props.title}>
142 <ScrollView151 <ScrollView
143 keyboardShouldPersistTaps="handled"152 keyboardShouldPersistTaps="handled"
144 contentInsetAdjustmentBehavior="automatic"153 contentInsetAdjustmentBehavior="automatic"
145 contentContainerStyle={{154 contentContainerStyle={{
146 paddingHorizontal: 20,155 paddingHorizontal: Platform.OS === "android" ? 16 : 20,
147 paddingTop: 16,156 paddingTop: 16,
148 paddingBottom: Math.max(insets.bottom, 18) + 18,157 paddingBottom: Math.max(insets.bottom, 18) + 18,
149 gap: 10,158 gap: Platform.OS === "android" ? 16 : 10,
150 }}159 }}
151 >160 >
152 {props.children}161 {props.children}
153 </ScrollView>162 </ScrollView>
154 </View>163 </SettingsScreen>
155 );164 );
156}165}
157166
158function ListSection(props: { readonly children: ReactNode }) {167function ListSection(props: { readonly children: ReactNode }) {
159 return <View className="overflow-hidden rounded-[24px] bg-card">{props.children}</View>;168 return (
169 <View
170 className={
171 Platform.OS === "android"
172 ? "overflow-hidden rounded-[28px] bg-card"
173 : "overflow-hidden rounded-[24px] bg-card"
174 }
175 >
176 {props.children}
177 </View>
178 );
160}179}
161180
162function ListRow(props: {181function ListRow(props: {
...@@ -169,6 +188,20 @@ function ListRow(props: {...@@ -169,6 +188,20 @@ function ListRow(props: {
169 readonly right?: ReactNode;188 readonly right?: ReactNode;
170 readonly onPress?: () => void;189 readonly onPress?: () => void;
171}) {190}) {
191 if (Platform.OS === "android") {
192 return (
193 <MaterialListRow
194 title={props.title}
195 subtitle={props.subtitle}
196 leading={props.icon}
197 trailing={props.right}
198 disabled={props.disabled}
199 onPress={props.onPress}
200 accessibilityRole={props.selected !== undefined ? "radio" : "button"}
201 accessibilityState={props.selected !== undefined ? { checked: props.selected } : undefined}
202 />
203 );
204 }
172 return (205 return (
173 <Pressable206 <Pressable
174 disabled={props.disabled}207 disabled={props.disabled}
...@@ -203,7 +236,7 @@ function ListRow(props: {...@@ -203,7 +236,7 @@ function ListRow(props: {
203 <SymbolView236 <SymbolView
204 name="chevron.right"237 name="chevron.right"
205 size={13}238 size={13}
206 tintColorClassName={"accent-chevron"}239 tintColorClassName="accent-chevron"
207 type="monochrome"240 type="monochrome"
208 />241 />
209 ) : null}242 ) : null}
...@@ -218,6 +251,7 @@ function PrimaryActionButton(props: {...@@ -218,6 +251,7 @@ function PrimaryActionButton(props: {
218 readonly loading?: boolean;251 readonly loading?: boolean;
219 readonly onPress: () => void;252 readonly onPress: () => void;
220}) {253}) {
254 if (Platform.OS === "android") return <MaterialButton {...props} tone="primary" fullWidth />;
221 return (255 return (
222 <Pressable256 <Pressable
223 disabled={props.disabled}257 disabled={props.disabled}
...@@ -435,9 +469,18 @@ function SourceControlRow(props: {...@@ -435,9 +469,18 @@ function SourceControlRow(props: {
435 : `Clone ${addProjectRemoteSourceLabel(props.source)} ${props.hint}`;469 : `Clone ${addProjectRemoteSourceLabel(props.source)} ${props.hint}`;
436 const icon =470 const icon =
437 props.source === "url" ? (471 props.source === "url" ? (
438 <SymbolView name="link" size={17} tintColorClassName={"accent-icon"} type="monochrome" />472 <SymbolView
473 name="link"
474 size={Platform.OS === "android" ? 24 : 17}
475 tintColorClassName="accent-icon"
476 type="monochrome"
477 />
439 ) : (478 ) : (
440 <SourceControlIcon kind={props.source} size={18} colorClassName="accent-icon" />479 <SourceControlIcon
480 kind={props.source}
481 size={Platform.OS === "android" ? 24 : 18}
482 colorClassName="accent-icon"
483 />
441 );484 );
442485
443 if (!props.ready) {486 if (!props.ready) {
...@@ -482,7 +525,7 @@ export function AddProjectSourceScreen() {...@@ -482,7 +525,7 @@ export function AddProjectSourceScreen() {
482 );525 );
483526
484 return (527 return (
485 <AddProjectShell>528 <AddProjectShell title="Add project">
486 {selectedEnvironment === null ? <EmptyEnvironmentState /> : null}529 {selectedEnvironment === null ? <EmptyEnvironmentState /> : null}
487530
488 {environmentOptions.length > 1 ? (531 {environmentOptions.length > 1 ? (
...@@ -495,7 +538,7 @@ export function AddProjectSourceScreen() {...@@ -495,7 +538,7 @@ export function AddProjectSourceScreen() {
495 title={environment.label}538 title={environment.label}
496 subtitle={539 subtitle={
497 canCreateProjectInEnvironment(environment.connectionState)540 canCreateProjectInEnvironment(environment.connectionState)
498 ? environment.environmentId541 ? undefined
499 : connectionStatusText({542 : connectionStatusText({
500 phase: environment.connectionState,543 phase: environment.connectionState,
501 error: environment.connectionError,544 error: environment.connectionError,
...@@ -505,7 +548,7 @@ export function AddProjectSourceScreen() {...@@ -505,7 +548,7 @@ export function AddProjectSourceScreen() {
505 icon={548 icon={
506 <EnvironmentMachineSymbol549 <EnvironmentMachineSymbol
507 kind={environment.machine}550 kind={environment.machine}
508 size={17}551 size={Platform.OS === "android" ? 24 : 17}
509 tintColorClassName="accent-icon"552 tintColorClassName="accent-icon"
510 />553 />
511 }554 }
...@@ -516,8 +559,8 @@ export function AddProjectSourceScreen() {...@@ -516,8 +559,8 @@ export function AddProjectSourceScreen() {
516 environment.environmentId === selectedEnvironment?.environmentId ? (559 environment.environmentId === selectedEnvironment?.environmentId ? (
517 <SymbolView560 <SymbolView
518 name="checkmark"561 name="checkmark"
519 size={14}562 size={Platform.OS === "android" ? 20 : 14}
520 tintColorClassName={"accent-icon"}563 tintColorClassName="accent-icon"
521 type="monochrome"564 type="monochrome"
522 />565 />
523 ) : null566 ) : null
...@@ -538,8 +581,8 @@ export function AddProjectSourceScreen() {...@@ -538,8 +581,8 @@ export function AddProjectSourceScreen() {
538 icon={581 icon={
539 <SymbolView582 <SymbolView
540 name="folder.badge.plus"583 name="folder.badge.plus"
541 size={17}584 size={Platform.OS === "android" ? 24 : 17}
542 tintColorClassName={"accent-icon"}585 tintColorClassName="accent-icon"
543 type="monochrome"586 type="monochrome"
544 />587 />
545 }588 }
...@@ -570,7 +613,7 @@ export function AddProjectSourceScreen() {...@@ -570,7 +613,7 @@ export function AddProjectSourceScreen() {
570 )}613 )}
571 </ListSection>614 </ListSection>
572 {discoveryState.isPending ? (615 {discoveryState.isPending ? (
573 <ActivityIndicator colorClassName={"accent-icon-muted"} />616 <ActivityIndicator colorClassName="accent-icon-muted" />
574 ) : null}617 ) : null}
575 </>618 </>
576 ) : null}619 ) : null}
...@@ -723,7 +766,7 @@ export function AddProjectRepositoryScreen(props: {...@@ -723,7 +766,7 @@ export function AddProjectRepositoryScreen(props: {
723 }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]);766 }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]);
724767
725 return (768 return (
726 <AddProjectShell>769 <AddProjectShell title={source === "url" ? "Git URL" : addProjectRemoteSourceLabel(source)}>
727 {error ? <ErrorBanner message={error} /> : null}770 {error ? <ErrorBanner message={error} /> : null}
728 {environment ? (771 {environment ? (
729 <>772 <>
...@@ -800,7 +843,7 @@ function FolderBrowser(props: {...@@ -800,7 +843,7 @@ function FolderBrowser(props: {
800 <ListSection>843 <ListSection>
801 {browseState.isPending && browseState.data === null ? (844 {browseState.isPending && browseState.data === null ? (
802 <View className="items-center py-5">845 <View className="items-center py-5">
803 <ActivityIndicator colorClassName={"accent-icon-muted"} />846 <ActivityIndicator colorClassName="accent-icon-muted" />
804 </View>847 </View>
805 ) : null}848 ) : null}
806 {browsePath.canBrowseUp ? (849 {browsePath.canBrowseUp ? (
...@@ -809,8 +852,8 @@ function FolderBrowser(props: {...@@ -809,8 +852,8 @@ function FolderBrowser(props: {
809 icon={852 icon={
810 <SymbolView853 <SymbolView
811 name="arrow.turn.left.up"854 name="arrow.turn.left.up"
812 size={17}855 size={Platform.OS === "android" ? 24 : 17}
813 tintColorClassName={"accent-icon-muted"}856 tintColorClassName="accent-icon-muted"
814 type="monochrome"857 type="monochrome"
815 />858 />
816 }859 }
...@@ -832,8 +875,8 @@ function FolderBrowser(props: {...@@ -832,8 +875,8 @@ function FolderBrowser(props: {
832 icon={875 icon={
833 <SymbolView876 <SymbolView
834 name="folder"877 name="folder"
835 size={17}878 size={Platform.OS === "android" ? 24 : 17}
836 tintColorClassName={"accent-icon-muted"}879 tintColorClassName="accent-icon-muted"
837 type="monochrome"880 type="monochrome"
838 />881 />
839 }882 }
...@@ -882,7 +925,7 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st...@@ -882,7 +925,7 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st
882 }, [createProject, environment, isBrowseNavigating, isSubmitting, pathInput]);925 }, [createProject, environment, isBrowseNavigating, isSubmitting, pathInput]);
883926
884 return (927 return (
885 <AddProjectShell>928 <AddProjectShell title="Local folder">
886 {error ? <ErrorBanner message={error} /> : null}929 {error ? <ErrorBanner message={error} /> : null}
887 {environment ? (930 {environment ? (
888 <>931 <>
...@@ -1025,7 +1068,7 @@ export function AddProjectDestinationScreen(props: {...@@ -1025,7 +1068,7 @@ export function AddProjectDestinationScreen(props: {
1025 ]);1068 ]);
10261069
1027 return (1070 return (
1028 <AddProjectShell>1071 <AddProjectShell title="Clone destination">
1029 {error ? <ErrorBanner message={error} /> : null}1072 {error ? <ErrorBanner message={error} /> : null}
1030 {repositoryTitle ? (1073 {repositoryTitle ? (
1031 <View className="rounded-[24px] bg-card px-4 py-3">1074 <View className="rounded-[24px] bg-card px-4 py-3">
apps/mobile/src/features/review/ReviewSheet.tsx+250-165
...@@ -35,6 +35,8 @@ import { AppText as Text } from "../../components/AppText";...@@ -35,6 +35,8 @@ import { AppText as Text } from "../../components/AppText";
35import { SymbolView } from "../../components/AppSymbol";35import { SymbolView } from "../../components/AppSymbol";
36import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";36import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
37import { ControlPillMenu } from "../../components/ControlPill";37import { ControlPillMenu } from "../../components/ControlPill";
38import { MaterialScreenContent } from "../../components/MaterialScreenContent";
39import { cn } from "../../lib/cn";
38import { environmentCatalog } from "../../connection/catalog";40import { environmentCatalog } from "../../connection/catalog";
39import { useEnvironmentPresentation } from "../../state/presentation";41import { useEnvironmentPresentation } from "../../state/presentation";
40import { useAtomCommand } from "../../state/use-atom-command";42import { useAtomCommand } from "../../state/use-atom-command";
...@@ -54,7 +56,10 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s...@@ -54,7 +56,10 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s
54import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";56import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
55import { useThreadSelection } from "../../state/use-thread-selection";57import { useThreadSelection } from "../../state/use-thread-selection";
56import { vcsEnvironment } from "../../state/vcs";58import { vcsEnvironment } from "../../state/vcs";
57import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";59import {
60 AndroidWorkspaceSidebarButton,
61 WorkspaceSidebarToolbar,
62} from "../layout/workspace-sidebar-toolbar";
58import { ThreadGitMenu } from "../threads/ThreadGitControls";63import { ThreadGitMenu } from "../threads/ThreadGitControls";
59import { useReviewCacheForThread } from "./reviewState";64import { useReviewCacheForThread } from "./reviewState";
60import {65import {
...@@ -81,7 +86,12 @@ const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";...@@ -81,7 +86,12 @@ const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
8186
82const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) {87const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) {
83 return (88 return (
84 <View className="border-b border-warning-border bg-warning px-4 py-3">89 <View
90 className={cn(
91 "bg-warning px-4 py-3",
92 Platform.OS === "android" ? "m-2 rounded-[20px]" : "border-b border-warning-border",
93 )}
94 >
85 <Text className="text-xs font-t3-bold uppercase text-warning-foreground">Partial diff</Text>95 <Text className="text-xs font-t3-bold uppercase text-warning-foreground">Partial diff</Text>
86 <Text className="text-xs leading-normal text-warning-foreground">{props.notice}</Text>96 <Text className="text-xs leading-normal text-warning-foreground">{props.notice}</Text>
87 </View>97 </View>
...@@ -103,7 +113,7 @@ function ReviewSelectionActionBar(props: {...@@ -103,7 +113,7 @@ function ReviewSelectionActionBar(props: {
103 <SymbolView113 <SymbolView
104 name={props.onOpenComment ? "text.bubble" : "line.3.horizontal.decrease.circle"}114 name={props.onOpenComment ? "text.bubble" : "line.3.horizontal.decrease.circle"}
105 size={16}115 size={16}
106 tintColorClassName={"accent-primary-foreground"}116 tintColorClassName="accent-primary-foreground"
107 type="monochrome"117 type="monochrome"
108 />118 />
109 <Text className="text-base font-t3-bold text-primary-foreground">{props.title}</Text>119 <Text className="text-base font-t3-bold text-primary-foreground">{props.title}</Text>
...@@ -143,7 +153,7 @@ function ReviewSelectionActionBar(props: {...@@ -143,7 +153,7 @@ function ReviewSelectionActionBar(props: {
143 <SymbolView153 <SymbolView
144 name="xmark"154 name="xmark"
145 size={16}155 size={16}
146 tintColorClassName={"accent-primary-foreground"}156 tintColorClassName="accent-primary-foreground"
147 type="monochrome"157 type="monochrome"
148 />158 />
149 </Pressable>159 </Pressable>
...@@ -174,9 +184,14 @@ const ReviewFileNavigatorRow = memo(function ReviewFileNavigatorRow(props: {...@@ -174,9 +184,14 @@ const ReviewFileNavigatorRow = memo(function ReviewFileNavigatorRow(props: {
174 accessibilityRole="button"184 accessibilityRole="button"
175 accessibilityState={{ selected }}185 accessibilityState={{ selected }}
176 className={186 className={
177 selected187 Platform.OS === "android"
178 ? "mt-1 min-h-12 justify-center rounded-xl bg-subtle-strong px-3 py-2"188 ? cn(
179 : "mt-1 min-h-12 justify-center rounded-xl px-3 py-2 active:bg-subtle"189 "mt-1 min-h-12 justify-center rounded-[20px] px-3 py-2 active:bg-subtle",
190 selected && "bg-thread-selected",
191 )
192 : selected
193 ? "mt-1 min-h-12 justify-center rounded-xl bg-subtle-strong px-3 py-2"
194 : "mt-1 min-h-12 justify-center rounded-xl px-3 py-2 active:bg-subtle"
180 }195 }
181 onPress={handlePress}196 onPress={handlePress}
182 >197 >
...@@ -323,16 +338,28 @@ function ReviewFileNavigator({...@@ -323,16 +338,28 @@ function ReviewFileNavigator({
323 }338 }
324339
325 return (340 return (
326 <View className="flex-1 border-l border-border bg-sheet">341 <View
327 <View className="border-b border-border" style={{ paddingTop: headerInset }}>342 className={
328 <View className="px-4 py-3">343 Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 border-l border-border bg-sheet"
329 <Text className="text-sm font-t3-bold text-foreground">Changed files</Text>344 }
330 <Text className="text-xs text-foreground-muted">345 >
331 {files.length} {files.length === 1 ? "file" : "files"}346 {Platform.OS === "android" ? (
332 </Text>347 <AndroidScreenHeader
348 title="Changed files"
349 subtitle={`${files.length} ${files.length === 1 ? "file" : "files"}`}
350 hideBottomBorder
351 />
352 ) : (
353 <View className="border-b border-border" style={{ paddingTop: headerInset }}>
354 <View className="px-4 py-3">
355 <Text className="text-sm font-t3-bold text-foreground">Changed files</Text>
356 <Text className="text-xs text-foreground-muted">
357 {files.length} {files.length === 1 ? "file" : "files"}
358 </Text>
359 </View>
333 </View>360 </View>
334 </View>361 )}
335 {fileList}362 <MaterialScreenContent insetHorizontal>{fileList}</MaterialScreenContent>
336 </View>363 </View>
337 );364 );
338}365}
...@@ -632,23 +659,21 @@ export function ReviewSheet(props: ReviewSheetProps) {...@@ -632,23 +659,21 @@ export function ReviewSheet(props: ReviewSheetProps) {
632 parsedDiff.kind === "files" &&659 parsedDiff.kind === "files" &&
633 NativeReviewDiffView !== null;660 NativeReviewDiffView !== null;
634 useRegisterWorkspaceInspector(showChangedFilesPane ? renderInspector : undefined);661 useRegisterWorkspaceInspector(showChangedFilesPane ? renderInspector : undefined);
635 // Raw fallback renders the patch inline with no inspector content, so the662 // A toggle needs registered content; loading, errors and raw patches have no navigator pane.
636 // pane toggle would open an empty column — hide it in exactly that case.663 const showChangedFilesToggle = panes.supportsAuxiliaryPane && showChangedFilesPane;
637 const showChangedFilesToggle =
638 panes.supportsAuxiliaryPane &&
639 !(
640 !showConnectionNotice &&
641 selectedSection !== null &&
642 parsedDiff.kind === "files" &&
643 NativeReviewDiffView === null
644 );
645664
646 const listHeader = useMemo(() => {665 const listHeader = useMemo(() => {
647 const children: ReactElement[] = [];666 const children: ReactElement[] = [];
648667
649 if (error) {668 if (error) {
650 children.push(669 children.push(
651 <View key="review-error" className="border-b border-border bg-card px-4 py-3">670 <View
671 key="review-error"
672 className={cn(
673 "bg-card px-4 py-3",
674 Platform.OS === "android" ? "m-2 rounded-[20px]" : "border-b border-border",
675 )}
676 >
652 <Text className="text-sm font-t3-bold text-foreground">Review unavailable</Text>677 <Text className="text-sm font-t3-bold text-foreground">Review unavailable</Text>
653 <Text className="text-xs leading-normal text-foreground-muted">{error}</Text>678 <Text className="text-xs leading-normal text-foreground-muted">{error}</Text>
654 </View>,679 </View>,
...@@ -699,21 +724,35 @@ export function ReviewSheet(props: ReviewSheetProps) {...@@ -699,21 +724,35 @@ export function ReviewSheet(props: ReviewSheetProps) {
699 {isAndroid ? (724 {isAndroid ? (
700 <AndroidScreenHeader725 <AndroidScreenHeader
701 title="Review changes"726 title="Review changes"
727 leading={<AndroidWorkspaceSidebarButton />}
728 hideBottomBorder
702 subtitle={androidHeaderSubtitle || "Select a diff"}729 subtitle={androidHeaderSubtitle || "Select a diff"}
703 onBack={handleReturnToThread}730 onBack={handleReturnToThread}
704 trailing={731 trailing={
705 showSectionToolbar ? (732 <>
706 <ControlPillMenu733 {showChangedFilesToggle ? (
707 actions={androidSectionMenuActions}
708 isAnchoredToRight
709 onPressAction={handleAndroidSectionMenuAction}
710 >
711 <AndroidHeaderIconButton734 <AndroidHeaderIconButton
712 accessibilityLabel="Select review diff"735 accessibilityLabel={
713 icon="ellipsis.circle"736 panes.auxiliaryPaneVisible ? "Hide changed files" : "Show changed files"
737 }
738 icon="sidebar.right"
739 selected={panes.auxiliaryPaneVisible}
740 onPress={toggleAuxiliaryPane}
714 />741 />
715 </ControlPillMenu>742 ) : null}
716 ) : null743 {showSectionToolbar ? (
744 <ControlPillMenu
745 actions={androidSectionMenuActions}
746 isAnchoredToRight
747 onPressAction={handleAndroidSectionMenuAction}
748 >
749 <AndroidHeaderIconButton
750 accessibilityLabel="Select review diff"
751 icon="ellipsis.circle"
752 />
753 </ControlPillMenu>
754 ) : null}
755 </>
717 }756 }
718 />757 />
719 ) : null}758 ) : null}
...@@ -805,141 +844,187 @@ export function ReviewSheet(props: ReviewSheetProps) {...@@ -805,141 +844,187 @@ export function ReviewSheet(props: ReviewSheetProps) {
805 </NativeHeaderToolbar>844 </NativeHeaderToolbar>
806 ) : null}845 ) : null}
807846
808 <View className="flex-1 bg-sheet">847 <MaterialScreenContent>
809 {showConnectionNotice ? (848 <View className={Platform.OS === "android" ? "flex-1 bg-sheet-solid" : "flex-1 bg-sheet"}>
810 <View className="flex-1" style={{ paddingTop: topContentInset }}>849 {showConnectionNotice ? (
811 <EnvironmentConnectionNotice850 <View className="flex-1" style={{ paddingTop: topContentInset }}>
812 environmentLabel={environment.presentation?.entry.target.label ?? "Environment"}851 <EnvironmentConnectionNotice
813 connection={852 environmentLabel={environment.presentation?.entry.target.label ?? "Environment"}
814 environment.presentation?.connection ?? {853 connection={
815 phase: "available",854 environment.presentation?.connection ?? {
816 error: null,855 phase: "available",
817 traceId: null,856 error: null,
857 traceId: null,
858 }
818 }859 }
819 }860 resourceName="review"
820 resourceName="review"861 onRetry={handleRetryEnvironment}
821 onRetry={handleRetryEnvironment}862 />
822 />863 </View>
823 </View>864 ) : selectedSection && parsedDiff.kind === "files" && NativeReviewDiffView ? (
824 ) : selectedSection && parsedDiff.kind === "files" && NativeReviewDiffView ? (
825 <View
826 className="flex-1"
827 style={{
828 backgroundColor: nativeBridge.theme.background,
829 }}
830 >
831 <View865 <View
832 className="min-w-0 flex-1"866 className="flex-1"
833 style={{ paddingTop: topContentInset + REVIEW_HEADER_SPACING }}867 style={{
868 backgroundColor: nativeBridge.theme.background,
869 }}
834 >870 >
835 {listHeader}871 <View
836 <View className="min-w-0 flex-1" collapsable={false}>872 className="min-w-0 flex-1"
837 <NativeReviewDiffView873 style={{ paddingTop: topContentInset + REVIEW_HEADER_SPACING }}
838 collapsable={false}874 >
839 testID="review-native-diff-view"875 {listHeader}
840 refreshing={isPullRefreshing || isSelectedSectionPending || areFilePatchesPending}876 <View className="min-w-0 flex-1" collapsable={false}>
841 onPullToRefresh={() => void handlePullToRefresh()}877 <NativeReviewDiffView
842 style={StyleSheet.absoluteFill}878 collapsable={false}
843 appearanceScheme={selectedTheme}879 testID="review-native-diff-view"
844 collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson}880 refreshing={
845 collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson}881 isPullRefreshing || isSelectedSectionPending || areFilePatchesPending
846 contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`}882 }
847 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}883 onPullToRefresh={() => void handlePullToRefresh()}
848 nativeViewRef={nativeReviewDiffViewRef}884 style={StyleSheet.absoluteFill}
849 rowHeight={nativeReviewDiffStyle.rowHeight}885 appearanceScheme={selectedTheme}
850 rowsJson={nativeBridge.rowsJson}886 collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson}
851 selectedRowIdsJson={nativeBridge.selectedRowIdsJson}887 collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson}
852 styleJson={nativeBridge.styleJson}888 contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`}
853 themeJson={nativeBridge.themeJson}889 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
854 tokensPatchJson={nativeBridge.tokensPatchJson}890 nativeViewRef={nativeReviewDiffViewRef}
855 tokensResetKey={nativeBridge.tokensResetKey}891 rowHeight={nativeReviewDiffStyle.rowHeight}
856 viewedFileIdsJson={nativeBridge.viewedFileIdsJson}892 rowsJson={nativeBridge.rowsJson}
857 onDebug={handleNativeDebug}893 selectedRowIdsJson={nativeBridge.selectedRowIdsJson}
858 onPressLine={commentSelection.onPressLine}894 styleJson={nativeBridge.styleJson}
859 onVisibleFileChange={handleVisibleFileChange}895 themeJson={nativeBridge.themeJson}
860 onToggleComment={nativeBridge.onToggleComment}896 tokensPatchJson={nativeBridge.tokensPatchJson}
861 onToggleFile={handleNativeToggleFile}897 tokensResetKey={nativeBridge.tokensResetKey}
862 onToggleViewedFile={handleNativeToggleViewedFile}898 viewedFileIdsJson={nativeBridge.viewedFileIdsJson}
863 />899 onDebug={handleNativeDebug}
900 onPressLine={commentSelection.onPressLine}
901 onVisibleFileChange={handleVisibleFileChange}
902 onToggleComment={nativeBridge.onToggleComment}
903 onToggleFile={handleNativeToggleFile}
904 onToggleViewedFile={handleNativeToggleViewedFile}
905 />
906 </View>
864 </View>907 </View>
865 </View>908 </View>
866 </View>909 ) : (
867 ) : (910 <ScrollView
868 <ScrollView911 contentContainerStyle={
869 contentInsetAdjustmentBehavior="never"912 Platform.OS === "android" && (parsedDiff.kind === "empty" || !selectedSection)
870 contentInset={{ top: topContentInset, bottom: Math.max(insets.bottom, 18) + 18 }}913 ? { flexGrow: 1, justifyContent: "center" }
871 contentOffset={{ x: 0, y: -topContentInset }}914 : undefined
872 scrollIndicatorInsets={{915 }
873 top: topContentInset,916 contentInsetAdjustmentBehavior="never"
874 bottom: Math.max(insets.bottom, 18) + 18,917 contentInset={{ top: topContentInset, bottom: Math.max(insets.bottom, 18) + 18 }}
875 }}918 contentOffset={{ x: 0, y: -topContentInset }}
876 showsVerticalScrollIndicator={false}919 scrollIndicatorInsets={{
877 className="flex-1"920 top: topContentInset,
878 refreshControl={921 bottom: Math.max(insets.bottom, 18) + 18,
879 // The native diff surface owns pull-to-refresh via onPullToRefresh;922 }}
880 // the raw fallback (and empty states) need an explicit control —923 showsVerticalScrollIndicator={false}
881 // iOS has no other refresh affordance here (the explicit924 className="flex-1"
882 // "Refresh current diff" menu is Android-only).925 refreshControl={
883 <RefreshControl926 // The native diff surface owns pull-to-refresh via onPullToRefresh;
884 refreshing={isPullRefreshing || isSelectedSectionPending || areFilePatchesPending}927 // the raw fallback (and empty states) need an explicit control —
885 onRefresh={() => void handlePullToRefresh()}928 // iOS has no other refresh affordance here (the explicit
886 />929 // "Refresh current diff" menu is Android-only).
887 }930 <RefreshControl
888 >931 refreshing={isPullRefreshing || isSelectedSectionPending || areFilePatchesPending}
889 {listHeader}932 onRefresh={() => void handlePullToRefresh()}
890 {!selectedSection ? (933 />
891 <View className="border-b border-border bg-card px-4 py-5">934 }
892 <Text className="text-sm font-t3-bold text-foreground">No review diffs</Text>935 >
893 <Text className="text-xs leading-normal text-foreground-muted">936 {listHeader}
894 This thread has no ready turn diffs and the worktree diff is empty.937 {!selectedSection ? (
895 </Text>938 <View
896 </View>939 className={
897 ) : selectedSection.isLoading && selectedSection.diff === null ? (940 Platform.OS === "android"
898 <View className="items-center gap-3 border-b border-border bg-card px-4 py-6">941 ? "items-center px-6 py-5"
899 <ActivityIndicator size="small" />942 : "border-b border-border bg-card px-4 py-5"
900 <Text className="text-xs text-foreground-muted">Loading diff…</Text>943 }
901 </View>944 >
902 ) : parsedDiff.kind === "empty" ? (945 <Text className="text-sm font-t3-bold text-foreground">No review diffs</Text>
903 <View className="border-b border-border bg-card px-4 py-5">946 <Text
904 <Text className="text-sm font-t3-bold text-foreground">No changes</Text>947 className={cn(
905 <Text className="text-xs leading-normal text-foreground-muted">948 "text-xs leading-normal text-foreground-muted",
906 {selectedSection.subtitle ?? "This diff is empty."}949 Platform.OS === "android" && "mt-2 text-center",
907 </Text>950 )}
908 </View>951 >
909 ) : parsedDiff.kind === "raw" ? (952 This thread has no ready turn diffs and the worktree diff is empty.
910 <View className="gap-3 border-b border-border bg-card px-4 py-4">
911 <Text className="text-xs leading-normal text-foreground-muted">
912 {parsedDiff.reason}
913 </Text>
914 <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}>
915 <Text selectable className="font-mono text-xs leading-relaxed text-foreground">
916 {parsedDiff.text}
917 </Text>953 </Text>
918 </ScrollView>954 </View>
919 </View>955 ) : selectedSection.isLoading && selectedSection.diff === null ? (
920 ) : parsedDiff.kind === "files" ? (956 <View
921 // The native diff surface could not be resolved on this binary;957 className={cn(
922 // degrade to the raw patch instead of crashing the app.958 "items-center gap-3 px-4 py-6",
923 <View className="gap-3 border-b border-border bg-card px-4 py-4">959 Platform.OS !== "android" && "border-b border-border bg-card",
924 <Text className="text-xs leading-normal text-foreground-muted">960 )}
925 Native diff view unavailable. Showing the raw patch.961 >
926 </Text>962 <ActivityIndicator size="small" />
927 <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}>963 <Text className="text-xs text-foreground-muted">Loading diff…</Text>
928 <Text selectable className="font-mono text-xs leading-relaxed text-foreground">964 </View>
929 {selectedSection?.diff ?? ""}965 ) : parsedDiff.kind === "empty" ? (
966 <View
967 className={
968 Platform.OS === "android"
969 ? "items-center px-6 py-5"
970 : "border-b border-border bg-card px-4 py-5"
971 }
972 >
973 <Text className="text-sm font-t3-bold text-foreground">No changes</Text>
974 <Text
975 className={cn(
976 "text-xs leading-normal text-foreground-muted",
977 Platform.OS === "android" && "mt-2 text-center",
978 )}
979 >
980 {selectedSection.subtitle ?? "This diff is empty."}
930 </Text>981 </Text>
931 </ScrollView>982 </View>
932 </View>983 ) : parsedDiff.kind === "raw" ? (
933 ) : null}984 <View
934 </ScrollView>985 className={cn(
935 )}986 "gap-3 bg-card px-4 py-4",
936 <ReviewSelectionActionBar987 Platform.OS === "android" ? "m-2 rounded-[20px]" : "border-b border-border",
937 bottomInset={insets.bottom}988 )}
938 title={commentSelection.selectionAction?.title ?? null}989 >
939 onOpenComment={commentSelection.selectionAction?.onOpenComment ?? null}990 <Text className="text-xs leading-normal text-foreground-muted">
940 onClear={commentSelection.clearSelection}991 {parsedDiff.reason}
941 />992 </Text>
942 </View>993 <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}>
994 <Text selectable className="font-mono text-xs leading-relaxed text-foreground">
995 {parsedDiff.text}
996 </Text>
997 </ScrollView>
998 </View>
999 ) : parsedDiff.kind === "files" ? (
1000 // The native diff surface could not be resolved on this binary;
1001 // degrade to the raw patch instead of crashing the app.
1002 <View
1003 className={cn(
1004 "gap-3 bg-card px-4 py-4",
1005 Platform.OS === "android" ? "m-2 rounded-[20px]" : "border-b border-border",
1006 )}
1007 >
1008 <Text className="text-xs leading-normal text-foreground-muted">
1009 Native diff view unavailable. Showing the raw patch.
1010 </Text>
1011 <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}>
1012 <Text selectable className="font-mono text-xs leading-relaxed text-foreground">
1013 {selectedSection?.diff ?? ""}
1014 </Text>
1015 </ScrollView>
1016 </View>
1017 ) : null}
1018 </ScrollView>
1019 )}
1020 <ReviewSelectionActionBar
1021 bottomInset={insets.bottom}
1022 title={commentSelection.selectionAction?.title ?? null}
1023 onOpenComment={commentSelection.selectionAction?.onOpenComment ?? null}
1024 onClear={commentSelection.clearSelection}
1025 />
1026 </View>
1027 </MaterialScreenContent>
943 </>1028 </>
944 );1029 );
945}1030}
apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts+25
...@@ -255,6 +255,31 @@ describe("createNativeReviewDiffTheme", () => {...@@ -255,6 +255,31 @@ describe("createNativeReviewDiffTheme", () => {
255 }255 }
256 });256 });
257257
258 it.each(["light", "dark"] as const)(
259 "preserves Material You RGBA hex channels and composites alpha in %s",
260 (appearance) => {
261 const variables = {
262 ...appTheme("material-you", appearance),
263 "--color-screen": "#101214FF",
264 "--color-sheet": "#20222480",
265 "--color-md-code-text": "#E3E2E6FF",
266 "--color-foreground-muted": "#C7C5D080",
267 "--color-border": "#44464F80",
268 "--color-primary": "#A8C7FAFF",
269 };
270 const theme = createNativeReviewDiffTheme(appearance, "material-you", variables);
271 expect(theme.background).toBe("#181a1c");
272 expect(theme.headerBackground).toBe(theme.background);
273 expect(theme.text).toBe("#e3e2e6");
274 expect(theme.mutedText).toBe("#707076");
275 expect(theme.border).toBe("#2e3036");
276 expect(theme.hunkText).toBe("#a8c7fa");
277 for (const color of Object.values(theme)) {
278 expect(color).toMatch(/^#[\da-f]{6}$/i);
279 }
280 },
281 );
282
258 it("uses the selected app palette for native code surfaces", () => {283 it("uses the selected app palette for native code surfaces", () => {
259 const standard = createNativeReviewDiffTheme("dark", "t3-code", appTheme("t3-code", "dark"));284 const standard = createNativeReviewDiffTheme("dark", "t3-code", appTheme("t3-code", "dark"));
260 const iris = createNativeReviewDiffTheme("dark", "iris", appTheme("iris", "dark"));285 const iris = createNativeReviewDiffTheme("dark", "iris", appTheme("iris", "dark"));
apps/mobile/src/features/review/nativeReviewDiffAdapter.ts+14-6
...@@ -19,7 +19,7 @@ import type { ReviewInlineComment } from "./reviewCommentSelection";...@@ -19,7 +19,7 @@ import type { ReviewInlineComment } from "./reviewCommentSelection";
1919
20const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4;20const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4;
21const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45;21const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45;
22const NATIVE_HEX_COLOR = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i;22const NATIVE_HEX_COLOR = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})([\da-f]{2})?$/i;
23const NATIVE_RGBA_COLOR =23const NATIVE_RGBA_COLOR =
24 /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/;24 /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/;
2525
...@@ -44,15 +44,19 @@ export function buildNativeReviewSnippetRows(...@@ -44,15 +44,19 @@ export function buildNativeReviewSnippetRows(
4444
45function opaqueNativeHexColor(color: string, background: string): string {45function opaqueNativeHexColor(color: string, background: string): string {
46 const hex = NATIVE_HEX_COLOR.exec(color);46 const hex = NATIVE_HEX_COLOR.exec(color);
47 if (hex) return color;47 if (hex && !hex[4]) return color;
4848
49 const rgba = NATIVE_RGBA_COLOR.exec(color);49 const rgba = NATIVE_RGBA_COLOR.exec(color);
50 const backgroundHex = NATIVE_HEX_COLOR.exec(background);50 const backgroundHex = NATIVE_HEX_COLOR.exec(background);
51 if (!rgba || !backgroundHex) return background;51 if ((!hex && !rgba) || !backgroundHex) return background;
5252
53 const alpha = rgba[4] === undefined ? 1 : Math.min(1, Math.max(0, Number(rgba[4])));53 const alpha = hex
54 ? Number.parseInt(hex[4] ?? "ff", 16) / 255
55 : rgba?.[4] === undefined
56 ? 1
57 : Math.min(1, Math.max(0, Number(rgba[4])));
54 const channels = [1, 2, 3].map((index) => {58 const channels = [1, 2, 3].map((index) => {
55 const foreground = Number(rgba[index]);59 const foreground = hex ? Number.parseInt(hex[index] ?? "0", 16) : Number(rgba?.[index]);
56 const behind = Number.parseInt(backgroundHex[index] ?? "0", 16);60 const behind = Number.parseInt(backgroundHex[index] ?? "0", 16);
57 return Math.round(foreground * alpha + behind * (1 - alpha));61 return Math.round(foreground * alpha + behind * (1 - alpha));
58 });62 });
...@@ -169,7 +173,11 @@ export function createNativeReviewDiffTheme(...@@ -169,7 +173,11 @@ export function createNativeReviewDiffTheme(
169 // Swift expects #RRGGBB/#RRGGBBAA while Android expects #RRGGBB/#AARRGGBB.173 // Swift expects #RRGGBB/#RRGGBBAA while Android expects #RRGGBB/#AARRGGBB.
170 // Flatten translucent app tokens onto the code surface so both native174 // Flatten translucent app tokens onto the code surface so both native
171 // implementations receive the one unambiguous shared format.175 // implementations receive the one unambiguous shared format.
172 const background = opaqueNativeHexColor(appTheme["--color-sheet"], appTheme["--color-screen"]);176 const screen = opaqueNativeHexColor(
177 appTheme["--color-screen"],
178 scheme === "dark" ? "#000000" : "#ffffff",
179 );
180 const background = opaqueNativeHexColor(appTheme["--color-sheet"], screen);
173 const nativeColor = (color: string) => opaqueNativeHexColor(color, background);181 const nativeColor = (color: string) => opaqueNativeHexColor(color, background);
174182
175 if (scheme === "dark") {183 if (scheme === "dark") {
apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx+4-13
...@@ -1,26 +1,17 @@...@@ -1,26 +1,17 @@
1import { useNavigation } from "@react-navigation/native";1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
2import { Platform, ScrollView, View } from "react-native";
3import { useSafeAreaInsets } from "react-native-safe-area-context";2import { useSafeAreaInsets } from "react-native-safe-area-context";
43
5import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";4import { SettingsScreen } from "./components/SettingsScreen";
6import { NativeStackScreenOptions } from "../../native/StackHeader";
7import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection";5import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection";
8import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection";6import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection";
9import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection";7import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection";
10import { ThemeAppearanceSection } from "./appearance/sections/ThemeAppearanceSection";8import { ThemeAppearanceSection } from "./appearance/sections/ThemeAppearanceSection";
119
12export function SettingsAppearanceRouteScreen() {10export function SettingsAppearanceRouteScreen() {
13 const navigation = useNavigation();
14 const insets = useSafeAreaInsets();11 const insets = useSafeAreaInsets();
1512
16 return (13 return (
17 <View collapsable={false} className="flex-1 bg-sheet">14 <SettingsScreen title="Appearance">
18 {Platform.OS === "android" ? (
19 <>
20 <NativeStackScreenOptions options={{ headerShown: false }} />
21 <AndroidScreenHeader title="Appearance" onBack={() => navigation.goBack()} />
22 </>
23 ) : null}
24 <ScrollView15 <ScrollView
25 contentInsetAdjustmentBehavior="automatic"16 contentInsetAdjustmentBehavior="automatic"
26 showsVerticalScrollIndicator={false}17 showsVerticalScrollIndicator={false}
...@@ -35,6 +26,6 @@ export function SettingsAppearanceRouteScreen() {...@@ -35,6 +26,6 @@ export function SettingsAppearanceRouteScreen() {
35 <TerminalAppearanceSection />26 <TerminalAppearanceSection />
36 <CodeAppearanceSection />27 <CodeAppearanceSection />
37 </ScrollView>28 </ScrollView>
38 </View>29 </SettingsScreen>
39 );30 );
40}31}
apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx+9-9
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
1import { useAtomSet, useAtomValue } from "@effect/atom-react";2import { useAtomSet, useAtomValue } from "@effect/atom-react";
2import { type EnvironmentMachineKind, resolveEnvironmentMachineKind } from "@t3tools/contracts";3import { type EnvironmentMachineKind, resolveEnvironmentMachineKind } from "@t3tools/contracts";
3import { AsyncResult } from "effect/unstable/reactivity";4import { AsyncResult } from "effect/unstable/reactivity";
4import { useMemo } from "react";5import { useMemo } from "react";
5import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";6import { ActivityIndicator, Alert, Pressable, View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";7import { useSafeAreaInsets } from "react-native-safe-area-context";
78
8import { AppText as Text } from "../../components/AppText";9import { AppText as Text } from "../../components/AppText";
...@@ -16,6 +17,7 @@ import {...@@ -16,6 +17,7 @@ import {
16import { useServerConfigs } from "../../state/entities";17import { useServerConfigs } from "../../state/entities";
17import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";18import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
18import { SettingsSection } from "./components/SettingsSection";19import { SettingsSection } from "./components/SettingsSection";
20import { SettingsScreen } from "./components/SettingsScreen";
1921
20export function SettingsClientStorageRouteScreen() {22export function SettingsClientStorageRouteScreen() {
21 const insets = useSafeAreaInsets();23 const insets = useSafeAreaInsets();
...@@ -71,7 +73,7 @@ export function SettingsClientStorageRouteScreen() {...@@ -71,7 +73,7 @@ export function SettingsClientStorageRouteScreen() {
71 };73 };
7274
73 return (75 return (
74 <View collapsable={false} className="flex-1 bg-sheet">76 <SettingsScreen title="Client Storage">
75 <ScrollView77 <ScrollView
76 contentInsetAdjustmentBehavior="automatic"78 contentInsetAdjustmentBehavior="automatic"
77 contentInset={{ bottom: Math.max(insets.bottom, 18) }}79 contentInset={{ bottom: Math.max(insets.bottom, 18) }}
...@@ -85,7 +87,7 @@ export function SettingsClientStorageRouteScreen() {...@@ -85,7 +87,7 @@ export function SettingsClientStorageRouteScreen() {
85 <SymbolView87 <SymbolView
86 name="exclamationmark.triangle"88 name="exclamationmark.triangle"
87 size={28}89 size={28}
88 tintColorClassName={"accent-danger-foreground"}90 tintColorClassName="accent-danger-foreground"
89 type="monochrome"91 type="monochrome"
90 weight="regular"92 weight="regular"
91 />93 />
...@@ -123,7 +125,7 @@ export function SettingsClientStorageRouteScreen() {...@@ -123,7 +125,7 @@ export function SettingsClientStorageRouteScreen() {
123 <SymbolView125 <SymbolView
124 name="checkmark.circle"126 name="checkmark.circle"
125 size={28}127 size={28}
126 tintColorClassName={"accent-icon"}128 tintColorClassName="accent-icon"
127 type="monochrome"129 type="monochrome"
128 weight="regular"130 weight="regular"
129 />131 />
...@@ -146,16 +148,14 @@ export function SettingsClientStorageRouteScreen() {...@@ -146,16 +148,14 @@ export function SettingsClientStorageRouteScreen() {
146 <SymbolView148 <SymbolView
147 name="trash"149 name="trash"
148 size={22}150 size={22}
149 tintColorClassName={"accent-danger-foreground"}151 tintColorClassName="accent-danger-foreground"
150 type="monochrome"152 type="monochrome"
151 weight="regular"153 weight="regular"
152 />154 />
153 <Text className="flex-1 text-lg tabular-nums text-danger-foreground">155 <Text className="flex-1 text-lg tabular-nums text-danger-foreground">
154 {summary ? `Clear ${formatBytes(summary.payloadBytes)}` : "Clear caches"}156 {summary ? `Clear ${formatBytes(summary.payloadBytes)}` : "Clear caches"}
155 </Text>157 </Text>
156 {isClearing ? (158 {isClearing ? <ActivityIndicator colorClassName="accent-danger-foreground" /> : null}
157 <ActivityIndicator colorClassName={"accent-danger-foreground"} />
158 ) : null}
159 </Pressable>159 </Pressable>
160 </SettingsSection>160 </SettingsSection>
161 <Text className="px-2 text-sm leading-normal text-foreground-muted">161 <Text className="px-2 text-sm leading-normal text-foreground-muted">
...@@ -169,7 +169,7 @@ export function SettingsClientStorageRouteScreen() {...@@ -169,7 +169,7 @@ export function SettingsClientStorageRouteScreen() {
169 ) : null}169 ) : null}
170 </View>170 </View>
171 </ScrollView>171 </ScrollView>
172 </View>172 </SettingsScreen>
173 );173 );
174}174}
175175
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+21-27
...@@ -1,13 +1,14 @@...@@ -1,13 +1,14 @@
1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
2import { NativeHeaderToolbar } from "../../native/StackHeader";
2import { useNavigation } from "@react-navigation/native";3import { useNavigation } from "@react-navigation/native";
3import { SymbolView } from "../../components/AppSymbol";4import { SymbolView } from "../../components/AppSymbol";
4import type { EnvironmentId } from "@t3tools/contracts";5import type { EnvironmentId } from "@t3tools/contracts";
5import { useCallback, useState } from "react";6import { useCallback, useState } from "react";
6import { Platform, ScrollView, View } from "react-native";7import { Platform, View } from "react-native";
7import { useSafeAreaInsets } from "react-native-safe-area-context";8import { useSafeAreaInsets } from "react-native-safe-area-context";
89
9import { AppText as Text } from "../../components/AppText";10import { AppText as Text } from "../../components/AppText";
10import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";11import { SettingsScreen } from "./components/SettingsScreen";
11import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";12import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";
12import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";13import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
13import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings";14import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings";
...@@ -79,28 +80,21 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -79,28 +80,21 @@ export function SettingsEnvironmentsRouteScreen() {
79 );80 );
8081
81 return (82 return (
82 <View collapsable={false} className="flex-1 bg-sheet">83 <SettingsScreen
83 {Platform.OS === "android" ? (84 title="Environments"
84 <>85 actions={[
85 {/* Android renders its own in-screen header instead of the native bar. */}86 {
86 <NativeStackScreenOptions options={{ headerShown: false }} />87 accessibilityLabel: "Add environment",
87 <AndroidScreenHeader88 icon: "plus",
88 title="Environments"89 onPress: () =>
89 onBack={() => navigation.goBack()}90 navigation.navigate("SettingsSheet", {
90 actions={[91 screen: "SettingsContent",
91 {92 params: { screen: "SettingsEnvironmentNew" },
92 accessibilityLabel: "Add environment",93 }),
93 icon: "plus",94 },
94 onPress: () =>95 ]}
95 navigation.navigate("SettingsSheet", {96 >
96 screen: "SettingsContent",97 {Platform.OS !== "android" ? (
97 params: { screen: "SettingsEnvironmentNew" },
98 }),
99 },
100 ]}
101 />
102 </>
103 ) : (
104 <NativeHeaderToolbar placement="right">98 <NativeHeaderToolbar placement="right">
105 <NativeHeaderToolbar.Button99 <NativeHeaderToolbar.Button
106 icon="plus"100 icon="plus"
...@@ -114,7 +108,7 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -114,7 +108,7 @@ export function SettingsEnvironmentsRouteScreen() {
114 tintColor={headerIconColor}108 tintColor={headerIconColor}
115 />109 />
116 </NativeHeaderToolbar>110 </NativeHeaderToolbar>
117 )}111 ) : null}
118 <ScrollView112 <ScrollView
119 contentInsetAdjustmentBehavior="automatic"113 contentInsetAdjustmentBehavior="automatic"
120 showsVerticalScrollIndicator={false}114 showsVerticalScrollIndicator={false}
...@@ -177,6 +171,6 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -177,6 +171,6 @@ export function SettingsEnvironmentsRouteScreen() {
177 />171 />
178 <GitHubRoutingSettings />172 <GitHubRoutingSettings />
179 </ScrollView>173 </ScrollView>
180 </View>174 </SettingsScreen>
181 );175 );
182}176}
apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx+13-35
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
1import { LegendList } from "@legendapp/list/react-native";2import { LegendList } from "@legendapp/list/react-native";
2import { type StaticScreenProps, useNavigation } from "@react-navigation/native";3import { type StaticScreenProps, useNavigation } from "@react-navigation/native";
3import {4import {
...@@ -8,18 +9,19 @@ import {...@@ -8,18 +9,19 @@ import {
8 type ThirdPartyLicenseEntry,9 type ThirdPartyLicenseEntry,
9} from "@t3tools/shared/thirdPartyLicenses";10} from "@t3tools/shared/thirdPartyLicenses";
10import { useCallback, useMemo, useState } from "react";11import { useCallback, useMemo, useState } from "react";
11import { Linking, Platform, Pressable, ScrollView, View } from "react-native";12import { Linking, Platform, Pressable, View } from "react-native";
12import { useSafeAreaInsets } from "react-native-safe-area-context";13import { useSafeAreaInsets } from "react-native-safe-area-context";
1314
14import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
15import { SymbolView } from "../../components/AppSymbol";15import { SymbolView } from "../../components/AppSymbol";
16import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";16import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
17import { SettingsScreen } from "./components/SettingsScreen";
17import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";18import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
18import {19import {
19 createNativeMailSearchToolbarItem,20 createNativeMailSearchToolbarItem,
20 NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,21 NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,
21 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,22 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
22} from "../layout/native-mail-search-toolbar";23} from "../layout/native-mail-search-toolbar";
24
23import { getMobileThirdPartyLicenses } from "./mobileThirdPartyLicenses";25import { getMobileThirdPartyLicenses } from "./mobileThirdPartyLicenses";
2426
25function useMobileThirdPartyLicenses() {27function useMobileThirdPartyLicenses() {
...@@ -97,24 +99,18 @@ export function SettingsOpenSourceLicensesRouteScreen() {...@@ -97,24 +99,18 @@ export function SettingsOpenSourceLicensesRouteScreen() {
9799
98 if (!manifest) {100 if (!manifest) {
99 return (101 return (
100 <View collapsable={false} className="flex-1 bg-sheet">102 <SettingsScreen title="Open source licenses">
101 {Platform.OS === "android" ? (
102 <>
103 <NativeStackScreenOptions options={{ headerShown: false }} />
104 <AndroidScreenHeader title="Open source licenses" onBack={() => navigation.goBack()} />
105 </>
106 ) : null}
107 <View className="flex-1 items-center justify-center px-6">103 <View className="flex-1 items-center justify-center px-6">
108 <Text className="text-center text-base text-foreground-muted">104 <Text className="text-center text-base text-foreground-muted">
109 License notices are unavailable in this build.105 License notices are unavailable in this build.
110 </Text>106 </Text>
111 </View>107 </View>
112 </View>108 </SettingsScreen>
113 );109 );
114 }110 }
115111
116 return (112 return (
117 <View collapsable={false} className="flex-1 bg-sheet">113 <SettingsScreen title="Open source licenses">
118 {Platform.OS === "ios" ? (114 {Platform.OS === "ios" ? (
119 <NativeStackScreenOptions115 <NativeStackScreenOptions
120 options={{116 options={{
...@@ -148,12 +144,7 @@ export function SettingsOpenSourceLicensesRouteScreen() {...@@ -148,12 +144,7 @@ export function SettingsOpenSourceLicensesRouteScreen() {
148 <NativeHeaderToolbar.SearchBarSlot />144 <NativeHeaderToolbar.SearchBarSlot />
149 </NativeHeaderToolbar>145 </NativeHeaderToolbar>
150 ) : null}146 ) : null}
151 {Platform.OS === "android" ? (147
152 <>
153 <NativeStackScreenOptions options={{ headerShown: false }} />
154 <AndroidScreenHeader title="Open source licenses" onBack={() => navigation.goBack()} />
155 </>
156 ) : null}
157 <LegendList148 <LegendList
158 className="flex-1"149 className="flex-1"
159 contentContainerStyle={{150 contentContainerStyle={{
...@@ -195,14 +186,13 @@ export function SettingsOpenSourceLicensesRouteScreen() {...@@ -195,14 +186,13 @@ export function SettingsOpenSourceLicensesRouteScreen() {
195 renderItem={renderItem}186 renderItem={renderItem}
196 showsVerticalScrollIndicator={false}187 showsVerticalScrollIndicator={false}
197 />188 />
198 </View>189 </SettingsScreen>
199 );190 );
200}191}
201192
202type LicenseDetailProps = StaticScreenProps<{ readonly entryKey: string }>;193type LicenseDetailProps = StaticScreenProps<{ readonly entryKey: string }>;
203194
204export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailProps) {195export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailProps) {
205 const navigation = useNavigation();
206 const insets = useSafeAreaInsets();196 const insets = useSafeAreaInsets();
207 const manifest = useMobileThirdPartyLicenses();197 const manifest = useMobileThirdPartyLicenses();
208 const entry = manifest198 const entry = manifest
...@@ -212,30 +202,18 @@ export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailPro...@@ -212,30 +202,18 @@ export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailPro
212202
213 if (!entry) {203 if (!entry) {
214 return (204 return (
215 <View collapsable={false} className="flex-1 bg-sheet">205 <SettingsScreen title="License notice">
216 {Platform.OS === "android" ? (
217 <>
218 <NativeStackScreenOptions options={{ headerShown: false }} />
219 <AndroidScreenHeader title="License notice" onBack={() => navigation.goBack()} />
220 </>
221 ) : null}
222 <View className="flex-1 items-center justify-center px-6">206 <View className="flex-1 items-center justify-center px-6">
223 <Text className="text-center text-base text-foreground-muted">207 <Text className="text-center text-base text-foreground-muted">
224 This license notice is unavailable.208 This license notice is unavailable.
225 </Text>209 </Text>
226 </View>210 </View>
227 </View>211 </SettingsScreen>
228 );212 );
229 }213 }
230214
231 return (215 return (
232 <View collapsable={false} className="flex-1 bg-sheet">216 <SettingsScreen title="License notice">
233 {Platform.OS === "android" ? (
234 <>
235 <NativeStackScreenOptions options={{ headerShown: false }} />
236 <AndroidScreenHeader title="License notice" onBack={() => navigation.goBack()} />
237 </>
238 ) : null}
239 <ScrollView217 <ScrollView
240 className="flex-1"218 className="flex-1"
241 contentInsetAdjustmentBehavior="automatic"219 contentInsetAdjustmentBehavior="automatic"
...@@ -275,6 +253,6 @@ export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailPro...@@ -275,6 +253,6 @@ export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailPro
275 </Text>253 </Text>
276 </View>254 </View>
277 </ScrollView>255 </ScrollView>
278 </View>256 </SettingsScreen>
279 );257 );
280}258}
apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx+5-13
...@@ -1,14 +1,13 @@...@@ -1,14 +1,13 @@
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
1import { useAtomSet, useAtomValue } from "@effect/atom-react";2import { useAtomSet, useAtomValue } from "@effect/atom-react";
2import { useNavigation } from "@react-navigation/native";
3import type { SidebarProjectGroupingMode } from "@t3tools/contracts";3import type { SidebarProjectGroupingMode } from "@t3tools/contracts";
4import { AsyncResult } from "effect/unstable/reactivity";4import { AsyncResult } from "effect/unstable/reactivity";
5import { Platform, Pressable, ScrollView, View } from "react-native";5import { Pressable, View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";6import { useSafeAreaInsets } from "react-native-safe-area-context";
77
8import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
9import { AppText as Text } from "../../components/AppText";8import { AppText as Text } from "../../components/AppText";
10import { SymbolView } from "../../components/AppSymbol";9import { SymbolView } from "../../components/AppSymbol";
11import { NativeStackScreenOptions } from "../../native/StackHeader";10import { SettingsScreen } from "./components/SettingsScreen";
12import {11import {
13 mobileProjectGroupingModePatch,12 mobileProjectGroupingModePatch,
14 resolveMobileProjectGroupingSettings,13 resolveMobileProjectGroupingSettings,
...@@ -39,7 +38,6 @@ const GROUPING_OPTIONS: ReadonlyArray<{...@@ -39,7 +38,6 @@ const GROUPING_OPTIONS: ReadonlyArray<{
39];38];
4039
41export function SettingsProjectGroupingRouteScreen() {40export function SettingsProjectGroupingRouteScreen() {
42 const navigation = useNavigation();
43 const insets = useSafeAreaInsets();41 const insets = useSafeAreaInsets();
44 const preferencesResult = useAtomValue(mobilePreferencesAtom);42 const preferencesResult = useAtomValue(mobilePreferencesAtom);
45 const savePreferences = useAtomSet(updateMobilePreferencesAtom);43 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
...@@ -49,13 +47,7 @@ export function SettingsProjectGroupingRouteScreen() {...@@ -49,13 +47,7 @@ export function SettingsProjectGroupingRouteScreen() {
49 : null;47 : null;
5048
51 return (49 return (
52 <View collapsable={false} className="flex-1 bg-sheet">50 <SettingsScreen title="Project Grouping">
53 {Platform.OS === "android" ? (
54 <>
55 <NativeStackScreenOptions options={{ headerShown: false }} />
56 <AndroidScreenHeader title="Project Grouping" onBack={() => navigation.goBack()} />
57 </>
58 ) : null}
59 <ScrollView51 <ScrollView
60 contentInsetAdjustmentBehavior="automatic"52 contentInsetAdjustmentBehavior="automatic"
61 showsVerticalScrollIndicator={false}53 showsVerticalScrollIndicator={false}
...@@ -99,6 +91,6 @@ export function SettingsProjectGroupingRouteScreen() {...@@ -99,6 +91,6 @@ export function SettingsProjectGroupingRouteScreen() {
99 ))}91 ))}
100 </SettingsSection>92 </SettingsSection>
101 </ScrollView>93 </ScrollView>
102 </View>94 </SettingsScreen>
103 );95 );
104}96}
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+48-58
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { AutoSettleDaysField } from "./components/AutoSettleDaysField";
2import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
1import { useAuth, useUser } from "@clerk/expo";3import { useAuth, useUser } from "@clerk/expo";
2import { useAtomSet, useAtomValue } from "@effect/atom-react";4import { useAtomSet, useAtomValue } from "@effect/atom-react";
3import Constants from "expo-constants";5import Constants from "expo-constants";
...@@ -8,7 +10,7 @@ import { SymbolView } from "../../components/AppSymbol";...@@ -8,7 +10,7 @@ import { SymbolView } from "../../components/AppSymbol";
8import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
9import { AsyncResult } from "effect/unstable/reactivity";11import { AsyncResult } from "effect/unstable/reactivity";
10import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";12import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
11import { Alert, Linking, Platform, Pressable, ScrollView, View } from "react-native";13import { Alert, Linking, Platform, Pressable, View } from "react-native";
12import { useSafeAreaInsets } from "react-native-safe-area-context";14import { useSafeAreaInsets } from "react-native-safe-area-context";
1315
14import {16import {
...@@ -18,8 +20,7 @@ import {...@@ -18,8 +20,7 @@ import {
18 settlePromise,20 settlePromise,
19 squashAtomCommandFailure,21 squashAtomCommandFailure,
20} from "@t3tools/client-runtime/state/runtime";22} from "@t3tools/client-runtime/state/runtime";
21import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";23import { AppText as Text } from "../../components/AppText";
22import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
23import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities";24import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities";
24import {25import {
25 openAndroidLiveUpdateSettings,26 openAndroidLiveUpdateSettings,
...@@ -37,15 +38,12 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu...@@ -37,15 +38,12 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu
37import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";38import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
38import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";39import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
39import { runtime } from "../../lib/runtime";40import { runtime } from "../../lib/runtime";
41import { cn } from "../../lib/cn";
40import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";42import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
41import { serverEnvironment } from "../../state/server";43import { serverEnvironment } from "../../state/server";
42import { useAtomCommand } from "../../state/use-atom-command";44import { useAtomCommand } from "../../state/use-atom-command";
43import { useEnvironments } from "../../state/environments";45import { useEnvironments } from "../../state/environments";
44import {46import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
45 DEFAULT_SERVER_SETTINGS,
46 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
47 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
48} from "@t3tools/contracts";
49import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings";47import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings";
50import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";48import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
51import {49import {
...@@ -58,6 +56,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re...@@ -58,6 +56,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re
58import { SettingsRow } from "./components/SettingsRow";56import { SettingsRow } from "./components/SettingsRow";
59import { SettingsSection } from "./components/SettingsSection";57import { SettingsSection } from "./components/SettingsSection";
60import { SettingsSwitchRow } from "./components/SettingsSwitchRow";58import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
59import { SettingsScreen } from "./components/SettingsScreen";
61import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic";60import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic";
62import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync";61import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync";
6362
...@@ -79,17 +78,16 @@ function useDeviceRegistered(): boolean {...@@ -79,17 +78,16 @@ function useDeviceRegistered(): boolean {
7978
80export function SettingsRouteScreen() {79export function SettingsRouteScreen() {
81 const navigation = useNavigation();80 const navigation = useNavigation();
81 const content = hasCloudPublicConfig() ? (
82 <ConfiguredSettingsRouteScreen />
83 ) : (
84 <LocalSettingsRouteScreen />
85 );
8286
83 return (87 return (
84 <>88 <>
85 <WorkspaceSidebarToolbar />89 <WorkspaceSidebarToolbar />
86 {Platform.OS === "android" ? (90 {Platform.OS !== "android" ? (
87 <>
88 {/* Android renders its own in-screen header instead of the native bar. */}
89 <NativeStackScreenOptions options={{ headerShown: false }} />
90 <AndroidScreenHeader title="Settings" onBack={() => navigation.goBack()} />
91 </>
92 ) : (
93 <NativeStackScreenOptions91 <NativeStackScreenOptions
94 options={{92 options={{
95 unstable_headerRightItems:93 unstable_headerRightItems:
...@@ -107,8 +105,12 @@ export function SettingsRouteScreen() {...@@ -107,8 +105,12 @@ export function SettingsRouteScreen() {
107 : undefined,105 : undefined,
108 }}106 }}
109 />107 />
108 ) : null}
109 {Platform.OS === "android" ? (
110 <SettingsScreen title="Settings">{content}</SettingsScreen>
111 ) : (
112 content
110 )}113 )}
111 {hasCloudPublicConfig() ? <ConfiguredSettingsRouteScreen /> : <LocalSettingsRouteScreen />}
112 </>114 </>
113 );115 );
114}116}
...@@ -134,6 +136,7 @@ function LocalSettingsRouteScreen() {...@@ -134,6 +136,7 @@ function LocalSettingsRouteScreen() {
134 icon="desktopcomputer"136 icon="desktopcomputer"
135 label="Environments"137 label="Environments"
136 value={`${environmentCount}`}138 value={`${environmentCount}`}
139 valuePosition="trailing"
137 target="SettingsEnvironments"140 target="SettingsEnvironments"
138 />141 />
139 </SettingsSection>142 </SettingsSection>
...@@ -516,6 +519,7 @@ function ConfiguredSettingsRouteScreen() {...@@ -516,6 +519,7 @@ function ConfiguredSettingsRouteScreen() {
516 icon="desktopcomputer"519 icon="desktopcomputer"
517 label="Environments"520 label="Environments"
518 value={`${environmentCount}`}521 value={`${environmentCount}`}
522 valuePosition="trailing"
519 target="SettingsEnvironments"523 target="SettingsEnvironments"
520 />524 />
521 <SettingsSwitchRow525 <SettingsSwitchRow
...@@ -616,6 +620,7 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD...@@ -616,6 +620,7 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD
616 */620 */
617function AutoSettleSettingsRows() {621function AutoSettleSettingsRows() {
618 const { environments } = useEnvironments();622 const { environments } = useEnvironments();
623 const [pendingWrites, setPendingWrites] = useState(0);
619 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {624 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
620 label: "server settings update",625 label: "server settings update",
621 reportFailure: true,626 reportFailure: true,
...@@ -625,16 +630,17 @@ function AutoSettleSettingsRows() {...@@ -625,16 +630,17 @@ function AutoSettleSettingsRows() {
625 const reference = syncTargets[0] ?? null;630 const reference = syncTargets[0] ?? null;
626 const referenceSettings = reference?.serverConfig?.settings ?? null;631 const referenceSettings = reference?.serverConfig?.settings ?? null;
627632
628 const [daysDraft, setDaysDraft] = useState<string | null>(null);
629
630 if (reference === null || referenceSettings === null) {633 if (reference === null || referenceSettings === null) {
631 return null;634 return null;
632 }635 }
633636
634 const writeToAll = (patch: Partial<AutoSettleSettings>) => {637 const writeToAll = (patch: Partial<AutoSettleSettings>) => {
635 for (const environment of syncTargets) {638 setPendingWrites((count) => count + 1);
636 void updateSettings({ environmentId: environment.environmentId, input: { patch } });639 void Promise.allSettled(
637 }640 syncTargets.map((environment) =>
641 updateSettings({ environmentId: environment.environmentId, input: { patch } }),
642 ),
643 ).finally(() => setPendingWrites((count) => count - 1));
638 };644 };
639645
640 const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync(646 const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync(
...@@ -647,21 +653,6 @@ function AutoSettleSettingsRows() {...@@ -647,21 +653,6 @@ function AutoSettleSettingsRows() {
647 );653 );
648654
649 const afterDays = referenceSettings.sidebarAutoSettleAfterDays;655 const afterDays = referenceSettings.sidebarAutoSettleAfterDays;
650 const commitDays = () => {
651 const draft = (daysDraft ?? "").trim();
652 setDaysDraft(null);
653 // Whole-string check so "3.5" and "3days" are rejected instead of
654 // silently becoming 3 on every eligible sync target.
655 const parsed = /^\d+$/.test(draft) ? Number(draft) : Number.NaN;
656 if (
657 Number.isInteger(parsed) &&
658 parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS &&
659 parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS &&
660 parsed !== afterDays
661 ) {
662 writeToAll({ sidebarAutoSettleAfterDays: parsed });
663 }
664 };
665656
666 return (657 return (
667 <>658 <>
...@@ -674,28 +665,34 @@ function AutoSettleSettingsRows() {...@@ -674,28 +665,34 @@ function AutoSettleSettingsRows() {
674 <SettingsSwitchRow665 <SettingsSwitchRow
675 icon="clock"666 icon="clock"
676 label="Auto-settle inactive threads"667 label="Auto-settle inactive threads"
677 subtitle={afterDays === null ? undefined : `After ${afterDays} days without activity`}
678 value={afterDays !== null}668 value={afterDays !== null}
679 onValueChange={(value) =>669 onValueChange={(value) =>
680 writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null })670 writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null })
681 }671 }
682 />672 />
683 {afterDays !== null ? (673 {afterDays !== null ? (
684 <View className="flex-row items-center gap-4 border-t border-border-subtle p-4">674 <View
685 <Text className="flex-1 text-lg text-foreground">Days before auto-settle</Text>675 className={cn(
686 <TextInput676 "flex-row items-center gap-4 px-4",
687 className="min-h-10 w-20 rounded-xl px-3 py-2 text-center text-base"677 Platform.OS === "android" ? "min-h-14 py-3" : "py-4",
688 keyboardType="number-pad"678 )}
689 returnKeyType="done"679 >
690 value={daysDraft ?? String(afterDays)}680 <View style={{ width: Platform.OS === "android" ? 24 : 22 }} />
691 onChangeText={setDaysDraft}681 <Text
692 onBlur={commitDays}682 className={cn(
693 onSubmitEditing={commitDays}683 "flex-1 text-foreground",
694 accessibilityLabel="Days before auto-settle"684 Platform.OS === "android" ? "text-base" : "text-lg",
685 )}
686 >
687 Inactive days
688 </Text>
689 <AutoSettleDaysField
690 value={afterDays}
691 onValueChange={(value) => writeToAll({ sidebarAutoSettleAfterDays: value })}
695 />692 />
696 </View>693 </View>
697 ) : null}694 ) : null}
698 {mismatches.length > 0 ? (695 {pendingWrites === 0 && mismatches.length > 0 ? (
699 <View className="flex-row items-center gap-4 border-t border-border-subtle p-4">696 <View className="flex-row items-center gap-4 border-t border-border-subtle p-4">
700 <View className="min-w-0 flex-1">697 <View className="min-w-0 flex-1">
701 <Text className="text-lg text-foreground">Auto-settle defaults differ</Text>698 <Text className="text-lg text-foreground">Auto-settle defaults differ</Text>
...@@ -705,14 +702,7 @@ function AutoSettleSettingsRows() {...@@ -705,14 +702,7 @@ function AutoSettleSettingsRows() {
705 </View>702 </View>
706 <Pressable703 <Pressable
707 accessibilityRole="button"704 accessibilityRole="button"
708 onPress={() => {705 onPress={() => writeToAll(autoSettlePatch)}
709 for (const mismatch of mismatches) {
710 void updateSettings({
711 environmentId: mismatch.environmentId,
712 input: { patch: autoSettlePatch },
713 });
714 }
715 }}
716 className="rounded-full bg-subtle px-4 py-2 active:opacity-70"706 className="rounded-full bg-subtle px-4 py-2 active:opacity-70"
717 >707 >
718 <Text className="text-base font-t3-medium text-foreground">708 <Text className="text-base font-t3-medium text-foreground">
...@@ -831,7 +821,7 @@ function AppSettingsSection() {...@@ -831,7 +821,7 @@ function AppSettingsSection() {
831 <SymbolView821 <SymbolView
832 name="info.circle"822 name="info.circle"
833 size={22}823 size={22}
834 tintColorClassName={"accent-icon"}824 tintColorClassName="accent-icon"
835 type="monochrome"825 type="monochrome"
836 weight="regular"826 weight="regular"
837 />827 />
apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx+1-19
...@@ -51,9 +51,6 @@ interface AppearancePreferencesContextValue {...@@ -51,9 +51,6 @@ interface AppearancePreferencesContextValue {
51 readonly themeIds: MobileThemeIds;51 readonly themeIds: MobileThemeIds;
52 readonly themeMode: MobileThemeMode;52 readonly themeMode: MobileThemeMode;
53 readonly themeAppearance: MobileThemeAppearance;53 readonly themeAppearance: MobileThemeAppearance;
54 readonly materialYouStyleLayoutEnabled: boolean;
55 readonly materialYouStyleLayoutActive: boolean;
56 readonly setMaterialYouStyleLayoutEnabled: (value: boolean) => void;
57 readonly systemColorsAvailable: boolean;54 readonly systemColorsAvailable: boolean;
58 readonly systemColorsActive: boolean;55 readonly systemColorsActive: boolean;
59 readonly themeVariables: MobileThemeVariables;56 readonly themeVariables: MobileThemeVariables;
...@@ -97,8 +94,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN...@@ -97,8 +94,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
97 [resolvedThemeIds.dark, resolvedThemeIds.light],94 [resolvedThemeIds.dark, resolvedThemeIds.light],
98 );95 );
99 const themeId = themeIds[themeAppearance];96 const themeId = themeIds[themeAppearance];
100 const materialYouStyleLayoutEnabled = storedPreferences?.materialYouStyleLayoutEnabled ?? false;
101 const materialYouStyleLayoutActive = Platform.OS === "android" && materialYouStyleLayoutEnabled;
102 const systemColorsActive = themeId === "material-you" && isSystemColorsAvailable;97 const systemColorsActive = themeId === "material-you" && isSystemColorsAvailable;
103 const [systemColorPalettes, setSystemColorPalettes] = useState(readSystemColorPalettes);98 const [systemColorPalettes, setSystemColorPalettes] = useState(readSystemColorPalettes);
104 useEffect(() => {99 useEffect(() => {
...@@ -120,7 +115,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN...@@ -120,7 +115,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
120 }, []);115 }, []);
121 const themeVariablesByAppearance = useMemo(() => {116 const themeVariablesByAppearance = useMemo(() => {
122 const resolve = (appearance: MobileThemeAppearance) => {117 const resolve = (appearance: MobileThemeAppearance) => {
123 const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance);118 const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance, Platform.OS);
124 return themeIds[appearance] === "material-you" && systemColorPalettes119 return themeIds[appearance] === "material-you" && systemColorPalettes
125 ? materialYouPaletteToMobileThemeVariables(120 ? materialYouPaletteToMobileThemeVariables(
126 systemColorPalettes[appearance],121 systemColorPalettes[appearance],
...@@ -247,13 +242,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN...@@ -247,13 +242,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
247 [runtimeState, syncThemeRuntime, updateThemePreferences],242 [runtimeState, syncThemeRuntime, updateThemePreferences],
248 );243 );
249244
250 const setMaterialYouStyleLayoutEnabled = useCallback(
251 (value: boolean) => {
252 updatePreferences({ materialYouStyleLayoutEnabled: value });
253 },
254 [updatePreferences],
255 );
256
257 const setBaseFontSize = useCallback(245 const setBaseFontSize = useCallback(
258 (value: number) => {246 (value: number) => {
259 const current = appliedRuntimeStateRef.current ?? runtimeState;247 const current = appliedRuntimeStateRef.current ?? runtimeState;
...@@ -293,9 +281,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN...@@ -293,9 +281,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
293 themeAppearance,281 themeAppearance,
294 systemColorsAvailable: isSystemColorsAvailable,282 systemColorsAvailable: isSystemColorsAvailable,
295 systemColorsActive,283 systemColorsActive,
296 materialYouStyleLayoutEnabled,
297 materialYouStyleLayoutActive,
298 setMaterialYouStyleLayoutEnabled,
299 themeVariables,284 themeVariables,
300 themeVariablesByAppearance,285 themeVariablesByAppearance,
301 systemColorPalettes,286 systemColorPalettes,
...@@ -315,9 +300,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN...@@ -315,9 +300,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
315 themeMode,300 themeMode,
316 themeAppearance,301 themeAppearance,
317 systemColorsActive,302 systemColorsActive,
318 materialYouStyleLayoutEnabled,
319 materialYouStyleLayoutActive,
320 setMaterialYouStyleLayoutEnabled,
321 themeVariables,303 themeVariables,
322 themeVariablesByAppearance,304 themeVariablesByAppearance,
323 systemColorPalettes,305 systemColorPalettes,
apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.android.tsx created+81
...@@ -0,0 +1,81 @@
1import { Host, Slider } from "@expo/ui/jetpack-compose";
2import { fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers";
3import * as Haptics from "expo-haptics";
4import { useRef, type ComponentProps } from "react";
5import { View } from "react-native";
6
7import { AppText as Text } from "../../../../components/AppText";
8import { SymbolView } from "../../../../components/AppSymbol";
9import { useAppearancePreferences } from "../AppearancePreferencesProvider";
10import type { FontSizeSliderRow as SharedFontSizeSliderRow } from "./FontSizeSliderRow.shared";
11
12export function FontSizeSliderRow(props: ComponentProps<typeof SharedFontSizeSliderRow>) {
13 const {
14 themeAppearance,
15 systemColorsActive,
16 themeVariables: colors,
17 } = useAppearancePreferences();
18 const draft = useRef(props.value);
19 const commit = (value: number) => {
20 if (props.disabled) return;
21 const next = Math.min(
22 props.max,
23 Math.max(props.min, props.min + Math.round((value - props.min) / props.step) * props.step),
24 );
25 if (next === props.value) return;
26 void Haptics.selectionAsync().catch(() => undefined);
27 props.onChange(next);
28 };
29 return (
30 <View className={props.disabled ? "gap-2 p-4 opacity-45" : "gap-2 p-4"}>
31 <View className="flex-row items-center gap-4">
32 <SymbolView name={props.icon} size={24} tintColorClassName="accent-icon" />
33 <Text className="min-w-0 flex-1 text-base">{props.label}</Text>
34 <Text className="text-sm text-foreground-muted">{props.valueLabel}</Text>
35 </View>
36 <View
37 accessible
38 accessibilityLabel={props.label}
39 accessibilityRole="adjustable"
40 accessibilityState={{ disabled: Boolean(props.disabled) }}
41 accessibilityValue={{
42 min: props.min,
43 max: props.max,
44 now: props.value,
45 text: props.valueLabel,
46 }}
47 accessibilityActions={[{ name: "increment" }, { name: "decrement" }]}
48 onAccessibilityAction={({ nativeEvent }) => {
49 if (nativeEvent.actionName === "increment") commit(props.value + props.step);
50 else if (nativeEvent.actionName === "decrement") commit(props.value - props.step);
51 }}
52 >
53 <View importantForAccessibility="no-hide-descendants">
54 <Host
55 colorScheme={themeAppearance}
56 seedColor={systemColorsActive ? undefined : colors["--color-primary"]}
57 style={{ height: 48, width: "100%" }}
58 >
59 <Slider
60 min={props.min}
61 max={props.max}
62 value={props.value}
63 steps={Math.max(0, Math.round((props.max - props.min) / props.step) - 1)}
64 enabled={!props.disabled}
65 modifiers={[fillMaxWidth()]}
66 colors={{
67 thumbColor: colors["--color-primary"],
68 activeTrackColor: colors["--color-primary"],
69 inactiveTrackColor: colors["--color-secondary"],
70 }}
71 onValueChange={(value) => {
72 draft.current = value;
73 }}
74 onValueChangeFinished={() => commit(draft.current)}
75 />
76 </Host>
77 </View>
78 </View>
79 </View>
80 );
81}
apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.shared.tsx created+211
...@@ -0,0 +1,211 @@
1import * as Haptics from "expo-haptics";
2import { SymbolView } from "../../../../components/AppSymbol";
3import { useCallback, useEffect, useMemo, useRef } from "react";
4import { View, type AccessibilityActionEvent } from "react-native";
5import { Gesture, GestureDetector } from "react-native-gesture-handler";
6import Animated, {
7 runOnJS,
8 useAnimatedStyle,
9 useSharedValue,
10 withTiming,
11} from "react-native-reanimated";
12import type { ComponentProps } from "react";
13
14import { AppText as Text } from "../../../../components/AppText";
15
16type SymbolName = ComponentProps<typeof SymbolView>["name"];
17
18const THUMB_SIZE = 26;
19const TRACK_HEIGHT = 4;
20const SNAP_ANIMATION = { duration: 120 } as const;
21
22function clampFraction(value: number): number {
23 "worklet";
24 return Math.min(1, Math.max(0, value));
25}
26
27export function FontSizeSliderRow(props: {
28 readonly disabled?: boolean;
29 readonly icon: SymbolName;
30 readonly label: string;
31 readonly valueLabel: string;
32 readonly min: number;
33 readonly max: number;
34 readonly step: number;
35 readonly value: number;
36 readonly onChange: (value: number) => void;
37}) {
38 const latest = useRef(props);
39 latest.current = props;
40
41 const { min, max, step, value, disabled } = props;
42 const fraction = (value - min) / (max - min);
43
44 const progress = useSharedValue(clampFraction(fraction));
45 const trackWidth = useSharedValue(0);
46 const dragging = useSharedValue(false);
47
48 useEffect(() => {
49 if (!dragging.value) {
50 progress.value = withTiming(clampFraction(fraction), SNAP_ANIMATION);
51 }
52 }, [dragging, fraction, progress]);
53
54 const commit = useCallback((next: number) => {
55 const current = latest.current;
56 if (current.disabled || next === current.value) {
57 return;
58 }
59 Haptics.selectionAsync().catch(() => undefined);
60 current.onChange(next);
61 }, []);
62
63 const gesture = useMemo(() => {
64 const snapValue = (raw: number): number => {
65 "worklet";
66 const stepped = Math.round((raw - min) / step) * step + min;
67 return Math.min(max, Math.max(min, stepped));
68 };
69 const fractionAt = (x: number): number => {
70 "worklet";
71 const usable = trackWidth.value - THUMB_SIZE;
72 if (usable <= 0) {
73 return 0;
74 }
75 return clampFraction((x - THUMB_SIZE / 2) / usable);
76 };
77 const valueAtFraction = (f: number): number => {
78 "worklet";
79 return snapValue(min + f * (max - min));
80 };
81 const fractionOfValue = (v: number): number => {
82 "worklet";
83 return clampFraction((v - min) / (max - min));
84 };
85
86 const pan = Gesture.Pan()
87 .enabled(!disabled)
88 .activeOffsetX([-8, 8])
89 .failOffsetY([-12, 12])
90 .onUpdate((event) => {
91 dragging.value = true;
92 const f = fractionAt(event.x);
93 progress.value = f;
94 })
95 .onFinalize((_event, success) => {
96 if (!dragging.value) {
97 return;
98 }
99 dragging.value = false;
100 if (!success) {
101 progress.value = withTiming(fractionOfValue(value), SNAP_ANIMATION);
102 return;
103 }
104 const next = valueAtFraction(progress.value);
105 progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION);
106 runOnJS(commit)(next);
107 });
108
109 const tap = Gesture.Tap()
110 .enabled(!disabled)
111 .onEnd((event) => {
112 const next = valueAtFraction(fractionAt(event.x));
113 progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION);
114 runOnJS(commit)(next);
115 });
116
117 return Gesture.Race(pan, tap);
118 }, [commit, disabled, dragging, max, min, progress, step, trackWidth, value]);
119
120 const fillStyle = useAnimatedStyle(() => ({
121 width: THUMB_SIZE / 2 + progress.value * Math.max(0, trackWidth.value - THUMB_SIZE),
122 }));
123 const thumbStyle = useAnimatedStyle(() => ({
124 transform: [{ translateX: progress.value * Math.max(0, trackWidth.value - THUMB_SIZE) }],
125 }));
126
127 const handleAccessibilityAction = (event: AccessibilityActionEvent) => {
128 if (event.nativeEvent.actionName === "increment") {
129 commit(Math.min(max, value + step));
130 } else if (event.nativeEvent.actionName === "decrement") {
131 commit(Math.max(min, value - step));
132 }
133 };
134
135 return (
136 <View className={disabled ? "gap-1 p-4 opacity-[0.45]" : "gap-1 p-4"}>
137 <View className="flex-row items-center gap-4">
138 <SymbolView
139 name={props.icon}
140 size={22}
141 tintColorClassName={"accent-icon"}
142 type="monochrome"
143 weight="regular"
144 />
145 <Text className="flex-1 text-lg text-foreground">{props.label}</Text>
146 <Text className="text-base font-t3-medium text-foreground-muted">{props.valueLabel}</Text>
147 </View>
148 <View className="flex-row items-center gap-3">
149 <SymbolView
150 name="textformat.size.smaller"
151 size={15}
152 tintColorClassName={"accent-icon-muted"}
153 type="monochrome"
154 weight="regular"
155 />
156 <GestureDetector gesture={gesture}>
157 <View
158 accessible
159 accessibilityActions={[
160 { name: "increment", label: `Increase ${props.label}` },
161 { name: "decrement", label: `Decrease ${props.label}` },
162 ]}
163 accessibilityLabel={props.label}
164 accessibilityRole="adjustable"
165 accessibilityState={{ disabled: Boolean(disabled) }}
166 accessibilityValue={{ min, max, now: value, text: props.valueLabel }}
167 className="h-11 flex-1 justify-center"
168 onAccessibilityAction={handleAccessibilityAction}
169 onLayout={(event) => {
170 trackWidth.value = event.nativeEvent.layout.width;
171 }}
172 >
173 <View
174 className="w-full rounded-full bg-secondary-border"
175 style={{ height: TRACK_HEIGHT }}
176 >
177 <Animated.View
178 className="absolute inset-y-0 left-0 rounded-full bg-primary"
179 style={fillStyle}
180 />
181 </View>
182 <Animated.View
183 className="absolute left-0 rounded-full border-border bg-primary-foreground"
184 style={[
185 {
186 borderWidth: 1,
187 height: THUMB_SIZE,
188 marginTop: -THUMB_SIZE / 2,
189 shadowColor: "#000000",
190 shadowOffset: { height: 2, width: 0 },
191 shadowOpacity: 0.18,
192 shadowRadius: 3,
193 top: "50%",
194 width: THUMB_SIZE,
195 },
196 thumbStyle,
197 ]}
198 />
199 </View>
200 </GestureDetector>
201 <SymbolView
202 name="textformat.size.larger"
203 size={22}
204 tintColorClassName={"accent-icon-muted"}
205 type="monochrome"
206 weight="regular"
207 />
208 </View>
209 </View>
210 );
211}
apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx+1-210
...@@ -1,210 +1 @@...@@ -1,210 +1 @@
1import * as Haptics from "expo-haptics";1export { FontSizeSliderRow } from "./FontSizeSliderRow.shared";
2import { SymbolView } from "../../../../components/AppSymbol";
3import { useCallback, useEffect, useMemo, useRef } from "react";
4import { View, type AccessibilityActionEvent } from "react-native";
5import { Gesture, GestureDetector } from "react-native-gesture-handler";
6import Animated, {
7 runOnJS,
8 useAnimatedStyle,
9 useSharedValue,
10 withTiming,
11} from "react-native-reanimated";
12import type { ComponentProps } from "react";
13
14import { AppText as Text } from "../../../../components/AppText";
15
16type SymbolName = ComponentProps<typeof SymbolView>["name"];
17
18const THUMB_SIZE = 26;
19const TRACK_HEIGHT = 4;
20const SNAP_ANIMATION = { duration: 120 } as const;
21
22function clampFraction(value: number): number {
23 "worklet";
24 return Math.min(1, Math.max(0, value));
25}
26
27export function FontSizeSliderRow(props: {
28 readonly disabled?: boolean;
29 readonly icon: SymbolName;
30 readonly label: string;
31 readonly valueLabel: string;
32 readonly min: number;
33 readonly max: number;
34 readonly step: number;
35 readonly value: number;
36 readonly onChange: (value: number) => void;
37}) {
38 const latest = useRef(props);
39 latest.current = props;
40
41 const { min, max, step, value, disabled } = props;
42 const fraction = (value - min) / (max - min);
43
44 const progress = useSharedValue(clampFraction(fraction));
45 const trackWidth = useSharedValue(0);
46 const dragging = useSharedValue(false);
47
48 useEffect(() => {
49 if (!dragging.value) {
50 progress.value = withTiming(clampFraction(fraction), SNAP_ANIMATION);
51 }
52 }, [dragging, fraction, progress]);
53
54 const commit = useCallback((next: number) => {
55 const current = latest.current;
56 if (next === current.value) {
57 return;
58 }
59 Haptics.selectionAsync().catch(() => undefined);
60 current.onChange(next);
61 }, []);
62
63 const gesture = useMemo(() => {
64 const snapValue = (raw: number): number => {
65 "worklet";
66 const stepped = Math.round((raw - min) / step) * step + min;
67 return Math.min(max, Math.max(min, stepped));
68 };
69 const fractionAt = (x: number): number => {
70 "worklet";
71 const usable = trackWidth.value - THUMB_SIZE;
72 if (usable <= 0) {
73 return 0;
74 }
75 return clampFraction((x - THUMB_SIZE / 2) / usable);
76 };
77 const valueAtFraction = (f: number): number => {
78 "worklet";
79 return snapValue(min + f * (max - min));
80 };
81 const fractionOfValue = (v: number): number => {
82 "worklet";
83 return clampFraction((v - min) / (max - min));
84 };
85
86 const pan = Gesture.Pan()
87 .enabled(!disabled)
88 .activeOffsetX([-8, 8])
89 .failOffsetY([-12, 12])
90 .onUpdate((event) => {
91 dragging.value = true;
92 const f = fractionAt(event.x);
93 progress.value = f;
94 })
95 .onFinalize((_event, success) => {
96 if (!dragging.value) {
97 return;
98 }
99 dragging.value = false;
100 if (!success) {
101 progress.value = withTiming(fractionOfValue(value), SNAP_ANIMATION);
102 return;
103 }
104 const next = valueAtFraction(progress.value);
105 progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION);
106 runOnJS(commit)(next);
107 });
108
109 const tap = Gesture.Tap()
110 .enabled(!disabled)
111 .onEnd((event) => {
112 const next = valueAtFraction(fractionAt(event.x));
113 progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION);
114 runOnJS(commit)(next);
115 });
116
117 return Gesture.Race(pan, tap);
118 }, [commit, disabled, dragging, max, min, progress, step, trackWidth, value]);
119
120 const fillStyle = useAnimatedStyle(() => ({
121 width: THUMB_SIZE / 2 + progress.value * Math.max(0, trackWidth.value - THUMB_SIZE),
122 }));
123 const thumbStyle = useAnimatedStyle(() => ({
124 transform: [{ translateX: progress.value * Math.max(0, trackWidth.value - THUMB_SIZE) }],
125 }));
126
127 const handleAccessibilityAction = (event: AccessibilityActionEvent) => {
128 if (event.nativeEvent.actionName === "increment") {
129 commit(Math.min(max, value + step));
130 } else if (event.nativeEvent.actionName === "decrement") {
131 commit(Math.max(min, value - step));
132 }
133 };
134
135 return (
136 <View className={disabled ? "gap-1 p-4 opacity-[0.45]" : "gap-1 p-4"}>
137 <View className="flex-row items-center gap-4">
138 <SymbolView
139 name={props.icon}
140 size={22}
141 tintColorClassName={"accent-icon"}
142 type="monochrome"
143 weight="regular"
144 />
145 <Text className="flex-1 text-lg text-foreground">{props.label}</Text>
146 <Text className="text-base font-t3-medium text-foreground-muted">{props.valueLabel}</Text>
147 </View>
148 <View className="flex-row items-center gap-3">
149 <SymbolView
150 name="textformat.size.smaller"
151 size={15}
152 tintColorClassName={"accent-icon-muted"}
153 type="monochrome"
154 weight="regular"
155 />
156 <GestureDetector gesture={gesture}>
157 <View
158 accessible
159 accessibilityActions={[
160 { name: "increment", label: `Increase ${props.label}` },
161 { name: "decrement", label: `Decrease ${props.label}` },
162 ]}
163 accessibilityLabel={props.label}
164 accessibilityRole="adjustable"
165 accessibilityValue={{ min, max, now: value, text: props.valueLabel }}
166 className="h-11 flex-1 justify-center"
167 onAccessibilityAction={handleAccessibilityAction}
168 onLayout={(event) => {
169 trackWidth.value = event.nativeEvent.layout.width;
170 }}
171 >
172 <View
173 className="w-full rounded-full bg-secondary-border"
174 style={{ height: TRACK_HEIGHT }}
175 >
176 <Animated.View
177 className="absolute inset-y-0 left-0 rounded-full bg-primary"
178 style={fillStyle}
179 />
180 </View>
181 <Animated.View
182 className="absolute left-0 rounded-full border-border bg-primary-foreground"
183 style={[
184 {
185 borderWidth: 1,
186 height: THUMB_SIZE,
187 marginTop: -THUMB_SIZE / 2,
188 shadowColor: "#000000",
189 shadowOffset: { height: 2, width: 0 },
190 shadowOpacity: 0.18,
191 shadowRadius: 3,
192 top: "50%",
193 width: THUMB_SIZE,
194 },
195 thumbStyle,
196 ]}
197 />
198 </View>
199 </GestureDetector>
200 <SymbolView
201 name="textformat.size.larger"
202 size={22}
203 tintColorClassName={"accent-icon-muted"}
204 type="monochrome"
205 weight="regular"
206 />
207 </View>
208 </View>
209 );
210}
apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx+1-18
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { memo, useId } from "react";1import { memo, useId } from "react";
2import { Platform, Pressable, View } from "react-native";2import { Pressable, View } from "react-native";
3import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg";3import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg";
4import { ScopedTheme, ScopedVariables } from "uniwind";4import { ScopedTheme, ScopedVariables } from "uniwind";
55
...@@ -19,9 +19,6 @@ import { getMobileUniwindThemeName } from "../../../../lib/mobileThemeRuntime";...@@ -19,9 +19,6 @@ import { getMobileUniwindThemeName } from "../../../../lib/mobileThemeRuntime";
19import { cn } from "../../../../lib/cn";19import { cn } from "../../../../lib/cn";
20import { useAppearancePreferences } from "../AppearancePreferencesProvider";20import { useAppearancePreferences } from "../AppearancePreferencesProvider";
2121
22import { SettingsSection } from "../../components/SettingsSection";
23import { SettingsSwitchRow } from "../../components/SettingsSwitchRow";
24
25const APPEARANCE_MODES: ReadonlyArray<{22const APPEARANCE_MODES: ReadonlyArray<{
26 readonly id: MobileThemeMode;23 readonly id: MobileThemeMode;
27 readonly label: string;24 readonly label: string;
...@@ -293,25 +290,11 @@ export function ThemeAppearanceSection() {...@@ -293,25 +290,11 @@ export function ThemeAppearanceSection() {
293 setThemeMode,290 setThemeMode,
294 themeIds,291 themeIds,
295 themeMode,292 themeMode,
296 materialYouStyleLayoutEnabled,
297 setMaterialYouStyleLayoutEnabled,
298 systemColorsAvailable,293 systemColorsAvailable,
299 } = useAppearancePreferences();294 } = useAppearancePreferences();
300295
301 return (296 return (
302 <View className="gap-6">297 <View className="gap-6">
303 {Platform.OS === "android" ? (
304 <SettingsSection card title="Android">
305 <SettingsSwitchRow
306 disabled={!isReady}
307 icon="square.grid.2x2"
308 label="Material You Layout"
309 onValueChange={setMaterialYouStyleLayoutEnabled}
310 subtitle="Use Material You surfaces, shapes, and component styling."
311 value={materialYouStyleLayoutEnabled}
312 />
313 </SettingsSection>
314 ) : null}
315 <View className="gap-2">298 <View className="gap-2">
316 <SectionLabel>Color scheme</SectionLabel>299 <SectionLabel>Color scheme</SectionLabel>
317 <View accessibilityRole="radiogroup" className="flex-row gap-2">300 <View accessibilityRole="radiogroup" className="flex-row gap-2">
apps/mobile/src/features/settings/components/AutoSettleDaysField.android.tsx created+50
...@@ -0,0 +1,50 @@
1import {
2 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
3 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
4} from "@t3tools/contracts";
5import { View } from "react-native";
6
7import { AppText } from "../../../components/AppText";
8import { MaterialIconButton } from "../../../components/MaterialIconButton";
9import type { AutoSettleDaysFieldProps } from "./AutoSettleDaysField";
10
11export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) {
12 const adjust = (amount: number) => {
13 const next = Math.max(
14 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
15 Math.min(MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, props.value + amount),
16 );
17 if (next !== props.value) props.onValueChange(next);
18 };
19
20 return (
21 <View className="shrink-0 flex-row items-center">
22 {/* Match the 32dp switch track while retaining 48dp button touch targets. */}
23 <View
24 pointerEvents="none"
25 className="absolute inset-x-0 rounded-full bg-subtle"
26 style={{ height: 32 }}
27 />
28 <MaterialIconButton
29 icon="minus"
30 accessibilityLabel="Decrease days before auto-settle"
31 disabled={props.value <= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS}
32 onPress={() => adjust(-1)}
33 />
34 <AppText
35 className="min-w-8 text-center text-base"
36 style={{ fontVariant: ["tabular-nums"] }}
37 accessibilityLabel={`${props.value} ${props.value === 1 ? "day" : "days"} before auto-settle`}
38 accessibilityLiveRegion="polite"
39 >
40 {props.value}
41 </AppText>
42 <MaterialIconButton
43 icon="plus"
44 accessibilityLabel="Increase days before auto-settle"
45 disabled={props.value >= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS}
46 onPress={() => adjust(1)}
47 />
48 </View>
49 );
50}
apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx created+94
...@@ -0,0 +1,94 @@
1import { Button, Host, HStack, Picker, Popover, Text, VStack } from "@expo/ui/swift-ui";
2import {
3 accessibilityLabel,
4 buttonStyle,
5 font,
6 foregroundStyle,
7 frame,
8 padding,
9 pickerStyle,
10 presentationBackground,
11 tag,
12} from "@expo/ui/swift-ui/modifiers";
13import {
14 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
15 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
16} from "@t3tools/contracts";
17import { useState } from "react";
18
19import { useAppearancePreferences } from "../appearance/AppearancePreferencesProvider";
20import type { AutoSettleDaysFieldProps } from "./AutoSettleDaysField";
21
22const days = Array.from(
23 { length: MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS - MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS + 1 },
24 (_, index) => MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS + index,
25);
26
27export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) {
28 const { themeAppearance, themeVariables: colors, appearance } = useAppearancePreferences();
29 const [open, setOpen] = useState(false);
30 const [draft, setDraft] = useState(props.value);
31 return (
32 <Host matchContents colorScheme={themeAppearance} seedColor={colors["--color-primary"]}>
33 <Popover isPresented={open} onIsPresentedChange={setOpen}>
34 <Popover.Trigger>
35 <Button
36 onPress={() => {
37 setDraft(props.value);
38 setOpen(true);
39 }}
40 modifiers={[
41 buttonStyle("bordered"),
42 accessibilityLabel(`Days before auto-settle: ${props.value}`),
43 frame({ minWidth: 64, minHeight: 44 }),
44 foregroundStyle(colors["--color-primary"]),
45 font({ size: appearance.baseFontSize }),
46 ]}
47 >
48 <Text>{String(props.value)}</Text>
49 </Button>
50 </Popover.Trigger>
51 <Popover.Content>
52 <VStack
53 modifiers={[
54 padding({ all: 12 }),
55 frame({ width: 240 }),
56 presentationBackground(colors["--color-sheet-solid"]),
57 ]}
58 >
59 <Picker
60 label="Days before auto-settle"
61 selection={draft}
62 onSelectionChange={setDraft}
63 modifiers={[pickerStyle("wheel"), frame({ height: 180 })]}
64 >
65 {days.map((value) => (
66 <Text
67 key={value}
68 modifiers={[tag(value), foregroundStyle(colors["--color-foreground"])]}
69 >
70 {`${value} ${value === 1 ? "day" : "days"}`}
71 </Text>
72 ))}
73 </Picker>
74 <HStack spacing={24}>
75 <Button
76 label="Cancel"
77 onPress={() => setOpen(false)}
78 modifiers={[foregroundStyle(colors["--color-primary"])]}
79 />
80 <Button
81 label="Done"
82 onPress={() => {
83 setOpen(false);
84 if (draft !== props.value) props.onValueChange(draft);
85 }}
86 modifiers={[foregroundStyle(colors["--color-primary"])]}
87 />
88 </HStack>
89 </VStack>
90 </Popover.Content>
91 </Popover>
92 </Host>
93 );
94}
apps/mobile/src/features/settings/components/AutoSettleDaysField.tsx created+42
...@@ -0,0 +1,42 @@
1import { useState } from "react";
2import {
3 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
4 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
5} from "@t3tools/contracts";
6
7import { AppTextInput } from "../../../components/AppText";
8
9export interface AutoSettleDaysFieldProps {
10 readonly value: number;
11 readonly onValueChange: (value: number) => void;
12}
13
14export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) {
15 const [draft, setDraft] = useState<string | null>(null);
16 const commit = () => {
17 const text = (draft ?? "").trim();
18 setDraft(null);
19 // Validate the whole input; decimals and trailing text must not become whole days.
20 const parsed = /^\d+$/.test(text) ? Number(text) : Number.NaN;
21 if (
22 Number.isInteger(parsed) &&
23 parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS &&
24 parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS &&
25 parsed !== props.value
26 ) {
27 props.onValueChange(parsed);
28 }
29 };
30 return (
31 <AppTextInput
32 className="min-h-10 w-20 rounded-xl px-3 py-2 text-center text-base"
33 keyboardType="number-pad"
34 returnKeyType="done"
35 value={draft ?? String(props.value)}
36 onChangeText={setDraft}
37 onBlur={commit}
38 onSubmitEditing={commit}
39 accessibilityLabel="Days before auto-settle"
40 />
41 );
42}
apps/mobile/src/features/settings/components/SettingsRow.tsx+65-26
...@@ -1,11 +1,12 @@...@@ -1,11 +1,12 @@
1import { MaterialListRow } from "../../../components/MaterialListRow";
1import { useNavigation } from "@react-navigation/native";2import { useNavigation } from "@react-navigation/native";
2import type { ComponentProps } from "react";3import type { ComponentProps } from "react";
3import { Pressable, View } from "react-native";4import { Platform, Pressable, View } from "react-native";
45
5import { SymbolView } from "../../../components/AppSymbol";6import { SymbolView } from "../../../components/AppSymbol";
6
7import { AppText as Text } from "../../../components/AppText";7import { AppText as Text } from "../../../components/AppText";
8import type { SettingsLegalDocumentTarget, SettingsSheetTarget } from "./settings-sheet-targets";8import type { SettingsLegalDocumentTarget, SettingsSheetTarget } from "./settings-sheet-targets";
9import { cn } from "../../../lib/cn";
910
10type SymbolName = ComponentProps<typeof SymbolView>["name"];11type SymbolName = ComponentProps<typeof SymbolView>["name"];
1112
...@@ -14,44 +15,77 @@ export function SettingsRow(props: {...@@ -14,44 +15,77 @@ export function SettingsRow(props: {
14 readonly icon: SymbolName;15 readonly icon: SymbolName;
15 readonly label: string;16 readonly label: string;
16 readonly value?: string;17 readonly value?: string;
18 readonly valuePosition?: "below" | "trailing";
17 readonly target?: SettingsSheetTarget;19 readonly target?: SettingsSheetTarget;
18 readonly fullScreenTarget?: SettingsLegalDocumentTarget;20 readonly fullScreenTarget?: SettingsLegalDocumentTarget;
19 readonly onPress?: () => void;21 readonly onPress?: () => void;
20}) {22}) {
21 const navigation = useNavigation();23 const navigation = useNavigation();
24 if (Platform.OS === "android") {
25 return (
26 <MaterialListRow
27 title={props.label}
28 subtitle={props.valuePosition === "trailing" ? undefined : props.value}
29 accessibilityLabel={[props.label, props.value].filter(Boolean).join(", ")}
30 trailing={
31 props.valuePosition === "trailing" && props.value ? (
32 <View className="flex-row items-center gap-3">
33 <Text className="text-sm text-foreground-muted">{props.value}</Text>
34 <SymbolView name="chevron.right" size={16} tintColorClassName="accent-chevron" />
35 </View>
36 ) : undefined
37 }
38 disabled={props.disabled}
39 leading={
40 <SymbolView
41 name={props.icon}
42 size={24}
43 tintColorClassName="accent-icon"
44 type="monochrome"
45 weight="regular"
46 />
47 }
48 onPress={() => {
49 if (props.target)
50 navigation.navigate("SettingsSheet", {
51 screen: "SettingsContent",
52 params: { screen: props.target },
53 });
54 else if (props.fullScreenTarget) navigation.navigate(props.fullScreenTarget);
55 else props.onPress?.();
56 }}
57 />
58 );
59 }
22 const content = (60 const content = (
23 <View61 <View className={cn("flex-row items-center gap-4 p-4", props.disabled && "opacity-[0.45]")}>
24 className={
25 props.disabled
26 ? "flex-row items-center gap-4 p-4 opacity-[0.45]"
27 : "flex-row items-center gap-4 p-4"
28 }
29 >
30 <SymbolView62 <SymbolView
31 name={props.icon}63 name={props.icon}
32 size={22}64 size={22}
33 tintColorClassName={"accent-icon"}65 tintColorClassName="accent-icon"
34 type="monochrome"66 type="monochrome"
35 weight="regular"67 weight="regular"
36 />68 />
37 <Text className="shrink-0 text-lg text-foreground" numberOfLines={1}>69 <>
38 {props.label}70 <Text className="shrink-0 text-lg text-foreground" numberOfLines={1}>
39 </Text>71 {props.label}
40 <View className="min-w-0 flex-1 items-end">72 </Text>
41 {props.value ? (73 <View className="min-w-0 flex-1 items-end">
42 <Text74 {props.value ? (
43 className="max-w-[180px] text-right text-base text-foreground-muted"75 <Text
44 ellipsizeMode="middle"76 className="max-w-[180px] text-right text-base text-foreground-muted"
45 numberOfLines={1}77 ellipsizeMode="middle"
46 >78 numberOfLines={1}
47 {props.value}79 >
48 </Text>80 {props.value}
49 ) : null}81 </Text>
50 </View>82 ) : null}
83 </View>
84 </>
51 <SymbolView85 <SymbolView
52 name="chevron.right"86 name="chevron.right"
53 size={16}87 size={16}
54 tintColorClassName={"accent-chevron"}88 tintColorClassName="accent-chevron"
55 type="monochrome"89 type="monochrome"
56 weight="semibold"90 weight="semibold"
57 />91 />
...@@ -92,7 +126,12 @@ export function SettingsRow(props: {...@@ -92,7 +126,12 @@ export function SettingsRow(props: {
92 }126 }
93127
94 return (128 return (
95 <Pressable accessibilityRole="button" disabled={props.disabled} onPress={props.onPress}>129 <Pressable
130 accessibilityLabel={props.label}
131 accessibilityRole="button"
132 disabled={props.disabled}
133 onPress={props.onPress}
134 >
96 {content}135 {content}
97 </Pressable>136 </Pressable>
98 );137 );
apps/mobile/src/features/settings/components/SettingsScreen.tsx created+43
...@@ -0,0 +1,43 @@
1import { useNavigation } from "@react-navigation/native";
2import type { ComponentProps, ReactNode } from "react";
3import { Platform, View } from "react-native";
4
5import { AndroidScreenHeader } from "../../../components/AndroidScreenHeader";
6import { MaterialScreenContent as SettingsScreenContent } from "../../../components/MaterialScreenContent";
7import { NativeStackScreenOptions } from "../../../native/StackHeader";
8import { AndroidWorkspaceSidebarButton } from "../../layout/workspace-sidebar-toolbar";
9
10export { SettingsScreenContent };
11
12export function SettingsScreen(
13 props: Pick<ComponentProps<typeof AndroidScreenHeader>, "title" | "actions" | "trailing"> & {
14 readonly children: ReactNode;
15 /** A native form sheet already owns its rounded outer frame. */
16 readonly formSheet?: boolean;
17 },
18) {
19 const navigation = useNavigation();
20
21 return (
22 <View collapsable={false} className="flex-1 bg-sheet">
23 {Platform.OS === "android" ? (
24 <>
25 <NativeStackScreenOptions options={{ headerShown: false }} />
26 <AndroidScreenHeader
27 title={props.title}
28 leading={props.formSheet ? undefined : <AndroidWorkspaceSidebarButton />}
29 actions={props.actions}
30 trailing={props.trailing}
31 onBack={() => navigation.goBack()}
32 hideBottomBorder={!props.formSheet}
33 />
34 </>
35 ) : null}
36 {props.formSheet ? (
37 props.children
38 ) : (
39 <SettingsScreenContent>{props.children}</SettingsScreenContent>
40 )}
41 </View>
42 );
43}
apps/mobile/src/features/settings/components/SettingsSection.tsx+15-5
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { ReactNode } from "react";1import type { ReactNode } from "react";
2import { View } from "react-native";2import { Platform, View } from "react-native";
33
4import { AppText as Text } from "../../../components/AppText";4import { AppText as Text } from "../../../components/AppText";
55
...@@ -12,13 +12,23 @@ export function SettingsSection(props: {...@@ -12,13 +12,23 @@ export function SettingsSection(props: {
12 return (12 return (
13 <View className="gap-2">13 <View className="gap-2">
14 {props.title ? (14 {props.title ? (
15 <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{props.title}</Text>15 <Text
16 className={
17 Platform.OS === "android"
18 ? "px-4 text-sm font-t3-medium text-primary"
19 : "px-2 text-sm font-t3-medium text-foreground-muted"
20 }
21 >
22 {props.title}
23 </Text>
16 ) : null}24 ) : null}
17 <View25 <View
18 className={26 className={
19 props.card27 Platform.OS === "android"
20 ? "overflow-hidden rounded-[24px] border-continuous bg-card"28 ? "overflow-hidden rounded-[28px] bg-card"
21 : "overflow-hidden rounded-[24px] border-continuous bg-card android:bg-transparent"29 : props.card
30 ? "overflow-hidden rounded-[24px] border-continuous bg-card"
31 : "overflow-hidden rounded-[24px] border-continuous bg-card android:bg-transparent"
22 }32 }
23 >33 >
24 {props.children}34 {props.children}
apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx+18-10
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { cn } from "../../../lib/cn";
1import type { ComponentProps } from "react";2import type { ComponentProps } from "react";
2import { View } from "react-native";3import { Platform, View } from "react-native";
34
4import { SymbolView } from "../../../components/AppSymbol";5import { SymbolView } from "../../../components/AppSymbol";
5import { AppText as Text } from "../../../components/AppText";6import { AppText as Text } from "../../../components/AppText";
...@@ -17,21 +18,28 @@ export function SettingsSwitchRow(props: {...@@ -17,21 +18,28 @@ export function SettingsSwitchRow(props: {
17}) {18}) {
18 return (19 return (
19 <View20 <View
20 className={21 className={cn(
21 props.disabled22 "flex-row items-center gap-4",
22 ? "flex-row items-center gap-4 p-4 opacity-[0.45]"23 Platform.OS === "android" ? "min-h-14 px-4 py-3" : "p-4",
23 : "flex-row items-center gap-4 p-4"24 Platform.OS === "android" && props.subtitle && "min-h-18",
24 }25 props.disabled && "opacity-[0.45]",
26 )}
25 >27 >
26 <SymbolView28 <SymbolView
27 name={props.icon}29 name={props.icon}
28 size={22}30 size={Platform.OS === "android" ? 24 : 22}
29 tintColorClassName={"accent-icon"}31 tintColorClassName="accent-icon"
30 type="monochrome"32 type="monochrome"
31 weight="regular"33 weight="regular"
32 />34 />
33 <View className="min-w-0 flex-1">35 <View className={cn("min-w-0 flex-1", Platform.OS === "android" && "gap-1")}>
34 <Text className="text-lg text-foreground">{props.label}</Text>36 <Text
37 className={
38 Platform.OS === "android" ? "text-base text-foreground" : "text-lg text-foreground"
39 }
40 >
41 {props.label}
42 </Text>
35 {props.subtitle ? (43 {props.subtitle ? (
36 <Text className="text-sm text-foreground-muted">{props.subtitle}</Text>44 <Text className="text-sm text-foreground-muted">{props.subtitle}</Text>
37 ) : null}45 ) : null}
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+184-158
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1import { BlurTargetView } from "expo-blur";
2import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";1import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";2import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
4import type { MenuAction } from "@react-native-menu/menu";3import type { MenuAction } from "@react-native-menu/menu";
...@@ -7,6 +6,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac...@@ -7,6 +6,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac
7import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";6import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
8import { useCallback, useEffect, useMemo, useRef, useState } from "react";7import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9import { Alert, Platform, Pressable, View } from "react-native";8import { Alert, Platform, Pressable, View } from "react-native";
9import { useSafeAreaInsets } from "react-native-safe-area-context";
10import { AppText as Text } from "../../components/AppText";10import { AppText as Text } from "../../components/AppText";
11import { TerminalContextSheet } from "./TerminalContextSheet";11import { TerminalContextSheet } from "./TerminalContextSheet";
12import { hasNativeTerminalSurface } from "./nativeTerminalModule";12import { hasNativeTerminalSurface } from "./nativeTerminalModule";
...@@ -29,6 +29,9 @@ import { ControlPillMenu } from "../../components/ControlPill";...@@ -29,6 +29,9 @@ import { ControlPillMenu } from "../../components/ControlPill";
29import { EmptyState } from "../../components/EmptyState";29import { EmptyState } from "../../components/EmptyState";
30import { GlassSurface } from "../../components/GlassSurface";30import { GlassSurface } from "../../components/GlassSurface";
31import { LoadingScreen } from "../../components/LoadingScreen";31import { LoadingScreen } from "../../components/LoadingScreen";
32import { MaterialScreenContent } from "../../components/MaterialScreenContent";
33import { MaterialButton } from "../../components/MaterialButton";
34import { MaterialIconButton } from "../../components/MaterialIconButton";
32import { environmentCatalog } from "../../connection/catalog";35import { environmentCatalog } from "../../connection/catalog";
33import { useEnvironmentPresentation } from "../../state/presentation";36import { useEnvironmentPresentation } from "../../state/presentation";
34import { terminalEnvironment } from "../../state/terminal";37import { terminalEnvironment } from "../../state/terminal";
...@@ -50,6 +53,7 @@ import { useThreadSelection } from "../../state/use-thread-selection";...@@ -50,6 +53,7 @@ import { useThreadSelection } from "../../state/use-thread-selection";
50import { useSelectedThreadDetail } from "../../state/use-thread-detail";53import { useSelectedThreadDetail } from "../../state/use-thread-detail";
51import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";54import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
52import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";55import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
56import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar";
53import { TerminalSurface } from "./NativeTerminalSurface";57import { TerminalSurface } from "./NativeTerminalSurface";
54import { getMobileTerminalTheme } from "./terminalTheme";58import { getMobileTerminalTheme } from "./terminalTheme";
55import { terminalDebugLog } from "./terminalDebugLog";59import { terminalDebugLog } from "./terminalDebugLog";
...@@ -158,7 +162,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{...@@ -158,7 +162,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{
158}>;162}>;
159163
160export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) {164export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) {
161 const terminalBlurTarget = useRef<View>(null);165 const insets = useSafeAreaInsets();
162 const navigation = useNavigation();166 const navigation = useNavigation();
163 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");167 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
164 const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize");168 const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize");
...@@ -190,6 +194,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -190,6 +194,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
190 themeAppearance: appearanceScheme,194 themeAppearance: appearanceScheme,
191 themeId,195 themeId,
192 setTerminalFontSize,196 setTerminalFontSize,
197 themeVariables,
193 } = useAppearancePreferences();198 } = useAppearancePreferences();
194 const fontSize = appearance.terminalFontSize;199 const fontSize = appearance.terminalFontSize;
195 const cachedRouteGridSize =200 const cachedRouteGridSize =
...@@ -515,6 +520,12 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -515,6 +520,12 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
515 isVisible: state.isVisible,520 isVisible: state.isVisible,
516 }));521 }));
517 const isAccessoryVisible = keyboardState.isVisible && !isAccessoryDismissed;522 const isAccessoryVisible = keyboardState.isVisible && !isAccessoryDismissed;
523 // Android's terminal owns an EditText; turning off autoFocus also clears its native focus.
524 const terminalAutoFocus =
525 Platform.OS === "android"
526 ? !isAccessoryDismissed &&
527 (!SHOWCASE_ENABLED || keyboardFocusRequest > 0 || keyboardState.isVisible)
528 : !SHOWCASE_ENABLED;
518 const terminalBottomInset =529 const terminalBottomInset =
519 (keyboardState.isVisible ? keyboardState.height : 0) +530 (keyboardState.isVisible ? keyboardState.height : 0) +
520 (isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0);531 (isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0);
...@@ -1103,6 +1114,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -1103,6 +1114,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
1103 }, []);1114 }, []);
11041115
1105 const handleShowKeyboard = useCallback(() => {1116 const handleShowKeyboard = useCallback(() => {
1117 setIsAccessoryDismissed(false);
1106 setKeyboardFocusRequest((current) => current + 1);1118 setKeyboardFocusRequest((current) => current + 1);
1107 }, []);1119 }, []);
1108 const handleRetryEnvironment = useCallback(() => {1120 const handleRetryEnvironment = useCallback(() => {
...@@ -1175,22 +1187,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -1175,22 +1187,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
1175 <AndroidScreenHeader1187 <AndroidScreenHeader
1176 title="Terminal"1188 title="Terminal"
1177 subtitle={headerSubtitle}1189 subtitle={headerSubtitle}
1190 leading={<AndroidWorkspaceSidebarButton />}
1178 onBack={handleCloseTerminal}1191 onBack={handleCloseTerminal}
1179 trailing={1192 trailing={
1180 <>1193 <>
1181 {layout.usesSplitView ? (
1182 <AndroidHeaderIconButton
1183 accessibilityLabel={
1184 panes.primarySidebarVisible ? "Maximize terminal" : "Show threads"
1185 }
1186 icon={
1187 panes.primarySidebarVisible
1188 ? "arrow.up.left.and.arrow.down.right"
1189 : "sidebar.left"
1190 }
1191 onPress={togglePrimarySidebar}
1192 />
1193 ) : null}
1194 {isEnvironmentReady ? (1194 {isEnvironmentReady ? (
1195 <ControlPillMenu1195 <ControlPillMenu
1196 actions={androidTerminalMenuActions}1196 actions={androidTerminalMenuActions}
...@@ -1280,162 +1280,188 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -1280,162 +1280,188 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
1280 </NativeHeaderToolbar>1280 </NativeHeaderToolbar>
1281 ) : null}1281 ) : null}
12821282
1283 <View className="flex-1" style={{ backgroundColor: terminalTheme.background }}>1283 <MaterialScreenContent>
1284 {!isEnvironmentReady ? (1284 <View
1285 <EnvironmentConnectionNotice1285 className="flex-1"
1286 environmentLabel={1286 style={{
1287 environment.presentation?.entry.target.label ??1287 backgroundColor:
1288 selectedEnvironmentConnection?.environmentLabel ??1288 Platform.OS === "android"
1289 "Environment"1289 ? themeVariables["--color-card-alt"]
1290 }1290 : terminalTheme.background,
1291 connection={1291 paddingBottom:
1292 environment.presentation?.connection ?? {1292 Platform.OS === "android" && !keyboardState.isVisible ? insets.bottom : 0,
1293 phase: "available",1293 }}
1294 error: null,1294 >
1295 traceId: null,1295 {!isEnvironmentReady ? (
1296 <EnvironmentConnectionNotice
1297 environmentLabel={
1298 environment.presentation?.entry.target.label ??
1299 selectedEnvironmentConnection?.environmentLabel ??
1300 "Environment"
1296 }1301 }
1297 }1302 connection={
1298 resourceName="terminal"1303 environment.presentation?.connection ?? {
1299 onRetry={handleRetryEnvironment}1304 phase: "available",
1300 />1305 error: null,
1301 ) : (1306 traceId: null,
1302 <>1307 }
1303 <BlurTargetView1308 }
1304 ref={terminalBlurTarget}1309 resourceName="terminal"
1305 style={{1310 onRetry={handleRetryEnvironment}
1306 flex: 1,1311 />
1307 paddingBottom: terminalBottomInset,1312 ) : (
1308 }}1313 <>
1309 >
1310 <View1314 <View
1311 pointerEvents="none"
1312 style={{1315 style={{
1313 position: "absolute",1316 flex: 1,
1314 inset: 0,1317 paddingBottom: terminalBottomInset,
1315 backgroundColor: terminalTheme.background,
1316 }}
1317 />
1318 <TerminalSurface
1319 autoFocus={!SHOWCASE_ENABLED}
1320 buffer={terminalSurfaceBuffer}
1321 fontSize={fontSize}
1322 isRunning={isRunning}
1323 keyboardFocusRequest={keyboardFocusRequest}
1324 captureRequest={captureRequest}
1325 onCapture={(text) => {
1326 if (text.trim()) setCapturedOutput(text);
1327 else Alert.alert("No terminal output", "There is no visible output to attach.");
1328 }}1318 }}
1329 onInput={handleInput}
1330 onResize={handleResize}
1331 style={{ flex: 1 }}
1332 terminalKey={terminalKey}
1333 theme={terminalTheme}
1334 />
1335 </BlurTargetView>
1336
1337 {selectedThread && hasNativeTerminalSurface() ? (
1338 <Pressable
1339 accessibilityRole="button"
1340 onPress={() => {
1341 KeyboardController.dismiss();
1342 setCaptureRequest((value) => value + 1);
1343 }}
1344 className="px-4 py-2"
1345 >
1346 <Text style={{ color: terminalTheme.foreground }}>Attach visible output</Text>
1347 </Pressable>
1348 ) : null}
1349 {isAccessoryVisible ? (
1350 <KeyboardStickyView
1351 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
1352 offset={{ closed: 0, opened: 0 }}
1353 >1319 >
1354 <View1320 <View
1355 className="border-t"1321 pointerEvents="none"
1356 style={{1322 style={{
1323 position: "absolute",
1324 inset: 0,
1357 backgroundColor: terminalTheme.background,1325 backgroundColor: terminalTheme.background,
1358 borderTopColor: terminalTheme.border,
1359 minHeight: TERMINAL_ACCESSORY_HEIGHT,
1360 }}1326 }}
1361 >1327 />
1362 <ComposerToolbarRow paddingBottom={4} paddingHorizontal={8} paddingTop={4}>1328 <TerminalSurface
1363 <ComposerToolbarScroller1329 autoFocus={terminalAutoFocus}
1364 contentPaddingRight={2}1330 buffer={terminalSurfaceBuffer}
1365 fadeOpaque={terminalTheme.background}1331 fontSize={fontSize}
1366 fadeTransparent={`${terminalTheme.background}00`}1332 isRunning={isRunning}
1367 >1333 keyboardFocusRequest={keyboardFocusRequest}
1368 {terminalToolbarActions.map((action) => {1334 captureRequest={captureRequest}
1369 const active =1335 onCapture={(text) => {
1370 action.kind === "modifier" && pendingModifier === action.modifier;1336 if (text.trim()) setCapturedOutput(text);
13711337 else Alert.alert("No terminal output", "There is no visible output to attach.");
1372 return (1338 }}
1373 <ComposerToolbarButton1339 onInput={handleInput}
1374 key={action.key}1340 onResize={handleResize}
1375 active={active}1341 style={{ flex: 1 }}
1376 label={action.label}1342 terminalKey={terminalKey}
1377 maxWidth={120}1343 theme={terminalTheme}
1378 minWidth={action.label.length > 1 ? 56 : 44}1344 />
1379 onPress={() => handleToolbarActionPress(action)}1345 </View>
1380 showChevron={false}1346
1381 textTransform={1347 {Platform.OS === "android" && !keyboardState.isVisible ? (
1382 action.kind === "modifier" || action.kind === "clear"1348 <View className="min-h-14 flex-row items-center gap-2 bg-card-alt px-2">
1383 ? "uppercase"1349 {selectedThread && hasNativeTerminalSurface() ? (
1384 : "none"1350 <MaterialButton
1385 }1351 label="Attach output"
1386 />1352 tone="text"
1387 );1353 onPress={() => setCaptureRequest((value) => value + 1)}
1388 })}
1389 </ComposerToolbarScroller>
1390 <ComposerToolbarButton
1391 accessibilityLabel="Dismiss keyboard"
1392 icon={{ ios: "keyboard.chevron.compact.down", android: "keyboard_hide" }}
1393 onPress={handleDismissKeyboard}
1394 showChevron={false}
1395 />1354 />
1396 </ComposerToolbarRow>1355 ) : null}
1356 <View className="flex-1" />
1357 <MaterialIconButton
1358 accessibilityLabel="Show keyboard"
1359 icon="keyboard"
1360 onPress={handleShowKeyboard}
1361 />
1397 </View>1362 </View>
1398 </KeyboardStickyView>1363 ) : Platform.OS !== "android" && selectedThread && hasNativeTerminalSurface() ? (
1399 ) : !keyboardState.isVisible ? (1364 <Pressable
1400 <Pressable1365 accessibilityRole="button"
1401 accessibilityLabel="Show keyboard"1366 onPress={() => {
1402 accessibilityRole="button"1367 KeyboardController.dismiss();
1403 onPress={handleShowKeyboard}1368 setCaptureRequest((value) => value + 1);
1404 style={({ pressed }) => ({
1405 bottom: 16,
1406 borderRadius: 28,
1407 opacity: pressed ? 0.72 : 1,
1408 position: "absolute",
1409 right: 16,
1410 })}
1411 >
1412 <GlassSurface
1413 chrome="none"
1414 blurTarget={terminalBlurTarget}
1415 fallbackColor={terminalTheme.background}
1416 glassEffectStyle="regular"
1417 tintColor="transparent"
1418 style={{
1419 alignItems: "center",
1420 borderRadius: 24,
1421 height: 48,
1422 justifyContent: "center",
1423 width: 48,
1424 }}1369 }}
1425 pointerEvents="none"1370 className="px-4 py-2"
1426 >1371 >
1427 <SymbolView1372 <Text style={{ color: terminalTheme.foreground }}>Attach visible output</Text>
1428 name={{ ios: "keyboard", android: "keyboard" }}1373 </Pressable>
1429 size={20}1374 ) : null}
1430 tintColor={terminalTheme.foreground}1375 {isAccessoryVisible ? (
1431 type="monochrome"1376 <KeyboardStickyView
1432 />1377 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
1433 </GlassSurface>1378 offset={{ closed: 0, opened: 0 }}
1434 </Pressable>1379 >
1435 ) : null}1380 <View
1436 </>1381 className="border-t"
1437 )}1382 style={{
1438 </View>1383 backgroundColor: terminalTheme.background,
1384 borderTopColor: terminalTheme.border,
1385 minHeight: TERMINAL_ACCESSORY_HEIGHT,
1386 }}
1387 >
1388 <ComposerToolbarRow paddingBottom={4} paddingHorizontal={8} paddingTop={4}>
1389 <ComposerToolbarScroller
1390 contentPaddingRight={2}
1391 fadeOpaque={terminalTheme.background}
1392 fadeTransparent={`${terminalTheme.background}00`}
1393 >
1394 {terminalToolbarActions.map((action) => {
1395 const active =
1396 action.kind === "modifier" && pendingModifier === action.modifier;
1397
1398 return (
1399 <ComposerToolbarButton
1400 key={action.key}
1401 active={active}
1402 label={action.label}
1403 maxWidth={120}
1404 minWidth={action.label.length > 1 ? 56 : 44}
1405 onPress={() => handleToolbarActionPress(action)}
1406 showChevron={false}
1407 textTransform={
1408 action.kind === "modifier" || action.kind === "clear"
1409 ? "uppercase"
1410 : "none"
1411 }
1412 />
1413 );
1414 })}
1415 </ComposerToolbarScroller>
1416 <ComposerToolbarButton
1417 accessibilityLabel="Dismiss keyboard"
1418 icon={{ ios: "keyboard.chevron.compact.down", android: "keyboard_hide" }}
1419 onPress={handleDismissKeyboard}
1420 showChevron={false}
1421 />
1422 </ComposerToolbarRow>
1423 </View>
1424 </KeyboardStickyView>
1425 ) : !keyboardState.isVisible && Platform.OS !== "android" ? (
1426 <Pressable
1427 accessibilityLabel="Show keyboard"
1428 accessibilityRole="button"
1429 onPress={handleShowKeyboard}
1430 style={({ pressed }) => ({
1431 bottom: 16,
1432 borderRadius: 28,
1433 opacity: pressed ? 0.72 : 1,
1434 position: "absolute",
1435 right: 16,
1436 })}
1437 >
1438 <GlassSurface
1439 chrome="none"
1440 fallbackColor={terminalTheme.background}
1441 glassEffectStyle="regular"
1442 tintColor="transparent"
1443 style={{
1444 alignItems: "center",
1445 borderRadius: 24,
1446 height: 48,
1447 justifyContent: "center",
1448 width: 48,
1449 }}
1450 pointerEvents="none"
1451 >
1452 <SymbolView
1453 name={{ ios: "keyboard", android: "keyboard" }}
1454 size={20}
1455 tintColor={terminalTheme.foreground}
1456 type="monochrome"
1457 />
1458 </GlassSurface>
1459 </Pressable>
1460 ) : null}
1461 </>
1462 )}
1463 </View>
1464 </MaterialScreenContent>
1439 </>1465 </>
1440 );1466 );
1441}1467}
apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx created+287
...@@ -0,0 +1,287 @@
1import { MaterialSegmentedButtons } from "../../components/MaterialSegmentedButtons.android";
2import {
3 BasicAlertDialog,
4 Button,
5 Column,
6 DateTimePicker,
7 Host,
8 FilledTonalIconButton,
9 Row,
10 Shape,
11 Surface,
12 Text,
13 TextButton,
14} from "@expo/ui/jetpack-compose";
15import {
16 padding,
17 size,
18 width,
19 fillMaxWidth,
20 testID,
21 verticalScroll,
22} from "@expo/ui/jetpack-compose/modifiers";
23import {
24 localSnoozeDate,
25 localSnoozeTime,
26 resolveCustomSnooze,
27 type CustomSnoozeInput,
28} from "@t3tools/client-runtime/state/thread-settled";
29import { requireNativeModule } from "expo";
30import { useEffect, useState } from "react";
31import { AppState, useWindowDimensions } from "react-native";
32
33import { OverlayPortal } from "../../components/OverlayPortal";
34import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
35import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
36import type { CustomSnoozeSheet as SharedCustomSnoozeSheet } from "./CustomSnoozeSheet.shared";
37import {
38 applySnoozePickerDate,
39 applySnoozePickerTime,
40 snoozeDateToPickerDate,
41} from "./customSnoozeDate";
42
43type Props = Parameters<typeof SharedCustomSnoozeSheet>[0];
44
45const roundedCorner = Shape.RoundedCorner;
46
47const modes = [
48 { value: "date", label: "Date and time" },
49 { value: "duration", label: "Duration" },
50] as const;
51const units = [
52 { value: "minutes", label: "Minutes" },
53 { value: "hours", label: "Hours" },
54 { value: "days", label: "Days" },
55] as const;
56
57function systemUses24HourClock() {
58 return requireNativeModule<{ is24HourFormat(): boolean }>("T3NativeControls").is24HourFormat();
59}
60
61export function CustomSnoozeSheet(props: Props) {
62 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
63 const [is24Hour, setIs24Hour] = useState(systemUses24HourClock);
64 useEffect(() => {
65 const subscription = AppState.addEventListener("change", (state) => {
66 if (state === "active") setIs24Hour(systemUses24HourClock());
67 });
68 return () => subscription.remove();
69 }, []);
70 const titleTypography = useScaledTextRole("title");
71 const bodyTypography = useScaledTextRole("footnote");
72 const { width: windowWidth } = useWindowDimensions();
73 const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date");
74 const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000));
75 const [picker, setPicker] = useState<"date" | "time">("date");
76 const [amount, setAmount] = useState(2);
77 const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours");
78 const [error, setError] = useState<string | null>(null);
79 const submit = () => {
80 const input: CustomSnoozeInput =
81 mode === "date"
82 ? { mode, date: localSnoozeDate(date), time: localSnoozeTime(date) }
83 : { mode, amount: String(amount), unit };
84 const snoozedUntil = resolveCustomSnooze(input, new Date());
85 if (!snoozedUntil) {
86 setError(
87 mode === "date" ? "Choose a date and time in the future." : "Enter a positive duration.",
88 );
89 return;
90 }
91 props.onSnooze(snoozedUntil);
92 props.onClose();
93 };
94 const pickerColors = {
95 containerColor: colors["--color-card-alt"],
96 titleContentColor: colors["--color-foreground-secondary"],
97 headlineContentColor: colors["--color-foreground"],
98 weekdayContentColor: colors["--color-foreground-secondary"],
99 subheadContentColor: colors["--color-foreground"],
100 navigationContentColor: colors["--color-foreground"],
101 yearContentColor: colors["--color-foreground"],
102 dayContentColor: colors["--color-foreground"],
103 currentYearContentColor: colors["--color-primary"],
104 selectedYearContainerColor: colors["--color-primary"],
105 selectedYearContentColor: colors["--color-primary-foreground"],
106 selectedDayContainerColor: colors["--color-primary"],
107 selectedDayContentColor: colors["--color-primary-foreground"],
108 todayContentColor: colors["--color-primary"],
109 todayDateBorderColor: colors["--color-primary"],
110 dividerColor: colors["--color-border"],
111 clockDialColor: colors["--color-secondary"],
112 clockDialSelectedContentColor: colors["--color-primary-foreground"],
113 clockDialUnselectedContentColor: colors["--color-secondary-foreground"],
114 selectorColor: colors["--color-primary"],
115 periodSelectorSelectedContainerColor: colors["--color-secondary"],
116 periodSelectorSelectedContentColor: colors["--color-secondary-foreground"],
117 periodSelectorUnselectedContentColor: colors["--color-foreground"],
118 timeSelectorSelectedContainerColor: colors["--color-secondary"],
119 timeSelectorSelectedContentColor: colors["--color-secondary-foreground"],
120 timeSelectorUnselectedContainerColor: colors["--color-card"],
121 timeSelectorUnselectedContentColor: colors["--color-foreground"],
122 };
123 return (
124 // Recycled thread rows can detach a zero-sized native dialog host.
125 <OverlayPortal>
126 <Host colorScheme={themeAppearance} style={{ height: 0, width: 0 }}>
127 <BasicAlertDialog
128 onDismissRequest={props.onClose}
129 properties={{ usePlatformDefaultWidth: false }}
130 modifiers={[width(Math.min(360, windowWidth - 32))]}
131 >
132 <Surface
133 color={colors["--color-card-alt"]}
134 contentColor={colors["--color-foreground"]}
135 shape={roundedCorner({
136 cornerRadii: { topStart: 28, topEnd: 28, bottomStart: 28, bottomEnd: 28 },
137 })}
138 >
139 <Column modifiers={[fillMaxWidth(), verticalScroll()]}>
140 <Column
141 verticalArrangement={{ spacedBy: 16 }}
142 modifiers={[fillMaxWidth(), padding(24, 24, 24, 16)]}
143 >
144 <Text style={titleTypography}>Custom snooze</Text>
145 <MaterialSegmentedButtons
146 options={modes}
147 selected={mode}
148 onSelect={(value) => {
149 setMode(value);
150 setError(null);
151 }}
152 />
153 {mode === "date" ? (
154 <MaterialSegmentedButtons
155 options={[
156 {
157 value: "date",
158 label: date.toLocaleDateString([], { month: "short", day: "numeric" }),
159 },
160 {
161 value: "time",
162 label: date.toLocaleTimeString([], {
163 hour: "numeric",
164 minute: "2-digit",
165 hourCycle: is24Hour ? "h23" : "h12",
166 }),
167 },
168 ]}
169 selected={picker}
170 onSelect={setPicker}
171 />
172 ) : null}
173 </Column>
174 {mode === "date" ? (
175 <Column horizontalAlignment="center" modifiers={[fillMaxWidth()]}>
176 <SnoozeDateTimePicker
177 key={picker}
178 date={date}
179 picker={picker}
180 is24Hour={is24Hour}
181 colors={pickerColors}
182 onChange={(selected) => {
183 setDate((current) =>
184 picker === "date"
185 ? applySnoozePickerDate(current, selected)
186 : applySnoozePickerTime(current, selected),
187 );
188 setError(null);
189 }}
190 />
191 </Column>
192 ) : (
193 <Column
194 verticalArrangement={{ spacedBy: 16 }}
195 modifiers={[fillMaxWidth(), padding(24, 8, 24, 16)]}
196 >
197 <Row
198 horizontalArrangement="spaceEvenly"
199 verticalAlignment="center"
200 modifiers={[fillMaxWidth()]}
201 >
202 <FilledTonalIconButton
203 enabled={amount > 1}
204 onClick={() => setAmount((current) => Math.max(1, current - 1))}
205 colors={{
206 containerColor: colors["--color-secondary"],
207 contentColor: colors["--color-secondary-foreground"],
208 }}
209 modifiers={[size(48, 48), testID("snooze-decrease-duration")]}
210 >
211 <Text style={titleTypography}>−</Text>
212 </FilledTonalIconButton>
213 <Text style={titleTypography} modifiers={[testID("snooze-duration")]}>
214 {String(amount)}
215 </Text>
216 <FilledTonalIconButton
217 enabled={amount < 99}
218 onClick={() => setAmount((current) => Math.min(99, current + 1))}
219 colors={{
220 containerColor: colors["--color-secondary"],
221 contentColor: colors["--color-secondary-foreground"],
222 }}
223 modifiers={[size(48, 48), testID("snooze-increase-duration")]}
224 >
225 <Text style={titleTypography}>+</Text>
226 </FilledTonalIconButton>
227 </Row>
228 <MaterialSegmentedButtons options={units} selected={unit} onSelect={setUnit} />
229 </Column>
230 )}
231 <Column
232 verticalArrangement={{ spacedBy: 16 }}
233 modifiers={[fillMaxWidth(), padding(24, 8, 24, 24)]}
234 >
235 {error ? (
236 <Text style={bodyTypography} color={colors["--color-danger-foreground"]}>
237 {error}
238 </Text>
239 ) : null}
240 <Row horizontalArrangement="end" modifiers={[fillMaxWidth()]}>
241 <TextButton
242 onClick={props.onClose}
243 colors={{ contentColor: colors["--color-primary"] }}
244 >
245 <Text style={bodyTypography}>Cancel</Text>
246 </TextButton>
247 <Button
248 onClick={submit}
249 colors={{
250 containerColor: colors["--color-primary"],
251 contentColor: colors["--color-primary-foreground"],
252 }}
253 >
254 <Text style={bodyTypography}>Snooze</Text>
255 </Button>
256 </Row>
257 </Column>
258 </Column>
259 </Surface>
260 </BasicAlertDialog>
261 </Host>
262 </OverlayPortal>
263 );
264}
265
266function SnoozeDateTimePicker(props: {
267 readonly date: Date;
268 readonly picker: "date" | "time";
269 readonly is24Hour: boolean;
270 readonly colors: React.ComponentProps<typeof DateTimePicker>["elementColors"];
271 readonly onChange: (date: Date) => void;
272}) {
273 // Changing initialDate resets Compose's picker state, including its active clock dial.
274 const [initialDate] = useState(() =>
275 props.picker === "date" ? snoozeDateToPickerDate(props.date) : props.date.toISOString(),
276 );
277 return (
278 <DateTimePicker
279 initialDate={initialDate}
280 displayedComponents={props.picker === "date" ? "date" : "hourAndMinute"}
281 is24Hour={props.is24Hour}
282 showVariantToggle={false}
283 elementColors={props.colors}
284 onDateSelected={props.onChange}
285 />
286 );
287}
apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx+154-192
...@@ -3,7 +3,6 @@ import {...@@ -3,7 +3,6 @@ import {
3 DatePicker,3 DatePicker,
4 Host,4 Host,
5 HStack,5 HStack,
6 Menu,
7 Picker,6 Picker,
8 Popover,7 Popover,
9 Spacer,8 Spacer,
...@@ -11,16 +10,20 @@ import {...@@ -11,16 +10,20 @@ import {
11 VStack,10 VStack,
12} from "@expo/ui/swift-ui";11} from "@expo/ui/swift-ui";
13import {12import {
14 background,13 accessibilityAddTraits,
14 accessibilityHidden,
15 buttonBorderShape,
15 buttonStyle,16 buttonStyle,
16 datePickerStyle,17 clipped,
18 controlSize,
17 font,19 font,
20 datePickerStyle,
18 foregroundStyle,21 foregroundStyle,
19 frame,22 frame,
23 labelsHidden,
24 labelStyle,
20 padding,25 padding,
21 pickerStyle,26 pickerStyle,
22 presentationBackground,
23 shapes,
24 tag,27 tag,
25} from "@expo/ui/swift-ui/modifiers";28} from "@expo/ui/swift-ui/modifiers";
26import {29import {
...@@ -30,9 +33,8 @@ import {...@@ -30,9 +33,8 @@ import {
30 type CustomSnoozeInput,33 type CustomSnoozeInput,
31} from "@t3tools/client-runtime/state/thread-settled";34} from "@t3tools/client-runtime/state/thread-settled";
32import { useState } from "react";35import { useState } from "react";
33import { Modal, Pressable, ScrollView, View } from "react-native";36import { useWindowDimensions } from "react-native";
34import { AppText } from "../../components/AppText";37import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
35import { SegmentedControl } from "../../components/SegmentedControl";
36import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";38import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
3739
38const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1);40const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1);
...@@ -50,13 +52,13 @@ export function CustomSnoozeSheet(props: {...@@ -50,13 +52,13 @@ export function CustomSnoozeSheet(props: {
50 readonly onClose: () => void;52 readonly onClose: () => void;
51 readonly onSnooze: (snoozedUntil: string) => void;53 readonly onSnooze: (snoozedUntil: string) => void;
52}) {54}) {
55 const { width } = useWindowDimensions();
53 const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date");56 const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date");
54 const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000));57 const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000));
55 const [amount, setAmount] = useState(2);58 const [amount, setAmount] = useState(2);
56 const [amountOpen, setAmountOpen] = useState(false);
57 const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours");59 const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours");
58 const [error, setError] = useState<string | null>(null);60 const [error, setError] = useState<string | null>(null);
59 const { themeVariables: colors, themeAppearance, appearance } = useAppearancePreferences();61 const { themeVariables: colors, themeAppearance } = useAppearancePreferences();
60 const updateDate = (value: Date) => {62 const updateDate = (value: Date) => {
61 setDate(value);63 setDate(value);
62 setError(null);64 setError(null);
...@@ -79,190 +81,150 @@ export function CustomSnoozeSheet(props: {...@@ -79,190 +81,150 @@ export function CustomSnoozeSheet(props: {
79 };81 };
8082
81 return (83 return (
82 <Modal visible transparent animationType="fade" onRequestClose={props.onClose}>84 <Host
83 <View className="flex-1 items-center justify-center bg-black/40 px-6">85 style={{ position: "absolute", top: 0, right: 0, bottom: 0, left: 0 }}
84 <ScrollView86 colorScheme={themeAppearance}
85 className="max-h-[80%] w-full max-w-sm grow-0 rounded-3xl border border-border bg-sheet-solid"87 seedColor={colors["--color-primary"]}
86 contentContainerStyle={{ padding: 20, gap: 16 }}88 >
87 >89 <Popover
88 <View className="flex-row items-center justify-between gap-2">90 isPresented
89 <Pressable accessibilityRole="button" onPress={props.onClose} hitSlop={10}>91 onIsPresentedChange={(presented) => {
90 <AppText className="text-base">Cancel</AppText>92 if (!presented) props.onClose();
91 </Pressable>93 }}
92 <AppText accessibilityRole="header" className="text-base font-t3-semibold">94 >
93 Custom snooze95 <Popover.Trigger>
94 </AppText>96 <Spacer
95 <Pressable accessibilityRole="button" onPress={submit} hitSlop={10}>97 modifiers={[frame({ maxWidth: Infinity, maxHeight: Infinity }), accessibilityHidden()]}
96 <AppText className="text-base text-primary">Snooze</AppText>
97 </Pressable>
98 </View>
99 <SegmentedControl
100 options={modes}
101 selected={mode}
102 onSelect={(value) => {
103 setMode(value);
104 setError(null);
105 }}
106 role="tab"
107 />98 />
108 <Host99 </Popover.Trigger>
109 matchContents={{ vertical: true }}100 <Popover.Content>
110 style={{ width: "100%" }}101 <VStack
111 colorScheme={themeAppearance}102 spacing={16}
112 seedColor={colors["--color-primary"]}103 modifiers={[padding({ all: 16 }), frame({ width: Math.min(360, width - 32) })]}
113 >104 >
114 <HStack105 <HStack spacing={12}>
115 spacing={6}106 <Button
116 modifiers={[107 label="Cancel"
117 frame({ minHeight: 44 }),108 systemImage="xmark"
118 foregroundStyle(colors["--color-foreground"]),109 role="cancel"
119 font({ size: appearance.baseFontSize }),110 onPress={props.onClose}
120 ]}111 modifiers={[
121 >112 labelStyle("iconOnly"),
122 <Text>{mode === "date" ? "Until" : "Snooze for"}</Text>113 buttonStyle(NATIVE_LIQUID_GLASS_SUPPORTED ? "glass" : "bordered"),
123 <Spacer />114 buttonBorderShape("circle"),
124 {mode === "date" ? (115 controlSize("large"),
125 <>116 ]}
126 <SnoozeDateField date={date} component="date" onChange={updateDate} />117 />
127 <SnoozeDateField date={date} component="hourAndMinute" onChange={updateDate} />118 <Text
128 </>119 modifiers={[
129 ) : (120 font({ textStyle: "headline" }),
130 <>121 frame({ maxWidth: Infinity, alignment: "leading" }),
131 <Popover isPresented={amountOpen} onIsPresentedChange={setAmountOpen}>122 accessibilityAddTraits(["isHeader"]),
132 <Popover.Trigger>123 ]}
133 <Button124 >
134 onPress={() => setAmountOpen(true)}125 Custom snooze
135 modifiers={[buttonStyle("plain")]}126 </Text>
136 >127 <Button
137 <FieldLabel label={String(amount)} />128 label="Snooze"
138 </Button>129 onPress={submit}
139 </Popover.Trigger>130 modifiers={[
140 <Popover.Content>131 buttonStyle(
141 <VStack132 NATIVE_LIQUID_GLASS_SUPPORTED ? "glassProminent" : "borderedProminent",
142 modifiers={[133 ),
143 padding({ all: 12 }),134 controlSize("large"),
144 frame({ width: 180 }),135 ]}
145 presentationBackground(colors["--color-sheet-solid"]),136 />
146 ]}
147 >
148 <Picker
149 label="Duration"
150 selection={amount}
151 onSelectionChange={(value: number) => {
152 setAmount(value);
153 setError(null);
154 }}
155 modifiers={[pickerStyle("wheel"), frame({ height: 160 })]}
156 >
157 {durationAmounts.map((value) => (
158 <Text
159 key={value}
160 modifiers={[
161 tag(value),
162 foregroundStyle(colors["--color-foreground"]),
163 ]}
164 >
165 {String(value)}
166 </Text>
167 ))}
168 </Picker>
169 <Button
170 label="Done"
171 onPress={() => setAmountOpen(false)}
172 modifiers={[foregroundStyle(colors["--color-primary"])]}
173 />
174 </VStack>
175 </Popover.Content>
176 </Popover>
177 <Menu
178 label={
179 <FieldLabel
180 label={unit === "minutes" ? "Minutes" : unit === "hours" ? "Hours" : "Days"}
181 />
182 }
183 >
184 {units.map((option) => (
185 <Button
186 key={option.value}
187 label={option.label}
188 onPress={() => {
189 setUnit(option.value);
190 setError(null);
191 }}
192 />
193 ))}
194 </Menu>
195 </>
196 )}
197 </HStack>137 </HStack>
198 </Host>138 <Picker
199 {error && <AppText className="text-sm text-danger-foreground">{error}</AppText>}139 label="Snooze mode"
200 </ScrollView>140 selection={mode}
201 </View>141 onSelectionChange={(value: CustomSnoozeInput["mode"]) => {
202 </Modal>142 setMode(value);
203 );143 setError(null);
204}144 }}
205145 modifiers={[pickerStyle("segmented")]}
206function FieldLabel(props: { readonly label: string }) {146 >
207 const { themeVariables: colors } = useAppearancePreferences();147 {modes.map((option) => (
208 return (148 <Text key={option.value} modifiers={[tag(option.value)]}>
209 <Text149 {option.label}
210 modifiers={[150 </Text>
211 padding({ horizontal: 12, vertical: 8 }),151 ))}
212 foregroundStyle(colors["--color-foreground"]),152 </Picker>
213 background(colors["--color-card"], shapes.capsule()),153 {mode === "date" ? (
214 ]}154 <DatePicker
215 >155 title="Snooze until"
216 {props.label}156 selection={date}
217 </Text>157 displayedComponents={["date", "hourAndMinute"]}
218 );158 onDateChange={updateDate}
219}159 modifiers={[
220160 datePickerStyle("wheel"),
221function SnoozeDateField(props: {161 labelsHidden(),
222 readonly date: Date;162 frame({ maxWidth: Infinity, height: 180 }),
223 readonly component: "date" | "hourAndMinute";163 ]}
224 readonly onChange: (date: Date) => void;164 />
225}) {165 ) : (
226 const [open, setOpen] = useState(false);166 <HStack spacing={0}>
227 const { themeVariables: colors } = useAppearancePreferences();167 <Picker
228 return (168 label="Duration amount"
229 <Popover isPresented={open} onIsPresentedChange={setOpen}>169 selection={amount}
230 <Popover.Trigger>170 onSelectionChange={(value: number) => {
231 <Button onPress={() => setOpen(true)} modifiers={[buttonStyle("plain")]}>171 setAmount(value);
232 <FieldLabel172 setError(null);
233 label={173 }}
234 props.component === "date"174 modifiers={[
235 ? props.date.toLocaleDateString(undefined, {175 pickerStyle("wheel"),
236 month: "short",176 labelsHidden(),
237 day: "numeric",177 frame({ minWidth: 0, maxWidth: Infinity, height: 180 }),
238 year: "numeric",178 clipped(),
239 })179 ]}
240 : props.date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })180 >
241 }181 {durationAmounts.map((value) => (
242 />182 <Text
243 </Button>183 key={value}
244 </Popover.Trigger>184 modifiers={[tag(value), foregroundStyle(colors["--color-foreground"])]}
245 <Popover.Content>185 >
246 <VStack186 {String(value)}
247 modifiers={[187 </Text>
248 padding({ all: 12 }),188 ))}
249 frame({ width: props.component === "date" ? 340 : 300 }),189 </Picker>
250 presentationBackground(colors["--color-sheet-solid"]),190 <Picker
251 ]}191 label="Duration unit"
252 >192 selection={unit}
253 <DatePicker193 onSelectionChange={(value: typeof unit) => {
254 selection={props.date}194 setUnit(value);
255 displayedComponents={[props.component]}195 setError(null);
256 onDateChange={props.onChange}196 }}
257 modifiers={[datePickerStyle(props.component === "date" ? "graphical" : "wheel")]}197 modifiers={[
258 />198 pickerStyle("wheel"),
259 <Button199 labelsHidden(),
260 label="Done"200 frame({ minWidth: 0, maxWidth: Infinity, height: 180 }),
261 onPress={() => setOpen(false)}201 clipped(),
262 modifiers={[foregroundStyle(colors["--color-primary"])]}202 ]}
263 />203 >
264 </VStack>204 {units.map((option) => (
265 </Popover.Content>205 <Text
266 </Popover>206 key={option.value}
207 modifiers={[tag(option.value), foregroundStyle(colors["--color-foreground"])]}
208 >
209 {option.label}
210 </Text>
211 ))}
212 </Picker>
213 </HStack>
214 )}
215 {error ? (
216 <Text
217 modifiers={[
218 font({ textStyle: "footnote" }),
219 foregroundStyle(colors["--color-danger-foreground"]),
220 ]}
221 >
222 {error}
223 </Text>
224 ) : null}
225 </VStack>
226 </Popover.Content>
227 </Popover>
228 </Host>
267 );229 );
268}230}
apps/mobile/src/features/threads/CustomSnoozeSheet.shared.tsx created+181
...@@ -0,0 +1,181 @@
1import { DateTimePicker } from "@expo/ui/community/datetime-picker";
2import {
3 localSnoozeDate,
4 localSnoozeTime,
5 resolveCustomSnooze,
6 type CustomSnoozeInput,
7} from "@t3tools/client-runtime/state/thread-settled";
8import { useState } from "react";
9import {
10 KeyboardAvoidingView,
11 Modal,
12 Platform,
13 Pressable,
14 ScrollView,
15 TextInput,
16 View,
17} from "react-native";
18import { AppText } from "../../components/AppText";
19import { SegmentedControl } from "../../components/SegmentedControl";
20
21export function CustomSnoozeSheet(props: {
22 readonly onClose: () => void;
23 readonly onSnooze: (snoozedUntil: string) => void;
24}) {
25 const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date");
26 const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000));
27 const [picker, setPicker] = useState<"date" | "time" | null>(null);
28 const [amount, setAmount] = useState("2");
29 const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours");
30 const [error, setError] = useState<string | null>(null);
31 return (
32 <Modal visible transparent animationType="fade" onRequestClose={props.onClose}>
33 <KeyboardAvoidingView
34 className="flex-1 items-center justify-center bg-black/40 px-6"
35 behavior={Platform.OS === "ios" ? "padding" : undefined}
36 >
37 <ScrollView
38 className="max-h-[80%] w-full max-w-md grow-0 rounded-3xl bg-screen"
39 keyboardShouldPersistTaps="handled"
40 contentInsetAdjustmentBehavior="automatic"
41 contentContainerStyle={{ padding: 24, paddingBottom: 24, gap: 20 }}
42 >
43 <AppText accessibilityRole="header" className="text-xl font-t3-semibold">
44 Custom snooze
45 </AppText>
46 <AppText className="text-base text-foreground-secondary">
47 Choose when snoozed threads return to your inbox.
48 </AppText>
49 <SegmentedControl
50 options={
51 [
52 { value: "date", label: "Date and time" },
53 { value: "duration", label: "Duration" },
54 ] as const
55 }
56 selected={mode}
57 onSelect={(value) => {
58 setMode(value);
59 setPicker(null);
60 setError(null);
61 }}
62 role="tab"
63 />
64 {mode === "date" ? (
65 <View className="gap-3">
66 {(["date", "time"] as const).map((value) => (
67 <Pressable
68 key={value}
69 accessibilityRole="button"
70 accessibilityLabel={value === "date" ? "Choose date" : "Choose time"}
71 className="min-h-12 flex-row items-center justify-between rounded-xl bg-subtle px-3"
72 onPress={() => setPicker(value)}
73 >
74 <AppText>{value === "date" ? "Date" : "Time"}</AppText>
75 <AppText>
76 {value === "date"
77 ? date.toLocaleDateString()
78 : date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
79 </AppText>
80 </Pressable>
81 ))}
82 {picker && (
83 <DateTimePicker
84 value={date}
85 mode={picker}
86 display={Platform.OS === "ios" ? "spinner" : "default"}
87 onDismiss={() => setPicker(null)}
88 onValueChange={(_, selected) => {
89 const next = new Date(date);
90 if (picker === "date")
91 next.setFullYear(
92 selected.getFullYear(),
93 selected.getMonth(),
94 selected.getDate(),
95 );
96 else next.setHours(selected.getHours(), selected.getMinutes(), 0, 0);
97 setDate(next);
98 setError(null);
99 }}
100 />
101 )}
102 </View>
103 ) : (
104 <View className="gap-3">
105 <AppText>Snooze for</AppText>
106 <TextInput
107 accessibilityLabel="Duration"
108 className="min-h-12 rounded-xl bg-subtle px-3 text-base text-foreground"
109 keyboardType="decimal-pad"
110 value={amount}
111 onChangeText={(value) => {
112 setAmount(value);
113 setError(null);
114 }}
115 />
116 <View className="flex-row gap-2">
117 {(["minutes", "hours", "days"] as const).map((value) => (
118 <Pressable
119 key={value}
120 accessibilityRole="radio"
121 accessibilityState={{ checked: unit === value }}
122 className={
123 unit === value
124 ? "min-h-12 flex-1 items-center justify-center rounded-xl bg-subtle"
125 : "min-h-12 flex-1 items-center justify-center rounded-xl"
126 }
127 onPress={() => {
128 setUnit(value);
129 setError(null);
130 }}
131 >
132 <AppText>
133 {value === "minutes" ? "Minutes" : value === "hours" ? "Hours" : "Days"}
134 </AppText>
135 </Pressable>
136 ))}
137 </View>
138 </View>
139 )}
140 {error && (
141 <AppText accessibilityRole="alert" className="text-danger-foreground">
142 {error}
143 </AppText>
144 )}
145 <View className="flex-row justify-end gap-3">
146 <Pressable
147 accessibilityRole="button"
148 className="min-h-12 justify-center px-3"
149 onPress={props.onClose}
150 >
151 <AppText>Cancel</AppText>
152 </Pressable>
153 <Pressable
154 accessibilityRole="button"
155 className="min-h-12 justify-center rounded-xl bg-primary px-3"
156 onPress={() => {
157 const input: CustomSnoozeInput =
158 mode === "date"
159 ? { mode, date: localSnoozeDate(date), time: localSnoozeTime(date) }
160 : { mode, amount: amount.replace(",", "."), unit };
161 const snoozedUntil = resolveCustomSnooze(input, new Date());
162 if (!snoozedUntil) {
163 setError(
164 mode === "date"
165 ? "Choose a date and time in the future."
166 : "Enter a positive duration.",
167 );
168 return;
169 }
170 props.onSnooze(snoozedUntil);
171 props.onClose();
172 }}
173 >
174 <AppText className="text-primary-foreground">Snooze</AppText>
175 </Pressable>
176 </View>
177 </ScrollView>
178 </KeyboardAvoidingView>
179 </Modal>
180 );
181}
apps/mobile/src/features/threads/CustomSnoozeSheet.tsx+1-182
...@@ -1,182 +1 @@...@@ -1,182 +1 @@
1import { DateTimePicker } from "@expo/ui/community/datetime-picker";1export { CustomSnoozeSheet } from "./CustomSnoozeSheet.shared";
2import {
3 localSnoozeDate,
4 localSnoozeTime,
5 resolveCustomSnooze,
6 type CustomSnoozeInput,
7} from "@t3tools/client-runtime/state/thread-settled";
8import { useState } from "react";
9import {
10 KeyboardAvoidingView,
11 Modal,
12 Platform,
13 Pressable,
14 ScrollView,
15 TextInput,
16 View,
17} from "react-native";
18import { AppText } from "../../components/AppText";
19import { SegmentedControl } from "../../components/SegmentedControl";
20
21export function CustomSnoozeSheet(props: {
22 readonly onClose: () => void;
23 readonly onSnooze: (snoozedUntil: string) => void;
24}) {
25 const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date");
26 const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000));
27 const [picker, setPicker] = useState<"date" | "time" | null>(null);
28 const [amount, setAmount] = useState("2");
29 const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours");
30 const [error, setError] = useState<string | null>(null);
31 return (
32 <Modal visible transparent animationType="fade" onRequestClose={props.onClose}>
33 <KeyboardAvoidingView
34 className="flex-1 items-center justify-center bg-black/40 px-6"
35 behavior={Platform.OS === "ios" ? "padding" : undefined}
36 >
37 <ScrollView
38 className="max-h-[80%] w-full max-w-md grow-0 rounded-3xl bg-screen"
39 keyboardShouldPersistTaps="handled"
40 contentInsetAdjustmentBehavior="automatic"
41 contentContainerStyle={{ padding: 24, paddingBottom: 24, gap: 20 }}
42 >
43 <AppText accessibilityRole="header" className="text-xl font-t3-semibold">
44 Custom snooze
45 </AppText>
46 <AppText className="text-base text-foreground-secondary">
47 Choose when snoozed threads return to your inbox.
48 </AppText>
49 <SegmentedControl
50 options={
51 [
52 { value: "date", label: "Date and time" },
53 { value: "duration", label: "Duration" },
54 ] as const
55 }
56 selected={mode}
57 onSelect={(value) => {
58 setMode(value);
59 setPicker(null);
60 setError(null);
61 }}
62 role="tab"
63 />
64 {mode === "date" ? (
65 <View className="gap-3">
66 {(["date", "time"] as const).map((value) => (
67 <Pressable
68 key={value}
69 accessibilityRole="button"
70 accessibilityLabel={value === "date" ? "Choose date" : "Choose time"}
71 className="min-h-12 flex-row items-center justify-between rounded-xl bg-subtle px-3"
72 onPress={() => setPicker(value)}
73 >
74 <AppText>{value === "date" ? "Date" : "Time"}</AppText>
75 <AppText>
76 {value === "date"
77 ? date.toLocaleDateString()
78 : date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
79 </AppText>
80 </Pressable>
81 ))}
82 {picker && (
83 <DateTimePicker
84 value={date}
85 mode={picker}
86 display={Platform.OS === "ios" ? "spinner" : "default"}
87 onDismiss={() => setPicker(null)}
88 onValueChange={(_, selected) => {
89 const next = new Date(date);
90 if (picker === "date")
91 next.setFullYear(
92 selected.getFullYear(),
93 selected.getMonth(),
94 selected.getDate(),
95 );
96 else next.setHours(selected.getHours(), selected.getMinutes(), 0, 0);
97 setDate(next);
98 setError(null);
99 if (Platform.OS === "android") setPicker(null);
100 }}
101 />
102 )}
103 </View>
104 ) : (
105 <View className="gap-3">
106 <AppText>Snooze for</AppText>
107 <TextInput
108 accessibilityLabel="Duration"
109 className="min-h-12 rounded-xl bg-subtle px-3 text-base text-foreground"
110 keyboardType="decimal-pad"
111 value={amount}
112 onChangeText={(value) => {
113 setAmount(value);
114 setError(null);
115 }}
116 />
117 <View className="flex-row gap-2">
118 {(["minutes", "hours", "days"] as const).map((value) => (
119 <Pressable
120 key={value}
121 accessibilityRole="radio"
122 accessibilityState={{ checked: unit === value }}
123 className={
124 unit === value
125 ? "min-h-12 flex-1 items-center justify-center rounded-xl bg-subtle"
126 : "min-h-12 flex-1 items-center justify-center rounded-xl"
127 }
128 onPress={() => {
129 setUnit(value);
130 setError(null);
131 }}
132 >
133 <AppText>
134 {value === "minutes" ? "Minutes" : value === "hours" ? "Hours" : "Days"}
135 </AppText>
136 </Pressable>
137 ))}
138 </View>
139 </View>
140 )}
141 {error && (
142 <AppText accessibilityRole="alert" className="text-danger-foreground">
143 {error}
144 </AppText>
145 )}
146 <View className="flex-row justify-end gap-3">
147 <Pressable
148 accessibilityRole="button"
149 className="min-h-12 justify-center px-3"
150 onPress={props.onClose}
151 >
152 <AppText>Cancel</AppText>
153 </Pressable>
154 <Pressable
155 accessibilityRole="button"
156 className="min-h-12 justify-center rounded-xl bg-primary px-3"
157 onPress={() => {
158 const input: CustomSnoozeInput =
159 mode === "date"
160 ? { mode, date: localSnoozeDate(date), time: localSnoozeTime(date) }
161 : { mode, amount: amount.replace(",", "."), unit };
162 const snoozedUntil = resolveCustomSnooze(input, new Date());
163 if (!snoozedUntil) {
164 setError(
165 mode === "date"
166 ? "Choose a date and time in the future."
167 : "Enter a positive duration.",
168 );
169 return;
170 }
171 props.onSnooze(snoozedUntil);
172 props.onClose();
173 }}
174 >
175 <AppText className="text-primary-foreground">Snooze</AppText>
176 </Pressable>
177 </View>
178 </ScrollView>
179 </KeyboardAvoidingView>
180 </Modal>
181 );
182}
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+122-51
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { MaterialListRow } from "../../components/MaterialListRow";
1import type { VcsRef } from "@t3tools/client-runtime/state/vcs";2import type { VcsRef } from "@t3tools/client-runtime/state/vcs";
2import { resolveEnvironmentMachineKind } from "@t3tools/contracts";3import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
3import { LegendList } from "@legendapp/list/react-native";4import { LegendList } from "@legendapp/list/react-native";
...@@ -20,6 +21,7 @@ import {...@@ -20,6 +21,7 @@ import {
20import { useSafeAreaInsets } from "react-native-safe-area-context";21import { useSafeAreaInsets } from "react-native-safe-area-context";
2122
22import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";23import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
24import { MaterialScreenContent } from "../../components/MaterialScreenContent";
23import { SymbolView } from "../../components/AppSymbol";25import { SymbolView } from "../../components/AppSymbol";
24import { AppText as Text } from "../../components/AppText";26import { AppText as Text } from "../../components/AppText";
25import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";27import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
...@@ -46,6 +48,34 @@ function SelectionRow(props: {...@@ -46,6 +48,34 @@ function SelectionRow(props: {
46 readonly subtitle?: string;48 readonly subtitle?: string;
47 readonly title: string;49 readonly title: string;
48}) {50}) {
51 if (Platform.OS === "android") {
52 return (
53 <MaterialListRow
54 title={props.title}
55 subtitle={props.subtitle}
56 leading={
57 props.icon === "arrow.triangle.branch" ? (
58 <SymbolView
59 name="arrow.triangle.branch"
60 size={24}
61 tintColorClassName="accent-icon-muted"
62 />
63 ) : (
64 props.icon
65 )
66 }
67 trailing={
68 props.selected ? (
69 <SymbolView name="checkmark" size={20} tintColorClassName="accent-primary" />
70 ) : null
71 }
72 accessibilityRole="radio"
73 accessibilityState={{ checked: props.selected }}
74 disabled={props.disabled}
75 onPress={props.onPress}
76 />
77 );
78 }
49 return (79 return (
50 <Pressable80 <Pressable
51 accessibilityLabel={[props.title, props.subtitle].filter(Boolean).join(", ")}81 accessibilityLabel={[props.title, props.subtitle].filter(Boolean).join(", ")}
...@@ -63,7 +93,7 @@ function SelectionRow(props: {...@@ -63,7 +93,7 @@ function SelectionRow(props: {
63 <SymbolView93 <SymbolView
64 name="arrow.triangle.branch"94 name="arrow.triangle.branch"
65 size={17}95 size={17}
66 tintColorClassName={"accent-icon-muted"}96 tintColorClassName="accent-icon-muted"
67 type="monochrome"97 type="monochrome"
68 />98 />
69 ) : (99 ) : (
...@@ -83,7 +113,7 @@ function SelectionRow(props: {...@@ -83,7 +113,7 @@ function SelectionRow(props: {
83 <SymbolView113 <SymbolView
84 name="checkmark"114 name="checkmark"
85 size={16}115 size={16}
86 tintColorClassName={"accent-icon"}116 tintColorClassName="accent-icon"
87 type="monochrome"117 type="monochrome"
88 weight="semibold"118 weight="semibold"
89 />119 />
...@@ -99,7 +129,13 @@ function ToggleRow(props: {...@@ -99,7 +129,13 @@ function ToggleRow(props: {
99}) {129}) {
100 return (130 return (
101 <View className="min-h-14 flex-row items-center gap-3 bg-card px-4 py-3">131 <View className="min-h-14 flex-row items-center gap-3 bg-card px-4 py-3">
102 <Text className="min-w-0 flex-1 text-base font-t3-medium text-foreground" numberOfLines={1}>132 <Text
133 className={cn(
134 "min-w-0 flex-1 text-base text-foreground",
135 Platform.OS !== "android" && "font-t3-medium",
136 )}
137 numberOfLines={1}
138 >
103 {props.title}139 {props.title}
104 </Text>140 </Text>
105 <ThemedSwitch141 <ThemedSwitch
...@@ -125,8 +161,14 @@ function BranchSelectionRow(props: {...@@ -125,8 +161,14 @@ function BranchSelectionRow(props: {
125 return (161 return (
126 <View162 <View
127 className={cn(163 className={cn(
128 props.isFirst && "overflow-hidden rounded-t-2xl",164 props.isFirst &&
129 props.isLast && "overflow-hidden rounded-b-2xl",165 (Platform.OS === "android"
166 ? "overflow-hidden rounded-t-[28px]"
167 : "overflow-hidden rounded-t-2xl"),
168 props.isLast &&
169 (Platform.OS === "android"
170 ? "overflow-hidden rounded-b-[28px]"
171 : "overflow-hidden rounded-b-2xl"),
130 )}172 )}
131 >173 >
132 <SelectionRow174 <SelectionRow
...@@ -143,7 +185,17 @@ function BranchSelectionRow(props: {...@@ -143,7 +185,17 @@ function BranchSelectionRow(props: {
143}185}
144186
145function PickerSurface(props: { readonly children: ReactNode }) {187function PickerSurface(props: { readonly children: ReactNode }) {
146 return <View className="overflow-hidden rounded-2xl bg-card">{props.children}</View>;188 return (
189 <View
190 className={
191 Platform.OS === "android"
192 ? "overflow-hidden rounded-[28px] bg-card"
193 : "overflow-hidden rounded-2xl bg-card"
194 }
195 >
196 {props.children}
197 </View>
198 );
147}199}
148200
149export function NewTaskEnvironmentPickerRouteScreen() {201export function NewTaskEnvironmentPickerRouteScreen() {
...@@ -161,42 +213,48 @@ export function NewTaskEnvironmentPickerRouteScreen() {...@@ -161,42 +213,48 @@ export function NewTaskEnvironmentPickerRouteScreen() {
161 }}213 }}
162 />214 />
163 {Platform.OS === "android" ? (215 {Platform.OS === "android" ? (
164 <AndroidScreenHeader title="Environment" onBack={() => navigation.goBack()} />216 <AndroidScreenHeader
217 title="Environment"
218 hideBottomBorder
219 onBack={() => navigation.goBack()}
220 />
165 ) : null}221 ) : null}
166 <ScrollView222 <MaterialScreenContent>
167 contentInsetAdjustmentBehavior="automatic"223 <ScrollView
168 contentContainerStyle={{224 contentInsetAdjustmentBehavior="automatic"
169 paddingBottom: Math.max(insets.bottom, 16) + 16,225 contentContainerStyle={{
170 paddingHorizontal: 16,226 paddingBottom: Math.max(insets.bottom, 16) + 16,
171 paddingTop: 16,227 paddingHorizontal: 16,
172 }}228 paddingTop: 16,
173 showsVerticalScrollIndicator={false}229 }}
174 >230 showsVerticalScrollIndicator={false}
175 <PickerSurface>231 >
176 {flow.environments.map((environment, index) => (232 <PickerSurface>
177 <SelectionRow233 {flow.environments.map((environment, index) => (
178 key={String(environment.environmentId)}234 <SelectionRow
179 icon={235 key={String(environment.environmentId)}
180 <EnvironmentMachineSymbol236 icon={
181 kind={resolveEnvironmentMachineKind(237 <EnvironmentMachineSymbol
182 serverConfigs.get(environment.environmentId) ?? null,238 kind={resolveEnvironmentMachineKind(
183 )}239 serverConfigs.get(environment.environmentId) ?? null,
184 size={17}240 )}
185 tintColorClassName="accent-icon-muted"241 size={Platform.OS === "android" ? 24 : 17}
186 />242 tintColorClassName="accent-icon-muted"
187 }243 />
188 isLast={index === flow.environments.length - 1}244 }
189 onPress={() => {245 isLast={index === flow.environments.length - 1}
190 void Haptics.selectionAsync();246 onPress={() => {
191 flow.selectEnvironment(environment.environmentId);247 void Haptics.selectionAsync();
192 navigation.goBack();248 flow.selectEnvironment(environment.environmentId);
193 }}249 navigation.goBack();
194 selected={flow.selectedEnvironmentId === environment.environmentId}250 }}
195 title={environment.environmentLabel}251 selected={flow.selectedEnvironmentId === environment.environmentId}
196 />252 title={environment.environmentLabel}
197 ))}253 />
198 </PickerSurface>254 ))}
199 </ScrollView>255 </PickerSurface>
256 </ScrollView>
257 </MaterialScreenContent>
200 </View>258 </View>
201 );259 );
202}260}
...@@ -225,7 +283,7 @@ export function NewTaskBranchPickerRouteScreen() {...@@ -225,7 +283,7 @@ export function NewTaskBranchPickerRouteScreen() {
225 ? 16283 ? 16
226 : Math.max(insets.bottom, 16) + 16,284 : Math.max(insets.bottom, 16) + 16,
227 paddingHorizontal: 16,285 paddingHorizontal: 16,
228 paddingTop: 12,286 paddingTop: 16,
229 }),287 }),
230 [insets.bottom, usesNativeMailSearchToolbar],288 [insets.bottom, usesNativeMailSearchToolbar],
231 );289 );
...@@ -327,7 +385,12 @@ export function NewTaskBranchPickerRouteScreen() {...@@ -327,7 +385,12 @@ export function NewTaskBranchPickerRouteScreen() {
327385
328 const branchListHeader =386 const branchListHeader =
329 flow.workspaceMode === "worktree" ? (387 flow.workspaceMode === "worktree" ? (
330 <View className="mb-3 overflow-hidden rounded-2xl">388 <View
389 className={cn(
390 "mb-3 overflow-hidden",
391 Platform.OS === "android" ? "rounded-[28px]" : "rounded-2xl",
392 )}
393 >
331 <ToggleRow394 <ToggleRow
332 onValueChange={flow.setStartFromOrigin}395 onValueChange={flow.setStartFromOrigin}
333 title="Start from origin"396 title="Start from origin"
...@@ -339,9 +402,9 @@ export function NewTaskBranchPickerRouteScreen() {...@@ -339,9 +402,9 @@ export function NewTaskBranchPickerRouteScreen() {
339 const branchContent =402 const branchContent =
340 flow.filteredBranches.length === 0 ? (403 flow.filteredBranches.length === 0 ? (
341 <ScrollView404 <ScrollView
342 className="flex-1 bg-sheet"405 className={Platform.OS === "android" ? "flex-1 bg-sheet-solid" : "flex-1 bg-sheet"}
343 contentInsetAdjustmentBehavior="automatic"406 contentInsetAdjustmentBehavior="automatic"
344 contentContainerStyle={{ flexGrow: 1, paddingHorizontal: 16, paddingTop: 12 }}407 contentContainerStyle={{ flexGrow: 1, paddingHorizontal: 16, paddingTop: 16 }}
345 scrollEnabled={false}408 scrollEnabled={false}
346 showsVerticalScrollIndicator={false}409 showsVerticalScrollIndicator={false}
347 >410 >
...@@ -380,7 +443,7 @@ export function NewTaskBranchPickerRouteScreen() {...@@ -380,7 +443,7 @@ export function NewTaskBranchPickerRouteScreen() {
380 alwaysBounceVertical={false}443 alwaysBounceVertical={false}
381 automaticallyAdjustsScrollIndicatorInsets444 automaticallyAdjustsScrollIndicatorInsets
382 automaticallyAdjustKeyboardInsets={Platform.OS === "ios"}445 automaticallyAdjustKeyboardInsets={Platform.OS === "ios"}
383 className="flex-1 bg-sheet"446 className={Platform.OS === "android" ? "flex-1 bg-sheet-solid" : "flex-1 bg-sheet"}
384 contentInsetAdjustmentBehavior="automatic"447 contentInsetAdjustmentBehavior="automatic"
385 contentContainerStyle={branchListContentStyle}448 contentContainerStyle={branchListContentStyle}
386 data={flow.filteredBranches}449 data={flow.filteredBranches}
...@@ -408,19 +471,27 @@ export function NewTaskBranchPickerRouteScreen() {...@@ -408,19 +471,27 @@ export function NewTaskBranchPickerRouteScreen() {
408 return (471 return (
409 <View className="flex-1 bg-sheet" collapsable={false}>472 <View className="flex-1 bg-sheet" collapsable={false}>
410 <NativeStackScreenOptions options={{ headerShown: false }} />473 <NativeStackScreenOptions options={{ headerShown: false }} />
411 <AndroidScreenHeader title={screenTitle} onBack={() => navigation.goBack()} />474 <AndroidScreenHeader
412 <View className="px-4 pb-2 pt-3">475 title={screenTitle}
476 hideBottomBorder
477 onBack={() => navigation.goBack()}
478 />
479 <View className="bg-header px-4 pb-3 pt-1">
413 <TextInput480 <TextInput
414 autoCapitalize="none"481 autoCapitalize="none"
415 autoCorrect={false}482 autoCorrect={false}
416 className="h-11 rounded-xl bg-card px-4 font-sans text-base text-foreground"483 accessibilityLabel="Find a branch"
484 className="h-12 rounded-full border border-input-border bg-input px-4 font-sans text-base text-foreground"
485 selectionColorClassName="accent-primary/32"
486 cursorColorClassName="accent-primary"
487 selectionHandleColorClassName="accent-primary"
417 onChangeText={flow.setBranchQuery}488 onChangeText={flow.setBranchQuery}
418 placeholder="Find a branch"489 placeholder="Find a branch"
419 placeholderTextColorClassName={"accent-placeholder"}490 placeholderTextColorClassName="accent-placeholder"
420 value={flow.branchQuery}491 value={flow.branchQuery}
421 />492 />
422 </View>493 </View>
423 {branchContent}494 <MaterialScreenContent>{branchContent}</MaterialScreenContent>
424 </View>495 </View>
425 );496 );
426 }497 }
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+25-16
...@@ -27,12 +27,10 @@ import Animated from "react-native-reanimated";...@@ -27,12 +27,10 @@ import Animated from "react-native-reanimated";
27import { useSafeAreaInsets } from "react-native-safe-area-context";27import { useSafeAreaInsets } from "react-native-safe-area-context";
28import { useUniwindTheme } from "../../lib/useUniwindTheme";28import { useUniwindTheme } from "../../lib/useUniwindTheme";
29import { useFontFamily } from "../../lib/useFontFamily";29import { useFontFamily } from "../../lib/useFontFamily";
30
31import {30import {
32 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,31 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
33 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,32 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
34 resolveEnvironmentMachineKind,33 resolveEnvironmentMachineKind,
35 type EnvironmentId,
36} from "@t3tools/contracts";34} from "@t3tools/contracts";
3735
38import {36import {
...@@ -51,6 +49,7 @@ import {...@@ -51,6 +49,7 @@ import {
51 ComposerToolbarRow,49 ComposerToolbarRow,
52} from "../../components/ComposerToolbar";50} from "../../components/ComposerToolbar";
53import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";51import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
52import { MaterialScreenContent } from "../../components/MaterialScreenContent";
54import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";53import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
55import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";54import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
56import { composerStripAttachments } from "../../lib/composerImages";55import { composerStripAttachments } from "../../lib/composerImages";
...@@ -81,7 +80,6 @@ import {...@@ -81,7 +80,6 @@ import {
81 useThreadSettingsSheetPresentation,80 useThreadSettingsSheetPresentation,
82 type NavigationWithFinishTransitioning,81 type NavigationWithFinishTransitioning,
83} from "./use-thread-settings-sheet-presentation";82} from "./use-thread-settings-sheet-presentation";
84
85import { makeTurnCommandMetadata } from "../../lib/commandMetadata";83import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
86import {84import {
87 convertPastedImagesToAttachments,85 convertPastedImagesToAttachments,
...@@ -140,7 +138,7 @@ function NewTaskWorkspaceIcon(props: {...@@ -140,7 +138,7 @@ function NewTaskWorkspaceIcon(props: {
140 <SymbolView138 <SymbolView
141 name="folder"139 name="folder"
142 size={16}140 size={16}
143 tintColorClassName={"accent-icon-muted"}141 tintColorClassName="accent-icon-muted"
144 type="monochrome"142 type="monochrome"
145 />143 />
146 );144 );
...@@ -151,14 +149,14 @@ function NewTaskWorkspaceIcon(props: {...@@ -151,14 +149,14 @@ function NewTaskWorkspaceIcon(props: {
151 <SymbolView149 <SymbolView
152 name="folder"150 name="folder"
153 size={16}151 size={16}
154 tintColorClassName={"accent-icon-muted"}152 tintColorClassName="accent-icon-muted"
155 type="monochrome"153 type="monochrome"
156 />154 />
157 <View className="absolute -right-1 -bottom-1">155 <View className="absolute -right-1 -bottom-1">
158 <SymbolView156 <SymbolView
159 name="arrow.triangle.branch"157 name="arrow.triangle.branch"
160 size={9}158 size={9}
161 tintColorClassName={"accent-icon-muted"}159 tintColorClassName="accent-icon-muted"
162 type="monochrome"160 type="monochrome"
163 />161 />
164 </View>162 </View>
...@@ -1322,7 +1320,11 @@ export function NewTaskDraftScreen(props: {...@@ -1322,7 +1320,11 @@ export function NewTaskDraftScreen(props: {
1322 {Platform.OS === "android" ? (1320 {Platform.OS === "android" ? (
1323 <>1321 <>
1324 <NativeStackScreenOptions options={{ headerShown: false }} />1322 <NativeStackScreenOptions options={{ headerShown: false }} />
1325 <AndroidScreenHeader title="New Thread" onBack={() => navigation.goBack()} />1323 <AndroidScreenHeader
1324 title="New thread"
1325 hideBottomBorder
1326 onBack={() => navigation.goBack()}
1327 />
1326 </>1328 </>
1327 ) : (1329 ) : (
1328 <NativeStackScreenOptions options={{ title: "Loading task" }} />1330 <NativeStackScreenOptions options={{ title: "Loading task" }} />
...@@ -1540,7 +1542,12 @@ export function NewTaskDraftScreen(props: {...@@ -1540,7 +1542,12 @@ export function NewTaskDraftScreen(props: {
1540 );1542 );
15411543
1542 const composerDock = (1544 const composerDock = (
1543 <View className="bg-sheet px-[12px] pt-1" style={{ paddingBottom: controlsBottomPadding }}>1545 <View
1546 className={
1547 Platform.OS === "android" ? "bg-sheet-solid px-[12px] pt-1" : "bg-sheet px-[12px] pt-1"
1548 }
1549 style={{ paddingBottom: controlsBottomPadding }}
1550 >
1544 {!voiceInput.isBusy &&1551 {!voiceInput.isBusy &&
1545 composerMenu.trigger &&1552 composerMenu.trigger &&
1546 (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request") ? (1553 (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request") ? (
...@@ -1754,15 +1761,17 @@ export function NewTaskDraftScreen(props: {...@@ -1754,15 +1761,17 @@ export function NewTaskDraftScreen(props: {
1754 return (1761 return (
1755 <View className="flex-1 bg-sheet" collapsable={false}>1762 <View className="flex-1 bg-sheet" collapsable={false}>
1756 <NativeStackScreenOptions options={{ headerShown: false }} />1763 <NativeStackScreenOptions options={{ headerShown: false }} />
1757 <AndroidScreenHeader title="New task" onBack={closeNewTask} />1764 <AndroidScreenHeader title="New thread" hideBottomBorder onBack={closeNewTask} />
1758 {heroViewport}1765 <MaterialScreenContent>
1766 {heroViewport}
17591767
1760 <KeyboardStickyView1768 <KeyboardStickyView
1761 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}1769 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
1762 offset={{ closed: 0, opened: keyboardOpenedOffset }}1770 offset={{ closed: 0, opened: keyboardOpenedOffset }}
1763 >1771 >
1764 {composerDock}1772 {composerDock}
1765 </KeyboardStickyView>1773 </KeyboardStickyView>
1774 </MaterialScreenContent>
1766 </View>1775 </View>
1767 );1776 );
1768 }1777 }
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+154-91
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { MaterialListRow } from "../../components/MaterialListRow";
1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";2import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
2import {3import {
3 StackActions,4 StackActions,
...@@ -8,11 +9,13 @@ import {...@@ -8,11 +9,13 @@ import {
8import { SymbolView } from "../../components/AppSymbol";9import { SymbolView } from "../../components/AppSymbol";
9import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";10import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
10import { useEffect, useRef } from "react";11import { useEffect, useRef } from "react";
11import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native";12import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native";
12import { useSafeAreaInsets } from "react-native-safe-area-context";13import { useSafeAreaInsets } from "react-native-safe-area-context";
13import { cn } from "../../lib/cn";14import { cn } from "../../lib/cn";
14
15import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";15import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
16import { MaterialScreenContent } from "../../components/MaterialScreenContent";
17import { MaterialButton } from "../../components/MaterialButton";
18import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
16import { AppText as Text } from "../../components/AppText";19import { AppText as Text } from "../../components/AppText";
17import { ProjectFavicon } from "../../components/ProjectFavicon";20import { ProjectFavicon } from "../../components/ProjectFavicon";
18import { useProjects } from "../../state/entities";21import { useProjects } from "../../state/entities";
...@@ -183,8 +186,9 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -183,8 +186,9 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
183 <NativeStackScreenOptions options={{ headerShown: false }} />186 <NativeStackScreenOptions options={{ headerShown: false }} />
184 <AndroidScreenHeader187 <AndroidScreenHeader
185 title={screenTitle}188 title={screenTitle}
189 hideBottomBorder
186 subtitle={incomingShareSubtitle}190 subtitle={incomingShareSubtitle}
187 onBack={layout.usesSplitView ? () => navigation.goBack() : undefined}191 onBack={() => navigation.goBack()}
188 actions={192 actions={
189 catalogState.hasReadyEnvironment193 catalogState.hasReadyEnvironment
190 ? [194 ? [
...@@ -226,97 +230,156 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -226,97 +230,156 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
226 </>230 </>
227 )}231 )}
228232
229 <ScrollView233 <MaterialScreenContent>
230 contentInsetAdjustmentBehavior="automatic"234 <ScrollView
231 showsVerticalScrollIndicator={false}235 contentInsetAdjustmentBehavior="automatic"
232 className="flex-1"236 showsVerticalScrollIndicator={false}
233 contentContainerStyle={{237 className="flex-1"
234 gap: 12,238 contentContainerStyle={{
235 paddingBottom: Math.max(insets.bottom, 18) + 18,239 gap: Platform.OS === "android" ? 8 : 12,
236 paddingHorizontal: 20,240 paddingBottom: Math.max(insets.bottom, 18) + 18,
237 paddingTop: 8,241 paddingHorizontal: Platform.OS === "android" ? 16 : 20,
238 }}242 paddingTop: Platform.OS === "android" ? 16 : 8,
239 >243 ...(Platform.OS === "android" && projectScopes.length === 0
240 {projectScopes.length === 0 ? (244 ? { flexGrow: 1, justifyContent: "center" as const }
241 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8">245 : {}),
242 {projectEmptyState.loading ? (246 }}
243 <ActivityIndicator colorClassName={"accent-icon-muted"} />247 >
244 ) : null}248 {projectScopes.length === 0 ? (
245 <Text className="text-center text-lg font-t3-bold text-foreground">249 <View
246 {projectEmptyState.title}250 collapsable={false}
247 </Text>251 className={cn(
248 <Text className="text-center text-sm leading-normal text-foreground-muted">252 "items-center gap-3 px-6 py-8",
249 {projectEmptyState.detail}253 Platform.OS !== "android" && "rounded-[24px] bg-card",
250 </Text>254 )}
251 {!catalogState.hasReadyEnvironment ? (255 >
252 <Pressable256 {projectEmptyState.loading ? (
253 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"257 <ActivityIndicator colorClassName="accent-icon-muted" />
254 onPress={() => navigation.navigate("ConnectionsNew")}258 ) : null}
255 >259 <Text className="text-center text-lg font-t3-bold text-foreground">
256 <Text className="text-sm font-t3-bold text-primary-foreground">260 {projectEmptyState.title}
257 Add environment261 </Text>
258 </Text>262 <Text className="text-center text-sm leading-normal text-foreground-muted">
259 </Pressable>263 {projectEmptyState.detail}
260 ) : (264 </Text>
261 <Pressable265 {Platform.OS === "android" ? (
262 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"266 <MaterialButton
263 onPress={() => navigation.dispatch(StackActions.push("AddProject"))}267 label={catalogState.hasReadyEnvironment ? "Add new project" : "Add environment"}
264 >268 tone="primary"
265 <Text className="text-sm font-t3-bold text-primary-foreground">269 onPress={() =>
266 Add new project270 catalogState.hasReadyEnvironment
267 </Text>271 ? navigation.dispatch(StackActions.push("AddProject"))
268 </Pressable>272 : navigation.navigate("ConnectionsNew")
269 )}273 }
270 </View>274 />
271 ) : (275 ) : !catalogState.hasReadyEnvironment ? (
272 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">276 <Pressable
273 {projectScopes.map((scope, scopeIndex) => {277 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
274 const hasMultipleProjects = scope.projects.length > 1;278 onPress={() => navigation.navigate("ConnectionsNew")}
275 const selectionTarget = getProjectScopeSelectionTarget(scope, selectedEnvironmentId);
276 return (
277 <View
278 key={scope.key}
279 className={cn(scopeIndex > 0 && "border-t border-border-subtle")}
280 >279 >
281 <Pressable280 <Text className="text-sm font-t3-bold text-primary-foreground">
282 disabled={reservedDestinationProject !== null}281 Add environment
283 onPress={() => void selectProject(selectionTarget)}282 </Text>
284 className="flex-row items-center gap-3 bg-card px-4 py-3.5"283 </Pressable>
285 >284 ) : (
286 <View className="h-7 w-7 items-center justify-center">285 <Pressable
287 <ProjectFavicon286 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
288 environmentId={scope.representative.environmentId}287 onPress={() => navigation.dispatch(StackActions.push("AddProject"))}
289 faviconPath={scope.representative.faviconPath}288 >
290 size={20}289 <Text className="text-sm font-t3-bold text-primary-foreground">
291 projectTitle={scope.title}290 Add new project
292 workspaceRoot={scope.representative.workspaceRoot}291 </Text>
293 />292 </Pressable>
294 </View>293 )}
295 <View className="min-w-0 flex-1">294 </View>
296 <Text className="text-base leading-snug font-t3-bold">{scope.title}</Text>295 ) : (
297 <Text296 <View
298 className="text-xs leading-snug text-foreground-muted"297 collapsable={false}
299 ellipsizeMode="middle"298 className={
300 numberOfLines={1}299 Platform.OS === "android"
301 >300 ? "overflow-hidden rounded-[28px] bg-card"
302 {hasMultipleProjects301 : "overflow-hidden rounded-[24px] bg-card"
302 }
303 >
304 {projectScopes.map((scope, scopeIndex) => {
305 const hasMultipleProjects = scope.projects.length > 1;
306 const selectionTarget = getProjectScopeSelectionTarget(
307 scope,
308 selectedEnvironmentId,
309 );
310 if (Platform.OS === "android") {
311 return (
312 <MaterialListRow
313 key={scope.key}
314 title={scope.title}
315 subtitle={
316 hasMultipleProjects
303 ? `${scope.projects.length} workspaces`317 ? `${scope.projects.length} workspaces`
304 : selectionTarget.workspaceRoot}318 : selectionTarget.workspaceRoot
305 </Text>319 }
306 </View>320 disabled={reservedDestinationProject !== null}
307 <SymbolView321 onPress={() => void selectProject(selectionTarget)}
308 name="chevron.right"322 leading={
309 size={14}323 <ProjectFavicon
310 tintColorClassName={"accent-chevron"}324 environmentId={scope.representative.environmentId}
311 type="monochrome"325 faviconPath={scope.representative.faviconPath}
326 size={24}
327 projectTitle={scope.title}
328 workspaceRoot={scope.representative.workspaceRoot}
329 />
330 }
312 />331 />
313 </Pressable>332 );
314 </View>333 }
315 );334 return (
316 })}335 <View
317 </View>336 key={scope.key}
318 )}337 className={cn(scopeIndex > 0 && "border-t border-border-subtle")}
319 </ScrollView>338 >
339 <Pressable
340 accessibilityRole="button"
341 accessibilityLabel={scope.title}
342 disabled={reservedDestinationProject !== null}
343 onPress={() => void selectProject(selectionTarget)}
344 className="flex-row items-center gap-3 bg-card px-4 py-3.5"
345 >
346 <View className="h-7 w-7 items-center justify-center">
347 <ProjectFavicon
348 environmentId={scope.representative.environmentId}
349 faviconPath={scope.representative.faviconPath}
350 size={20}
351 projectTitle={scope.title}
352 workspaceRoot={scope.representative.workspaceRoot}
353 />
354 </View>
355 <View className="min-w-0 flex-1">
356 <Text className={cn("text-base leading-snug", "font-t3-bold")}>
357 {scope.title}
358 </Text>
359 <Text
360 className="text-xs leading-snug text-foreground-muted"
361 ellipsizeMode="middle"
362 numberOfLines={1}
363 >
364 {hasMultipleProjects
365 ? `${scope.projects.length} workspaces`
366 : selectionTarget.workspaceRoot}
367 </Text>
368 </View>
369 <SymbolView
370 name="chevron.right"
371 size={14}
372 tintColorClassName="accent-chevron"
373 type="monochrome"
374 />
375 </Pressable>
376 </View>
377 );
378 })}
379 </View>
380 )}
381 </ScrollView>
382 </MaterialScreenContent>
320 </View>383 </View>
321 );384 );
322}385}
apps/mobile/src/features/threads/ThreadComposer.tsx+8-12
...@@ -49,6 +49,7 @@ import Animated, {...@@ -49,6 +49,7 @@ import Animated, {
49 withTiming,49 withTiming,
50} from "react-native-reanimated";50} from "react-native-reanimated";
51import { useUniwindTheme } from "../../lib/useUniwindTheme";51import { useUniwindTheme } from "../../lib/useUniwindTheme";
52import { themeColorWithAlpha } from "../../lib/mobileTheme";
52import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";53import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
53import { scopedThreadKey } from "../../lib/scopedEntities";54import { scopedThreadKey } from "../../lib/scopedEntities";
54import {55import {
...@@ -208,7 +209,6 @@ export function ComposerSurface(props: {...@@ -208,7 +209,6 @@ export function ComposerSurface(props: {
208 /** Morphs between the compact and expanded composer layouts. */209 /** Morphs between the compact and expanded composer layouts. */
209 readonly animateLayout?: boolean;210 readonly animateLayout?: boolean;
210}) {211}) {
211 const { materialYouStyleLayoutActive } = useAppearancePreferences();
212 const colors = useUniwindTheme();212 const colors = useUniwindTheme();
213 const targetBorderRadius =213 const targetBorderRadius =
214 typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0;214 typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0;
...@@ -232,27 +232,23 @@ export function ComposerSurface(props: {...@@ -232,27 +232,23 @@ export function ComposerSurface(props: {
232 return (232 return (
233 <Animated.View233 <Animated.View
234 className={234 className={
235 materialYouStyleLayoutActive235 Platform.OS === "android" ? undefined : "shadow-[0_6px_28px] shadow-adaptive-black-a15-a35"
236 ? undefined
237 : "shadow-[0_6px_28px] shadow-adaptive-black-a15-a35"
238 }236 }
239 layout={layoutTransition}237 layout={layoutTransition}
240 style={[238 style={[
241 animatedShapeStyle,239 animatedShapeStyle,
242 {240 {
243 overflow: "hidden",241 overflow: "hidden",
244 // Android versions before 9 do not support outset box shadows.
245 elevation: Platform.OS === "android" && Platform.Version < 28 ? 10 : undefined,
246 },242 },
247 ]}243 ]}
248 >244 >
249 <AnimatedGlassSurface245 <AnimatedGlassSurface
250 chrome="none"246 chrome="none"
251 fallbackColor={247 fallbackColor={
252 materialYouStyleLayoutActive ? colors["--color-composer-surface"] : colors["--color-card"]248 Platform.OS === "android" ? colors["--color-composer-surface"] : colors["--color-card"]
253 }249 }
254 fallbackClassName={250 fallbackClassName={
255 materialYouStyleLayoutActive ? "border border-composer-border" : "border border-border"251 Platform.OS === "android" ? "border border-composer-border" : "border border-border"
256 }252 }
257 glassEffectStyle="regular"253 glassEffectStyle="regular"
258 // The composer is a passive material containing interactive controls.254 // The composer is a passive material containing interactive controls.
...@@ -277,8 +273,7 @@ export function ComposerSurface(props: {...@@ -277,8 +273,7 @@ export function ComposerSurface(props: {
277273
278export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {274export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
279 const project = useProject(scopeProjectRef(props.environmentId, props.selectedThread.projectId));275 const project = useProject(scopeProjectRef(props.environmentId, props.selectedThread.projectId));
280 const { materialYouStyleLayoutActive, themeVariables: materialTheme } =276 const { themeVariables: materialTheme } = useAppearancePreferences();
281 useAppearancePreferences();
282 const composerPanel = materialTheme["--color-composer-panel"];277 const composerPanel = materialTheme["--color-composer-panel"];
283 const navigation = useNavigation();278 const navigation = useNavigation();
284 const foregroundColor = useUniwindTheme()["--color-foreground"];279 const foregroundColor = useUniwindTheme()["--color-foreground"];
...@@ -627,7 +622,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -627,7 +622,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
627 style={{622 style={{
628 paddingTop: isExpanded ? 8 : 6,623 paddingTop: isExpanded ? 8 : 6,
629 paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6),624 paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6),
630 backgroundColor: materialYouStyleLayoutActive ? composerPanel : undefined,625 backgroundColor:
626 Platform.OS === "android" ? themeColorWithAlpha(composerPanel, 1) : undefined,
631 }}627 }}
632 >628 >
633 {/* The backdrop gradient lives on a plain View: Reanimated's Animated.View629 {/* The backdrop gradient lives on a plain View: Reanimated's Animated.View
...@@ -635,7 +631,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -635,7 +631,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
635 strip fully transparent and the feed text legible through the composer. */}631 strip fully transparent and the feed text legible through the composer. */}
636 <View632 <View
637 className={633 className={
638 materialYouStyleLayoutActive634 Platform.OS === "android"
639 ? "hidden"635 ? "hidden"
640 : "absolute inset-0 bg-linear-to-b from-screen/0 via-screen/60 to-screen/90"636 : "absolute inset-0 bg-linear-to-b from-screen/0 via-screen/60 to-screen/90"
641 }637 }
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+40-47
...@@ -27,8 +27,6 @@ import type {...@@ -27,8 +27,6 @@ import type {
27 UserInputQuestion,27 UserInputQuestion,
28} from "@t3tools/contracts";28} from "@t3tools/contracts";
29import * as Haptics from "expo-haptics";29import * as Haptics from "expo-haptics";
30import { BlurTargetView } from "expo-blur";
31import { GlassBlurTargetContext } from "../../lib/glassBlurTarget";
32import {30import {
33 memo,31 memo,
34 useCallback,32 useCallback,
...@@ -65,7 +63,6 @@ import Animated, {...@@ -65,7 +63,6 @@ import Animated, {
65} from "react-native-reanimated";63} from "react-native-reanimated";
66import { useSafeAreaInsets } from "react-native-safe-area-context";64import { useSafeAreaInsets } from "react-native-safe-area-context";
67import { useWorkspaceContentWidth } from "../layout/workspace-content-width";65import { useWorkspaceContentWidth } from "../layout/workspace-content-width";
68
69import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";66import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
70import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";67import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";
71import type { ComposerEditorHandle } from "../../components/ComposerEditor";68import type { ComposerEditorHandle } from "../../components/ComposerEditor";
...@@ -819,7 +816,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -819,7 +816,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
819 }, [freeze, scrollMessageToEnd]);816 }, [freeze, scrollMessageToEnd]);
820817
821 const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled;818 const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled;
822 const { themeAppearance, materialYouStyleLayoutActive } = useAppearancePreferences();819 const { themeAppearance } = useAppearancePreferences();
823 const isDarkMode = themeAppearance === "dark";820 const isDarkMode = themeAppearance === "dark";
824821
825 const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => {822 const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => {
...@@ -851,13 +848,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -851,13 +848,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
851 const handleFeedTouchCancel = useCallback(() => {848 const handleFeedTouchCancel = useCallback(() => {
852 feedTouchStartRef.current = null;849 feedTouchStartRef.current = null;
853 }, []);850 }, []);
854 const feedBlurTarget = useRef<View>(null);
855851
856 return (852 return (
857 <View className="flex-1">853 <View className="flex-1">
858 {showContent ? (854 {showContent ? (
859 <BlurTargetView855 <View
860 ref={feedBlurTarget}
861 style={{ flex: 1 }}856 style={{ flex: 1 }}
862 onTouchStart={handleFeedTouchStart}857 onTouchStart={handleFeedTouchStart}
863 onTouchMove={handleFeedTouchMove}858 onTouchMove={handleFeedTouchMove}
...@@ -867,7 +862,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -867,7 +862,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
867 <View862 <View
868 pointerEvents="none"863 pointerEvents="none"
869 className={864 className={
870 materialYouStyleLayoutActive865 Platform.OS === "android"
871 ? "absolute inset-0 bg-thread-canvas"866 ? "absolute inset-0 bg-thread-canvas"
872 : "absolute inset-0 bg-screen"867 : "absolute inset-0 bg-screen"
873 }868 }
...@@ -903,7 +898,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -903,7 +898,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
903 onUseArtifactTemplate={handleUseArtifactTemplate}898 onUseArtifactTemplate={handleUseArtifactTemplate}
904 loadEarlier={props.loadEarlier ?? null}899 loadEarlier={props.loadEarlier ?? null}
905 />900 />
906 </BlurTargetView>901 </View>
907 ) : (902 ) : (
908 <View className="flex-1" />903 <View className="flex-1" />
909 )}904 )}
...@@ -1025,44 +1020,42 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -1025,44 +1020,42 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
1025 : undefined1020 : undefined
1026 }1021 }
1027 >1022 >
1028 <GlassBlurTargetContext value={feedBlurTarget}>1023 <ThreadComposer
1029 <ThreadComposer1024 editorRef={composerEditorRef}
1030 editorRef={composerEditorRef}1025 draftMessage={props.draftMessage}
1031 draftMessage={props.draftMessage}1026 draftAttachments={props.draftAttachments}
1032 draftAttachments={props.draftAttachments}1027 placeholder="Ask the repo agent, or run a command…"
1033 placeholder="Ask the repo agent, or run a command…"1028 contentMaxWidth={contentMaxWidth}
1034 contentMaxWidth={contentMaxWidth}1029 connectionState={props.connectionStateLabel}
1035 connectionState={props.connectionStateLabel}1030 environmentLabel={props.environmentLabel}
1036 environmentLabel={props.environmentLabel}1031 selectedThread={props.selectedThread}
1037 selectedThread={props.selectedThread}1032 hasCompactableConversation={hasCompactableConversation && !props.isCompacting}
1038 hasCompactableConversation={hasCompactableConversation && !props.isCompacting}1033 serverConfig={props.serverConfig}
1039 serverConfig={props.serverConfig}1034 queueCount={props.selectedThreadQueueCount}
1040 queueCount={props.selectedThreadQueueCount}1035 environmentId={props.environmentId}
1041 environmentId={props.environmentId}1036 projectCwd={props.threadCwd ?? props.projectWorkspaceRoot}
1042 projectCwd={props.threadCwd ?? props.projectWorkspaceRoot}1037 // Follow-ups typed during setup wait in the draft: queueing
1043 // Follow-ups typed during setup wait in the draft: queueing1038 // them against a thread id the server may still reject
1044 // them against a thread id the server may still reject1039 // would strand them in the outbox.
1045 // would strand them in the outbox.1040 sendBlockedReason={
1046 sendBlockedReason={1041 props.creationState?.kind === "preparing" ? "Starting the task…" : null
1047 props.creationState?.kind === "preparing" ? "Starting the task…" : null1042 }
1048 }1043 bottomInset={composerBottomInset}
1049 bottomInset={composerBottomInset}1044 onChangeDraftMessage={props.onChangeDraftMessage}
1050 onChangeDraftMessage={props.onChangeDraftMessage}1045 onPickDraftMedia={props.onPickDraftMedia}
1051 onPickDraftMedia={props.onPickDraftMedia}1046 onPickDraftFiles={props.onPickDraftFiles}
1052 onPickDraftFiles={props.onPickDraftFiles}1047 onNativePasteImages={props.onNativePasteImages}
1053 onNativePasteImages={props.onNativePasteImages}1048 onNativePasteText={props.onNativePasteText}
1054 onNativePasteText={props.onNativePasteText}1049 onRemoveDraftImage={props.onRemoveDraftImage}
1055 onRemoveDraftImage={props.onRemoveDraftImage}1050 onStopThread={props.onStopThread}
1056 onStopThread={props.onStopThread}1051 onSendMessage={handleSendMessage}
1057 onSendMessage={handleSendMessage}1052 onShowUsageLimits={showUsageLimits}
1058 onShowUsageLimits={showUsageLimits}1053 onUpdateModelSelection={props.onUpdateThreadModelSelection}
1059 onUpdateModelSelection={props.onUpdateThreadModelSelection}1054 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
1060 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}1055 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
1061 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}1056 onExpandedChange={setComposerExpanded}
1062 onExpandedChange={setComposerExpanded}1057 onEditorFocusChange={handleComposerFocusChange}
1063 onEditorFocusChange={handleComposerFocusChange}1058 />
1064 />
1065 </GlassBlurTargetContext>
1066 </View>1059 </View>
1067 </View>1060 </View>
1068 </Animated.View>1061 </Animated.View>
apps/mobile/src/features/threads/ThreadFeed.tsx+1
...@@ -880,6 +880,7 @@ function MarkdownCodeBlock(props: {...@@ -880,6 +880,7 @@ function MarkdownCodeBlock(props: {
880 >880 >
881 <NativeText881 <NativeText
882 selectable882 selectable
883 selectionColorClassName={Platform.OS === "android" ? "accent-primary/32" : undefined}
883 className="font-mono"884 className="font-mono"
884 style={{885 style={{
885 color: props.textColor,886 color: props.textColor,
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+146-126
...@@ -65,6 +65,9 @@ import {...@@ -65,6 +65,9 @@ import {
65 WorkspaceConnectionTitle,65 WorkspaceConnectionTitle,
66} from "../home/WorkspaceConnectionTitle";66} from "../home/WorkspaceConnectionTitle";
67import { SidebarHeaderActions } from "./sidebar-header-actions";67import { SidebarHeaderActions } from "./sidebar-header-actions";
68import { MaterialThreadListToolbar } from "../home/MaterialThreadListToolbar";
69import { useMaterialToolbarHeight } from "../../components/useMaterialToolbarHeight";
70import { useMaterialFabScroll } from "../home/MaterialFabScrollContext";
68import { SidebarFilterButton } from "./sidebar-filter-button";71import { SidebarFilterButton } from "./sidebar-filter-button";
69import { createSidebarHeaderItems } from "./sidebar-native-header-items";72import { createSidebarHeaderItems } from "./sidebar-native-header-items";
70import { SidebarNavigationShell } from "./sidebar-navigation-shell";73import { SidebarNavigationShell } from "./sidebar-navigation-shell";
...@@ -147,10 +150,8 @@ function NativeSidebarContainer(props: ThreadNavigationSidebarProps) {...@@ -147,10 +150,8 @@ function NativeSidebarContainer(props: ThreadNavigationSidebarProps) {
147function ThreadNavigationSidebarPane(150function ThreadNavigationSidebarPane(
148 props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean },151 props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean },
149) {152) {
150 const { materialYouStyleLayoutActive, themeVariables: materialTheme } =153 const { themeVariables: materialTheme } = useAppearancePreferences();
151 useAppearancePreferences();
152 const screenColor = materialTheme["--color-screen"];154 const screenColor = materialTheme["--color-screen"];
153 const mutedColor = materialTheme["--color-foreground-muted"];
154155
155 const insets = useSafeAreaInsets();156 const insets = useSafeAreaInsets();
156 const projects = useProjects();157 const projects = useProjects();
...@@ -747,13 +748,18 @@ function ThreadNavigationSidebarPane(...@@ -747,13 +748,18 @@ function ThreadNavigationSidebarPane(
747 );748 );
748749
749 const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null);750 const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null);
751 const materialToolbarHeight = useMaterialToolbarHeight();
750 // The sticky header (title row, search field, optional connection status)752 // The sticky header (title row, search field, optional connection status)
751 // is measured so the list inset always matches its real height — no753 // is measured so the list inset always matches its real height — no
752 // hardcoded per-variant constants.754 // hardcoded per-variant constants.
753 const stickyHeaderHeight = measuredHeaderHeight ?? insets.top + SIDEBAR_STICKY_HEADER_HEIGHT;755 const stickyHeaderHeight =
756 measuredHeaderHeight ??
757 (Platform.OS === "android"
758 ? Math.max(insets.top, 12) + materialToolbarHeight + 8
759 : insets.top + SIDEBAR_STICKY_HEADER_HEIGHT);
754 const topListInset = stickyHeaderHeight + 6;760 const topListInset = stickyHeaderHeight + 6;
755 const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => {761 const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => {
756 const nextHeight = Math.round(event.nativeEvent.layout.height);762 const nextHeight = event.nativeEvent.layout.height;
757 setMeasuredHeaderHeight((current) => (current === nextHeight ? current : nextHeight));763 setMeasuredHeaderHeight((current) => (current === nextHeight ? current : nextHeight));
758 }, []);764 }, []);
759 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {765 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {
...@@ -777,7 +783,9 @@ function ThreadNavigationSidebarPane(...@@ -777,7 +783,9 @@ function ThreadNavigationSidebarPane(
777 const handleScrollBeginDrag = useCallback(() => {783 const handleScrollBeginDrag = useCallback(() => {
778 openSwipeableRef.current?.close();784 openSwipeableRef.current?.close();
779 }, []);785 }, []);
786 const onMaterialFabScroll = useMaterialFabScroll();
780 const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({787 const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({
788 onScroll: onMaterialFabScroll,
781 onScrollBeginDrag: handleScrollBeginDrag,789 onScrollBeginDrag: handleScrollBeginDrag,
782 });790 });
783 // Project shells load after the first rows draw, so the maps they feed have791 // Project shells load after the first rows draw, so the maps they feed have
...@@ -850,6 +858,7 @@ function ThreadNavigationSidebarPane(...@@ -850,6 +858,7 @@ function ThreadNavigationSidebarPane(
850 [],858 [],
851 );859 );
852 const focusSearch = useCallback(() => {860 const focusSearch = useCallback(() => {
861 if (Platform.OS === "android") return false;
853 const focus = () => {862 const focus = () => {
854 if (props.nativeChrome) {863 if (props.nativeChrome) {
855 searchBarRef.current?.focus();864 searchBarRef.current?.focus();
...@@ -925,7 +934,7 @@ function ThreadNavigationSidebarPane(...@@ -925,7 +934,7 @@ function ThreadNavigationSidebarPane(
925 }),934 }),
926 )}935 )}
927 searchQuery={props.searchQuery}936 searchQuery={props.searchQuery}
928 pane={materialYouStyleLayoutActive ? "screen" : "sidebar"}937 pane={Platform.OS === "android" ? "screen" : "sidebar"}
929 selected={938 selected={
930 scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey939 scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey
931 }940 }
...@@ -966,7 +975,7 @@ function ThreadNavigationSidebarPane(...@@ -966,7 +975,7 @@ function ThreadNavigationSidebarPane(
966 disabled={!shelfPreferencesLoaded}975 disabled={!shelfPreferencesLoaded}
967 expanded={item.expanded}976 expanded={item.expanded}
968 onToggle={toggleSnoozedShelf}977 onToggle={toggleSnoozedShelf}
969 pane={materialYouStyleLayoutActive ? "screen" : "sidebar"}978 pane={Platform.OS === "android" ? "screen" : "sidebar"}
970 />979 />
971 );980 );
972 case "v2-settled-shelf":981 case "v2-settled-shelf":
...@@ -976,7 +985,7 @@ function ThreadNavigationSidebarPane(...@@ -976,7 +985,7 @@ function ThreadNavigationSidebarPane(
976 disabled={!shelfPreferencesLoaded}985 disabled={!shelfPreferencesLoaded}
977 expanded={item.expanded}986 expanded={item.expanded}
978 onToggle={toggleSettledShelf}987 onToggle={toggleSettledShelf}
979 pane={materialYouStyleLayoutActive ? "screen" : "sidebar"}988 pane={Platform.OS === "android" ? "screen" : "sidebar"}
980 />989 />
981 );990 );
982 case "v2-show-more":991 case "v2-show-more":
...@@ -996,7 +1005,7 @@ function ThreadNavigationSidebarPane(...@@ -996,7 +1005,7 @@ function ThreadNavigationSidebarPane(
996 case "header":1005 case "header":
997 return (1006 return (
998 <ThreadListGroupHeader1007 <ThreadListGroupHeader
999 variant={materialYouStyleLayoutActive ? "compact" : "sidebar"}1008 variant={Platform.OS === "android" ? "compact" : "sidebar"}
1000 collapsed={item.collapsed}1009 collapsed={item.collapsed}
1001 isFirst={item.isFirst}1010 isFirst={item.isFirst}
1002 groupKey={item.group.key}1011 groupKey={item.group.key}
...@@ -1014,7 +1023,7 @@ function ThreadNavigationSidebarPane(...@@ -1014,7 +1023,7 @@ function ThreadNavigationSidebarPane(
1014 case "pending-task":1023 case "pending-task":
1015 return (1024 return (
1016 <PendingTaskListRow1025 <PendingTaskListRow
1017 variant={materialYouStyleLayoutActive ? "compact" : "sidebar"}1026 variant={Platform.OS === "android" ? "compact" : "sidebar"}
1018 pendingTask={item.pendingTask}1027 pendingTask={item.pendingTask}
1019 environmentLabel={1028 environmentLabel={
1020 savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null1029 savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null
...@@ -1064,7 +1073,7 @@ function ThreadNavigationSidebarPane(...@@ -1064,7 +1073,7 @@ function ThreadNavigationSidebarPane(
1064 case "show-more":1073 case "show-more":
1065 return (1074 return (
1066 <ThreadListShowMoreRow1075 <ThreadListShowMoreRow
1067 variant={materialYouStyleLayoutActive ? "compact" : "sidebar"}1076 variant={Platform.OS === "android" ? "compact" : "sidebar"}
1068 hiddenCount={item.hiddenCount}1077 hiddenCount={item.hiddenCount}
1069 canShowLess={item.canShowLess}1078 canShowLess={item.canShowLess}
1070 groupKey={item.groupKey}1079 groupKey={item.groupKey}
...@@ -1074,7 +1083,6 @@ function ThreadNavigationSidebarPane(...@@ -1074,7 +1083,6 @@ function ThreadNavigationSidebarPane(
1074 }1083 }
1075 },1084 },
1076 [1085 [
1077 materialYouStyleLayoutActive,
1078 archiveThread,1086 archiveThread,
1079 activeReorderEnvironmentIds,1087 activeReorderEnvironmentIds,
1080 threadMovePlanners,1088 threadMovePlanners,
...@@ -1166,16 +1174,24 @@ function ThreadNavigationSidebarPane(...@@ -1166,16 +1174,24 @@ function ThreadNavigationSidebarPane(
1166 // Snoozed threads need no special case: the shelf header is a list row1174 // Snoozed threads need no special case: the shelf header is a list row
1167 // even while collapsed.1175 // even while collapsed.
1168 const listEmpty = (1176 const listEmpty = (
1169 <Text className="px-2 py-4 text-sm text-foreground-muted">1177 <Text
1178 className={
1179 Platform.OS === "android"
1180 ? "px-4 py-4 text-center text-sm text-foreground-muted"
1181 : "px-2 py-4 text-sm text-foreground-muted"
1182 }
1183 >
1170 {catalogState.isLoadingConnections1184 {catalogState.isLoadingConnections
1171 ? "Loading threads…"1185 ? "Loading threads…"
1172 : props.searchQuery.trim().length > 01186 : Platform.OS === "android" && !catalogState.hasConnections
1173 ? threadSearch.isPending1187 ? "No environments connected"
1174 ? "Searching thread messages…"1188 : props.searchQuery.trim().length > 0
1175 : "No matching threads"1189 ? threadSearch.isPending
1176 : selectedProjectScope !== null1190 ? "Searching thread messages…"
1177 ? `No threads in ${selectedProjectScope.title}`1191 : "No matching threads"
1178 : "No threads yet"}1192 : selectedProjectScope !== null
1193 ? `No threads in ${selectedProjectScope.title}`
1194 : "No threads yet"}
1179 </Text>1195 </Text>
1180 );1196 );
11811197
...@@ -1232,7 +1248,7 @@ function ThreadNavigationSidebarPane(...@@ -1232,7 +1248,7 @@ function ThreadNavigationSidebarPane(
1232 }1248 }
1233 contentContainerStyle={[1249 contentContainerStyle={[
1234 styles.threadListContent,1250 styles.threadListContent,
1235 materialYouStyleLayoutActive ? { paddingHorizontal: 0 } : null,1251 Platform.OS === "android" ? { paddingHorizontal: 0 } : null,
1236 {1252 {
1237 paddingBottom: Math.max(insets.bottom, 16) + 16,1253 paddingBottom: Math.max(insets.bottom, 16) + 16,
1238 paddingTop: 6,1254 paddingTop: 6,
...@@ -1257,15 +1273,18 @@ function ThreadNavigationSidebarPane(...@@ -1257,15 +1273,18 @@ function ThreadNavigationSidebarPane(
1257 return (1273 return (
1258 <View1274 <View
1259 testID="thread-navigation-sidebar"1275 testID="thread-navigation-sidebar"
1260 className="flex-1 border-r border-border bg-drawer"1276 className={
1277 Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 border-r border-border bg-drawer"
1278 }
1261 style={{ width: props.width }}1279 style={{ width: props.width }}
1262 >1280 >
1263 <View1281 <View
1264 className="flex-1"1282 className="flex-1"
1265 style={1283 style={
1266 materialYouStyleLayoutActive1284 Platform.OS === "android"
1267 ? {1285 ? {
1268 marginTop: stickyHeaderHeight,1286 marginTop: stickyHeaderHeight,
1287 marginHorizontal: 4,
1269 paddingBottom: insets.bottom,1288 paddingBottom: insets.bottom,
1270 backgroundColor: screenColor,1289 backgroundColor: screenColor,
1271 borderTopLeftRadius: 28,1290 borderTopLeftRadius: 28,
...@@ -1275,116 +1294,117 @@ function ThreadNavigationSidebarPane(...@@ -1275,116 +1294,117 @@ function ThreadNavigationSidebarPane(
1275 : { paddingBottom: insets.bottom }1294 : { paddingBottom: insets.bottom }
1276 }1295 }
1277 >1296 >
1278 <SwipeableScrollGateProvider enabled={swipeEnabled}>1297 {Platform.OS === "android" && listItems.length === 0 ? (
1279 <GestureDetector gesture={sidebarScrollGesture}>1298 <View className="flex-1 items-center justify-center">{listEmpty}</View>
1280 <LegendList1299 ) : (
1281 data={listItems}1300 <SwipeableScrollGateProvider enabled={swipeEnabled}>
1282 drawDistance={500}1301 <GestureDetector gesture={sidebarScrollGesture}>
1283 estimatedItemSize={64}1302 <LegendList
1284 extraData={listExtraData}1303 data={listItems}
1285 getItemType={(item) => item.type}1304 drawDistance={500}
1286 itemsAreEqual={sidebarItemsAreEqual}1305 estimatedItemSize={64}
1287 keyExtractor={(item) => item.key}1306 extraData={listExtraData}
1288 renderItem={renderListItem}1307 getItemType={(item) => item.type}
1289 contentContainerStyle={[1308 itemsAreEqual={sidebarItemsAreEqual}
1290 styles.threadListContent,1309 keyExtractor={(item) => item.key}
1291 materialYouStyleLayoutActive ? { paddingHorizontal: 0 } : null,1310 renderItem={renderListItem}
1292 {1311 contentContainerStyle={[
1293 paddingBottom:1312 styles.threadListContent,
1294 Platform.OS === "android"1313 Platform.OS === "android" ? { paddingHorizontal: 0 } : null,
1295 ? Math.max(insets.bottom, 16) + 88 - insets.bottom1314 {
1296 : 16 + insets.bottom,1315 paddingBottom:
1297 paddingTop: materialYouStyleLayoutActive ? 6 : topListInset,1316 Platform.OS === "android"
1298 },1317 ? Math.max(insets.bottom, 16) + 148 - insets.bottom
1299 ]}1318 : 16 + insets.bottom,
1300 keyboardDismissMode="on-drag"1319 paddingTop: Platform.OS === "android" ? 6 : topListInset,
1301 keyboardShouldPersistTaps="handled"1320 },
1302 {...scrollGateHandlers}1321 ]}
1303 recycleItems1322 keyboardDismissMode="on-drag"
1304 scrollEventThrottle={16}1323 keyboardShouldPersistTaps="handled"
1305 showsVerticalScrollIndicator={false}1324 {...scrollGateHandlers}
1306 style={styles.threadList}1325 recycleItems
1307 ListEmptyComponent={listEmpty}1326 scrollEventThrottle={16}
1308 />1327 showsVerticalScrollIndicator={false}
1309 </GestureDetector>1328 style={styles.threadList}
1310 </SwipeableScrollGateProvider>1329 ListEmptyComponent={listEmpty}
1330 />
1331 </GestureDetector>
1332 </SwipeableScrollGateProvider>
1333 )}
1311 </View>1334 </View>
13121335
1313 <View1336 {Platform.OS === "android" ? (
1314 className="absolute inset-x-0 top-0 z-[4] bg-drawer"1337 <MaterialThreadListToolbar
1315 collapsable={false}1338 sidebar
1316 onLayout={handleStickyHeaderLayout}1339 onLayout={handleStickyHeaderLayout}
1317 pointerEvents="auto"1340 searchQuery={props.searchQuery}
1318 style={{ paddingTop: insets.top }}1341 onSearchQueryChange={props.onSearchQueryChange}
1319 >1342 filterActions={listMenuActions}
1320 <View className="h-[50px] flex-row items-end gap-0.5 pr-2 pl-5">1343 filterCustomized={filterCustomized}
1321 {/* Title slot doubles as the connection status surface: while an1344 onFilterAction={handleListMenuAction}
1345 onOpenSettings={props.onOpenSettings}
1346 onOpenEnvironments={props.onOpenEnvironmentSettings}
1347 onRequestVisibility={props.onRequestVisibility}
1348 />
1349 ) : (
1350 <View
1351 className="absolute inset-x-0 top-0 z-[4] bg-drawer"
1352 collapsable={false}
1353 onLayout={handleStickyHeaderLayout}
1354 pointerEvents="auto"
1355 style={{ paddingTop: insets.top }}
1356 >
1357 <View className="h-[50px] flex-row items-end gap-0.5 pr-2 pl-5">
1358 {/* Title slot doubles as the connection status surface: while an
1322 environment reconnects, the brand fades to a status label in1359 environment reconnects, the brand fades to a status label in
1323 place (no layout shift in the list below). */}1360 place (no layout shift in the list below). */}
1324 <WorkspaceConnectionTitle1361 <WorkspaceConnectionTitle
1325 grow1362 grow
1326 onPress={props.onOpenEnvironmentSettings}1363 onPress={props.onOpenEnvironmentSettings}
1327 size="pageTitle"1364 size="pageTitle"
1328 brand={1365 brand={
1329 <View className="h-11 flex-1 justify-center">1366 <View className="h-11 flex-1 justify-center">
1330 <CompactBrandTitle allowFontScaling={false} />1367 <CompactBrandTitle allowFontScaling={false} />
1331 </View>1368 </View>
1332 }1369 }
1333 />1370 />
1334 <View className="flex-row items-center gap-2.5">1371 <View className="flex-row items-center gap-2.5">
1335 <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}>1372 <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}>
1336 <SidebarFilterButton accessibilityLabel="Filter and sort threads" icon={filterIcon} />1373 <SidebarFilterButton
1337 </ControlPillMenu>1374 accessibilityLabel="Filter and sort threads"
1338 <SidebarHeaderActions onOpenSettings={props.onOpenSettings} />1375 icon={filterIcon}
1376 />
1377 </ControlPillMenu>
1378 <SidebarHeaderActions onOpenSettings={props.onOpenSettings} />
1379 </View>
1339 </View>1380 </View>
1340 </View>
13411381
1342 <View1382 <View className="mx-4 mt-[9px] h-[38px] flex-row items-center gap-1.5 rounded-xl bg-sidebar-search pr-2.5 pl-[11px]">
1343 className={1383 <SymbolView
1344 materialYouStyleLayoutActive1384 name="magnifyingglass"
1345 ? "mx-4 mt-[9px] min-h-12 flex-row items-center gap-2.5 rounded-full border border-input-border bg-input px-3.5"1385 size={15}
1346 : "mx-4 mt-[9px] h-[38px] flex-row items-center gap-1.5 rounded-xl bg-sidebar-search pr-2.5 pl-[11px]"1386 tintColorClassName="accent-foreground-muted"
1347 }1387 type="monochrome"
1348 >1388 />
1349 <SymbolView1389 <TextInput
1350 name="magnifyingglass"1390 ref={searchInputRef}
1351 size={15}1391 accessibilityLabel="Search threads"
1352 tintColorClassName={"accent-foreground-muted"}1392 autoCapitalize="none"
1353 type="monochrome"1393 autoCorrect={false}
1354 />1394 clearButtonMode="while-editing"
1355 <TextInput1395 onChangeText={props.onSearchQueryChange}
1356 ref={searchInputRef}1396 placeholder="Search"
1357 accessibilityLabel="Search threads"1397 placeholderTextColorClassName="accent-placeholder"
1358 autoCapitalize="none"1398 selectionColorClassName={undefined}
1359 autoCorrect={false}1399 cursorColorClassName={undefined}
1360 clearButtonMode={materialYouStyleLayoutActive ? "never" : "while-editing"}1400 selectionHandleColorClassName={undefined}
1361 onChangeText={props.onSearchQueryChange}1401 returnKeyType="search"
1362 placeholder="Search"1402 className="h-[34px] flex-1 px-0 py-0 font-sans text-base text-foreground"
1363 placeholderTextColorClassName={"accent-placeholder"}1403 value={props.searchQuery}
1364 returnKeyType="search"1404 />
1365 className={1405 </View>
1366 materialYouStyleLayoutActive
1367 ? "flex-1 px-0 py-2.5 font-sans text-base text-foreground"
1368 : "h-[34px] flex-1 px-0 py-0 font-sans text-base text-foreground"
1369 }
1370 value={props.searchQuery}
1371 />
1372 {materialYouStyleLayoutActive && props.searchQuery.length > 0 ? (
1373 <Pressable
1374 accessibilityLabel="Clear search"
1375 hitSlop={10}
1376 onPress={() => props.onSearchQueryChange("")}
1377 >
1378 <SymbolView
1379 name="xmark.circle.fill"
1380 size={17}
1381 tintColor={mutedColor}
1382 type="monochrome"
1383 />
1384 </Pressable>
1385 ) : null}
1386 </View>1406 </View>
1387 </View>1407 )}
1388 </View>1408 </View>
1389 );1409 );
1390}1410}
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+29-21
...@@ -32,17 +32,17 @@ import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draf...@@ -32,17 +32,17 @@ import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draf
32import { useEnvironmentQuery } from "../../state/query";32import { useEnvironmentQuery } from "../../state/query";
33import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";33import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";
34import { vcsEnvironment } from "../../state/vcs";34import { vcsEnvironment } from "../../state/vcs";
35
36import { EmptyState } from "../../components/EmptyState";35import { EmptyState } from "../../components/EmptyState";
37import {36import {
37 AndroidHeaderIconButton,
38 AndroidScreenHeader,38 AndroidScreenHeader,
39 type AndroidHeaderAction,39 type AndroidHeaderAction,
40} from "../../components/AndroidScreenHeader";40} from "../../components/AndroidScreenHeader";
41import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar";
41import { LoadingScreen } from "../../components/LoadingScreen";42import { LoadingScreen } from "../../components/LoadingScreen";
42import { scopedThreadKey } from "../../lib/scopedEntities";43import { scopedThreadKey } from "../../lib/scopedEntities";
43import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";44import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
44import { connectionTone } from "../connection/connectionTone";45import { connectionTone } from "../connection/connectionTone";
45
46import {46import {
47 useRemoteConnections,47 useRemoteConnections,
48 useRemoteConnectionStatus,48 useRemoteConnectionStatus,
...@@ -225,7 +225,7 @@ function ThreadRouteContent(...@@ -225,7 +225,7 @@ function ThreadRouteContent(
225 readonly selectedThreadDetailState: ReturnType<typeof useSelectedThreadDetailState>;225 readonly selectedThreadDetailState: ReturnType<typeof useSelectedThreadDetailState>;
226 },226 },
227) {227) {
228 const { materialYouStyleLayoutActive, themeVariables } = useAppearancePreferences();228 const { themeVariables } = useAppearancePreferences();
229 const headerColor = themeVariables["--color-header"];229 const headerColor = themeVariables["--color-header"];
230 const {230 const {
231 fileInspector,231 fileInspector,
...@@ -746,10 +746,12 @@ function ThreadRouteContent(...@@ -746,10 +746,12 @@ function ThreadRouteContent(
746 });746 });
747 }747 }
748 if (selectedThreadCwd !== null) {748 if (selectedThreadCwd !== null) {
749 const filesVisible = inspectorMode === "files" && panes.auxiliaryPaneVisible;
749 actions.push({750 actions.push({
750 accessibilityLabel: "Open files",751 accessibilityLabel: filesVisible ? "Close files" : "Open files",
752 selected: filesVisible,
751 icon: "folder",753 icon: "folder",
752 onPress: handleOpenFilesInspector,754 onPress: filesVisible ? toggleAuxiliaryPane : handleOpenFilesInspector,
753 });755 });
754 }756 }
755 if (selectedThreadProject?.workspaceRoot) {757 if (selectedThreadProject?.workspaceRoot) {
...@@ -764,20 +766,14 @@ function ThreadRouteContent(...@@ -764,20 +766,14 @@ function ThreadRouteContent(
764 icon: "point.topleft.down.curvedto.point.bottomright.up",766 icon: "point.topleft.down.curvedto.point.bottomright.up",
765 onPress: handleOpenGitInspector,767 onPress: handleOpenGitInspector,
766 });768 });
767 if (fileInspector.supported && selectedThreadCwd !== null) {
768 actions.push({
769 accessibilityLabel: "Toggle inspector",
770 icon: "sidebar.right",
771 onPress: handleToggleInspector,
772 });
773 }
774 return actions;769 return actions;
775 }, [770 }, [
776 fileInspector.supported,771 inspectorMode,
772 panes.auxiliaryPaneVisible,
777 handleOpenFilesInspector,773 handleOpenFilesInspector,
778 handleOpenTerminal,774 handleOpenTerminal,
779 handleOpenGitInspector,775 handleOpenGitInspector,
780 handleToggleInspector,776 toggleAuxiliaryPane,
781 props.onReturnToThread,777 props.onReturnToThread,
782 selectedThreadCwd,778 selectedThreadCwd,
783 selectedThreadProject?.workspaceRoot,779 selectedThreadProject?.workspaceRoot,
...@@ -891,13 +887,12 @@ function ThreadRouteContent(...@@ -891,13 +887,12 @@ function ThreadRouteContent(
891 <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} />887 <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} />
892888
893 <View889 <View
894 className={materialYouStyleLayoutActive ? "flex-1 bg-thread-canvas" : "flex-1 bg-screen"}890 className={Platform.OS === "android" ? "flex-1 bg-thread-canvas" : "flex-1 bg-screen"}
895 style={891 style={
896 materialYouStyleLayoutActive892 Platform.OS === "android"
897 ? {893 ? {
898 borderTopLeftRadius: 28,894 borderTopLeftRadius: 28,
899 borderTopRightRadius: 28,895 borderTopRightRadius: 28,
900 marginRight: layout.usesSplitView ? 8 : 0,
901 overflow: "hidden",896 overflow: "hidden",
902 }897 }
903 : undefined898 : undefined
...@@ -996,9 +991,7 @@ function ThreadRouteContent(...@@ -996,9 +991,7 @@ function ThreadRouteContent(
996 : undefined,991 : undefined,
997 unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined,992 unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined,
998 contentStyle:993 contentStyle:
999 Platform.OS === "android" && materialYouStyleLayoutActive994 Platform.OS === "android" && true ? { backgroundColor: headerColor } : undefined,
1000 ? { backgroundColor: headerColor }
1001 : undefined,
1002 }}995 }}
1003 />996 />
1004997
...@@ -1006,6 +999,21 @@ function ThreadRouteContent(...@@ -1006,6 +999,21 @@ function ThreadRouteContent(
1006 <AndroidScreenHeader999 <AndroidScreenHeader
1007 title={selectedThread.title}1000 title={selectedThread.title}
1008 subtitle={headerSubtitle}1001 subtitle={headerSubtitle}
1002 leading={<AndroidWorkspaceSidebarButton />}
1003 trailing={
1004 fileInspector.supported && selectedThreadCwd !== null ? (
1005 <AndroidHeaderIconButton
1006 accessibilityLabel={
1007 inspectorMode !== null && panes.auxiliaryPaneVisible
1008 ? "Hide inspector"
1009 : "Show inspector"
1010 }
1011 icon="sidebar.right"
1012 selected={inspectorMode !== null && panes.auxiliaryPaneVisible}
1013 onPress={handleToggleInspector}
1014 />
1015 ) : null
1016 }
1009 onBack={1017 onBack={
1010 layout.usesSplitView1018 layout.usesSplitView
1011 ? undefined1019 ? undefined
...@@ -1017,7 +1025,7 @@ function ThreadRouteContent(...@@ -1017,7 +1025,7 @@ function ThreadRouteContent(
1017 }1025 }
1018 }1026 }
1019 actions={androidHeaderActions}1027 actions={androidHeaderActions}
1020 hideBottomBorder={materialYouStyleLayoutActive}1028 hideBottomBorder
1021 />1029 />
1022 ) : null}1030 ) : null}
10231031
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+98-41
...@@ -36,6 +36,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";...@@ -36,6 +36,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
36import { SymbolView } from "../../components/AppSymbol";36import { SymbolView } from "../../components/AppSymbol";
37import { AppText as Text } from "../../components/AppText";37import { AppText as Text } from "../../components/AppText";
38import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";38import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
39import { MaterialButton } from "../../components/MaterialButton";
40import { MaterialIconButton } from "../../components/MaterialIconButton";
41import { MaterialRadioIndicator } from "../../components/MaterialRadioIndicator";
39import { ProviderIcon } from "../../components/ProviderIcon";42import { ProviderIcon } from "../../components/ProviderIcon";
40import { ThemedSwitch } from "../../components/ThemedSwitch";43import { ThemedSwitch } from "../../components/ThemedSwitch";
41import { cn } from "../../lib/cn";44import { cn } from "../../lib/cn";
...@@ -52,7 +55,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";...@@ -52,7 +55,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
52import { serverEnvironment } from "../../state/server";55import { serverEnvironment } from "../../state/server";
53import { useAtomCommand } from "../../state/use-atom-command";56import { useAtomCommand } from "../../state/use-atom-command";
54import { useNewTaskFlow } from "./new-task-flow-provider";57import { useNewTaskFlow } from "./new-task-flow-provider";
55import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";58import { MaterialScreenContent } from "../../components/MaterialScreenContent";
56import {59import {
57 createProviderCatalogRefreshRunner,60 createProviderCatalogRefreshRunner,
58 providerCatalogRefreshError,61 providerCatalogRefreshError,
...@@ -105,8 +108,7 @@ function ModelRow(props: {...@@ -105,8 +108,7 @@ function ModelRow(props: {
105 readonly isFirst: boolean;108 readonly isFirst: boolean;
106 readonly isLast: boolean;109 readonly isLast: boolean;
107}) {110}) {
108 const { materialYouStyleLayoutActive } = useAppearancePreferences();111 const selectedMaterialRow = Platform.OS === "android" && props.selected;
109 const selectedMaterialRow = materialYouStyleLayoutActive && props.selected;
110 return (112 return (
111 <Pressable113 <Pressable
112 accessibilityLabel={[props.option.label, props.option.subtitle].filter(Boolean).join(", ")}114 accessibilityLabel={[props.option.label, props.option.subtitle].filter(Boolean).join(", ")}
...@@ -117,6 +119,7 @@ function ModelRow(props: {...@@ -117,6 +119,7 @@ function ModelRow(props: {
117 }}119 }}
118 disabled={props.option.isUnavailable}120 disabled={props.option.isUnavailable}
119 onPress={props.onPress}121 onPress={props.onPress}
122 style={Platform.OS === "android" ? { minHeight: 56, paddingVertical: 12 } : undefined}
120 className={cn(123 className={cn(
121 "mx-4 min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle",124 "mx-4 min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle",
122 selectedMaterialRow && "bg-thread-selected",125 selectedMaterialRow && "bg-thread-selected",
...@@ -124,11 +127,12 @@ function ModelRow(props: {...@@ -124,11 +127,12 @@ function ModelRow(props: {
124 props.isLast ? "rounded-b-2xl" : "border-b border-border-subtle",127 props.isLast ? "rounded-b-2xl" : "border-b border-border-subtle",
125 )}128 )}
126 >129 >
130 {Platform.OS === "android" ? <MaterialRadioIndicator selected={props.selected} /> : null}
127 <View className="min-w-0 flex-1">131 <View className="min-w-0 flex-1">
128 <View className="flex-row items-center gap-2">132 <View className="flex-row items-center gap-2">
129 <Text133 <Text
130 className="min-w-0 shrink text-base font-t3-medium text-foreground"134 className="min-w-0 shrink text-base font-t3-medium text-foreground"
131 numberOfLines={1}135 numberOfLines={Platform.OS === "android" ? 2 : 1}
132 >136 >
133 {props.option.label}137 {props.option.label}
134 </Text>138 </Text>
...@@ -147,16 +151,19 @@ function ModelRow(props: {...@@ -147,16 +151,19 @@ function ModelRow(props: {
147 ) : null}151 ) : null}
148 </View>152 </View>
149 {props.option.subtitle ? (153 {props.option.subtitle ? (
150 <Text className="text-xs text-foreground-muted" numberOfLines={1}>154 <Text
155 className="text-xs text-foreground-muted"
156 numberOfLines={Platform.OS === "android" ? 2 : 1}
157 >
151 {props.option.subtitle}158 {props.option.subtitle}
152 </Text>159 </Text>
153 ) : null}160 ) : null}
154 </View>161 </View>
155 {props.selected ? (162 {props.selected && Platform.OS !== "android" ? (
156 <SymbolView163 <SymbolView
157 name="checkmark"164 name="checkmark"
158 size={16}165 size={16}
159 tintColorClassName={"accent-icon"}166 tintColorClassName="accent-icon"
160 type="monochrome"167 type="monochrome"
161 weight="semibold"168 weight="semibold"
162 />169 />
...@@ -189,7 +196,7 @@ function ProviderHeader(props: {...@@ -189,7 +196,7 @@ function ProviderHeader(props: {
189 <SymbolView196 <SymbolView
190 name={props.collapsed ? "chevron.down" : "chevron.up"}197 name={props.collapsed ? "chevron.down" : "chevron.up"}
191 size={12}198 size={12}
192 tintColorClassName={"accent-icon-subtle"}199 tintColorClassName="accent-icon-subtle"
193 type="monochrome"200 type="monochrome"
194 />201 />
195 </>202 </>
...@@ -205,6 +212,7 @@ function ProviderHeader(props: {...@@ -205,6 +212,7 @@ function ProviderHeader(props: {
205 accessibilityState={{ expanded: !props.collapsed }}212 accessibilityState={{ expanded: !props.collapsed }}
206 className="mx-4 mt-1 min-h-11 flex-row items-center gap-2 rounded-xl px-1 pt-2 active:opacity-60"213 className="mx-4 mt-1 min-h-11 flex-row items-center gap-2 rounded-xl px-1 pt-2 active:opacity-60"
207 onPress={props.onToggle}214 onPress={props.onToggle}
215 style={Platform.OS === "android" ? { minHeight: 48 } : undefined}
208 >216 >
209 {content}217 {content}
210 </Pressable>218 </Pressable>
...@@ -229,6 +237,7 @@ function DisclosureRow(props: {...@@ -229,6 +237,7 @@ function DisclosureRow(props: {
229 <Pressable237 <Pressable
230 accessibilityRole="button"238 accessibilityRole="button"
231 onPress={props.onPress}239 onPress={props.onPress}
240 style={Platform.OS === "android" ? { minHeight: 56 } : undefined}
232 className={cn(241 className={cn(
233 "min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle",242 "min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle",
234 !props.isLast && "border-b border-border-subtle",243 !props.isLast && "border-b border-border-subtle",
...@@ -244,7 +253,7 @@ function DisclosureRow(props: {...@@ -244,7 +253,7 @@ function DisclosureRow(props: {
244 <SymbolView253 <SymbolView
245 name="chevron.right"254 name="chevron.right"
246 size={12}255 size={12}
247 tintColorClassName={"accent-icon-subtle"}256 tintColorClassName="accent-icon-subtle"
248 type="monochrome"257 type="monochrome"
249 />258 />
250 </Pressable>259 </Pressable>
...@@ -265,22 +274,24 @@ function ChoiceRow(props: {...@@ -265,22 +274,24 @@ function ChoiceRow(props: {
265 accessibilityRole="radio"274 accessibilityRole="radio"
266 accessibilityState={{ checked: props.selected }}275 accessibilityState={{ checked: props.selected }}
267 onPress={props.onPress}276 onPress={props.onPress}
277 style={Platform.OS === "android" ? { minHeight: 56 } : undefined}
268 className={cn(278 className={cn(
269 "min-h-14 flex-row items-center gap-3 bg-card px-4 py-3 active:bg-subtle",279 "min-h-14 flex-row items-center gap-3 bg-card px-4 py-3 active:bg-subtle",
270 !props.isLast && "border-b border-border-subtle",280 !props.isLast && "border-b border-border-subtle",
271 )}281 )}
272 >282 >
283 {Platform.OS === "android" ? <MaterialRadioIndicator selected={props.selected} /> : null}
273 <View className="min-w-0 flex-1 gap-0.5">284 <View className="min-w-0 flex-1 gap-0.5">
274 <Text className="text-base font-t3-medium text-foreground">{props.label}</Text>285 <Text className="text-base font-t3-medium text-foreground">{props.label}</Text>
275 {props.description ? (286 {props.description ? (
276 <Text className="text-sm leading-5 text-foreground-muted">{props.description}</Text>287 <Text className="text-sm leading-5 text-foreground-muted">{props.description}</Text>
277 ) : null}288 ) : null}
278 </View>289 </View>
279 {props.selected ? (290 {props.selected && Platform.OS !== "android" ? (
280 <SymbolView291 <SymbolView
281 name="checkmark"292 name="checkmark"
282 size={16}293 size={16}
283 tintColorClassName={"accent-icon"}294 tintColorClassName="accent-icon"
284 type="monochrome"295 type="monochrome"
285 weight="semibold"296 weight="semibold"
286 />297 />
...@@ -853,10 +864,15 @@ function ThreadSettingsMainContent(props: {...@@ -853,10 +864,15 @@ function ThreadSettingsMainContent(props: {
853 <AnimatedLegendList864 <AnimatedLegendList
854 automaticallyAdjustsScrollIndicatorInsets865 automaticallyAdjustsScrollIndicatorInsets
855 className="flex-1 bg-sheet"866 className="flex-1 bg-sheet"
867 style={
868 Platform.OS === "android"
869 ? { width: "100%", maxWidth: 720, alignSelf: "center" }
870 : undefined
871 }
856 contentContainerStyle={{ paddingTop: 4 }}872 contentContainerStyle={{ paddingTop: 4 }}
857 contentInsetAdjustmentBehavior={usesTransparentNativeHeader ? "never" : "automatic"}873 contentInsetAdjustmentBehavior={usesTransparentNativeHeader ? "never" : "automatic"}
858 data={listItems}874 data={listItems}
859 estimatedItemSize={48}875 estimatedItemSize={Platform.OS === "android" ? 56 : 48}
860 extraData={animationsReady}876 extraData={animationsReady}
861 getItemType={(item) => item.kind}877 getItemType={(item) => item.kind}
862 itemLayoutAnimation={THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION}878 itemLayoutAnimation={THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION}
...@@ -869,16 +885,39 @@ function ThreadSettingsMainContent(props: {...@@ -869,16 +885,39 @@ function ThreadSettingsMainContent(props: {
869 {usesTransparentNativeHeader ? <View style={{ height: nativeHeaderHeight }} /> : null}885 {usesTransparentNativeHeader ? <View style={{ height: nativeHeaderHeight }} /> : null}
870 {Platform.OS === "android" ? (886 {Platform.OS === "android" ? (
871 <View className="px-4 pb-2 pt-3">887 <View className="px-4 pb-2 pt-3">
872 <TextInput888 <View
873 accessibilityLabel="Find a model"889 className="flex-row items-center rounded-full bg-input px-2"
874 autoCapitalize="none"890 style={{ minHeight: 56 }}
875 autoCorrect={false}891 >
876 className="h-11 rounded-xl bg-card px-4 text-base text-foreground"892 <View pointerEvents="none" className="px-2">
877 onChangeText={session.setSearchQuery}893 <SymbolView
878 placeholder="Find a model"894 name="magnifyingglass"
879 placeholderTextColorClassName="accent-placeholder"895 size={24}
880 value={session.searchQuery}896 tintColorClassName="accent-icon-subtle"
881 />897 />
898 </View>
899 <TextInput
900 accessibilityLabel="Find a model"
901 autoCapitalize="none"
902 autoCorrect={false}
903 className="min-w-0 flex-1 px-2 py-0 text-base text-foreground"
904 style={{ minHeight: 56, includeFontPadding: false, textAlignVertical: "center" }}
905 onChangeText={session.setSearchQuery}
906 placeholder="Find a model"
907 placeholderTextColorClassName="accent-placeholder"
908 selectionColorClassName="accent-primary/32"
909 cursorColorClassName="accent-primary"
910 selectionHandleColorClassName="accent-primary"
911 value={session.searchQuery}
912 />
913 {session.searchQuery.length > 0 ? (
914 <MaterialIconButton
915 accessibilityLabel="Clear model search"
916 icon="xmark"
917 onPress={() => session.setSearchQuery("")}
918 />
919 ) : null}
920 </View>
882 </View>921 </View>
883 ) : null}922 ) : null}
884 </>923 </>
...@@ -945,6 +984,11 @@ function ThreadSettingsChoiceContent(props: {...@@ -945,6 +984,11 @@ function ThreadSettingsChoiceContent(props: {
945 return (984 return (
946 <ScrollView985 <ScrollView
947 className="flex-1 bg-sheet"986 className="flex-1 bg-sheet"
987 style={
988 Platform.OS === "android"
989 ? { width: "100%", maxWidth: 720, alignSelf: "center" }
990 : undefined
991 }
948 contentContainerStyle={{992 contentContainerStyle={{
949 paddingBottom: insets.bottom + 12,993 paddingBottom: insets.bottom + 12,
950 paddingHorizontal: 16,994 paddingHorizontal: 16,
...@@ -1068,14 +1112,15 @@ function ThreadSettingsModelsScreen() {...@@ -1068,14 +1112,15 @@ function ThreadSettingsModelsScreen() {
1068 icon: "arrow.clockwise",1112 icon: "arrow.clockwise",
1069 onPress: refreshProviders,1113 onPress: refreshProviders,
1070 },1114 },
1071 {
1072 accessibilityLabel: session.pendingModel ? "Save thread settings" : "Done",
1073 icon: "checkmark",
1074 onPress: commitAndClose,
1075 },
1076 ]}1115 ]}
1116 trailing={
1117 session.pendingModel ? (
1118 <MaterialButton label="Save" tone="text" onPress={commitAndClose} />
1119 ) : undefined
1120 }
1077 onBack={presentation.onClose}1121 onBack={presentation.onClose}
1078 title="Thread settings"1122 title="Thread settings"
1123 hideBottomBorder
1079 />1124 />
1080 ) : null}1125 ) : null}
1081 <NativeStackScreenOptions1126 <NativeStackScreenOptions
...@@ -1114,17 +1159,19 @@ function ThreadSettingsModelsScreen() {...@@ -1114,17 +1159,19 @@ function ThreadSettingsModelsScreen() {
1114 : undefined,1159 : undefined,
1115 }}1160 }}
1116 />1161 />
1117 <ThreadSettingsMainContent1162 <MaterialScreenContent>
1118 onOpenSubmenu={(submenu) => {1163 <ThreadSettingsMainContent
1119 const title =1164 onOpenSubmenu={(submenu) => {
1120 submenu.kind === "runtime"1165 const title =
1121 ? "Runtime"1166 submenu.kind === "runtime"
1122 : (session.displayedDescriptors.find(1167 ? "Runtime"
1123 (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id,1168 : (session.displayedDescriptors.find(
1124 )?.label ?? "Option");1169 (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id,
1125 navigation.navigate("ThreadSettingsChoice", { ...submenu, title });1170 )?.label ?? "Option");
1126 }}1171 navigation.navigate("ThreadSettingsChoice", { ...submenu, title });
1127 />1172 }}
1173 />
1174 </MaterialScreenContent>
1128 <NativeHeaderToolbar placement="left">1175 <NativeHeaderToolbar placement="left">
1129 <NativeHeaderToolbar.Button1176 <NativeHeaderToolbar.Button
1130 accessibilityLabel="Cancel thread settings"1177 accessibilityLabel="Cancel thread settings"
...@@ -1199,9 +1246,18 @@ function ThreadSettingsChoiceScreen() {...@@ -1199,9 +1246,18 @@ function ThreadSettingsChoiceScreen() {
1199 <>1246 <>
1200 <NativeStackScreenOptions options={{ headerShown: Platform.OS !== "android" }} />1247 <NativeStackScreenOptions options={{ headerShown: Platform.OS !== "android" }} />
1201 {Platform.OS === "android" ? (1248 {Platform.OS === "android" ? (
1202 <AndroidScreenHeader title={route.params.title} onBack={() => navigation.goBack()} />1249 <AndroidScreenHeader
1250 title={route.params.title}
1251 onBack={() => navigation.goBack()}
1252 hideBottomBorder
1253 />
1203 ) : null}1254 ) : null}
1204 <ThreadSettingsChoiceContent submenu={route.params} onSelected={() => navigation.goBack()} />1255 <MaterialScreenContent>
1256 <ThreadSettingsChoiceContent
1257 submenu={route.params}
1258 onSelected={() => navigation.goBack()}
1259 />
1260 </MaterialScreenContent>
1205 </>1261 </>
1206 );1262 );
1207}1263}
...@@ -1222,11 +1278,12 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation)...@@ -1222,11 +1278,12 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation)
1222 <ThreadSettingsPickerStack.Navigator1278 <ThreadSettingsPickerStack.Navigator
1223 initialRouteName="ThreadSettingsModels"1279 initialRouteName="ThreadSettingsModels"
1224 screenOptions={{1280 screenOptions={{
1225 animation: "slide_from_right",1281 animation: Platform.OS === "android" ? "default" : "slide_from_right",
1226 contentStyle: { backgroundColor: solidSheetBackground },1282 contentStyle: { backgroundColor: solidSheetBackground },
1227 gestureEnabled: true,1283 gestureEnabled: true,
1228 headerBackButtonDisplayMode: "minimal",1284 headerBackButtonDisplayMode: "minimal",
1229 headerBackTitle: "",1285 headerBackTitle: "",
1286 headerShown: Platform.OS !== "android",
1230 headerShadowVisible: false,1287 headerShadowVisible: false,
1231 headerStyle: {1288 headerStyle: {
1232 backgroundColor: NATIVE_LIQUID_GLASS_SUPPORTED ? "transparent" : solidSheetBackground,1289 backgroundColor: NATIVE_LIQUID_GLASS_SUPPORTED ? "transparent" : solidSheetBackground,
apps/mobile/src/features/threads/customSnoozeDate.test.ts created+36
...@@ -0,0 +1,36 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 applySnoozePickerDate,
5 applySnoozePickerTime,
6 snoozeDateToPickerDate,
7} from "./customSnoozeDate";
8
9describe("custom snooze calendar conversion", () => {
10 it("passes the local calendar day to Compose without shifting it across time zones", () => {
11 const date = new Date(2026, 8, 17, 0, 30);
12 expect(snoozeDateToPickerDate(date)).toBe("2026-09-17T00:00:00.000Z");
13 });
14
15 it("applies Compose's UTC calendar day while retaining the local time", () => {
16 const date = new Date(2026, 8, 16, 23, 45);
17 const next = applySnoozePickerDate(date, new Date("2026-09-20T00:00:00Z"));
18 expect([
19 next.getFullYear(),
20 next.getMonth(),
21 next.getDate(),
22 next.getHours(),
23 next.getMinutes(),
24 ]).toEqual([2026, 8, 20, 23, 45]);
25 expect(date.getDate()).toBe(16);
26 });
27
28 it("applies a picked local time without changing the chosen calendar day", () => {
29 const date = new Date(2026, 8, 20, 23, 45);
30 const next = applySnoozePickerTime(date, new Date(2026, 8, 16, 8, 15, 30));
31 expect([next.getDate(), next.getHours(), next.getMinutes(), next.getSeconds()]).toEqual([
32 20, 8, 15, 0,
33 ]);
34 expect(date.getHours()).toBe(23);
35 });
36});
apps/mobile/src/features/threads/customSnoozeDate.ts created+16
...@@ -0,0 +1,16 @@
1/** Compose calendars exchange calendar days at UTC midnight, not local instants. */
2export function snoozeDateToPickerDate(date: Date): string {
3 return new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())).toISOString();
4}
5
6export function applySnoozePickerDate(date: Date, selected: Date): Date {
7 const next = new Date(date);
8 next.setFullYear(selected.getUTCFullYear(), selected.getUTCMonth(), selected.getUTCDate());
9 return next;
10}
11
12export function applySnoozePickerTime(date: Date, selected: Date): Date {
13 const next = new Date(date);
14 next.setHours(selected.getHours(), selected.getMinutes(), 0, 0);
15 return next;
16}
apps/mobile/src/features/threads/git/GitBranchesSheet.tsx+208-111
...@@ -1,10 +1,12 @@...@@ -1,10 +1,12 @@
1import { sanitizeFeatureBranchName } from "@t3tools/shared/git";1import { sanitizeFeatureBranchName } from "@t3tools/shared/git";
2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import { useState } from "react";3import { useState } from "react";
4import { Platform, Pressable, ScrollView, View } from "react-native";4import { Platform, Pressable, ScrollView, useWindowDimensions, View } from "react-native";
5import { useSafeAreaInsets } from "react-native-safe-area-context";5import { useSafeAreaInsets } from "react-native-safe-area-context";
66
7import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";7import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";
8import { MaterialScreenContent } from "../../../components/MaterialScreenContent";
9import { NativeStackScreenOptions } from "../../../native/StackHeader";
8import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";10import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
9import { cn } from "../../../lib/cn";11import { cn } from "../../../lib/cn";
10import { useEnvironmentQuery } from "../../../state/query";12import { useEnvironmentQuery } from "../../../state/query";
...@@ -23,6 +25,7 @@ type GitBranchesSheetProps = StaticScreenProps<{...@@ -23,6 +25,7 @@ type GitBranchesSheetProps = StaticScreenProps<{
23export function GitBranchesSheet(_props: GitBranchesSheetProps) {25export function GitBranchesSheet(_props: GitBranchesSheetProps) {
24 const navigation = useNavigation();26 const navigation = useNavigation();
25 const insets = useSafeAreaInsets();27 const insets = useSafeAreaInsets();
28 const { height: windowHeight } = useWindowDimensions();
26 const { selectedThread } = useThreadSelection();29 const { selectedThread } = useThreadSelection();
27 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();30 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
28 const gitState = useSelectedThreadGitState();31 const gitState = useSelectedThreadGitState();
...@@ -58,125 +61,219 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {...@@ -58,125 +61,219 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {
58 const disabledExistingBranches = new Set(disabledExistingBranchNames);61 const disabledExistingBranches = new Set(disabledExistingBranchNames);
5962
60 return (63 return (
61 <View collapsable={false} className="flex-1 bg-sheet">64 <View
65 collapsable={false}
66 className={Platform.OS === "android" ? "bg-sheet" : "flex-1 bg-sheet"}
67 style={Platform.OS === "android" ? { maxHeight: windowHeight * 0.92 } : undefined}
68 >
62 {Platform.OS === "android" ? (69 {Platform.OS === "android" ? (
63 <AndroidSheetHeader title="Branches & worktrees" onBack={() => navigation.goBack()} />70 <NativeStackScreenOptions
71 options={{
72 sheetCornerRadius: 28,
73 sheetAllowedDetents: "fitToContents",
74 }}
75 />
64 ) : null}76 ) : null}
65 <ScrollView77 {Platform.OS === "android" ? (
66 className="flex-1"78 <AndroidSheetHeader
67 showsVerticalScrollIndicator={false}79 title="Branches & worktrees"
68 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}80 onBack={() => navigation.goBack()}
69 contentContainerClassName="gap-4 px-5 pt-2"81 hideBottomBorder
70 >82 />
71 <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4">83 ) : null}
72 <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase">84 <MaterialScreenContent fitToContents>
73 New branch85 <ScrollView
74 </Text>86 className={Platform.OS === "android" ? "shrink grow-0" : "flex-1"}
75 <TextInput87 keyboardShouldPersistTaps="handled"
76 value={newBranchName}88 showsVerticalScrollIndicator={false}
77 onChangeText={setNewBranchName}89 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
78 placeholder="feature/mobile-polish"90 contentContainerClassName={Platform.OS === "android" ? "gap-2 p-2" : "gap-4 px-5 pt-2"}
79 className="rounded-[18px]"91 contentContainerStyle={
80 />92 Platform.OS === "android"
81 <SheetActionButton93 ? { paddingBottom: Math.max(insets.bottom, 18) + 18 }
82 icon="plus"94 : undefined
83 label="Create & checkout"95 }
84 tone="primary"96 >
85 disabled={busy || newBranchName.trim().length === 0}97 <View
86 onPress={() => {98 className={
87 const branch = sanitizeFeatureBranchName(newBranchName.trim());99 Platform.OS === "android"
88 if (branch.length === 0) return;100 ? "gap-3 rounded-[20px] bg-card p-4"
89 void gitActions.onCreateSelectedThreadBranch(branch).then(() => {101 : "gap-2 rounded-[18px] border border-border bg-card px-4 py-4"
90 setNewBranchName("");
91 navigation.goBack();
92 });
93 }}
94 />
95 </View>
96
97 <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4">
98 <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase">
99 New worktree
100 </Text>
101 <TextInput
102 value={worktreeBaseBranch}
103 onChangeText={setWorktreeBaseBranch}
104 placeholder="main"
105 className="rounded-[18px]"
106 />
107 <TextInput
108 value={worktreeBranchName}
109 onChangeText={setWorktreeBranchName}
110 placeholder="feature/mobile-thread"
111 className="rounded-[18px]"
112 />
113 <SheetActionButton
114 icon="square.split.2x1"
115 label="Create worktree"
116 tone="primary"
117 disabled={
118 busy ||
119 worktreeBaseBranch.trim().length === 0 ||
120 worktreeBranchName.trim().length === 0
121 }102 }
122 onPress={() => {103 >
123 const baseBranch = worktreeBaseBranch.trim();104 <Text
124 const newBranch = worktreeBranchName.trim();105 className={
125 if (baseBranch.length === 0 || newBranch.length === 0) return;106 Platform.OS === "android"
126 void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => {107 ? "text-foreground text-base font-t3-medium"
127 setWorktreeBranchName("");108 : "text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase"
128 navigation.goBack();109 }
129 });110 >
130 }}111 New branch
131 />
132 </View>
133
134 <View className="gap-2">
135 <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase">
136 Existing branches
137 </Text>
138 {branchesLoading ? (
139 <Text className="text-foreground-secondary text-sm font-medium">
140 Loading branches...
141 </Text>112 </Text>
142 ) : null}113 <TextInput
143 {!branchesLoading && availableBranches.length === 0 ? (114 value={newBranchName}
144 <Text className="text-foreground-secondary text-sm font-medium">115 onChangeText={setNewBranchName}
145 No local branches found.116 placeholder="feature/mobile-polish"
117 accessibilityLabel="New branch name"
118 className={Platform.OS === "android" ? "rounded-xl bg-sheet-solid" : "rounded-[18px]"}
119 />
120 <SheetActionButton
121 icon="plus"
122 label="Create & checkout"
123 tone="primary"
124 disabled={busy || newBranchName.trim().length === 0}
125 onPress={() => {
126 const branch = sanitizeFeatureBranchName(newBranchName.trim());
127 if (branch.length === 0) return;
128 void gitActions.onCreateSelectedThreadBranch(branch).then(() => {
129 setNewBranchName("");
130 navigation.goBack();
131 });
132 }}
133 />
134 </View>
135
136 <View
137 className={
138 Platform.OS === "android"
139 ? "gap-3 rounded-[20px] bg-card p-4"
140 : "gap-2 rounded-[18px] border border-border bg-card px-4 py-4"
141 }
142 >
143 <Text
144 className={
145 Platform.OS === "android"
146 ? "text-foreground text-base font-t3-medium"
147 : "text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase"
148 }
149 >
150 New worktree
146 </Text>151 </Text>
147 ) : null}152 {Platform.OS === "android" ? (
148 {availableBranches.map((branch) => {153 <Text className="text-foreground-secondary text-sm">Base branch</Text>
149 const disabled = disabledExistingBranches.has(branch.name);154 ) : null}
150 const subtitle = branch.worktreePath155 <TextInput
151 ? branch.worktreePath === currentWorktreePath156 value={worktreeBaseBranch}
152 ? "Checked out in this thread"157 onChangeText={setWorktreeBaseBranch}
153 : "Checked out in another worktree"158 placeholder="main"
154 : branch.isDefault159 accessibilityLabel="Worktree base branch"
155 ? "Default branch"160 className={Platform.OS === "android" ? "rounded-xl bg-sheet-solid" : "rounded-[18px]"}
156 : "Local branch";161 />
162 {Platform.OS === "android" ? (
163 <Text className="text-foreground-secondary text-sm">New branch</Text>
164 ) : null}
165 <TextInput
166 value={worktreeBranchName}
167 onChangeText={setWorktreeBranchName}
168 placeholder="feature/mobile-thread"
169 accessibilityLabel="Worktree branch name"
170 className={Platform.OS === "android" ? "rounded-xl bg-sheet-solid" : "rounded-[18px]"}
171 />
172 <SheetActionButton
173 icon="square.split.2x1"
174 label="Create worktree"
175 tone="primary"
176 disabled={
177 busy ||
178 worktreeBaseBranch.trim().length === 0 ||
179 worktreeBranchName.trim().length === 0
180 }
181 onPress={() => {
182 const baseBranch = worktreeBaseBranch.trim();
183 const newBranch = worktreeBranchName.trim();
184 if (baseBranch.length === 0 || newBranch.length === 0) return;
185 void gitActions
186 .onCreateSelectedThreadWorktree({ baseBranch, newBranch })
187 .then(() => {
188 setWorktreeBranchName("");
189 navigation.goBack();
190 });
191 }}
192 />
193 </View>
157194
158 return (195 <View className="gap-2">
159 <Pressable196 <Text
160 key={branch.name}197 className={
198 Platform.OS === "android"
199 ? "px-4 pb-1 pt-3 text-foreground-secondary text-sm font-t3-medium"
200 : "text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase"
201 }
202 >
203 Existing branches
204 </Text>
205 {branchesLoading ? (
206 <Text
161 className={cn(207 className={cn(
162 "gap-1 rounded-[18px] border px-4 py-3 disabled:opacity-[0.45]",208 "text-foreground-secondary text-sm font-medium",
163 branch.current ? "border-subtle-strong" : "border-border",209 Platform.OS === "android" && "px-4",
164 )}210 )}
165 disabled={busy || disabled}
166 onPress={() => {
167 void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => {
168 navigation.goBack();
169 });
170 }}
171 >211 >
172 <View className="absolute inset-0 rounded-[18px] bg-card" />212 Loading branches...
173 <Text className="text-foreground text-base font-t3-bold">{branch.name}</Text>213 </Text>
174 <Text className="text-foreground-secondary text-xs font-medium">{subtitle}</Text>214 ) : null}
175 </Pressable>215 {!branchesLoading && availableBranches.length === 0 ? (
176 );216 <Text
177 })}217 className={cn(
178 </View>218 "text-foreground-secondary text-sm font-medium",
179 </ScrollView>219 Platform.OS === "android" && "px-4",
220 )}
221 >
222 No local branches found.
223 </Text>
224 ) : null}
225 {availableBranches.map((branch) => {
226 const disabled = disabledExistingBranches.has(branch.name);
227 const subtitle = branch.worktreePath
228 ? branch.worktreePath === currentWorktreePath
229 ? "Checked out in this thread"
230 : "Checked out in another worktree"
231 : branch.isDefault
232 ? "Default branch"
233 : "Local branch";
234
235 return (
236 <Pressable
237 key={branch.name}
238 className={cn(
239 "gap-1 px-4 py-3 disabled:opacity-[0.45]",
240 Platform.OS === "android"
241 ? cn(
242 "rounded-[20px] active:bg-subtle",
243 branch.current ? "bg-secondary" : "bg-card",
244 )
245 : cn(
246 "rounded-[18px] border",
247 branch.current ? "border-subtle-strong" : "border-border",
248 ),
249 )}
250 accessibilityRole="button"
251 accessibilityState={{ selected: branch.current, disabled: busy || disabled }}
252 disabled={busy || disabled}
253 onPress={() => {
254 void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => {
255 navigation.goBack();
256 });
257 }}
258 >
259 {Platform.OS !== "android" ? (
260 <View className="absolute inset-0 rounded-[18px] bg-card" />
261 ) : null}
262 <Text
263 className={cn(
264 "text-foreground text-base",
265 Platform.OS === "android" ? "font-t3-medium" : "font-t3-bold",
266 )}
267 >
268 {branch.name}
269 </Text>
270 <Text className="text-foreground-secondary text-xs font-medium">{subtitle}</Text>
271 </Pressable>
272 );
273 })}
274 </View>
275 </ScrollView>
276 </MaterialScreenContent>
180 </View>277 </View>
181 );278 );
182}279}
apps/mobile/src/features/threads/git/GitCommitSheet.tsx+267-147
...@@ -1,9 +1,12 @@...@@ -1,9 +1,12 @@
1import { useNavigation, type StaticScreenProps } from "@react-navigation/native";1import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
2import { useCallback, useState } from "react";2import { useCallback, useState } from "react";
3import { Platform, Pressable, ScrollView, View } from "react-native";3import { Platform, Pressable, ScrollView, useWindowDimensions, View } from "react-native";
4import { useSafeAreaInsets } from "react-native-safe-area-context";4import { useSafeAreaInsets } from "react-native-safe-area-context";
55
6import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";6import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";
7import { SymbolView } from "../../../components/AppSymbol";
8import { MaterialScreenContent } from "../../../components/MaterialScreenContent";
9import { NativeStackScreenOptions } from "../../../native/StackHeader";
7import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";10import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
8import { cn } from "../../../lib/cn";11import { cn } from "../../../lib/cn";
9import { useEnvironmentQuery } from "../../../state/query";12import { useEnvironmentQuery } from "../../../state/query";
...@@ -22,6 +25,7 @@ type GitCommitSheetProps = StaticScreenProps<{...@@ -22,6 +25,7 @@ type GitCommitSheetProps = StaticScreenProps<{
22export function GitCommitSheet(_props: GitCommitSheetProps) {25export function GitCommitSheet(_props: GitCommitSheetProps) {
23 const navigation = useNavigation();26 const navigation = useNavigation();
24 const insets = useSafeAreaInsets();27 const insets = useSafeAreaInsets();
28 const { height: windowHeight } = useWindowDimensions();
25 const { selectedThread } = useThreadSelection();29 const { selectedThread } = useThreadSelection();
26 const { selectedThreadCwd } = useSelectedThreadWorktree();30 const { selectedThreadCwd } = useSelectedThreadWorktree();
27 const gitState = useSelectedThreadGitState();31 const gitState = useSelectedThreadGitState();
...@@ -66,168 +70,284 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {...@@ -66,168 +70,284 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {
66 );70 );
6771
68 return (72 return (
69 <View collapsable={false} className="flex-1 bg-sheet">73 <View
74 collapsable={false}
75 className={Platform.OS === "android" ? "bg-sheet" : "flex-1 bg-sheet"}
76 style={Platform.OS === "android" ? { maxHeight: windowHeight * 0.92 } : undefined}
77 >
70 {Platform.OS === "android" ? (78 {Platform.OS === "android" ? (
71 <AndroidSheetHeader title="Commit changes" onBack={() => navigation.goBack()} />79 <NativeStackScreenOptions
80 options={{
81 sheetCornerRadius: 28,
82 sheetAllowedDetents: "fitToContents",
83 }}
84 />
72 ) : null}85 ) : null}
73 <ScrollView86 {Platform.OS === "android" ? (
74 className="flex-1"87 <AndroidSheetHeader
75 keyboardShouldPersistTaps="handled"88 title="Commit changes"
76 showsVerticalScrollIndicator={false}89 onBack={() => navigation.goBack()}
77 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}90 hideBottomBorder
78 contentContainerClassName="gap-4 px-5 pt-2"91 />
79 >92 ) : null}
80 <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4">93 <MaterialScreenContent fitToContents>
81 <View className="flex-row items-center justify-between gap-3">94 <ScrollView
82 <Text className="text-foreground-muted text-sm font-medium">Branch</Text>95 className={Platform.OS === "android" ? "shrink grow-0" : "flex-1"}
83 <Text className="text-foreground text-base font-t3-bold">96 keyboardShouldPersistTaps="handled"
84 {gitStatus.data?.refName ?? "(detached HEAD)"}97 showsVerticalScrollIndicator={false}
85 </Text>98 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
86 </View>99 contentContainerClassName={Platform.OS === "android" ? "gap-2 p-2" : "gap-4 px-5 pt-2"}
87 {isDefaultRef ? (100 contentContainerStyle={
88 <Text className="text-xs leading-normal text-warning-foreground">101 Platform.OS === "android"
89 Warning: this is the default branch.102 ? { paddingBottom: Math.max(insets.bottom, 18) + 18 }
90 </Text>103 : undefined
91 ) : null}104 }
92 </View>105 >
93106 <View
94 <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4">107 className={cn(
95 <View className="flex-row items-center justify-between gap-3">108 "gap-3 bg-card p-4",
96 <View className="gap-1">109 Platform.OS === "android" ? "rounded-[20px]" : "rounded-[22px] border border-border",
97 <Text className="text-foreground text-base font-t3-bold">Files</Text>110 )}
98 <Text className="text-foreground-muted text-xs leading-normal">111 >
99 {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions}112 <View
100 </Text>113 className={
101 </View>114 Platform.OS === "android" ? "gap-1" : "flex-row items-center justify-between gap-3"
102 <View className="flex-row items-center gap-2">115 }
103 {!allSelected && isEditingFiles ? (116 >
104 <Pressable117 <Text className="text-foreground-muted text-sm font-medium">Branch</Text>
105 className="bg-subtle rounded-full px-3 py-2"118 <Text
106 onPress={() => setExcludedFiles(new Set())}119 className={cn(
107 >120 "text-foreground text-base",
108 <Text className="text-foreground text-2xs font-t3-bold uppercase">Reset</Text>121 Platform.OS === "android" ? "font-t3-medium" : "font-t3-bold",
109 </Pressable>122 )}
110 ) : null}
111 <Pressable
112 className="bg-subtle rounded-full px-3 py-2"
113 onPress={() => setIsEditingFiles((current) => !current)}
114 >123 >
115 <Text className="text-foreground text-2xs font-t3-bold uppercase">124 {gitStatus.data?.refName ?? "(detached HEAD)"}
116 {isEditingFiles ? "Done" : "Edit"}125 </Text>
117 </Text>
118 </Pressable>
119 </View>126 </View>
127 {isDefaultRef ? (
128 <Text className="text-xs leading-normal text-warning-foreground">
129 Warning: this is the default branch.
130 </Text>
131 ) : null}
120 </View>132 </View>
121133
122 {allFiles.length === 0 ? (134 <View
123 <Text className="text-foreground-secondary text-sm leading-normal">135 className={cn(
124 No changed files are available to commit.136 "gap-3 bg-card p-4",
125 </Text>137 Platform.OS === "android" ? "rounded-[20px]" : "rounded-[22px] border border-border",
126 ) : !isEditingFiles ? (138 )}
127 <View className="gap-2">139 >
128 {selectedFilePreview.map((file) => (140 <View className="flex-row items-center justify-between gap-3">
129 <View key={file.path} className="flex-row items-center justify-between gap-3">141 <View className="gap-1">
130 <Text className="text-foreground flex-1 text-sm font-medium" numberOfLines={1}>142 <Text
131 {file.path}143 className={cn(
132 </Text>144 "text-foreground text-base",
133 <Text className="text-xs font-t3-bold text-emerald-500">+{file.insertions}</Text>145 Platform.OS === "android" ? "font-t3-medium" : "font-t3-bold",
134 <Text className="text-xs font-t3-bold text-rose-500">-{file.deletions}</Text>146 )}
135 </View>147 >
136 ))}148 Files
137 {selectedFiles.length > selectedFilePreview.length ? (
138 <Text className="text-foreground-muted text-xs leading-snug">
139 +{selectedFiles.length - selectedFilePreview.length} more files
140 </Text>149 </Text>
141 ) : null}150 <Text className="text-foreground-muted text-xs leading-normal">
142 </View>151 {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions}
143 ) : (152 </Text>
144 <View className="gap-2">153 </View>
145 {allFiles.map((file) => {154 <View className="flex-row items-center gap-2">
146 const included = !excludedFiles.has(file.path);155 {!allSelected && isEditingFiles ? (
147 return (
148 <Pressable156 <Pressable
149 key={file.path}157 className={
150 className={cn(158 Platform.OS === "android"
151 "rounded-[18px] border px-4 py-3",159 ? "min-h-12 justify-center rounded-full px-3 active:bg-subtle"
152 included ? "border-border" : "border-border-subtle",160 : "bg-subtle rounded-full px-3 py-2"
153 )}161 }
154 onPress={() => {162 onPress={() => setExcludedFiles(new Set())}
155 setExcludedFiles((current) => {
156 const next = new Set(current);
157 if (next.has(file.path)) {
158 next.delete(file.path);
159 } else {
160 next.add(file.path);
161 }
162 return next;
163 });
164 }}
165 >163 >
166 <View164 <Text
167 className={`absolute inset-0 rounded-[18px] ${included ? "bg-card" : "bg-subtle"}`}165 className={
168 />166 Platform.OS === "android"
169 <View className="flex-row items-start justify-between gap-3">167 ? "text-primary text-sm font-t3-medium"
170 <View className="flex-1 gap-1">168 : "text-foreground text-2xs font-t3-bold uppercase"
171 <Text169 }
172 selectable170 >
173 className={`text-sm font-t3-bold ${included ? "text-foreground" : "text-foreground-muted"}`}171 Reset
174 >172 </Text>
175 {file.path}
176 </Text>
177 {!included ? (
178 <Text className="text-foreground-muted text-2xs leading-normal">
179 Excluded from this commit
180 </Text>
181 ) : null}
182 </View>
183 <View className="items-end gap-1">
184 <Text className="text-xs font-t3-bold text-emerald-500">
185 +{file.insertions}
186 </Text>
187 <Text className="text-xs font-t3-bold text-rose-500">
188 -{file.deletions}
189 </Text>
190 </View>
191 </View>
192 </Pressable>173 </Pressable>
193 );174 ) : null}
194 })}175 <Pressable
176 className={
177 Platform.OS === "android"
178 ? "min-h-12 justify-center rounded-full px-3 active:bg-subtle"
179 : "bg-subtle rounded-full px-3 py-2"
180 }
181 onPress={() => setIsEditingFiles((current) => !current)}
182 >
183 <Text
184 className={
185 Platform.OS === "android"
186 ? "text-primary text-sm font-t3-medium"
187 : "text-foreground text-2xs font-t3-bold uppercase"
188 }
189 >
190 {isEditingFiles ? "Done" : "Edit"}
191 </Text>
192 </Pressable>
193 </View>
195 </View>194 </View>
196 )}
197 </View>
198195
199 <View className="gap-2">196 {allFiles.length === 0 ? (
200 <Text className="text-foreground text-sm font-t3-bold">Commit message</Text>197 <Text className="text-foreground-secondary text-sm leading-normal">
201 <TextInput198 No changed files are available to commit.
202 multiline199 </Text>
203 value={dialogCommitMessage}200 ) : !isEditingFiles ? (
204 onChangeText={setDialogCommitMessage}201 <View className="gap-2">
205 placeholder="Leave empty to auto-generate"202 {selectedFilePreview.map((file) => (
206 textAlignVertical="top"203 <View key={file.path} className="flex-row items-center justify-between gap-3">
207 className="min-h-[128px] rounded-[20px] px-4 py-3.5"204 <Text className="text-foreground flex-1 text-sm font-medium" numberOfLines={1}>
208 />205 {file.path}
209 </View>206 </Text>
207 <Text className="text-xs font-t3-bold text-emerald-500">
208 +{file.insertions}
209 </Text>
210 <Text className="text-xs font-t3-bold text-rose-500">-{file.deletions}</Text>
211 </View>
212 ))}
213 {selectedFiles.length > selectedFilePreview.length ? (
214 <Text className="text-foreground-muted text-xs leading-snug">
215 +{selectedFiles.length - selectedFilePreview.length} more files
216 </Text>
217 ) : null}
218 </View>
219 ) : (
220 <View className="gap-2">
221 {allFiles.map((file) => {
222 const included = !excludedFiles.has(file.path);
223 return (
224 <Pressable
225 key={file.path}
226 className={cn(
227 "px-4 py-3",
228 Platform.OS === "android"
229 ? included
230 ? "rounded-xl bg-subtle"
231 : "rounded-xl"
232 : cn(
233 "rounded-[18px] border",
234 included ? "border-border" : "border-border-subtle",
235 ),
236 )}
237 accessibilityRole="checkbox"
238 accessibilityLabel={file.path}
239 accessibilityState={{ checked: included }}
240 onPress={() => {
241 setExcludedFiles((current) => {
242 const next = new Set(current);
243 if (next.has(file.path)) {
244 next.delete(file.path);
245 } else {
246 next.add(file.path);
247 }
248 return next;
249 });
250 }}
251 >
252 {Platform.OS !== "android" ? (
253 <View
254 className={`absolute inset-0 rounded-[18px] ${included ? "bg-card" : "bg-subtle"}`}
255 />
256 ) : null}
257 <View className="flex-row items-start justify-between gap-3">
258 {Platform.OS === "android" ? (
259 <View
260 className={cn(
261 "mt-0.5 size-5 items-center justify-center rounded-sm",
262 included ? "bg-primary" : "border border-input-border",
263 )}
264 >
265 {included ? (
266 <SymbolView
267 name="checkmark"
268 size={16}
269 tintColorClassName="accent-primary-foreground"
270 type="monochrome"
271 />
272 ) : null}
273 </View>
274 ) : null}
275 <View className="flex-1 gap-1">
276 <Text
277 selectable
278 className={`text-sm font-t3-bold ${included ? "text-foreground" : "text-foreground-muted"}`}
279 >
280 {file.path}
281 </Text>
282 {!included ? (
283 <Text className="text-foreground-muted text-2xs leading-normal">
284 Excluded from this commit
285 </Text>
286 ) : null}
287 </View>
288 <View className="items-end gap-1">
289 <Text className="text-xs font-t3-bold text-emerald-500">
290 +{file.insertions}
291 </Text>
292 <Text className="text-xs font-t3-bold text-rose-500">
293 -{file.deletions}
294 </Text>
295 </View>
296 </View>
297 </Pressable>
298 );
299 })}
300 </View>
301 )}
302 </View>
210303
211 <View className="flex-row gap-3">304 <View
212 <View className="flex-1">305 className={Platform.OS === "android" ? "gap-3 rounded-[20px] bg-card p-4" : "gap-2"}
213 <SheetActionButton306 >
214 icon="arrow.branch"307 <Text
215 label="Commit on new branch"308 className={
216 disabled={noneSelected || busy}309 Platform.OS === "android"
217 onPress={() => void runCommitAction(true)}310 ? "text-foreground text-base font-t3-medium"
311 : "text-foreground text-sm font-t3-bold"
312 }
313 >
314 Commit message
315 </Text>
316 <TextInput
317 multiline
318 accessibilityLabel="Commit message"
319 value={dialogCommitMessage}
320 onChangeText={setDialogCommitMessage}
321 placeholder="Leave empty to auto-generate"
322 textAlignVertical="top"
323 className={cn(
324 "min-h-[128px] px-4 py-3.5",
325 Platform.OS === "android" ? "rounded-xl bg-sheet-solid" : "rounded-[20px]",
326 )}
218 />327 />
219 </View>328 </View>
220 <View className="flex-1">329
221 <SheetActionButton330 <View className={Platform.OS === "android" ? "gap-2" : "flex-row gap-3"}>
222 icon="checkmark.circle"331 <View className={Platform.OS === "android" ? undefined : "flex-1"}>
223 label="Commit"332 <SheetActionButton
224 tone="primary"333 icon="arrow.branch"
225 disabled={noneSelected || busy}334 label="Commit on new branch"
226 onPress={() => void runCommitAction(false)}335 disabled={noneSelected || busy}
227 />336 onPress={() => void runCommitAction(true)}
337 />
338 </View>
339 <View className={Platform.OS === "android" ? undefined : "flex-1"}>
340 <SheetActionButton
341 icon="checkmark.circle"
342 label="Commit"
343 tone="primary"
344 disabled={noneSelected || busy}
345 onPress={() => void runCommitAction(false)}
346 />
347 </View>
228 </View>348 </View>
229 </View>349 </ScrollView>
230 </ScrollView>350 </MaterialScreenContent>
231 </View>351 </View>
232 );352 );
233}353}
apps/mobile/src/features/threads/git/GitConfirmSheet.tsx+86-27
...@@ -4,11 +4,13 @@ import * as Arr from "effect/Array";...@@ -4,11 +4,13 @@ import * as Arr from "effect/Array";
4import * as Result from "effect/Result";4import * as Result from "effect/Result";
5import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";5import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
6import { useCallback, useMemo } from "react";6import { useCallback, useMemo } from "react";
7import { Platform, View } from "react-native";7import { Platform, ScrollView, useWindowDimensions, View } from "react-native";
88
9import { useSafeAreaInsets } from "react-native-safe-area-context";9import { useSafeAreaInsets } from "react-native-safe-area-context";
1010
11import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";11import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";
12import { MaterialScreenContent } from "../../../components/MaterialScreenContent";
13import { NativeStackScreenOptions } from "../../../native/StackHeader";
12import { AppText as Text } from "../../../components/AppText";14import { AppText as Text } from "../../../components/AppText";
13import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";15import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
14import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";16import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
...@@ -27,6 +29,7 @@ type GitConfirmSheetProps = StaticScreenProps<{...@@ -27,6 +29,7 @@ type GitConfirmSheetProps = StaticScreenProps<{
27export function GitConfirmSheet(props: GitConfirmSheetProps) {29export function GitConfirmSheet(props: GitConfirmSheetProps) {
28 const navigation = useNavigation();30 const navigation = useNavigation();
29 const insets = useSafeAreaInsets();31 const insets = useSafeAreaInsets();
32 const { height: windowHeight } = useWindowDimensions();
30 const gitState = useSelectedThreadGitState();33 const gitState = useSelectedThreadGitState();
31 const gitActions = useSelectedThreadGitActions();34 const gitActions = useSelectedThreadGitActions();
3235
...@@ -102,38 +105,94 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) {...@@ -102,38 +105,94 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) {
102 ]);105 ]);
103106
104 return (107 return (
105 <View collapsable={false} className="flex-1 bg-sheet">108 <View
109 collapsable={false}
110 className={Platform.OS === "android" ? "bg-sheet" : "flex-1 bg-sheet"}
111 style={Platform.OS === "android" ? { maxHeight: windowHeight * 0.92 } : undefined}
112 >
106 {Platform.OS === "android" ? (113 {Platform.OS === "android" ? (
107 <AndroidSheetHeader title="Confirm action" onBack={() => navigation.goBack()} />114 <NativeStackScreenOptions
115 options={{
116 sheetCornerRadius: 28,
117 sheetAllowedDetents: "fitToContents",
118 }}
119 />
120 ) : null}
121 {Platform.OS === "android" ? (
122 <AndroidSheetHeader
123 title="Confirm action"
124 onBack={() => navigation.goBack()}
125 hideBottomBorder
126 />
108 ) : (127 ) : (
109 <View className="min-h-4 pt-2" />128 <View className="min-h-4 pt-2" />
110 )}129 )}
111130
112 <View className="items-center gap-1 px-5 pb-3 pt-4">131 <MaterialScreenContent fitToContents>
113 <Text className="text-xs font-t3-bold tracking-[1px] uppercase text-foreground-muted">132 <ScrollView
114 Confirm133 className={Platform.OS === "android" ? "shrink grow-0" : "flex-1"}
115 </Text>134 showsVerticalScrollIndicator={false}
116 <Text className="text-center text-3xl font-t3-bold">135 contentContainerClassName={Platform.OS === "android" ? "gap-2 p-2" : undefined}
117 {copy?.title ?? "Run action on default branch?"}136 contentContainerStyle={
118 </Text>137 Platform.OS === "android"
119 <Text className="text-center text-foreground-secondary text-sm font-medium leading-normal">138 ? { paddingBottom: Math.max(insets.bottom, 18) + 8 }
120 {copy?.description ?? "Choose how to continue."}139 : undefined
121 </Text>140 }
122 </View>141 >
142 <View
143 className={
144 Platform.OS === "android"
145 ? "gap-2 rounded-[20px] bg-card p-3"
146 : "items-center gap-1 px-5 pb-3 pt-4"
147 }
148 >
149 {Platform.OS !== "android" ? (
150 <Text className="text-xs font-t3-bold tracking-[1px] uppercase text-foreground-muted">
151 Confirm
152 </Text>
153 ) : null}
154 <Text
155 className={
156 Platform.OS === "android"
157 ? "text-xl font-t3-medium"
158 : "text-center text-3xl font-t3-bold"
159 }
160 >
161 {copy?.title ?? "Run action on default branch?"}
162 </Text>
163 <Text
164 className={
165 Platform.OS === "android"
166 ? "text-foreground-secondary text-base leading-normal"
167 : "text-center text-foreground-secondary text-sm font-medium leading-normal"
168 }
169 >
170 {copy?.description ?? "Choose how to continue."}
171 </Text>
172 </View>
123173
124 <View className="gap-3 px-5 pt-2" style={{ paddingBottom: Math.max(insets.bottom, 18) + 8 }}>174 <View
125 <SheetActionButton175 className={Platform.OS === "android" ? "gap-2" : "gap-3 px-5 pt-2"}
126 icon="arrow.right.circle"176 style={
127 label={copy?.continueLabel ?? "Continue"}177 Platform.OS === "android"
128 onPress={() => void continuePendingAction()}178 ? undefined
129 />179 : { paddingBottom: Math.max(insets.bottom, 18) + 8 }
130 <SheetActionButton180 }
131 icon="arrow.branch"181 >
132 label="Feature branch & continue"182 <SheetActionButton
133 tone="primary"183 icon="arrow.right.circle"
134 onPress={() => void movePendingActionToFeatureBranch()}184 label={copy?.continueLabel ?? "Continue"}
135 />185 onPress={() => void continuePendingAction()}
136 </View>186 />
187 <SheetActionButton
188 icon="arrow.branch"
189 label="Feature branch & continue"
190 tone="primary"
191 onPress={() => void movePendingActionToFeatureBranch()}
192 />
193 </View>
194 </ScrollView>
195 </MaterialScreenContent>
137 </View>196 </View>
138 );197 );
139}198}
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+84-35
...@@ -22,10 +22,17 @@ import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "re...@@ -22,10 +22,17 @@ import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "re
22import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";22import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
23import { useSafeAreaInsets } from "react-native-safe-area-context";23import { useSafeAreaInsets } from "react-native-safe-area-context";
24import { useUniwindTheme } from "../../../lib/useUniwindTheme";24import { useUniwindTheme } from "../../../lib/useUniwindTheme";
2525import {
26import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";26 AndroidHeaderIconButton,
27 AndroidSheetHeader,
28} from "../../../components/AndroidScreenHeader";
29import { MaterialScreenContent } from "../../../components/MaterialScreenContent";
30import { useAdaptiveWorkspaceLayout } from "../../layout/AdaptiveWorkspaceLayout";
27import { AppText as Text } from "../../../components/AppText";31import { AppText as Text } from "../../../components/AppText";
28import { nativeHeaderScrollEdgeEffects } from "../../../native/StackHeader";32import {
33 NativeStackScreenOptions,
34 nativeHeaderScrollEdgeEffects,
35} from "../../../native/StackHeader";
29import { tryOpenExternalUrl } from "../../../lib/openExternalUrl";36import { tryOpenExternalUrl } from "../../../lib/openExternalUrl";
30import { useEnvironmentQuery } from "../../../state/query";37import { useEnvironmentQuery } from "../../../state/query";
31import { useThreadSelection } from "../../../state/use-thread-selection";38import { useThreadSelection } from "../../../state/use-thread-selection";
...@@ -47,6 +54,7 @@ type GitOverviewSheetProps = StaticScreenProps<{...@@ -47,6 +54,7 @@ type GitOverviewSheetProps = StaticScreenProps<{
47};54};
4855
49export function GitOverviewSheet(props: GitOverviewSheetProps) {56export function GitOverviewSheet(props: GitOverviewSheetProps) {
57 const { layout } = useAdaptiveWorkspaceLayout();
50 const navigation = useNavigation();58 const navigation = useNavigation();
51 const insets = useSafeAreaInsets();59 const insets = useSafeAreaInsets();
52 const presentation = props.presentation ?? "sheet";60 const presentation = props.presentation ?? "sheet";
...@@ -224,14 +232,14 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {...@@ -224,14 +232,14 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
224232
225 const content = (233 const content = (
226 <ScrollView234 <ScrollView
227 className="flex-1 bg-screen"235 className={Platform.OS === "android" ? "flex-1 bg-sheet-solid" : "flex-1 bg-screen"}
228 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}236 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
229 showsVerticalScrollIndicator={false}237 showsVerticalScrollIndicator={false}
230 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}238 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
231 contentContainerStyle={{239 contentContainerStyle={{
232 paddingHorizontal: isInspector ? 12 : 20,240 paddingHorizontal: Platform.OS === "android" ? 8 : isInspector ? 12 : 20,
233 paddingTop: 8,241 paddingTop: 8,
234 gap: 14,242 gap: Platform.OS === "android" ? 8 : 14,
235 }}243 }}
236 refreshControl={244 refreshControl={
237 <RefreshControl refreshing={isPullRefreshing} onRefresh={() => void handlePullRefresh()} />245 <RefreshControl refreshing={isPullRefreshing} onRefresh={() => void handlePullRefresh()} />
...@@ -239,14 +247,18 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {...@@ -239,14 +247,18 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
239 >247 >
240 <View248 <View
241 className={249 className={
242 isInspector250 Platform.OS === "android"
243 ? "overflow-hidden rounded-2xl border border-border bg-card px-3 py-1"251 ? "overflow-hidden rounded-[20px] bg-card"
244 : "overflow-hidden rounded-[22px] border border-border bg-card px-4 py-1"252 : isInspector
253 ? "overflow-hidden rounded-2xl border border-border bg-card px-3 py-1"
254 : "overflow-hidden rounded-[22px] border border-border bg-card px-4 py-1"
245 }255 }
246 >256 >
247 {sheetMenuItems.map(({ item, disabledReason }, index) => (257 {sheetMenuItems.map(({ item, disabledReason }, index) => (
248 <View key={`${item.id}-${item.label}`}>258 <View key={`${item.id}-${item.label}`}>
249 {index > 0 ? <View className="ml-12 h-px bg-border" /> : null}259 {index > 0 && Platform.OS !== "android" ? (
260 <View className="ml-12 h-px bg-border" />
261 ) : null}
250 <SheetListRow262 <SheetListRow
251 icon={menuItemIconName(item.icon)}263 icon={menuItemIconName(item.icon)}
252 title={item.label}264 title={item.label}
...@@ -258,7 +270,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {...@@ -258,7 +270,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
258 ))}270 ))}
259 {behindCount > 0 ? (271 {behindCount > 0 ? (
260 <>272 <>
261 <View className="ml-12 h-px bg-border" />273 {Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null}
262 <SheetListRow274 <SheetListRow
263 icon="arrow.down.circle"275 icon="arrow.down.circle"
264 title="Pull latest"276 title="Pull latest"
...@@ -268,7 +280,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {...@@ -268,7 +280,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
268 />280 />
269 </>281 </>
270 ) : null}282 ) : null}
271 <View className="ml-12 h-px bg-border" />283 {Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null}
272 <SheetListRow284 <SheetListRow
273 icon="text.bubble"285 icon="text.bubble"
274 title="Review changes"286 title="Review changes"
...@@ -283,7 +295,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {...@@ -283,7 +295,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
283 );295 );
284 }}296 }}
285 />297 />
286 <View className="ml-12 h-px bg-border" />298 {Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null}
287 <SheetListRow299 <SheetListRow
288 icon="point.topleft.down.curvedto.point.bottomright.up"300 icon="point.topleft.down.curvedto.point.bottomright.up"
289 title="Branches & worktrees"301 title="Branches & worktrees"
...@@ -306,7 +318,11 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {...@@ -306,7 +318,11 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
306 {linkedPrChains.map((chain) => (318 {linkedPrChains.map((chain) => (
307 <View319 <View
308 key={threadPullRequestKeyOf(chain.layers[0]!)}320 key={threadPullRequestKeyOf(chain.layers[0]!)}
309 className="overflow-hidden rounded-2xl border border-border bg-card px-3 py-1"321 className={
322 Platform.OS === "android"
323 ? "overflow-hidden rounded-[20px] bg-card"
324 : "overflow-hidden rounded-2xl border border-border bg-card px-3 py-1"
325 }
310 >326 >
311 {chain.layers.length > 1 ? (327 {chain.layers.length > 1 ? (
312 <View className="flex-row items-center gap-2 px-1 pt-2 pb-1">328 <View className="flex-row items-center gap-2 px-1 pt-2 pb-1">
...@@ -323,7 +339,9 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {...@@ -323,7 +339,9 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
323 ) : null}339 ) : null}
324 {chain.layers.map((link, index) => (340 {chain.layers.map((link, index) => (
325 <View key={threadPullRequestKeyOf(link)}>341 <View key={threadPullRequestKeyOf(link)}>
326 {index > 0 ? <View className="ml-12 h-px bg-border" /> : null}342 {index > 0 && Platform.OS !== "android" ? (
343 <View className="ml-12 h-px bg-border" />
344 ) : null}
327 <SheetListRow345 <SheetListRow
328 icon="arrow.triangle.pull"346 icon="arrow.triangle.pull"
329 title={`#${link.number} ${link.snapshot?.title ?? "Pull request"}`}347 title={`#${link.number} ${link.snapshot?.title ?? "Pull request"}`}
...@@ -416,8 +434,19 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {...@@ -416,8 +434,19 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
416 return (434 return (
417 <View435 <View
418 collapsable={false}436 collapsable={false}
419 className={isInspector ? "flex-1 border-l border-border bg-sheet" : "flex-1 bg-sheet"}437 className={
438 Platform.OS === "android"
439 ? "flex-1 bg-header"
440 : isInspector
441 ? "flex-1 border-l border-border bg-sheet"
442 : "flex-1 bg-sheet"
443 }
420 >444 >
445 {!isInspector ? (
446 <NativeStackScreenOptions
447 options={{ sheetCornerRadius: Platform.OS === "android" ? 28 : undefined }}
448 />
449 ) : null}
421 {isInspector ? (450 {isInspector ? (
422 <View451 <View
423 style={{452 style={{
...@@ -428,24 +457,41 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {...@@ -428,24 +457,41 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
428 ) : null}457 ) : null}
429458
430 {isInspector ? (459 {isInspector ? (
431 <View className="gap-1 border-b border-border px-4 pb-4 pt-3">460 <View
432 <Pressable461 className={
433 className={462 Platform.OS === "android"
434 busy463 ? "gap-1 bg-header px-4 pb-4 pt-3"
435 ? "absolute right-3 top-4 z-[1] h-9 w-9 items-center justify-center rounded-full bg-subtle opacity-[0.45]"464 : "gap-1 border-b border-border px-4 pb-4 pt-3"
436 : "absolute right-3 top-4 z-[1] h-9 w-9 items-center justify-center rounded-full bg-subtle"465 }
437 }466 >
438 disabled={busy}467 {Platform.OS === "android" ? (
439 onPress={() => void gitActions.refreshSelectedThreadGitStatus()}468 <View className="absolute right-3 top-4 z-[1]">
440 >469 <AndroidHeaderIconButton
441 <SymbolView470 accessibilityLabel="Refresh repository status"
442 name="arrow.clockwise"471 disabled={busy}
443 size={16}472 icon="arrow.clockwise"
444 tintColorClassName={"accent-icon"}473 onPress={() => void gitActions.refreshSelectedThreadGitStatus()}
445 type="monochrome"474 />
446 weight="medium"475 </View>
447 />476 ) : (
448 </Pressable>477 <Pressable
478 className={
479 busy
480 ? "absolute right-3 top-4 z-[1] h-9 w-9 items-center justify-center rounded-full bg-subtle opacity-[0.45]"
481 : "absolute right-3 top-4 z-[1] h-9 w-9 items-center justify-center rounded-full bg-subtle"
482 }
483 disabled={busy}
484 onPress={() => void gitActions.refreshSelectedThreadGitStatus()}
485 >
486 <SymbolView
487 name="arrow.clockwise"
488 size={16}
489 tintColorClassName="accent-icon"
490 type="monochrome"
491 weight="medium"
492 />
493 </Pressable>
494 )}
449 <Text className="text-xs font-t3-bold tracking-[1px] uppercase text-foreground-muted">495 <Text className="text-xs font-t3-bold tracking-[1px] uppercase text-foreground-muted">
450 Repository496 Repository
451 </Text>497 </Text>
...@@ -457,6 +503,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {...@@ -457,6 +503,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
457 ) : (503 ) : (
458 <AndroidSheetHeader504 <AndroidSheetHeader
459 title={currentBranchLabel}505 title={currentBranchLabel}
506 hideBottomBorder={Platform.OS === "android"}
460 subtitle={currentStatusSummary}507 subtitle={currentStatusSummary}
461 onBack={() => navigation.goBack()}508 onBack={() => navigation.goBack()}
462 actions={[509 actions={[
...@@ -470,7 +517,9 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {...@@ -470,7 +517,9 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
470 />517 />
471 )}518 )}
472519
473 {content}520 <MaterialScreenContent insetHorizontal={layout.usesSplitView}>
521 {content}
522 </MaterialScreenContent>
474 </View>523 </View>
475 );524 );
476}525}
apps/mobile/src/features/threads/git/gitSheetComponents.tsx+58-19
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1import { SymbolView } from "../../../components/AppSymbol";1import { SymbolView } from "../../../components/AppSymbol";
2import type { ComponentProps } from "react";2import type { ComponentProps } from "react";
3import { Pressable, View } from "react-native";3import { Platform, Pressable, View } from "react-native";
4import { AppText as Text } from "../../../components/AppText";4import { AppText as Text } from "../../../components/AppText";
5import { MaterialButton } from "../../../components/MaterialButton";
5import { cn } from "../../../lib/cn";6import { cn } from "../../../lib/cn";
67
7/* ─── Shared sheet components ──────────────────────────────────────── */8/* ─── Shared sheet components ──────────────────────────────────────── */
...@@ -13,6 +14,18 @@ export function SheetActionButton(props: {...@@ -13,6 +14,18 @@ export function SheetActionButton(props: {
13 readonly tone?: "primary" | "secondary" | "danger";14 readonly tone?: "primary" | "secondary" | "danger";
14 readonly onPress: () => void;15 readonly onPress: () => void;
15}) {16}) {
17 if (Platform.OS === "android")
18 return (
19 <View className="flex-1">
20 <MaterialButton
21 label={props.label}
22 tone={props.tone}
23 disabled={props.disabled}
24 onPress={props.onPress}
25 fullWidth
26 />
27 </View>
28 );
16 const tone = props.tone ?? "secondary";29 const tone = props.tone ?? "secondary";
17 const textColorClassName =30 const textColorClassName =
18 tone === "primary"31 tone === "primary"
...@@ -24,13 +37,13 @@ export function SheetActionButton(props: {...@@ -24,13 +37,13 @@ export function SheetActionButton(props: {
24 return (37 return (
25 <Pressable38 <Pressable
26 className={cn(39 className={cn(
27 "min-h-[48px] flex-1 flex-row items-center justify-center gap-2 rounded-[18px] px-4 py-3 disabled:opacity-[0.45]",40 "min-h-[48px] flex-row items-center justify-center gap-2 px-4 py-3 disabled:opacity-[0.45]",
28 tone === "primary"41 "flex-1 rounded-[18px]",
29 ? "bg-primary"42 tone === "primary" ? "bg-primary" : tone === "danger" ? "bg-danger" : "bg-secondary",
30 : tone === "danger"43 tone !== "primary" &&
31 ? "border border-danger-border bg-danger"44 (tone === "danger" ? "border border-danger-border" : "border border-secondary-border"),
32 : "border border-secondary-border bg-secondary",
33 )}45 )}
46 accessibilityRole="button"
34 disabled={props.disabled}47 disabled={props.disabled}
35 onPress={props.onPress}48 onPress={props.onPress}
36 >49 >
...@@ -58,7 +71,13 @@ export function SheetActionButton(props: {...@@ -58,7 +71,13 @@ export function SheetActionButton(props: {
5871
59export function MetaCard(props: { readonly label: string; readonly value: string }) {72export function MetaCard(props: { readonly label: string; readonly value: string }) {
60 return (73 return (
61 <View className="rounded-[18px] border border-border bg-card px-4 py-3">74 <View
75 className={
76 Platform.OS === "android"
77 ? "rounded-[20px] bg-card px-4 py-3"
78 : "rounded-[18px] border border-border bg-card px-4 py-3"
79 }
80 >
62 <Text className="text-foreground-muted text-2xs font-t3-bold tracking-[0.9px] uppercase">81 <Text className="text-foreground-muted text-2xs font-t3-bold tracking-[0.9px] uppercase">
63 {props.label}82 {props.label}
64 </Text>83 </Text>
...@@ -78,30 +97,50 @@ export function SheetListRow(props: {...@@ -78,30 +97,50 @@ export function SheetListRow(props: {
78}) {97}) {
79 return (98 return (
80 <Pressable99 <Pressable
81 className="flex-row items-center gap-3 px-1 py-3 disabled:opacity-[0.45]"100 className={
101 Platform.OS === "android"
102 ? "min-h-16 flex-row items-center gap-4 px-4 py-3 active:bg-subtle disabled:opacity-[0.45]"
103 : "flex-row items-center gap-3 px-1 py-3 disabled:opacity-[0.45]"
104 }
82 disabled={props.disabled}105 disabled={props.disabled}
83 onPress={props.onPress}106 onPress={props.onPress}
84 >107 >
85 <View className="bg-subtle h-9 w-9 items-center justify-center rounded-full">108 <View
109 className={
110 Platform.OS === "android"
111 ? "size-6 items-center justify-center"
112 : "bg-subtle h-9 w-9 items-center justify-center rounded-full"
113 }
114 >
86 <SymbolView115 <SymbolView
87 name={props.icon}116 name={props.icon}
88 size={16}117 size={Platform.OS === "android" ? 24 : 16}
89 tintColorClassName={"accent-icon"}118 tintColorClassName="accent-icon"
90 type="monochrome"119 type="monochrome"
91 />120 />
92 </View>121 </View>
93 <View className="flex-1 gap-0.5">122 <View className="flex-1 gap-0.5">
94 <Text className="text-foreground text-base font-t3-bold">{props.title}</Text>123 <Text
124 className={
125 Platform.OS === "android"
126 ? "text-foreground text-base font-t3-medium"
127 : "text-foreground text-base font-t3-bold"
128 }
129 >
130 {props.title}
131 </Text>
95 {props.subtitle ? (132 {props.subtitle ? (
96 <Text className="text-foreground-muted text-xs leading-snug">{props.subtitle}</Text>133 <Text className="text-foreground-muted text-xs leading-snug">{props.subtitle}</Text>
97 ) : null}134 ) : null}
98 </View>135 </View>
99 <SymbolView136 {Platform.OS !== "android" ? (
100 name="chevron.right"137 <SymbolView
101 size={13}138 name="chevron.right"
102 tintColorClassName={"accent-icon-subtle"}139 size={13}
103 type="monochrome"140 tintColorClassName="accent-icon-subtle"
104 />141 type="monochrome"
142 />
143 ) : null}
105 </Pressable>144 </Pressable>
106 );145 );
107}146}
apps/mobile/src/features/threads/thread-list-items.tsx+43-36
...@@ -183,7 +183,7 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props:...@@ -183,7 +183,7 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props:
183 <SymbolView183 <SymbolView
184 name="plus"184 name="plus"
185 size={compact ? 20 : 16}185 size={compact ? 20 : 16}
186 tintColorClassName={"accent-icon-muted"}186 tintColorClassName="accent-icon-muted"
187 type="monochrome"187 type="monochrome"
188 weight="medium"188 weight="medium"
189 />189 />
...@@ -232,7 +232,7 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props:...@@ -232,7 +232,7 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props:
232 <SymbolView232 <SymbolView
233 name={icon}233 name={icon}
234 size={10}234 size={10}
235 tintColorClassName={"accent-icon-subtle"}235 tintColorClassName="accent-icon-subtle"
236 type="monochrome"236 type="monochrome"
237 weight="semibold"237 weight="semibold"
238 />238 />
...@@ -379,7 +379,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {...@@ -379,7 +379,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
379 <SymbolView379 <SymbolView
380 name="chevron.right"380 name="chevron.right"
381 size={13}381 size={13}
382 tintColorClassName={"accent-icon-subtle"}382 tintColorClassName="accent-icon-subtle"
383 type="monochrome"383 type="monochrome"
384 />384 />
385 </View>385 </View>
...@@ -469,10 +469,10 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -469,10 +469,10 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
469 >["simultaneousWithExternalGesture"];469 >["simultaneousWithExternalGesture"];
470}) {470}) {
471 const { width: windowWidth } = useWindowDimensions();471 const { width: windowWidth } = useWindowDimensions();
472 const { themeAppearance: colorScheme, materialYouStyleLayoutActive } = useAppearancePreferences();472 const { themeAppearance: colorScheme } = useAppearancePreferences();
473 const compact = props.variant === "compact";473 const compact = props.variant === "compact";
474 const selected = props.selected === true;474 const selected = props.selected === true;
475 const visuallySelected = selected && (!compact || materialYouStyleLayoutActive);475 const visuallySelected = selected && (!compact || Platform.OS === "android");
476 const theme = useUniwindTheme();476 const theme = useUniwindTheme();
477 const screenColor = theme["--color-screen"];477 const screenColor = theme["--color-screen"];
478 const drawerColor = theme["--color-drawer"];478 const drawerColor = theme["--color-drawer"];
...@@ -507,22 +507,22 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -507,22 +507,22 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
507 );507 );
508508
509 const backgroundColor = compact ? screenColor : drawerColor;509 const backgroundColor = compact ? screenColor : drawerColor;
510 const effectiveSelectedBackground = materialYouStyleLayoutActive510 const effectiveSelectedBackground =
511 ? materialSelectedBackgroundColor511 Platform.OS === "android" ? materialSelectedBackgroundColor : selectedBackgroundColor;
512 : selectedBackgroundColor;512 const effectiveSelectedForeground =
513 const effectiveSelectedForeground = materialYouStyleLayoutActive513 Platform.OS === "android" ? materialSelectedForegroundColor : selectedForegroundColor;
514 ? materialSelectedForegroundColor
515 : selectedForegroundColor;
516 const effectiveStatus =514 const effectiveStatus =
517 visuallySelected && status515 visuallySelected && status
518 ? {516 ? {
519 ...status,517 ...status,
520 pillClassName: materialYouStyleLayoutActive518 pillClassName:
521 ? "bg-thread-selected-foreground/20"519 Platform.OS === "android"
522 : "bg-user-bubble-foreground/20",520 ? "bg-thread-selected-foreground/20"
523 textClassName: materialYouStyleLayoutActive521 : "bg-user-bubble-foreground/20",
524 ? "text-thread-selected-foreground"522 textClassName:
525 : "text-user-bubble-foreground",523 Platform.OS === "android"
524 ? "text-thread-selected-foreground"
525 : "text-user-bubble-foreground",
526 }526 }
527 : status;527 : status;
528528
...@@ -596,7 +596,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -596,7 +596,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
596 compact596 compact
597 ? "accent-icon-subtle"597 ? "accent-icon-subtle"
598 : selected598 : selected
599 ? materialYouStyleLayoutActive599 ? Platform.OS === "android"
600 ? "accent-thread-selected-foreground-muted"600 ? "accent-thread-selected-foreground-muted"
601 : "accent-user-bubble-foreground-muted"601 : "accent-user-bubble-foreground-muted"
602 : "accent-foreground-muted"602 : "accent-foreground-muted"
...@@ -608,7 +608,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -608,7 +608,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
608 "shrink",608 "shrink",
609 compact ? "text-sm" : "text-xs",609 compact ? "text-sm" : "text-xs",
610 visuallySelected610 visuallySelected
611 ? materialYouStyleLayoutActive611 ? Platform.OS === "android"
612 ? "text-thread-selected-foreground-muted"612 ? "text-thread-selected-foreground-muted"
613 : "text-user-bubble-foreground-muted"613 : "text-user-bubble-foreground-muted"
614 : "text-foreground-muted",614 : "text-foreground-muted",
...@@ -647,7 +647,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -647,7 +647,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
647 <Text647 <Text
648 className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${648 className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${
649 visuallySelected649 visuallySelected
650 ? materialYouStyleLayoutActive650 ? Platform.OS === "android"
651 ? "text-thread-selected-foreground"651 ? "text-thread-selected-foreground"
652 : "text-user-bubble-foreground"652 : "text-user-bubble-foreground"
653 : pr.textClassName653 : pr.textClassName
...@@ -666,7 +666,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -666,7 +666,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
666 key={`${thread.environmentId}:${thread.id}`}666 key={`${thread.environmentId}:${thread.id}`}
667 interactionClassName={667 interactionClassName={
668 visuallySelected668 visuallySelected
669 ? materialYouStyleLayoutActive669 ? Platform.OS === "android"
670 ? "bg-thread-selected-foreground"670 ? "bg-thread-selected-foreground"
671 : "bg-user-bubble-foreground"671 : "bg-user-bubble-foreground"
672 : "bg-primary"672 : "bg-primary"
...@@ -676,10 +676,10 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -676,10 +676,10 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
676 accessibilityRole="button"676 accessibilityRole="button"
677 className="bg-screen"677 className="bg-screen"
678 style={678 style={
679 materialYouStyleLayoutActive679 Platform.OS === "android"
680 ? {680 ? {
681 backgroundColor: visuallySelected ? effectiveSelectedBackground : backgroundColor,681 backgroundColor: visuallySelected ? effectiveSelectedBackground : backgroundColor,
682 borderRadius: SIDEBAR_ROW_RADIUS,682 borderRadius: 20,
683 }683 }
684 : undefined684 : undefined
685 }685 }
...@@ -689,13 +689,18 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -689,13 +689,18 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
689 }}689 }}
690 >690 >
691 <View className="pr-[18px] pt-[10px]" style={{ paddingLeft: THREAD_LIST_COMPACT_INSET }}>691 <View className="pr-[18px] pt-[10px]" style={{ paddingLeft: THREAD_LIST_COMPACT_INSET }}>
692 <View className={cn("gap-[3px] pb-[10px]", !props.isLast && "border-b border-separator")}>692 <View
693 className={cn(
694 "gap-[3px] pb-[10px]",
695 Platform.OS !== "android" && !props.isLast && "border-b border-separator",
696 )}
697 >
693 <View className="flex-row items-center justify-between gap-2">698 <View className="flex-row items-center justify-between gap-2">
694 <Text699 <Text
695 className={cn(700 className={cn(
696 "flex-1 text-lg font-t3-bold",701 "flex-1 text-lg font-t3-bold",
697 visuallySelected702 visuallySelected
698 ? materialYouStyleLayoutActive703 ? Platform.OS === "android"
699 ? "text-thread-selected-foreground"704 ? "text-thread-selected-foreground"
700 : "text-user-bubble-foreground"705 : "text-user-bubble-foreground"
701 : "text-foreground",706 : "text-foreground",
...@@ -706,14 +711,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -706,14 +711,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
706 </Text>711 </Text>
707 <View className="flex-row items-center gap-2">712 <View className="flex-row items-center gap-2">
708 {props.hasQueuedMessages ? (713 {props.hasQueuedMessages ? (
709 <QueuedMessageIcon selected={visuallySelected && !materialYouStyleLayoutActive} />714 <QueuedMessageIcon selected={visuallySelected && Platform.OS !== "android"} />
710 ) : null}715 ) : null}
711 {statusPill}716 {statusPill}
712 <Text717 <Text
713 className={cn(718 className={cn(
714 "text-base tabular-nums",719 "text-base tabular-nums",
715 visuallySelected720 visuallySelected
716 ? materialYouStyleLayoutActive721 ? Platform.OS === "android"
717 ? "text-thread-selected-foreground-muted"722 ? "text-thread-selected-foreground-muted"
718 : "text-user-bubble-foreground-muted"723 : "text-user-bubble-foreground-muted"
719 : "text-foreground-tertiary",724 : "text-foreground-tertiary",
...@@ -724,7 +729,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -724,7 +729,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
724 <SymbolView729 <SymbolView
725 name="chevron.right"730 name="chevron.right"
726 size={13}731 size={13}
727 tintColorClassName={"accent-icon-subtle"}732 tintColorClassName="accent-icon-subtle"
728 type="monochrome"733 type="monochrome"
729 />734 />
730 </View>735 </View>
...@@ -746,7 +751,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -746,7 +751,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
746 key={`${thread.environmentId}:${thread.id}`}751 key={`${thread.environmentId}:${thread.id}`}
747 interactionClassName={752 interactionClassName={
748 visuallySelected753 visuallySelected
749 ? materialYouStyleLayoutActive754 ? Platform.OS === "android"
750 ? "bg-thread-selected-foreground"755 ? "bg-thread-selected-foreground"
751 : "bg-user-bubble-foreground"756 : "bg-user-bubble-foreground"
752 : "bg-primary"757 : "bg-primary"
...@@ -761,7 +766,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -761,7 +766,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
761 }}766 }}
762 style={{767 style={{
763 backgroundColor: visuallySelected ? effectiveSelectedBackground : backgroundColor,768 backgroundColor: visuallySelected ? effectiveSelectedBackground : backgroundColor,
764 borderRadius: SIDEBAR_ROW_RADIUS,769 borderRadius: Platform.OS === "android" ? 20 : SIDEBAR_ROW_RADIUS,
765 minHeight: 64,770 minHeight: 64,
766 justifyContent: "center",771 justifyContent: "center",
767 paddingHorizontal: 12,772 paddingHorizontal: 12,
...@@ -774,7 +779,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -774,7 +779,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
774 className={cn(779 className={cn(
775 "flex-1 text-base font-t3-medium",780 "flex-1 text-base font-t3-medium",
776 visuallySelected781 visuallySelected
777 ? materialYouStyleLayoutActive782 ? Platform.OS === "android"
778 ? "text-thread-selected-foreground"783 ? "text-thread-selected-foreground"
779 : "text-user-bubble-foreground"784 : "text-user-bubble-foreground"
780 : "text-foreground",785 : "text-foreground",
...@@ -785,14 +790,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -785,14 +790,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
785 </Text>790 </Text>
786 <View className="flex-row items-center gap-2">791 <View className="flex-row items-center gap-2">
787 {props.hasQueuedMessages ? (792 {props.hasQueuedMessages ? (
788 <QueuedMessageIcon selected={visuallySelected && !materialYouStyleLayoutActive} />793 <QueuedMessageIcon selected={visuallySelected && Platform.OS !== "android"} />
789 ) : null}794 ) : null}
790 {statusPill}795 {statusPill}
791 <Text796 <Text
792 className={cn(797 className={cn(
793 "text-xs tabular-nums",798 "text-xs tabular-nums",
794 visuallySelected799 visuallySelected
795 ? materialYouStyleLayoutActive800 ? Platform.OS === "android"
796 ? "text-thread-selected-foreground-muted"801 ? "text-thread-selected-foreground-muted"
797 : "text-user-bubble-foreground-muted"802 : "text-user-bubble-foreground-muted"
798 : "text-foreground-muted",803 : "text-foreground-muted",
...@@ -820,9 +825,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -820,9 +825,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
820 threadKey={`${thread.environmentId}:${thread.id}`}825 threadKey={`${thread.environmentId}:${thread.id}`}
821 backgroundColor={backgroundColor}826 backgroundColor={backgroundColor}
822 containerStyle={827 containerStyle={
823 compact && !materialYouStyleLayoutActive828 Platform.OS === "android"
824 ? undefined829 ? { borderRadius: 20, overflow: "hidden", marginHorizontal: 8, marginVertical: 2 }
825 : { borderRadius: SIDEBAR_ROW_RADIUS, overflow: "hidden" }830 : compact
831 ? undefined
832 : { borderRadius: SIDEBAR_ROW_RADIUS, overflow: "hidden" }
826 }833 }
827 enableTrackpadSwipe834 enableTrackpadSwipe
828 fullSwipeWidth={props.fullSwipeWidth ?? windowWidth - 32}835 fullSwipeWidth={props.fullSwipeWidth ?? windowWidth - 32}
apps/mobile/src/features/threads/thread-list-v2-items.tsx+42-36
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1import { RowPressable } from "../../components/RowPressable";1import { RowPressable } from "../../components/RowPressable";
2import { CustomSnoozeSheet } from "./CustomSnoozeSheet";2import { CustomSnoozeSheet } from "./CustomSnoozeSheet";
3import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
4import { appAtomRegistry } from "../../state/atom-registry";3import { appAtomRegistry } from "../../state/atom-registry";
5import { threadArrangementOpenAtom } from "../../state/thread-order";4import { threadArrangementOpenAtom } from "../../state/thread-order";
6import type { ThreadMoveDestination } from "./threadOrder";5import type { ThreadMoveDestination } from "./threadOrder";
...@@ -182,7 +181,7 @@ export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledS...@@ -182,7 +181,7 @@ export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledS
182 <SymbolView181 <SymbolView
183 name="chevron.down"182 name="chevron.down"
184 size={10}183 size={10}
185 tintColorClassName={"accent-foreground-muted"}184 tintColorClassName="accent-foreground-muted"
186 type="monochrome"185 type="monochrome"
187 style={{ transform: [{ rotate: props.expanded ? "180deg" : "0deg" }] }}186 style={{ transform: [{ rotate: props.expanded ? "180deg" : "0deg" }] }}
188 />187 />
...@@ -441,12 +440,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -441,12 +440,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
441440
442 const pr = useThreadPr(thread);441 const pr = useThreadPr(thread);
443442
444 const { materialYouStyleLayoutActive } = useAppearancePreferences();
445 const theme = useUniwindTheme();443 const theme = useUniwindTheme();
446 const screenColor = theme["--color-screen"];444 const screenColor = theme["--color-screen"];
447 const drawerColor = theme["--color-drawer"];445 const drawerColor = theme["--color-drawer"];
448 const selectedBackgroundColor =446 const selectedBackgroundColor =
449 theme[materialYouStyleLayoutActive ? "--color-thread-selected" : "--color-user-bubble"];447 theme[Platform.OS === "android" ? "--color-thread-selected" : "--color-user-bubble"];
450 const sidebarPane = props.pane === "sidebar";448 const sidebarPane = props.pane === "sidebar";
451 const selected = props.selected === true;449 const selected = props.selected === true;
452 // The provider badge's border blends into the row's own surface, which450 // The provider badge's border blends into the row's own surface, which
...@@ -456,7 +454,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -456,7 +454,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
456 const providerIconSurfaceColor = sidebarPane454 const providerIconSurfaceColor = sidebarPane
457 ? selected455 ? selected
458 ? selectedBackgroundColor456 ? selectedBackgroundColor
459 : drawerColor457 : Platform.OS === "android"
458 ? screenColor
459 : drawerColor
460 : screenColor;460 : screenColor;
461461
462 const status = resolveThreadListV2Status(thread);462 const status = resolveThreadListV2Status(thread);
...@@ -750,7 +750,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -750,7 +750,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
750 className={cn(750 className={cn(
751 "flex-1 text-sm font-t3-medium",751 "flex-1 text-sm font-t3-medium",
752 selected752 selected
753 ? materialYouStyleLayoutActive753 ? Platform.OS === "android"
754 ? "text-thread-selected-foreground-muted"754 ? "text-thread-selected-foreground-muted"
755 : "text-user-bubble-foreground-muted"755 : "text-user-bubble-foreground-muted"
756 : "text-foreground-muted",756 : "text-foreground-muted",
...@@ -764,7 +764,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -764,7 +764,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
764 <SymbolView764 <SymbolView
765 name="pin"765 name="pin"
766 size={11}766 size={11}
767 tintColorClassName={"accent-foreground-muted"}767 tintColorClassName="accent-foreground-muted"
768 type="monochrome"768 type="monochrome"
769 />769 />
770 ) : null}770 ) : null}
...@@ -772,7 +772,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -772,7 +772,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
772 className={cn(772 className={cn(
773 "text-xs tabular-nums",773 "text-xs tabular-nums",
774 selected774 selected
775 ? materialYouStyleLayoutActive775 ? Platform.OS === "android"
776 ? "text-thread-selected-foreground"776 ? "text-thread-selected-foreground"
777 : "text-user-bubble-foreground"777 : "text-user-bubble-foreground"
778 : (statusLabel?.className ?? "text-foreground-tertiary"),778 : (statusLabel?.className ?? "text-foreground-tertiary"),
...@@ -785,7 +785,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -785,7 +785,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
785 className={cn(785 className={cn(
786 "mt-1 text-base font-t3-medium",786 "mt-1 text-base font-t3-medium",
787 selected787 selected
788 ? materialYouStyleLayoutActive788 ? Platform.OS === "android"
789 ? "text-thread-selected-foreground"789 ? "text-thread-selected-foreground"
790 : "text-user-bubble-foreground"790 : "text-user-bubble-foreground"
791 : "text-foreground",791 : "text-foreground",
...@@ -809,7 +809,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -809,7 +809,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
809 className={cn(809 className={cn(
810 "flex-1 text-xs",810 "flex-1 text-xs",
811 selected811 selected
812 ? materialYouStyleLayoutActive812 ? Platform.OS === "android"
813 ? "text-thread-selected-foreground-muted"813 ? "text-thread-selected-foreground-muted"
814 : "text-user-bubble-foreground-muted"814 : "text-user-bubble-foreground-muted"
815 : "text-danger-foreground",815 : "text-danger-foreground",
...@@ -830,7 +830,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -830,7 +830,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
830 className={cn(830 className={cn(
831 "shrink text-xs",831 "shrink text-xs",
832 selected832 selected
833 ? materialYouStyleLayoutActive833 ? Platform.OS === "android"
834 ? "text-thread-selected-foreground-muted"834 ? "text-thread-selected-foreground-muted"
835 : "text-user-bubble-foreground-muted"835 : "text-user-bubble-foreground-muted"
836 : "text-foreground-muted",836 : "text-foreground-muted",
...@@ -842,7 +842,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -842,7 +842,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
842 className={cn(842 className={cn(
843 "text-xs",843 "text-xs",
844 selected844 selected
845 ? materialYouStyleLayoutActive845 ? Platform.OS === "android"
846 ? "text-thread-selected-foreground-muted"846 ? "text-thread-selected-foreground-muted"
847 : "text-user-bubble-foreground-muted"847 : "text-user-bubble-foreground-muted"
848 : "text-foreground-muted",848 : "text-foreground-muted",
...@@ -858,7 +858,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -858,7 +858,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
858 className={cn(858 className={cn(
859 "text-xs",859 "text-xs",
860 selected860 selected
861 ? materialYouStyleLayoutActive861 ? Platform.OS === "android"
862 ? "text-thread-selected-foreground-muted"862 ? "text-thread-selected-foreground-muted"
863 : "text-user-bubble-foreground-muted"863 : "text-user-bubble-foreground-muted"
864 : "text-foreground-tertiary",864 : "text-foreground-tertiary",
...@@ -874,7 +874,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -874,7 +874,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
874 size={11}874 size={11}
875 tintColorClassName={875 tintColorClassName={
876 selected876 selected
877 ? materialYouStyleLayoutActive877 ? Platform.OS === "android"
878 ? "accent-thread-selected-foreground-muted"878 ? "accent-thread-selected-foreground-muted"
879 : "accent-user-bubble-foreground-muted"879 : "accent-user-bubble-foreground-muted"
880 : "accent-foreground-tertiary"880 : "accent-foreground-tertiary"
...@@ -893,7 +893,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -893,7 +893,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
893 size={12}893 size={12}
894 tintColorClassName={894 tintColorClassName={
895 selected895 selected
896 ? materialYouStyleLayoutActive896 ? Platform.OS === "android"
897 ? "accent-thread-selected-foreground"897 ? "accent-thread-selected-foreground"
898 : "accent-user-bubble-foreground"898 : "accent-user-bubble-foreground"
899 : pr.state === null || pr.isDraft899 : pr.state === null || pr.isDraft
...@@ -911,7 +911,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -911,7 +911,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
911 className={cn(911 className={cn(
912 "text-xs",912 "text-xs",
913 selected913 selected
914 ? materialYouStyleLayoutActive914 ? Platform.OS === "android"
915 ? "text-thread-selected-foreground"915 ? "text-thread-selected-foreground"
916 : "text-user-bubble-foreground"916 : "text-user-bubble-foreground"
917 : pr.textClassName,917 : pr.textClassName,
...@@ -941,13 +941,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -941,13 +941,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
941 <RowPressable941 <RowPressable
942 key={`${thread.environmentId}:${thread.id}`}942 key={`${thread.environmentId}:${thread.id}`}
943 interactionClassName={943 interactionClassName={
944 selected && (sidebarPane || materialYouStyleLayoutActive)944 selected && (sidebarPane || Platform.OS === "android")
945 ? materialYouStyleLayoutActive945 ? Platform.OS === "android"
946 ? "bg-thread-selected-foreground"946 ? "bg-thread-selected-foreground"
947 : "bg-user-bubble-foreground"947 : "bg-user-bubble-foreground"
948 : "bg-primary"948 : "bg-primary"
949 }949 }
950 className={sidebarPane || materialYouStyleLayoutActive ? undefined : "bg-screen"}950 className={sidebarPane || Platform.OS === "android" ? undefined : "bg-screen"}
951 accessibilityHint={swipeAccessibilityHint}951 accessibilityHint={swipeAccessibilityHint}
952 accessibilityLabel={952 accessibilityLabel={
953 props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title953 props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title
...@@ -959,14 +959,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -959,14 +959,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
959 onSelectThread(thread);959 onSelectThread(thread);
960 }}960 }}
961 style={961 style={
962 sidebarPane || materialYouStyleLayoutActive962 sidebarPane || Platform.OS === "android"
963 ? {963 ? {
964 backgroundColor: selected964 backgroundColor: selected
965 ? selectedBackgroundColor965 ? selectedBackgroundColor
966 : sidebarPane966 : sidebarPane && Platform.OS !== "android"
967 ? drawerColor967 ? drawerColor
968 : screenColor,968 : screenColor,
969 borderRadius: SIDEBAR_V2_ROW_RADIUS,969 borderRadius: Platform.OS === "android" ? 20 : SIDEBAR_V2_ROW_RADIUS,
970 ...(sidebarPane ? { paddingHorizontal: 12, paddingVertical: 10 } : null),970 ...(sidebarPane ? { paddingHorizontal: 12, paddingVertical: 10 } : null),
971 }971 }
972 : undefined972 : undefined
...@@ -980,8 +980,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -980,8 +980,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
980 separates rows. The opaque screen background stays so swipe980 separates rows. The opaque screen background stays so swipe
981 actions reveal behind the row. */981 actions reveal behind the row. */
982 <View>982 <View>
983 <View className="px-5 py-2.5">{cardContent}</View>983 <View className={Platform.OS === "android" ? "px-3 py-2.5" : "px-5 py-2.5"}>
984 {props.showTrailingDivider !== false ? (984 {cardContent}
985 </View>
986 {Platform.OS !== "android" && props.showTrailingDivider !== false ? (
985 <View className="ml-5 h-px bg-border-subtle" />987 <View className="ml-5 h-px bg-border-subtle" />
986 ) : null}988 ) : null}
987 </View>989 </View>
...@@ -991,8 +993,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -991,8 +993,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
991 <RowPressable993 <RowPressable
992 key={`${thread.environmentId}:${thread.id}`}994 key={`${thread.environmentId}:${thread.id}`}
993 interactionClassName={995 interactionClassName={
994 selected && (sidebarPane || materialYouStyleLayoutActive)996 selected && (sidebarPane || Platform.OS === "android")
995 ? materialYouStyleLayoutActive997 ? Platform.OS === "android"
996 ? "bg-thread-selected-foreground"998 ? "bg-thread-selected-foreground"
997 : "bg-user-bubble-foreground"999 : "bg-user-bubble-foreground"
998 : "bg-primary"1000 : "bg-primary"
...@@ -1003,20 +1005,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -1003,20 +1005,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
1003 }1005 }
1004 accessibilityRole="button"1006 accessibilityRole="button"
1005 accessibilityState={{ selected }}1007 accessibilityState={{ selected }}
1006 className={sidebarPane || materialYouStyleLayoutActive ? undefined : "bg-screen"}1008 className={sidebarPane || Platform.OS === "android" ? undefined : "bg-screen"}
1007 onPress={() => {1009 onPress={() => {
1008 close();1010 close();
1009 onSelectThread(thread);1011 onSelectThread(thread);
1010 }}1012 }}
1011 style={1013 style={
1012 sidebarPane || materialYouStyleLayoutActive1014 sidebarPane || Platform.OS === "android"
1013 ? {1015 ? {
1014 backgroundColor: selected1016 backgroundColor: selected
1015 ? selectedBackgroundColor1017 ? selectedBackgroundColor
1016 : sidebarPane1018 : sidebarPane && Platform.OS !== "android"
1017 ? drawerColor1019 ? drawerColor
1018 : screenColor,1020 : screenColor,
1019 borderRadius: SIDEBAR_V2_ROW_RADIUS,1021 borderRadius: Platform.OS === "android" ? 20 : SIDEBAR_V2_ROW_RADIUS,
1020 }1022 }
1021 : undefined1023 : undefined
1022 }1024 }
...@@ -1044,7 +1046,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -1044,7 +1046,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
1044 className={cn(1046 className={cn(
1045 "text-base",1047 "text-base",
1046 selected1048 selected
1047 ? materialYouStyleLayoutActive1049 ? Platform.OS === "android"
1048 ? "text-thread-selected-foreground"1050 ? "text-thread-selected-foreground"
1049 : "text-user-bubble-foreground"1051 : "text-user-bubble-foreground"
1050 : "text-foreground-muted",1052 : "text-foreground-muted",
...@@ -1066,7 +1068,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -1066,7 +1068,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
1066 className={cn(1068 className={cn(
1067 "text-sm tabular-nums",1069 "text-sm tabular-nums",
1068 selected1070 selected
1069 ? materialYouStyleLayoutActive1071 ? Platform.OS === "android"
1070 ? "text-thread-selected-foreground-muted"1072 ? "text-thread-selected-foreground-muted"
1071 : "text-user-bubble-foreground-muted"1073 : "text-user-bubble-foreground-muted"
1072 : snoozedRow1074 : snoozedRow
...@@ -1084,16 +1086,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -1084,16 +1086,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
1084 );1086 );
10851087
1086 return (1088 return (
1087 <>1089 <View collapsable={false}>
1088 {customSnoozeOpen && (1090 {customSnoozeOpen && (
1089 <CustomSnoozeSheet onClose={() => setCustomSnoozeOpen(false)} onSnooze={handleSnooze} />1091 <CustomSnoozeSheet onClose={() => setCustomSnoozeOpen(false)} onSnooze={handleSnooze} />
1090 )}1092 )}
1091 <ThreadSwipeable1093 <ThreadSwipeable
1092 threadKey={`${thread.environmentId}:${thread.id}`}1094 threadKey={`${thread.environmentId}:${thread.id}`}
1093 backgroundColor={sidebarPane ? drawerColor : screenColor}1095 backgroundColor={sidebarPane && Platform.OS !== "android" ? drawerColor : screenColor}
1094 compactActions={variant === "slim"}1096 compactActions={variant === "slim"}
1095 containerStyle={1097 containerStyle={
1096 sidebarPane ? { borderRadius: SIDEBAR_V2_ROW_RADIUS, overflow: "hidden" } : undefined1098 Platform.OS === "android"
1099 ? { borderRadius: 20, overflow: "hidden", marginHorizontal: 8, marginVertical: 2 }
1100 : sidebarPane
1101 ? { borderRadius: SIDEBAR_V2_ROW_RADIUS, overflow: "hidden" }
1102 : undefined
1097 }1103 }
1098 enableTrackpadSwipe1104 enableTrackpadSwipe
1099 // Full swipe commits the advertised lifecycle action (Settle /1105 // Full swipe commits the advertised lifecycle action (Settle /
...@@ -1141,6 +1147,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -1141,6 +1147,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
1141 </ControlPillMenu>1147 </ControlPillMenu>
1142 )}1148 )}
1143 </ThreadSwipeable>1149 </ThreadSwipeable>
1144 </>1150 </View>
1145 );1151 );
1146});1152});
apps/mobile/src/features/threads/thread-search-match.tsx+4-5
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1import { Platform } from "react-native";
1import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";2import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
23
3import { AppText as Text } from "../../components/AppText";4import { AppText as Text } from "../../components/AppText";
4import { cn } from "../../lib/cn";5import { cn } from "../../lib/cn";
5import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
66
7function foldAsciiCase(value: string): string {7function foldAsciiCase(value: string): string {
8 return value.replace(/[A-Z]/g, (character) => character.toLowerCase());8 return value.replace(/[A-Z]/g, (character) => character.toLowerCase());
...@@ -51,14 +51,13 @@ export function ThreadSearchMatchExcerpt(props: {...@@ -51,14 +51,13 @@ export function ThreadSearchMatchExcerpt(props: {
51 readonly compact?: boolean;51 readonly compact?: boolean;
52}) {52}) {
53 const isUser = props.match.source === "user";53 const isUser = props.match.source === "user";
54 const { materialYouStyleLayoutActive } = useAppearancePreferences();
55 const parts = splitHighlightParts(props.match.snippet, props.query);54 const parts = splitHighlightParts(props.match.snippet, props.query);
56 return (55 return (
57 <Text56 <Text
58 className={cn(57 className={cn(
59 props.compact ? "text-sm" : "text-xs",58 props.compact ? "text-sm" : "text-xs",
60 props.selected59 props.selected
61 ? materialYouStyleLayoutActive60 ? Platform.OS === "android"
62 ? "text-thread-selected-foreground-muted"61 ? "text-thread-selected-foreground-muted"
63 : "text-user-bubble-foreground-muted"62 : "text-user-bubble-foreground-muted"
64 : "text-foreground-muted",63 : "text-foreground-muted",
...@@ -69,7 +68,7 @@ export function ThreadSearchMatchExcerpt(props: {...@@ -69,7 +68,7 @@ export function ThreadSearchMatchExcerpt(props: {
69 className={cn(68 className={cn(
70 props.compact ? "text-sm font-t3-medium" : "text-xs font-t3-medium",69 props.compact ? "text-sm font-t3-medium" : "text-xs font-t3-medium",
71 props.selected70 props.selected
72 ? materialYouStyleLayoutActive71 ? Platform.OS === "android"
73 ? "text-thread-selected-foreground"72 ? "text-thread-selected-foreground"
74 : "text-user-bubble-foreground"73 : "text-user-bubble-foreground"
75 : isUser74 : isUser
...@@ -85,7 +84,7 @@ export function ThreadSearchMatchExcerpt(props: {...@@ -85,7 +84,7 @@ export function ThreadSearchMatchExcerpt(props: {
85 props.compact ? "text-sm" : "text-xs",84 props.compact ? "text-sm" : "text-xs",
86 part.highlighted && "font-t3-bold",85 part.highlighted && "font-t3-bold",
87 props.selected86 props.selected
88 ? materialYouStyleLayoutActive87 ? Platform.OS === "android"
89 ? "text-thread-selected-foreground"88 ? "text-thread-selected-foreground"
90 : "text-user-bubble-foreground"89 : "text-user-bubble-foreground"
91 : part.highlighted90 : part.highlighted
apps/mobile/src/features/usage/UsageLimitsPooled.tsx+4-12
...@@ -12,15 +12,14 @@ import {...@@ -12,15 +12,14 @@ import {
12 type LimitPoolWindow,12 type LimitPoolWindow,
13} from "@t3tools/shared/usageLimits";13} from "@t3tools/shared/usageLimits";
14import { useId, useState } from "react";14import { useId, useState } from "react";
15import { Platform, Pressable, ScrollView, View } from "react-native";15import { Pressable, ScrollView, View } from "react-native";
16import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg";16import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg";
17import { useSafeAreaInsets } from "react-native-safe-area-context";17import { useSafeAreaInsets } from "react-native-safe-area-context";
1818
19import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
20import { SymbolView } from "../../components/AppSymbol";19import { SymbolView } from "../../components/AppSymbol";
21import { AppText as Text } from "../../components/AppText";20import { AppText as Text } from "../../components/AppText";
22import { ProviderIcon } from "../../components/ProviderIcon";21import { ProviderIcon } from "../../components/ProviderIcon";
23import { NativeStackScreenOptions } from "../../native/StackHeader";22import { SettingsScreen } from "../settings/components/SettingsScreen";
24import { environmentPresentations } from "../../state/presentation";23import { environmentPresentations } from "../../state/presentation";
25import { ResetCredits } from "./UsageLimitsSection";24import { ResetCredits } from "./UsageLimitsSection";
26import { useProviderColors } from "./usageProviders";25import { useProviderColors } from "./usageProviders";
...@@ -278,7 +277,6 @@ type AccountScreenProps = StaticScreenProps<{...@@ -278,7 +277,6 @@ type AccountScreenProps = StaticScreenProps<{
278277
279/** Resolve the account again so live quota and credit updates reach the open detail screen. */278/** Resolve the account again so live quota and credit updates reach the open detail screen. */
280export function UsageLimitAccountScreen({ route }: AccountScreenProps) {279export function UsageLimitAccountScreen({ route }: AccountScreenProps) {
281 const navigation = useNavigation();
282 const insets = useSafeAreaInsets();280 const insets = useSafeAreaInsets();
283 const presentations = useAtomValue(environmentPresentations.presentationsAtom);281 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
284 const { accountKey, windowId, windowKind, environmentIds, now } = route.params;282 const { accountKey, windowId, windowKind, environmentIds, now } = route.params;
...@@ -297,13 +295,7 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) {...@@ -297,13 +295,7 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) {
297 const reset = pool?.resets.find((candidate) => candidate.member.account.key === accountKey);295 const reset = pool?.resets.find((candidate) => candidate.member.account.key === accountKey);
298 const [revealed, setRevealed] = useState(false);296 const [revealed, setRevealed] = useState(false);
299 return (297 return (
300 <View collapsable={false} className="flex-1 bg-sheet">298 <SettingsScreen title="Account">
301 {Platform.OS === "android" ? (
302 <>
303 <NativeStackScreenOptions options={{ headerShown: false }} />
304 <AndroidScreenHeader title="Account" onBack={() => navigation.goBack()} />
305 </>
306 ) : null}
307 <ScrollView299 <ScrollView
308 contentInsetAdjustmentBehavior="automatic"300 contentInsetAdjustmentBehavior="automatic"
309 contentContainerClassName="gap-5 p-5"301 contentContainerClassName="gap-5 p-5"
...@@ -389,6 +381,6 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) {...@@ -389,6 +381,6 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) {
389 </>381 </>
390 )}382 )}
391 </ScrollView>383 </ScrollView>
392 </View>384 </SettingsScreen>
393 );385 );
394}386}
apps/mobile/src/features/usage/UsageRouteScreen.tsx+8-18
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
1import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";2import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
2import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native";3import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native";
3import {4import {
...@@ -18,15 +19,14 @@ import {...@@ -18,15 +19,14 @@ import {
18 makeWindow,19 makeWindow,
19} from "@t3tools/shared/usageFormat";20} from "@t3tools/shared/usageFormat";
20import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";21import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
21import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";22import { Platform, Pressable, RefreshControl, View } from "react-native";
22import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated";23import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated";
23import { useSafeAreaInsets } from "react-native-safe-area-context";24import { useSafeAreaInsets } from "react-native-safe-area-context";
2425
25import { SegmentedControl } from "../../components/SegmentedControl";26import { SegmentedControl } from "../../components/SegmentedControl";
26import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
27import { AppText as Text } from "../../components/AppText";27import { AppText as Text } from "../../components/AppText";
28import { cn } from "../../lib/cn";28import { cn } from "../../lib/cn";
29import { NativeStackScreenOptions } from "../../native/StackHeader";29import { SettingsScreen } from "../settings/components/SettingsScreen";
30import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";30import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
31import { SettingsSection } from "../settings/components/SettingsSection";31import { SettingsSection } from "../settings/components/SettingsSection";
32import { UsageDailyChart } from "./UsageDailyChart";32import { UsageDailyChart } from "./UsageDailyChart";
...@@ -236,17 +236,7 @@ export function UsageRouteScreen() {...@@ -236,17 +236,7 @@ export function UsageRouteScreen() {
236 }, [navigation, environmentFilter]);236 }, [navigation, environmentFilter]);
237237
238 return (238 return (
239 <View collapsable={false} className="flex-1 bg-sheet">239 <SettingsScreen title="Usage" trailing={environmentFilter}>
240 {Platform.OS === "android" ? (
241 <>
242 <NativeStackScreenOptions options={{ headerShown: false }} />
243 <AndroidScreenHeader
244 title="Usage"
245 onBack={() => navigation.goBack()}
246 trailing={environmentFilter}
247 />
248 </>
249 ) : null}
250 <ScrollView240 <ScrollView
251 contentInsetAdjustmentBehavior="automatic"241 contentInsetAdjustmentBehavior="automatic"
252 showsVerticalScrollIndicator={false}242 showsVerticalScrollIndicator={false}
...@@ -277,20 +267,20 @@ export function UsageRouteScreen() {...@@ -277,20 +267,20 @@ export function UsageRouteScreen() {
277 <>267 <>
278 {/* Period and metric together: neither applies to Limits, and268 {/* Period and metric together: neither applies to Limits, and
279 both change every number below, so they share one bar. */}269 both change every number below, so they share one bar. */}
280 <View className="flex-row items-center gap-3">270 <View className={cn("gap-3", Platform.OS !== "android" && "flex-row items-center")}>
281 <SegmentedControl271 <SegmentedControl
282 options={WINDOW_OPTIONS}272 options={WINDOW_OPTIONS}
283 selected={windowDays}273 selected={windowDays}
284 onSelect={selectWindow}274 onSelect={selectWindow}
285 size="compact"275 size="compact"
286 className="flex-1"276 className={Platform.OS === "android" ? "w-full" : "flex-1"}
287 />277 />
288 <SegmentedControl278 <SegmentedControl
289 options={METRIC_OPTIONS}279 options={METRIC_OPTIONS}
290 selected={metric}280 selected={metric}
291 onSelect={setMetric}281 onSelect={setMetric}
292 size="compact"282 size="compact"
293 className="w-36"283 className={Platform.OS === "android" ? "w-full" : "w-36"}
294 />284 />
295 </View>285 </View>
296 {merged.duplicateSources.length > 0 ? (286 {merged.duplicateSources.length > 0 ? (
...@@ -330,7 +320,7 @@ export function UsageRouteScreen() {...@@ -330,7 +320,7 @@ export function UsageRouteScreen() {
330 )}320 )}
331 </Animated.View>321 </Animated.View>
332 </ScrollView>322 </ScrollView>
333 </View>323 </SettingsScreen>
334 );324 );
335}325}
336326
apps/mobile/src/lib/appBlurTarget.ts deleted-8
...@@ -1,8 +0,0 @@
1import { createRef } from "react";
2import type { View } from "react-native";
3
4// Android BlurViews can't sample their real backdrop like iOS does — they
5// need an explicit BlurTargetView to snapshot. App.tsx wraps the app content
6// in one and binds this ref so overlays hosted in separate windows (the
7// anchored dropdown's Modal) can still blur what's on screen behind them.
8export const appBlurTargetRef = createRef<View>();
apps/mobile/src/lib/glassBlurTarget.ts deleted-6
...@@ -1,6 +0,0 @@
1import { createContext, type RefObject } from "react";
2import type { View } from "react-native";
3
4// Android cannot sample a target that contains the BlurView itself. Keep the
5// feed in a separate target, shared by the composer and its popovers.
6export const GlassBlurTargetContext = createContext<RefObject<View | null> | undefined>(undefined);
apps/mobile/src/lib/materialYouTheme.test.ts+15-3
...@@ -31,8 +31,20 @@ const palette: MaterialYouPalette = {...@@ -31,8 +31,20 @@ const palette: MaterialYouPalette = {
31};31};
3232
33describe("Material You system colors", () => {33describe("Material You system colors", () => {
34 it.each(["light", "dark"] as const)(
35 "uses the system surface for the Android frame in %s",
36 (appearance) => {
37 const variables = materialYouPaletteToMobileThemeVariables(
38 palette,
39 appearance,
40 getMobileThemeRuntimeVariables("material-you", appearance, "android"),
41 );
42 expect(variables["--color-header"]).toBe(palette.surfaceContainerHigh);
43 },
44 );
45
34 it("overrides the selected theme without mutating its base variables", () => {46 it("overrides the selected theme without mutating its base variables", () => {
35 const base = getMobileThemeRuntimeVariables("t3-code", "dark");47 const base = getMobileThemeRuntimeVariables("t3-code", "dark", "android");
36 const snapshot = { ...base };48 const snapshot = { ...base };
3749
38 const variables = materialYouPaletteToMobileThemeVariables(palette, "dark", base);50 const variables = materialYouPaletteToMobileThemeVariables(palette, "dark", base);
...@@ -47,7 +59,7 @@ describe("Material You system colors", () => {...@@ -47,7 +59,7 @@ describe("Material You system colors", () => {
47 });59 });
4860
49 it("uses the Messages-style RCS tones for sent messages", () => {61 it("uses the Messages-style RCS tones for sent messages", () => {
50 const base = getMobileThemeRuntimeVariables("t3-code", "dark");62 const base = getMobileThemeRuntimeVariables("t3-code", "dark", "android");
51 const dark = materialYouPaletteToMobileThemeVariables(63 const dark = materialYouPaletteToMobileThemeVariables(
52 { ...palette, inversePrimary: "#A31D8DFF" },64 { ...palette, inversePrimary: "#A31D8DFF" },
53 "dark",65 "dark",
...@@ -56,7 +68,7 @@ describe("Material You system colors", () => {...@@ -56,7 +68,7 @@ describe("Material You system colors", () => {
56 const light = materialYouPaletteToMobileThemeVariables(68 const light = materialYouPaletteToMobileThemeVariables(
57 { ...palette, inversePrimary: "#A31D8DFF" },69 { ...palette, inversePrimary: "#A31D8DFF" },
58 "light",70 "light",
59 getMobileThemeRuntimeVariables("t3-code", "light"),71 getMobileThemeRuntimeVariables("t3-code", "light", "android"),
60 );72 );
6173
62 expect(dark["--color-user-bubble"]).toBe("#850073FF");74 expect(dark["--color-user-bubble"]).toBe("#850073FF");
apps/mobile/src/lib/mobileThemeVariables.test.ts+38-5
...@@ -1,25 +1,58 @@...@@ -1,25 +1,58 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support";3import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support";
4import { getMobileThemeVariables } from "./mobileTheme";4import { getMobileThemeVariables, MOBILE_THEME_IDS, themeColorWithAlpha } from "./mobileTheme";
5import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables";5import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables";
66
7describe("mobile theme runtime variables", () => {7describe("mobile theme runtime variables", () => {
8 it("derives the standard runtime palette from global.css", () => {8 it("derives the standard runtime palette from global.css", () => {
9 expect(getMobileThemeRuntimeVariables("t3-code", "light")).toEqual(9 expect(getMobileThemeRuntimeVariables("t3-code", "light", "ios")).toEqual(
10 readDefaultMobileThemeVariables("light"),10 readDefaultMobileThemeVariables("light"),
11 );11 );
12 expect(getMobileThemeRuntimeVariables("t3-code", "dark")).toEqual(12 expect(getMobileThemeRuntimeVariables("t3-code", "dark", "ios")).toEqual(
13 readDefaultMobileThemeVariables("dark"),13 readDefaultMobileThemeVariables("dark"),
14 );14 );
15 });15 });
1616
17 it("uses the same shared palette source as generated custom themes", () => {17 it("uses the same shared palette source as generated custom themes", () => {
18 expect(getMobileThemeRuntimeVariables("ocean", "light")).toEqual(18 expect(getMobileThemeRuntimeVariables("ocean", "light", "ios")).toEqual(
19 getMobileThemeVariables("ocean", "light"),19 getMobileThemeVariables("ocean", "light"),
20 );20 );
21 expect(getMobileThemeRuntimeVariables("iris", "dark")).toEqual(21 expect(getMobileThemeRuntimeVariables("iris", "dark", "ios")).toEqual(
22 getMobileThemeVariables("iris", "dark"),22 getMobileThemeVariables("iris", "dark"),
23 );23 );
24 });24 });
25
26 it.each(MOBILE_THEME_IDS)(
27 "keeps %s colors on Android with an opaque Material frame",
28 (themeId) => {
29 for (const appearance of ["light", "dark"] as const) {
30 const ios = getMobileThemeRuntimeVariables(themeId, appearance, "ios");
31 const android = getMobileThemeRuntimeVariables(themeId, appearance, "android");
32 expect(android).toEqual({
33 ...ios,
34 "--color-header": themeColorWithAlpha(
35 ios[
36 themeId === "t3-code" || themeId === "material-you"
37 ? "--color-card"
38 : "--color-drawer"
39 ],
40 1,
41 ),
42 });
43 expect(android["--color-header"]).toMatch(/^rgba\(\d+, \d+, \d+, 1\)$/);
44 }
45 },
46 );
47
48 it.each(["t3-code", "material-you"] as const)(
49 "keeps the %s default dark frame distinct from the rounded settings body",
50 (themeId) => {
51 const variables = getMobileThemeRuntimeVariables(themeId, "dark", "android");
52 expect(variables["--color-header"]).toBe("rgba(23, 23, 23, 1)");
53 expect(variables["--color-header"]).not.toBe(
54 themeColorWithAlpha(variables["--color-sheet-solid"], 1),
55 );
56 },
57 );
25});58});
apps/mobile/src/lib/mobileThemeVariables.ts+16-2
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1import defaultThemeVariables from "../../generated-uniwind-default-theme-variables.json";1import defaultThemeVariables from "../../generated-uniwind-default-theme-variables.json";
2
3import {2import {
4 DEFAULT_MOBILE_THEME_ID,3 DEFAULT_MOBILE_THEME_ID,
5 getMobileThemeVariables,4 getMobileThemeVariables,
5 themeColorWithAlpha,
6 type MobileThemeAppearance,6 type MobileThemeAppearance,
7 type MobileThemeId,7 type MobileThemeId,
8 type MobileThemeVariables,8 type MobileThemeVariables,
...@@ -20,8 +20,22 @@ const defaults = defaultThemeVariables as Readonly<...@@ -20,8 +20,22 @@ const defaults = defaultThemeVariables as Readonly<
20export function getMobileThemeRuntimeVariables(20export function getMobileThemeRuntimeVariables(
21 themeId: MobileThemeId,21 themeId: MobileThemeId,
22 appearance: MobileThemeAppearance,22 appearance: MobileThemeAppearance,
23 platform: string,
23): MobileThemeVariables {24): MobileThemeVariables {
24 return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you"25 const usesDefaultPalette = themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you";
26 const variables = usesDefaultPalette
25 ? defaults[appearance]27 ? defaults[appearance]
26 : getMobileThemeVariables(themeId, appearance);28 : getMobileThemeVariables(themeId, appearance);
29 if (platform !== "android") return variables;
30
31 // Rounded panes share one opaque frame. The default dark drawer matches the
32 // settings body, so use its card tone to keep the rounded edge visible.
33 // System colors replace this with their own surfaceContainerHigh afterwards.
34 return {
35 ...variables,
36 "--color-header": themeColorWithAlpha(
37 variables[usesDefaultPalette ? "--color-card" : "--color-drawer"],
38 1,
39 ),
40 };
27}41}
apps/mobile/src/lib/storage.test.ts+9-15
...@@ -204,21 +204,15 @@ describe("mobile connection storage", () => {...@@ -204,21 +204,15 @@ describe("mobile connection storage", () => {
204 await expect(loadPreferences()).resolves.toEqual({ ...themes, lightThemeId: "t3-chat" });204 await expect(loadPreferences()).resolves.toEqual({ ...themes, lightThemeId: "t3-chat" });
205 });205 });
206206
207 it("persists the Material You layout independently of the selected theme", async () => {207 it.each([true, false])("drops the removed Android layout preference (%s)", async (enabled) => {
208 await savePreferencesPatch({208 mocks.setPreferencesJson(
209 lightThemeId: "material-you",209 JSON.stringify({
210 materialYouStyleLayoutEnabled: true,210 materialYouStyleLayoutEnabled: enabled,
211 });211 lightThemeId: "t3-chat",
212 await savePreferencesPatch({ lightThemeId: "t3-chat" });212 }),
213 await expect(loadPreferences()).resolves.toEqual({213 10,
214 lightThemeId: "t3-chat",214 );
215 materialYouStyleLayoutEnabled: true,215 await expect(loadPreferences()).resolves.toEqual({ lightThemeId: "t3-chat" });
216 });
217 await savePreferencesPatch({ materialYouStyleLayoutEnabled: false });
218 await expect(loadPreferences()).resolves.toEqual({
219 lightThemeId: "t3-chat",
220 materialYouStyleLayoutEnabled: false,
221 });
222 });216 });
223217
224 it("drops the removed theme transition preference", async () => {218 it("drops the removed theme transition preference", async () => {
apps/mobile/src/native/SelectableMarkdownText.android.tsx+17-1
...@@ -2,8 +2,11 @@ import {...@@ -2,8 +2,11 @@ import {
2 SelectableMarkdownText as T3SelectableMarkdownText,2 SelectableMarkdownText as T3SelectableMarkdownText,
3 type SelectableMarkdownTextProps,3 type SelectableMarkdownTextProps,
4} from "@t3tools/mobile-markdown-text/renderer";4} from "@t3tools/mobile-markdown-text/renderer";
5import { useMemo } from "react";
56
6import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter";7import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter";
8import { themeColorWithAlpha } from "../lib/mobileTheme";
9import { useUniwindTheme } from "../lib/useUniwindTheme";
710
8type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;11type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;
912
...@@ -20,5 +23,18 @@ export function hasNativeSelectableMarkdownText(): boolean {...@@ -20,5 +23,18 @@ export function hasNativeSelectableMarkdownText(): boolean {
20}23}
2124
22export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) {25export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) {
23 return <T3SelectableMarkdownText {...props} highlightCode={highlightCodeSnippet} />;26 const theme = useUniwindTheme();
27 const selectionColor = themeColorWithAlpha(theme["--color-primary"], 0.32);
28 const selectionHandleColor = theme["--color-primary"];
29 const textStyle = useMemo(
30 () => ({ selectionColor, selectionHandleColor, ...props.textStyle }),
31 [props.textStyle, selectionColor, selectionHandleColor],
32 );
33 return (
34 <T3SelectableMarkdownText
35 {...props}
36 textStyle={textStyle}
37 highlightCode={highlightCodeSnippet}
38 />
39 );
24}40}
apps/mobile/src/native/T3ComposerEditor.native.tsx+2-2
...@@ -14,7 +14,7 @@ import {...@@ -14,7 +14,7 @@ import {
14 type Ref,14 type Ref,
15} from "react";15} from "react";
16import type { NativeSyntheticEvent, ViewProps } from "react-native";16import type { NativeSyntheticEvent, ViewProps } from "react-native";
17import { Image, StyleSheet } from "react-native";17import { Image, Platform, StyleSheet } from "react-native";
1818
19import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";19import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
20import {20import {
...@@ -259,7 +259,7 @@ export function ComposerEditor({...@@ -259,7 +259,7 @@ export function ComposerEditor({
259 );259 );
260 const { systemColorsActive } = useAppearancePreferences();260 const { systemColorsActive } = useAppearancePreferences();
261 const themeJson = JSON.stringify({261 const themeJson = JSON.stringify({
262 selection: systemColorsActive ? theme["--color-primary"] : null,262 selection: Platform.OS === "android" || systemColorsActive ? theme["--color-primary"] : null,
263 text: theme["--color-foreground"],263 text: theme["--color-foreground"],
264 placeholder: theme["--color-placeholder"],264 placeholder: theme["--color-placeholder"],
265 chipBackground: theme["--color-subtle"],265 chipBackground: theme["--color-subtle"],
apps/mobile/src/persistence/mobile-preferences.ts-6
...@@ -8,7 +8,6 @@ import * as Semaphore from "effect/Semaphore";...@@ -8,7 +8,6 @@ import * as Semaphore from "effect/Semaphore";
8import type { SidebarProjectGroupingMode } from "@t3tools/contracts";8import type { SidebarProjectGroupingMode } from "@t3tools/contracts";
9import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior";9import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior";
10import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";10import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";
11
12import * as MobileDatabase from "./mobile-database";11import * as MobileDatabase from "./mobile-database";
13import * as MobileSecureStorage from "./mobile-secure-storage";12import * as MobileSecureStorage from "./mobile-secure-storage";
14import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage";13import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage";
...@@ -22,7 +21,6 @@ export interface Preferences {...@@ -22,7 +21,6 @@ export interface Preferences {
22 readonly lightThemeId?: MobileThemeId;21 readonly lightThemeId?: MobileThemeId;
23 readonly darkThemeId?: MobileThemeId;22 readonly darkThemeId?: MobileThemeId;
24 readonly themeMode?: MobileThemeMode;23 readonly themeMode?: MobileThemeMode;
25 readonly materialYouStyleLayoutEnabled?: boolean;
26 readonly baseFontSize?: number;24 readonly baseFontSize?: number;
27 readonly terminalFontSize?: number | null;25 readonly terminalFontSize?: number | null;
28 readonly markdownFontSize?: number;26 readonly markdownFontSize?: number;
...@@ -94,7 +92,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -94,7 +92,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {
94 lightThemeId?: MobileThemeId;92 lightThemeId?: MobileThemeId;
95 darkThemeId?: MobileThemeId;93 darkThemeId?: MobileThemeId;
96 themeMode?: MobileThemeMode;94 themeMode?: MobileThemeMode;
97 materialYouStyleLayoutEnabled?: boolean;
98 baseFontSize?: number;95 baseFontSize?: number;
99 terminalFontSize?: number | null;96 terminalFontSize?: number | null;
100 markdownFontSize?: number;97 markdownFontSize?: number;
...@@ -139,9 +136,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -139,9 +136,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {
139 ) {136 ) {
140 preferences.themeMode = parsed.themeMode;137 preferences.themeMode = parsed.themeMode;
141 }138 }
142 if (typeof parsed.materialYouStyleLayoutEnabled === "boolean") {
143 preferences.materialYouStyleLayoutEnabled = parsed.materialYouStyleLayoutEnabled;
144 }
145 if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize;139 if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize;
146 if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) {140 if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) {
147 preferences.terminalFontSize = parsed.terminalFontSize;141 preferences.terminalFontSize = parsed.terminalFontSize;
docs/user/appearance.md+2-1
...@@ -10,7 +10,8 @@ themes or defaults....@@ -10,7 +10,8 @@ themes or defaults.
10On Android 12 or newer, choose the **Material You** theme in Appearance to use colors from10On Android 12 or newer, choose the **Material You** theme in Appearance to use colors from
11your wallpaper. Selecting another theme replaces those colors. Like other themes, Material You11your wallpaper. Selecting another theme replaces those colors. Like other themes, Material You
12can be selected separately for light and dark appearances.12can be selected separately for light and dark appearances.
13Android also offers **Material You Layout**, which changes shapes and spacing independently13Android uses **Material You Layout** by default unless you have turned it off in Appearance.
14It changes shapes, spacing, and controls independently
14of the selected theme.15of the selected theme.
1516
16## Motion17## Motion
vite.config.ts+2
...@@ -184,6 +184,7 @@ export default defineConfig({...@@ -184,6 +184,7 @@ export default defineConfig({
184 "apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx",184 "apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx",
185 "apps/mobile/src/features/files/FileMarkdownPreview.tsx",185 "apps/mobile/src/features/files/FileMarkdownPreview.tsx",
186 "apps/mobile/src/features/files/SourceFileSurface.tsx",186 "apps/mobile/src/features/files/SourceFileSurface.tsx",
187 "apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx",
187 "apps/mobile/src/features/files/AttachmentFileScreen.tsx",188 "apps/mobile/src/features/files/AttachmentFileScreen.tsx",
188 "apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx",189 "apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx",
189 "apps/mobile/src/features/files/thread-file-navigator-pane.tsx",190 "apps/mobile/src/features/files/thread-file-navigator-pane.tsx",
...@@ -204,6 +205,7 @@ export default defineConfig({...@@ -204,6 +205,7 @@ export default defineConfig({
204 "apps/mobile/src/lib/useMobileNavigationTheme.ts",205 "apps/mobile/src/lib/useMobileNavigationTheme.ts",
205 "apps/mobile/src/native/T3ComposerEditor.ios.tsx",206 "apps/mobile/src/native/T3ComposerEditor.ios.tsx",
206 "apps/mobile/src/native/T3ComposerEditor.native.tsx",207 "apps/mobile/src/native/T3ComposerEditor.native.tsx",
208 "apps/mobile/src/native/SelectableMarkdownText.android.tsx",
207 ],209 ],
208 rules: {210 rules: {
209 "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }],211 "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }],