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
88import android.graphics.Color
99import android.graphics.Paint
1010import android.graphics.Typeface
11import android.os.Build
1112import android.text.Spannable
1213import android.text.SpannableStringBuilder
1314import android.text.Spanned
......@@ -164,6 +165,17 @@ private class SanitizingSelectionActionModeCallback(
164165 }
165166}
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
167179class T3MarkdownTextSelectionModule : Module() {
168180 private val chipImages = LruCache<String, Map<String, Any>>(128)
169181
......@@ -183,9 +195,23 @@ class T3MarkdownTextSelectionModule : Module() {
183195 }
184196 }.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
186208 override fun definition() = ModuleDefinition {
187209 Name("T3MarkdownTextSelection")
188210
211 Function("setSelectionHandleColor") { reactTag: Int, color: Int ->
212 setSelectionHandleColor(reactTag, color)
213 }
214
189215 Function("renderContextChip") { payloadJson: String ->
190216 val resources = appContext.reactContext?.resources ?: return@Function null
191217 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 @@
11import 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";
313import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent";
414import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent";
515import { flattenStyles } from "./util";
......@@ -34,6 +44,7 @@ export type ContextMenuActionEvent = {
3444 */
3545export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & {
3646 nativeTextRef?: Ref<RNText>;
47 selectionHandleColor?: ColorValue;
3748 uiTextView?: boolean;
3849 contextMenuConfig?: string;
3950 contextClipboardConfig?: string;
......@@ -116,7 +127,45 @@ function MarkdownTextPrimitiveInner({ nativeTextRef, ...props }: MarkdownTextPri
116127 return <MarkdownTextPrimitiveChild {...props} />;
117128}
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 }
120169 if (Platform.OS !== "ios") {
121170 const { nativeTextRef, contextClipboardConfig: _contextClipboardConfig, ...textProps } = props;
122171 return <RNText ref={nativeTextRef} {...textProps} />;
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx+15-5
......@@ -133,7 +133,13 @@ function HighlightedCodeText(props: {
133133 }
134134 }
135135 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 >
137143 {props.highlighted ? lines : props.content}
138144 </MarkdownTextPrimitive>
139145 );
......@@ -174,8 +180,10 @@ function NativeCodeBlock(props: {
174180 justifyContent: "space-between",
175181 }}
176182 >
177 <Text
183 <MarkdownTextPrimitive
178184 selectable
185 selectionColor={props.textStyle.selectionColor}
186 selectionHandleColor={props.textStyle.selectionHandleColor}
179187 style={{
180188 flex: 1,
181189 color: props.textStyle.mutedColor,
......@@ -184,7 +192,7 @@ function NativeCodeBlock(props: {
184192 }}
185193 >
186194 {languageLabel}
187 </Text>
195 </MarkdownTextPrimitive>
188196 <CopyTextButton
189197 accessibilityLabel={`Copy ${languageLabel.toLowerCase()} code`}
190198 text={content}
......@@ -332,8 +340,10 @@ function NativeMarkdownImage(props: {
332340 }}
333341 />
334342 {props.node.alt ? (
335 <Text
343 <MarkdownTextPrimitive
336344 selectable
345 selectionColor={props.textStyle.selectionColor}
346 selectionHandleColor={props.textStyle.selectionHandleColor}
337347 style={{
338348 color: props.textStyle.mutedColor,
339349 fontFamily: props.textStyle.fontFamily,
......@@ -342,7 +352,7 @@ function NativeMarkdownImage(props: {
342352 }}
343353 >
344354 {props.node.alt}
345 </Text>
355 </MarkdownTextPrimitive>
346356 ) : null}
347357 </View>
348358 );
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx+2
......@@ -338,6 +338,8 @@ export function NativeMarkdownSelectableText(props: {
338338 }
339339 uiTextView
340340 selectable
341 selectionColor={props.textStyle.selectionColor}
342 selectionHandleColor={props.textStyle.selectionHandleColor}
341343 style={{
342344 flexShrink: 1,
343345 minWidth: 0,
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts+2
......@@ -1,4 +1,6 @@
11export interface NativeMarkdownTextStyle {
2 readonly selectionColor?: string;
3 readonly selectionHandleColor?: string;
24 readonly color: string;
35 readonly strongColor: string;
46 readonly mutedColor: string;
apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts+5
......@@ -1,6 +1,7 @@
11import { requireOptionalNativeModule } from "expo";
22
33interface T3MarkdownTextSelectionNativeModule {
4 readonly setSelectionHandleColor?: (reactTag: number, color: number) => void;
45 readonly installCopySanitizer: (reactTag: number, contextClipboardConfig: string) => void;
56 readonly renderContextChip?: (payloadJson: string) => {
67 readonly uri: string;
......@@ -20,6 +21,10 @@ export function installMarkdownCopySanitizer(reactTag: number, contextClipboardC
2021 nativeModule?.installCopySanitizer(reactTag, contextClipboardConfig);
2122}
2223
24export function setMarkdownSelectionHandleColor(reactTag: number, color: number): void {
25 nativeModule?.setSelectionHandleColor?.(reactTag, color);
26}
27
2328export function renderAndroidContextChip(payloadJson: string) {
2429 return nativeModule?.renderContextChip?.(payloadJson) ?? null;
2530}
apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt+6
......@@ -1,6 +1,7 @@
11package expo.modules.t3nativecontrols
22
33import android.content.Intent
4import android.text.format.DateFormat
45import androidx.core.content.FileProvider
56import expo.modules.kotlin.Promise
67import expo.modules.kotlin.modules.Module
......@@ -15,6 +16,11 @@ class T3NativeControlsModule : Module() {
1516 override fun definition() = ModuleDefinition {
1617 Name("T3NativeControls")
1718
19 Function("is24HourFormat") {
20 val context = appContext.reactContext ?: error("The app is not active.")
21 DateFormat.is24HourFormat(context)
22 }
23
1824 AsyncFunction("openFile") { uri: String, mimeType: String, promise: Promise ->
1925 check(filePreviewPromise == null) { "A document viewer is already open." }
2026 val activity = appContext.currentActivity ?: error("The app is not active.")
apps/mobile/src/App.tsx+3-7
......@@ -1,8 +1,7 @@
1import { BlurTargetView } from "expo-blur";
21import * as Linking from "expo-linking";
32import * as SplashScreen from "expo-splash-screen";
43import { useEffect } from "react";
5import { StatusBar } from "react-native";
4import { StatusBar, View } from "react-native";
65import { GestureHandlerRootView } from "react-native-gesture-handler";
76import { KeyboardProvider } from "react-native-keyboard-controller";
87import { SafeAreaProvider } from "react-native-safe-area-context";
......@@ -21,10 +20,8 @@ import {
2120import { RootStack } from "./Stack";
2221import { appAtomRegistry } from "./state/atom-registry";
2322import { OverlayPortalHost } from "./components/OverlayPortal";
24import { appBlurTargetRef } from "./lib/appBlurTarget";
2523import { shouldHandleAppLink } from "./lib/appLinking";
2624import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";
27
2825import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";
2926
3027import "../global.css";
......@@ -88,14 +85,13 @@ function AppContent() {
8885 this, React Navigation defaults to its light theme and every native
8986 header (glass buttons, title, materials) is forced light even when
9087 the system is in dark mode. */}
91 {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */}
92 <BlurTargetView ref={appBlurTargetRef} style={{ flex: 1 }}>
88 <View style={{ flex: 1 }}>
9389 <IncomingShareProvider>
9490 <Navigation linking={appLinking} theme={navigationTheme} />
9591 </IncomingShareProvider>
9692 <ConfirmDialogHost />
9793 <ThreadArrangementHost />
98 </BlurTargetView>
94 </View>
9995 {/* Anchored-menu overlays render here — in-window, so the
10096 keyboard stays up while a dropdown is open. */}
10197 <OverlayPortalHost />
apps/mobile/src/components/AndroidAnchoredMenu.tsx+36-105
......@@ -6,12 +6,8 @@ import { BackHandler, Pressable, ScrollView, View } from "react-native";
66import { useKeyboardState } from "react-native-keyboard-controller";
77import 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";
139import { OverlayPortal } from "./OverlayPortal";
14import { GlassBackdrop } from "./GlassBackdrop";
10import { MaterialMenuPopup } from "./MaterialMenuPopup";
1511
1612const MENU_WIDTH = 250;
1713const SCREEN_MARGIN = 12;
......@@ -27,6 +23,7 @@ type AnchorSnapshot = {
2723 readonly y: number;
2824 readonly width: number;
2925 readonly height: number;
26 readonly keyboardWasVisible: boolean;
3027};
3128
3229type OverlayFrame = {
......@@ -53,13 +50,9 @@ export type AndroidAnchoredMenuProps = {
5350};
5451
5552/**
56 * Token-styled anchored dropdown for Android, drop-in for the subset of the
57 * MenuView contract the app uses (actions with state/subtitle/image/
58 * attributes, one level of subactions). The native AppCompat PopupMenu caps
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.
53 * Adapts the app's MenuView actions to Material dropdowns on Android. Editor
54 * menus render native Material rows in-window to retain keyboard focus; other
55 * menus use the native popup for placement, animation and dismissal.
6356 */
6457export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
6558 const [anchor, setAnchor] = useState<AnchorSnapshot | null>(null);
......@@ -89,9 +82,9 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
8982
9083 const open = useCallback(() => {
9184 anchorRef.current?.measureInWindow((x, y, width, height) => {
92 setAnchor({ x, y, width, height });
85 setAnchor({ x, y, width, height, keyboardWasVisible: keyboardVisible });
9386 });
94 }, []);
87 }, [keyboardVisible]);
9588
9689 const measureOverlay = useCallback(() => {
9790 overlayRef.current?.measureInWindow((x, y, width, height) => {
......@@ -100,18 +93,11 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
10093 });
10194 }, []);
10295
103 // The dropdown renders in-window (no Modal takes focus), so the hardware
104 // back gesture needs explicit handling while it is open. Back steps out of
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.
96 // The native popup owns back dismissal. In-window menus need a handler;
97 // back returns to the parent submenu before closing the overlay.
11298 const submenuDepth = path.length;
11399 useEffect(() => {
114 if (anchor === null) {
100 if (anchor === null || !anchor.keyboardWasVisible) {
115101 return;
116102 }
117103 const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
......@@ -212,10 +198,20 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
212198 onLayout={measureOverlay}
213199 >
214200 <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 ) : (
216212 <Animated.View
217213 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"
219215 style={{
220216 left,
221217 maxHeight,
......@@ -224,93 +220,28 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
224220 : { bottom: (rootHeight ?? 0) - local.y + ANCHOR_GAP }),
225221 }}
226222 >
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
228226 {/* keyboardShouldPersistTaps: the menu often opens over an
229227 active editor; the first item tap must act, not just
230228 dismiss the keyboard. */}
231229 <ScrollView
230 contentContainerClassName="py-2"
232231 bounces={false}
233232 keyboardShouldPersistTaps="always"
234233 showsVerticalScrollIndicator={false}
235234 >
236 {parent !== null ? (
237 // Muted parent title as the submenu header; tapping it
238 // steps back, but it reads as a label, not a button.
239 <Pressable
240 className="px-3.5 pb-1 pt-2.5"
241 onPress={() => setPath((current) => current.slice(0, -1))}
242 >
243 <Text className="text-xs font-t3-bold text-foreground-muted">
244 {parent.title}
245 </Text>
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 })}
235 <MaterialMenuPopup
236 inline
237 anchor={local}
238 actions={levelActions}
239 title={props.title}
240 parent={parent}
241 onPress={onPressItem}
242 onBack={() => setPath((current) => current.slice(0, -1))}
243 onClose={close}
244 />
314245 </ScrollView>
315246 </Animated.View>
316247 )}
apps/mobile/src/components/AndroidScreenHeader.tsx+57-39
......@@ -1,16 +1,21 @@
1import type { ReactNode } from "react";
2import { Pressable, View } from "react-native";
1import { useState, type ReactNode } from "react";
2import { View } from "react-native";
33import { useSafeAreaInsets } from "react-native-safe-area-context";
44
5import { SymbolView, type AppSymbolName } from "./AppSymbol";
5import type { AppSymbolName } from "./AppSymbol";
66import { AppText as Text } from "./AppText";
77import { cn } from "../lib/cn";
8import { MaterialIconButton } from "./MaterialIconButton";
9import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";
10import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
11import { useMaterialToolbarHeight } from "./useMaterialToolbarHeight";
812
913export interface AndroidHeaderAction {
1014 readonly accessibilityLabel: string;
1115 readonly icon: AppSymbolName;
1216 readonly onPress: () => void;
1317 readonly disabled?: boolean;
18 readonly selected?: boolean;
1419}
1520
1621export function AndroidHeaderIconButton(props: {
......@@ -18,73 +23,62 @@ export function AndroidHeaderIconButton(props: {
1823 readonly icon: AppSymbolName;
1924 readonly onPress?: () => void;
2025 readonly disabled?: boolean;
26 readonly selected?: boolean;
2127}) {
22 return (
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 );
28 return <MaterialIconButton {...props} variant={props.selected ? "tonal" : "standard"} />;
4229}
4330
4431export function AndroidScreenHeader(props: {
4532 readonly title: string;
4633 readonly subtitle?: string | null;
4734 readonly actions?: ReadonlyArray<AndroidHeaderAction>;
35 readonly leading?: ReactNode;
4836 readonly trailing?: ReactNode;
4937 readonly onBack?: () => void;
5038 readonly embedded?: boolean;
5139 readonly hideBottomBorder?: boolean;
5240}) {
5341 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
5551 return (
5652 <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"
5855 style={{
5956 paddingTop: props.embedded ? 8 : Math.max(insets.top, 12),
6057 borderBottomWidth: props.hideBottomBorder ? 0 : undefined,
6158 }}
6259 >
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 >
6464 {props.onBack ? (
65 <Pressable
65 <MaterialIconButton
6666 accessibilityLabel="Navigate up"
67 accessibilityRole="button"
68 hitSlop={8}
67 icon="arrow.left"
6968 onPress={props.onBack}
70 className="-mr-2 size-11 items-center justify-center"
71 >
72 <SymbolView
73 name="chevron.left"
74 size={24}
75 tintColorClassName={"accent-foreground"}
76 type="monochrome"
77 />
78 </Pressable>
69 />
7970 ) : null}
8071
72 {props.leading}
73
8174 <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">
8376 {props.title}
8477 </Text>
8578 {props.subtitle ? (
8679 <Text
8780 numberOfLines={1}
81 style={subtitleTypography}
8882 className="mt-px text-[13px] font-t3-medium text-foreground-muted"
8983 >
9084 {props.subtitle}
......@@ -92,15 +86,39 @@ export function AndroidScreenHeader(props: {
9286 ) : null}
9387 </View>
9488
95 {props.actions?.map((action) => (
89 {visibleActions.map((action) => (
9690 <AndroidHeaderIconButton
9791 key={action.accessibilityLabel}
9892 accessibilityLabel={action.accessibilityLabel}
9993 disabled={action.disabled}
94 selected={action.selected}
10095 icon={action.icon}
10196 onPress={action.onPress}
10297 />
10398 ))}
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}
104122 {props.trailing}
105123 </View>
106124 </View>
apps/mobile/src/components/AppSymbol.tsx+9
......@@ -9,6 +9,7 @@ import IconAlertTriangle from "@tabler/icons-react-native/IconAlertTriangle";
99import IconApps from "@tabler/icons-react-native/IconApps";
1010import IconArchive from "@tabler/icons-react-native/IconArchive";
1111import IconArrowBackUp from "@tabler/icons-react-native/IconArrowBackUp";
12import IconArrowLeft from "@tabler/icons-react-native/IconArrowLeft";
1213import IconArrowDownCircle from "@tabler/icons-react-native/IconArrowDownCircle";
1314import IconArrowRightCircle from "@tabler/icons-react-native/IconArrowRightCircle";
1415import IconArrowUp from "@tabler/icons-react-native/IconArrowUp";
......@@ -36,8 +37,10 @@ import IconClock from "@tabler/icons-react-native/IconClock";
3637import IconCode from "@tabler/icons-react-native/IconCode";
3738import IconCopy from "@tabler/icons-react-native/IconCopy";
3839import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop";
40import IconDatabase from "@tabler/icons-react-native/IconDatabase";
3941import IconDeviceLaptop from "@tabler/icons-react-native/IconDeviceLaptop";
4042import IconDots from "@tabler/icons-react-native/IconDots";
43import IconDotsVertical from "@tabler/icons-react-native/IconDotsVertical";
4144import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal";
4245import IconEdit from "@tabler/icons-react-native/IconEdit";
4346import IconExternalLink from "@tabler/icons-react-native/IconExternalLink";
......@@ -97,6 +100,7 @@ import { withUniwind } from "uniwind";
97100
98101const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
99102 "arrow.branch": IconGitBranch,
103 "arrow.left": IconArrowLeft,
100104 "arrow.clockwise": IconRefresh,
101105 "arrow.down.circle": IconArrowDownCircle,
102106 "arrow.right.circle": IconArrowRightCircle,
......@@ -142,7 +146,10 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
142146 "folder.badge.plus": IconFolderPlus,
143147 "folder.fill": IconFolder,
144148 gearshape: IconSettings,
149 hammer: IconHammer,
145150 "info.circle": IconInfoCircle,
151 internaldrive: IconDatabase,
152 keyboard: IconKeyboard,
146153 laptopcomputer: IconDeviceLaptop,
147154 link: IconLink,
148155 "line.3.horizontal": IconMenu2,
......@@ -160,6 +167,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
160167 "pin.slash": IconPinnedOff,
161168 play: IconPlayerPlay,
162169 plus: IconPlus,
170 minus: IconMinus,
163171 "qrcode.viewfinder": IconQrcode,
164172 "point.3.connected.trianglepath.dotted": IconNetwork,
165173 "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge,
......@@ -209,6 +217,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME: Record<string, Icon> = {
209217 keyboard_arrow_down: IconChevronDown,
210218 keyboard_arrow_up: IconChevronUp,
211219 keyboard_hide: IconKeyboardHide,
220 more_vert: IconDotsVertical,
212221 public: IconWorld,
213222 remove: IconMinus,
214223 terminal: IconTerminal2,
apps/mobile/src/components/AppText.tsx+15-3
......@@ -1,4 +1,5 @@
11import {
2 Platform,
23 Text as RNText,
34 TextInput as RNTextInput,
45 type TextInputProps as RNTextInputProps,
......@@ -14,7 +15,13 @@ export type AppTextProps = RNTextProps & { readonly className?: string };
1415 * Uses Uniwind className — no manual style parsing.
1516 */
1617export 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 );
1825}
1926
2027export type AppTextInputProps = Omit<RNTextInputProps, "placeholderTextColor"> & {
......@@ -35,8 +42,13 @@ export function AppTextInput({ className, ref, ...props }: AppTextInputProps) {
3542 className,
3643 )}
3744 placeholderTextColorClassName="accent-placeholder"
38 selectionColorClassName="accent-foreground-secondary"
39 cursorColorClassName="accent-foreground-secondary"
45 selectionColorClassName={
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}
4052 {...props}
4153 />
4254 );
apps/mobile/src/components/ConfirmDialogHost.tsx+30-11
......@@ -1,8 +1,9 @@
11import { useCallback, useEffect, useState } from "react";
2import { Modal, Pressable, TextInput, View } from "react-native";
2import { Platform, Modal, Pressable, TextInput, View } from "react-native";
33
44import { cn } from "../lib/cn";
55import { AppText } from "./AppText";
6import { MaterialConfirmDialog } from "./MaterialConfirmDialog";
67
78export type ConfirmDialogRequest = {
89 readonly title: string;
......@@ -67,17 +68,35 @@ export function ConfirmDialogHost() {
6768 setPresented(null);
6869 }, [presented]);
6970
70 const handleConfirm = useCallback(() => {
71 if (presented?.kind === "confirm") {
72 presented.request.onConfirm();
73 } else if (presented?.kind === "text-input") {
74 presented.request.onConfirm(inputValue);
75 }
76 setPresented(null);
77 }, [inputValue, presented]);
71 const handleConfirm = useCallback(
72 (nativeInputValue?: string) => {
73 if (presented?.kind === "confirm") {
74 presented.request.onConfirm();
75 } else if (presented?.kind === "text-input") {
76 presented.request.onConfirm(nativeInputValue ?? inputValue);
77 }
78 setPresented(null);
79 },
80 [inputValue, presented],
81 );
7882
7983 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
81100 return (
82101 <Modal
83102 visible={presented !== null}
......@@ -102,7 +121,7 @@ export function ConfirmDialogHost() {
102121 autoFocus
103122 className="mt-4 rounded-xl border border-border bg-screen px-3 py-2.5 text-base text-foreground"
104123 onChangeText={setInputValue}
105 onSubmitEditing={confirmDisabled ? undefined : handleConfirm}
124 onSubmitEditing={confirmDisabled ? undefined : () => handleConfirm()}
106125 returnKeyType="done"
107126 selectTextOnFocus
108127 value={inputValue}
......@@ -125,7 +144,7 @@ export function ConfirmDialogHost() {
125144 accessibilityRole="button"
126145 disabled={confirmDisabled}
127146 className="min-h-10 items-center justify-center px-4 active:bg-subtle"
128 onPress={handleConfirm}
147 onPress={() => handleConfirm()}
129148 >
130149 <AppText
131150 className={cn(
apps/mobile/src/components/ControlPill.tsx+45-5
......@@ -19,12 +19,13 @@ import {
1919} from "react-native";
2020import { withUniwind } from "uniwind";
2121import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
22
2322import { cn } from "../lib/cn";
2423import { withMenuActionIconColors } from "../lib/menu-action-colors";
2524import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";
2625import { SymbolView } from "./AppSymbol";
2726import { AppText as Text } from "./AppText";
27import { MaterialIconButton } from "./MaterialIconButton";
28import { MaterialButton } from "./MaterialButton";
2829
2930const ThemedMenuView = withUniwind(
3031 function NativeMenuView({
......@@ -121,6 +122,47 @@ export function ControlPill(props: {
121122 : "",
122123 );
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
124166 return (
125167 <Pressable
126168 accessibilityLabel={props.accessibilityLabel ?? props.label}
......@@ -146,10 +188,8 @@ export function ControlPill(props: {
146188 );
147189}
148190
149// iOS renders the native UIMenu (standard checkmark for `state: "on"`);
150// Android renders the token-styled AndroidAnchoredMenu, since the native
151// AppCompat popup can't be themed past its stock animation, metrics, and
152// submenu chrome.
191// iOS renders UIMenu; AndroidAnchoredMenu adapts the same actions to the
192// selected Android appearance and keeps editor menus above the keyboard.
153193export function ControlPillMenu(
154194 props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> &
155195 Pick<AccessibilityProps, "accessible" | "accessibilityLabel" | "accessibilityRole"> & {
apps/mobile/src/components/EmptyState.tsx+8-2
......@@ -1,4 +1,5 @@
11import { Pressable, View } from "react-native";
2import type { ReactNode } from "react";
23
34import { AppText as Text } from "./AppText";
45
......@@ -7,6 +8,7 @@ export function EmptyState(props: {
78 readonly detail: string;
89 readonly actionLabel?: string;
910 readonly onAction?: () => void;
11 readonly action?: ReactNode;
1012 readonly variant?: "card" | "plain";
1113}) {
1214 if (props.variant === "plain") {
......@@ -16,7 +18,9 @@ export function EmptyState(props: {
1618 <Text className="mt-2 text-center font-sans text-base leading-normal text-foreground-muted">
1719 {props.detail}
1820 </Text>
19 {props.actionLabel && props.onAction ? (
21 {props.action ? (
22 <View className="mt-5">{props.action}</View>
23 ) : props.actionLabel && props.onAction ? (
2024 <Pressable
2125 className="mt-5 rounded-full bg-primary px-5 py-3 active:opacity-70"
2226 onPress={props.onAction}
......@@ -36,7 +40,9 @@ export function EmptyState(props: {
3640 <Text className="mt-2 font-sans text-sm leading-relaxed text-foreground-muted">
3741 {props.detail}
3842 </Text>
39 {props.actionLabel && props.onAction ? (
43 {props.action ? (
44 <View className="mt-4 self-start">{props.action}</View>
45 ) : props.actionLabel && props.onAction ? (
4046 <Pressable
4147 className="mt-4 self-start rounded-full bg-primary px-4 py-2.5 active:opacity-70"
4248 onPress={props.onAction}
apps/mobile/src/components/GlassBackdrop.tsx+2-19
......@@ -1,22 +1,13 @@
11import { BlurView } from "expo-blur";
2import { useContext, type RefObject } from "react";
32import { Platform, StyleSheet, View, type ColorValue } from "react-native";
43
54import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
6import { GlassBlurTargetContext } from "../lib/glassBlurTarget";
75import { themeColorWithAlpha } from "../lib/mobileTheme";
86
97/** Frosted backdrop for containers that clip their children to their shape. */
10export function GlassBackdrop(props: {
11 readonly fallbackColor?: ColorValue;
12 readonly blurTarget?: RefObject<View | null>;
13}) {
8export function GlassBackdrop(props: { readonly fallbackColor?: ColorValue }) {
149 const { themeAppearance } = useAppearancePreferences();
15 const inheritedBlurTarget = useContext(GlassBlurTargetContext);
16 const target = props.blurTarget ?? inheritedBlurTarget;
17 const supportsBlur =
18 Platform.OS === "ios" ||
19 (Platform.OS === "android" && Platform.Version >= 31 && target !== undefined);
10 const supportsBlur = Platform.OS === "ios";
2011 const colorStyle =
2112 props.fallbackColor === undefined
2213 ? undefined
......@@ -24,17 +15,9 @@ export function GlassBackdrop(props: {
2415
2516 return (
2617 <>
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}
3318 {supportsBlur ? (
3419 <BlurView
3520 pointerEvents="none"
36 blurTarget={target}
37 blurMethod="dimezisBlurViewSdk31Plus"
3821 intensity={80}
3922 tint={themeAppearance === "dark" ? "dark" : "default"}
4023 style={StyleSheet.absoluteFill}
apps/mobile/src/components/GlassSurface.tsx+8-18
......@@ -1,5 +1,5 @@
11import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect";
2import type { ReactNode, Ref, RefObject } from "react";
2import type { ReactNode, Ref } from "react";
33import {
44 Platform,
55 useColorScheme,
......@@ -28,7 +28,6 @@ interface GlassSurfaceProps extends ViewProps {
2828 readonly chrome?: "default" | "none";
2929 /** Base color for the frosted tint, or solid fill when blur is unavailable. */
3030 readonly fallbackColor?: ColorValue;
31 readonly blurTarget?: RefObject<View | null>;
3231 /** Uniwind styling used only when native Liquid Glass is unavailable. */
3332 readonly fallbackClassName?: string;
3433}
......@@ -41,7 +40,6 @@ export function GlassSurface({
4140 tintColor,
4241 tintColorClassName,
4342 fallbackColor,
44 blurTarget,
4543 fallbackClassName,
4644 className,
4745 style,
......@@ -49,23 +47,15 @@ export function GlassSurface({
4947}: GlassSurfaceProps) {
5048 const isDarkMode = useColorScheme() === "dark";
5149 const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable();
50 const hasShadow = chrome !== "none" && Platform.OS !== "android";
5251 const surfaceStyle: ViewStyle = {
5352 borderRadius: 32,
5453 overflow: "hidden",
55 shadowColor: chrome === "none" ? "transparent" : "#000000",
56 shadowOpacity: chrome === "none" ? 0 : isDarkMode ? 0.22 : 0.08,
57 shadowRadius: chrome === "none" ? 0 : 28,
58 shadowOffset:
59 chrome === "none"
60 ? {
61 width: 0,
62 height: 0,
63 }
64 : {
65 width: 0,
66 height: 14,
67 },
68 elevation: chrome === "none" ? 0 : 12,
54 shadowColor: hasShadow ? "#000000" : "transparent",
55 shadowOpacity: hasShadow ? (isDarkMode ? 0.22 : 0.08) : 0,
56 shadowRadius: hasShadow ? 28 : 0,
57 shadowOffset: { width: 0, height: hasShadow ? 14 : 0 },
58 elevation: hasShadow ? 12 : 0,
6959 };
7060
7161 if (supportsGlass) {
......@@ -103,7 +93,7 @@ export function GlassSurface({
10393 )}
10494 style={[surfaceStyle, style]}
10595 >
106 <GlassBackdrop blurTarget={blurTarget} fallbackColor={fallbackColor} />
96 <GlassBackdrop fallbackColor={fallbackColor} />
10797 {children}
10898 </View>
10999 );
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";
22import Animated, { Easing, LinearTransition, ReduceMotion } from "react-native-reanimated";
33import { AppText as Text } from "./AppText";
44import { cn } from "../lib/cn";
5import { MaterialSegmentedControl } from "./MaterialSegmentedControl";
56
6export function SegmentedControl<Value extends number | string>(props: {
7export interface SegmentedControlProps<Value extends number | string> {
78 readonly options: readonly {
89 readonly value: Value;
910 readonly label: string;
......@@ -11,18 +12,26 @@ export function SegmentedControl<Value extends number | string>(props: {
1112 }[];
1213 readonly selected: Value;
1314 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. */
1516 readonly size?: "default" | "compact";
1617 /** "tab" for the view switcher; filters stay plain buttons. */
1718 readonly role?: "tab" | "button";
1819 readonly className?: string;
19}) {
20}
21
22export function SegmentedControl<Value extends number | string>(
23 props: SegmentedControlProps<Value>,
24) {
2025 const compact = props.size === "compact";
26 if (Platform.OS === "android") {
27 return <MaterialSegmentedControl {...props} />;
28 }
2129 return (
2230 <View
2331 accessible={false}
2432 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",
2635 props.className,
2736 )}
2837 >
......@@ -31,7 +40,7 @@ export function SegmentedControl<Value extends number | string>(props: {
3140 layout={LinearTransition.duration(200)
3241 .easing(Easing.out(Easing.cubic))
3342 .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"
3544 style={{
3645 width: `${100 / props.options.length}%`,
3746 start: `${
apps/mobile/src/components/ThemedSwitch.tsx+16-52
......@@ -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";
4import { SymbolView } from "./AppSymbol";
3import { MaterialSwitch } from "./MaterialSwitch";
54
6export function ThemedSwitch(props: SwitchProps) {
7 const { materialYouStyleLayoutActive } = useAppearancePreferences();
8 if (materialYouStyleLayoutActive) {
9 return (
10 <Pressable
11 accessibilityHint={props.accessibilityHint}
12 accessibilityLabel={props.accessibilityLabel}
13 accessibilityRole="switch"
14 accessibilityState={{ checked: Boolean(props.value), disabled: props.disabled }}
15 disabled={props.disabled}
16 hitSlop={8}
17 onPress={() => props.onValueChange?.(!props.value)}
18 style={props.style}
19 testID={props.testID}
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 );
5export type ThemedSwitchProps = Pick<
6 SwitchProps,
7 | "accessibilityHint"
8 | "accessibilityLabel"
9 | "disabled"
10 | "onValueChange"
11 | "style"
12 | "testID"
13 | "value"
14>;
15
16export function ThemedSwitch(props: ThemedSwitchProps) {
17 if (Platform.OS === "android") {
18 return <MaterialSwitch {...props} />;
4819 }
4920
5021 return (
5122 <Switch
5223 {...props}
5324 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 }
6125 trackColorOffClassName="accent-switch-inactive-track"
6226 trackColorOnClassName="accent-switch-active-track"
6327 />
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 {
4040 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
4141} from "../layout/native-mail-search-toolbar";
4242import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";
43import { SettingsScreenContent } from "../settings/components/SettingsScreen";
4344
4445export interface ArchivedThreadsHeaderEnvironment {
4546 readonly environmentId: EnvironmentId;
......@@ -146,6 +147,7 @@ function ArchivedThreadsHeader(props: {
146147 className="border-b border-header-border bg-header px-3 pb-2.5"
147148 style={{
148149 paddingTop: Math.max(insets.top, 12),
150 borderBottomWidth: 0,
149151 }}
150152 >
151153 <View className="min-h-12 flex-row items-center gap-2">
......@@ -159,7 +161,7 @@ function ArchivedThreadsHeader(props: {
159161 <SymbolView
160162 name="chevron.left"
161163 size={24}
162 tintColorClassName={"accent-foreground"}
164 tintColorClassName="accent-foreground"
163165 type="monochrome"
164166 />
165167 </Pressable>
......@@ -167,7 +169,7 @@ function ArchivedThreadsHeader(props: {
167169 <SymbolView
168170 name="magnifyingglass"
169171 size={17}
170 tintColorClassName={"accent-icon"}
172 tintColorClassName="accent-icon"
171173 type="monochrome"
172174 />
173175 <TextInput
......@@ -197,7 +199,7 @@ function ArchivedThreadsHeader(props: {
197199 : "line.3.horizontal.decrease.circle"
198200 }
199201 size={16}
200 tintColorClassName={"accent-icon"}
202 tintColorClassName="accent-icon"
201203 type="monochrome"
202204 />
203205 </Pressable>
......@@ -455,7 +457,7 @@ function ArchivedThreadRow(props: {
455457 <SymbolView
456458 name="archivebox.fill"
457459 size={15}
458 tintColorClassName={"accent-icon-subtle"}
460 tintColorClassName="accent-icon-subtle"
459461 type="monochrome"
460462 />
461463 </View>
......@@ -477,7 +479,7 @@ function ArchivedThreadRow(props: {
477479 <SymbolView
478480 name="arrow.triangle.branch"
479481 size={10}
480 tintColorClassName={"accent-icon-subtle"}
482 tintColorClassName="accent-icon-subtle"
481483 type="monochrome"
482484 />
483485 <Text
......@@ -615,7 +617,7 @@ export function ArchivedThreadsScreen(props: {
615617 if (isInitialLoad) {
616618 return (
617619 <View className="items-center py-16">
618 <ActivityIndicator colorClassName={"accent-icon"} />
620 <ActivityIndicator colorClassName="accent-icon" />
619621 <Text className="mt-3 text-sm text-foreground-muted">Loading archive...</Text>
620622 </View>
621623 );
......@@ -646,37 +648,39 @@ export function ArchivedThreadsScreen(props: {
646648 sortOrder={props.sortOrder}
647649 />
648650
649 <GestureDetector gesture={archiveScrollGesture}>
650 <LegendList
651 className="flex-1"
652 contentContainerStyle={{
653 paddingBottom: 32,
654 paddingHorizontal: 16,
655 paddingTop: 4,
656 }}
657 contentInsetAdjustmentBehavior="automatic"
658 data={listItems}
659 estimatedItemSize={62}
660 getItemType={(item) => item.kind}
661 keyboardDismissMode="on-drag"
662 keyboardShouldPersistTaps="handled"
663 keyExtractor={(item) => item.key}
664 ListEmptyComponent={listEmptyComponent}
665 ListHeaderComponent={
666 props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null
667 }
668 onScrollBeginDrag={() => openSwipeableRef.current?.close()}
669 refreshControl={
670 <RefreshControl
671 onRefresh={props.onRefresh}
672 refreshing={props.isLoading && !isInitialLoad}
673 tintColorClassName={String("accent-icon")}
674 />
675 }
676 renderItem={renderListItem}
677 showsVerticalScrollIndicator={false}
678 />
679 </GestureDetector>
651 <SettingsScreenContent>
652 <GestureDetector gesture={archiveScrollGesture}>
653 <LegendList
654 className="flex-1"
655 contentContainerStyle={{
656 paddingBottom: 32,
657 paddingHorizontal: 16,
658 paddingTop: 4,
659 }}
660 contentInsetAdjustmentBehavior="automatic"
661 data={listItems}
662 estimatedItemSize={62}
663 getItemType={(item) => item.kind}
664 keyboardDismissMode="on-drag"
665 keyboardShouldPersistTaps="handled"
666 keyExtractor={(item) => item.key}
667 ListEmptyComponent={listEmptyComponent}
668 ListHeaderComponent={
669 props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null
670 }
671 onScrollBeginDrag={() => openSwipeableRef.current?.close()}
672 refreshControl={
673 <RefreshControl
674 onRefresh={props.onRefresh}
675 refreshing={props.isLoading && !isInitialLoad}
676 tintColorClassName={String("accent-icon")}
677 />
678 }
679 renderItem={renderListItem}
680 showsVerticalScrollIndicator={false}
681 />
682 </GestureDetector>
683 </SettingsScreenContent>
680684 </View>
681685 );
682686}
apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx+2-1
......@@ -1,8 +1,9 @@
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
12import { NativeHeaderToolbar } from "../../native/StackHeader";
23import { useAuth } from "@clerk/expo";
34import { StackActions, useNavigation } from "@react-navigation/native";
45import { useCallback, useEffect, useState } from "react";
5import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
6import { Platform, Pressable, RefreshControl, View } from "react-native";
67import { useSafeAreaInsets } from "react-native-safe-area-context";
78
89import { 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";
66import * as Cause from "effect/Cause";
77import { AsyncResult } from "effect/unstable/reactivity";
88import { useCallback, useState } from "react";
9import { Alert, Pressable, View } from "react-native";
9import { Platform, Alert, Pressable, View } from "react-native";
1010import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
1111
1212import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
1313import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
14import { MaterialButton } from "../../components/MaterialButton";
15import { MaterialIconButton } from "../../components/MaterialIconButton";
1416import { ThemedSwitch } from "../../components/ThemedSwitch";
1517import { cn } from "../../lib/cn";
1618import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
......@@ -142,7 +144,7 @@ export function ConnectionEnvironmentRow(props: {
142144 <SymbolView
143145 name="chevron.down"
144146 size={12}
145 tintColorClassName={"accent-icon-subtle"}
147 tintColorClassName="accent-icon-subtle"
146148 type="monochrome"
147149 style={{
148150 transform: [{ rotate: props.expanded ? "180deg" : "0deg" }],
......@@ -193,49 +195,79 @@ export function ConnectionEnvironmentRow(props: {
193195 </>
194196 )}
195197
196 <View className="flex-row justify-end gap-2">
197 {props.environment.isRelayManaged ? null : (
198 {Platform.OS === "android" ? (
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
198245 <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"
200 onPress={handleSave}
246 className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-input-border bg-input active:opacity-70 disabled:opacity-40"
247 disabled={!enabled}
248 onPress={() => props.onReconnect(props.environment.environmentId)}
201249 >
202250 <SymbolView
203 name="checkmark"
204 size={13}
205 tintColorClassName={"accent-primary-foreground"}
251 name="arrow.clockwise"
252 size={14}
253 tintColorClassName="accent-icon-subtle"
206254 type="monochrome"
207255 />
208 <Text className="text-xs font-t3-bold tracking-[0.8px] uppercase text-primary-foreground">
209 Save
210 </Text>
211256 </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 <Pressable
228 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)}
230 >
231 <SymbolView
232 name="trash"
233 size={14}
234 tintColorClassName={"accent-danger-foreground"}
235 type="monochrome"
236 />
237 </Pressable>
238 </View>
258 <Pressable
259 className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-danger-border bg-danger active:opacity-70"
260 onPress={() => props.onRemove(props.environment.environmentId)}
261 >
262 <SymbolView
263 name="trash"
264 size={14}
265 tintColorClassName="accent-danger-foreground"
266 type="monochrome"
267 />
268 </Pressable>
269 </View>
270 )}
239271 </Animated.View>
240272 ) : null}
241273 </Animated.View>
apps/mobile/src/features/connection/ConnectionSheetButton.tsx+12
......@@ -3,6 +3,7 @@ import { Platform, Pressable } from "react-native";
33
44import { AppText as Text } from "../../components/AppText";
55import { cn } from "../../lib/cn";
6import { MaterialButton } from "../../components/MaterialButton";
67
78const CARD_SHADOW = Platform.select({
89 ios: {
......@@ -32,8 +33,19 @@ export function ConnectionSheetButton(props: {
3233 readonly disabled?: boolean;
3334 readonly tone?: "primary" | "secondary" | "danger";
3435 readonly compact?: boolean;
36 readonly fullWidth?: boolean;
3537 readonly onPress: () => void;
3638}) {
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 );
3749 const tone = props.tone ?? "secondary";
3850
3951 const textColorClassName =
apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx+42-40
......@@ -1,13 +1,18 @@
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
12import { CameraView, useCameraPermissions } from "expo-camera";
23import { 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";
410import { AsyncResult } from "effect/unstable/reactivity";
511import { useCallback, useEffect, useRef, useState } from "react";
6import { Alert, Linking, Platform, ScrollView, View } from "react-native";
12import { Alert, Linking, Platform, View } from "react-native";
713import { useSafeAreaInsets } from "react-native-safe-area-context";
814import { useUniwindTheme } from "../../lib/useUniwindTheme";
9
10import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
15import { SettingsScreen } from "../settings/components/SettingsScreen";
1116import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
1217import { ErrorBanner } from "../../components/ErrorBanner";
1318import { ConnectionSheetButton } from "./ConnectionSheetButton";
......@@ -30,6 +35,7 @@ export function ConnectionsNewRouteScreen({
3035 pairingConnectionError,
3136 } = useRemoteConnections();
3237 const navigation = useNavigation();
38 const routeName = useRoute().name;
3339 const params = route.params ?? {};
3440 // Deep-link prefill exists for development automation only. A production
3541 // link must not arrive with attacker-chosen host and token already filled.
......@@ -181,33 +187,27 @@ export function ConnectionsNewRouteScreen({
181187 }, [connectAndClose, routePairingUrl, shouldAutoConnect]);
182188
183189 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 >
185207 <NativeStackScreenOptions
186 options={{
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 }}
208 options={{ title: showScanner ? "Scan QR Code" : "Add Environment" }}
191209 />
192 {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 ) : (
210 {Platform.OS !== "android" ? (
211211 <NativeHeaderToolbar placement="right">
212212 <NativeHeaderToolbar.Button
213213 icon={showScanner ? "xmark" : "qrcode.viewfinder"}
......@@ -222,7 +222,7 @@ export function ConnectionsNewRouteScreen({
222222 tintColor={headerIconColor}
223223 />
224224 </NativeHeaderToolbar>
225 )}
225 ) : null}
226226
227227 <ScrollView
228228 contentInsetAdjustmentBehavior="automatic"
......@@ -293,19 +293,21 @@ export function ConnectionsNewRouteScreen({
293293
294294 {pairingConnectionError ? <ErrorBanner message={pairingConnectionError} /> : null}
295295
296 <ConnectionSheetButton
297 icon="plus"
298 label={isSubmitting ? "Pairing..." : "Add environment"}
299 disabled={connectDisabled}
300 tone="primary"
301 onPress={() => {
302 void handleSubmit();
303 }}
304 />
296 <View className={Platform.OS === "android" ? "flex-row justify-end" : undefined}>
297 <ConnectionSheetButton
298 icon="plus"
299 label={isSubmitting ? "Pairing..." : "Add environment"}
300 disabled={connectDisabled}
301 tone="primary"
302 onPress={() => {
303 void handleSubmit();
304 }}
305 />
306 </View>
305307 </View>
306308 )}
307309 </View>
308310 </ScrollView>
309 </View>
311 </SettingsScreen>
310312 );
311313}
apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx+2-1
......@@ -1,9 +1,10 @@
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
12import { NativeHeaderToolbar } from "../../native/StackHeader";
23import { useNavigation } from "@react-navigation/native";
34import { SymbolView } from "../../components/AppSymbol";
45import type { EnvironmentId } from "@t3tools/contracts";
56import { useCallback, useState } from "react";
6import { Platform, ScrollView, View } from "react-native";
7import { Platform, View } from "react-native";
78import { useSafeAreaInsets } from "react-native-safe-area-context";
89
910import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx+7-5
......@@ -1,12 +1,14 @@
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
12import Constants from "expo-constants";
23import * as Updates from "expo-updates";
34import { useEffect, useState } from "react";
4import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native";
5import { ActivityIndicator, Platform, Pressable, View } from "react-native";
56import { useSafeAreaInsets } from "react-native-safe-area-context";
67
78import { SymbolView } from "../../components/AppSymbol";
89import { AppText as Text } from "../../components/AppText";
910import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
11import { SettingsScreen } from "../settings/components/SettingsScreen";
1012import { SettingsSection } from "../settings/components/SettingsSection";
1113import {
1214 formatStartupCrashReport,
......@@ -71,7 +73,7 @@ export function SettingsDiagnosticsRouteScreen() {
7173 };
7274
7375 return (
74 <View collapsable={false} className="flex-1 bg-sheet">
76 <SettingsScreen title="Diagnostics">
7577 <ScrollView
7678 contentInsetAdjustmentBehavior="automatic"
7779 contentInset={{ bottom: Math.max(insets.bottom, 18) }}
......@@ -115,7 +117,7 @@ export function SettingsDiagnosticsRouteScreen() {
115117 <SymbolView
116118 name={copied ? "checkmark" : "doc.on.doc"}
117119 size={22}
118 tintColorClassName={"accent-icon"}
120 tintColorClassName="accent-icon"
119121 type="monochrome"
120122 weight="regular"
121123 />
......@@ -131,7 +133,7 @@ export function SettingsDiagnosticsRouteScreen() {
131133 </Text>
132134 </View>
133135 </ScrollView>
134 </View>
136 </SettingsScreen>
135137 );
136138}
137139
......@@ -145,7 +147,7 @@ function EmptyState(props: {
145147 <SymbolView
146148 name={props.icon}
147149 size={28}
148 tintColorClassName={"accent-icon"}
150 tintColorClassName="accent-icon"
149151 type="monochrome"
150152 weight="regular"
151153 />
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";
44import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
55import {
66 FlatList,
7 Platform,
78 RefreshControl,
89 ScrollView,
910 Text as NativeText,
......@@ -73,6 +74,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {
7374 </NativeText>
7475 <NativeText
7576 selectable
77 selectionColorClassName={Platform.OS === "android" ? "accent-primary/32" : undefined}
7678 numberOfLines={props.wordBreak ? undefined : 1}
7779 className="flex-1 font-normal text-foreground"
7880 style={{
......@@ -102,6 +104,9 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {
102104 <NativeText
103105 key={`${start}:${token.content.length}:${token.color ?? ""}`}
104106 selectable
107 selectionColorClassName={
108 Platform.OS === "android" ? "accent-primary/32" : undefined
109 }
105110 style={{
106111 color: token.color ?? undefined,
107112 fontFamily: REVIEW_MONO_FONT_FAMILY,
......@@ -274,6 +279,7 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
274279 <MarkdownTextPrimitive
275280 uiTextView
276281 selectable
282 selectionColorClassName={Platform.OS === "android" ? "accent-primary/32" : undefined}
277283 style={{
278284 color: foreground,
279285 fontFamily: REVIEW_MONO_FONT_FAMILY,
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+62-82
......@@ -15,8 +15,8 @@ import {
1515import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
1616
1717import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
18import { SymbolView } from "../../components/AppSymbol";
19import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
18import { MaterialScreenContent } from "../../components/MaterialScreenContent";
19import { AppText as Text } from "../../components/AppText";
2020import { AudioFilePreview } from "../../components/AudioFilePreview";
2121import { ControlPillMenu } from "../../components/ControlPill";
2222import { EmptyState } from "../../components/EmptyState";
......@@ -43,7 +43,10 @@ import {
4343 createNativeMailSearchToolbarItem,
4444 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
4545} from "../layout/native-mail-search-toolbar";
46import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
46import {
47 AndroidWorkspaceSidebarButton,
48 WorkspaceSidebarToolbar,
49} from "../layout/workspace-sidebar-toolbar";
4750import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
4851import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
4952import { FileMarkdownPreview } from "./FileMarkdownPreview";
......@@ -52,6 +55,7 @@ import { useFileTreeEntries } from "./useFileTreeEntries";
5255import { preloadWorkspaceFileContents } from "./preload-workspace-file";
5356import { SourceFileSurface } from "./SourceFileSurface";
5457import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";
58import { MaterialFilesHeader } from "./MaterialFilesHeader";
5559import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";
5660import { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError";
5761import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview";
......@@ -328,10 +332,9 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
328332 useAdaptiveWorkspaceLayout();
329333 const [searchQuery, setSearchQuery] = useState("");
330334 const isAndroid = Platform.OS === "android";
331 const { themeAppearance: highlightTheme, materialYouStyleLayoutActive } =
332 useAppearancePreferences();
335 const { themeAppearance: highlightTheme } = useAppearancePreferences();
333336 const theme = useUniwindTheme();
334 const screenColor = theme["--color-screen"];
337 const headerColor = theme["--color-header"];
335338 const sheetSurfaceColor = theme["--color-sheet-solid"];
336339 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
337340 props.route.params,
......@@ -451,7 +454,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
451454 <NativeStackScreenOptions
452455 options={{
453456 contentStyle: {
454 backgroundColor: materialYouStyleLayoutActive ? screenColor : sheetSurfaceColor,
457 backgroundColor: Platform.OS === "android" ? headerColor : sheetSurfaceColor,
455458 },
456459 headerShown: !isAndroid,
457460 unstable_headerSubtitle:
......@@ -484,46 +487,16 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
484487 />
485488 {isAndroid ? (
486489 <>
487 <AndroidScreenHeader
488 title="Files"
489 subtitle={projectName}
490 onBack={handleReturnToThread}
491 hideBottomBorder={materialYouStyleLayoutActive}
492 actions={[
493 {
494 accessibilityLabel: "Refresh files",
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"
490 {
491 <MaterialFilesHeader
492 projectName={projectName}
493 leading={<AndroidWorkspaceSidebarButton />}
494 searchQuery={searchQuery}
495 onSearchQueryChange={setSearchQuery}
496 onRefresh={entriesQuery.refresh}
497 onBack={handleReturnToThread}
512498 />
513 <TextInput
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>
499 }
527500 </>
528501 ) : (
529502 <>
......@@ -548,26 +521,28 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
548521 )}
549522 </>
550523 )}
551 <FileTreeBrowser
552 key={JSON.stringify([environmentId, cwd])}
553 entries={entriesQuery.entries}
554 loadedDirectories={entriesQuery.loadedDirectories}
555 onLoadDirectory={entriesQuery.loadDirectory}
556 error={entriesQuery.error}
557 isPending={entriesQuery.isPending}
558 searchQuery={searchQuery}
559 searchTruncated={entriesQuery.searchTruncated}
560 selectedPath={null}
561 onPreviewFile={handlePreviewFile}
562 onRefresh={entriesQuery.refresh}
563 onSelectFile={handleSelectFile}
564 />
565 <FilesToolbarBottomFade />
524 <MaterialScreenContent insetHorizontal={layout.usesSplitView}>
525 <FileTreeBrowser
526 key={JSON.stringify([environmentId, cwd])}
527 entries={entriesQuery.entries}
528 loadedDirectories={entriesQuery.loadedDirectories}
529 onLoadDirectory={entriesQuery.loadDirectory}
530 error={entriesQuery.error}
531 isPending={entriesQuery.isPending}
532 searchQuery={searchQuery}
533 searchTruncated={entriesQuery.searchTruncated}
534 selectedPath={null}
535 onPreviewFile={handlePreviewFile}
536 onRefresh={entriesQuery.refresh}
537 onSelectFile={handleSelectFile}
538 />
539 <FilesToolbarBottomFade />
540 </MaterialScreenContent>
566541 </>
567542 );
568543
569 return materialYouStyleLayoutActive ? (
570 <View className="flex-1" style={{ backgroundColor: screenColor }}>
544 return Platform.OS === "android" ? (
545 <View className="flex-1" style={{ backgroundColor: headerColor }}>
571546 {content}
572547 </View>
573548 ) : (
......@@ -933,6 +908,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
933908 <AndroidScreenHeader
934909 title={basename(relativePath)}
935910 subtitle={headerSubtitle}
911 leading={<AndroidWorkspaceSidebarButton />}
912 hideBottomBorder
936913 onBack={handleBack}
937914 trailing={
938915 <>
......@@ -942,6 +919,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
942919 panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator"
943920 }
944921 icon="sidebar.right"
922 selected={panes.auxiliaryPaneVisible}
945923 onPress={toggleAuxiliaryPane}
946924 />
947925 ) : null}
......@@ -1007,25 +985,27 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
1007985 ))}
1008986 </NativeHeaderToolbar.Menu>
1009987 </NativeHeaderToolbar>
1010 <FileContent
1011 key={previewKey}
1012 activeMode={resolvedActiveMode}
1013 cwd={cwd}
1014 environmentId={environmentId}
1015 previewUri={previewUri}
1016 previewFailure={assetPreview._tag === "Failure" ? assetPreview.reason : null}
1017 onRetryPreview={handleRetryPreview}
1018 videoSource={videoSource}
1019 mediaSource={mediaSource}
1020 resolveVideoUri={assetPreview.refresh}
1021 fileContents={fileData?.contents ?? null}
1022 fileError={fileQuery.error}
1023 initialLine={targetLine}
1024 relativePath={relativePath}
1025 threadId={threadId}
1026 truncated={fileData?.truncated ?? false}
1027 onRefresh={() => fileQuery.refresh()}
1028 />
988 <MaterialScreenContent>
989 <FileContent
990 key={previewKey}
991 activeMode={resolvedActiveMode}
992 cwd={cwd}
993 environmentId={environmentId}
994 previewUri={previewUri}
995 previewFailure={assetPreview._tag === "Failure" ? assetPreview.reason : null}
996 onRetryPreview={handleRetryPreview}
997 videoSource={videoSource}
998 mediaSource={mediaSource}
999 resolveVideoUri={assetPreview.refresh}
1000 fileContents={fileData?.contents ?? null}
1001 fileError={fileQuery.error}
1002 initialLine={targetLine}
1003 relativePath={relativePath}
1004 threadId={threadId}
1005 truncated={fileData?.truncated ?? false}
1006 onRefresh={() => fileQuery.refresh()}
1007 />
1008 </MaterialScreenContent>
10291009 <FilePreviewModal
10301010 source={fullScreenPreview}
10311011 onRequestClose={() => setFullScreenPreview(null)}
apps/mobile/src/features/files/thread-file-navigator-pane.tsx+59-48
......@@ -1,5 +1,6 @@
11import type { EnvironmentId } from "@t3tools/contracts";
22import { SymbolView } from "../../components/AppSymbol";
3import { MaterialScreenContent } from "../../components/MaterialScreenContent";
34import { useCallback, useMemo, useState, type ComponentProps } from "react";
45import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native";
56import {
......@@ -11,6 +12,7 @@ import {
1112} from "react-native-screens";
1213
1314import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
15import { MaterialFilesHeader } from "./MaterialFilesHeader";
1416import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
1517import { useUniwindTheme } from "../../lib/useUniwindTheme";
1618import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
......@@ -149,59 +151,68 @@ export function ThreadFileNavigatorPane(props: {
149151 }
150152
151153 return (
152 <View className="flex-1 border-l border-border bg-sheet">
153 <View className="border-b border-border" style={{ paddingTop: props.headerInset }}>
154 <View className="h-12 flex-row items-center gap-2 px-3">
155 <View className="min-w-0 flex-1">
156 <Text className="text-sm font-t3-bold text-foreground">Files</Text>
157 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
158 {props.projectName}
159 </Text>
154 <View
155 className={
156 Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 border-l border-border bg-sheet"
157 }
158 >
159 <View
160 className={Platform.OS === "android" ? "bg-header" : "border-b border-border"}
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>
160192 </View>
161 <Pressable
162 accessibilityRole="button"
163 accessibilityLabel="Refresh files"
164 hitSlop={8}
165 className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"
166 onPress={entriesQuery.refresh}
167 >
193 )}
194 {Platform.OS !== "android" ? (
195 <View className="flex-row items-center gap-2 border-t border-border px-3 py-2">
168196 <SymbolView
169 name="arrow.clockwise"
170 size={14}
171 tintColorClassName={"accent-icon-muted"}
197 name="magnifyingglass"
198 size={15}
199 tintColorClassName="accent-icon-muted"
172200 type="monochrome"
173201 />
174 </Pressable>
175 <Pressable
176 accessibilityRole="button"
177 accessibilityLabel="Close files"
178 hitSlop={8}
179 className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"
180 onPress={toggleAuxiliaryPane}
181 >
182 <SymbolView name="xmark" size={14} tintColorClassName="accent-icon-muted" />
183 </Pressable>
184 </View>
185 <View className="flex-row items-center gap-2 border-t border-border px-3 py-2">
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>
202 <TextInput
203 accessibilityLabel="Search files"
204 autoCapitalize="none"
205 autoCorrect={false}
206 clearButtonMode="while-editing"
207 className="min-h-10 flex-1 rounded-xl py-2 text-sm"
208 placeholder="Search files"
209 value={searchQuery}
210 onChangeText={setSearchQuery}
211 />
212 </View>
213 ) : null}
203214 </View>
204 {fileTree}
215 <MaterialScreenContent insetHorizontal>{fileTree}</MaterialScreenContent>
205216 </View>
206217 );
207218}
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 @@
1import type { ReactNode } from "react";
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}
1export { AndroidHomeFabLayout } from "./AndroidHomeFab.shared";
apps/mobile/src/features/home/HomeHeader.tsx+12-127
......@@ -1,18 +1,11 @@
1import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
21import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts";
32import type { MenuAction } from "@react-native-menu/menu";
4import Constants from "expo-constants";
3
54import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
65import { useCallback, useMemo, useRef } from "react";
7import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native";
6import { Platform } from "react-native";
87import 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";
169import { useUniwindTheme } from "../../lib/useUniwindTheme";
1710import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
1811import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
......@@ -22,7 +15,7 @@ import {
2215 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
2316} from "../layout/native-mail-search-toolbar";
2417import type { HomeProjectSortOrder } from "./homeThreadList";
25import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle";
18import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar";
2619import {
2720 buildHomeListFilterMenu,
2821 type HomeListFilterMenuEnvironment,
......@@ -67,9 +60,6 @@ function checkedMenuState(checked: boolean) {
6760}
6861
6962function AndroidHomeHeader(props: HomeHeaderProps) {
70 const { materialYouStyleLayoutActive } = useAppearancePreferences();
71 const insets = useSafeAreaInsets();
72 const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant);
7363 // Thread List v2 lays the list out in fixed creation order, so the
7464 // sort/group filter controls would be silently ignored — hide them and
7565 // key the "customized" icon state off the environment filter alone.
......@@ -200,120 +190,15 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
200190 return (
201191 <>
202192 <NativeStackScreenOptions options={{ headerShown: false }} />
203 <View
204 className={
205 materialYouStyleLayoutActive
206 ? "bg-header pb-3"
207 : "border-b border-header-border bg-header pb-3"
208 }
209 style={{
210 paddingHorizontal: HOME_HORIZONTAL_INSET,
211 paddingTop: Math.max(insets.top, 12),
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>
193 <MaterialThreadListToolbar
194 searchQuery={props.searchQuery}
195 onSearchQueryChange={props.onSearchQueryChange}
196 filterActions={menuActions}
197 filterCustomized={hasCustomListOptions}
198 onFilterAction={handleMenuAction}
199 onOpenSettings={props.onOpenSettings}
200 onOpenEnvironments={props.onOpenEnvironments}
201 />
317202 </>
318203 );
319204}
apps/mobile/src/features/home/HomeRouteScreen.tsx+23-3
......@@ -11,7 +11,11 @@ import { useWorkspaceState } from "../../state/workspace";
1111import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
1212import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
1313import { 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";
1519import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates";
1620import { AndroidHomeFabLayout } from "./AndroidHomeFab";
1721import { HomeScreen } from "./HomeScreen";
......@@ -27,7 +31,7 @@ import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle
2731
2832export function HomeRouteScreen() {
2933 const { width: windowWidth } = useWindowDimensions();
30 const { layout } = useAdaptiveWorkspaceLayout();
34 const { layout, panes } = useAdaptiveWorkspaceLayout();
3135 const projects = useProjects();
3236 const threads = useThreadShells();
3337 const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
......@@ -126,8 +130,24 @@ export function HomeRouteScreen() {
126130 />
127131 }
128132 />
133 {Platform.OS === "android" ? (
134 <AndroidScreenHeader title="Threads" leading={<AndroidWorkspaceSidebarButton />} />
135 ) : null}
129136 <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 }
131151 />
132152 </>
133153 );
apps/mobile/src/features/home/HomeScreen.tsx+58-14
......@@ -30,13 +30,13 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
3030import { cn } from "../../lib/cn";
3131import { AppText as Text } from "../../components/AppText";
3232import { EmptyState } from "../../components/EmptyState";
33import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton";
3334import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel";
3435import type { SavedRemoteConnection } from "../../lib/connection";
3536import { scopedProjectKey } from "../../lib/scopedEntities";
3637import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
3738import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
3839import { useThreadSearch } from "../../state/queries";
39import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
4040import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
4141import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
4242import { usePendingThreadOrder } from "../../state/thread-order";
......@@ -83,6 +83,7 @@ import {
8383 type HomeProjectSortOrder,
8484} from "./homeThreadList";
8585import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions";
86import { useMaterialFabScroll } from "./MaterialFabScrollContext";
8687
8788/* ─── Types ──────────────────────────────────────────────────────────── */
8889
......@@ -219,7 +220,6 @@ function HomeTopContentSpacer() {
219220/* ─── Main screen ────────────────────────────────────────────────────── */
220221
221222export function HomeScreen(props: HomeScreenProps) {
222 const { materialYouStyleLayoutActive } = useAppearancePreferences();
223223 const [groupDisplayStates, setGroupDisplayStates] = useState<
224224 ReadonlyMap<string, HomeGroupDisplayState>
225225 >(() => new Map());
......@@ -315,7 +315,9 @@ export function HomeScreen(props: HomeScreenProps) {
315315 const handleScrollBeginDrag = useCallback(() => {
316316 openSwipeableRef.current?.close();
317317 }, []);
318 const onMaterialFabScroll = useMaterialFabScroll();
318319 const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({
320 onScroll: onMaterialFabScroll,
319321 onScrollBeginDrag: handleScrollBeginDrag,
320322 });
321323
......@@ -1104,11 +1106,11 @@ export function HomeScreen(props: HomeScreenProps) {
11041106
11051107 if (!hasAnyThreads) {
11061108 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"}>
11081110 <View
11091111 className={cn(
11101112 "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]",
11121114 )}
11131115 style={{
11141116 paddingBottom: Math.max(insets.bottom, 24) + iosBottomToolbarClearance,
......@@ -1121,11 +1123,22 @@ export function HomeScreen(props: HomeScreenProps) {
11211123 detail={emptyState.detail}
11221124 actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined}
11231125 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 }
11241137 variant="plain"
11251138 />
11261139 {emptyState.loading ? (
11271140 <View className="mt-4 items-center">
1128 <ActivityIndicator colorClassName={"accent-icon-muted"} />
1141 <ActivityIndicator colorClassName="accent-icon-muted" />
11291142 </View>
11301143 ) : null}
11311144 </View>
......@@ -1142,41 +1155,72 @@ export function HomeScreen(props: HomeScreenProps) {
11421155
11431156 const listEmpty = !hasResults ? (
11441157 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 />
11461163 ) : selectedProjectScope !== null ? (
11471164 <EmptyState
11481165 title={`No threads in ${selectedProjectScope.title}`}
11491166 detail="Choose another project or create a new task."
1167 variant={Platform.OS === "android" ? "plain" : undefined}
11501168 />
11511169 ) : selectedEnvironmentLabel ? (
11521170 <EmptyState
11531171 title={`No threads in ${selectedEnvironmentLabel}`}
11541172 detail="Choose another environment or create a new task."
1173 variant={Platform.OS === "android" ? "plain" : undefined}
11551174 />
11561175 ) : (
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 />
11581181 )
11591182 ) : null;
11601183 // Use the v2 project scope for its empty state. Snoozed threads need no
11611184 // special empty state: their shelf header is a list row even while collapsed.
11621185 const v2ListEmpty =
11631186 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 />
11651192 ) : v2ScopedProjectGroup !== null ? (
11661193 <EmptyState
11671194 title={`No threads in ${v2ScopedProjectGroup.title}`}
11681195 detail="Choose another project or create a new task."
1196 variant={Platform.OS === "android" ? "plain" : undefined}
11691197 />
11701198 ) : (
11711199 listEmpty
11721200 );
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
11741218 if (threadListV2Enabled) {
11751219 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"}>
11771221 <View
11781222 className={
1179 materialYouStyleLayoutActive
1223 Platform.OS === "android"
11801224 ? "flex-1 overflow-hidden rounded-t-[28px] bg-screen"
11811225 : "flex-1 bg-screen"
11821226 }
......@@ -1216,7 +1260,7 @@ export function HomeScreen(props: HomeScreenProps) {
12161260 paddingBottom:
12171261 Platform.OS === "ios"
12181262 ? 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),
12201264 }}
12211265 />
12221266 </SwipeableScrollGateProvider>
......@@ -1226,10 +1270,10 @@ export function HomeScreen(props: HomeScreenProps) {
12261270 }
12271271
12281272 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"}>
12301274 <View
12311275 className={
1232 materialYouStyleLayoutActive
1276 Platform.OS === "android"
12331277 ? "flex-1 overflow-hidden rounded-t-[28px] bg-screen"
12341278 : "flex-1 bg-screen"
12351279 }
......@@ -1270,7 +1314,7 @@ export function HomeScreen(props: HomeScreenProps) {
12701314 paddingBottom:
12711315 Platform.OS === "ios"
12721316 ? 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),
12741318 }}
12751319 scrollIndicatorInsets={
12761320 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 {
2222 useState,
2323 type ReactNode,
2424} from "react";
25import { useWindowDimensions, View } from "react-native";
25import { Platform, useWindowDimensions, View } from "react-native";
2626import Animated, {
2727 useAnimatedStyle,
2828 useDerivedValue,
......@@ -56,7 +56,6 @@ import {
5656} from "../keyboard/hardwareKeyboardCommands";
5757import { AndroidHomeFabLayout } from "../home/AndroidHomeFab";
5858import { HomeListOptionsProvider } from "../home/home-list-options";
59import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
6059import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";
6160import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
6261import { WorkspaceInspectorPane } from "./workspace-inspector-pane";
......@@ -234,7 +233,6 @@ function AdaptiveWorkspaceLayoutContent(
234233 },
235234) {
236235 const projectGroupingMode = props.projectGroupingMode;
237 const { materialYouStyleLayoutActive } = useAppearancePreferences();
238236 const { width, height } = useWindowDimensions();
239237 const pathname = props.pathname;
240238 const navigation = useNavigation();
......@@ -582,7 +580,7 @@ function AdaptiveWorkspaceLayoutContent(
582580 style={sidebarAnimatedStyle}
583581 >
584582 <View className="flex-1" style={{ width: layout.listPaneWidth }}>
585 <AndroidHomeFabLayout onStartNewTask={handleStartNewTask}>
583 <AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}>
586584 <ThreadNavigationSidebar
587585 width={layout.listPaneWidth}
588586 visible={panes.primarySidebarVisible}
......@@ -602,7 +600,7 @@ function AdaptiveWorkspaceLayoutContent(
602600 ) : null}
603601 <View
604602 className={
605 materialYouStyleLayoutActive
603 Platform.OS === "android"
606604 ? "flex-1 overflow-hidden bg-header"
607605 : "flex-1 overflow-hidden bg-screen"
608606 }
......@@ -611,7 +609,12 @@ function AdaptiveWorkspaceLayoutContent(
611609 <View
612610 collapsable={false}
613611 style={
614 contentSettledWidth !== null ? { flex: 1, width: contentSettledWidth } : { flex: 1 }
612 contentSettledWidth !== null
613 ? {
614 flex: 1,
615 width: contentSettledWidth,
616 }
617 : { flex: 1 }
615618 }
616619 >
617620 <WorkspaceContentWidthContext
apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx+55-26
......@@ -1,40 +1,69 @@
1import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
21import { SymbolView } from "../../components/AppSymbol";
3import { Pressable, View } from "react-native";
2import { Platform, Pressable, View } from "react-native";
43
54import { 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 }) {
8 const { materialYouStyleLayoutActive } = useAppearancePreferences();
9export function WorkspaceEmptyDetail(props: {
10 readonly onStartNewTask?: () => void;
11 readonly onAddConnection?: () => void;
12}) {
913 return (
1014 <View
1115 className={
12 materialYouStyleLayoutActive
16 Platform.OS === "android"
1317 ? "flex-1 items-center justify-center px-10"
1418 : "flex-1 items-center justify-center bg-screen px-10"
1519 }
1620 >
17 <View className="max-w-[360px] items-center gap-3">
18 <SymbolView
19 name="sidebar.left"
20 size={34}
21 tintColorClassName={"accent-icon-subtle"}
22 type="hierarchical"
23 />
24 <Text className="text-center text-xl font-t3-bold">Select a thread</Text>
25 <Text className="text-center text-base text-foreground-muted">
26 Choose a thread from the sidebar or start a new task.
27 </Text>
28 {props.onStartNewTask ? (
29 <Pressable
30 accessibilityRole="button"
31 className="mt-2 flex-row items-center gap-2 rounded-full bg-primary px-5 py-3 active:opacity-70"
32 onPress={props.onStartNewTask}
33 >
34 <Text className="text-base font-t3-bold text-primary-foreground">New Task</Text>
35 </Pressable>
36 ) : null}
37 </View>
21 {props.onAddConnection ? (
22 <View className="w-full max-w-[430px]">
23 <EmptyState
24 title="No environments connected"
25 detail="Add an environment to load projects and start coding sessions."
26 variant="plain"
27 action={
28 <MaterialFloatingActionButton
29 label="Add environment"
30 icon="plus"
31 variant="extended"
32 tone="primary"
33 onPress={props.onAddConnection}
34 />
35 }
36 />
37 </View>
38 ) : (
39 <View className="max-w-[360px] items-center gap-3">
40 <SymbolView
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 )}
3867 </View>
3968 );
4069}
apps/mobile/src/features/layout/workspace-pane-divider.tsx+2-1
......@@ -1,5 +1,5 @@
11import { 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";
33import { Gesture, GestureDetector } from "react-native-gesture-handler";
44import { runOnJS } from "react-native-reanimated";
55import { cn } from "../../lib/cn";
......@@ -79,6 +79,7 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
7979 className={cn(
8080 "h-full self-center bg-border opacity-70",
8181 dragging ? "w-0.5 bg-primary opacity-100" : "w-px",
82 Platform.OS === "android" && !dragging && "opacity-0",
8283 )}
8384 style={[styles.line, dragging && styles.activeLine]}
8485 />
apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx+18
......@@ -2,8 +2,26 @@ import { NativeHeaderToolbar } from "../../native/StackHeader";
22import type { ReactNode } from "react";
33import { Platform } from "react-native";
44
5import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader";
6
57import { 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
725export function WorkspaceSidebarToolbar(
826 props: {
927 readonly children?: ReactNode;
apps/mobile/src/features/projects/AddProjectScreen.tsx+71-28
......@@ -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";
15import {
26 addProjectRemoteSourceLabel,
37 addProjectRemoteSourcePathHint,
......@@ -41,14 +45,13 @@ import {
4145import { CommonActions, StackActions, useNavigation } from "@react-navigation/native";
4246import { SymbolView } from "../../components/AppSymbol";
4347import { 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";
4549import { useSafeAreaInsets } from "react-native-safe-area-context";
4650import * as Arr from "effect/Array";
4751import * as Cause from "effect/Cause";
4852import * as Order from "effect/Order";
4953import { AsyncResult } from "effect/unstable/reactivity";
5054import { cn } from "../../lib/cn";
51
5255import { useProjects, useServerConfigs, waitForProject } from "../../state/entities";
5356import { filesystemEnvironment } from "../../state/filesystem";
5457import { projectEnvironment } from "../../state/projects";
......@@ -123,13 +126,19 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote
123126
124127function SectionTitle(props: { readonly children: string }) {
125128 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 >
127136 {props.children}
128137 </Text>
129138 );
130139}
131140
132function AddProjectShell(props: { readonly children: ReactNode }) {
141function AddProjectShell(props: { readonly children: ReactNode; readonly title: string }) {
133142 const insets = useSafeAreaInsets();
134143
135144 return (
......@@ -138,25 +147,35 @@ function AddProjectShell(props: { readonly children: ReactNode }) {
138147 // scroll-view frame correction mistakes this full-height wrapper for a
139148 // "header" sibling, coercing the ScrollView to zero height (blank sheet
140149 // 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}>
142151 <ScrollView
143152 keyboardShouldPersistTaps="handled"
144153 contentInsetAdjustmentBehavior="automatic"
145154 contentContainerStyle={{
146 paddingHorizontal: 20,
155 paddingHorizontal: Platform.OS === "android" ? 16 : 20,
147156 paddingTop: 16,
148157 paddingBottom: Math.max(insets.bottom, 18) + 18,
149 gap: 10,
158 gap: Platform.OS === "android" ? 16 : 10,
150159 }}
151160 >
152161 {props.children}
153162 </ScrollView>
154 </View>
163 </SettingsScreen>
155164 );
156165}
157166
158167function 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 );
160179}
161180
162181function ListRow(props: {
......@@ -169,6 +188,20 @@ function ListRow(props: {
169188 readonly right?: ReactNode;
170189 readonly onPress?: () => void;
171190}) {
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 }
172205 return (
173206 <Pressable
174207 disabled={props.disabled}
......@@ -203,7 +236,7 @@ function ListRow(props: {
203236 <SymbolView
204237 name="chevron.right"
205238 size={13}
206 tintColorClassName={"accent-chevron"}
239 tintColorClassName="accent-chevron"
207240 type="monochrome"
208241 />
209242 ) : null}
......@@ -218,6 +251,7 @@ function PrimaryActionButton(props: {
218251 readonly loading?: boolean;
219252 readonly onPress: () => void;
220253}) {
254 if (Platform.OS === "android") return <MaterialButton {...props} tone="primary" fullWidth />;
221255 return (
222256 <Pressable
223257 disabled={props.disabled}
......@@ -435,9 +469,18 @@ function SourceControlRow(props: {
435469 : `Clone ${addProjectRemoteSourceLabel(props.source)} ${props.hint}`;
436470 const icon =
437471 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 />
439478 ) : (
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 />
441484 );
442485
443486 if (!props.ready) {
......@@ -482,7 +525,7 @@ export function AddProjectSourceScreen() {
482525 );
483526
484527 return (
485 <AddProjectShell>
528 <AddProjectShell title="Add project">
486529 {selectedEnvironment === null ? <EmptyEnvironmentState /> : null}
487530
488531 {environmentOptions.length > 1 ? (
......@@ -495,7 +538,7 @@ export function AddProjectSourceScreen() {
495538 title={environment.label}
496539 subtitle={
497540 canCreateProjectInEnvironment(environment.connectionState)
498 ? environment.environmentId
541 ? undefined
499542 : connectionStatusText({
500543 phase: environment.connectionState,
501544 error: environment.connectionError,
......@@ -505,7 +548,7 @@ export function AddProjectSourceScreen() {
505548 icon={
506549 <EnvironmentMachineSymbol
507550 kind={environment.machine}
508 size={17}
551 size={Platform.OS === "android" ? 24 : 17}
509552 tintColorClassName="accent-icon"
510553 />
511554 }
......@@ -516,8 +559,8 @@ export function AddProjectSourceScreen() {
516559 environment.environmentId === selectedEnvironment?.environmentId ? (
517560 <SymbolView
518561 name="checkmark"
519 size={14}
520 tintColorClassName={"accent-icon"}
562 size={Platform.OS === "android" ? 20 : 14}
563 tintColorClassName="accent-icon"
521564 type="monochrome"
522565 />
523566 ) : null
......@@ -538,8 +581,8 @@ export function AddProjectSourceScreen() {
538581 icon={
539582 <SymbolView
540583 name="folder.badge.plus"
541 size={17}
542 tintColorClassName={"accent-icon"}
584 size={Platform.OS === "android" ? 24 : 17}
585 tintColorClassName="accent-icon"
543586 type="monochrome"
544587 />
545588 }
......@@ -570,7 +613,7 @@ export function AddProjectSourceScreen() {
570613 )}
571614 </ListSection>
572615 {discoveryState.isPending ? (
573 <ActivityIndicator colorClassName={"accent-icon-muted"} />
616 <ActivityIndicator colorClassName="accent-icon-muted" />
574617 ) : null}
575618 </>
576619 ) : null}
......@@ -723,7 +766,7 @@ export function AddProjectRepositoryScreen(props: {
723766 }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]);
724767
725768 return (
726 <AddProjectShell>
769 <AddProjectShell title={source === "url" ? "Git URL" : addProjectRemoteSourceLabel(source)}>
727770 {error ? <ErrorBanner message={error} /> : null}
728771 {environment ? (
729772 <>
......@@ -800,7 +843,7 @@ function FolderBrowser(props: {
800843 <ListSection>
801844 {browseState.isPending && browseState.data === null ? (
802845 <View className="items-center py-5">
803 <ActivityIndicator colorClassName={"accent-icon-muted"} />
846 <ActivityIndicator colorClassName="accent-icon-muted" />
804847 </View>
805848 ) : null}
806849 {browsePath.canBrowseUp ? (
......@@ -809,8 +852,8 @@ function FolderBrowser(props: {
809852 icon={
810853 <SymbolView
811854 name="arrow.turn.left.up"
812 size={17}
813 tintColorClassName={"accent-icon-muted"}
855 size={Platform.OS === "android" ? 24 : 17}
856 tintColorClassName="accent-icon-muted"
814857 type="monochrome"
815858 />
816859 }
......@@ -832,8 +875,8 @@ function FolderBrowser(props: {
832875 icon={
833876 <SymbolView
834877 name="folder"
835 size={17}
836 tintColorClassName={"accent-icon-muted"}
878 size={Platform.OS === "android" ? 24 : 17}
879 tintColorClassName="accent-icon-muted"
837880 type="monochrome"
838881 />
839882 }
......@@ -882,7 +925,7 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st
882925 }, [createProject, environment, isBrowseNavigating, isSubmitting, pathInput]);
883926
884927 return (
885 <AddProjectShell>
928 <AddProjectShell title="Local folder">
886929 {error ? <ErrorBanner message={error} /> : null}
887930 {environment ? (
888931 <>
......@@ -1025,7 +1068,7 @@ export function AddProjectDestinationScreen(props: {
10251068 ]);
10261069
10271070 return (
1028 <AddProjectShell>
1071 <AddProjectShell title="Clone destination">
10291072 {error ? <ErrorBanner message={error} /> : null}
10301073 {repositoryTitle ? (
10311074 <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";
3535import { SymbolView } from "../../components/AppSymbol";
3636import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
3737import { ControlPillMenu } from "../../components/ControlPill";
38import { MaterialScreenContent } from "../../components/MaterialScreenContent";
39import { cn } from "../../lib/cn";
3840import { environmentCatalog } from "../../connection/catalog";
3941import { useEnvironmentPresentation } from "../../state/presentation";
4042import { useAtomCommand } from "../../state/use-atom-command";
......@@ -54,7 +56,10 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s
5456import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
5557import { useThreadSelection } from "../../state/use-thread-selection";
5658import { vcsEnvironment } from "../../state/vcs";
57import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
59import {
60 AndroidWorkspaceSidebarButton,
61 WorkspaceSidebarToolbar,
62} from "../layout/workspace-sidebar-toolbar";
5863import { ThreadGitMenu } from "../threads/ThreadGitControls";
5964import { useReviewCacheForThread } from "./reviewState";
6065import {
......@@ -81,7 +86,12 @@ const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
8186
8287const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) {
8388 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 >
8595 <Text className="text-xs font-t3-bold uppercase text-warning-foreground">Partial diff</Text>
8696 <Text className="text-xs leading-normal text-warning-foreground">{props.notice}</Text>
8797 </View>
......@@ -103,7 +113,7 @@ function ReviewSelectionActionBar(props: {
103113 <SymbolView
104114 name={props.onOpenComment ? "text.bubble" : "line.3.horizontal.decrease.circle"}
105115 size={16}
106 tintColorClassName={"accent-primary-foreground"}
116 tintColorClassName="accent-primary-foreground"
107117 type="monochrome"
108118 />
109119 <Text className="text-base font-t3-bold text-primary-foreground">{props.title}</Text>
......@@ -143,7 +153,7 @@ function ReviewSelectionActionBar(props: {
143153 <SymbolView
144154 name="xmark"
145155 size={16}
146 tintColorClassName={"accent-primary-foreground"}
156 tintColorClassName="accent-primary-foreground"
147157 type="monochrome"
148158 />
149159 </Pressable>
......@@ -174,9 +184,14 @@ const ReviewFileNavigatorRow = memo(function ReviewFileNavigatorRow(props: {
174184 accessibilityRole="button"
175185 accessibilityState={{ selected }}
176186 className={
177 selected
178 ? "mt-1 min-h-12 justify-center rounded-xl bg-subtle-strong px-3 py-2"
179 : "mt-1 min-h-12 justify-center rounded-xl px-3 py-2 active:bg-subtle"
187 Platform.OS === "android"
188 ? cn(
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"
180195 }
181196 onPress={handlePress}
182197 >
......@@ -323,16 +338,28 @@ function ReviewFileNavigator({
323338 }
324339
325340 return (
326 <View className="flex-1 border-l border-border bg-sheet">
327 <View className="border-b border-border" style={{ paddingTop: headerInset }}>
328 <View className="px-4 py-3">
329 <Text className="text-sm font-t3-bold text-foreground">Changed files</Text>
330 <Text className="text-xs text-foreground-muted">
331 {files.length} {files.length === 1 ? "file" : "files"}
332 </Text>
341 <View
342 className={
343 Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 border-l border-border bg-sheet"
344 }
345 >
346 {Platform.OS === "android" ? (
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>
333360 </View>
334 </View>
335 {fileList}
361 )}
362 <MaterialScreenContent insetHorizontal>{fileList}</MaterialScreenContent>
336363 </View>
337364 );
338365}
......@@ -632,23 +659,21 @@ export function ReviewSheet(props: ReviewSheetProps) {
632659 parsedDiff.kind === "files" &&
633660 NativeReviewDiffView !== null;
634661 useRegisterWorkspaceInspector(showChangedFilesPane ? renderInspector : undefined);
635 // Raw fallback renders the patch inline with no inspector content, so the
636 // pane toggle would open an empty column — hide it in exactly that case.
637 const showChangedFilesToggle =
638 panes.supportsAuxiliaryPane &&
639 !(
640 !showConnectionNotice &&
641 selectedSection !== null &&
642 parsedDiff.kind === "files" &&
643 NativeReviewDiffView === null
644 );
662 // A toggle needs registered content; loading, errors and raw patches have no navigator pane.
663 const showChangedFilesToggle = panes.supportsAuxiliaryPane && showChangedFilesPane;
645664
646665 const listHeader = useMemo(() => {
647666 const children: ReactElement[] = [];
648667
649668 if (error) {
650669 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 >
652677 <Text className="text-sm font-t3-bold text-foreground">Review unavailable</Text>
653678 <Text className="text-xs leading-normal text-foreground-muted">{error}</Text>
654679 </View>,
......@@ -699,21 +724,35 @@ export function ReviewSheet(props: ReviewSheetProps) {
699724 {isAndroid ? (
700725 <AndroidScreenHeader
701726 title="Review changes"
727 leading={<AndroidWorkspaceSidebarButton />}
728 hideBottomBorder
702729 subtitle={androidHeaderSubtitle || "Select a diff"}
703730 onBack={handleReturnToThread}
704731 trailing={
705 showSectionToolbar ? (
706 <ControlPillMenu
707 actions={androidSectionMenuActions}
708 isAnchoredToRight
709 onPressAction={handleAndroidSectionMenuAction}
710 >
732 <>
733 {showChangedFilesToggle ? (
711734 <AndroidHeaderIconButton
712 accessibilityLabel="Select review diff"
713 icon="ellipsis.circle"
735 accessibilityLabel={
736 panes.auxiliaryPaneVisible ? "Hide changed files" : "Show changed files"
737 }
738 icon="sidebar.right"
739 selected={panes.auxiliaryPaneVisible}
740 onPress={toggleAuxiliaryPane}
714741 />
715 </ControlPillMenu>
716 ) : null
742 ) : null}
743 {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 </>
717756 }
718757 />
719758 ) : null}
......@@ -805,141 +844,187 @@ export function ReviewSheet(props: ReviewSheetProps) {
805844 </NativeHeaderToolbar>
806845 ) : null}
807846
808 <View className="flex-1 bg-sheet">
809 {showConnectionNotice ? (
810 <View className="flex-1" style={{ paddingTop: topContentInset }}>
811 <EnvironmentConnectionNotice
812 environmentLabel={environment.presentation?.entry.target.label ?? "Environment"}
813 connection={
814 environment.presentation?.connection ?? {
815 phase: "available",
816 error: null,
817 traceId: null,
847 <MaterialScreenContent>
848 <View className={Platform.OS === "android" ? "flex-1 bg-sheet-solid" : "flex-1 bg-sheet"}>
849 {showConnectionNotice ? (
850 <View className="flex-1" style={{ paddingTop: topContentInset }}>
851 <EnvironmentConnectionNotice
852 environmentLabel={environment.presentation?.entry.target.label ?? "Environment"}
853 connection={
854 environment.presentation?.connection ?? {
855 phase: "available",
856 error: null,
857 traceId: null,
858 }
818859 }
819 }
820 resourceName="review"
821 onRetry={handleRetryEnvironment}
822 />
823 </View>
824 ) : selectedSection && parsedDiff.kind === "files" && NativeReviewDiffView ? (
825 <View
826 className="flex-1"
827 style={{
828 backgroundColor: nativeBridge.theme.background,
829 }}
830 >
860 resourceName="review"
861 onRetry={handleRetryEnvironment}
862 />
863 </View>
864 ) : selectedSection && parsedDiff.kind === "files" && NativeReviewDiffView ? (
831865 <View
832 className="min-w-0 flex-1"
833 style={{ paddingTop: topContentInset + REVIEW_HEADER_SPACING }}
866 className="flex-1"
867 style={{
868 backgroundColor: nativeBridge.theme.background,
869 }}
834870 >
835 {listHeader}
836 <View className="min-w-0 flex-1" collapsable={false}>
837 <NativeReviewDiffView
838 collapsable={false}
839 testID="review-native-diff-view"
840 refreshing={isPullRefreshing || isSelectedSectionPending || areFilePatchesPending}
841 onPullToRefresh={() => void handlePullToRefresh()}
842 style={StyleSheet.absoluteFill}
843 appearanceScheme={selectedTheme}
844 collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson}
845 collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson}
846 contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`}
847 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
848 nativeViewRef={nativeReviewDiffViewRef}
849 rowHeight={nativeReviewDiffStyle.rowHeight}
850 rowsJson={nativeBridge.rowsJson}
851 selectedRowIdsJson={nativeBridge.selectedRowIdsJson}
852 styleJson={nativeBridge.styleJson}
853 themeJson={nativeBridge.themeJson}
854 tokensPatchJson={nativeBridge.tokensPatchJson}
855 tokensResetKey={nativeBridge.tokensResetKey}
856 viewedFileIdsJson={nativeBridge.viewedFileIdsJson}
857 onDebug={handleNativeDebug}
858 onPressLine={commentSelection.onPressLine}
859 onVisibleFileChange={handleVisibleFileChange}
860 onToggleComment={nativeBridge.onToggleComment}
861 onToggleFile={handleNativeToggleFile}
862 onToggleViewedFile={handleNativeToggleViewedFile}
863 />
871 <View
872 className="min-w-0 flex-1"
873 style={{ paddingTop: topContentInset + REVIEW_HEADER_SPACING }}
874 >
875 {listHeader}
876 <View className="min-w-0 flex-1" collapsable={false}>
877 <NativeReviewDiffView
878 collapsable={false}
879 testID="review-native-diff-view"
880 refreshing={
881 isPullRefreshing || isSelectedSectionPending || areFilePatchesPending
882 }
883 onPullToRefresh={() => void handlePullToRefresh()}
884 style={StyleSheet.absoluteFill}
885 appearanceScheme={selectedTheme}
886 collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson}
887 collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson}
888 contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`}
889 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
890 nativeViewRef={nativeReviewDiffViewRef}
891 rowHeight={nativeReviewDiffStyle.rowHeight}
892 rowsJson={nativeBridge.rowsJson}
893 selectedRowIdsJson={nativeBridge.selectedRowIdsJson}
894 styleJson={nativeBridge.styleJson}
895 themeJson={nativeBridge.themeJson}
896 tokensPatchJson={nativeBridge.tokensPatchJson}
897 tokensResetKey={nativeBridge.tokensResetKey}
898 viewedFileIdsJson={nativeBridge.viewedFileIdsJson}
899 onDebug={handleNativeDebug}
900 onPressLine={commentSelection.onPressLine}
901 onVisibleFileChange={handleVisibleFileChange}
902 onToggleComment={nativeBridge.onToggleComment}
903 onToggleFile={handleNativeToggleFile}
904 onToggleViewedFile={handleNativeToggleViewedFile}
905 />
906 </View>
864907 </View>
865908 </View>
866 </View>
867 ) : (
868 <ScrollView
869 contentInsetAdjustmentBehavior="never"
870 contentInset={{ top: topContentInset, bottom: Math.max(insets.bottom, 18) + 18 }}
871 contentOffset={{ x: 0, y: -topContentInset }}
872 scrollIndicatorInsets={{
873 top: topContentInset,
874 bottom: Math.max(insets.bottom, 18) + 18,
875 }}
876 showsVerticalScrollIndicator={false}
877 className="flex-1"
878 refreshControl={
879 // The native diff surface owns pull-to-refresh via onPullToRefresh;
880 // the raw fallback (and empty states) need an explicit control —
881 // iOS has no other refresh affordance here (the explicit
882 // "Refresh current diff" menu is Android-only).
883 <RefreshControl
884 refreshing={isPullRefreshing || isSelectedSectionPending || areFilePatchesPending}
885 onRefresh={() => void handlePullToRefresh()}
886 />
887 }
888 >
889 {listHeader}
890 {!selectedSection ? (
891 <View className="border-b border-border bg-card px-4 py-5">
892 <Text className="text-sm font-t3-bold text-foreground">No review diffs</Text>
893 <Text className="text-xs leading-normal text-foreground-muted">
894 This thread has no ready turn diffs and the worktree diff is empty.
895 </Text>
896 </View>
897 ) : selectedSection.isLoading && selectedSection.diff === null ? (
898 <View className="items-center gap-3 border-b border-border bg-card px-4 py-6">
899 <ActivityIndicator size="small" />
900 <Text className="text-xs text-foreground-muted">Loading diff…</Text>
901 </View>
902 ) : parsedDiff.kind === "empty" ? (
903 <View className="border-b border-border bg-card px-4 py-5">
904 <Text className="text-sm font-t3-bold text-foreground">No changes</Text>
905 <Text className="text-xs leading-normal text-foreground-muted">
906 {selectedSection.subtitle ?? "This diff is empty."}
907 </Text>
908 </View>
909 ) : parsedDiff.kind === "raw" ? (
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}
909 ) : (
910 <ScrollView
911 contentContainerStyle={
912 Platform.OS === "android" && (parsedDiff.kind === "empty" || !selectedSection)
913 ? { flexGrow: 1, justifyContent: "center" }
914 : undefined
915 }
916 contentInsetAdjustmentBehavior="never"
917 contentInset={{ top: topContentInset, bottom: Math.max(insets.bottom, 18) + 18 }}
918 contentOffset={{ x: 0, y: -topContentInset }}
919 scrollIndicatorInsets={{
920 top: topContentInset,
921 bottom: Math.max(insets.bottom, 18) + 18,
922 }}
923 showsVerticalScrollIndicator={false}
924 className="flex-1"
925 refreshControl={
926 // The native diff surface owns pull-to-refresh via onPullToRefresh;
927 // the raw fallback (and empty states) need an explicit control —
928 // iOS has no other refresh affordance here (the explicit
929 // "Refresh current diff" menu is Android-only).
930 <RefreshControl
931 refreshing={isPullRefreshing || isSelectedSectionPending || areFilePatchesPending}
932 onRefresh={() => void handlePullToRefresh()}
933 />
934 }
935 >
936 {listHeader}
937 {!selectedSection ? (
938 <View
939 className={
940 Platform.OS === "android"
941 ? "items-center px-6 py-5"
942 : "border-b border-border bg-card px-4 py-5"
943 }
944 >
945 <Text className="text-sm font-t3-bold text-foreground">No review diffs</Text>
946 <Text
947 className={cn(
948 "text-xs leading-normal text-foreground-muted",
949 Platform.OS === "android" && "mt-2 text-center",
950 )}
951 >
952 This thread has no ready turn diffs and the worktree diff is empty.
917953 </Text>
918 </ScrollView>
919 </View>
920 ) : parsedDiff.kind === "files" ? (
921 // The native diff surface could not be resolved on this binary;
922 // degrade to the raw patch instead of crashing the app.
923 <View className="gap-3 border-b border-border bg-card px-4 py-4">
924 <Text className="text-xs leading-normal text-foreground-muted">
925 Native diff view unavailable. Showing the raw patch.
926 </Text>
927 <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}>
928 <Text selectable className="font-mono text-xs leading-relaxed text-foreground">
929 {selectedSection?.diff ?? ""}
954 </View>
955 ) : selectedSection.isLoading && selectedSection.diff === null ? (
956 <View
957 className={cn(
958 "items-center gap-3 px-4 py-6",
959 Platform.OS !== "android" && "border-b border-border bg-card",
960 )}
961 >
962 <ActivityIndicator size="small" />
963 <Text className="text-xs text-foreground-muted">Loading diff…</Text>
964 </View>
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."}
930981 </Text>
931 </ScrollView>
932 </View>
933 ) : null}
934 </ScrollView>
935 )}
936 <ReviewSelectionActionBar
937 bottomInset={insets.bottom}
938 title={commentSelection.selectionAction?.title ?? null}
939 onOpenComment={commentSelection.selectionAction?.onOpenComment ?? null}
940 onClear={commentSelection.clearSelection}
941 />
942 </View>
982 </View>
983 ) : parsedDiff.kind === "raw" ? (
984 <View
985 className={cn(
986 "gap-3 bg-card px-4 py-4",
987 Platform.OS === "android" ? "m-2 rounded-[20px]" : "border-b border-border",
988 )}
989 >
990 <Text className="text-xs leading-normal text-foreground-muted">
991 {parsedDiff.reason}
992 </Text>
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>
9431028 </>
9441029 );
9451030}
apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts+25
......@@ -255,6 +255,31 @@ describe("createNativeReviewDiffTheme", () => {
255255 }
256256 });
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
258283 it("uses the selected app palette for native code surfaces", () => {
259284 const standard = createNativeReviewDiffTheme("dark", "t3-code", appTheme("t3-code", "dark"));
260285 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";
1919
2020const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4;
2121const 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;
2323const NATIVE_RGBA_COLOR =
2424 /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/;
2525
......@@ -44,15 +44,19 @@ export function buildNativeReviewSnippetRows(
4444
4545function opaqueNativeHexColor(color: string, background: string): string {
4646 const hex = NATIVE_HEX_COLOR.exec(color);
47 if (hex) return color;
47 if (hex && !hex[4]) return color;
4848
4949 const rgba = NATIVE_RGBA_COLOR.exec(color);
5050 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])));
5458 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]);
5660 const behind = Number.parseInt(backgroundHex[index] ?? "0", 16);
5761 return Math.round(foreground * alpha + behind * (1 - alpha));
5862 });
......@@ -169,7 +173,11 @@ export function createNativeReviewDiffTheme(
169173 // Swift expects #RRGGBB/#RRGGBBAA while Android expects #RRGGBB/#AARRGGBB.
170174 // Flatten translucent app tokens onto the code surface so both native
171175 // 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);
173181 const nativeColor = (color: string) => opaqueNativeHexColor(color, background);
174182
175183 if (scheme === "dark") {
apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx+4-13
......@@ -1,26 +1,17 @@
1import { useNavigation } from "@react-navigation/native";
2import { Platform, ScrollView, View } from "react-native";
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
32import { useSafeAreaInsets } from "react-native-safe-area-context";
43
5import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
6import { NativeStackScreenOptions } from "../../native/StackHeader";
4import { SettingsScreen } from "./components/SettingsScreen";
75import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection";
86import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection";
97import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection";
108import { ThemeAppearanceSection } from "./appearance/sections/ThemeAppearanceSection";
119
1210export function SettingsAppearanceRouteScreen() {
13 const navigation = useNavigation();
1411 const insets = useSafeAreaInsets();
1512
1613 return (
17 <View collapsable={false} className="flex-1 bg-sheet">
18 {Platform.OS === "android" ? (
19 <>
20 <NativeStackScreenOptions options={{ headerShown: false }} />
21 <AndroidScreenHeader title="Appearance" onBack={() => navigation.goBack()} />
22 </>
23 ) : null}
14 <SettingsScreen title="Appearance">
2415 <ScrollView
2516 contentInsetAdjustmentBehavior="automatic"
2617 showsVerticalScrollIndicator={false}
......@@ -35,6 +26,6 @@ export function SettingsAppearanceRouteScreen() {
3526 <TerminalAppearanceSection />
3627 <CodeAppearanceSection />
3728 </ScrollView>
38 </View>
29 </SettingsScreen>
3930 );
4031}
apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx+9-9
......@@ -1,8 +1,9 @@
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
12import { useAtomSet, useAtomValue } from "@effect/atom-react";
23import { type EnvironmentMachineKind, resolveEnvironmentMachineKind } from "@t3tools/contracts";
34import { AsyncResult } from "effect/unstable/reactivity";
45import { useMemo } from "react";
5import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
6import { ActivityIndicator, Alert, Pressable, View } from "react-native";
67import { useSafeAreaInsets } from "react-native-safe-area-context";
78
89import { AppText as Text } from "../../components/AppText";
......@@ -16,6 +17,7 @@ import {
1617import { useServerConfigs } from "../../state/entities";
1718import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
1819import { SettingsSection } from "./components/SettingsSection";
20import { SettingsScreen } from "./components/SettingsScreen";
1921
2022export function SettingsClientStorageRouteScreen() {
2123 const insets = useSafeAreaInsets();
......@@ -71,7 +73,7 @@ export function SettingsClientStorageRouteScreen() {
7173 };
7274
7375 return (
74 <View collapsable={false} className="flex-1 bg-sheet">
76 <SettingsScreen title="Client Storage">
7577 <ScrollView
7678 contentInsetAdjustmentBehavior="automatic"
7779 contentInset={{ bottom: Math.max(insets.bottom, 18) }}
......@@ -85,7 +87,7 @@ export function SettingsClientStorageRouteScreen() {
8587 <SymbolView
8688 name="exclamationmark.triangle"
8789 size={28}
88 tintColorClassName={"accent-danger-foreground"}
90 tintColorClassName="accent-danger-foreground"
8991 type="monochrome"
9092 weight="regular"
9193 />
......@@ -123,7 +125,7 @@ export function SettingsClientStorageRouteScreen() {
123125 <SymbolView
124126 name="checkmark.circle"
125127 size={28}
126 tintColorClassName={"accent-icon"}
128 tintColorClassName="accent-icon"
127129 type="monochrome"
128130 weight="regular"
129131 />
......@@ -146,16 +148,14 @@ export function SettingsClientStorageRouteScreen() {
146148 <SymbolView
147149 name="trash"
148150 size={22}
149 tintColorClassName={"accent-danger-foreground"}
151 tintColorClassName="accent-danger-foreground"
150152 type="monochrome"
151153 weight="regular"
152154 />
153155 <Text className="flex-1 text-lg tabular-nums text-danger-foreground">
154156 {summary ? `Clear ${formatBytes(summary.payloadBytes)}` : "Clear caches"}
155157 </Text>
156 {isClearing ? (
157 <ActivityIndicator colorClassName={"accent-danger-foreground"} />
158 ) : null}
158 {isClearing ? <ActivityIndicator colorClassName="accent-danger-foreground" /> : null}
159159 </Pressable>
160160 </SettingsSection>
161161 <Text className="px-2 text-sm leading-normal text-foreground-muted">
......@@ -169,7 +169,7 @@ export function SettingsClientStorageRouteScreen() {
169169 ) : null}
170170 </View>
171171 </ScrollView>
172 </View>
172 </SettingsScreen>
173173 );
174174}
175175
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+21-27
......@@ -1,13 +1,14 @@
1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
2import { NativeHeaderToolbar } from "../../native/StackHeader";
23import { useNavigation } from "@react-navigation/native";
34import { SymbolView } from "../../components/AppSymbol";
45import type { EnvironmentId } from "@t3tools/contracts";
56import { useCallback, useState } from "react";
6import { Platform, ScrollView, View } from "react-native";
7import { Platform, View } from "react-native";
78import { useSafeAreaInsets } from "react-native-safe-area-context";
89
910import { AppText as Text } from "../../components/AppText";
10import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
11import { SettingsScreen } from "./components/SettingsScreen";
1112import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";
1213import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
1314import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings";
......@@ -79,28 +80,21 @@ export function SettingsEnvironmentsRouteScreen() {
7980 );
8081
8182 return (
82 <View collapsable={false} className="flex-1 bg-sheet">
83 {Platform.OS === "android" ? (
84 <>
85 {/* Android renders its own in-screen header instead of the native bar. */}
86 <NativeStackScreenOptions options={{ headerShown: false }} />
87 <AndroidScreenHeader
88 title="Environments"
89 onBack={() => navigation.goBack()}
90 actions={[
91 {
92 accessibilityLabel: "Add environment",
93 icon: "plus",
94 onPress: () =>
95 navigation.navigate("SettingsSheet", {
96 screen: "SettingsContent",
97 params: { screen: "SettingsEnvironmentNew" },
98 }),
99 },
100 ]}
101 />
102 </>
103 ) : (
83 <SettingsScreen
84 title="Environments"
85 actions={[
86 {
87 accessibilityLabel: "Add environment",
88 icon: "plus",
89 onPress: () =>
90 navigation.navigate("SettingsSheet", {
91 screen: "SettingsContent",
92 params: { screen: "SettingsEnvironmentNew" },
93 }),
94 },
95 ]}
96 >
97 {Platform.OS !== "android" ? (
10498 <NativeHeaderToolbar placement="right">
10599 <NativeHeaderToolbar.Button
106100 icon="plus"
......@@ -114,7 +108,7 @@ export function SettingsEnvironmentsRouteScreen() {
114108 tintColor={headerIconColor}
115109 />
116110 </NativeHeaderToolbar>
117 )}
111 ) : null}
118112 <ScrollView
119113 contentInsetAdjustmentBehavior="automatic"
120114 showsVerticalScrollIndicator={false}
......@@ -177,6 +171,6 @@ export function SettingsEnvironmentsRouteScreen() {
177171 />
178172 <GitHubRoutingSettings />
179173 </ScrollView>
180 </View>
174 </SettingsScreen>
181175 );
182176}
apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx+13-35
......@@ -1,3 +1,4 @@
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
12import { LegendList } from "@legendapp/list/react-native";
23import { type StaticScreenProps, useNavigation } from "@react-navigation/native";
34import {
......@@ -8,18 +9,19 @@ import {
89 type ThirdPartyLicenseEntry,
910} from "@t3tools/shared/thirdPartyLicenses";
1011import { useCallback, useMemo, useState } from "react";
11import { Linking, Platform, Pressable, ScrollView, View } from "react-native";
12import { Linking, Platform, Pressable, View } from "react-native";
1213import { useSafeAreaInsets } from "react-native-safe-area-context";
1314
14import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
1515import { SymbolView } from "../../components/AppSymbol";
1616import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
17import { SettingsScreen } from "./components/SettingsScreen";
1718import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
1819import {
1920 createNativeMailSearchToolbarItem,
2021 NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,
2122 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
2223} from "../layout/native-mail-search-toolbar";
24
2325import { getMobileThirdPartyLicenses } from "./mobileThirdPartyLicenses";
2426
2527function useMobileThirdPartyLicenses() {
......@@ -97,24 +99,18 @@ export function SettingsOpenSourceLicensesRouteScreen() {
9799
98100 if (!manifest) {
99101 return (
100 <View collapsable={false} className="flex-1 bg-sheet">
101 {Platform.OS === "android" ? (
102 <>
103 <NativeStackScreenOptions options={{ headerShown: false }} />
104 <AndroidScreenHeader title="Open source licenses" onBack={() => navigation.goBack()} />
105 </>
106 ) : null}
102 <SettingsScreen title="Open source licenses">
107103 <View className="flex-1 items-center justify-center px-6">
108104 <Text className="text-center text-base text-foreground-muted">
109105 License notices are unavailable in this build.
110106 </Text>
111107 </View>
112 </View>
108 </SettingsScreen>
113109 );
114110 }
115111
116112 return (
117 <View collapsable={false} className="flex-1 bg-sheet">
113 <SettingsScreen title="Open source licenses">
118114 {Platform.OS === "ios" ? (
119115 <NativeStackScreenOptions
120116 options={{
......@@ -148,12 +144,7 @@ export function SettingsOpenSourceLicensesRouteScreen() {
148144 <NativeHeaderToolbar.SearchBarSlot />
149145 </NativeHeaderToolbar>
150146 ) : null}
151 {Platform.OS === "android" ? (
152 <>
153 <NativeStackScreenOptions options={{ headerShown: false }} />
154 <AndroidScreenHeader title="Open source licenses" onBack={() => navigation.goBack()} />
155 </>
156 ) : null}
147
157148 <LegendList
158149 className="flex-1"
159150 contentContainerStyle={{
......@@ -195,14 +186,13 @@ export function SettingsOpenSourceLicensesRouteScreen() {
195186 renderItem={renderItem}
196187 showsVerticalScrollIndicator={false}
197188 />
198 </View>
189 </SettingsScreen>
199190 );
200191}
201192
202193type LicenseDetailProps = StaticScreenProps<{ readonly entryKey: string }>;
203194
204195export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailProps) {
205 const navigation = useNavigation();
206196 const insets = useSafeAreaInsets();
207197 const manifest = useMobileThirdPartyLicenses();
208198 const entry = manifest
......@@ -212,30 +202,18 @@ export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailPro
212202
213203 if (!entry) {
214204 return (
215 <View collapsable={false} className="flex-1 bg-sheet">
216 {Platform.OS === "android" ? (
217 <>
218 <NativeStackScreenOptions options={{ headerShown: false }} />
219 <AndroidScreenHeader title="License notice" onBack={() => navigation.goBack()} />
220 </>
221 ) : null}
205 <SettingsScreen title="License notice">
222206 <View className="flex-1 items-center justify-center px-6">
223207 <Text className="text-center text-base text-foreground-muted">
224208 This license notice is unavailable.
225209 </Text>
226210 </View>
227 </View>
211 </SettingsScreen>
228212 );
229213 }
230214
231215 return (
232 <View collapsable={false} className="flex-1 bg-sheet">
233 {Platform.OS === "android" ? (
234 <>
235 <NativeStackScreenOptions options={{ headerShown: false }} />
236 <AndroidScreenHeader title="License notice" onBack={() => navigation.goBack()} />
237 </>
238 ) : null}
216 <SettingsScreen title="License notice">
239217 <ScrollView
240218 className="flex-1"
241219 contentInsetAdjustmentBehavior="automatic"
......@@ -275,6 +253,6 @@ export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailPro
275253 </Text>
276254 </View>
277255 </ScrollView>
278 </View>
256 </SettingsScreen>
279257 );
280258}
apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx+5-13
......@@ -1,14 +1,13 @@
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
12import { useAtomSet, useAtomValue } from "@effect/atom-react";
2import { useNavigation } from "@react-navigation/native";
33import type { SidebarProjectGroupingMode } from "@t3tools/contracts";
44import { AsyncResult } from "effect/unstable/reactivity";
5import { Platform, Pressable, ScrollView, View } from "react-native";
5import { Pressable, View } from "react-native";
66import { useSafeAreaInsets } from "react-native-safe-area-context";
77
8import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
98import { AppText as Text } from "../../components/AppText";
109import { SymbolView } from "../../components/AppSymbol";
11import { NativeStackScreenOptions } from "../../native/StackHeader";
10import { SettingsScreen } from "./components/SettingsScreen";
1211import {
1312 mobileProjectGroupingModePatch,
1413 resolveMobileProjectGroupingSettings,
......@@ -39,7 +38,6 @@ const GROUPING_OPTIONS: ReadonlyArray<{
3938];
4039
4140export function SettingsProjectGroupingRouteScreen() {
42 const navigation = useNavigation();
4341 const insets = useSafeAreaInsets();
4442 const preferencesResult = useAtomValue(mobilePreferencesAtom);
4543 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
......@@ -49,13 +47,7 @@ export function SettingsProjectGroupingRouteScreen() {
4947 : null;
5048
5149 return (
52 <View collapsable={false} className="flex-1 bg-sheet">
53 {Platform.OS === "android" ? (
54 <>
55 <NativeStackScreenOptions options={{ headerShown: false }} />
56 <AndroidScreenHeader title="Project Grouping" onBack={() => navigation.goBack()} />
57 </>
58 ) : null}
50 <SettingsScreen title="Project Grouping">
5951 <ScrollView
6052 contentInsetAdjustmentBehavior="automatic"
6153 showsVerticalScrollIndicator={false}
......@@ -99,6 +91,6 @@ export function SettingsProjectGroupingRouteScreen() {
9991 ))}
10092 </SettingsSection>
10193 </ScrollView>
102 </View>
94 </SettingsScreen>
10395 );
10496}
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+48-58
......@@ -1,3 +1,5 @@
1import { AutoSettleDaysField } from "./components/AutoSettleDaysField";
2import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
13import { useAuth, useUser } from "@clerk/expo";
24import { useAtomSet, useAtomValue } from "@effect/atom-react";
35import Constants from "expo-constants";
......@@ -8,7 +10,7 @@ import { SymbolView } from "../../components/AppSymbol";
810import * as Effect from "effect/Effect";
911import { AsyncResult } from "effect/unstable/reactivity";
1012import { 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";
1214import { useSafeAreaInsets } from "react-native-safe-area-context";
1315
1416import {
......@@ -18,8 +20,7 @@ import {
1820 settlePromise,
1921 squashAtomCommandFailure,
2022} from "@t3tools/client-runtime/state/runtime";
21import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
22import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
23import { AppText as Text } from "../../components/AppText";
2324import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities";
2425import {
2526 openAndroidLiveUpdateSettings,
......@@ -37,15 +38,12 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu
3738import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
3839import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
3940import { runtime } from "../../lib/runtime";
41import { cn } from "../../lib/cn";
4042import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
4143import { serverEnvironment } from "../../state/server";
4244import { useAtomCommand } from "../../state/use-atom-command";
4345import { useEnvironments } from "../../state/environments";
44import {
45 DEFAULT_SERVER_SETTINGS,
46 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
47 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
48} from "@t3tools/contracts";
46import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
4947import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings";
5048import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
5149import {
......@@ -58,6 +56,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re
5856import { SettingsRow } from "./components/SettingsRow";
5957import { SettingsSection } from "./components/SettingsSection";
6058import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
59import { SettingsScreen } from "./components/SettingsScreen";
6160import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic";
6261import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync";
6362
......@@ -79,17 +78,16 @@ function useDeviceRegistered(): boolean {
7978
8079export function SettingsRouteScreen() {
8180 const navigation = useNavigation();
81 const content = hasCloudPublicConfig() ? (
82 <ConfiguredSettingsRouteScreen />
83 ) : (
84 <LocalSettingsRouteScreen />
85 );
8286
8387 return (
8488 <>
8589 <WorkspaceSidebarToolbar />
86 {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 ) : (
90 {Platform.OS !== "android" ? (
9391 <NativeStackScreenOptions
9492 options={{
9593 unstable_headerRightItems:
......@@ -107,8 +105,12 @@ export function SettingsRouteScreen() {
107105 : undefined,
108106 }}
109107 />
108 ) : null}
109 {Platform.OS === "android" ? (
110 <SettingsScreen title="Settings">{content}</SettingsScreen>
111 ) : (
112 content
110113 )}
111 {hasCloudPublicConfig() ? <ConfiguredSettingsRouteScreen /> : <LocalSettingsRouteScreen />}
112114 </>
113115 );
114116}
......@@ -134,6 +136,7 @@ function LocalSettingsRouteScreen() {
134136 icon="desktopcomputer"
135137 label="Environments"
136138 value={`${environmentCount}`}
139 valuePosition="trailing"
137140 target="SettingsEnvironments"
138141 />
139142 </SettingsSection>
......@@ -516,6 +519,7 @@ function ConfiguredSettingsRouteScreen() {
516519 icon="desktopcomputer"
517520 label="Environments"
518521 value={`${environmentCount}`}
522 valuePosition="trailing"
519523 target="SettingsEnvironments"
520524 />
521525 <SettingsSwitchRow
......@@ -616,6 +620,7 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD
616620 */
617621function AutoSettleSettingsRows() {
618622 const { environments } = useEnvironments();
623 const [pendingWrites, setPendingWrites] = useState(0);
619624 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
620625 label: "server settings update",
621626 reportFailure: true,
......@@ -625,16 +630,17 @@ function AutoSettleSettingsRows() {
625630 const reference = syncTargets[0] ?? null;
626631 const referenceSettings = reference?.serverConfig?.settings ?? null;
627632
628 const [daysDraft, setDaysDraft] = useState<string | null>(null);
629
630633 if (reference === null || referenceSettings === null) {
631634 return null;
632635 }
633636
634637 const writeToAll = (patch: Partial<AutoSettleSettings>) => {
635 for (const environment of syncTargets) {
636 void updateSettings({ environmentId: environment.environmentId, input: { patch } });
637 }
638 setPendingWrites((count) => count + 1);
639 void Promise.allSettled(
640 syncTargets.map((environment) =>
641 updateSettings({ environmentId: environment.environmentId, input: { patch } }),
642 ),
643 ).finally(() => setPendingWrites((count) => count - 1));
638644 };
639645
640646 const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync(
......@@ -647,21 +653,6 @@ function AutoSettleSettingsRows() {
647653 );
648654
649655 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
666657 return (
667658 <>
......@@ -674,28 +665,34 @@ function AutoSettleSettingsRows() {
674665 <SettingsSwitchRow
675666 icon="clock"
676667 label="Auto-settle inactive threads"
677 subtitle={afterDays === null ? undefined : `After ${afterDays} days without activity`}
678668 value={afterDays !== null}
679669 onValueChange={(value) =>
680670 writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null })
681671 }
682672 />
683673 {afterDays !== null ? (
684 <View className="flex-row items-center gap-4 border-t border-border-subtle p-4">
685 <Text className="flex-1 text-lg text-foreground">Days before auto-settle</Text>
686 <TextInput
687 className="min-h-10 w-20 rounded-xl px-3 py-2 text-center text-base"
688 keyboardType="number-pad"
689 returnKeyType="done"
690 value={daysDraft ?? String(afterDays)}
691 onChangeText={setDaysDraft}
692 onBlur={commitDays}
693 onSubmitEditing={commitDays}
694 accessibilityLabel="Days before auto-settle"
674 <View
675 className={cn(
676 "flex-row items-center gap-4 px-4",
677 Platform.OS === "android" ? "min-h-14 py-3" : "py-4",
678 )}
679 >
680 <View style={{ width: Platform.OS === "android" ? 24 : 22 }} />
681 <Text
682 className={cn(
683 "flex-1 text-foreground",
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 })}
695692 />
696693 </View>
697694 ) : null}
698 {mismatches.length > 0 ? (
695 {pendingWrites === 0 && mismatches.length > 0 ? (
699696 <View className="flex-row items-center gap-4 border-t border-border-subtle p-4">
700697 <View className="min-w-0 flex-1">
701698 <Text className="text-lg text-foreground">Auto-settle defaults differ</Text>
......@@ -705,14 +702,7 @@ function AutoSettleSettingsRows() {
705702 </View>
706703 <Pressable
707704 accessibilityRole="button"
708 onPress={() => {
709 for (const mismatch of mismatches) {
710 void updateSettings({
711 environmentId: mismatch.environmentId,
712 input: { patch: autoSettlePatch },
713 });
714 }
715 }}
705 onPress={() => writeToAll(autoSettlePatch)}
716706 className="rounded-full bg-subtle px-4 py-2 active:opacity-70"
717707 >
718708 <Text className="text-base font-t3-medium text-foreground">
......@@ -831,7 +821,7 @@ function AppSettingsSection() {
831821 <SymbolView
832822 name="info.circle"
833823 size={22}
834 tintColorClassName={"accent-icon"}
824 tintColorClassName="accent-icon"
835825 type="monochrome"
836826 weight="regular"
837827 />
apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx+1-19
......@@ -51,9 +51,6 @@ interface AppearancePreferencesContextValue {
5151 readonly themeIds: MobileThemeIds;
5252 readonly themeMode: MobileThemeMode;
5353 readonly themeAppearance: MobileThemeAppearance;
54 readonly materialYouStyleLayoutEnabled: boolean;
55 readonly materialYouStyleLayoutActive: boolean;
56 readonly setMaterialYouStyleLayoutEnabled: (value: boolean) => void;
5754 readonly systemColorsAvailable: boolean;
5855 readonly systemColorsActive: boolean;
5956 readonly themeVariables: MobileThemeVariables;
......@@ -97,8 +94,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
9794 [resolvedThemeIds.dark, resolvedThemeIds.light],
9895 );
9996 const themeId = themeIds[themeAppearance];
100 const materialYouStyleLayoutEnabled = storedPreferences?.materialYouStyleLayoutEnabled ?? false;
101 const materialYouStyleLayoutActive = Platform.OS === "android" && materialYouStyleLayoutEnabled;
10297 const systemColorsActive = themeId === "material-you" && isSystemColorsAvailable;
10398 const [systemColorPalettes, setSystemColorPalettes] = useState(readSystemColorPalettes);
10499 useEffect(() => {
......@@ -120,7 +115,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
120115 }, []);
121116 const themeVariablesByAppearance = useMemo(() => {
122117 const resolve = (appearance: MobileThemeAppearance) => {
123 const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance);
118 const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance, Platform.OS);
124119 return themeIds[appearance] === "material-you" && systemColorPalettes
125120 ? materialYouPaletteToMobileThemeVariables(
126121 systemColorPalettes[appearance],
......@@ -247,13 +242,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
247242 [runtimeState, syncThemeRuntime, updateThemePreferences],
248243 );
249244
250 const setMaterialYouStyleLayoutEnabled = useCallback(
251 (value: boolean) => {
252 updatePreferences({ materialYouStyleLayoutEnabled: value });
253 },
254 [updatePreferences],
255 );
256
257245 const setBaseFontSize = useCallback(
258246 (value: number) => {
259247 const current = appliedRuntimeStateRef.current ?? runtimeState;
......@@ -293,9 +281,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
293281 themeAppearance,
294282 systemColorsAvailable: isSystemColorsAvailable,
295283 systemColorsActive,
296 materialYouStyleLayoutEnabled,
297 materialYouStyleLayoutActive,
298 setMaterialYouStyleLayoutEnabled,
299284 themeVariables,
300285 themeVariablesByAppearance,
301286 systemColorPalettes,
......@@ -315,9 +300,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
315300 themeMode,
316301 themeAppearance,
317302 systemColorsActive,
318 materialYouStyleLayoutEnabled,
319 materialYouStyleLayoutActive,
320 setMaterialYouStyleLayoutEnabled,
321303 themeVariables,
322304 themeVariablesByAppearance,
323305 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 @@
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 (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}
1export { FontSizeSliderRow } from "./FontSizeSliderRow.shared";
apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx+1-18
......@@ -1,5 +1,5 @@
11import { memo, useId } from "react";
2import { Platform, Pressable, View } from "react-native";
2import { Pressable, View } from "react-native";
33import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg";
44import { ScopedTheme, ScopedVariables } from "uniwind";
55
......@@ -19,9 +19,6 @@ import { getMobileUniwindThemeName } from "../../../../lib/mobileThemeRuntime";
1919import { cn } from "../../../../lib/cn";
2020import { useAppearancePreferences } from "../AppearancePreferencesProvider";
2121
22import { SettingsSection } from "../../components/SettingsSection";
23import { SettingsSwitchRow } from "../../components/SettingsSwitchRow";
24
2522const APPEARANCE_MODES: ReadonlyArray<{
2623 readonly id: MobileThemeMode;
2724 readonly label: string;
......@@ -293,25 +290,11 @@ export function ThemeAppearanceSection() {
293290 setThemeMode,
294291 themeIds,
295292 themeMode,
296 materialYouStyleLayoutEnabled,
297 setMaterialYouStyleLayoutEnabled,
298293 systemColorsAvailable,
299294 } = useAppearancePreferences();
300295
301296 return (
302297 <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}
315298 <View className="gap-2">
316299 <SectionLabel>Color scheme</SectionLabel>
317300 <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 @@
1import { MaterialListRow } from "../../../components/MaterialListRow";
12import { useNavigation } from "@react-navigation/native";
23import type { ComponentProps } from "react";
3import { Pressable, View } from "react-native";
4import { Platform, Pressable, View } from "react-native";
45
56import { SymbolView } from "../../../components/AppSymbol";
6
77import { AppText as Text } from "../../../components/AppText";
88import type { SettingsLegalDocumentTarget, SettingsSheetTarget } from "./settings-sheet-targets";
9import { cn } from "../../../lib/cn";
910
1011type SymbolName = ComponentProps<typeof SymbolView>["name"];
1112
......@@ -14,44 +15,77 @@ export function SettingsRow(props: {
1415 readonly icon: SymbolName;
1516 readonly label: string;
1617 readonly value?: string;
18 readonly valuePosition?: "below" | "trailing";
1719 readonly target?: SettingsSheetTarget;
1820 readonly fullScreenTarget?: SettingsLegalDocumentTarget;
1921 readonly onPress?: () => void;
2022}) {
2123 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 }
2260 const content = (
23 <View
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 >
61 <View className={cn("flex-row items-center gap-4 p-4", props.disabled && "opacity-[0.45]")}>
3062 <SymbolView
3163 name={props.icon}
3264 size={22}
33 tintColorClassName={"accent-icon"}
65 tintColorClassName="accent-icon"
3466 type="monochrome"
3567 weight="regular"
3668 />
37 <Text className="shrink-0 text-lg text-foreground" numberOfLines={1}>
38 {props.label}
39 </Text>
40 <View className="min-w-0 flex-1 items-end">
41 {props.value ? (
42 <Text
43 className="max-w-[180px] text-right text-base text-foreground-muted"
44 ellipsizeMode="middle"
45 numberOfLines={1}
46 >
47 {props.value}
48 </Text>
49 ) : null}
50 </View>
69 <>
70 <Text className="shrink-0 text-lg text-foreground" numberOfLines={1}>
71 {props.label}
72 </Text>
73 <View className="min-w-0 flex-1 items-end">
74 {props.value ? (
75 <Text
76 className="max-w-[180px] text-right text-base text-foreground-muted"
77 ellipsizeMode="middle"
78 numberOfLines={1}
79 >
80 {props.value}
81 </Text>
82 ) : null}
83 </View>
84 </>
5185 <SymbolView
5286 name="chevron.right"
5387 size={16}
54 tintColorClassName={"accent-chevron"}
88 tintColorClassName="accent-chevron"
5589 type="monochrome"
5690 weight="semibold"
5791 />
......@@ -92,7 +126,12 @@ export function SettingsRow(props: {
92126 }
93127
94128 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 >
96135 {content}
97136 </Pressable>
98137 );
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 @@
11import type { ReactNode } from "react";
2import { View } from "react-native";
2import { Platform, View } from "react-native";
33
44import { AppText as Text } from "../../../components/AppText";
55
......@@ -12,13 +12,23 @@ export function SettingsSection(props: {
1212 return (
1313 <View className="gap-2">
1414 {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>
1624 ) : null}
1725 <View
1826 className={
19 props.card
20 ? "overflow-hidden rounded-[24px] border-continuous bg-card"
21 : "overflow-hidden rounded-[24px] border-continuous bg-card android:bg-transparent"
27 Platform.OS === "android"
28 ? "overflow-hidden rounded-[28px] bg-card"
29 : props.card
30 ? "overflow-hidden rounded-[24px] border-continuous bg-card"
31 : "overflow-hidden rounded-[24px] border-continuous bg-card android:bg-transparent"
2232 }
2333 >
2434 {props.children}
apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx+18-10
......@@ -1,5 +1,6 @@
1import { cn } from "../../../lib/cn";
12import type { ComponentProps } from "react";
2import { View } from "react-native";
3import { Platform, View } from "react-native";
34
45import { SymbolView } from "../../../components/AppSymbol";
56import { AppText as Text } from "../../../components/AppText";
......@@ -17,21 +18,28 @@ export function SettingsSwitchRow(props: {
1718}) {
1819 return (
1920 <View
20 className={
21 props.disabled
22 ? "flex-row items-center gap-4 p-4 opacity-[0.45]"
23 : "flex-row items-center gap-4 p-4"
24 }
21 className={cn(
22 "flex-row items-center gap-4",
23 Platform.OS === "android" ? "min-h-14 px-4 py-3" : "p-4",
24 Platform.OS === "android" && props.subtitle && "min-h-18",
25 props.disabled && "opacity-[0.45]",
26 )}
2527 >
2628 <SymbolView
2729 name={props.icon}
28 size={22}
29 tintColorClassName={"accent-icon"}
30 size={Platform.OS === "android" ? 24 : 22}
31 tintColorClassName="accent-icon"
3032 type="monochrome"
3133 weight="regular"
3234 />
33 <View className="min-w-0 flex-1">
34 <Text className="text-lg text-foreground">{props.label}</Text>
35 <View className={cn("min-w-0 flex-1", Platform.OS === "android" && "gap-1")}>
36 <Text
37 className={
38 Platform.OS === "android" ? "text-base text-foreground" : "text-lg text-foreground"
39 }
40 >
41 {props.label}
42 </Text>
3543 {props.subtitle ? (
3644 <Text className="text-sm text-foreground-muted">{props.subtitle}</Text>
3745 ) : null}
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+184-158
......@@ -1,4 +1,3 @@
1import { BlurTargetView } from "expo-blur";
21import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
32import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
43import type { MenuAction } from "@react-native-menu/menu";
......@@ -7,6 +6,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac
76import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
87import { useCallback, useEffect, useMemo, useRef, useState } from "react";
98import { Alert, Platform, Pressable, View } from "react-native";
9import { useSafeAreaInsets } from "react-native-safe-area-context";
1010import { AppText as Text } from "../../components/AppText";
1111import { TerminalContextSheet } from "./TerminalContextSheet";
1212import { hasNativeTerminalSurface } from "./nativeTerminalModule";
......@@ -29,6 +29,9 @@ import { ControlPillMenu } from "../../components/ControlPill";
2929import { EmptyState } from "../../components/EmptyState";
3030import { GlassSurface } from "../../components/GlassSurface";
3131import { LoadingScreen } from "../../components/LoadingScreen";
32import { MaterialScreenContent } from "../../components/MaterialScreenContent";
33import { MaterialButton } from "../../components/MaterialButton";
34import { MaterialIconButton } from "../../components/MaterialIconButton";
3235import { environmentCatalog } from "../../connection/catalog";
3336import { useEnvironmentPresentation } from "../../state/presentation";
3437import { terminalEnvironment } from "../../state/terminal";
......@@ -50,6 +53,7 @@ import { useThreadSelection } from "../../state/use-thread-selection";
5053import { useSelectedThreadDetail } from "../../state/use-thread-detail";
5154import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
5255import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
56import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar";
5357import { TerminalSurface } from "./NativeTerminalSurface";
5458import { getMobileTerminalTheme } from "./terminalTheme";
5559import { terminalDebugLog } from "./terminalDebugLog";
......@@ -158,7 +162,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{
158162}>;
159163
160164export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) {
161 const terminalBlurTarget = useRef<View>(null);
165 const insets = useSafeAreaInsets();
162166 const navigation = useNavigation();
163167 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
164168 const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize");
......@@ -190,6 +194,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
190194 themeAppearance: appearanceScheme,
191195 themeId,
192196 setTerminalFontSize,
197 themeVariables,
193198 } = useAppearancePreferences();
194199 const fontSize = appearance.terminalFontSize;
195200 const cachedRouteGridSize =
......@@ -515,6 +520,12 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
515520 isVisible: state.isVisible,
516521 }));
517522 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;
518529 const terminalBottomInset =
519530 (keyboardState.isVisible ? keyboardState.height : 0) +
520531 (isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0);
......@@ -1103,6 +1114,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
11031114 }, []);
11041115
11051116 const handleShowKeyboard = useCallback(() => {
1117 setIsAccessoryDismissed(false);
11061118 setKeyboardFocusRequest((current) => current + 1);
11071119 }, []);
11081120 const handleRetryEnvironment = useCallback(() => {
......@@ -1175,22 +1187,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
11751187 <AndroidScreenHeader
11761188 title="Terminal"
11771189 subtitle={headerSubtitle}
1190 leading={<AndroidWorkspaceSidebarButton />}
11781191 onBack={handleCloseTerminal}
11791192 trailing={
11801193 <>
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}
11941194 {isEnvironmentReady ? (
11951195 <ControlPillMenu
11961196 actions={androidTerminalMenuActions}
......@@ -1280,162 +1280,188 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
12801280 </NativeHeaderToolbar>
12811281 ) : null}
12821282
1283 <View className="flex-1" style={{ backgroundColor: terminalTheme.background }}>
1284 {!isEnvironmentReady ? (
1285 <EnvironmentConnectionNotice
1286 environmentLabel={
1287 environment.presentation?.entry.target.label ??
1288 selectedEnvironmentConnection?.environmentLabel ??
1289 "Environment"
1290 }
1291 connection={
1292 environment.presentation?.connection ?? {
1293 phase: "available",
1294 error: null,
1295 traceId: null,
1283 <MaterialScreenContent>
1284 <View
1285 className="flex-1"
1286 style={{
1287 backgroundColor:
1288 Platform.OS === "android"
1289 ? themeVariables["--color-card-alt"]
1290 : terminalTheme.background,
1291 paddingBottom:
1292 Platform.OS === "android" && !keyboardState.isVisible ? insets.bottom : 0,
1293 }}
1294 >
1295 {!isEnvironmentReady ? (
1296 <EnvironmentConnectionNotice
1297 environmentLabel={
1298 environment.presentation?.entry.target.label ??
1299 selectedEnvironmentConnection?.environmentLabel ??
1300 "Environment"
12961301 }
1297 }
1298 resourceName="terminal"
1299 onRetry={handleRetryEnvironment}
1300 />
1301 ) : (
1302 <>
1303 <BlurTargetView
1304 ref={terminalBlurTarget}
1305 style={{
1306 flex: 1,
1307 paddingBottom: terminalBottomInset,
1308 }}
1309 >
1302 connection={
1303 environment.presentation?.connection ?? {
1304 phase: "available",
1305 error: null,
1306 traceId: null,
1307 }
1308 }
1309 resourceName="terminal"
1310 onRetry={handleRetryEnvironment}
1311 />
1312 ) : (
1313 <>
13101314 <View
1311 pointerEvents="none"
13121315 style={{
1313 position: "absolute",
1314 inset: 0,
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.");
1316 flex: 1,
1317 paddingBottom: terminalBottomInset,
13281318 }}
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 }}
13531319 >
13541320 <View
1355 className="border-t"
1321 pointerEvents="none"
13561322 style={{
1323 position: "absolute",
1324 inset: 0,
13571325 backgroundColor: terminalTheme.background,
1358 borderTopColor: terminalTheme.border,
1359 minHeight: TERMINAL_ACCESSORY_HEIGHT,
13601326 }}
1361 >
1362 <ComposerToolbarRow paddingBottom={4} paddingHorizontal={8} paddingTop={4}>
1363 <ComposerToolbarScroller
1364 contentPaddingRight={2}
1365 fadeOpaque={terminalTheme.background}
1366 fadeTransparent={`${terminalTheme.background}00`}
1367 >
1368 {terminalToolbarActions.map((action) => {
1369 const active =
1370 action.kind === "modifier" && pendingModifier === action.modifier;
1371
1372 return (
1373 <ComposerToolbarButton
1374 key={action.key}
1375 active={active}
1376 label={action.label}
1377 maxWidth={120}
1378 minWidth={action.label.length > 1 ? 56 : 44}
1379 onPress={() => handleToolbarActionPress(action)}
1380 showChevron={false}
1381 textTransform={
1382 action.kind === "modifier" || action.kind === "clear"
1383 ? "uppercase"
1384 : "none"
1385 }
1386 />
1387 );
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}
1327 />
1328 <TerminalSurface
1329 autoFocus={terminalAutoFocus}
1330 buffer={terminalSurfaceBuffer}
1331 fontSize={fontSize}
1332 isRunning={isRunning}
1333 keyboardFocusRequest={keyboardFocusRequest}
1334 captureRequest={captureRequest}
1335 onCapture={(text) => {
1336 if (text.trim()) setCapturedOutput(text);
1337 else Alert.alert("No terminal output", "There is no visible output to attach.");
1338 }}
1339 onInput={handleInput}
1340 onResize={handleResize}
1341 style={{ flex: 1 }}
1342 terminalKey={terminalKey}
1343 theme={terminalTheme}
1344 />
1345 </View>
1346
1347 {Platform.OS === "android" && !keyboardState.isVisible ? (
1348 <View className="min-h-14 flex-row items-center gap-2 bg-card-alt px-2">
1349 {selectedThread && hasNativeTerminalSurface() ? (
1350 <MaterialButton
1351 label="Attach output"
1352 tone="text"
1353 onPress={() => setCaptureRequest((value) => value + 1)}
13951354 />
1396 </ComposerToolbarRow>
1355 ) : null}
1356 <View className="flex-1" />
1357 <MaterialIconButton
1358 accessibilityLabel="Show keyboard"
1359 icon="keyboard"
1360 onPress={handleShowKeyboard}
1361 />
13971362 </View>
1398 </KeyboardStickyView>
1399 ) : !keyboardState.isVisible ? (
1400 <Pressable
1401 accessibilityLabel="Show keyboard"
1402 accessibilityRole="button"
1403 onPress={handleShowKeyboard}
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,
1363 ) : Platform.OS !== "android" && selectedThread && hasNativeTerminalSurface() ? (
1364 <Pressable
1365 accessibilityRole="button"
1366 onPress={() => {
1367 KeyboardController.dismiss();
1368 setCaptureRequest((value) => value + 1);
14241369 }}
1425 pointerEvents="none"
1370 className="px-4 py-2"
14261371 >
1427 <SymbolView
1428 name={{ ios: "keyboard", android: "keyboard" }}
1429 size={20}
1430 tintColor={terminalTheme.foreground}
1431 type="monochrome"
1432 />
1433 </GlassSurface>
1434 </Pressable>
1435 ) : null}
1436 </>
1437 )}
1438 </View>
1372 <Text style={{ color: terminalTheme.foreground }}>Attach visible output</Text>
1373 </Pressable>
1374 ) : null}
1375 {isAccessoryVisible ? (
1376 <KeyboardStickyView
1377 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
1378 offset={{ closed: 0, opened: 0 }}
1379 >
1380 <View
1381 className="border-t"
1382 style={{
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>
14391465 </>
14401466 );
14411467}
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 {
33 DatePicker,
44 Host,
55 HStack,
6 Menu,
76 Picker,
87 Popover,
98 Spacer,
......@@ -11,16 +10,20 @@ import {
1110 VStack,
1211} from "@expo/ui/swift-ui";
1312import {
14 background,
13 accessibilityAddTraits,
14 accessibilityHidden,
15 buttonBorderShape,
1516 buttonStyle,
16 datePickerStyle,
17 clipped,
18 controlSize,
1719 font,
20 datePickerStyle,
1821 foregroundStyle,
1922 frame,
23 labelsHidden,
24 labelStyle,
2025 padding,
2126 pickerStyle,
22 presentationBackground,
23 shapes,
2427 tag,
2528} from "@expo/ui/swift-ui/modifiers";
2629import {
......@@ -30,9 +33,8 @@ import {
3033 type CustomSnoozeInput,
3134} from "@t3tools/client-runtime/state/thread-settled";
3235import { useState } from "react";
33import { Modal, Pressable, ScrollView, View } from "react-native";
34import { AppText } from "../../components/AppText";
35import { SegmentedControl } from "../../components/SegmentedControl";
36import { useWindowDimensions } from "react-native";
37import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
3638import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
3739
3840const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1);
......@@ -50,13 +52,13 @@ export function CustomSnoozeSheet(props: {
5052 readonly onClose: () => void;
5153 readonly onSnooze: (snoozedUntil: string) => void;
5254}) {
55 const { width } = useWindowDimensions();
5356 const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date");
5457 const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000));
5558 const [amount, setAmount] = useState(2);
56 const [amountOpen, setAmountOpen] = useState(false);
5759 const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours");
5860 const [error, setError] = useState<string | null>(null);
59 const { themeVariables: colors, themeAppearance, appearance } = useAppearancePreferences();
61 const { themeVariables: colors, themeAppearance } = useAppearancePreferences();
6062 const updateDate = (value: Date) => {
6163 setDate(value);
6264 setError(null);
......@@ -79,190 +81,150 @@ export function CustomSnoozeSheet(props: {
7981 };
8082
8183 return (
82 <Modal visible transparent animationType="fade" onRequestClose={props.onClose}>
83 <View className="flex-1 items-center justify-center bg-black/40 px-6">
84 <ScrollView
85 className="max-h-[80%] w-full max-w-sm grow-0 rounded-3xl border border-border bg-sheet-solid"
86 contentContainerStyle={{ padding: 20, gap: 16 }}
87 >
88 <View className="flex-row items-center justify-between gap-2">
89 <Pressable accessibilityRole="button" onPress={props.onClose} hitSlop={10}>
90 <AppText className="text-base">Cancel</AppText>
91 </Pressable>
92 <AppText accessibilityRole="header" className="text-base font-t3-semibold">
93 Custom snooze
94 </AppText>
95 <Pressable accessibilityRole="button" onPress={submit} hitSlop={10}>
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"
84 <Host
85 style={{ position: "absolute", top: 0, right: 0, bottom: 0, left: 0 }}
86 colorScheme={themeAppearance}
87 seedColor={colors["--color-primary"]}
88 >
89 <Popover
90 isPresented
91 onIsPresentedChange={(presented) => {
92 if (!presented) props.onClose();
93 }}
94 >
95 <Popover.Trigger>
96 <Spacer
97 modifiers={[frame({ maxWidth: Infinity, maxHeight: Infinity }), accessibilityHidden()]}
10798 />
108 <Host
109 matchContents={{ vertical: true }}
110 style={{ width: "100%" }}
111 colorScheme={themeAppearance}
112 seedColor={colors["--color-primary"]}
99 </Popover.Trigger>
100 <Popover.Content>
101 <VStack
102 spacing={16}
103 modifiers={[padding({ all: 16 }), frame({ width: Math.min(360, width - 32) })]}
113104 >
114 <HStack
115 spacing={6}
116 modifiers={[
117 frame({ minHeight: 44 }),
118 foregroundStyle(colors["--color-foreground"]),
119 font({ size: appearance.baseFontSize }),
120 ]}
121 >
122 <Text>{mode === "date" ? "Until" : "Snooze for"}</Text>
123 <Spacer />
124 {mode === "date" ? (
125 <>
126 <SnoozeDateField date={date} component="date" onChange={updateDate} />
127 <SnoozeDateField date={date} component="hourAndMinute" onChange={updateDate} />
128 </>
129 ) : (
130 <>
131 <Popover isPresented={amountOpen} onIsPresentedChange={setAmountOpen}>
132 <Popover.Trigger>
133 <Button
134 onPress={() => setAmountOpen(true)}
135 modifiers={[buttonStyle("plain")]}
136 >
137 <FieldLabel label={String(amount)} />
138 </Button>
139 </Popover.Trigger>
140 <Popover.Content>
141 <VStack
142 modifiers={[
143 padding({ all: 12 }),
144 frame({ width: 180 }),
145 presentationBackground(colors["--color-sheet-solid"]),
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 )}
105 <HStack spacing={12}>
106 <Button
107 label="Cancel"
108 systemImage="xmark"
109 role="cancel"
110 onPress={props.onClose}
111 modifiers={[
112 labelStyle("iconOnly"),
113 buttonStyle(NATIVE_LIQUID_GLASS_SUPPORTED ? "glass" : "bordered"),
114 buttonBorderShape("circle"),
115 controlSize("large"),
116 ]}
117 />
118 <Text
119 modifiers={[
120 font({ textStyle: "headline" }),
121 frame({ maxWidth: Infinity, alignment: "leading" }),
122 accessibilityAddTraits(["isHeader"]),
123 ]}
124 >
125 Custom snooze
126 </Text>
127 <Button
128 label="Snooze"
129 onPress={submit}
130 modifiers={[
131 buttonStyle(
132 NATIVE_LIQUID_GLASS_SUPPORTED ? "glassProminent" : "borderedProminent",
133 ),
134 controlSize("large"),
135 ]}
136 />
197137 </HStack>
198 </Host>
199 {error && <AppText className="text-sm text-danger-foreground">{error}</AppText>}
200 </ScrollView>
201 </View>
202 </Modal>
203 );
204}
205
206function FieldLabel(props: { readonly label: string }) {
207 const { themeVariables: colors } = useAppearancePreferences();
208 return (
209 <Text
210 modifiers={[
211 padding({ horizontal: 12, vertical: 8 }),
212 foregroundStyle(colors["--color-foreground"]),
213 background(colors["--color-card"], shapes.capsule()),
214 ]}
215 >
216 {props.label}
217 </Text>
218 );
219}
220
221function SnoozeDateField(props: {
222 readonly date: Date;
223 readonly component: "date" | "hourAndMinute";
224 readonly onChange: (date: Date) => void;
225}) {
226 const [open, setOpen] = useState(false);
227 const { themeVariables: colors } = useAppearancePreferences();
228 return (
229 <Popover isPresented={open} onIsPresentedChange={setOpen}>
230 <Popover.Trigger>
231 <Button onPress={() => setOpen(true)} modifiers={[buttonStyle("plain")]}>
232 <FieldLabel
233 label={
234 props.component === "date"
235 ? props.date.toLocaleDateString(undefined, {
236 month: "short",
237 day: "numeric",
238 year: "numeric",
239 })
240 : props.date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
241 }
242 />
243 </Button>
244 </Popover.Trigger>
245 <Popover.Content>
246 <VStack
247 modifiers={[
248 padding({ all: 12 }),
249 frame({ width: props.component === "date" ? 340 : 300 }),
250 presentationBackground(colors["--color-sheet-solid"]),
251 ]}
252 >
253 <DatePicker
254 selection={props.date}
255 displayedComponents={[props.component]}
256 onDateChange={props.onChange}
257 modifiers={[datePickerStyle(props.component === "date" ? "graphical" : "wheel")]}
258 />
259 <Button
260 label="Done"
261 onPress={() => setOpen(false)}
262 modifiers={[foregroundStyle(colors["--color-primary"])]}
263 />
264 </VStack>
265 </Popover.Content>
266 </Popover>
138 <Picker
139 label="Snooze mode"
140 selection={mode}
141 onSelectionChange={(value: CustomSnoozeInput["mode"]) => {
142 setMode(value);
143 setError(null);
144 }}
145 modifiers={[pickerStyle("segmented")]}
146 >
147 {modes.map((option) => (
148 <Text key={option.value} modifiers={[tag(option.value)]}>
149 {option.label}
150 </Text>
151 ))}
152 </Picker>
153 {mode === "date" ? (
154 <DatePicker
155 title="Snooze until"
156 selection={date}
157 displayedComponents={["date", "hourAndMinute"]}
158 onDateChange={updateDate}
159 modifiers={[
160 datePickerStyle("wheel"),
161 labelsHidden(),
162 frame({ maxWidth: Infinity, height: 180 }),
163 ]}
164 />
165 ) : (
166 <HStack spacing={0}>
167 <Picker
168 label="Duration amount"
169 selection={amount}
170 onSelectionChange={(value: number) => {
171 setAmount(value);
172 setError(null);
173 }}
174 modifiers={[
175 pickerStyle("wheel"),
176 labelsHidden(),
177 frame({ minWidth: 0, maxWidth: Infinity, height: 180 }),
178 clipped(),
179 ]}
180 >
181 {durationAmounts.map((value) => (
182 <Text
183 key={value}
184 modifiers={[tag(value), foregroundStyle(colors["--color-foreground"])]}
185 >
186 {String(value)}
187 </Text>
188 ))}
189 </Picker>
190 <Picker
191 label="Duration unit"
192 selection={unit}
193 onSelectionChange={(value: typeof unit) => {
194 setUnit(value);
195 setError(null);
196 }}
197 modifiers={[
198 pickerStyle("wheel"),
199 labelsHidden(),
200 frame({ minWidth: 0, maxWidth: Infinity, height: 180 }),
201 clipped(),
202 ]}
203 >
204 {units.map((option) => (
205 <Text
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>
267229 );
268230}
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 @@
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 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}
1export { CustomSnoozeSheet } from "./CustomSnoozeSheet.shared";
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+122-51
......@@ -1,3 +1,4 @@
1import { MaterialListRow } from "../../components/MaterialListRow";
12import type { VcsRef } from "@t3tools/client-runtime/state/vcs";
23import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
34import { LegendList } from "@legendapp/list/react-native";
......@@ -20,6 +21,7 @@ import {
2021import { useSafeAreaInsets } from "react-native-safe-area-context";
2122
2223import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
24import { MaterialScreenContent } from "../../components/MaterialScreenContent";
2325import { SymbolView } from "../../components/AppSymbol";
2426import { AppText as Text } from "../../components/AppText";
2527import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
......@@ -46,6 +48,34 @@ function SelectionRow(props: {
4648 readonly subtitle?: string;
4749 readonly title: string;
4850}) {
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 }
4979 return (
5080 <Pressable
5181 accessibilityLabel={[props.title, props.subtitle].filter(Boolean).join(", ")}
......@@ -63,7 +93,7 @@ function SelectionRow(props: {
6393 <SymbolView
6494 name="arrow.triangle.branch"
6595 size={17}
66 tintColorClassName={"accent-icon-muted"}
96 tintColorClassName="accent-icon-muted"
6797 type="monochrome"
6898 />
6999 ) : (
......@@ -83,7 +113,7 @@ function SelectionRow(props: {
83113 <SymbolView
84114 name="checkmark"
85115 size={16}
86 tintColorClassName={"accent-icon"}
116 tintColorClassName="accent-icon"
87117 type="monochrome"
88118 weight="semibold"
89119 />
......@@ -99,7 +129,13 @@ function ToggleRow(props: {
99129}) {
100130 return (
101131 <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 >
103139 {props.title}
104140 </Text>
105141 <ThemedSwitch
......@@ -125,8 +161,14 @@ function BranchSelectionRow(props: {
125161 return (
126162 <View
127163 className={cn(
128 props.isFirst && "overflow-hidden rounded-t-2xl",
129 props.isLast && "overflow-hidden rounded-b-2xl",
164 props.isFirst &&
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"),
130172 )}
131173 >
132174 <SelectionRow
......@@ -143,7 +185,17 @@ function BranchSelectionRow(props: {
143185}
144186
145187function 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 );
147199}
148200
149201export function NewTaskEnvironmentPickerRouteScreen() {
......@@ -161,42 +213,48 @@ export function NewTaskEnvironmentPickerRouteScreen() {
161213 }}
162214 />
163215 {Platform.OS === "android" ? (
164 <AndroidScreenHeader title="Environment" onBack={() => navigation.goBack()} />
216 <AndroidScreenHeader
217 title="Environment"
218 hideBottomBorder
219 onBack={() => navigation.goBack()}
220 />
165221 ) : null}
166 <ScrollView
167 contentInsetAdjustmentBehavior="automatic"
168 contentContainerStyle={{
169 paddingBottom: Math.max(insets.bottom, 16) + 16,
170 paddingHorizontal: 16,
171 paddingTop: 16,
172 }}
173 showsVerticalScrollIndicator={false}
174 >
175 <PickerSurface>
176 {flow.environments.map((environment, index) => (
177 <SelectionRow
178 key={String(environment.environmentId)}
179 icon={
180 <EnvironmentMachineSymbol
181 kind={resolveEnvironmentMachineKind(
182 serverConfigs.get(environment.environmentId) ?? null,
183 )}
184 size={17}
185 tintColorClassName="accent-icon-muted"
186 />
187 }
188 isLast={index === flow.environments.length - 1}
189 onPress={() => {
190 void Haptics.selectionAsync();
191 flow.selectEnvironment(environment.environmentId);
192 navigation.goBack();
193 }}
194 selected={flow.selectedEnvironmentId === environment.environmentId}
195 title={environment.environmentLabel}
196 />
197 ))}
198 </PickerSurface>
199 </ScrollView>
222 <MaterialScreenContent>
223 <ScrollView
224 contentInsetAdjustmentBehavior="automatic"
225 contentContainerStyle={{
226 paddingBottom: Math.max(insets.bottom, 16) + 16,
227 paddingHorizontal: 16,
228 paddingTop: 16,
229 }}
230 showsVerticalScrollIndicator={false}
231 >
232 <PickerSurface>
233 {flow.environments.map((environment, index) => (
234 <SelectionRow
235 key={String(environment.environmentId)}
236 icon={
237 <EnvironmentMachineSymbol
238 kind={resolveEnvironmentMachineKind(
239 serverConfigs.get(environment.environmentId) ?? null,
240 )}
241 size={Platform.OS === "android" ? 24 : 17}
242 tintColorClassName="accent-icon-muted"
243 />
244 }
245 isLast={index === flow.environments.length - 1}
246 onPress={() => {
247 void Haptics.selectionAsync();
248 flow.selectEnvironment(environment.environmentId);
249 navigation.goBack();
250 }}
251 selected={flow.selectedEnvironmentId === environment.environmentId}
252 title={environment.environmentLabel}
253 />
254 ))}
255 </PickerSurface>
256 </ScrollView>
257 </MaterialScreenContent>
200258 </View>
201259 );
202260}
......@@ -225,7 +283,7 @@ export function NewTaskBranchPickerRouteScreen() {
225283 ? 16
226284 : Math.max(insets.bottom, 16) + 16,
227285 paddingHorizontal: 16,
228 paddingTop: 12,
286 paddingTop: 16,
229287 }),
230288 [insets.bottom, usesNativeMailSearchToolbar],
231289 );
......@@ -327,7 +385,12 @@ export function NewTaskBranchPickerRouteScreen() {
327385
328386 const branchListHeader =
329387 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 >
331394 <ToggleRow
332395 onValueChange={flow.setStartFromOrigin}
333396 title="Start from origin"
......@@ -339,9 +402,9 @@ export function NewTaskBranchPickerRouteScreen() {
339402 const branchContent =
340403 flow.filteredBranches.length === 0 ? (
341404 <ScrollView
342 className="flex-1 bg-sheet"
405 className={Platform.OS === "android" ? "flex-1 bg-sheet-solid" : "flex-1 bg-sheet"}
343406 contentInsetAdjustmentBehavior="automatic"
344 contentContainerStyle={{ flexGrow: 1, paddingHorizontal: 16, paddingTop: 12 }}
407 contentContainerStyle={{ flexGrow: 1, paddingHorizontal: 16, paddingTop: 16 }}
345408 scrollEnabled={false}
346409 showsVerticalScrollIndicator={false}
347410 >
......@@ -380,7 +443,7 @@ export function NewTaskBranchPickerRouteScreen() {
380443 alwaysBounceVertical={false}
381444 automaticallyAdjustsScrollIndicatorInsets
382445 automaticallyAdjustKeyboardInsets={Platform.OS === "ios"}
383 className="flex-1 bg-sheet"
446 className={Platform.OS === "android" ? "flex-1 bg-sheet-solid" : "flex-1 bg-sheet"}
384447 contentInsetAdjustmentBehavior="automatic"
385448 contentContainerStyle={branchListContentStyle}
386449 data={flow.filteredBranches}
......@@ -408,19 +471,27 @@ export function NewTaskBranchPickerRouteScreen() {
408471 return (
409472 <View className="flex-1 bg-sheet" collapsable={false}>
410473 <NativeStackScreenOptions options={{ headerShown: false }} />
411 <AndroidScreenHeader title={screenTitle} onBack={() => navigation.goBack()} />
412 <View className="px-4 pb-2 pt-3">
474 <AndroidScreenHeader
475 title={screenTitle}
476 hideBottomBorder
477 onBack={() => navigation.goBack()}
478 />
479 <View className="bg-header px-4 pb-3 pt-1">
413480 <TextInput
414481 autoCapitalize="none"
415482 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"
417488 onChangeText={flow.setBranchQuery}
418489 placeholder="Find a branch"
419 placeholderTextColorClassName={"accent-placeholder"}
490 placeholderTextColorClassName="accent-placeholder"
420491 value={flow.branchQuery}
421492 />
422493 </View>
423 {branchContent}
494 <MaterialScreenContent>{branchContent}</MaterialScreenContent>
424495 </View>
425496 );
426497 }
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+25-16
......@@ -27,12 +27,10 @@ import Animated from "react-native-reanimated";
2727import { useSafeAreaInsets } from "react-native-safe-area-context";
2828import { useUniwindTheme } from "../../lib/useUniwindTheme";
2929import { useFontFamily } from "../../lib/useFontFamily";
30
3130import {
3231 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
3332 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
3433 resolveEnvironmentMachineKind,
35 type EnvironmentId,
3634} from "@t3tools/contracts";
3735
3836import {
......@@ -51,6 +49,7 @@ import {
5149 ComposerToolbarRow,
5250} from "../../components/ComposerToolbar";
5351import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
52import { MaterialScreenContent } from "../../components/MaterialScreenContent";
5453import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
5554import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
5655import { composerStripAttachments } from "../../lib/composerImages";
......@@ -81,7 +80,6 @@ import {
8180 useThreadSettingsSheetPresentation,
8281 type NavigationWithFinishTransitioning,
8382} from "./use-thread-settings-sheet-presentation";
84
8583import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
8684import {
8785 convertPastedImagesToAttachments,
......@@ -140,7 +138,7 @@ function NewTaskWorkspaceIcon(props: {
140138 <SymbolView
141139 name="folder"
142140 size={16}
143 tintColorClassName={"accent-icon-muted"}
141 tintColorClassName="accent-icon-muted"
144142 type="monochrome"
145143 />
146144 );
......@@ -151,14 +149,14 @@ function NewTaskWorkspaceIcon(props: {
151149 <SymbolView
152150 name="folder"
153151 size={16}
154 tintColorClassName={"accent-icon-muted"}
152 tintColorClassName="accent-icon-muted"
155153 type="monochrome"
156154 />
157155 <View className="absolute -right-1 -bottom-1">
158156 <SymbolView
159157 name="arrow.triangle.branch"
160158 size={9}
161 tintColorClassName={"accent-icon-muted"}
159 tintColorClassName="accent-icon-muted"
162160 type="monochrome"
163161 />
164162 </View>
......@@ -1322,7 +1320,11 @@ export function NewTaskDraftScreen(props: {
13221320 {Platform.OS === "android" ? (
13231321 <>
13241322 <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 />
13261328 </>
13271329 ) : (
13281330 <NativeStackScreenOptions options={{ title: "Loading task" }} />
......@@ -1540,7 +1542,12 @@ export function NewTaskDraftScreen(props: {
15401542 );
15411543
15421544 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 >
15441551 {!voiceInput.isBusy &&
15451552 composerMenu.trigger &&
15461553 (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request") ? (
......@@ -1754,15 +1761,17 @@ export function NewTaskDraftScreen(props: {
17541761 return (
17551762 <View className="flex-1 bg-sheet" collapsable={false}>
17561763 <NativeStackScreenOptions options={{ headerShown: false }} />
1757 <AndroidScreenHeader title="New task" onBack={closeNewTask} />
1758 {heroViewport}
1764 <AndroidScreenHeader title="New thread" hideBottomBorder onBack={closeNewTask} />
1765 <MaterialScreenContent>
1766 {heroViewport}
17591767
1760 <KeyboardStickyView
1761 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
1762 offset={{ closed: 0, opened: keyboardOpenedOffset }}
1763 >
1764 {composerDock}
1765 </KeyboardStickyView>
1768 <KeyboardStickyView
1769 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
1770 offset={{ closed: 0, opened: keyboardOpenedOffset }}
1771 >
1772 {composerDock}
1773 </KeyboardStickyView>
1774 </MaterialScreenContent>
17661775 </View>
17671776 );
17681777 }
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+154-91
......@@ -1,3 +1,4 @@
1import { MaterialListRow } from "../../components/MaterialListRow";
12import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
23import {
34 StackActions,
......@@ -8,11 +9,13 @@ import {
89import { SymbolView } from "../../components/AppSymbol";
910import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
1011import { useEffect, useRef } from "react";
11import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native";
12import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native";
1213import { useSafeAreaInsets } from "react-native-safe-area-context";
1314import { cn } from "../../lib/cn";
14
1515import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
16import { MaterialScreenContent } from "../../components/MaterialScreenContent";
17import { MaterialButton } from "../../components/MaterialButton";
18import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
1619import { AppText as Text } from "../../components/AppText";
1720import { ProjectFavicon } from "../../components/ProjectFavicon";
1821import { useProjects } from "../../state/entities";
......@@ -183,8 +186,9 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
183186 <NativeStackScreenOptions options={{ headerShown: false }} />
184187 <AndroidScreenHeader
185188 title={screenTitle}
189 hideBottomBorder
186190 subtitle={incomingShareSubtitle}
187 onBack={layout.usesSplitView ? () => navigation.goBack() : undefined}
191 onBack={() => navigation.goBack()}
188192 actions={
189193 catalogState.hasReadyEnvironment
190194 ? [
......@@ -226,97 +230,156 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
226230 </>
227231 )}
228232
229 <ScrollView
230 contentInsetAdjustmentBehavior="automatic"
231 showsVerticalScrollIndicator={false}
232 className="flex-1"
233 contentContainerStyle={{
234 gap: 12,
235 paddingBottom: Math.max(insets.bottom, 18) + 18,
236 paddingHorizontal: 20,
237 paddingTop: 8,
238 }}
239 >
240 {projectScopes.length === 0 ? (
241 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8">
242 {projectEmptyState.loading ? (
243 <ActivityIndicator colorClassName={"accent-icon-muted"} />
244 ) : null}
245 <Text className="text-center text-lg font-t3-bold text-foreground">
246 {projectEmptyState.title}
247 </Text>
248 <Text className="text-center text-sm leading-normal text-foreground-muted">
249 {projectEmptyState.detail}
250 </Text>
251 {!catalogState.hasReadyEnvironment ? (
252 <Pressable
253 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
254 onPress={() => navigation.navigate("ConnectionsNew")}
255 >
256 <Text className="text-sm font-t3-bold text-primary-foreground">
257 Add environment
258 </Text>
259 </Pressable>
260 ) : (
261 <Pressable
262 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
263 onPress={() => navigation.dispatch(StackActions.push("AddProject"))}
264 >
265 <Text className="text-sm font-t3-bold text-primary-foreground">
266 Add new project
267 </Text>
268 </Pressable>
269 )}
270 </View>
271 ) : (
272 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
273 {projectScopes.map((scope, scopeIndex) => {
274 const hasMultipleProjects = scope.projects.length > 1;
275 const selectionTarget = getProjectScopeSelectionTarget(scope, selectedEnvironmentId);
276 return (
277 <View
278 key={scope.key}
279 className={cn(scopeIndex > 0 && "border-t border-border-subtle")}
233 <MaterialScreenContent>
234 <ScrollView
235 contentInsetAdjustmentBehavior="automatic"
236 showsVerticalScrollIndicator={false}
237 className="flex-1"
238 contentContainerStyle={{
239 gap: Platform.OS === "android" ? 8 : 12,
240 paddingBottom: Math.max(insets.bottom, 18) + 18,
241 paddingHorizontal: Platform.OS === "android" ? 16 : 20,
242 paddingTop: Platform.OS === "android" ? 16 : 8,
243 ...(Platform.OS === "android" && projectScopes.length === 0
244 ? { flexGrow: 1, justifyContent: "center" as const }
245 : {}),
246 }}
247 >
248 {projectScopes.length === 0 ? (
249 <View
250 collapsable={false}
251 className={cn(
252 "items-center gap-3 px-6 py-8",
253 Platform.OS !== "android" && "rounded-[24px] bg-card",
254 )}
255 >
256 {projectEmptyState.loading ? (
257 <ActivityIndicator colorClassName="accent-icon-muted" />
258 ) : null}
259 <Text className="text-center text-lg font-t3-bold text-foreground">
260 {projectEmptyState.title}
261 </Text>
262 <Text className="text-center text-sm leading-normal text-foreground-muted">
263 {projectEmptyState.detail}
264 </Text>
265 {Platform.OS === "android" ? (
266 <MaterialButton
267 label={catalogState.hasReadyEnvironment ? "Add new project" : "Add environment"}
268 tone="primary"
269 onPress={() =>
270 catalogState.hasReadyEnvironment
271 ? navigation.dispatch(StackActions.push("AddProject"))
272 : navigation.navigate("ConnectionsNew")
273 }
274 />
275 ) : !catalogState.hasReadyEnvironment ? (
276 <Pressable
277 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
278 onPress={() => navigation.navigate("ConnectionsNew")}
280279 >
281 <Pressable
282 disabled={reservedDestinationProject !== null}
283 onPress={() => void selectProject(selectionTarget)}
284 className="flex-row items-center gap-3 bg-card px-4 py-3.5"
285 >
286 <View className="h-7 w-7 items-center justify-center">
287 <ProjectFavicon
288 environmentId={scope.representative.environmentId}
289 faviconPath={scope.representative.faviconPath}
290 size={20}
291 projectTitle={scope.title}
292 workspaceRoot={scope.representative.workspaceRoot}
293 />
294 </View>
295 <View className="min-w-0 flex-1">
296 <Text className="text-base leading-snug font-t3-bold">{scope.title}</Text>
297 <Text
298 className="text-xs leading-snug text-foreground-muted"
299 ellipsizeMode="middle"
300 numberOfLines={1}
301 >
302 {hasMultipleProjects
280 <Text className="text-sm font-t3-bold text-primary-foreground">
281 Add environment
282 </Text>
283 </Pressable>
284 ) : (
285 <Pressable
286 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
287 onPress={() => navigation.dispatch(StackActions.push("AddProject"))}
288 >
289 <Text className="text-sm font-t3-bold text-primary-foreground">
290 Add new project
291 </Text>
292 </Pressable>
293 )}
294 </View>
295 ) : (
296 <View
297 collapsable={false}
298 className={
299 Platform.OS === "android"
300 ? "overflow-hidden rounded-[28px] bg-card"
301 : "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
303317 ? `${scope.projects.length} workspaces`
304 : selectionTarget.workspaceRoot}
305 </Text>
306 </View>
307 <SymbolView
308 name="chevron.right"
309 size={14}
310 tintColorClassName={"accent-chevron"}
311 type="monochrome"
318 : selectionTarget.workspaceRoot
319 }
320 disabled={reservedDestinationProject !== null}
321 onPress={() => void selectProject(selectionTarget)}
322 leading={
323 <ProjectFavicon
324 environmentId={scope.representative.environmentId}
325 faviconPath={scope.representative.faviconPath}
326 size={24}
327 projectTitle={scope.title}
328 workspaceRoot={scope.representative.workspaceRoot}
329 />
330 }
312331 />
313 </Pressable>
314 </View>
315 );
316 })}
317 </View>
318 )}
319 </ScrollView>
332 );
333 }
334 return (
335 <View
336 key={scope.key}
337 className={cn(scopeIndex > 0 && "border-t border-border-subtle")}
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>
320383 </View>
321384 );
322385}
apps/mobile/src/features/threads/ThreadComposer.tsx+8-12
......@@ -49,6 +49,7 @@ import Animated, {
4949 withTiming,
5050} from "react-native-reanimated";
5151import { useUniwindTheme } from "../../lib/useUniwindTheme";
52import { themeColorWithAlpha } from "../../lib/mobileTheme";
5253import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
5354import { scopedThreadKey } from "../../lib/scopedEntities";
5455import {
......@@ -208,7 +209,6 @@ export function ComposerSurface(props: {
208209 /** Morphs between the compact and expanded composer layouts. */
209210 readonly animateLayout?: boolean;
210211}) {
211 const { materialYouStyleLayoutActive } = useAppearancePreferences();
212212 const colors = useUniwindTheme();
213213 const targetBorderRadius =
214214 typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0;
......@@ -232,27 +232,23 @@ export function ComposerSurface(props: {
232232 return (
233233 <Animated.View
234234 className={
235 materialYouStyleLayoutActive
236 ? undefined
237 : "shadow-[0_6px_28px] shadow-adaptive-black-a15-a35"
235 Platform.OS === "android" ? undefined : "shadow-[0_6px_28px] shadow-adaptive-black-a15-a35"
238236 }
239237 layout={layoutTransition}
240238 style={[
241239 animatedShapeStyle,
242240 {
243241 overflow: "hidden",
244 // Android versions before 9 do not support outset box shadows.
245 elevation: Platform.OS === "android" && Platform.Version < 28 ? 10 : undefined,
246242 },
247243 ]}
248244 >
249245 <AnimatedGlassSurface
250246 chrome="none"
251247 fallbackColor={
252 materialYouStyleLayoutActive ? colors["--color-composer-surface"] : colors["--color-card"]
248 Platform.OS === "android" ? colors["--color-composer-surface"] : colors["--color-card"]
253249 }
254250 fallbackClassName={
255 materialYouStyleLayoutActive ? "border border-composer-border" : "border border-border"
251 Platform.OS === "android" ? "border border-composer-border" : "border border-border"
256252 }
257253 glassEffectStyle="regular"
258254 // The composer is a passive material containing interactive controls.
......@@ -277,8 +273,7 @@ export function ComposerSurface(props: {
277273
278274export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
279275 const project = useProject(scopeProjectRef(props.environmentId, props.selectedThread.projectId));
280 const { materialYouStyleLayoutActive, themeVariables: materialTheme } =
281 useAppearancePreferences();
276 const { themeVariables: materialTheme } = useAppearancePreferences();
282277 const composerPanel = materialTheme["--color-composer-panel"];
283278 const navigation = useNavigation();
284279 const foregroundColor = useUniwindTheme()["--color-foreground"];
......@@ -627,7 +622,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
627622 style={{
628623 paddingTop: isExpanded ? 8 : 6,
629624 paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6),
630 backgroundColor: materialYouStyleLayoutActive ? composerPanel : undefined,
625 backgroundColor:
626 Platform.OS === "android" ? themeColorWithAlpha(composerPanel, 1) : undefined,
631627 }}
632628 >
633629 {/* The backdrop gradient lives on a plain View: Reanimated's Animated.View
......@@ -635,7 +631,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
635631 strip fully transparent and the feed text legible through the composer. */}
636632 <View
637633 className={
638 materialYouStyleLayoutActive
634 Platform.OS === "android"
639635 ? "hidden"
640636 : "absolute inset-0 bg-linear-to-b from-screen/0 via-screen/60 to-screen/90"
641637 }
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+40-47
......@@ -27,8 +27,6 @@ import type {
2727 UserInputQuestion,
2828} from "@t3tools/contracts";
2929import * as Haptics from "expo-haptics";
30import { BlurTargetView } from "expo-blur";
31import { GlassBlurTargetContext } from "../../lib/glassBlurTarget";
3230import {
3331 memo,
3432 useCallback,
......@@ -65,7 +63,6 @@ import Animated, {
6563} from "react-native-reanimated";
6664import { useSafeAreaInsets } from "react-native-safe-area-context";
6765import { useWorkspaceContentWidth } from "../layout/workspace-content-width";
68
6966import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
7067import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";
7168import type { ComposerEditorHandle } from "../../components/ComposerEditor";
......@@ -819,7 +816,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
819816 }, [freeze, scrollMessageToEnd]);
820817
821818 const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled;
822 const { themeAppearance, materialYouStyleLayoutActive } = useAppearancePreferences();
819 const { themeAppearance } = useAppearancePreferences();
823820 const isDarkMode = themeAppearance === "dark";
824821
825822 const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => {
......@@ -851,13 +848,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
851848 const handleFeedTouchCancel = useCallback(() => {
852849 feedTouchStartRef.current = null;
853850 }, []);
854 const feedBlurTarget = useRef<View>(null);
855851
856852 return (
857853 <View className="flex-1">
858854 {showContent ? (
859 <BlurTargetView
860 ref={feedBlurTarget}
855 <View
861856 style={{ flex: 1 }}
862857 onTouchStart={handleFeedTouchStart}
863858 onTouchMove={handleFeedTouchMove}
......@@ -867,7 +862,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
867862 <View
868863 pointerEvents="none"
869864 className={
870 materialYouStyleLayoutActive
865 Platform.OS === "android"
871866 ? "absolute inset-0 bg-thread-canvas"
872867 : "absolute inset-0 bg-screen"
873868 }
......@@ -903,7 +898,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
903898 onUseArtifactTemplate={handleUseArtifactTemplate}
904899 loadEarlier={props.loadEarlier ?? null}
905900 />
906 </BlurTargetView>
901 </View>
907902 ) : (
908903 <View className="flex-1" />
909904 )}
......@@ -1025,44 +1020,42 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
10251020 : undefined
10261021 }
10271022 >
1028 <GlassBlurTargetContext value={feedBlurTarget}>
1029 <ThreadComposer
1030 editorRef={composerEditorRef}
1031 draftMessage={props.draftMessage}
1032 draftAttachments={props.draftAttachments}
1033 placeholder="Ask the repo agent, or run a command…"
1034 contentMaxWidth={contentMaxWidth}
1035 connectionState={props.connectionStateLabel}
1036 environmentLabel={props.environmentLabel}
1037 selectedThread={props.selectedThread}
1038 hasCompactableConversation={hasCompactableConversation && !props.isCompacting}
1039 serverConfig={props.serverConfig}
1040 queueCount={props.selectedThreadQueueCount}
1041 environmentId={props.environmentId}
1042 projectCwd={props.threadCwd ?? props.projectWorkspaceRoot}
1043 // Follow-ups typed during setup wait in the draft: queueing
1044 // them against a thread id the server may still reject
1045 // would strand them in the outbox.
1046 sendBlockedReason={
1047 props.creationState?.kind === "preparing" ? "Starting the task…" : null
1048 }
1049 bottomInset={composerBottomInset}
1050 onChangeDraftMessage={props.onChangeDraftMessage}
1051 onPickDraftMedia={props.onPickDraftMedia}
1052 onPickDraftFiles={props.onPickDraftFiles}
1053 onNativePasteImages={props.onNativePasteImages}
1054 onNativePasteText={props.onNativePasteText}
1055 onRemoveDraftImage={props.onRemoveDraftImage}
1056 onStopThread={props.onStopThread}
1057 onSendMessage={handleSendMessage}
1058 onShowUsageLimits={showUsageLimits}
1059 onUpdateModelSelection={props.onUpdateThreadModelSelection}
1060 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
1061 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
1062 onExpandedChange={setComposerExpanded}
1063 onEditorFocusChange={handleComposerFocusChange}
1064 />
1065 </GlassBlurTargetContext>
1023 <ThreadComposer
1024 editorRef={composerEditorRef}
1025 draftMessage={props.draftMessage}
1026 draftAttachments={props.draftAttachments}
1027 placeholder="Ask the repo agent, or run a command…"
1028 contentMaxWidth={contentMaxWidth}
1029 connectionState={props.connectionStateLabel}
1030 environmentLabel={props.environmentLabel}
1031 selectedThread={props.selectedThread}
1032 hasCompactableConversation={hasCompactableConversation && !props.isCompacting}
1033 serverConfig={props.serverConfig}
1034 queueCount={props.selectedThreadQueueCount}
1035 environmentId={props.environmentId}
1036 projectCwd={props.threadCwd ?? props.projectWorkspaceRoot}
1037 // Follow-ups typed during setup wait in the draft: queueing
1038 // them against a thread id the server may still reject
1039 // would strand them in the outbox.
1040 sendBlockedReason={
1041 props.creationState?.kind === "preparing" ? "Starting the task…" : null
1042 }
1043 bottomInset={composerBottomInset}
1044 onChangeDraftMessage={props.onChangeDraftMessage}
1045 onPickDraftMedia={props.onPickDraftMedia}
1046 onPickDraftFiles={props.onPickDraftFiles}
1047 onNativePasteImages={props.onNativePasteImages}
1048 onNativePasteText={props.onNativePasteText}
1049 onRemoveDraftImage={props.onRemoveDraftImage}
1050 onStopThread={props.onStopThread}
1051 onSendMessage={handleSendMessage}
1052 onShowUsageLimits={showUsageLimits}
1053 onUpdateModelSelection={props.onUpdateThreadModelSelection}
1054 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
1055 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
1056 onExpandedChange={setComposerExpanded}
1057 onEditorFocusChange={handleComposerFocusChange}
1058 />
10661059 </View>
10671060 </View>
10681061 </Animated.View>
apps/mobile/src/features/threads/ThreadFeed.tsx+1
......@@ -880,6 +880,7 @@ function MarkdownCodeBlock(props: {
880880 >
881881 <NativeText
882882 selectable
883 selectionColorClassName={Platform.OS === "android" ? "accent-primary/32" : undefined}
883884 className="font-mono"
884885 style={{
885886 color: props.textColor,
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+146-126
......@@ -65,6 +65,9 @@ import {
6565 WorkspaceConnectionTitle,
6666} from "../home/WorkspaceConnectionTitle";
6767import { SidebarHeaderActions } from "./sidebar-header-actions";
68import { MaterialThreadListToolbar } from "../home/MaterialThreadListToolbar";
69import { useMaterialToolbarHeight } from "../../components/useMaterialToolbarHeight";
70import { useMaterialFabScroll } from "../home/MaterialFabScrollContext";
6871import { SidebarFilterButton } from "./sidebar-filter-button";
6972import { createSidebarHeaderItems } from "./sidebar-native-header-items";
7073import { SidebarNavigationShell } from "./sidebar-navigation-shell";
......@@ -147,10 +150,8 @@ function NativeSidebarContainer(props: ThreadNavigationSidebarProps) {
147150function ThreadNavigationSidebarPane(
148151 props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean },
149152) {
150 const { materialYouStyleLayoutActive, themeVariables: materialTheme } =
151 useAppearancePreferences();
153 const { themeVariables: materialTheme } = useAppearancePreferences();
152154 const screenColor = materialTheme["--color-screen"];
153 const mutedColor = materialTheme["--color-foreground-muted"];
154155
155156 const insets = useSafeAreaInsets();
156157 const projects = useProjects();
......@@ -747,13 +748,18 @@ function ThreadNavigationSidebarPane(
747748 );
748749
749750 const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null);
751 const materialToolbarHeight = useMaterialToolbarHeight();
750752 // The sticky header (title row, search field, optional connection status)
751753 // is measured so the list inset always matches its real height — no
752754 // 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);
754760 const topListInset = stickyHeaderHeight + 6;
755761 const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => {
756 const nextHeight = Math.round(event.nativeEvent.layout.height);
762 const nextHeight = event.nativeEvent.layout.height;
757763 setMeasuredHeaderHeight((current) => (current === nextHeight ? current : nextHeight));
758764 }, []);
759765 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {
......@@ -777,7 +783,9 @@ function ThreadNavigationSidebarPane(
777783 const handleScrollBeginDrag = useCallback(() => {
778784 openSwipeableRef.current?.close();
779785 }, []);
786 const onMaterialFabScroll = useMaterialFabScroll();
780787 const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({
788 onScroll: onMaterialFabScroll,
781789 onScrollBeginDrag: handleScrollBeginDrag,
782790 });
783791 // Project shells load after the first rows draw, so the maps they feed have
......@@ -850,6 +858,7 @@ function ThreadNavigationSidebarPane(
850858 [],
851859 );
852860 const focusSearch = useCallback(() => {
861 if (Platform.OS === "android") return false;
853862 const focus = () => {
854863 if (props.nativeChrome) {
855864 searchBarRef.current?.focus();
......@@ -925,7 +934,7 @@ function ThreadNavigationSidebarPane(
925934 }),
926935 )}
927936 searchQuery={props.searchQuery}
928 pane={materialYouStyleLayoutActive ? "screen" : "sidebar"}
937 pane={Platform.OS === "android" ? "screen" : "sidebar"}
929938 selected={
930939 scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey
931940 }
......@@ -966,7 +975,7 @@ function ThreadNavigationSidebarPane(
966975 disabled={!shelfPreferencesLoaded}
967976 expanded={item.expanded}
968977 onToggle={toggleSnoozedShelf}
969 pane={materialYouStyleLayoutActive ? "screen" : "sidebar"}
978 pane={Platform.OS === "android" ? "screen" : "sidebar"}
970979 />
971980 );
972981 case "v2-settled-shelf":
......@@ -976,7 +985,7 @@ function ThreadNavigationSidebarPane(
976985 disabled={!shelfPreferencesLoaded}
977986 expanded={item.expanded}
978987 onToggle={toggleSettledShelf}
979 pane={materialYouStyleLayoutActive ? "screen" : "sidebar"}
988 pane={Platform.OS === "android" ? "screen" : "sidebar"}
980989 />
981990 );
982991 case "v2-show-more":
......@@ -996,7 +1005,7 @@ function ThreadNavigationSidebarPane(
9961005 case "header":
9971006 return (
9981007 <ThreadListGroupHeader
999 variant={materialYouStyleLayoutActive ? "compact" : "sidebar"}
1008 variant={Platform.OS === "android" ? "compact" : "sidebar"}
10001009 collapsed={item.collapsed}
10011010 isFirst={item.isFirst}
10021011 groupKey={item.group.key}
......@@ -1014,7 +1023,7 @@ function ThreadNavigationSidebarPane(
10141023 case "pending-task":
10151024 return (
10161025 <PendingTaskListRow
1017 variant={materialYouStyleLayoutActive ? "compact" : "sidebar"}
1026 variant={Platform.OS === "android" ? "compact" : "sidebar"}
10181027 pendingTask={item.pendingTask}
10191028 environmentLabel={
10201029 savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null
......@@ -1064,7 +1073,7 @@ function ThreadNavigationSidebarPane(
10641073 case "show-more":
10651074 return (
10661075 <ThreadListShowMoreRow
1067 variant={materialYouStyleLayoutActive ? "compact" : "sidebar"}
1076 variant={Platform.OS === "android" ? "compact" : "sidebar"}
10681077 hiddenCount={item.hiddenCount}
10691078 canShowLess={item.canShowLess}
10701079 groupKey={item.groupKey}
......@@ -1074,7 +1083,6 @@ function ThreadNavigationSidebarPane(
10741083 }
10751084 },
10761085 [
1077 materialYouStyleLayoutActive,
10781086 archiveThread,
10791087 activeReorderEnvironmentIds,
10801088 threadMovePlanners,
......@@ -1166,16 +1174,24 @@ function ThreadNavigationSidebarPane(
11661174 // Snoozed threads need no special case: the shelf header is a list row
11671175 // even while collapsed.
11681176 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 >
11701184 {catalogState.isLoadingConnections
11711185 ? "Loading threads…"
1172 : props.searchQuery.trim().length > 0
1173 ? threadSearch.isPending
1174 ? "Searching thread messages…"
1175 : "No matching threads"
1176 : selectedProjectScope !== null
1177 ? `No threads in ${selectedProjectScope.title}`
1178 : "No threads yet"}
1186 : Platform.OS === "android" && !catalogState.hasConnections
1187 ? "No environments connected"
1188 : props.searchQuery.trim().length > 0
1189 ? threadSearch.isPending
1190 ? "Searching thread messages…"
1191 : "No matching threads"
1192 : selectedProjectScope !== null
1193 ? `No threads in ${selectedProjectScope.title}`
1194 : "No threads yet"}
11791195 </Text>
11801196 );
11811197
......@@ -1232,7 +1248,7 @@ function ThreadNavigationSidebarPane(
12321248 }
12331249 contentContainerStyle={[
12341250 styles.threadListContent,
1235 materialYouStyleLayoutActive ? { paddingHorizontal: 0 } : null,
1251 Platform.OS === "android" ? { paddingHorizontal: 0 } : null,
12361252 {
12371253 paddingBottom: Math.max(insets.bottom, 16) + 16,
12381254 paddingTop: 6,
......@@ -1257,15 +1273,18 @@ function ThreadNavigationSidebarPane(
12571273 return (
12581274 <View
12591275 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 }
12611279 style={{ width: props.width }}
12621280 >
12631281 <View
12641282 className="flex-1"
12651283 style={
1266 materialYouStyleLayoutActive
1284 Platform.OS === "android"
12671285 ? {
12681286 marginTop: stickyHeaderHeight,
1287 marginHorizontal: 4,
12691288 paddingBottom: insets.bottom,
12701289 backgroundColor: screenColor,
12711290 borderTopLeftRadius: 28,
......@@ -1275,116 +1294,117 @@ function ThreadNavigationSidebarPane(
12751294 : { paddingBottom: insets.bottom }
12761295 }
12771296 >
1278 <SwipeableScrollGateProvider enabled={swipeEnabled}>
1279 <GestureDetector gesture={sidebarScrollGesture}>
1280 <LegendList
1281 data={listItems}
1282 drawDistance={500}
1283 estimatedItemSize={64}
1284 extraData={listExtraData}
1285 getItemType={(item) => item.type}
1286 itemsAreEqual={sidebarItemsAreEqual}
1287 keyExtractor={(item) => item.key}
1288 renderItem={renderListItem}
1289 contentContainerStyle={[
1290 styles.threadListContent,
1291 materialYouStyleLayoutActive ? { paddingHorizontal: 0 } : null,
1292 {
1293 paddingBottom:
1294 Platform.OS === "android"
1295 ? Math.max(insets.bottom, 16) + 88 - insets.bottom
1296 : 16 + insets.bottom,
1297 paddingTop: materialYouStyleLayoutActive ? 6 : topListInset,
1298 },
1299 ]}
1300 keyboardDismissMode="on-drag"
1301 keyboardShouldPersistTaps="handled"
1302 {...scrollGateHandlers}
1303 recycleItems
1304 scrollEventThrottle={16}
1305 showsVerticalScrollIndicator={false}
1306 style={styles.threadList}
1307 ListEmptyComponent={listEmpty}
1308 />
1309 </GestureDetector>
1310 </SwipeableScrollGateProvider>
1297 {Platform.OS === "android" && listItems.length === 0 ? (
1298 <View className="flex-1 items-center justify-center">{listEmpty}</View>
1299 ) : (
1300 <SwipeableScrollGateProvider enabled={swipeEnabled}>
1301 <GestureDetector gesture={sidebarScrollGesture}>
1302 <LegendList
1303 data={listItems}
1304 drawDistance={500}
1305 estimatedItemSize={64}
1306 extraData={listExtraData}
1307 getItemType={(item) => item.type}
1308 itemsAreEqual={sidebarItemsAreEqual}
1309 keyExtractor={(item) => item.key}
1310 renderItem={renderListItem}
1311 contentContainerStyle={[
1312 styles.threadListContent,
1313 Platform.OS === "android" ? { paddingHorizontal: 0 } : null,
1314 {
1315 paddingBottom:
1316 Platform.OS === "android"
1317 ? Math.max(insets.bottom, 16) + 148 - insets.bottom
1318 : 16 + insets.bottom,
1319 paddingTop: Platform.OS === "android" ? 6 : topListInset,
1320 },
1321 ]}
1322 keyboardDismissMode="on-drag"
1323 keyboardShouldPersistTaps="handled"
1324 {...scrollGateHandlers}
1325 recycleItems
1326 scrollEventThrottle={16}
1327 showsVerticalScrollIndicator={false}
1328 style={styles.threadList}
1329 ListEmptyComponent={listEmpty}
1330 />
1331 </GestureDetector>
1332 </SwipeableScrollGateProvider>
1333 )}
13111334 </View>
13121335
1313 <View
1314 className="absolute inset-x-0 top-0 z-[4] bg-drawer"
1315 collapsable={false}
1316 onLayout={handleStickyHeaderLayout}
1317 pointerEvents="auto"
1318 style={{ paddingTop: insets.top }}
1319 >
1320 <View className="h-[50px] flex-row items-end gap-0.5 pr-2 pl-5">
1321 {/* Title slot doubles as the connection status surface: while an
1336 {Platform.OS === "android" ? (
1337 <MaterialThreadListToolbar
1338 sidebar
1339 onLayout={handleStickyHeaderLayout}
1340 searchQuery={props.searchQuery}
1341 onSearchQueryChange={props.onSearchQueryChange}
1342 filterActions={listMenuActions}
1343 filterCustomized={filterCustomized}
1344 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
13221359 environment reconnects, the brand fades to a status label in
13231360 place (no layout shift in the list below). */}
1324 <WorkspaceConnectionTitle
1325 grow
1326 onPress={props.onOpenEnvironmentSettings}
1327 size="pageTitle"
1328 brand={
1329 <View className="h-11 flex-1 justify-center">
1330 <CompactBrandTitle allowFontScaling={false} />
1331 </View>
1332 }
1333 />
1334 <View className="flex-row items-center gap-2.5">
1335 <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}>
1336 <SidebarFilterButton accessibilityLabel="Filter and sort threads" icon={filterIcon} />
1337 </ControlPillMenu>
1338 <SidebarHeaderActions onOpenSettings={props.onOpenSettings} />
1361 <WorkspaceConnectionTitle
1362 grow
1363 onPress={props.onOpenEnvironmentSettings}
1364 size="pageTitle"
1365 brand={
1366 <View className="h-11 flex-1 justify-center">
1367 <CompactBrandTitle allowFontScaling={false} />
1368 </View>
1369 }
1370 />
1371 <View className="flex-row items-center gap-2.5">
1372 <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}>
1373 <SidebarFilterButton
1374 accessibilityLabel="Filter and sort threads"
1375 icon={filterIcon}
1376 />
1377 </ControlPillMenu>
1378 <SidebarHeaderActions onOpenSettings={props.onOpenSettings} />
1379 </View>
13391380 </View>
1340 </View>
13411381
1342 <View
1343 className={
1344 materialYouStyleLayoutActive
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"
1346 : "mx-4 mt-[9px] h-[38px] flex-row items-center gap-1.5 rounded-xl bg-sidebar-search pr-2.5 pl-[11px]"
1347 }
1348 >
1349 <SymbolView
1350 name="magnifyingglass"
1351 size={15}
1352 tintColorClassName={"accent-foreground-muted"}
1353 type="monochrome"
1354 />
1355 <TextInput
1356 ref={searchInputRef}
1357 accessibilityLabel="Search threads"
1358 autoCapitalize="none"
1359 autoCorrect={false}
1360 clearButtonMode={materialYouStyleLayoutActive ? "never" : "while-editing"}
1361 onChangeText={props.onSearchQueryChange}
1362 placeholder="Search"
1363 placeholderTextColorClassName={"accent-placeholder"}
1364 returnKeyType="search"
1365 className={
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}
1382 <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]">
1383 <SymbolView
1384 name="magnifyingglass"
1385 size={15}
1386 tintColorClassName="accent-foreground-muted"
1387 type="monochrome"
1388 />
1389 <TextInput
1390 ref={searchInputRef}
1391 accessibilityLabel="Search threads"
1392 autoCapitalize="none"
1393 autoCorrect={false}
1394 clearButtonMode="while-editing"
1395 onChangeText={props.onSearchQueryChange}
1396 placeholder="Search"
1397 placeholderTextColorClassName="accent-placeholder"
1398 selectionColorClassName={undefined}
1399 cursorColorClassName={undefined}
1400 selectionHandleColorClassName={undefined}
1401 returnKeyType="search"
1402 className="h-[34px] flex-1 px-0 py-0 font-sans text-base text-foreground"
1403 value={props.searchQuery}
1404 />
1405 </View>
13861406 </View>
1387 </View>
1407 )}
13881408 </View>
13891409 );
13901410}
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+29-21
......@@ -32,17 +32,17 @@ import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draf
3232import { useEnvironmentQuery } from "../../state/query";
3333import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";
3434import { vcsEnvironment } from "../../state/vcs";
35
3635import { EmptyState } from "../../components/EmptyState";
3736import {
37 AndroidHeaderIconButton,
3838 AndroidScreenHeader,
3939 type AndroidHeaderAction,
4040} from "../../components/AndroidScreenHeader";
41import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar";
4142import { LoadingScreen } from "../../components/LoadingScreen";
4243import { scopedThreadKey } from "../../lib/scopedEntities";
4344import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
4445import { connectionTone } from "../connection/connectionTone";
45
4646import {
4747 useRemoteConnections,
4848 useRemoteConnectionStatus,
......@@ -225,7 +225,7 @@ function ThreadRouteContent(
225225 readonly selectedThreadDetailState: ReturnType<typeof useSelectedThreadDetailState>;
226226 },
227227) {
228 const { materialYouStyleLayoutActive, themeVariables } = useAppearancePreferences();
228 const { themeVariables } = useAppearancePreferences();
229229 const headerColor = themeVariables["--color-header"];
230230 const {
231231 fileInspector,
......@@ -746,10 +746,12 @@ function ThreadRouteContent(
746746 });
747747 }
748748 if (selectedThreadCwd !== null) {
749 const filesVisible = inspectorMode === "files" && panes.auxiliaryPaneVisible;
749750 actions.push({
750 accessibilityLabel: "Open files",
751 accessibilityLabel: filesVisible ? "Close files" : "Open files",
752 selected: filesVisible,
751753 icon: "folder",
752 onPress: handleOpenFilesInspector,
754 onPress: filesVisible ? toggleAuxiliaryPane : handleOpenFilesInspector,
753755 });
754756 }
755757 if (selectedThreadProject?.workspaceRoot) {
......@@ -764,20 +766,14 @@ function ThreadRouteContent(
764766 icon: "point.topleft.down.curvedto.point.bottomright.up",
765767 onPress: handleOpenGitInspector,
766768 });
767 if (fileInspector.supported && selectedThreadCwd !== null) {
768 actions.push({
769 accessibilityLabel: "Toggle inspector",
770 icon: "sidebar.right",
771 onPress: handleToggleInspector,
772 });
773 }
774769 return actions;
775770 }, [
776 fileInspector.supported,
771 inspectorMode,
772 panes.auxiliaryPaneVisible,
777773 handleOpenFilesInspector,
778774 handleOpenTerminal,
779775 handleOpenGitInspector,
780 handleToggleInspector,
776 toggleAuxiliaryPane,
781777 props.onReturnToThread,
782778 selectedThreadCwd,
783779 selectedThreadProject?.workspaceRoot,
......@@ -891,13 +887,12 @@ function ThreadRouteContent(
891887 <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} />
892888
893889 <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"}
895891 style={
896 materialYouStyleLayoutActive
892 Platform.OS === "android"
897893 ? {
898894 borderTopLeftRadius: 28,
899895 borderTopRightRadius: 28,
900 marginRight: layout.usesSplitView ? 8 : 0,
901896 overflow: "hidden",
902897 }
903898 : undefined
......@@ -996,9 +991,7 @@ function ThreadRouteContent(
996991 : undefined,
997992 unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined,
998993 contentStyle:
999 Platform.OS === "android" && materialYouStyleLayoutActive
1000 ? { backgroundColor: headerColor }
1001 : undefined,
994 Platform.OS === "android" && true ? { backgroundColor: headerColor } : undefined,
1002995 }}
1003996 />
1004997
......@@ -1006,6 +999,21 @@ function ThreadRouteContent(
1006999 <AndroidScreenHeader
10071000 title={selectedThread.title}
10081001 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 }
10091017 onBack={
10101018 layout.usesSplitView
10111019 ? undefined
......@@ -1017,7 +1025,7 @@ function ThreadRouteContent(
10171025 }
10181026 }
10191027 actions={androidHeaderActions}
1020 hideBottomBorder={materialYouStyleLayoutActive}
1028 hideBottomBorder
10211029 />
10221030 ) : null}
10231031
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+98-41
......@@ -36,6 +36,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
3636import { SymbolView } from "../../components/AppSymbol";
3737import { AppText as Text } from "../../components/AppText";
3838import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
39import { MaterialButton } from "../../components/MaterialButton";
40import { MaterialIconButton } from "../../components/MaterialIconButton";
41import { MaterialRadioIndicator } from "../../components/MaterialRadioIndicator";
3942import { ProviderIcon } from "../../components/ProviderIcon";
4043import { ThemedSwitch } from "../../components/ThemedSwitch";
4144import { cn } from "../../lib/cn";
......@@ -52,7 +55,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
5255import { serverEnvironment } from "../../state/server";
5356import { useAtomCommand } from "../../state/use-atom-command";
5457import { useNewTaskFlow } from "./new-task-flow-provider";
55import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
58import { MaterialScreenContent } from "../../components/MaterialScreenContent";
5659import {
5760 createProviderCatalogRefreshRunner,
5861 providerCatalogRefreshError,
......@@ -105,8 +108,7 @@ function ModelRow(props: {
105108 readonly isFirst: boolean;
106109 readonly isLast: boolean;
107110}) {
108 const { materialYouStyleLayoutActive } = useAppearancePreferences();
109 const selectedMaterialRow = materialYouStyleLayoutActive && props.selected;
111 const selectedMaterialRow = Platform.OS === "android" && props.selected;
110112 return (
111113 <Pressable
112114 accessibilityLabel={[props.option.label, props.option.subtitle].filter(Boolean).join(", ")}
......@@ -117,6 +119,7 @@ function ModelRow(props: {
117119 }}
118120 disabled={props.option.isUnavailable}
119121 onPress={props.onPress}
122 style={Platform.OS === "android" ? { minHeight: 56, paddingVertical: 12 } : undefined}
120123 className={cn(
121124 "mx-4 min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle",
122125 selectedMaterialRow && "bg-thread-selected",
......@@ -124,11 +127,12 @@ function ModelRow(props: {
124127 props.isLast ? "rounded-b-2xl" : "border-b border-border-subtle",
125128 )}
126129 >
130 {Platform.OS === "android" ? <MaterialRadioIndicator selected={props.selected} /> : null}
127131 <View className="min-w-0 flex-1">
128132 <View className="flex-row items-center gap-2">
129133 <Text
130134 className="min-w-0 shrink text-base font-t3-medium text-foreground"
131 numberOfLines={1}
135 numberOfLines={Platform.OS === "android" ? 2 : 1}
132136 >
133137 {props.option.label}
134138 </Text>
......@@ -147,16 +151,19 @@ function ModelRow(props: {
147151 ) : null}
148152 </View>
149153 {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 >
151158 {props.option.subtitle}
152159 </Text>
153160 ) : null}
154161 </View>
155 {props.selected ? (
162 {props.selected && Platform.OS !== "android" ? (
156163 <SymbolView
157164 name="checkmark"
158165 size={16}
159 tintColorClassName={"accent-icon"}
166 tintColorClassName="accent-icon"
160167 type="monochrome"
161168 weight="semibold"
162169 />
......@@ -189,7 +196,7 @@ function ProviderHeader(props: {
189196 <SymbolView
190197 name={props.collapsed ? "chevron.down" : "chevron.up"}
191198 size={12}
192 tintColorClassName={"accent-icon-subtle"}
199 tintColorClassName="accent-icon-subtle"
193200 type="monochrome"
194201 />
195202 </>
......@@ -205,6 +212,7 @@ function ProviderHeader(props: {
205212 accessibilityState={{ expanded: !props.collapsed }}
206213 className="mx-4 mt-1 min-h-11 flex-row items-center gap-2 rounded-xl px-1 pt-2 active:opacity-60"
207214 onPress={props.onToggle}
215 style={Platform.OS === "android" ? { minHeight: 48 } : undefined}
208216 >
209217 {content}
210218 </Pressable>
......@@ -229,6 +237,7 @@ function DisclosureRow(props: {
229237 <Pressable
230238 accessibilityRole="button"
231239 onPress={props.onPress}
240 style={Platform.OS === "android" ? { minHeight: 56 } : undefined}
232241 className={cn(
233242 "min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle",
234243 !props.isLast && "border-b border-border-subtle",
......@@ -244,7 +253,7 @@ function DisclosureRow(props: {
244253 <SymbolView
245254 name="chevron.right"
246255 size={12}
247 tintColorClassName={"accent-icon-subtle"}
256 tintColorClassName="accent-icon-subtle"
248257 type="monochrome"
249258 />
250259 </Pressable>
......@@ -265,22 +274,24 @@ function ChoiceRow(props: {
265274 accessibilityRole="radio"
266275 accessibilityState={{ checked: props.selected }}
267276 onPress={props.onPress}
277 style={Platform.OS === "android" ? { minHeight: 56 } : undefined}
268278 className={cn(
269279 "min-h-14 flex-row items-center gap-3 bg-card px-4 py-3 active:bg-subtle",
270280 !props.isLast && "border-b border-border-subtle",
271281 )}
272282 >
283 {Platform.OS === "android" ? <MaterialRadioIndicator selected={props.selected} /> : null}
273284 <View className="min-w-0 flex-1 gap-0.5">
274285 <Text className="text-base font-t3-medium text-foreground">{props.label}</Text>
275286 {props.description ? (
276287 <Text className="text-sm leading-5 text-foreground-muted">{props.description}</Text>
277288 ) : null}
278289 </View>
279 {props.selected ? (
290 {props.selected && Platform.OS !== "android" ? (
280291 <SymbolView
281292 name="checkmark"
282293 size={16}
283 tintColorClassName={"accent-icon"}
294 tintColorClassName="accent-icon"
284295 type="monochrome"
285296 weight="semibold"
286297 />
......@@ -853,10 +864,15 @@ function ThreadSettingsMainContent(props: {
853864 <AnimatedLegendList
854865 automaticallyAdjustsScrollIndicatorInsets
855866 className="flex-1 bg-sheet"
867 style={
868 Platform.OS === "android"
869 ? { width: "100%", maxWidth: 720, alignSelf: "center" }
870 : undefined
871 }
856872 contentContainerStyle={{ paddingTop: 4 }}
857873 contentInsetAdjustmentBehavior={usesTransparentNativeHeader ? "never" : "automatic"}
858874 data={listItems}
859 estimatedItemSize={48}
875 estimatedItemSize={Platform.OS === "android" ? 56 : 48}
860876 extraData={animationsReady}
861877 getItemType={(item) => item.kind}
862878 itemLayoutAnimation={THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION}
......@@ -869,16 +885,39 @@ function ThreadSettingsMainContent(props: {
869885 {usesTransparentNativeHeader ? <View style={{ height: nativeHeaderHeight }} /> : null}
870886 {Platform.OS === "android" ? (
871887 <View className="px-4 pb-2 pt-3">
872 <TextInput
873 accessibilityLabel="Find a model"
874 autoCapitalize="none"
875 autoCorrect={false}
876 className="h-11 rounded-xl bg-card px-4 text-base text-foreground"
877 onChangeText={session.setSearchQuery}
878 placeholder="Find a model"
879 placeholderTextColorClassName="accent-placeholder"
880 value={session.searchQuery}
881 />
888 <View
889 className="flex-row items-center rounded-full bg-input px-2"
890 style={{ minHeight: 56 }}
891 >
892 <View pointerEvents="none" className="px-2">
893 <SymbolView
894 name="magnifyingglass"
895 size={24}
896 tintColorClassName="accent-icon-subtle"
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>
882921 </View>
883922 ) : null}
884923 </>
......@@ -945,6 +984,11 @@ function ThreadSettingsChoiceContent(props: {
945984 return (
946985 <ScrollView
947986 className="flex-1 bg-sheet"
987 style={
988 Platform.OS === "android"
989 ? { width: "100%", maxWidth: 720, alignSelf: "center" }
990 : undefined
991 }
948992 contentContainerStyle={{
949993 paddingBottom: insets.bottom + 12,
950994 paddingHorizontal: 16,
......@@ -1068,14 +1112,15 @@ function ThreadSettingsModelsScreen() {
10681112 icon: "arrow.clockwise",
10691113 onPress: refreshProviders,
10701114 },
1071 {
1072 accessibilityLabel: session.pendingModel ? "Save thread settings" : "Done",
1073 icon: "checkmark",
1074 onPress: commitAndClose,
1075 },
10761115 ]}
1116 trailing={
1117 session.pendingModel ? (
1118 <MaterialButton label="Save" tone="text" onPress={commitAndClose} />
1119 ) : undefined
1120 }
10771121 onBack={presentation.onClose}
10781122 title="Thread settings"
1123 hideBottomBorder
10791124 />
10801125 ) : null}
10811126 <NativeStackScreenOptions
......@@ -1114,17 +1159,19 @@ function ThreadSettingsModelsScreen() {
11141159 : undefined,
11151160 }}
11161161 />
1117 <ThreadSettingsMainContent
1118 onOpenSubmenu={(submenu) => {
1119 const title =
1120 submenu.kind === "runtime"
1121 ? "Runtime"
1122 : (session.displayedDescriptors.find(
1123 (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id,
1124 )?.label ?? "Option");
1125 navigation.navigate("ThreadSettingsChoice", { ...submenu, title });
1126 }}
1127 />
1162 <MaterialScreenContent>
1163 <ThreadSettingsMainContent
1164 onOpenSubmenu={(submenu) => {
1165 const title =
1166 submenu.kind === "runtime"
1167 ? "Runtime"
1168 : (session.displayedDescriptors.find(
1169 (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id,
1170 )?.label ?? "Option");
1171 navigation.navigate("ThreadSettingsChoice", { ...submenu, title });
1172 }}
1173 />
1174 </MaterialScreenContent>
11281175 <NativeHeaderToolbar placement="left">
11291176 <NativeHeaderToolbar.Button
11301177 accessibilityLabel="Cancel thread settings"
......@@ -1199,9 +1246,18 @@ function ThreadSettingsChoiceScreen() {
11991246 <>
12001247 <NativeStackScreenOptions options={{ headerShown: Platform.OS !== "android" }} />
12011248 {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 />
12031254 ) : null}
1204 <ThreadSettingsChoiceContent submenu={route.params} onSelected={() => navigation.goBack()} />
1255 <MaterialScreenContent>
1256 <ThreadSettingsChoiceContent
1257 submenu={route.params}
1258 onSelected={() => navigation.goBack()}
1259 />
1260 </MaterialScreenContent>
12051261 </>
12061262 );
12071263}
......@@ -1222,11 +1278,12 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation)
12221278 <ThreadSettingsPickerStack.Navigator
12231279 initialRouteName="ThreadSettingsModels"
12241280 screenOptions={{
1225 animation: "slide_from_right",
1281 animation: Platform.OS === "android" ? "default" : "slide_from_right",
12261282 contentStyle: { backgroundColor: solidSheetBackground },
12271283 gestureEnabled: true,
12281284 headerBackButtonDisplayMode: "minimal",
12291285 headerBackTitle: "",
1286 headerShown: Platform.OS !== "android",
12301287 headerShadowVisible: false,
12311288 headerStyle: {
12321289 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 @@
11import { sanitizeFeatureBranchName } from "@t3tools/shared/git";
22import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
33import { useState } from "react";
4import { Platform, Pressable, ScrollView, View } from "react-native";
4import { Platform, Pressable, ScrollView, useWindowDimensions, View } from "react-native";
55import { useSafeAreaInsets } from "react-native-safe-area-context";
66
77import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";
8import { MaterialScreenContent } from "../../../components/MaterialScreenContent";
9import { NativeStackScreenOptions } from "../../../native/StackHeader";
810import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
911import { cn } from "../../../lib/cn";
1012import { useEnvironmentQuery } from "../../../state/query";
......@@ -23,6 +25,7 @@ type GitBranchesSheetProps = StaticScreenProps<{
2325export function GitBranchesSheet(_props: GitBranchesSheetProps) {
2426 const navigation = useNavigation();
2527 const insets = useSafeAreaInsets();
28 const { height: windowHeight } = useWindowDimensions();
2629 const { selectedThread } = useThreadSelection();
2730 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
2831 const gitState = useSelectedThreadGitState();
......@@ -58,125 +61,219 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {
5861 const disabledExistingBranches = new Set(disabledExistingBranchNames);
5962
6063 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 >
6269 {Platform.OS === "android" ? (
63 <AndroidSheetHeader title="Branches & worktrees" onBack={() => navigation.goBack()} />
70 <NativeStackScreenOptions
71 options={{
72 sheetCornerRadius: 28,
73 sheetAllowedDetents: "fitToContents",
74 }}
75 />
6476 ) : null}
65 <ScrollView
66 className="flex-1"
67 showsVerticalScrollIndicator={false}
68 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
69 contentContainerClassName="gap-4 px-5 pt-2"
70 >
71 <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4">
72 <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase">
73 New branch
74 </Text>
75 <TextInput
76 value={newBranchName}
77 onChangeText={setNewBranchName}
78 placeholder="feature/mobile-polish"
79 className="rounded-[18px]"
80 />
81 <SheetActionButton
82 icon="plus"
83 label="Create & checkout"
84 tone="primary"
85 disabled={busy || newBranchName.trim().length === 0}
86 onPress={() => {
87 const branch = sanitizeFeatureBranchName(newBranchName.trim());
88 if (branch.length === 0) return;
89 void gitActions.onCreateSelectedThreadBranch(branch).then(() => {
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
77 {Platform.OS === "android" ? (
78 <AndroidSheetHeader
79 title="Branches & worktrees"
80 onBack={() => navigation.goBack()}
81 hideBottomBorder
82 />
83 ) : null}
84 <MaterialScreenContent fitToContents>
85 <ScrollView
86 className={Platform.OS === "android" ? "shrink grow-0" : "flex-1"}
87 keyboardShouldPersistTaps="handled"
88 showsVerticalScrollIndicator={false}
89 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
90 contentContainerClassName={Platform.OS === "android" ? "gap-2 p-2" : "gap-4 px-5 pt-2"}
91 contentContainerStyle={
92 Platform.OS === "android"
93 ? { paddingBottom: Math.max(insets.bottom, 18) + 18 }
94 : undefined
95 }
96 >
97 <View
98 className={
99 Platform.OS === "android"
100 ? "gap-3 rounded-[20px] bg-card p-4"
101 : "gap-2 rounded-[18px] border border-border bg-card px-4 py-4"
121102 }
122 onPress={() => {
123 const baseBranch = worktreeBaseBranch.trim();
124 const newBranch = worktreeBranchName.trim();
125 if (baseBranch.length === 0 || newBranch.length === 0) return;
126 void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => {
127 setWorktreeBranchName("");
128 navigation.goBack();
129 });
130 }}
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...
103 >
104 <Text
105 className={
106 Platform.OS === "android"
107 ? "text-foreground text-base font-t3-medium"
108 : "text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase"
109 }
110 >
111 New branch
141112 </Text>
142 ) : null}
143 {!branchesLoading && availableBranches.length === 0 ? (
144 <Text className="text-foreground-secondary text-sm font-medium">
145 No local branches found.
113 <TextInput
114 value={newBranchName}
115 onChangeText={setNewBranchName}
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
146151 </Text>
147 ) : null}
148 {availableBranches.map((branch) => {
149 const disabled = disabledExistingBranches.has(branch.name);
150 const subtitle = branch.worktreePath
151 ? branch.worktreePath === currentWorktreePath
152 ? "Checked out in this thread"
153 : "Checked out in another worktree"
154 : branch.isDefault
155 ? "Default branch"
156 : "Local branch";
152 {Platform.OS === "android" ? (
153 <Text className="text-foreground-secondary text-sm">Base branch</Text>
154 ) : null}
155 <TextInput
156 value={worktreeBaseBranch}
157 onChangeText={setWorktreeBaseBranch}
158 placeholder="main"
159 accessibilityLabel="Worktree base branch"
160 className={Platform.OS === "android" ? "rounded-xl bg-sheet-solid" : "rounded-[18px]"}
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 (
159 <Pressable
160 key={branch.name}
195 <View className="gap-2">
196 <Text
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
161207 className={cn(
162 "gap-1 rounded-[18px] border px-4 py-3 disabled:opacity-[0.45]",
163 branch.current ? "border-subtle-strong" : "border-border",
208 "text-foreground-secondary text-sm font-medium",
209 Platform.OS === "android" && "px-4",
164210 )}
165 disabled={busy || disabled}
166 onPress={() => {
167 void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => {
168 navigation.goBack();
169 });
170 }}
171211 >
172 <View className="absolute inset-0 rounded-[18px] bg-card" />
173 <Text className="text-foreground text-base font-t3-bold">{branch.name}</Text>
174 <Text className="text-foreground-secondary text-xs font-medium">{subtitle}</Text>
175 </Pressable>
176 );
177 })}
178 </View>
179 </ScrollView>
212 Loading branches...
213 </Text>
214 ) : null}
215 {!branchesLoading && availableBranches.length === 0 ? (
216 <Text
217 className={cn(
218 "text-foreground-secondary text-sm font-medium",
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>
180277 </View>
181278 );
182279}
apps/mobile/src/features/threads/git/GitCommitSheet.tsx+267-147
......@@ -1,9 +1,12 @@
11import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
22import { useCallback, useState } from "react";
3import { Platform, Pressable, ScrollView, View } from "react-native";
3import { Platform, Pressable, ScrollView, useWindowDimensions, View } from "react-native";
44import { useSafeAreaInsets } from "react-native-safe-area-context";
55
66import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";
7import { SymbolView } from "../../../components/AppSymbol";
8import { MaterialScreenContent } from "../../../components/MaterialScreenContent";
9import { NativeStackScreenOptions } from "../../../native/StackHeader";
710import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
811import { cn } from "../../../lib/cn";
912import { useEnvironmentQuery } from "../../../state/query";
......@@ -22,6 +25,7 @@ type GitCommitSheetProps = StaticScreenProps<{
2225export function GitCommitSheet(_props: GitCommitSheetProps) {
2326 const navigation = useNavigation();
2427 const insets = useSafeAreaInsets();
28 const { height: windowHeight } = useWindowDimensions();
2529 const { selectedThread } = useThreadSelection();
2630 const { selectedThreadCwd } = useSelectedThreadWorktree();
2731 const gitState = useSelectedThreadGitState();
......@@ -66,168 +70,284 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {
6670 );
6771
6872 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 >
7078 {Platform.OS === "android" ? (
71 <AndroidSheetHeader title="Commit changes" onBack={() => navigation.goBack()} />
79 <NativeStackScreenOptions
80 options={{
81 sheetCornerRadius: 28,
82 sheetAllowedDetents: "fitToContents",
83 }}
84 />
7285 ) : null}
73 <ScrollView
74 className="flex-1"
75 keyboardShouldPersistTaps="handled"
76 showsVerticalScrollIndicator={false}
77 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
78 contentContainerClassName="gap-4 px-5 pt-2"
79 >
80 <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4">
81 <View className="flex-row items-center justify-between gap-3">
82 <Text className="text-foreground-muted text-sm font-medium">Branch</Text>
83 <Text className="text-foreground text-base font-t3-bold">
84 {gitStatus.data?.refName ?? "(detached HEAD)"}
85 </Text>
86 </View>
87 {isDefaultRef ? (
88 <Text className="text-xs leading-normal text-warning-foreground">
89 Warning: this is the default branch.
90 </Text>
91 ) : null}
92 </View>
93
94 <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4">
95 <View className="flex-row items-center justify-between gap-3">
96 <View className="gap-1">
97 <Text className="text-foreground text-base font-t3-bold">Files</Text>
98 <Text className="text-foreground-muted text-xs leading-normal">
99 {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions}
100 </Text>
101 </View>
102 <View className="flex-row items-center gap-2">
103 {!allSelected && isEditingFiles ? (
104 <Pressable
105 className="bg-subtle rounded-full px-3 py-2"
106 onPress={() => setExcludedFiles(new Set())}
107 >
108 <Text className="text-foreground text-2xs font-t3-bold uppercase">Reset</Text>
109 </Pressable>
110 ) : null}
111 <Pressable
112 className="bg-subtle rounded-full px-3 py-2"
113 onPress={() => setIsEditingFiles((current) => !current)}
86 {Platform.OS === "android" ? (
87 <AndroidSheetHeader
88 title="Commit changes"
89 onBack={() => navigation.goBack()}
90 hideBottomBorder
91 />
92 ) : null}
93 <MaterialScreenContent fitToContents>
94 <ScrollView
95 className={Platform.OS === "android" ? "shrink grow-0" : "flex-1"}
96 keyboardShouldPersistTaps="handled"
97 showsVerticalScrollIndicator={false}
98 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
99 contentContainerClassName={Platform.OS === "android" ? "gap-2 p-2" : "gap-4 px-5 pt-2"}
100 contentContainerStyle={
101 Platform.OS === "android"
102 ? { paddingBottom: Math.max(insets.bottom, 18) + 18 }
103 : undefined
104 }
105 >
106 <View
107 className={cn(
108 "gap-3 bg-card p-4",
109 Platform.OS === "android" ? "rounded-[20px]" : "rounded-[22px] border border-border",
110 )}
111 >
112 <View
113 className={
114 Platform.OS === "android" ? "gap-1" : "flex-row items-center justify-between gap-3"
115 }
116 >
117 <Text className="text-foreground-muted text-sm font-medium">Branch</Text>
118 <Text
119 className={cn(
120 "text-foreground text-base",
121 Platform.OS === "android" ? "font-t3-medium" : "font-t3-bold",
122 )}
114123 >
115 <Text className="text-foreground text-2xs font-t3-bold uppercase">
116 {isEditingFiles ? "Done" : "Edit"}
117 </Text>
118 </Pressable>
124 {gitStatus.data?.refName ?? "(detached HEAD)"}
125 </Text>
119126 </View>
127 {isDefaultRef ? (
128 <Text className="text-xs leading-normal text-warning-foreground">
129 Warning: this is the default branch.
130 </Text>
131 ) : null}
120132 </View>
121133
122 {allFiles.length === 0 ? (
123 <Text className="text-foreground-secondary text-sm leading-normal">
124 No changed files are available to commit.
125 </Text>
126 ) : !isEditingFiles ? (
127 <View className="gap-2">
128 {selectedFilePreview.map((file) => (
129 <View key={file.path} className="flex-row items-center justify-between gap-3">
130 <Text className="text-foreground flex-1 text-sm font-medium" numberOfLines={1}>
131 {file.path}
132 </Text>
133 <Text className="text-xs font-t3-bold text-emerald-500">+{file.insertions}</Text>
134 <Text className="text-xs font-t3-bold text-rose-500">-{file.deletions}</Text>
135 </View>
136 ))}
137 {selectedFiles.length > selectedFilePreview.length ? (
138 <Text className="text-foreground-muted text-xs leading-snug">
139 +{selectedFiles.length - selectedFilePreview.length} more files
134 <View
135 className={cn(
136 "gap-3 bg-card p-4",
137 Platform.OS === "android" ? "rounded-[20px]" : "rounded-[22px] border border-border",
138 )}
139 >
140 <View className="flex-row items-center justify-between gap-3">
141 <View className="gap-1">
142 <Text
143 className={cn(
144 "text-foreground text-base",
145 Platform.OS === "android" ? "font-t3-medium" : "font-t3-bold",
146 )}
147 >
148 Files
140149 </Text>
141 ) : null}
142 </View>
143 ) : (
144 <View className="gap-2">
145 {allFiles.map((file) => {
146 const included = !excludedFiles.has(file.path);
147 return (
150 <Text className="text-foreground-muted text-xs leading-normal">
151 {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions}
152 </Text>
153 </View>
154 <View className="flex-row items-center gap-2">
155 {!allSelected && isEditingFiles ? (
148156 <Pressable
149 key={file.path}
150 className={cn(
151 "rounded-[18px] border px-4 py-3",
152 included ? "border-border" : "border-border-subtle",
153 )}
154 onPress={() => {
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 }}
157 className={
158 Platform.OS === "android"
159 ? "min-h-12 justify-center rounded-full px-3 active:bg-subtle"
160 : "bg-subtle rounded-full px-3 py-2"
161 }
162 onPress={() => setExcludedFiles(new Set())}
165163 >
166 <View
167 className={`absolute inset-0 rounded-[18px] ${included ? "bg-card" : "bg-subtle"}`}
168 />
169 <View className="flex-row items-start justify-between gap-3">
170 <View className="flex-1 gap-1">
171 <Text
172 selectable
173 className={`text-sm font-t3-bold ${included ? "text-foreground" : "text-foreground-muted"}`}
174 >
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>
164 <Text
165 className={
166 Platform.OS === "android"
167 ? "text-primary text-sm font-t3-medium"
168 : "text-foreground text-2xs font-t3-bold uppercase"
169 }
170 >
171 Reset
172 </Text>
192173 </Pressable>
193 );
194 })}
174 ) : null}
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>
195194 </View>
196 )}
197 </View>
198195
199 <View className="gap-2">
200 <Text className="text-foreground text-sm font-t3-bold">Commit message</Text>
201 <TextInput
202 multiline
203 value={dialogCommitMessage}
204 onChangeText={setDialogCommitMessage}
205 placeholder="Leave empty to auto-generate"
206 textAlignVertical="top"
207 className="min-h-[128px] rounded-[20px] px-4 py-3.5"
208 />
209 </View>
196 {allFiles.length === 0 ? (
197 <Text className="text-foreground-secondary text-sm leading-normal">
198 No changed files are available to commit.
199 </Text>
200 ) : !isEditingFiles ? (
201 <View className="gap-2">
202 {selectedFilePreview.map((file) => (
203 <View key={file.path} className="flex-row items-center justify-between gap-3">
204 <Text className="text-foreground flex-1 text-sm font-medium" numberOfLines={1}>
205 {file.path}
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">
212 <View className="flex-1">
213 <SheetActionButton
214 icon="arrow.branch"
215 label="Commit on new branch"
216 disabled={noneSelected || busy}
217 onPress={() => void runCommitAction(true)}
304 <View
305 className={Platform.OS === "android" ? "gap-3 rounded-[20px] bg-card p-4" : "gap-2"}
306 >
307 <Text
308 className={
309 Platform.OS === "android"
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 )}
218327 />
219328 </View>
220 <View className="flex-1">
221 <SheetActionButton
222 icon="checkmark.circle"
223 label="Commit"
224 tone="primary"
225 disabled={noneSelected || busy}
226 onPress={() => void runCommitAction(false)}
227 />
329
330 <View className={Platform.OS === "android" ? "gap-2" : "flex-row gap-3"}>
331 <View className={Platform.OS === "android" ? undefined : "flex-1"}>
332 <SheetActionButton
333 icon="arrow.branch"
334 label="Commit on new branch"
335 disabled={noneSelected || busy}
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>
228348 </View>
229 </View>
230 </ScrollView>
349 </ScrollView>
350 </MaterialScreenContent>
231351 </View>
232352 );
233353}
apps/mobile/src/features/threads/git/GitConfirmSheet.tsx+86-27
......@@ -4,11 +4,13 @@ import * as Arr from "effect/Array";
44import * as Result from "effect/Result";
55import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
66import { useCallback, useMemo } from "react";
7import { Platform, View } from "react-native";
7import { Platform, ScrollView, useWindowDimensions, View } from "react-native";
88
99import { useSafeAreaInsets } from "react-native-safe-area-context";
1010
1111import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";
12import { MaterialScreenContent } from "../../../components/MaterialScreenContent";
13import { NativeStackScreenOptions } from "../../../native/StackHeader";
1214import { AppText as Text } from "../../../components/AppText";
1315import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
1416import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
......@@ -27,6 +29,7 @@ type GitConfirmSheetProps = StaticScreenProps<{
2729export function GitConfirmSheet(props: GitConfirmSheetProps) {
2830 const navigation = useNavigation();
2931 const insets = useSafeAreaInsets();
32 const { height: windowHeight } = useWindowDimensions();
3033 const gitState = useSelectedThreadGitState();
3134 const gitActions = useSelectedThreadGitActions();
3235
......@@ -102,38 +105,94 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) {
102105 ]);
103106
104107 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 >
106113 {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 />
108127 ) : (
109128 <View className="min-h-4 pt-2" />
110129 )}
111130
112 <View className="items-center gap-1 px-5 pb-3 pt-4">
113 <Text className="text-xs font-t3-bold tracking-[1px] uppercase text-foreground-muted">
114 Confirm
115 </Text>
116 <Text className="text-center text-3xl font-t3-bold">
117 {copy?.title ?? "Run action on default branch?"}
118 </Text>
119 <Text className="text-center text-foreground-secondary text-sm font-medium leading-normal">
120 {copy?.description ?? "Choose how to continue."}
121 </Text>
122 </View>
131 <MaterialScreenContent fitToContents>
132 <ScrollView
133 className={Platform.OS === "android" ? "shrink grow-0" : "flex-1"}
134 showsVerticalScrollIndicator={false}
135 contentContainerClassName={Platform.OS === "android" ? "gap-2 p-2" : undefined}
136 contentContainerStyle={
137 Platform.OS === "android"
138 ? { paddingBottom: Math.max(insets.bottom, 18) + 8 }
139 : undefined
140 }
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 }}>
125 <SheetActionButton
126 icon="arrow.right.circle"
127 label={copy?.continueLabel ?? "Continue"}
128 onPress={() => void continuePendingAction()}
129 />
130 <SheetActionButton
131 icon="arrow.branch"
132 label="Feature branch & continue"
133 tone="primary"
134 onPress={() => void movePendingActionToFeatureBranch()}
135 />
136 </View>
174 <View
175 className={Platform.OS === "android" ? "gap-2" : "gap-3 px-5 pt-2"}
176 style={
177 Platform.OS === "android"
178 ? undefined
179 : { paddingBottom: Math.max(insets.bottom, 18) + 8 }
180 }
181 >
182 <SheetActionButton
183 icon="arrow.right.circle"
184 label={copy?.continueLabel ?? "Continue"}
185 onPress={() => void continuePendingAction()}
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>
137196 </View>
138197 );
139198}
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+84-35
......@@ -22,10 +22,17 @@ import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "re
2222import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
2323import { useSafeAreaInsets } from "react-native-safe-area-context";
2424import { useUniwindTheme } from "../../../lib/useUniwindTheme";
25
26import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";
25import {
26 AndroidHeaderIconButton,
27 AndroidSheetHeader,
28} from "../../../components/AndroidScreenHeader";
29import { MaterialScreenContent } from "../../../components/MaterialScreenContent";
30import { useAdaptiveWorkspaceLayout } from "../../layout/AdaptiveWorkspaceLayout";
2731import { AppText as Text } from "../../../components/AppText";
28import { nativeHeaderScrollEdgeEffects } from "../../../native/StackHeader";
32import {
33 NativeStackScreenOptions,
34 nativeHeaderScrollEdgeEffects,
35} from "../../../native/StackHeader";
2936import { tryOpenExternalUrl } from "../../../lib/openExternalUrl";
3037import { useEnvironmentQuery } from "../../../state/query";
3138import { useThreadSelection } from "../../../state/use-thread-selection";
......@@ -47,6 +54,7 @@ type GitOverviewSheetProps = StaticScreenProps<{
4754};
4855
4956export function GitOverviewSheet(props: GitOverviewSheetProps) {
57 const { layout } = useAdaptiveWorkspaceLayout();
5058 const navigation = useNavigation();
5159 const insets = useSafeAreaInsets();
5260 const presentation = props.presentation ?? "sheet";
......@@ -224,14 +232,14 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
224232
225233 const content = (
226234 <ScrollView
227 className="flex-1 bg-screen"
235 className={Platform.OS === "android" ? "flex-1 bg-sheet-solid" : "flex-1 bg-screen"}
228236 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
229237 showsVerticalScrollIndicator={false}
230238 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
231239 contentContainerStyle={{
232 paddingHorizontal: isInspector ? 12 : 20,
240 paddingHorizontal: Platform.OS === "android" ? 8 : isInspector ? 12 : 20,
233241 paddingTop: 8,
234 gap: 14,
242 gap: Platform.OS === "android" ? 8 : 14,
235243 }}
236244 refreshControl={
237245 <RefreshControl refreshing={isPullRefreshing} onRefresh={() => void handlePullRefresh()} />
......@@ -239,14 +247,18 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
239247 >
240248 <View
241249 className={
242 isInspector
243 ? "overflow-hidden rounded-2xl border border-border bg-card px-3 py-1"
244 : "overflow-hidden rounded-[22px] border border-border bg-card px-4 py-1"
250 Platform.OS === "android"
251 ? "overflow-hidden rounded-[20px] bg-card"
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"
245255 }
246256 >
247257 {sheetMenuItems.map(({ item, disabledReason }, index) => (
248258 <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}
250262 <SheetListRow
251263 icon={menuItemIconName(item.icon)}
252264 title={item.label}
......@@ -258,7 +270,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
258270 ))}
259271 {behindCount > 0 ? (
260272 <>
261 <View className="ml-12 h-px bg-border" />
273 {Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null}
262274 <SheetListRow
263275 icon="arrow.down.circle"
264276 title="Pull latest"
......@@ -268,7 +280,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
268280 />
269281 </>
270282 ) : null}
271 <View className="ml-12 h-px bg-border" />
283 {Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null}
272284 <SheetListRow
273285 icon="text.bubble"
274286 title="Review changes"
......@@ -283,7 +295,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
283295 );
284296 }}
285297 />
286 <View className="ml-12 h-px bg-border" />
298 {Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null}
287299 <SheetListRow
288300 icon="point.topleft.down.curvedto.point.bottomright.up"
289301 title="Branches & worktrees"
......@@ -306,7 +318,11 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
306318 {linkedPrChains.map((chain) => (
307319 <View
308320 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 }
310326 >
311327 {chain.layers.length > 1 ? (
312328 <View className="flex-row items-center gap-2 px-1 pt-2 pb-1">
......@@ -323,7 +339,9 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
323339 ) : null}
324340 {chain.layers.map((link, index) => (
325341 <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}
327345 <SheetListRow
328346 icon="arrow.triangle.pull"
329347 title={`#${link.number} ${link.snapshot?.title ?? "Pull request"}`}
......@@ -416,8 +434,19 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
416434 return (
417435 <View
418436 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 }
420444 >
445 {!isInspector ? (
446 <NativeStackScreenOptions
447 options={{ sheetCornerRadius: Platform.OS === "android" ? 28 : undefined }}
448 />
449 ) : null}
421450 {isInspector ? (
422451 <View
423452 style={{
......@@ -428,24 +457,41 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
428457 ) : null}
429458
430459 {isInspector ? (
431 <View className="gap-1 border-b border-border px-4 pb-4 pt-3">
432 <Pressable
433 className={
434 busy
435 ? "absolute right-3 top-4 z-[1] h-9 w-9 items-center justify-center rounded-full bg-subtle opacity-[0.45]"
436 : "absolute right-3 top-4 z-[1] h-9 w-9 items-center justify-center rounded-full bg-subtle"
437 }
438 disabled={busy}
439 onPress={() => void gitActions.refreshSelectedThreadGitStatus()}
440 >
441 <SymbolView
442 name="arrow.clockwise"
443 size={16}
444 tintColorClassName={"accent-icon"}
445 type="monochrome"
446 weight="medium"
447 />
448 </Pressable>
460 <View
461 className={
462 Platform.OS === "android"
463 ? "gap-1 bg-header px-4 pb-4 pt-3"
464 : "gap-1 border-b border-border px-4 pb-4 pt-3"
465 }
466 >
467 {Platform.OS === "android" ? (
468 <View className="absolute right-3 top-4 z-[1]">
469 <AndroidHeaderIconButton
470 accessibilityLabel="Refresh repository status"
471 disabled={busy}
472 icon="arrow.clockwise"
473 onPress={() => void gitActions.refreshSelectedThreadGitStatus()}
474 />
475 </View>
476 ) : (
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 )}
449495 <Text className="text-xs font-t3-bold tracking-[1px] uppercase text-foreground-muted">
450496 Repository
451497 </Text>
......@@ -457,6 +503,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
457503 ) : (
458504 <AndroidSheetHeader
459505 title={currentBranchLabel}
506 hideBottomBorder={Platform.OS === "android"}
460507 subtitle={currentStatusSummary}
461508 onBack={() => navigation.goBack()}
462509 actions={[
......@@ -470,7 +517,9 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
470517 />
471518 )}
472519
473 {content}
520 <MaterialScreenContent insetHorizontal={layout.usesSplitView}>
521 {content}
522 </MaterialScreenContent>
474523 </View>
475524 );
476525}
apps/mobile/src/features/threads/git/gitSheetComponents.tsx+58-19
......@@ -1,7 +1,8 @@
11import { SymbolView } from "../../../components/AppSymbol";
22import type { ComponentProps } from "react";
3import { Pressable, View } from "react-native";
3import { Platform, Pressable, View } from "react-native";
44import { AppText as Text } from "../../../components/AppText";
5import { MaterialButton } from "../../../components/MaterialButton";
56import { cn } from "../../../lib/cn";
67
78/* ─── Shared sheet components ──────────────────────────────────────── */
......@@ -13,6 +14,18 @@ export function SheetActionButton(props: {
1314 readonly tone?: "primary" | "secondary" | "danger";
1415 readonly onPress: () => void;
1516}) {
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 );
1629 const tone = props.tone ?? "secondary";
1730 const textColorClassName =
1831 tone === "primary"
......@@ -24,13 +37,13 @@ export function SheetActionButton(props: {
2437 return (
2538 <Pressable
2639 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]",
28 tone === "primary"
29 ? "bg-primary"
30 : tone === "danger"
31 ? "border border-danger-border bg-danger"
32 : "border border-secondary-border bg-secondary",
40 "min-h-[48px] flex-row items-center justify-center gap-2 px-4 py-3 disabled:opacity-[0.45]",
41 "flex-1 rounded-[18px]",
42 tone === "primary" ? "bg-primary" : tone === "danger" ? "bg-danger" : "bg-secondary",
43 tone !== "primary" &&
44 (tone === "danger" ? "border border-danger-border" : "border border-secondary-border"),
3345 )}
46 accessibilityRole="button"
3447 disabled={props.disabled}
3548 onPress={props.onPress}
3649 >
......@@ -58,7 +71,13 @@ export function SheetActionButton(props: {
5871
5972export function MetaCard(props: { readonly label: string; readonly value: string }) {
6073 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 >
6281 <Text className="text-foreground-muted text-2xs font-t3-bold tracking-[0.9px] uppercase">
6382 {props.label}
6483 </Text>
......@@ -78,30 +97,50 @@ export function SheetListRow(props: {
7897}) {
7998 return (
8099 <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 }
82105 disabled={props.disabled}
83106 onPress={props.onPress}
84107 >
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 >
86115 <SymbolView
87116 name={props.icon}
88 size={16}
89 tintColorClassName={"accent-icon"}
117 size={Platform.OS === "android" ? 24 : 16}
118 tintColorClassName="accent-icon"
90119 type="monochrome"
91120 />
92121 </View>
93122 <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>
95132 {props.subtitle ? (
96133 <Text className="text-foreground-muted text-xs leading-snug">{props.subtitle}</Text>
97134 ) : null}
98135 </View>
99 <SymbolView
100 name="chevron.right"
101 size={13}
102 tintColorClassName={"accent-icon-subtle"}
103 type="monochrome"
104 />
136 {Platform.OS !== "android" ? (
137 <SymbolView
138 name="chevron.right"
139 size={13}
140 tintColorClassName="accent-icon-subtle"
141 type="monochrome"
142 />
143 ) : null}
105144 </Pressable>
106145 );
107146}
apps/mobile/src/features/threads/thread-list-items.tsx+43-36
......@@ -183,7 +183,7 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props:
183183 <SymbolView
184184 name="plus"
185185 size={compact ? 20 : 16}
186 tintColorClassName={"accent-icon-muted"}
186 tintColorClassName="accent-icon-muted"
187187 type="monochrome"
188188 weight="medium"
189189 />
......@@ -232,7 +232,7 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props:
232232 <SymbolView
233233 name={icon}
234234 size={10}
235 tintColorClassName={"accent-icon-subtle"}
235 tintColorClassName="accent-icon-subtle"
236236 type="monochrome"
237237 weight="semibold"
238238 />
......@@ -379,7 +379,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
379379 <SymbolView
380380 name="chevron.right"
381381 size={13}
382 tintColorClassName={"accent-icon-subtle"}
382 tintColorClassName="accent-icon-subtle"
383383 type="monochrome"
384384 />
385385 </View>
......@@ -469,10 +469,10 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
469469 >["simultaneousWithExternalGesture"];
470470}) {
471471 const { width: windowWidth } = useWindowDimensions();
472 const { themeAppearance: colorScheme, materialYouStyleLayoutActive } = useAppearancePreferences();
472 const { themeAppearance: colorScheme } = useAppearancePreferences();
473473 const compact = props.variant === "compact";
474474 const selected = props.selected === true;
475 const visuallySelected = selected && (!compact || materialYouStyleLayoutActive);
475 const visuallySelected = selected && (!compact || Platform.OS === "android");
476476 const theme = useUniwindTheme();
477477 const screenColor = theme["--color-screen"];
478478 const drawerColor = theme["--color-drawer"];
......@@ -507,22 +507,22 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
507507 );
508508
509509 const backgroundColor = compact ? screenColor : drawerColor;
510 const effectiveSelectedBackground = materialYouStyleLayoutActive
511 ? materialSelectedBackgroundColor
512 : selectedBackgroundColor;
513 const effectiveSelectedForeground = materialYouStyleLayoutActive
514 ? materialSelectedForegroundColor
515 : selectedForegroundColor;
510 const effectiveSelectedBackground =
511 Platform.OS === "android" ? materialSelectedBackgroundColor : selectedBackgroundColor;
512 const effectiveSelectedForeground =
513 Platform.OS === "android" ? materialSelectedForegroundColor : selectedForegroundColor;
516514 const effectiveStatus =
517515 visuallySelected && status
518516 ? {
519517 ...status,
520 pillClassName: materialYouStyleLayoutActive
521 ? "bg-thread-selected-foreground/20"
522 : "bg-user-bubble-foreground/20",
523 textClassName: materialYouStyleLayoutActive
524 ? "text-thread-selected-foreground"
525 : "text-user-bubble-foreground",
518 pillClassName:
519 Platform.OS === "android"
520 ? "bg-thread-selected-foreground/20"
521 : "bg-user-bubble-foreground/20",
522 textClassName:
523 Platform.OS === "android"
524 ? "text-thread-selected-foreground"
525 : "text-user-bubble-foreground",
526526 }
527527 : status;
528528
......@@ -596,7 +596,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
596596 compact
597597 ? "accent-icon-subtle"
598598 : selected
599 ? materialYouStyleLayoutActive
599 ? Platform.OS === "android"
600600 ? "accent-thread-selected-foreground-muted"
601601 : "accent-user-bubble-foreground-muted"
602602 : "accent-foreground-muted"
......@@ -608,7 +608,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
608608 "shrink",
609609 compact ? "text-sm" : "text-xs",
610610 visuallySelected
611 ? materialYouStyleLayoutActive
611 ? Platform.OS === "android"
612612 ? "text-thread-selected-foreground-muted"
613613 : "text-user-bubble-foreground-muted"
614614 : "text-foreground-muted",
......@@ -647,7 +647,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
647647 <Text
648648 className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${
649649 visuallySelected
650 ? materialYouStyleLayoutActive
650 ? Platform.OS === "android"
651651 ? "text-thread-selected-foreground"
652652 : "text-user-bubble-foreground"
653653 : pr.textClassName
......@@ -666,7 +666,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
666666 key={`${thread.environmentId}:${thread.id}`}
667667 interactionClassName={
668668 visuallySelected
669 ? materialYouStyleLayoutActive
669 ? Platform.OS === "android"
670670 ? "bg-thread-selected-foreground"
671671 : "bg-user-bubble-foreground"
672672 : "bg-primary"
......@@ -676,10 +676,10 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
676676 accessibilityRole="button"
677677 className="bg-screen"
678678 style={
679 materialYouStyleLayoutActive
679 Platform.OS === "android"
680680 ? {
681681 backgroundColor: visuallySelected ? effectiveSelectedBackground : backgroundColor,
682 borderRadius: SIDEBAR_ROW_RADIUS,
682 borderRadius: 20,
683683 }
684684 : undefined
685685 }
......@@ -689,13 +689,18 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
689689 }}
690690 >
691691 <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 >
693698 <View className="flex-row items-center justify-between gap-2">
694699 <Text
695700 className={cn(
696701 "flex-1 text-lg font-t3-bold",
697702 visuallySelected
698 ? materialYouStyleLayoutActive
703 ? Platform.OS === "android"
699704 ? "text-thread-selected-foreground"
700705 : "text-user-bubble-foreground"
701706 : "text-foreground",
......@@ -706,14 +711,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
706711 </Text>
707712 <View className="flex-row items-center gap-2">
708713 {props.hasQueuedMessages ? (
709 <QueuedMessageIcon selected={visuallySelected && !materialYouStyleLayoutActive} />
714 <QueuedMessageIcon selected={visuallySelected && Platform.OS !== "android"} />
710715 ) : null}
711716 {statusPill}
712717 <Text
713718 className={cn(
714719 "text-base tabular-nums",
715720 visuallySelected
716 ? materialYouStyleLayoutActive
721 ? Platform.OS === "android"
717722 ? "text-thread-selected-foreground-muted"
718723 : "text-user-bubble-foreground-muted"
719724 : "text-foreground-tertiary",
......@@ -724,7 +729,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
724729 <SymbolView
725730 name="chevron.right"
726731 size={13}
727 tintColorClassName={"accent-icon-subtle"}
732 tintColorClassName="accent-icon-subtle"
728733 type="monochrome"
729734 />
730735 </View>
......@@ -746,7 +751,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
746751 key={`${thread.environmentId}:${thread.id}`}
747752 interactionClassName={
748753 visuallySelected
749 ? materialYouStyleLayoutActive
754 ? Platform.OS === "android"
750755 ? "bg-thread-selected-foreground"
751756 : "bg-user-bubble-foreground"
752757 : "bg-primary"
......@@ -761,7 +766,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
761766 }}
762767 style={{
763768 backgroundColor: visuallySelected ? effectiveSelectedBackground : backgroundColor,
764 borderRadius: SIDEBAR_ROW_RADIUS,
769 borderRadius: Platform.OS === "android" ? 20 : SIDEBAR_ROW_RADIUS,
765770 minHeight: 64,
766771 justifyContent: "center",
767772 paddingHorizontal: 12,
......@@ -774,7 +779,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
774779 className={cn(
775780 "flex-1 text-base font-t3-medium",
776781 visuallySelected
777 ? materialYouStyleLayoutActive
782 ? Platform.OS === "android"
778783 ? "text-thread-selected-foreground"
779784 : "text-user-bubble-foreground"
780785 : "text-foreground",
......@@ -785,14 +790,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
785790 </Text>
786791 <View className="flex-row items-center gap-2">
787792 {props.hasQueuedMessages ? (
788 <QueuedMessageIcon selected={visuallySelected && !materialYouStyleLayoutActive} />
793 <QueuedMessageIcon selected={visuallySelected && Platform.OS !== "android"} />
789794 ) : null}
790795 {statusPill}
791796 <Text
792797 className={cn(
793798 "text-xs tabular-nums",
794799 visuallySelected
795 ? materialYouStyleLayoutActive
800 ? Platform.OS === "android"
796801 ? "text-thread-selected-foreground-muted"
797802 : "text-user-bubble-foreground-muted"
798803 : "text-foreground-muted",
......@@ -820,9 +825,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
820825 threadKey={`${thread.environmentId}:${thread.id}`}
821826 backgroundColor={backgroundColor}
822827 containerStyle={
823 compact && !materialYouStyleLayoutActive
824 ? undefined
825 : { borderRadius: SIDEBAR_ROW_RADIUS, overflow: "hidden" }
828 Platform.OS === "android"
829 ? { borderRadius: 20, overflow: "hidden", marginHorizontal: 8, marginVertical: 2 }
830 : compact
831 ? undefined
832 : { borderRadius: SIDEBAR_ROW_RADIUS, overflow: "hidden" }
826833 }
827834 enableTrackpadSwipe
828835 fullSwipeWidth={props.fullSwipeWidth ?? windowWidth - 32}
apps/mobile/src/features/threads/thread-list-v2-items.tsx+42-36
......@@ -1,6 +1,5 @@
11import { RowPressable } from "../../components/RowPressable";
22import { CustomSnoozeSheet } from "./CustomSnoozeSheet";
3import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
43import { appAtomRegistry } from "../../state/atom-registry";
54import { threadArrangementOpenAtom } from "../../state/thread-order";
65import type { ThreadMoveDestination } from "./threadOrder";
......@@ -182,7 +181,7 @@ export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledS
182181 <SymbolView
183182 name="chevron.down"
184183 size={10}
185 tintColorClassName={"accent-foreground-muted"}
184 tintColorClassName="accent-foreground-muted"
186185 type="monochrome"
187186 style={{ transform: [{ rotate: props.expanded ? "180deg" : "0deg" }] }}
188187 />
......@@ -441,12 +440,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
441440
442441 const pr = useThreadPr(thread);
443442
444 const { materialYouStyleLayoutActive } = useAppearancePreferences();
445443 const theme = useUniwindTheme();
446444 const screenColor = theme["--color-screen"];
447445 const drawerColor = theme["--color-drawer"];
448446 const selectedBackgroundColor =
449 theme[materialYouStyleLayoutActive ? "--color-thread-selected" : "--color-user-bubble"];
447 theme[Platform.OS === "android" ? "--color-thread-selected" : "--color-user-bubble"];
450448 const sidebarPane = props.pane === "sidebar";
451449 const selected = props.selected === true;
452450 // The provider badge's border blends into the row's own surface, which
......@@ -456,7 +454,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
456454 const providerIconSurfaceColor = sidebarPane
457455 ? selected
458456 ? selectedBackgroundColor
459 : drawerColor
457 : Platform.OS === "android"
458 ? screenColor
459 : drawerColor
460460 : screenColor;
461461
462462 const status = resolveThreadListV2Status(thread);
......@@ -750,7 +750,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
750750 className={cn(
751751 "flex-1 text-sm font-t3-medium",
752752 selected
753 ? materialYouStyleLayoutActive
753 ? Platform.OS === "android"
754754 ? "text-thread-selected-foreground-muted"
755755 : "text-user-bubble-foreground-muted"
756756 : "text-foreground-muted",
......@@ -764,7 +764,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
764764 <SymbolView
765765 name="pin"
766766 size={11}
767 tintColorClassName={"accent-foreground-muted"}
767 tintColorClassName="accent-foreground-muted"
768768 type="monochrome"
769769 />
770770 ) : null}
......@@ -772,7 +772,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
772772 className={cn(
773773 "text-xs tabular-nums",
774774 selected
775 ? materialYouStyleLayoutActive
775 ? Platform.OS === "android"
776776 ? "text-thread-selected-foreground"
777777 : "text-user-bubble-foreground"
778778 : (statusLabel?.className ?? "text-foreground-tertiary"),
......@@ -785,7 +785,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
785785 className={cn(
786786 "mt-1 text-base font-t3-medium",
787787 selected
788 ? materialYouStyleLayoutActive
788 ? Platform.OS === "android"
789789 ? "text-thread-selected-foreground"
790790 : "text-user-bubble-foreground"
791791 : "text-foreground",
......@@ -809,7 +809,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
809809 className={cn(
810810 "flex-1 text-xs",
811811 selected
812 ? materialYouStyleLayoutActive
812 ? Platform.OS === "android"
813813 ? "text-thread-selected-foreground-muted"
814814 : "text-user-bubble-foreground-muted"
815815 : "text-danger-foreground",
......@@ -830,7 +830,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
830830 className={cn(
831831 "shrink text-xs",
832832 selected
833 ? materialYouStyleLayoutActive
833 ? Platform.OS === "android"
834834 ? "text-thread-selected-foreground-muted"
835835 : "text-user-bubble-foreground-muted"
836836 : "text-foreground-muted",
......@@ -842,7 +842,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
842842 className={cn(
843843 "text-xs",
844844 selected
845 ? materialYouStyleLayoutActive
845 ? Platform.OS === "android"
846846 ? "text-thread-selected-foreground-muted"
847847 : "text-user-bubble-foreground-muted"
848848 : "text-foreground-muted",
......@@ -858,7 +858,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
858858 className={cn(
859859 "text-xs",
860860 selected
861 ? materialYouStyleLayoutActive
861 ? Platform.OS === "android"
862862 ? "text-thread-selected-foreground-muted"
863863 : "text-user-bubble-foreground-muted"
864864 : "text-foreground-tertiary",
......@@ -874,7 +874,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
874874 size={11}
875875 tintColorClassName={
876876 selected
877 ? materialYouStyleLayoutActive
877 ? Platform.OS === "android"
878878 ? "accent-thread-selected-foreground-muted"
879879 : "accent-user-bubble-foreground-muted"
880880 : "accent-foreground-tertiary"
......@@ -893,7 +893,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
893893 size={12}
894894 tintColorClassName={
895895 selected
896 ? materialYouStyleLayoutActive
896 ? Platform.OS === "android"
897897 ? "accent-thread-selected-foreground"
898898 : "accent-user-bubble-foreground"
899899 : pr.state === null || pr.isDraft
......@@ -911,7 +911,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
911911 className={cn(
912912 "text-xs",
913913 selected
914 ? materialYouStyleLayoutActive
914 ? Platform.OS === "android"
915915 ? "text-thread-selected-foreground"
916916 : "text-user-bubble-foreground"
917917 : pr.textClassName,
......@@ -941,13 +941,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
941941 <RowPressable
942942 key={`${thread.environmentId}:${thread.id}`}
943943 interactionClassName={
944 selected && (sidebarPane || materialYouStyleLayoutActive)
945 ? materialYouStyleLayoutActive
944 selected && (sidebarPane || Platform.OS === "android")
945 ? Platform.OS === "android"
946946 ? "bg-thread-selected-foreground"
947947 : "bg-user-bubble-foreground"
948948 : "bg-primary"
949949 }
950 className={sidebarPane || materialYouStyleLayoutActive ? undefined : "bg-screen"}
950 className={sidebarPane || Platform.OS === "android" ? undefined : "bg-screen"}
951951 accessibilityHint={swipeAccessibilityHint}
952952 accessibilityLabel={
953953 props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title
......@@ -959,14 +959,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
959959 onSelectThread(thread);
960960 }}
961961 style={
962 sidebarPane || materialYouStyleLayoutActive
962 sidebarPane || Platform.OS === "android"
963963 ? {
964964 backgroundColor: selected
965965 ? selectedBackgroundColor
966 : sidebarPane
966 : sidebarPane && Platform.OS !== "android"
967967 ? drawerColor
968968 : screenColor,
969 borderRadius: SIDEBAR_V2_ROW_RADIUS,
969 borderRadius: Platform.OS === "android" ? 20 : SIDEBAR_V2_ROW_RADIUS,
970970 ...(sidebarPane ? { paddingHorizontal: 12, paddingVertical: 10 } : null),
971971 }
972972 : undefined
......@@ -980,8 +980,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
980980 separates rows. The opaque screen background stays so swipe
981981 actions reveal behind the row. */
982982 <View>
983 <View className="px-5 py-2.5">{cardContent}</View>
984 {props.showTrailingDivider !== false ? (
983 <View className={Platform.OS === "android" ? "px-3 py-2.5" : "px-5 py-2.5"}>
984 {cardContent}
985 </View>
986 {Platform.OS !== "android" && props.showTrailingDivider !== false ? (
985987 <View className="ml-5 h-px bg-border-subtle" />
986988 ) : null}
987989 </View>
......@@ -991,8 +993,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
991993 <RowPressable
992994 key={`${thread.environmentId}:${thread.id}`}
993995 interactionClassName={
994 selected && (sidebarPane || materialYouStyleLayoutActive)
995 ? materialYouStyleLayoutActive
996 selected && (sidebarPane || Platform.OS === "android")
997 ? Platform.OS === "android"
996998 ? "bg-thread-selected-foreground"
997999 : "bg-user-bubble-foreground"
9981000 : "bg-primary"
......@@ -1003,20 +1005,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
10031005 }
10041006 accessibilityRole="button"
10051007 accessibilityState={{ selected }}
1006 className={sidebarPane || materialYouStyleLayoutActive ? undefined : "bg-screen"}
1008 className={sidebarPane || Platform.OS === "android" ? undefined : "bg-screen"}
10071009 onPress={() => {
10081010 close();
10091011 onSelectThread(thread);
10101012 }}
10111013 style={
1012 sidebarPane || materialYouStyleLayoutActive
1014 sidebarPane || Platform.OS === "android"
10131015 ? {
10141016 backgroundColor: selected
10151017 ? selectedBackgroundColor
1016 : sidebarPane
1018 : sidebarPane && Platform.OS !== "android"
10171019 ? drawerColor
10181020 : screenColor,
1019 borderRadius: SIDEBAR_V2_ROW_RADIUS,
1021 borderRadius: Platform.OS === "android" ? 20 : SIDEBAR_V2_ROW_RADIUS,
10201022 }
10211023 : undefined
10221024 }
......@@ -1044,7 +1046,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
10441046 className={cn(
10451047 "text-base",
10461048 selected
1047 ? materialYouStyleLayoutActive
1049 ? Platform.OS === "android"
10481050 ? "text-thread-selected-foreground"
10491051 : "text-user-bubble-foreground"
10501052 : "text-foreground-muted",
......@@ -1066,7 +1068,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
10661068 className={cn(
10671069 "text-sm tabular-nums",
10681070 selected
1069 ? materialYouStyleLayoutActive
1071 ? Platform.OS === "android"
10701072 ? "text-thread-selected-foreground-muted"
10711073 : "text-user-bubble-foreground-muted"
10721074 : snoozedRow
......@@ -1084,16 +1086,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
10841086 );
10851087
10861088 return (
1087 <>
1089 <View collapsable={false}>
10881090 {customSnoozeOpen && (
10891091 <CustomSnoozeSheet onClose={() => setCustomSnoozeOpen(false)} onSnooze={handleSnooze} />
10901092 )}
10911093 <ThreadSwipeable
10921094 threadKey={`${thread.environmentId}:${thread.id}`}
1093 backgroundColor={sidebarPane ? drawerColor : screenColor}
1095 backgroundColor={sidebarPane && Platform.OS !== "android" ? drawerColor : screenColor}
10941096 compactActions={variant === "slim"}
10951097 containerStyle={
1096 sidebarPane ? { borderRadius: SIDEBAR_V2_ROW_RADIUS, overflow: "hidden" } : undefined
1098 Platform.OS === "android"
1099 ? { borderRadius: 20, overflow: "hidden", marginHorizontal: 8, marginVertical: 2 }
1100 : sidebarPane
1101 ? { borderRadius: SIDEBAR_V2_ROW_RADIUS, overflow: "hidden" }
1102 : undefined
10971103 }
10981104 enableTrackpadSwipe
10991105 // Full swipe commits the advertised lifecycle action (Settle /
......@@ -1141,6 +1147,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
11411147 </ControlPillMenu>
11421148 )}
11431149 </ThreadSwipeable>
1144 </>
1150 </View>
11451151 );
11461152});
apps/mobile/src/features/threads/thread-search-match.tsx+4-5
......@@ -1,8 +1,8 @@
1import { Platform } from "react-native";
12import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
23
34import { AppText as Text } from "../../components/AppText";
45import { cn } from "../../lib/cn";
5import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
66
77function foldAsciiCase(value: string): string {
88 return value.replace(/[A-Z]/g, (character) => character.toLowerCase());
......@@ -51,14 +51,13 @@ export function ThreadSearchMatchExcerpt(props: {
5151 readonly compact?: boolean;
5252}) {
5353 const isUser = props.match.source === "user";
54 const { materialYouStyleLayoutActive } = useAppearancePreferences();
5554 const parts = splitHighlightParts(props.match.snippet, props.query);
5655 return (
5756 <Text
5857 className={cn(
5958 props.compact ? "text-sm" : "text-xs",
6059 props.selected
61 ? materialYouStyleLayoutActive
60 ? Platform.OS === "android"
6261 ? "text-thread-selected-foreground-muted"
6362 : "text-user-bubble-foreground-muted"
6463 : "text-foreground-muted",
......@@ -69,7 +68,7 @@ export function ThreadSearchMatchExcerpt(props: {
6968 className={cn(
7069 props.compact ? "text-sm font-t3-medium" : "text-xs font-t3-medium",
7170 props.selected
72 ? materialYouStyleLayoutActive
71 ? Platform.OS === "android"
7372 ? "text-thread-selected-foreground"
7473 : "text-user-bubble-foreground"
7574 : isUser
......@@ -85,7 +84,7 @@ export function ThreadSearchMatchExcerpt(props: {
8584 props.compact ? "text-sm" : "text-xs",
8685 part.highlighted && "font-t3-bold",
8786 props.selected
88 ? materialYouStyleLayoutActive
87 ? Platform.OS === "android"
8988 ? "text-thread-selected-foreground"
9089 : "text-user-bubble-foreground"
9190 : part.highlighted
apps/mobile/src/features/usage/UsageLimitsPooled.tsx+4-12
......@@ -12,15 +12,14 @@ import {
1212 type LimitPoolWindow,
1313} from "@t3tools/shared/usageLimits";
1414import { useId, useState } from "react";
15import { Platform, Pressable, ScrollView, View } from "react-native";
15import { Pressable, ScrollView, View } from "react-native";
1616import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg";
1717import { useSafeAreaInsets } from "react-native-safe-area-context";
1818
19import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
2019import { SymbolView } from "../../components/AppSymbol";
2120import { AppText as Text } from "../../components/AppText";
2221import { ProviderIcon } from "../../components/ProviderIcon";
23import { NativeStackScreenOptions } from "../../native/StackHeader";
22import { SettingsScreen } from "../settings/components/SettingsScreen";
2423import { environmentPresentations } from "../../state/presentation";
2524import { ResetCredits } from "./UsageLimitsSection";
2625import { useProviderColors } from "./usageProviders";
......@@ -278,7 +277,6 @@ type AccountScreenProps = StaticScreenProps<{
278277
279278/** Resolve the account again so live quota and credit updates reach the open detail screen. */
280279export function UsageLimitAccountScreen({ route }: AccountScreenProps) {
281 const navigation = useNavigation();
282280 const insets = useSafeAreaInsets();
283281 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
284282 const { accountKey, windowId, windowKind, environmentIds, now } = route.params;
......@@ -297,13 +295,7 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) {
297295 const reset = pool?.resets.find((candidate) => candidate.member.account.key === accountKey);
298296 const [revealed, setRevealed] = useState(false);
299297 return (
300 <View collapsable={false} className="flex-1 bg-sheet">
301 {Platform.OS === "android" ? (
302 <>
303 <NativeStackScreenOptions options={{ headerShown: false }} />
304 <AndroidScreenHeader title="Account" onBack={() => navigation.goBack()} />
305 </>
306 ) : null}
298 <SettingsScreen title="Account">
307299 <ScrollView
308300 contentInsetAdjustmentBehavior="automatic"
309301 contentContainerClassName="gap-5 p-5"
......@@ -389,6 +381,6 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) {
389381 </>
390382 )}
391383 </ScrollView>
392 </View>
384 </SettingsScreen>
393385 );
394386}
apps/mobile/src/features/usage/UsageRouteScreen.tsx+8-18
......@@ -1,3 +1,4 @@
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
12import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
23import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native";
34import {
......@@ -18,15 +19,14 @@ import {
1819 makeWindow,
1920} from "@t3tools/shared/usageFormat";
2021import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
21import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
22import { Platform, Pressable, RefreshControl, View } from "react-native";
2223import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated";
2324import { useSafeAreaInsets } from "react-native-safe-area-context";
2425
2526import { SegmentedControl } from "../../components/SegmentedControl";
26import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
2727import { AppText as Text } from "../../components/AppText";
2828import { cn } from "../../lib/cn";
29import { NativeStackScreenOptions } from "../../native/StackHeader";
29import { SettingsScreen } from "../settings/components/SettingsScreen";
3030import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
3131import { SettingsSection } from "../settings/components/SettingsSection";
3232import { UsageDailyChart } from "./UsageDailyChart";
......@@ -236,17 +236,7 @@ export function UsageRouteScreen() {
236236 }, [navigation, environmentFilter]);
237237
238238 return (
239 <View collapsable={false} className="flex-1 bg-sheet">
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}
239 <SettingsScreen title="Usage" trailing={environmentFilter}>
250240 <ScrollView
251241 contentInsetAdjustmentBehavior="automatic"
252242 showsVerticalScrollIndicator={false}
......@@ -277,20 +267,20 @@ export function UsageRouteScreen() {
277267 <>
278268 {/* Period and metric together: neither applies to Limits, and
279269 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")}>
281271 <SegmentedControl
282272 options={WINDOW_OPTIONS}
283273 selected={windowDays}
284274 onSelect={selectWindow}
285275 size="compact"
286 className="flex-1"
276 className={Platform.OS === "android" ? "w-full" : "flex-1"}
287277 />
288278 <SegmentedControl
289279 options={METRIC_OPTIONS}
290280 selected={metric}
291281 onSelect={setMetric}
292282 size="compact"
293 className="w-36"
283 className={Platform.OS === "android" ? "w-full" : "w-36"}
294284 />
295285 </View>
296286 {merged.duplicateSources.length > 0 ? (
......@@ -330,7 +320,7 @@ export function UsageRouteScreen() {
330320 )}
331321 </Animated.View>
332322 </ScrollView>
333 </View>
323 </SettingsScreen>
334324 );
335325}
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 = {
3131};
3232
3333describe("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
3446 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");
3648 const snapshot = { ...base };
3749
3850 const variables = materialYouPaletteToMobileThemeVariables(palette, "dark", base);
......@@ -47,7 +59,7 @@ describe("Material You system colors", () => {
4759 });
4860
4961 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");
5163 const dark = materialYouPaletteToMobileThemeVariables(
5264 { ...palette, inversePrimary: "#A31D8DFF" },
5365 "dark",
......@@ -56,7 +68,7 @@ describe("Material You system colors", () => {
5668 const light = materialYouPaletteToMobileThemeVariables(
5769 { ...palette, inversePrimary: "#A31D8DFF" },
5870 "light",
59 getMobileThemeRuntimeVariables("t3-code", "light"),
71 getMobileThemeRuntimeVariables("t3-code", "light", "android"),
6072 );
6173
6274 expect(dark["--color-user-bubble"]).toBe("#850073FF");
apps/mobile/src/lib/mobileThemeVariables.test.ts+38-5
......@@ -1,25 +1,58 @@
11import { describe, expect, it } from "vite-plus/test";
22
33import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support";
4import { getMobileThemeVariables } from "./mobileTheme";
4import { getMobileThemeVariables, MOBILE_THEME_IDS, themeColorWithAlpha } from "./mobileTheme";
55import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables";
66
77describe("mobile theme runtime variables", () => {
88 it("derives the standard runtime palette from global.css", () => {
9 expect(getMobileThemeRuntimeVariables("t3-code", "light")).toEqual(
9 expect(getMobileThemeRuntimeVariables("t3-code", "light", "ios")).toEqual(
1010 readDefaultMobileThemeVariables("light"),
1111 );
12 expect(getMobileThemeRuntimeVariables("t3-code", "dark")).toEqual(
12 expect(getMobileThemeRuntimeVariables("t3-code", "dark", "ios")).toEqual(
1313 readDefaultMobileThemeVariables("dark"),
1414 );
1515 });
1616
1717 it("uses the same shared palette source as generated custom themes", () => {
18 expect(getMobileThemeRuntimeVariables("ocean", "light")).toEqual(
18 expect(getMobileThemeRuntimeVariables("ocean", "light", "ios")).toEqual(
1919 getMobileThemeVariables("ocean", "light"),
2020 );
21 expect(getMobileThemeRuntimeVariables("iris", "dark")).toEqual(
21 expect(getMobileThemeRuntimeVariables("iris", "dark", "ios")).toEqual(
2222 getMobileThemeVariables("iris", "dark"),
2323 );
2424 });
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 );
2558});
apps/mobile/src/lib/mobileThemeVariables.ts+16-2
......@@ -1,8 +1,8 @@
11import defaultThemeVariables from "../../generated-uniwind-default-theme-variables.json";
2
32import {
43 DEFAULT_MOBILE_THEME_ID,
54 getMobileThemeVariables,
5 themeColorWithAlpha,
66 type MobileThemeAppearance,
77 type MobileThemeId,
88 type MobileThemeVariables,
......@@ -20,8 +20,22 @@ const defaults = defaultThemeVariables as Readonly<
2020export function getMobileThemeRuntimeVariables(
2121 themeId: MobileThemeId,
2222 appearance: MobileThemeAppearance,
23 platform: string,
2324): 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
2527 ? defaults[appearance]
2628 : 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 };
2741}
apps/mobile/src/lib/storage.test.ts+9-15
......@@ -204,21 +204,15 @@ describe("mobile connection storage", () => {
204204 await expect(loadPreferences()).resolves.toEqual({ ...themes, lightThemeId: "t3-chat" });
205205 });
206206
207 it("persists the Material You layout independently of the selected theme", async () => {
208 await savePreferencesPatch({
209 lightThemeId: "material-you",
210 materialYouStyleLayoutEnabled: true,
211 });
212 await savePreferencesPatch({ lightThemeId: "t3-chat" });
213 await expect(loadPreferences()).resolves.toEqual({
214 lightThemeId: "t3-chat",
215 materialYouStyleLayoutEnabled: true,
216 });
217 await savePreferencesPatch({ materialYouStyleLayoutEnabled: false });
218 await expect(loadPreferences()).resolves.toEqual({
219 lightThemeId: "t3-chat",
220 materialYouStyleLayoutEnabled: false,
221 });
207 it.each([true, false])("drops the removed Android layout preference (%s)", async (enabled) => {
208 mocks.setPreferencesJson(
209 JSON.stringify({
210 materialYouStyleLayoutEnabled: enabled,
211 lightThemeId: "t3-chat",
212 }),
213 10,
214 );
215 await expect(loadPreferences()).resolves.toEqual({ lightThemeId: "t3-chat" });
222216 });
223217
224218 it("drops the removed theme transition preference", async () => {
apps/mobile/src/native/SelectableMarkdownText.android.tsx+17-1
......@@ -2,8 +2,11 @@ import {
22 SelectableMarkdownText as T3SelectableMarkdownText,
33 type SelectableMarkdownTextProps,
44} from "@t3tools/mobile-markdown-text/renderer";
5import { useMemo } from "react";
56
67import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter";
8import { themeColorWithAlpha } from "../lib/mobileTheme";
9import { useUniwindTheme } from "../lib/useUniwindTheme";
710
811type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;
912
......@@ -20,5 +23,18 @@ export function hasNativeSelectableMarkdownText(): boolean {
2023}
2124
2225export 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 );
2440}
apps/mobile/src/native/T3ComposerEditor.native.tsx+2-2
......@@ -14,7 +14,7 @@ import {
1414 type Ref,
1515} from "react";
1616import type { NativeSyntheticEvent, ViewProps } from "react-native";
17import { Image, StyleSheet } from "react-native";
17import { Image, Platform, StyleSheet } from "react-native";
1818
1919import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
2020import {
......@@ -259,7 +259,7 @@ export function ComposerEditor({
259259 );
260260 const { systemColorsActive } = useAppearancePreferences();
261261 const themeJson = JSON.stringify({
262 selection: systemColorsActive ? theme["--color-primary"] : null,
262 selection: Platform.OS === "android" || systemColorsActive ? theme["--color-primary"] : null,
263263 text: theme["--color-foreground"],
264264 placeholder: theme["--color-placeholder"],
265265 chipBackground: theme["--color-subtle"],
apps/mobile/src/persistence/mobile-preferences.ts-6
......@@ -8,7 +8,6 @@ import * as Semaphore from "effect/Semaphore";
88import type { SidebarProjectGroupingMode } from "@t3tools/contracts";
99import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior";
1010import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";
11
1211import * as MobileDatabase from "./mobile-database";
1312import * as MobileSecureStorage from "./mobile-secure-storage";
1413import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage";
......@@ -22,7 +21,6 @@ export interface Preferences {
2221 readonly lightThemeId?: MobileThemeId;
2322 readonly darkThemeId?: MobileThemeId;
2423 readonly themeMode?: MobileThemeMode;
25 readonly materialYouStyleLayoutEnabled?: boolean;
2624 readonly baseFontSize?: number;
2725 readonly terminalFontSize?: number | null;
2826 readonly markdownFontSize?: number;
......@@ -94,7 +92,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {
9492 lightThemeId?: MobileThemeId;
9593 darkThemeId?: MobileThemeId;
9694 themeMode?: MobileThemeMode;
97 materialYouStyleLayoutEnabled?: boolean;
9895 baseFontSize?: number;
9996 terminalFontSize?: number | null;
10097 markdownFontSize?: number;
......@@ -139,9 +136,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {
139136 ) {
140137 preferences.themeMode = parsed.themeMode;
141138 }
142 if (typeof parsed.materialYouStyleLayoutEnabled === "boolean") {
143 preferences.materialYouStyleLayoutEnabled = parsed.materialYouStyleLayoutEnabled;
144 }
145139 if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize;
146140 if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) {
147141 preferences.terminalFontSize = parsed.terminalFontSize;
docs/user/appearance.md+2-1
......@@ -10,7 +10,8 @@ themes or defaults.
1010On Android 12 or newer, choose the **Material You** theme in Appearance to use colors from
1111your wallpaper. Selecting another theme replaces those colors. Like other themes, Material You
1212can be selected separately for light and dark appearances.
13Android also offers **Material You Layout**, which changes shapes and spacing independently
13Android uses **Material You Layout** by default unless you have turned it off in Appearance.
14It changes shapes, spacing, and controls independently
1415of the selected theme.
1516
1617## Motion
vite.config.ts+2
......@@ -184,6 +184,7 @@ export default defineConfig({
184184 "apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx",
185185 "apps/mobile/src/features/files/FileMarkdownPreview.tsx",
186186 "apps/mobile/src/features/files/SourceFileSurface.tsx",
187 "apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx",
187188 "apps/mobile/src/features/files/AttachmentFileScreen.tsx",
188189 "apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx",
189190 "apps/mobile/src/features/files/thread-file-navigator-pane.tsx",
......@@ -204,6 +205,7 @@ export default defineConfig({
204205 "apps/mobile/src/lib/useMobileNavigationTheme.ts",
205206 "apps/mobile/src/native/T3ComposerEditor.ios.tsx",
206207 "apps/mobile/src/native/T3ComposerEditor.native.tsx",
208 "apps/mobile/src/native/SelectableMarkdownText.android.tsx",
207209 ],
208210 rules: {
209211 "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }],