| author | |
| committer | |
| log | e106fe95cc814797cfb3dad6d78c062e96d0060f |
| tree | c17f6cb4e22c9d61a68357f39920551d4e28041c |
| parent | 394af73e02238e551db14f44d57f1469dd2942aa |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>128 files changed, 5811 insertions(+), 2824 deletions(-)
apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt+26| ... | ... | @@ -8,6 +8,7 @@ import android.graphics.Canvas |
| 8 | 8 | import android.graphics.Color |
| 9 | 9 | import android.graphics.Paint |
| 10 | 10 | import android.graphics.Typeface |
| 11 | import android.os.Build | |
| 11 | 12 | import android.text.Spannable |
| 12 | 13 | import android.text.SpannableStringBuilder |
| 13 | 14 | import android.text.Spanned |
| ... | ... | @@ -164,6 +165,17 @@ private class SanitizingSelectionActionModeCallback( |
| 164 | 165 | } |
| 165 | 166 | } |
| 166 | 167 | |
| 168 | internal 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 | ||
| 167 | 179 | class T3MarkdownTextSelectionModule : Module() { |
| 168 | 180 | private val chipImages = LruCache<String, Map<String, Any>>(128) |
| 169 | 181 | |
| ... | ... | @@ -183,9 +195,23 @@ class T3MarkdownTextSelectionModule : Module() { |
| 183 | 195 | } |
| 184 | 196 | }.fontMetricsInt |
| 185 | 197 | |
| 198 | private fun setSelectionHandleColor(reactTag: Int, color: Int) { | |
| 199 | val reactContext = appContext.reactContext as? ReactContext ?: return | |
| 200 | reactContext.runOnUiQueueThread { | |
| 201 | val textView = runCatching { | |
| 202 | UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)?.resolveView(reactTag) | |
| 203 | }.getOrNull() as? TextView ?: return@runOnUiQueueThread | |
| 204 | applySelectionHandleColor(textView, color) | |
| 205 | } | |
| 206 | } | |
| 207 | ||
| 186 | 208 | override fun definition() = ModuleDefinition { |
| 187 | 209 | Name("T3MarkdownTextSelection") |
| 188 | 210 | |
| 211 | Function("setSelectionHandleColor") { reactTag: Int, color: Int -> | |
| 212 | setSelectionHandleColor(reactTag, color) | |
| 213 | } | |
| 214 | ||
| 189 | 215 | Function("renderContextChip") { payloadJson: String -> |
| 190 | 216 | val resources = appContext.reactContext?.resources ?: return@Function null |
| 191 | 217 | 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 @@ |
| 1 | package expo.modules.t3markdowntext | |
| 2 | ||
| 3 | import android.graphics.Bitmap | |
| 4 | import android.graphics.Canvas | |
| 5 | import android.graphics.Color | |
| 6 | import android.graphics.drawable.ColorDrawable | |
| 7 | import android.graphics.drawable.Drawable | |
| 8 | import android.widget.TextView | |
| 9 | import org.junit.Assert.assertEquals | |
| 10 | import org.junit.Test | |
| 11 | import org.junit.runner.RunWith | |
| 12 | import org.robolectric.RobolectricTestRunner | |
| 13 | import org.robolectric.RuntimeEnvironment | |
| 14 | import org.robolectric.annotation.Config | |
| 15 | import org.robolectric.annotation.GraphicsMode | |
| 16 | ||
| 17 | @RunWith(RobolectricTestRunner::class) | |
| 18 | @Config(sdk = [36], manifest = Config.NONE) | |
| 19 | @GraphicsMode(GraphicsMode.Mode.NATIVE) | |
| 20 | class MarkdownSelectionColorTest { | |
| 21 | private fun textView() = TextView(RuntimeEnvironment.getApplication()).apply { | |
| 22 | setTextSelectHandle(ColorDrawable(Color.WHITE)) | |
| 23 | setTextSelectHandleLeft(ColorDrawable(Color.WHITE)) | |
| 24 | setTextSelectHandleRight(ColorDrawable(Color.WHITE)) | |
| 25 | } | |
| 26 | ||
| 27 | private fun renderedColor(drawable: Drawable?): Int { | |
| 28 | requireNotNull(drawable) | |
| 29 | val bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888) | |
| 30 | drawable.setBounds(0, 0, 4, 4) | |
| 31 | drawable.draw(Canvas(bitmap)) | |
| 32 | val color = bitmap.getPixel(2, 2) | |
| 33 | bitmap.recycle() | |
| 34 | return color | |
| 35 | } | |
| 36 | ||
| 37 | @Test | |
| 38 | fun retintsAllHandlesWhenTheThemeChangesWithoutChangingTheHighlight() { | |
| 39 | val text = textView() | |
| 40 | val highlight = 0x52FF0088 | |
| 41 | text.highlightColor = highlight | |
| 42 | ||
| 43 | for (color in listOf(Color.MAGENTA, Color.GREEN, Color.MAGENTA)) { | |
| 44 | applySelectionHandleColor(text, color) | |
| 45 | assertEquals(color, renderedColor(text.textSelectHandle)) | |
| 46 | assertEquals(color, renderedColor(text.textSelectHandleLeft)) | |
| 47 | assertEquals(color, renderedColor(text.textSelectHandleRight)) | |
| 48 | assertEquals(highlight, text.highlightColor) | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | @Test | |
| 53 | fun doesNotTintOtherTextViewsSharingDrawableState() { | |
| 54 | val original = ColorDrawable(Color.WHITE) | |
| 55 | val first = textView().apply { | |
| 56 | setTextSelectHandleLeft(original.constantState!!.newDrawable()) | |
| 57 | } | |
| 58 | val second = textView().apply { | |
| 59 | setTextSelectHandleLeft(original.constantState!!.newDrawable()) | |
| 60 | } | |
| 61 | ||
| 62 | applySelectionHandleColor(first, Color.MAGENTA) | |
| 63 | ||
| 64 | assertEquals(Color.MAGENTA, renderedColor(first.textSelectHandleLeft)) | |
| 65 | assertEquals(Color.WHITE, renderedColor(second.textSelectHandleLeft)) | |
| 66 | } | |
| 67 | } |
apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx+51-2| ... | ... | @@ -1,5 +1,15 @@ |
| 1 | 1 | import React, { type Ref } from "react"; |
| 2 | import { Platform, StyleSheet, Text as RNText, type TextProps, type ViewStyle } from "react-native"; | |
| 2 | import { | |
| 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"; | |
| 12 | import { setMarkdownSelectionHandleColor } from "./T3MarkdownTextSelectionModule"; | |
| 3 | 13 | import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent"; |
| 4 | 14 | import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent"; |
| 5 | 15 | import { flattenStyles } from "./util"; |
| ... | ... | @@ -34,6 +44,7 @@ export type ContextMenuActionEvent = { |
| 34 | 44 | */ |
| 35 | 45 | export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & { |
| 36 | 46 | nativeTextRef?: Ref<RNText>; |
| 47 | selectionHandleColor?: ColorValue; | |
| 37 | 48 | uiTextView?: boolean; |
| 38 | 49 | contextMenuConfig?: string; |
| 39 | 50 | contextClipboardConfig?: string; |
| ... | ... | @@ -116,7 +127,45 @@ function MarkdownTextPrimitiveInner({ nativeTextRef, ...props }: MarkdownTextPri |
| 116 | 127 | return <MarkdownTextPrimitiveChild {...props} />; |
| 117 | 128 | } |
| 118 | 129 | |
| 119 | export function MarkdownTextPrimitive(props: MarkdownTextPrimitiveProps) { | |
| 130 | function 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 | ||
| 162 | export function MarkdownTextPrimitive({ | |
| 163 | selectionHandleColor, | |
| 164 | ...props | |
| 165 | }: MarkdownTextPrimitiveProps) { | |
| 166 | if (Platform.OS === "android" && selectionHandleColor !== undefined) { | |
| 167 | return <AndroidMarkdownText {...props} selectionHandleColor={selectionHandleColor} />; | |
| 168 | } | |
| 120 | 169 | if (Platform.OS !== "ios") { |
| 121 | 170 | const { nativeTextRef, contextClipboardConfig: _contextClipboardConfig, ...textProps } = props; |
| 122 | 171 | return <RNText ref={nativeTextRef} {...textProps} />; |
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx+15-5| ... | ... | @@ -133,7 +133,13 @@ function HighlightedCodeText(props: { |
| 133 | 133 | } |
| 134 | 134 | } |
| 135 | 135 | return ( |
| 136 | <MarkdownTextPrimitive uiTextView selectable style={style}> | |
| 136 | <MarkdownTextPrimitive | |
| 137 | uiTextView | |
| 138 | selectable | |
| 139 | selectionColor={props.textStyle.selectionColor} | |
| 140 | selectionHandleColor={props.textStyle.selectionHandleColor} | |
| 141 | style={style} | |
| 142 | > | |
| 137 | 143 | {props.highlighted ? lines : props.content} |
| 138 | 144 | </MarkdownTextPrimitive> |
| 139 | 145 | ); |
| ... | ... | @@ -174,8 +180,10 @@ function NativeCodeBlock(props: { |
| 174 | 180 | justifyContent: "space-between", |
| 175 | 181 | }} |
| 176 | 182 | > |
| 177 | <Text | |
| 183 | <MarkdownTextPrimitive | |
| 178 | 184 | selectable |
| 185 | selectionColor={props.textStyle.selectionColor} | |
| 186 | selectionHandleColor={props.textStyle.selectionHandleColor} | |
| 179 | 187 | style={{ |
| 180 | 188 | flex: 1, |
| 181 | 189 | color: props.textStyle.mutedColor, |
| ... | ... | @@ -184,7 +192,7 @@ function NativeCodeBlock(props: { |
| 184 | 192 | }} |
| 185 | 193 | > |
| 186 | 194 | {languageLabel} |
| 187 | </Text> | |
| 195 | </MarkdownTextPrimitive> | |
| 188 | 196 | <CopyTextButton |
| 189 | 197 | accessibilityLabel={`Copy ${languageLabel.toLowerCase()} code`} |
| 190 | 198 | text={content} |
| ... | ... | @@ -332,8 +340,10 @@ function NativeMarkdownImage(props: { |
| 332 | 340 | }} |
| 333 | 341 | /> |
| 334 | 342 | {props.node.alt ? ( |
| 335 | <Text | |
| 343 | <MarkdownTextPrimitive | |
| 336 | 344 | selectable |
| 345 | selectionColor={props.textStyle.selectionColor} | |
| 346 | selectionHandleColor={props.textStyle.selectionHandleColor} | |
| 337 | 347 | style={{ |
| 338 | 348 | color: props.textStyle.mutedColor, |
| 339 | 349 | fontFamily: props.textStyle.fontFamily, |
| ... | ... | @@ -342,7 +352,7 @@ function NativeMarkdownImage(props: { |
| 342 | 352 | }} |
| 343 | 353 | > |
| 344 | 354 | {props.node.alt} |
| 345 | </Text> | |
| 355 | </MarkdownTextPrimitive> | |
| 346 | 356 | ) : null} |
| 347 | 357 | </View> |
| 348 | 358 | ); |
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx+2| ... | ... | @@ -338,6 +338,8 @@ export function NativeMarkdownSelectableText(props: { |
| 338 | 338 | } |
| 339 | 339 | uiTextView |
| 340 | 340 | selectable |
| 341 | selectionColor={props.textStyle.selectionColor} | |
| 342 | selectionHandleColor={props.textStyle.selectionHandleColor} | |
| 341 | 343 | style={{ |
| 342 | 344 | flexShrink: 1, |
| 343 | 345 | minWidth: 0, |
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts+2| ... | ... | @@ -1,4 +1,6 @@ |
| 1 | 1 | export interface NativeMarkdownTextStyle { |
| 2 | readonly selectionColor?: string; | |
| 3 | readonly selectionHandleColor?: string; | |
| 2 | 4 | readonly color: string; |
| 3 | 5 | readonly strongColor: string; |
| 4 | 6 | readonly mutedColor: string; |
apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts+5| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { requireOptionalNativeModule } from "expo"; |
| 2 | 2 | |
| 3 | 3 | interface T3MarkdownTextSelectionNativeModule { |
| 4 | readonly setSelectionHandleColor?: (reactTag: number, color: number) => void; | |
| 4 | 5 | readonly installCopySanitizer: (reactTag: number, contextClipboardConfig: string) => void; |
| 5 | 6 | readonly renderContextChip?: (payloadJson: string) => { |
| 6 | 7 | readonly uri: string; |
| ... | ... | @@ -20,6 +21,10 @@ export function installMarkdownCopySanitizer(reactTag: number, contextClipboardC |
| 20 | 21 | nativeModule?.installCopySanitizer(reactTag, contextClipboardConfig); |
| 21 | 22 | } |
| 22 | 23 | |
| 24 | export function setMarkdownSelectionHandleColor(reactTag: number, color: number): void { | |
| 25 | nativeModule?.setSelectionHandleColor?.(reactTag, color); | |
| 26 | } | |
| 27 | ||
| 23 | 28 | export function renderAndroidContextChip(payloadJson: string) { |
| 24 | 29 | return nativeModule?.renderContextChip?.(payloadJson) ?? null; |
| 25 | 30 | } |
apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt+6| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | package expo.modules.t3nativecontrols |
| 2 | 2 | |
| 3 | 3 | import android.content.Intent |
| 4 | import android.text.format.DateFormat | |
| 4 | 5 | import androidx.core.content.FileProvider |
| 5 | 6 | import expo.modules.kotlin.Promise |
| 6 | 7 | import expo.modules.kotlin.modules.Module |
| ... | ... | @@ -15,6 +16,11 @@ class T3NativeControlsModule : Module() { |
| 15 | 16 | override fun definition() = ModuleDefinition { |
| 16 | 17 | Name("T3NativeControls") |
| 17 | 18 | |
| 19 | Function("is24HourFormat") { | |
| 20 | val context = appContext.reactContext ?: error("The app is not active.") | |
| 21 | DateFormat.is24HourFormat(context) | |
| 22 | } | |
| 23 | ||
| 18 | 24 | AsyncFunction("openFile") { uri: String, mimeType: String, promise: Promise -> |
| 19 | 25 | check(filePreviewPromise == null) { "A document viewer is already open." } |
| 20 | 26 | val activity = appContext.currentActivity ?: error("The app is not active.") |
apps/mobile/src/App.tsx+3-7| ... | ... | @@ -1,8 +1,7 @@ |
| 1 | import { BlurTargetView } from "expo-blur"; | |
| 2 | 1 | import * as Linking from "expo-linking"; |
| 3 | 2 | import * as SplashScreen from "expo-splash-screen"; |
| 4 | 3 | import { useEffect } from "react"; |
| 5 | import { StatusBar } from "react-native"; | |
| 4 | import { StatusBar, View } from "react-native"; | |
| 6 | 5 | import { GestureHandlerRootView } from "react-native-gesture-handler"; |
| 7 | 6 | import { KeyboardProvider } from "react-native-keyboard-controller"; |
| 8 | 7 | import { SafeAreaProvider } from "react-native-safe-area-context"; |
| ... | ... | @@ -21,10 +20,8 @@ import { |
| 21 | 20 | import { RootStack } from "./Stack"; |
| 22 | 21 | import { appAtomRegistry } from "./state/atom-registry"; |
| 23 | 22 | import { OverlayPortalHost } from "./components/OverlayPortal"; |
| 24 | import { appBlurTargetRef } from "./lib/appBlurTarget"; | |
| 25 | 23 | import { shouldHandleAppLink } from "./lib/appLinking"; |
| 26 | 24 | import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; |
| 27 | ||
| 28 | 25 | import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; |
| 29 | 26 | |
| 30 | 27 | import "../global.css"; |
| ... | ... | @@ -88,14 +85,13 @@ function AppContent() { |
| 88 | 85 | this, React Navigation defaults to its light theme and every native |
| 89 | 86 | header (glass buttons, title, materials) is forced light even when |
| 90 | 87 | 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 }}> | |
| 93 | 89 | <IncomingShareProvider> |
| 94 | 90 | <Navigation linking={appLinking} theme={navigationTheme} /> |
| 95 | 91 | </IncomingShareProvider> |
| 96 | 92 | <ConfirmDialogHost /> |
| 97 | 93 | <ThreadArrangementHost /> |
| 98 | </BlurTargetView> | |
| 94 | </View> | |
| 99 | 95 | {/* Anchored-menu overlays render here — in-window, so the |
| 100 | 96 | keyboard stays up while a dropdown is open. */} |
| 101 | 97 | <OverlayPortalHost /> |
apps/mobile/src/components/AndroidAnchoredMenu.tsx+36-105| ... | ... | @@ -6,12 +6,8 @@ import { BackHandler, Pressable, ScrollView, View } from "react-native"; |
| 6 | 6 | import { useKeyboardState } from "react-native-keyboard-controller"; |
| 7 | 7 | import Animated, { FadeIn } from "react-native-reanimated"; |
| 8 | 8 | |
| 9 | import { appBlurTargetRef } from "../lib/appBlurTarget"; | |
| 10 | import { cn } from "../lib/cn"; | |
| 11 | import { type AppSymbolName, SymbolView } from "./AppSymbol"; | |
| 12 | import { AppText as Text } from "./AppText"; | |
| 13 | 9 | import { OverlayPortal } from "./OverlayPortal"; |
| 14 | import { GlassBackdrop } from "./GlassBackdrop"; | |
| 10 | import { MaterialMenuPopup } from "./MaterialMenuPopup"; | |
| 15 | 11 | |
| 16 | 12 | const MENU_WIDTH = 250; |
| 17 | 13 | const SCREEN_MARGIN = 12; |
| ... | ... | @@ -27,6 +23,7 @@ type AnchorSnapshot = { |
| 27 | 23 | readonly y: number; |
| 28 | 24 | readonly width: number; |
| 29 | 25 | readonly height: number; |
| 26 | readonly keyboardWasVisible: boolean; | |
| 30 | 27 | }; |
| 31 | 28 | |
| 32 | 29 | type OverlayFrame = { |
| ... | ... | @@ -53,13 +50,9 @@ export type AndroidAnchoredMenuProps = { |
| 53 | 50 | }; |
| 54 | 51 | |
| 55 | 52 | /** |
| 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. | |
| 63 | 56 | */ |
| 64 | 57 | export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { |
| 65 | 58 | const [anchor, setAnchor] = useState<AnchorSnapshot | null>(null); |
| ... | ... | @@ -89,9 +82,9 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { |
| 89 | 82 | |
| 90 | 83 | const open = useCallback(() => { |
| 91 | 84 | anchorRef.current?.measureInWindow((x, y, width, height) => { |
| 92 | setAnchor({ x, y, width, height }); | |
| 85 | setAnchor({ x, y, width, height, keyboardWasVisible: keyboardVisible }); | |
| 93 | 86 | }); |
| 94 | }, []); | |
| 87 | }, [keyboardVisible]); | |
| 95 | 88 | |
| 96 | 89 | const measureOverlay = useCallback(() => { |
| 97 | 90 | overlayRef.current?.measureInWindow((x, y, width, height) => { |
| ... | ... | @@ -100,18 +93,11 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { |
| 100 | 93 | }); |
| 101 | 94 | }, []); |
| 102 | 95 | |
| 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. | |
| 112 | 98 | const submenuDepth = path.length; |
| 113 | 99 | useEffect(() => { |
| 114 | if (anchor === null) { | |
| 100 | if (anchor === null || !anchor.keyboardWasVisible) { | |
| 115 | 101 | return; |
| 116 | 102 | } |
| 117 | 103 | const subscription = BackHandler.addEventListener("hardwareBackPress", () => { |
| ... | ... | @@ -212,10 +198,20 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { |
| 212 | 198 | onLayout={measureOverlay} |
| 213 | 199 | > |
| 214 | 200 | <Pressable accessible={false} className="absolute inset-0" onPress={close} /> |
| 215 | {!placeable || local === null ? null : ( | |
| 201 | {!placeable || local === null ? null : !anchor.keyboardWasVisible ? ( | |
| 202 | <MaterialMenuPopup | |
| 203 | anchor={local} | |
| 204 | actions={levelActions} | |
| 205 | title={props.title} | |
| 206 | parent={parent} | |
| 207 | onPress={onPressItem} | |
| 208 | onBack={() => setPath((current) => current.slice(0, -1))} | |
| 209 | onClose={close} | |
| 210 | /> | |
| 211 | ) : ( | |
| 216 | 212 | <Animated.View |
| 217 | 213 | entering={FadeIn.duration(120)} |
| 218 | className="absolute w-[250px] overflow-hidden rounded-[12px] border border-border shadow-2xl" | |
| 214 | className="absolute w-[250px] overflow-hidden rounded-[4px] bg-card-alt shadow-md" | |
| 219 | 215 | style={{ |
| 220 | 216 | left, |
| 221 | 217 | maxHeight, |
| ... | ... | @@ -224,93 +220,28 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { |
| 224 | 220 | : { bottom: (rootHeight ?? 0) - local.y + ANCHOR_GAP }), |
| 225 | 221 | }} |
| 226 | 222 | > |
| 227 | <GlassBackdrop blurTarget={appBlurTargetRef} /> | |
| 223 | {/* Compose DropdownMenu takes popup focus in the pinned Expo UI version. | |
| 224 | Keep editor menus in-window so opening one preserves the keyboard. */} | |
| 225 | ||
| 228 | 226 | {/* keyboardShouldPersistTaps: the menu often opens over an |
| 229 | 227 | active editor; the first item tap must act, not just |
| 230 | 228 | dismiss the keyboard. */} |
| 231 | 229 | <ScrollView |
| 230 | contentContainerClassName="py-2" | |
| 232 | 231 | bounces={false} |
| 233 | 232 | keyboardShouldPersistTaps="always" |
| 234 | 233 | showsVerticalScrollIndicator={false} |
| 235 | 234 | > |
| 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 | /> | |
| 314 | 245 | </ScrollView> |
| 315 | 246 | </Animated.View> |
| 316 | 247 | )} |
apps/mobile/src/components/AndroidScreenHeader.tsx+57-39| ... | ... | @@ -1,16 +1,21 @@ |
| 1 | import type { ReactNode } from "react"; | |
| 2 | import { Pressable, View } from "react-native"; | |
| 1 | import { useState, type ReactNode } from "react"; | |
| 2 | import { View } from "react-native"; | |
| 3 | 3 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 4 | 4 | |
| 5 | import { SymbolView, type AppSymbolName } from "./AppSymbol"; | |
| 5 | import type { AppSymbolName } from "./AppSymbol"; | |
| 6 | 6 | import { AppText as Text } from "./AppText"; |
| 7 | 7 | import { cn } from "../lib/cn"; |
| 8 | import { MaterialIconButton } from "./MaterialIconButton"; | |
| 9 | import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; | |
| 10 | import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; | |
| 11 | import { useMaterialToolbarHeight } from "./useMaterialToolbarHeight"; | |
| 8 | 12 | |
| 9 | 13 | export interface AndroidHeaderAction { |
| 10 | 14 | readonly accessibilityLabel: string; |
| 11 | 15 | readonly icon: AppSymbolName; |
| 12 | 16 | readonly onPress: () => void; |
| 13 | 17 | readonly disabled?: boolean; |
| 18 | readonly selected?: boolean; | |
| 14 | 19 | } |
| 15 | 20 | |
| 16 | 21 | export function AndroidHeaderIconButton(props: { |
| ... | ... | @@ -18,73 +23,62 @@ export function AndroidHeaderIconButton(props: { |
| 18 | 23 | readonly icon: AppSymbolName; |
| 19 | 24 | readonly onPress?: () => void; |
| 20 | 25 | readonly disabled?: boolean; |
| 26 | readonly selected?: boolean; | |
| 21 | 27 | }) { |
| 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"} />; | |
| 42 | 29 | } |
| 43 | 30 | |
| 44 | 31 | export function AndroidScreenHeader(props: { |
| 45 | 32 | readonly title: string; |
| 46 | 33 | readonly subtitle?: string | null; |
| 47 | 34 | readonly actions?: ReadonlyArray<AndroidHeaderAction>; |
| 35 | readonly leading?: ReactNode; | |
| 48 | 36 | readonly trailing?: ReactNode; |
| 49 | 37 | readonly onBack?: () => void; |
| 50 | 38 | readonly embedded?: boolean; |
| 51 | 39 | readonly hideBottomBorder?: boolean; |
| 52 | 40 | }) { |
| 53 | 41 | const insets = useSafeAreaInsets(); |
| 42 | const titleTypography = useScaledTextRole("title"); | |
| 43 | const subtitleTypography = useScaledTextRole("label"); | |
| 44 | const materialToolbarHeight = useMaterialToolbarHeight(); | |
| 45 | const [headerWidth, setHeaderWidth] = useState(0); | |
| 46 | const actions = props.actions ?? []; | |
| 47 | const directCount = actions.length > 2 ? (headerWidth >= 600 ? 3 : 1) : actions.length; | |
| 48 | const visibleActions = actions.slice(0, directCount); | |
| 49 | const overflowActions = actions.slice(directCount); | |
| 54 | 50 | |
| 55 | 51 | return ( |
| 56 | 52 | <View |
| 57 | className="border-b border-header-border bg-header px-3 pb-2.5" | |
| 53 | onLayout={(event) => setHeaderWidth(event.nativeEvent.layout.width)} | |
| 54 | className="border-b border-header-border bg-header px-2 pb-2" | |
| 58 | 55 | style={{ |
| 59 | 56 | paddingTop: props.embedded ? 8 : Math.max(insets.top, 12), |
| 60 | 57 | borderBottomWidth: props.hideBottomBorder ? 0 : undefined, |
| 61 | 58 | }} |
| 62 | 59 | > |
| 63 | <View className="min-h-12 flex-row items-center gap-2"> | |
| 60 | <View | |
| 61 | style={{ minHeight: materialToolbarHeight }} | |
| 62 | className="min-h-14 flex-row items-center gap-1" | |
| 63 | > | |
| 64 | 64 | {props.onBack ? ( |
| 65 | <Pressable | |
| 65 | <MaterialIconButton | |
| 66 | 66 | accessibilityLabel="Navigate up" |
| 67 | accessibilityRole="button" | |
| 68 | hitSlop={8} | |
| 67 | icon="arrow.left" | |
| 69 | 68 | 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 | /> | |
| 79 | 70 | ) : null} |
| 80 | 71 | |
| 72 | {props.leading} | |
| 73 | ||
| 81 | 74 | <View className={cn("min-w-0 flex-1", !props.onBack && "pl-1")}> |
| 82 | <Text numberOfLines={1} className="text-lg font-t3-bold text-foreground"> | |
| 75 | <Text numberOfLines={1} style={titleTypography} className="text-foreground"> | |
| 83 | 76 | {props.title} |
| 84 | 77 | </Text> |
| 85 | 78 | {props.subtitle ? ( |
| 86 | 79 | <Text |
| 87 | 80 | numberOfLines={1} |
| 81 | style={subtitleTypography} | |
| 88 | 82 | className="mt-px text-[13px] font-t3-medium text-foreground-muted" |
| 89 | 83 | > |
| 90 | 84 | {props.subtitle} |
| ... | ... | @@ -92,15 +86,39 @@ export function AndroidScreenHeader(props: { |
| 92 | 86 | ) : null} |
| 93 | 87 | </View> |
| 94 | 88 | |
| 95 | {props.actions?.map((action) => ( | |
| 89 | {visibleActions.map((action) => ( | |
| 96 | 90 | <AndroidHeaderIconButton |
| 97 | 91 | key={action.accessibilityLabel} |
| 98 | 92 | accessibilityLabel={action.accessibilityLabel} |
| 99 | 93 | disabled={action.disabled} |
| 94 | selected={action.selected} | |
| 100 | 95 | icon={action.icon} |
| 101 | 96 | onPress={action.onPress} |
| 102 | 97 | /> |
| 103 | 98 | ))} |
| 99 | {overflowActions.length > 0 ? ( | |
| 100 | <AndroidAnchoredMenu | |
| 101 | actions={overflowActions.map((action, index) => ({ | |
| 102 | id: String(index), | |
| 103 | title: action.accessibilityLabel, | |
| 104 | attributes: { | |
| 105 | disabled: Boolean(action.disabled), | |
| 106 | state: action.selected ? "on" : undefined, | |
| 107 | }, | |
| 108 | }))} | |
| 109 | onPressAction={({ nativeEvent }) => | |
| 110 | overflowActions[Number(nativeEvent.event)]?.onPress() | |
| 111 | } | |
| 112 | > | |
| 113 | {(open) => ( | |
| 114 | <MaterialIconButton | |
| 115 | accessibilityLabel="More actions" | |
| 116 | icon="ellipsis" | |
| 117 | onPress={open} | |
| 118 | /> | |
| 119 | )} | |
| 120 | </AndroidAnchoredMenu> | |
| 121 | ) : null} | |
| 104 | 122 | {props.trailing} |
| 105 | 123 | </View> |
| 106 | 124 | </View> |
apps/mobile/src/components/AppSymbol.tsx+9| ... | ... | @@ -9,6 +9,7 @@ import IconAlertTriangle from "@tabler/icons-react-native/IconAlertTriangle"; |
| 9 | 9 | import IconApps from "@tabler/icons-react-native/IconApps"; |
| 10 | 10 | import IconArchive from "@tabler/icons-react-native/IconArchive"; |
| 11 | 11 | import IconArrowBackUp from "@tabler/icons-react-native/IconArrowBackUp"; |
| 12 | import IconArrowLeft from "@tabler/icons-react-native/IconArrowLeft"; | |
| 12 | 13 | import IconArrowDownCircle from "@tabler/icons-react-native/IconArrowDownCircle"; |
| 13 | 14 | import IconArrowRightCircle from "@tabler/icons-react-native/IconArrowRightCircle"; |
| 14 | 15 | import IconArrowUp from "@tabler/icons-react-native/IconArrowUp"; |
| ... | ... | @@ -36,8 +37,10 @@ import IconClock from "@tabler/icons-react-native/IconClock"; |
| 36 | 37 | import IconCode from "@tabler/icons-react-native/IconCode"; |
| 37 | 38 | import IconCopy from "@tabler/icons-react-native/IconCopy"; |
| 38 | 39 | import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop"; |
| 40 | import IconDatabase from "@tabler/icons-react-native/IconDatabase"; | |
| 39 | 41 | import IconDeviceLaptop from "@tabler/icons-react-native/IconDeviceLaptop"; |
| 40 | 42 | import IconDots from "@tabler/icons-react-native/IconDots"; |
| 43 | import IconDotsVertical from "@tabler/icons-react-native/IconDotsVertical"; | |
| 41 | 44 | import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal"; |
| 42 | 45 | import IconEdit from "@tabler/icons-react-native/IconEdit"; |
| 43 | 46 | import IconExternalLink from "@tabler/icons-react-native/IconExternalLink"; |
| ... | ... | @@ -97,6 +100,7 @@ import { withUniwind } from "uniwind"; |
| 97 | 100 | |
| 98 | 101 | const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 99 | 102 | "arrow.branch": IconGitBranch, |
| 103 | "arrow.left": IconArrowLeft, | |
| 100 | 104 | "arrow.clockwise": IconRefresh, |
| 101 | 105 | "arrow.down.circle": IconArrowDownCircle, |
| 102 | 106 | "arrow.right.circle": IconArrowRightCircle, |
| ... | ... | @@ -142,7 +146,10 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 142 | 146 | "folder.badge.plus": IconFolderPlus, |
| 143 | 147 | "folder.fill": IconFolder, |
| 144 | 148 | gearshape: IconSettings, |
| 149 | hammer: IconHammer, | |
| 145 | 150 | "info.circle": IconInfoCircle, |
| 151 | internaldrive: IconDatabase, | |
| 152 | keyboard: IconKeyboard, | |
| 146 | 153 | laptopcomputer: IconDeviceLaptop, |
| 147 | 154 | link: IconLink, |
| 148 | 155 | "line.3.horizontal": IconMenu2, |
| ... | ... | @@ -160,6 +167,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 160 | 167 | "pin.slash": IconPinnedOff, |
| 161 | 168 | play: IconPlayerPlay, |
| 162 | 169 | plus: IconPlus, |
| 170 | minus: IconMinus, | |
| 163 | 171 | "qrcode.viewfinder": IconQrcode, |
| 164 | 172 | "point.3.connected.trianglepath.dotted": IconNetwork, |
| 165 | 173 | "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge, |
| ... | ... | @@ -209,6 +217,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME: Record<string, Icon> = { |
| 209 | 217 | keyboard_arrow_down: IconChevronDown, |
| 210 | 218 | keyboard_arrow_up: IconChevronUp, |
| 211 | 219 | keyboard_hide: IconKeyboardHide, |
| 220 | more_vert: IconDotsVertical, | |
| 212 | 221 | public: IconWorld, |
| 213 | 222 | remove: IconMinus, |
| 214 | 223 | terminal: IconTerminal2, |
apps/mobile/src/components/AppText.tsx+15-3| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { |
| 2 | Platform, | |
| 2 | 3 | Text as RNText, |
| 3 | 4 | TextInput as RNTextInput, |
| 4 | 5 | type TextInputProps as RNTextInputProps, |
| ... | ... | @@ -14,7 +15,13 @@ export type AppTextProps = RNTextProps & { readonly className?: string }; |
| 14 | 15 | * Uses Uniwind className — no manual style parsing. |
| 15 | 16 | */ |
| 16 | 17 | export function AppText({ className, ...props }: AppTextProps) { |
| 17 | return <RNText className={cn("font-sans text-foreground", className)} {...props} />; | |
| 18 | return ( | |
| 19 | <RNText | |
| 20 | className={cn("font-sans text-foreground", className)} | |
| 21 | selectionColorClassName={Platform.OS === "android" ? "accent-primary/32" : undefined} | |
| 22 | {...props} | |
| 23 | /> | |
| 24 | ); | |
| 18 | 25 | } |
| 19 | 26 | |
| 20 | 27 | export type AppTextInputProps = Omit<RNTextInputProps, "placeholderTextColor"> & { |
| ... | ... | @@ -35,8 +42,13 @@ export function AppTextInput({ className, ref, ...props }: AppTextInputProps) { |
| 35 | 42 | className, |
| 36 | 43 | )} |
| 37 | 44 | 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} | |
| 40 | 52 | {...props} |
| 41 | 53 | /> |
| 42 | 54 | ); |
apps/mobile/src/components/ConfirmDialogHost.tsx+30-11| ... | ... | @@ -1,8 +1,9 @@ |
| 1 | 1 | import { useCallback, useEffect, useState } from "react"; |
| 2 | import { Modal, Pressable, TextInput, View } from "react-native"; | |
| 2 | import { Platform, Modal, Pressable, TextInput, View } from "react-native"; | |
| 3 | 3 | |
| 4 | 4 | import { cn } from "../lib/cn"; |
| 5 | 5 | import { AppText } from "./AppText"; |
| 6 | import { MaterialConfirmDialog } from "./MaterialConfirmDialog"; | |
| 6 | 7 | |
| 7 | 8 | export type ConfirmDialogRequest = { |
| 8 | 9 | readonly title: string; |
| ... | ... | @@ -67,17 +68,35 @@ export function ConfirmDialogHost() { |
| 67 | 68 | setPresented(null); |
| 68 | 69 | }, [presented]); |
| 69 | 70 | |
| 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 | ); | |
| 78 | 82 | |
| 79 | 83 | const confirmDisabled = presented?.kind === "text-input" && inputValue.trim().length === 0; |
| 80 | 84 | |
| 85 | if (Platform.OS === "android") | |
| 86 | return presented ? ( | |
| 87 | <MaterialConfirmDialog | |
| 88 | key={`${presented.kind}:${presented.request.title}:${presented.kind === "text-input" ? presented.request.initialValue : ""}`} | |
| 89 | request={presented.request} | |
| 90 | inputInitialValue={ | |
| 91 | presented.kind === "text-input" ? presented.request.initialValue : undefined | |
| 92 | } | |
| 93 | onInputChange={setInputValue} | |
| 94 | confirmDisabled={confirmDisabled} | |
| 95 | onCancel={handleCancel} | |
| 96 | onConfirm={handleConfirm} | |
| 97 | /> | |
| 98 | ) : null; | |
| 99 | ||
| 81 | 100 | return ( |
| 82 | 101 | <Modal |
| 83 | 102 | visible={presented !== null} |
| ... | ... | @@ -102,7 +121,7 @@ export function ConfirmDialogHost() { |
| 102 | 121 | autoFocus |
| 103 | 122 | className="mt-4 rounded-xl border border-border bg-screen px-3 py-2.5 text-base text-foreground" |
| 104 | 123 | onChangeText={setInputValue} |
| 105 | onSubmitEditing={confirmDisabled ? undefined : handleConfirm} | |
| 124 | onSubmitEditing={confirmDisabled ? undefined : () => handleConfirm()} | |
| 106 | 125 | returnKeyType="done" |
| 107 | 126 | selectTextOnFocus |
| 108 | 127 | value={inputValue} |
| ... | ... | @@ -125,7 +144,7 @@ export function ConfirmDialogHost() { |
| 125 | 144 | accessibilityRole="button" |
| 126 | 145 | disabled={confirmDisabled} |
| 127 | 146 | className="min-h-10 items-center justify-center px-4 active:bg-subtle" |
| 128 | onPress={handleConfirm} | |
| 147 | onPress={() => handleConfirm()} | |
| 129 | 148 | > |
| 130 | 149 | <AppText |
| 131 | 150 | className={cn( |
apps/mobile/src/components/ControlPill.tsx+45-5| ... | ... | @@ -19,12 +19,13 @@ import { |
| 19 | 19 | } from "react-native"; |
| 20 | 20 | import { withUniwind } from "uniwind"; |
| 21 | 21 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 22 | ||
| 23 | 22 | import { cn } from "../lib/cn"; |
| 24 | 23 | import { withMenuActionIconColors } from "../lib/menu-action-colors"; |
| 25 | 24 | import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; |
| 26 | 25 | import { SymbolView } from "./AppSymbol"; |
| 27 | 26 | import { AppText as Text } from "./AppText"; |
| 27 | import { MaterialIconButton } from "./MaterialIconButton"; | |
| 28 | import { MaterialButton } from "./MaterialButton"; | |
| 28 | 29 | |
| 29 | 30 | const ThemedMenuView = withUniwind( |
| 30 | 31 | function NativeMenuView({ |
| ... | ... | @@ -121,6 +122,47 @@ export function ControlPill(props: { |
| 121 | 122 | : "", |
| 122 | 123 | ); |
| 123 | 124 | |
| 125 | if ( | |
| 126 | Platform.OS === "android" && | |
| 127 | (variant === "pill" || variant === "primary") && | |
| 128 | props.label && | |
| 129 | props.onPress && | |
| 130 | !props.icon && | |
| 131 | !props.iconNode && | |
| 132 | !props.className && | |
| 133 | !props.activateOnPressIn && | |
| 134 | (!props.accessibilityLabel || props.accessibilityLabel === props.label) | |
| 135 | ) { | |
| 136 | return ( | |
| 137 | <MaterialButton | |
| 138 | label={props.label} | |
| 139 | onPress={props.onPress} | |
| 140 | disabled={props.disabled} | |
| 141 | tone={variant === "primary" ? "primary" : "secondary"} | |
| 142 | /> | |
| 143 | ); | |
| 144 | } | |
| 145 | ||
| 146 | if ( | |
| 147 | Platform.OS === "android" && | |
| 148 | props.accessibilityLabel && | |
| 149 | props.icon && | |
| 150 | !props.iconNode && | |
| 151 | !props.label && | |
| 152 | !props.className && | |
| 153 | !props.activateOnPressIn | |
| 154 | ) { | |
| 155 | return ( | |
| 156 | <MaterialIconButton | |
| 157 | accessibilityLabel={props.accessibilityLabel} | |
| 158 | icon={props.icon} | |
| 159 | onPress={props.onPress} | |
| 160 | disabled={props.disabled} | |
| 161 | variant={variant === "primary" ? "primary" : variant === "danger" ? "danger" : "tonal"} | |
| 162 | /> | |
| 163 | ); | |
| 164 | } | |
| 165 | ||
| 124 | 166 | return ( |
| 125 | 167 | <Pressable |
| 126 | 168 | accessibilityLabel={props.accessibilityLabel ?? props.label} |
| ... | ... | @@ -146,10 +188,8 @@ export function ControlPill(props: { |
| 146 | 188 | ); |
| 147 | 189 | } |
| 148 | 190 | |
| 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. | |
| 153 | 193 | export function ControlPillMenu( |
| 154 | 194 | props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> & |
| 155 | 195 | Pick<AccessibilityProps, "accessible" | "accessibilityLabel" | "accessibilityRole"> & { |
apps/mobile/src/components/EmptyState.tsx+8-2| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { Pressable, View } from "react-native"; |
| 2 | import type { ReactNode } from "react"; | |
| 2 | 3 | |
| 3 | 4 | import { AppText as Text } from "./AppText"; |
| 4 | 5 | |
| ... | ... | @@ -7,6 +8,7 @@ export function EmptyState(props: { |
| 7 | 8 | readonly detail: string; |
| 8 | 9 | readonly actionLabel?: string; |
| 9 | 10 | readonly onAction?: () => void; |
| 11 | readonly action?: ReactNode; | |
| 10 | 12 | readonly variant?: "card" | "plain"; |
| 11 | 13 | }) { |
| 12 | 14 | if (props.variant === "plain") { |
| ... | ... | @@ -16,7 +18,9 @@ export function EmptyState(props: { |
| 16 | 18 | <Text className="mt-2 text-center font-sans text-base leading-normal text-foreground-muted"> |
| 17 | 19 | {props.detail} |
| 18 | 20 | </Text> |
| 19 | {props.actionLabel && props.onAction ? ( | |
| 21 | {props.action ? ( | |
| 22 | <View className="mt-5">{props.action}</View> | |
| 23 | ) : props.actionLabel && props.onAction ? ( | |
| 20 | 24 | <Pressable |
| 21 | 25 | className="mt-5 rounded-full bg-primary px-5 py-3 active:opacity-70" |
| 22 | 26 | onPress={props.onAction} |
| ... | ... | @@ -36,7 +40,9 @@ export function EmptyState(props: { |
| 36 | 40 | <Text className="mt-2 font-sans text-sm leading-relaxed text-foreground-muted"> |
| 37 | 41 | {props.detail} |
| 38 | 42 | </Text> |
| 39 | {props.actionLabel && props.onAction ? ( | |
| 43 | {props.action ? ( | |
| 44 | <View className="mt-4 self-start">{props.action}</View> | |
| 45 | ) : props.actionLabel && props.onAction ? ( | |
| 40 | 46 | <Pressable |
| 41 | 47 | className="mt-4 self-start rounded-full bg-primary px-4 py-2.5 active:opacity-70" |
| 42 | 48 | onPress={props.onAction} |
apps/mobile/src/components/GlassBackdrop.tsx+2-19| ... | ... | @@ -1,22 +1,13 @@ |
| 1 | 1 | import { BlurView } from "expo-blur"; |
| 2 | import { useContext, type RefObject } from "react"; | |
| 3 | 2 | import { Platform, StyleSheet, View, type ColorValue } from "react-native"; |
| 4 | 3 | |
| 5 | 4 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 6 | import { GlassBlurTargetContext } from "../lib/glassBlurTarget"; | |
| 7 | 5 | import { themeColorWithAlpha } from "../lib/mobileTheme"; |
| 8 | 6 | |
| 9 | 7 | /** Frosted backdrop for containers that clip their children to their shape. */ |
| 10 | export function GlassBackdrop(props: { | |
| 11 | readonly fallbackColor?: ColorValue; | |
| 12 | readonly blurTarget?: RefObject<View | null>; | |
| 13 | }) { | |
| 8 | export function GlassBackdrop(props: { readonly fallbackColor?: ColorValue }) { | |
| 14 | 9 | 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"; | |
| 20 | 11 | const colorStyle = |
| 21 | 12 | props.fallbackColor === undefined |
| 22 | 13 | ? undefined |
| ... | ... | @@ -24,17 +15,9 @@ export function GlassBackdrop(props: { |
| 24 | 15 | |
| 25 | 16 | return ( |
| 26 | 17 | <> |
| 27 | {/* Android samples a separate target. An opaque backing prevents any | |
| 28 | transparent pixels in that sample from exposing the unblurred feed. | |
| 29 | iOS samples its actual backdrop, so a backing there would hide it. */} | |
| 30 | {Platform.OS === "android" ? ( | |
| 31 | <View pointerEvents="none" className="absolute inset-0 bg-card" style={colorStyle} /> | |
| 32 | ) : null} | |
| 33 | 18 | {supportsBlur ? ( |
| 34 | 19 | <BlurView |
| 35 | 20 | pointerEvents="none" |
| 36 | blurTarget={target} | |
| 37 | blurMethod="dimezisBlurViewSdk31Plus" | |
| 38 | 21 | intensity={80} |
| 39 | 22 | tint={themeAppearance === "dark" ? "dark" : "default"} |
| 40 | 23 | style={StyleSheet.absoluteFill} |
apps/mobile/src/components/GlassSurface.tsx+8-18| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect"; |
| 2 | import type { ReactNode, Ref, RefObject } from "react"; | |
| 2 | import type { ReactNode, Ref } from "react"; | |
| 3 | 3 | import { |
| 4 | 4 | Platform, |
| 5 | 5 | useColorScheme, |
| ... | ... | @@ -28,7 +28,6 @@ interface GlassSurfaceProps extends ViewProps { |
| 28 | 28 | readonly chrome?: "default" | "none"; |
| 29 | 29 | /** Base color for the frosted tint, or solid fill when blur is unavailable. */ |
| 30 | 30 | readonly fallbackColor?: ColorValue; |
| 31 | readonly blurTarget?: RefObject<View | null>; | |
| 32 | 31 | /** Uniwind styling used only when native Liquid Glass is unavailable. */ |
| 33 | 32 | readonly fallbackClassName?: string; |
| 34 | 33 | } |
| ... | ... | @@ -41,7 +40,6 @@ export function GlassSurface({ |
| 41 | 40 | tintColor, |
| 42 | 41 | tintColorClassName, |
| 43 | 42 | fallbackColor, |
| 44 | blurTarget, | |
| 45 | 43 | fallbackClassName, |
| 46 | 44 | className, |
| 47 | 45 | style, |
| ... | ... | @@ -49,23 +47,15 @@ export function GlassSurface({ |
| 49 | 47 | }: GlassSurfaceProps) { |
| 50 | 48 | const isDarkMode = useColorScheme() === "dark"; |
| 51 | 49 | const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable(); |
| 50 | const hasShadow = chrome !== "none" && Platform.OS !== "android"; | |
| 52 | 51 | const surfaceStyle: ViewStyle = { |
| 53 | 52 | borderRadius: 32, |
| 54 | 53 | 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, | |
| 69 | 59 | }; |
| 70 | 60 | |
| 71 | 61 | if (supportsGlass) { |
| ... | ... | @@ -103,7 +93,7 @@ export function GlassSurface({ |
| 103 | 93 | )} |
| 104 | 94 | style={[surfaceStyle, style]} |
| 105 | 95 | > |
| 106 | <GlassBackdrop blurTarget={blurTarget} fallbackColor={fallbackColor} /> | |
| 96 | <GlassBackdrop fallbackColor={fallbackColor} /> | |
| 107 | 97 | {children} |
| 108 | 98 | </View> |
| 109 | 99 | ); |
apps/mobile/src/components/MaterialButton.android.tsx created+94| ... | ... | @@ -0,0 +1,94 @@ |
| 1 | import { | |
| 2 | Box, | |
| 3 | Button, | |
| 4 | CircularProgressIndicator, | |
| 5 | FilledTonalButton, | |
| 6 | Host, | |
| 7 | Row, | |
| 8 | Text, | |
| 9 | TextButton, | |
| 10 | } from "@expo/ui/jetpack-compose"; | |
| 11 | import { defaultMinSize, fillMaxWidth, size } from "@expo/ui/jetpack-compose/modifiers"; | |
| 12 | import { View } from "react-native"; | |
| 13 | ||
| 14 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | |
| 15 | import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; | |
| 16 | import type { MaterialButtonProps } from "./MaterialButton"; | |
| 17 | ||
| 18 | export 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 @@ |
| 1 | import { Pressable } from "react-native"; | |
| 2 | import { AppText } from "./AppText"; | |
| 3 | ||
| 4 | export 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 | ||
| 13 | export 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 @@ |
| 1 | import { | |
| 2 | AlertDialog, | |
| 3 | Host, | |
| 4 | OutlinedTextField, | |
| 5 | Text, | |
| 6 | TextButton, | |
| 7 | useNativeState, | |
| 8 | } from "@expo/ui/jetpack-compose"; | |
| 9 | ||
| 10 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | |
| 11 | import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; | |
| 12 | import type { MaterialConfirmDialogProps } from "./MaterialConfirmDialog"; | |
| 13 | ||
| 14 | export 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 @@ |
| 1 | import type { ConfirmDialogRequest } from "./ConfirmDialogHost"; | |
| 2 | ||
| 3 | export 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 | ||
| 15 | export function MaterialConfirmDialog(_props: MaterialConfirmDialogProps) { | |
| 16 | return null; | |
| 17 | } |
apps/mobile/src/components/MaterialFloatingActionButton.android.tsx created+86| ... | ... | @@ -0,0 +1,86 @@ |
| 1 | import { | |
| 2 | Box, | |
| 3 | ExtendedFloatingActionButton, | |
| 4 | FloatingActionButton, | |
| 5 | Host, | |
| 6 | LargeFloatingActionButton, | |
| 7 | Text, | |
| 8 | } from "@expo/ui/jetpack-compose"; | |
| 9 | import { size } from "@expo/ui/jetpack-compose/modifiers"; | |
| 10 | import { View, type StyleProp, type ViewStyle } from "react-native"; | |
| 11 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | |
| 12 | import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; | |
| 13 | import { SymbolView, type AppSymbolName } from "./AppSymbol"; | |
| 14 | ||
| 15 | export 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 @@ |
| 1 | import type { ComponentProps } from "react"; | |
| 2 | import { Pressable } from "react-native"; | |
| 3 | import type { MaterialFloatingActionButton as AndroidMaterialFloatingActionButton } from "./MaterialFloatingActionButton.android"; | |
| 4 | import { AppText } from "./AppText"; | |
| 5 | import { SymbolView } from "./AppSymbol"; | |
| 6 | import { cn } from "../lib/cn"; | |
| 7 | ||
| 8 | export 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 @@ |
| 1 | import { | |
| 2 | FilledIconButton, | |
| 3 | FilledTonalIconButton, | |
| 4 | Host, | |
| 5 | IconButton, | |
| 6 | } from "@expo/ui/jetpack-compose"; | |
| 7 | import { size } from "@expo/ui/jetpack-compose/modifiers"; | |
| 8 | import { View } from "react-native"; | |
| 9 | ||
| 10 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | |
| 11 | import { SymbolView, type AppSymbolName } from "./AppSymbol"; | |
| 12 | ||
| 13 | export 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 @@ |
| 1 | import { Pressable } from "react-native"; | |
| 2 | ||
| 3 | import { SymbolView, type AppSymbolName } from "./AppSymbol"; | |
| 4 | ||
| 5 | export 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 @@ |
| 1 | import type { ComponentProps, ReactNode } from "react"; | |
| 2 | import { Platform, Pressable, View } from "react-native"; | |
| 3 | ||
| 4 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | |
| 5 | import { cn } from "../lib/cn"; | |
| 6 | import { AppText } from "./AppText"; | |
| 7 | import { SymbolView } from "./AppSymbol"; | |
| 8 | ||
| 9 | /** Shared geometry for Material navigation and selection lists. Group rows in one card. */ | |
| 10 | export 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 @@ |
| 1 | import { | |
| 2 | Box, | |
| 3 | Column, | |
| 4 | DropdownMenu, | |
| 5 | DropdownMenuItem, | |
| 6 | Host, | |
| 7 | RNHostView, | |
| 8 | Text, | |
| 9 | } from "@expo/ui/jetpack-compose"; | |
| 10 | import { padding, size, width } from "@expo/ui/jetpack-compose/modifiers"; | |
| 11 | import { View } from "react-native"; | |
| 12 | ||
| 13 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | |
| 14 | import type { MaterialMenuPopupProps } from "./MaterialMenuPopup"; | |
| 15 | import { SymbolView, type AppSymbolName } from "./AppSymbol"; | |
| 16 | ||
| 17 | function 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. */ | |
| 43 | export 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 @@ |
| 1 | import type { MenuAction } from "@react-native-menu/menu"; | |
| 2 | ||
| 3 | export 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 | ||
| 20 | export function MaterialMenuPopup(_props: MaterialMenuPopupProps) { | |
| 21 | return null; | |
| 22 | } |
apps/mobile/src/components/MaterialNewThreadButton.android.tsx created+17| ... | ... | @@ -0,0 +1,17 @@ |
| 1 | import type { ComponentProps } from "react"; | |
| 2 | import { MaterialFloatingActionButton } from "./MaterialFloatingActionButton.android"; | |
| 3 | import type { MaterialNewThreadButton as SharedMaterialNewThreadButton } from "./MaterialNewThreadButton.shared"; | |
| 4 | ||
| 5 | export 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 @@ |
| 1 | import { Pressable, type StyleProp, type ViewStyle } from "react-native"; | |
| 2 | ||
| 3 | import { cn } from "../lib/cn"; | |
| 4 | import { AppText } from "./AppText"; | |
| 5 | import { SymbolView } from "./AppSymbol"; | |
| 6 | ||
| 7 | /** Shared compose action for the floating button and empty workspace. */ | |
| 8 | export 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 @@ |
| 1 | export { MaterialNewThreadButton } from "./MaterialNewThreadButton.shared"; |
apps/mobile/src/components/MaterialRadioIndicator.android.tsx created+55| ... | ... | @@ -0,0 +1,55 @@ |
| 1 | import { Host, RadioButton } from "@expo/ui/jetpack-compose"; | |
| 2 | import { size } from "@expo/ui/jetpack-compose/modifiers"; | |
| 3 | import { View } from "react-native"; | |
| 4 | ||
| 5 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | |
| 6 | ||
| 7 | /** The enclosing radio row owns selection, touch and accessibility. */ | |
| 8 | export 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 @@ |
| 1 | import { SymbolView } from "./AppSymbol"; | |
| 2 | ||
| 3 | /** The enclosing radio row owns selection, touch and accessibility. */ | |
| 4 | export 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 @@ |
| 1 | import type { ReactNode } from "react"; | |
| 2 | import { Platform, View } from "react-native"; | |
| 3 | ||
| 4 | /** Keeps the header surface visible behind rounded Android content corners. */ | |
| 5 | export 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 @@ |
| 1 | import { SegmentedButton, SingleChoiceSegmentedButtonRow, Text } from "@expo/ui/jetpack-compose"; | |
| 2 | import { defaultMinSize, fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; | |
| 3 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | |
| 4 | import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; | |
| 5 | import type { SegmentedControlProps } from "./SegmentedControl"; | |
| 6 | ||
| 7 | /** Compose content shared by screen controls and native dialogs, inside their existing Host. */ | |
| 8 | export 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 @@ |
| 1 | import { Host } from "@expo/ui/jetpack-compose"; | |
| 2 | import { MaterialSegmentedButtons } from "./MaterialSegmentedButtons.android"; | |
| 3 | import { View } from "react-native"; | |
| 4 | ||
| 5 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | |
| 6 | import type { SegmentedControlProps } from "./SegmentedControl"; | |
| 7 | ||
| 8 | export 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 @@ |
| 1 | import type { SegmentedControlProps } from "./SegmentedControl"; | |
| 2 | ||
| 3 | export 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 @@ |
| 1 | import { Host, Switch as ComposeSwitch } from "@expo/ui/jetpack-compose"; | |
| 2 | import { View } from "react-native"; | |
| 3 | import type { ThemedSwitchProps } from "./ThemedSwitch"; | |
| 4 | ||
| 5 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | |
| 6 | ||
| 7 | /** Material's native switch, with the same palette and accessibility contract as our RN controls. */ | |
| 8 | export 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 @@ |
| 1 | export { Switch as MaterialSwitch } from "react-native"; |
apps/mobile/src/components/ScreenScrollView.tsx created+19| ... | ... | @@ -0,0 +1,19 @@ |
| 1 | import type { ComponentProps } from "react"; | |
| 2 | import { Platform, ScrollView } from "react-native"; | |
| 3 | ||
| 4 | /** Keeps forms and settings readable inside a wide pane while its surface fills the screen. */ | |
| 5 | export function ScreenScrollView(props: ComponentProps<typeof ScrollView>) { | |
| 6 | return ( | |
| 7 | <ScrollView | |
| 8 | {...props} | |
| 9 | contentContainerStyle={[ | |
| 10 | props.contentContainerStyle, | |
| 11 | Platform.OS === "android" && { | |
| 12 | width: "100%", | |
| 13 | maxWidth: 720, | |
| 14 | alignSelf: "center", | |
| 15 | }, | |
| 16 | ]} | |
| 17 | /> | |
| 18 | ); | |
| 19 | } |
apps/mobile/src/components/SegmentedControl.tsx+14-5| ... | ... | @@ -2,8 +2,9 @@ import { Platform, Pressable, View } from "react-native"; |
| 2 | 2 | import Animated, { Easing, LinearTransition, ReduceMotion } from "react-native-reanimated"; |
| 3 | 3 | import { AppText as Text } from "./AppText"; |
| 4 | 4 | import { cn } from "../lib/cn"; |
| 5 | import { MaterialSegmentedControl } from "./MaterialSegmentedControl"; | |
| 5 | 6 | |
| 6 | export function SegmentedControl<Value extends number | string>(props: { | |
| 7 | export interface SegmentedControlProps<Value extends number | string> { | |
| 7 | 8 | readonly options: readonly { |
| 8 | 9 | readonly value: Value; |
| 9 | 10 | readonly label: string; |
| ... | ... | @@ -11,18 +12,26 @@ export function SegmentedControl<Value extends number | string>(props: { |
| 11 | 12 | }[]; |
| 12 | 13 | readonly selected: Value; |
| 13 | 14 | readonly onSelect: (value: Value) => void; |
| 14 | /** The tab bar is full height; filters under it are shorter so it stays primary. */ | |
| 15 | /** Compact sizing applies to the non-Material control. */ | |
| 15 | 16 | readonly size?: "default" | "compact"; |
| 16 | 17 | /** "tab" for the view switcher; filters stay plain buttons. */ |
| 17 | 18 | readonly role?: "tab" | "button"; |
| 18 | 19 | readonly className?: string; |
| 19 | }) { | |
| 20 | } | |
| 21 | ||
| 22 | export function SegmentedControl<Value extends number | string>( | |
| 23 | props: SegmentedControlProps<Value>, | |
| 24 | ) { | |
| 20 | 25 | const compact = props.size === "compact"; |
| 26 | if (Platform.OS === "android") { | |
| 27 | return <MaterialSegmentedControl {...props} />; | |
| 28 | } | |
| 21 | 29 | return ( |
| 22 | 30 | <View |
| 23 | 31 | accessible={false} |
| 24 | 32 | className={cn( |
| 25 | "flex-row overflow-hidden rounded-full border-continuous bg-card", | |
| 33 | "flex-row overflow-hidden", | |
| 34 | "rounded-full border-continuous bg-card", | |
| 26 | 35 | props.className, |
| 27 | 36 | )} |
| 28 | 37 | > |
| ... | ... | @@ -31,7 +40,7 @@ export function SegmentedControl<Value extends number | string>(props: { |
| 31 | 40 | layout={LinearTransition.duration(200) |
| 32 | 41 | .easing(Easing.out(Easing.cubic)) |
| 33 | 42 | .reduceMotion(ReduceMotion.System)} |
| 34 | className="absolute bottom-0 top-0 rounded-full bg-subtle-strong" | |
| 43 | className="absolute inset-y-0 rounded-full bg-subtle-strong" | |
| 35 | 44 | style={{ |
| 36 | 45 | width: `${100 / props.options.length}%`, |
| 37 | 46 | start: `${ |
apps/mobile/src/components/ThemedSwitch.tsx+16-52| ... | ... | @@ -1,63 +1,27 @@ |
| 1 | import { Platform, Pressable, Switch, View, type SwitchProps } from "react-native"; | |
| 1 | import { Platform, Switch, type SwitchProps } from "react-native"; | |
| 2 | 2 | |
| 3 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | |
| 4 | import { SymbolView } from "./AppSymbol"; | |
| 3 | import { MaterialSwitch } from "./MaterialSwitch"; | |
| 5 | 4 | |
| 6 | export 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 | ); | |
| 5 | export type ThemedSwitchProps = Pick< | |
| 6 | SwitchProps, | |
| 7 | | "accessibilityHint" | |
| 8 | | "accessibilityLabel" | |
| 9 | | "disabled" | |
| 10 | | "onValueChange" | |
| 11 | | "style" | |
| 12 | | "testID" | |
| 13 | | "value" | |
| 14 | >; | |
| 15 | ||
| 16 | export function ThemedSwitch(props: ThemedSwitchProps) { | |
| 17 | if (Platform.OS === "android") { | |
| 18 | return <MaterialSwitch {...props} />; | |
| 48 | 19 | } |
| 49 | 20 | |
| 50 | 21 | return ( |
| 51 | 22 | <Switch |
| 52 | 23 | {...props} |
| 53 | 24 | ios_backgroundColorClassName="accent-switch-inactive-track" |
| 54 | thumbColorClassName={ | |
| 55 | Platform.OS === "android" | |
| 56 | ? props.value | |
| 57 | ? "accent-switch-active-thumb" | |
| 58 | : "accent-switch-inactive-thumb" | |
| 59 | : undefined | |
| 60 | } | |
| 61 | 25 | trackColorOffClassName="accent-switch-inactive-track" |
| 62 | 26 | trackColorOnClassName="accent-switch-active-track" |
| 63 | 27 | /> |
apps/mobile/src/components/useMaterialToolbarHeight.ts created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | import { useWindowDimensions } from "react-native"; | |
| 2 | ||
| 3 | import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; | |
| 4 | ||
| 5 | /** Reserve the same title/subtitle space in every pane, including icon-only and search headers. */ | |
| 6 | export function useMaterialToolbarHeight() { | |
| 7 | const title = useScaledTextRole("title"); | |
| 8 | const subtitle = useScaledTextRole("label"); | |
| 9 | const { fontScale } = useWindowDimensions(); | |
| 10 | return Math.ceil(Math.max(56, (title.lineHeight + subtitle.lineHeight) * fontScale + 1)); | |
| 11 | } |
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+41-37| ... | ... | @@ -40,6 +40,7 @@ import { |
| 40 | 40 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, |
| 41 | 41 | } from "../layout/native-mail-search-toolbar"; |
| 42 | 42 | import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; |
| 43 | import { SettingsScreenContent } from "../settings/components/SettingsScreen"; | |
| 43 | 44 | |
| 44 | 45 | export interface ArchivedThreadsHeaderEnvironment { |
| 45 | 46 | readonly environmentId: EnvironmentId; |
| ... | ... | @@ -146,6 +147,7 @@ function ArchivedThreadsHeader(props: { |
| 146 | 147 | className="border-b border-header-border bg-header px-3 pb-2.5" |
| 147 | 148 | style={{ |
| 148 | 149 | paddingTop: Math.max(insets.top, 12), |
| 150 | borderBottomWidth: 0, | |
| 149 | 151 | }} |
| 150 | 152 | > |
| 151 | 153 | <View className="min-h-12 flex-row items-center gap-2"> |
| ... | ... | @@ -159,7 +161,7 @@ function ArchivedThreadsHeader(props: { |
| 159 | 161 | <SymbolView |
| 160 | 162 | name="chevron.left" |
| 161 | 163 | size={24} |
| 162 | tintColorClassName={"accent-foreground"} | |
| 164 | tintColorClassName="accent-foreground" | |
| 163 | 165 | type="monochrome" |
| 164 | 166 | /> |
| 165 | 167 | </Pressable> |
| ... | ... | @@ -167,7 +169,7 @@ function ArchivedThreadsHeader(props: { |
| 167 | 169 | <SymbolView |
| 168 | 170 | name="magnifyingglass" |
| 169 | 171 | size={17} |
| 170 | tintColorClassName={"accent-icon"} | |
| 172 | tintColorClassName="accent-icon" | |
| 171 | 173 | type="monochrome" |
| 172 | 174 | /> |
| 173 | 175 | <TextInput |
| ... | ... | @@ -197,7 +199,7 @@ function ArchivedThreadsHeader(props: { |
| 197 | 199 | : "line.3.horizontal.decrease.circle" |
| 198 | 200 | } |
| 199 | 201 | size={16} |
| 200 | tintColorClassName={"accent-icon"} | |
| 202 | tintColorClassName="accent-icon" | |
| 201 | 203 | type="monochrome" |
| 202 | 204 | /> |
| 203 | 205 | </Pressable> |
| ... | ... | @@ -455,7 +457,7 @@ function ArchivedThreadRow(props: { |
| 455 | 457 | <SymbolView |
| 456 | 458 | name="archivebox.fill" |
| 457 | 459 | size={15} |
| 458 | tintColorClassName={"accent-icon-subtle"} | |
| 460 | tintColorClassName="accent-icon-subtle" | |
| 459 | 461 | type="monochrome" |
| 460 | 462 | /> |
| 461 | 463 | </View> |
| ... | ... | @@ -477,7 +479,7 @@ function ArchivedThreadRow(props: { |
| 477 | 479 | <SymbolView |
| 478 | 480 | name="arrow.triangle.branch" |
| 479 | 481 | size={10} |
| 480 | tintColorClassName={"accent-icon-subtle"} | |
| 482 | tintColorClassName="accent-icon-subtle" | |
| 481 | 483 | type="monochrome" |
| 482 | 484 | /> |
| 483 | 485 | <Text |
| ... | ... | @@ -615,7 +617,7 @@ export function ArchivedThreadsScreen(props: { |
| 615 | 617 | if (isInitialLoad) { |
| 616 | 618 | return ( |
| 617 | 619 | <View className="items-center py-16"> |
| 618 | <ActivityIndicator colorClassName={"accent-icon"} /> | |
| 620 | <ActivityIndicator colorClassName="accent-icon" /> | |
| 619 | 621 | <Text className="mt-3 text-sm text-foreground-muted">Loading archive...</Text> |
| 620 | 622 | </View> |
| 621 | 623 | ); |
| ... | ... | @@ -646,37 +648,39 @@ export function ArchivedThreadsScreen(props: { |
| 646 | 648 | sortOrder={props.sortOrder} |
| 647 | 649 | /> |
| 648 | 650 | |
| 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> | |
| 680 | 684 | </View> |
| 681 | 685 | ); |
| 682 | 686 | } |
apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx+2-1| ... | ... | @@ -1,8 +1,9 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | |
| 1 | 2 | import { NativeHeaderToolbar } from "../../native/StackHeader"; |
| 2 | 3 | import { useAuth } from "@clerk/expo"; |
| 3 | 4 | import { StackActions, useNavigation } from "@react-navigation/native"; |
| 4 | 5 | import { useCallback, useEffect, useState } from "react"; |
| 5 | import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; | |
| 6 | import { Platform, Pressable, RefreshControl, View } from "react-native"; | |
| 6 | 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | 8 | |
| 8 | 9 | import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime"; |
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+70-38| ... | ... | @@ -6,11 +6,13 @@ import { useAtomValue } from "@effect/atom-react"; |
| 6 | 6 | import * as Cause from "effect/Cause"; |
| 7 | 7 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 8 | 8 | import { useCallback, useState } from "react"; |
| 9 | import { Alert, Pressable, View } from "react-native"; | |
| 9 | import { Platform, Alert, Pressable, View } from "react-native"; | |
| 10 | 10 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; |
| 11 | 11 | |
| 12 | 12 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 13 | 13 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; |
| 14 | import { MaterialButton } from "../../components/MaterialButton"; | |
| 15 | import { MaterialIconButton } from "../../components/MaterialIconButton"; | |
| 14 | 16 | import { ThemedSwitch } from "../../components/ThemedSwitch"; |
| 15 | 17 | import { cn } from "../../lib/cn"; |
| 16 | 18 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| ... | ... | @@ -142,7 +144,7 @@ export function ConnectionEnvironmentRow(props: { |
| 142 | 144 | <SymbolView |
| 143 | 145 | name="chevron.down" |
| 144 | 146 | size={12} |
| 145 | tintColorClassName={"accent-icon-subtle"} | |
| 147 | tintColorClassName="accent-icon-subtle" | |
| 146 | 148 | type="monochrome" |
| 147 | 149 | style={{ |
| 148 | 150 | transform: [{ rotate: props.expanded ? "180deg" : "0deg" }], |
| ... | ... | @@ -193,49 +195,79 @@ export function ConnectionEnvironmentRow(props: { |
| 193 | 195 | </> |
| 194 | 196 | )} |
| 195 | 197 | |
| 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 | ||
| 198 | 245 | <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)} | |
| 201 | 249 | > |
| 202 | 250 | <SymbolView |
| 203 | name="checkmark" | |
| 204 | size={13} | |
| 205 | tintColorClassName={"accent-primary-foreground"} | |
| 251 | name="arrow.clockwise" | |
| 252 | size={14} | |
| 253 | tintColorClassName="accent-icon-subtle" | |
| 206 | 254 | type="monochrome" |
| 207 | 255 | /> |
| 208 | <Text className="text-xs font-t3-bold tracking-[0.8px] uppercase text-primary-foreground"> | |
| 209 | Save | |
| 210 | </Text> | |
| 211 | 256 | </Pressable> |
| 212 | )} | |
| 213 | ||
| 214 | <Pressable | |
| 215 | className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-input-border bg-input active:opacity-70 disabled:opacity-40" | |
| 216 | disabled={!enabled} | |
| 217 | onPress={() => props.onReconnect(props.environment.environmentId)} | |
| 218 | > | |
| 219 | <SymbolView | |
| 220 | name="arrow.clockwise" | |
| 221 | size={14} | |
| 222 | tintColorClassName={"accent-icon-subtle"} | |
| 223 | type="monochrome" | |
| 224 | /> | |
| 225 | </Pressable> | |
| 226 | 257 | |
| 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 | )} | |
| 239 | 271 | </Animated.View> |
| 240 | 272 | ) : null} |
| 241 | 273 | </Animated.View> |
apps/mobile/src/features/connection/ConnectionSheetButton.tsx+12| ... | ... | @@ -3,6 +3,7 @@ import { Platform, Pressable } from "react-native"; |
| 3 | 3 | |
| 4 | 4 | import { AppText as Text } from "../../components/AppText"; |
| 5 | 5 | import { cn } from "../../lib/cn"; |
| 6 | import { MaterialButton } from "../../components/MaterialButton"; | |
| 6 | 7 | |
| 7 | 8 | const CARD_SHADOW = Platform.select({ |
| 8 | 9 | ios: { |
| ... | ... | @@ -32,8 +33,19 @@ export function ConnectionSheetButton(props: { |
| 32 | 33 | readonly disabled?: boolean; |
| 33 | 34 | readonly tone?: "primary" | "secondary" | "danger"; |
| 34 | 35 | readonly compact?: boolean; |
| 36 | readonly fullWidth?: boolean; | |
| 35 | 37 | readonly onPress: () => void; |
| 36 | 38 | }) { |
| 39 | if (Platform.OS === "android") | |
| 40 | return ( | |
| 41 | <MaterialButton | |
| 42 | label={props.label} | |
| 43 | disabled={props.disabled} | |
| 44 | onPress={props.onPress} | |
| 45 | tone={props.tone} | |
| 46 | fullWidth={props.fullWidth} | |
| 47 | /> | |
| 48 | ); | |
| 37 | 49 | const tone = props.tone ?? "secondary"; |
| 38 | 50 | |
| 39 | 51 | const textColorClassName = |
apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx+42-40| ... | ... | @@ -1,13 +1,18 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | |
| 1 | 2 | import { CameraView, useCameraPermissions } from "expo-camera"; |
| 2 | 3 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 3 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | |
| 4 | import { | |
| 5 | StackActions, | |
| 6 | useNavigation, | |
| 7 | useRoute, | |
| 8 | type StaticScreenProps, | |
| 9 | } from "@react-navigation/native"; | |
| 4 | 10 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 5 | 11 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 6 | import { Alert, Linking, Platform, ScrollView, View } from "react-native"; | |
| 12 | import { Alert, Linking, Platform, View } from "react-native"; | |
| 7 | 13 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | 14 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 9 | ||
| 10 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | |
| 15 | import { SettingsScreen } from "../settings/components/SettingsScreen"; | |
| 11 | 16 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 12 | 17 | import { ErrorBanner } from "../../components/ErrorBanner"; |
| 13 | 18 | import { ConnectionSheetButton } from "./ConnectionSheetButton"; |
| ... | ... | @@ -30,6 +35,7 @@ export function ConnectionsNewRouteScreen({ |
| 30 | 35 | pairingConnectionError, |
| 31 | 36 | } = useRemoteConnections(); |
| 32 | 37 | const navigation = useNavigation(); |
| 38 | const routeName = useRoute().name; | |
| 33 | 39 | const params = route.params ?? {}; |
| 34 | 40 | // Deep-link prefill exists for development automation only. A production |
| 35 | 41 | // link must not arrive with attacker-chosen host and token already filled. |
| ... | ... | @@ -181,33 +187,27 @@ export function ConnectionsNewRouteScreen({ |
| 181 | 187 | }, [connectAndClose, routePairingUrl, shouldAutoConnect]); |
| 182 | 188 | |
| 183 | 189 | return ( |
| 184 | <View collapsable={false} className="flex-1 bg-sheet"> | |
| 190 | <SettingsScreen | |
| 191 | formSheet={routeName === "ConnectionsNew"} | |
| 192 | title={showScanner ? "Scan QR Code" : "Add Environment"} | |
| 193 | actions={[ | |
| 194 | { | |
| 195 | accessibilityLabel: showScanner ? "Close scanner" : "Scan QR code", | |
| 196 | icon: showScanner ? "xmark" : "camera", | |
| 197 | onPress: () => { | |
| 198 | if (showScanner) { | |
| 199 | closeScanner(); | |
| 200 | } else { | |
| 201 | void openScanner(); | |
| 202 | } | |
| 203 | }, | |
| 204 | }, | |
| 205 | ]} | |
| 206 | > | |
| 185 | 207 | <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" }} | |
| 191 | 209 | /> |
| 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" ? ( | |
| 211 | 211 | <NativeHeaderToolbar placement="right"> |
| 212 | 212 | <NativeHeaderToolbar.Button |
| 213 | 213 | icon={showScanner ? "xmark" : "qrcode.viewfinder"} |
| ... | ... | @@ -222,7 +222,7 @@ export function ConnectionsNewRouteScreen({ |
| 222 | 222 | tintColor={headerIconColor} |
| 223 | 223 | /> |
| 224 | 224 | </NativeHeaderToolbar> |
| 225 | )} | |
| 225 | ) : null} | |
| 226 | 226 | |
| 227 | 227 | <ScrollView |
| 228 | 228 | contentInsetAdjustmentBehavior="automatic" |
| ... | ... | @@ -293,19 +293,21 @@ export function ConnectionsNewRouteScreen({ |
| 293 | 293 | |
| 294 | 294 | {pairingConnectionError ? <ErrorBanner message={pairingConnectionError} /> : null} |
| 295 | 295 | |
| 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> | |
| 305 | 307 | </View> |
| 306 | 308 | )} |
| 307 | 309 | </View> |
| 308 | 310 | </ScrollView> |
| 309 | </View> | |
| 311 | </SettingsScreen> | |
| 310 | 312 | ); |
| 311 | 313 | } |
apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx+2-1| ... | ... | @@ -1,9 +1,10 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | |
| 1 | 2 | import { NativeHeaderToolbar } from "../../native/StackHeader"; |
| 2 | 3 | import { useNavigation } from "@react-navigation/native"; |
| 3 | 4 | import { SymbolView } from "../../components/AppSymbol"; |
| 4 | 5 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 5 | 6 | import { useCallback, useState } from "react"; |
| 6 | import { Platform, ScrollView, View } from "react-native"; | |
| 7 | import { Platform, View } from "react-native"; | |
| 7 | 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | 9 | |
| 9 | 10 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx+7-5| ... | ... | @@ -1,12 +1,14 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | |
| 1 | 2 | import Constants from "expo-constants"; |
| 2 | 3 | import * as Updates from "expo-updates"; |
| 3 | 4 | import { useEffect, useState } from "react"; |
| 4 | import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native"; | |
| 5 | import { ActivityIndicator, Platform, Pressable, View } from "react-native"; | |
| 5 | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | 7 | |
| 7 | 8 | import { SymbolView } from "../../components/AppSymbol"; |
| 8 | 9 | import { AppText as Text } from "../../components/AppText"; |
| 9 | 10 | import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 11 | import { SettingsScreen } from "../settings/components/SettingsScreen"; | |
| 10 | 12 | import { SettingsSection } from "../settings/components/SettingsSection"; |
| 11 | 13 | import { |
| 12 | 14 | formatStartupCrashReport, |
| ... | ... | @@ -71,7 +73,7 @@ export function SettingsDiagnosticsRouteScreen() { |
| 71 | 73 | }; |
| 72 | 74 | |
| 73 | 75 | return ( |
| 74 | <View collapsable={false} className="flex-1 bg-sheet"> | |
| 76 | <SettingsScreen title="Diagnostics"> | |
| 75 | 77 | <ScrollView |
| 76 | 78 | contentInsetAdjustmentBehavior="automatic" |
| 77 | 79 | contentInset={{ bottom: Math.max(insets.bottom, 18) }} |
| ... | ... | @@ -115,7 +117,7 @@ export function SettingsDiagnosticsRouteScreen() { |
| 115 | 117 | <SymbolView |
| 116 | 118 | name={copied ? "checkmark" : "doc.on.doc"} |
| 117 | 119 | size={22} |
| 118 | tintColorClassName={"accent-icon"} | |
| 120 | tintColorClassName="accent-icon" | |
| 119 | 121 | type="monochrome" |
| 120 | 122 | weight="regular" |
| 121 | 123 | /> |
| ... | ... | @@ -131,7 +133,7 @@ export function SettingsDiagnosticsRouteScreen() { |
| 131 | 133 | </Text> |
| 132 | 134 | </View> |
| 133 | 135 | </ScrollView> |
| 134 | </View> | |
| 136 | </SettingsScreen> | |
| 135 | 137 | ); |
| 136 | 138 | } |
| 137 | 139 | |
| ... | ... | @@ -145,7 +147,7 @@ function EmptyState(props: { |
| 145 | 147 | <SymbolView |
| 146 | 148 | name={props.icon} |
| 147 | 149 | size={28} |
| 148 | tintColorClassName={"accent-icon"} | |
| 150 | tintColorClassName="accent-icon" | |
| 149 | 151 | type="monochrome" |
| 150 | 152 | weight="regular" |
| 151 | 153 | /> |
apps/mobile/src/features/files/MaterialFilesHeader.tsx created+122| ... | ... | @@ -0,0 +1,122 @@ |
| 1 | import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; | |
| 2 | import { BackHandler, Keyboard, Pressable, TextInput, View } from "react-native"; | |
| 3 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 4 | ||
| 5 | import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | |
| 6 | import { SymbolView } from "../../components/AppSymbol"; | |
| 7 | ||
| 8 | /** Keep Files search in the same header row on compact and expanded layouts. */ | |
| 9 | export function MaterialFilesHeader(props: { | |
| 10 | readonly projectName: string; | |
| 11 | readonly searchQuery: string; | |
| 12 | readonly onSearchQueryChange: (query: string) => void; | |
| 13 | readonly onRefresh: () => void; | |
| 14 | readonly onBack?: () => void; | |
| 15 | readonly leading?: ReactNode; | |
| 16 | }) { | |
| 17 | const insets = useSafeAreaInsets(); | |
| 18 | const searchRef = useRef<TextInput>(null); | |
| 19 | const [searchOpen, setSearchOpen] = useState(false); | |
| 20 | const searching = searchOpen || props.searchQuery.length > 0; | |
| 21 | const { onSearchQueryChange } = props; | |
| 22 | const closeSearch = useCallback(() => { | |
| 23 | onSearchQueryChange(""); | |
| 24 | setSearchOpen(false); | |
| 25 | Keyboard.dismiss(); | |
| 26 | }, [onSearchQueryChange]); | |
| 27 | ||
| 28 | useEffect(() => { | |
| 29 | if (!searching) return; | |
| 30 | const subscription = BackHandler.addEventListener("hardwareBackPress", () => { | |
| 31 | closeSearch(); | |
| 32 | return true; | |
| 33 | }); | |
| 34 | return () => subscription.remove(); | |
| 35 | }, [closeSearch, searching]); | |
| 36 | ||
| 37 | return ( | |
| 38 | <View> | |
| 39 | {/* Keep the title/subtitle's natural height, including larger text, while searching. */} | |
| 40 | <View | |
| 41 | pointerEvents={searching ? "none" : "auto"} | |
| 42 | accessibilityElementsHidden={searching} | |
| 43 | importantForAccessibility={searching ? "no-hide-descendants" : "auto"} | |
| 44 | style={{ opacity: searching ? 0 : 1 }} | |
| 45 | > | |
| 46 | <AndroidScreenHeader | |
| 47 | title="Files" | |
| 48 | subtitle={props.projectName} | |
| 49 | hideBottomBorder | |
| 50 | onBack={props.onBack} | |
| 51 | leading={props.leading} | |
| 52 | actions={[ | |
| 53 | { | |
| 54 | accessibilityLabel: "Search files", | |
| 55 | icon: "magnifyingglass", | |
| 56 | onPress: () => setSearchOpen(true), | |
| 57 | }, | |
| 58 | { | |
| 59 | accessibilityLabel: "Refresh files", | |
| 60 | icon: "arrow.clockwise", | |
| 61 | onPress: props.onRefresh, | |
| 62 | }, | |
| 63 | ]} | |
| 64 | /> | |
| 65 | </View> | |
| 66 | {searching ? ( | |
| 67 | <View | |
| 68 | className="absolute inset-0 bg-header px-2 pb-2" | |
| 69 | style={{ paddingTop: Math.max(insets.top, 12) }} | |
| 70 | > | |
| 71 | <View className="min-h-14 flex-1 flex-row items-center gap-1"> | |
| 72 | <AndroidHeaderIconButton | |
| 73 | accessibilityLabel="Close file search" | |
| 74 | icon="arrow.left" | |
| 75 | onPress={closeSearch} | |
| 76 | /> | |
| 77 | <View className="h-12 min-w-0 flex-1 flex-row items-center gap-2 rounded-full border border-input-border bg-input px-3"> | |
| 78 | <SymbolView | |
| 79 | name="magnifyingglass" | |
| 80 | size={18} | |
| 81 | tintColorClassName="accent-foreground-muted" | |
| 82 | /> | |
| 83 | <TextInput | |
| 84 | ref={searchRef} | |
| 85 | accessibilityLabel="Search files" | |
| 86 | autoFocus | |
| 87 | autoCapitalize="none" | |
| 88 | autoCorrect={false} | |
| 89 | returnKeyType="search" | |
| 90 | placeholder="Search files" | |
| 91 | placeholderTextColorClassName="accent-placeholder" | |
| 92 | selectionColorClassName="accent-primary/32" | |
| 93 | cursorColorClassName="accent-primary" | |
| 94 | selectionHandleColorClassName="accent-primary" | |
| 95 | className="min-w-0 flex-1 py-2 font-sans text-base text-foreground" | |
| 96 | value={props.searchQuery} | |
| 97 | onChangeText={onSearchQueryChange} | |
| 98 | /> | |
| 99 | {props.searchQuery.length > 0 ? ( | |
| 100 | <Pressable | |
| 101 | accessibilityLabel="Clear file search" | |
| 102 | accessibilityRole="button" | |
| 103 | hitSlop={10} | |
| 104 | onPress={() => { | |
| 105 | onSearchQueryChange(""); | |
| 106 | searchRef.current?.focus(); | |
| 107 | }} | |
| 108 | > | |
| 109 | <SymbolView | |
| 110 | name="xmark.circle.fill" | |
| 111 | size={18} | |
| 112 | tintColorClassName="accent-foreground-muted" | |
| 113 | /> | |
| 114 | </Pressable> | |
| 115 | ) : null} | |
| 116 | </View> | |
| 117 | </View> | |
| 118 | </View> | |
| 119 | ) : null} | |
| 120 | </View> | |
| 121 | ); | |
| 122 | } |
apps/mobile/src/features/files/SourceFileSurface.tsx+6| ... | ... | @@ -4,6 +4,7 @@ import type { ComponentType } from "react"; |
| 4 | 4 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 5 | 5 | import { |
| 6 | 6 | FlatList, |
| 7 | Platform, | |
| 7 | 8 | RefreshControl, |
| 8 | 9 | ScrollView, |
| 9 | 10 | Text as NativeText, |
| ... | ... | @@ -73,6 +74,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { |
| 73 | 74 | </NativeText> |
| 74 | 75 | <NativeText |
| 75 | 76 | selectable |
| 77 | selectionColorClassName={Platform.OS === "android" ? "accent-primary/32" : undefined} | |
| 76 | 78 | numberOfLines={props.wordBreak ? undefined : 1} |
| 77 | 79 | className="flex-1 font-normal text-foreground" |
| 78 | 80 | style={{ |
| ... | ... | @@ -102,6 +104,9 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { |
| 102 | 104 | <NativeText |
| 103 | 105 | key={`${start}:${token.content.length}:${token.color ?? ""}`} |
| 104 | 106 | selectable |
| 107 | selectionColorClassName={ | |
| 108 | Platform.OS === "android" ? "accent-primary/32" : undefined | |
| 109 | } | |
| 105 | 110 | style={{ |
| 106 | 111 | color: token.color ?? undefined, |
| 107 | 112 | fontFamily: REVIEW_MONO_FONT_FAMILY, |
| ... | ... | @@ -274,6 +279,7 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { |
| 274 | 279 | <MarkdownTextPrimitive |
| 275 | 280 | uiTextView |
| 276 | 281 | selectable |
| 282 | selectionColorClassName={Platform.OS === "android" ? "accent-primary/32" : undefined} | |
| 277 | 283 | style={{ |
| 278 | 284 | color: foreground, |
| 279 | 285 | fontFamily: REVIEW_MONO_FONT_FAMILY, |
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+62-82| ... | ... | @@ -15,8 +15,8 @@ import { |
| 15 | 15 | import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; |
| 16 | 16 | |
| 17 | 17 | import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 18 | import { SymbolView } from "../../components/AppSymbol"; | |
| 19 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | |
| 18 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; | |
| 19 | import { AppText as Text } from "../../components/AppText"; | |
| 20 | 20 | import { AudioFilePreview } from "../../components/AudioFilePreview"; |
| 21 | 21 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 22 | 22 | import { EmptyState } from "../../components/EmptyState"; |
| ... | ... | @@ -43,7 +43,10 @@ import { |
| 43 | 43 | createNativeMailSearchToolbarItem, |
| 44 | 44 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, |
| 45 | 45 | } from "../layout/native-mail-search-toolbar"; |
| 46 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; | |
| 46 | import { | |
| 47 | AndroidWorkspaceSidebarButton, | |
| 48 | WorkspaceSidebarToolbar, | |
| 49 | } from "../layout/workspace-sidebar-toolbar"; | |
| 47 | 50 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 48 | 51 | import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; |
| 49 | 52 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; |
| ... | ... | @@ -52,6 +55,7 @@ import { useFileTreeEntries } from "./useFileTreeEntries"; |
| 52 | 55 | import { preloadWorkspaceFileContents } from "./preload-workspace-file"; |
| 53 | 56 | import { SourceFileSurface } from "./SourceFileSurface"; |
| 54 | 57 | import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; |
| 58 | import { MaterialFilesHeader } from "./MaterialFilesHeader"; | |
| 55 | 59 | import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; |
| 56 | 60 | import { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError"; |
| 57 | 61 | import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; |
| ... | ... | @@ -328,10 +332,9 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { |
| 328 | 332 | useAdaptiveWorkspaceLayout(); |
| 329 | 333 | const [searchQuery, setSearchQuery] = useState(""); |
| 330 | 334 | const isAndroid = Platform.OS === "android"; |
| 331 | const { themeAppearance: highlightTheme, materialYouStyleLayoutActive } = | |
| 332 | useAppearancePreferences(); | |
| 335 | const { themeAppearance: highlightTheme } = useAppearancePreferences(); | |
| 333 | 336 | const theme = useUniwindTheme(); |
| 334 | const screenColor = theme["--color-screen"]; | |
| 337 | const headerColor = theme["--color-header"]; | |
| 335 | 338 | const sheetSurfaceColor = theme["--color-sheet-solid"]; |
| 336 | 339 | const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( |
| 337 | 340 | props.route.params, |
| ... | ... | @@ -451,7 +454,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { |
| 451 | 454 | <NativeStackScreenOptions |
| 452 | 455 | options={{ |
| 453 | 456 | contentStyle: { |
| 454 | backgroundColor: materialYouStyleLayoutActive ? screenColor : sheetSurfaceColor, | |
| 457 | backgroundColor: Platform.OS === "android" ? headerColor : sheetSurfaceColor, | |
| 455 | 458 | }, |
| 456 | 459 | headerShown: !isAndroid, |
| 457 | 460 | unstable_headerSubtitle: |
| ... | ... | @@ -484,46 +487,16 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { |
| 484 | 487 | /> |
| 485 | 488 | {isAndroid ? ( |
| 486 | 489 | <> |
| 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} | |
| 512 | 498 | /> |
| 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 | } | |
| 527 | 500 | </> |
| 528 | 501 | ) : ( |
| 529 | 502 | <> |
| ... | ... | @@ -548,26 +521,28 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { |
| 548 | 521 | )} |
| 549 | 522 | </> |
| 550 | 523 | )} |
| 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> | |
| 566 | 541 | </> |
| 567 | 542 | ); |
| 568 | 543 | |
| 569 | return materialYouStyleLayoutActive ? ( | |
| 570 | <View className="flex-1" style={{ backgroundColor: screenColor }}> | |
| 544 | return Platform.OS === "android" ? ( | |
| 545 | <View className="flex-1" style={{ backgroundColor: headerColor }}> | |
| 571 | 546 | {content} |
| 572 | 547 | </View> |
| 573 | 548 | ) : ( |
| ... | ... | @@ -933,6 +908,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 933 | 908 | <AndroidScreenHeader |
| 934 | 909 | title={basename(relativePath)} |
| 935 | 910 | subtitle={headerSubtitle} |
| 911 | leading={<AndroidWorkspaceSidebarButton />} | |
| 912 | hideBottomBorder | |
| 936 | 913 | onBack={handleBack} |
| 937 | 914 | trailing={ |
| 938 | 915 | <> |
| ... | ... | @@ -942,6 +919,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 942 | 919 | panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator" |
| 943 | 920 | } |
| 944 | 921 | icon="sidebar.right" |
| 922 | selected={panes.auxiliaryPaneVisible} | |
| 945 | 923 | onPress={toggleAuxiliaryPane} |
| 946 | 924 | /> |
| 947 | 925 | ) : null} |
| ... | ... | @@ -1007,25 +985,27 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 1007 | 985 | ))} |
| 1008 | 986 | </NativeHeaderToolbar.Menu> |
| 1009 | 987 | </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> | |
| 1029 | 1009 | <FilePreviewModal |
| 1030 | 1010 | source={fullScreenPreview} |
| 1031 | 1011 | onRequestClose={() => setFullScreenPreview(null)} |
apps/mobile/src/features/files/thread-file-navigator-pane.tsx+59-48| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | 2 | import { SymbolView } from "../../components/AppSymbol"; |
| 3 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; | |
| 3 | 4 | import { useCallback, useMemo, useState, type ComponentProps } from "react"; |
| 4 | 5 | import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native"; |
| 5 | 6 | import { |
| ... | ... | @@ -11,6 +12,7 @@ import { |
| 11 | 12 | } from "react-native-screens"; |
| 12 | 13 | |
| 13 | 14 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 15 | import { MaterialFilesHeader } from "./MaterialFilesHeader"; | |
| 14 | 16 | import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; |
| 15 | 17 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 16 | 18 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| ... | ... | @@ -149,59 +151,68 @@ export function ThreadFileNavigatorPane(props: { |
| 149 | 151 | } |
| 150 | 152 | |
| 151 | 153 | 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> | |
| 160 | 192 | </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"> | |
| 168 | 196 | <SymbolView |
| 169 | name="arrow.clockwise" | |
| 170 | size={14} | |
| 171 | tintColorClassName={"accent-icon-muted"} | |
| 197 | name="magnifyingglass" | |
| 198 | size={15} | |
| 199 | tintColorClassName="accent-icon-muted" | |
| 172 | 200 | type="monochrome" |
| 173 | 201 | /> |
| 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} | |
| 203 | 214 | </View> |
| 204 | {fileTree} | |
| 215 | <MaterialScreenContent insetHorizontal>{fileTree}</MaterialScreenContent> | |
| 205 | 216 | </View> |
| 206 | 217 | ); |
| 207 | 218 | } |
apps/mobile/src/features/home/AndroidHomeFab.android.tsx created+44| ... | ... | @@ -0,0 +1,44 @@ |
| 1 | import { useCallback, useRef, useState, type ComponentProps } from "react"; | |
| 2 | import { View, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native"; | |
| 3 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 4 | import { MaterialNewThreadButton } from "../../components/MaterialNewThreadButton"; | |
| 5 | import type { AndroidHomeFabLayout as SharedAndroidHomeFabLayout } from "./AndroidHomeFab.shared"; | |
| 6 | import { useWorkspaceState } from "../../state/workspace"; | |
| 7 | import { MaterialFabScrollContext } from "./MaterialFabScrollContext"; | |
| 8 | import { updateMaterialFabScroll } from "./material-fab-scroll"; | |
| 9 | ||
| 10 | export 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 @@ |
| 1 | import type { ReactNode } from "react"; | |
| 2 | ||
| 3 | /** Other platforms render the list without Android's floating action button. */ | |
| 4 | export function AndroidHomeFabLayout(props: { | |
| 5 | readonly onStartNewTask: () => void; | |
| 6 | readonly children: ReactNode; | |
| 7 | readonly sidebar?: boolean; | |
| 8 | }) { | |
| 9 | return <>{props.children}</>; | |
| 10 | } |
apps/mobile/src/features/home/AndroidHomeFab.tsx+1-48| ... | ... | @@ -1,48 +1 @@ |
| 1 | import type { ReactNode } from "react"; | |
| 2 | import { Platform, Pressable, View } from "react-native"; | |
| 3 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 4 | ||
| 5 | import { 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 | */ | |
| 11 | export 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 | ||
| 22 | function 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 | } | |
| 1 | export { AndroidHomeFabLayout } from "./AndroidHomeFab.shared"; |
apps/mobile/src/features/home/HomeHeader.tsx+12-127| ... | ... | @@ -1,18 +1,11 @@ |
| 1 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | |
| 2 | 1 | import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; |
| 3 | 2 | import type { MenuAction } from "@react-native-menu/menu"; |
| 4 | import Constants from "expo-constants"; | |
| 3 | ||
| 5 | 4 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 6 | 5 | import { useCallback, useMemo, useRef } from "react"; |
| 7 | import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native"; | |
| 6 | import { Platform } from "react-native"; | |
| 8 | 7 | import type { SearchBarCommands } from "react-native-screens"; |
| 9 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 10 | 8 | |
| 11 | import { ControlPillMenu } from "../../components/ControlPill"; | |
| 12 | import { SymbolView } from "../../components/AppSymbol"; | |
| 13 | import { T3Wordmark } from "../../components/T3Wordmark"; | |
| 14 | import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; | |
| 15 | import { resolveMobileStageLabel } from "../../lib/mobileBranding"; | |
| 16 | 9 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 17 | 10 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; |
| 18 | 11 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| ... | ... | @@ -22,7 +15,7 @@ import { |
| 22 | 15 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, |
| 23 | 16 | } from "../layout/native-mail-search-toolbar"; |
| 24 | 17 | import type { HomeProjectSortOrder } from "./homeThreadList"; |
| 25 | import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle"; | |
| 18 | import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar"; | |
| 26 | 19 | import { |
| 27 | 20 | buildHomeListFilterMenu, |
| 28 | 21 | type HomeListFilterMenuEnvironment, |
| ... | ... | @@ -67,9 +60,6 @@ function checkedMenuState(checked: boolean) { |
| 67 | 60 | } |
| 68 | 61 | |
| 69 | 62 | function AndroidHomeHeader(props: HomeHeaderProps) { |
| 70 | const { materialYouStyleLayoutActive } = useAppearancePreferences(); | |
| 71 | const insets = useSafeAreaInsets(); | |
| 72 | const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); | |
| 73 | 63 | // Thread List v2 lays the list out in fixed creation order, so the |
| 74 | 64 | // sort/group filter controls would be silently ignored — hide them and |
| 75 | 65 | // key the "customized" icon state off the environment filter alone. |
| ... | ... | @@ -200,120 +190,15 @@ function AndroidHomeHeader(props: HomeHeaderProps) { |
| 200 | 190 | return ( |
| 201 | 191 | <> |
| 202 | 192 | <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 | /> | |
| 317 | 202 | </> |
| 318 | 203 | ); |
| 319 | 204 | } |
apps/mobile/src/features/home/HomeRouteScreen.tsx+23-3| ... | ... | @@ -11,7 +11,11 @@ import { useWorkspaceState } from "../../state/workspace"; |
| 11 | 11 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 12 | 12 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 13 | 13 | import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; |
| 14 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; | |
| 14 | import { | |
| 15 | AndroidWorkspaceSidebarButton, | |
| 16 | WorkspaceSidebarToolbar, | |
| 17 | } from "../layout/workspace-sidebar-toolbar"; | |
| 18 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | |
| 15 | 19 | import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates"; |
| 16 | 20 | import { AndroidHomeFabLayout } from "./AndroidHomeFab"; |
| 17 | 21 | import { HomeScreen } from "./HomeScreen"; |
| ... | ... | @@ -27,7 +31,7 @@ import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle |
| 27 | 31 | |
| 28 | 32 | export function HomeRouteScreen() { |
| 29 | 33 | const { width: windowWidth } = useWindowDimensions(); |
| 30 | const { layout } = useAdaptiveWorkspaceLayout(); | |
| 34 | const { layout, panes } = useAdaptiveWorkspaceLayout(); | |
| 31 | 35 | const projects = useProjects(); |
| 32 | 36 | const threads = useThreadShells(); |
| 33 | 37 | const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); |
| ... | ... | @@ -126,8 +130,24 @@ export function HomeRouteScreen() { |
| 126 | 130 | /> |
| 127 | 131 | } |
| 128 | 132 | /> |
| 133 | {Platform.OS === "android" ? ( | |
| 134 | <AndroidScreenHeader title="Threads" leading={<AndroidWorkspaceSidebarButton />} /> | |
| 135 | ) : null} | |
| 129 | 136 | <WorkspaceEmptyDetail |
| 130 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} | |
| 137 | onAddConnection={ | |
| 138 | Platform.OS === "android" && !catalogState.hasConnections | |
| 139 | ? () => | |
| 140 | navigation.navigate("SettingsSheet", { | |
| 141 | screen: "SettingsContent", | |
| 142 | params: { screen: "SettingsEnvironmentNew" }, | |
| 143 | }) | |
| 144 | : undefined | |
| 145 | } | |
| 146 | onStartNewTask={ | |
| 147 | Platform.OS === "android" && panes.primarySidebarVisible | |
| 148 | ? undefined | |
| 149 | : () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }) | |
| 150 | } | |
| 131 | 151 | /> |
| 132 | 152 | </> |
| 133 | 153 | ); |
apps/mobile/src/features/home/HomeScreen.tsx+58-14| ... | ... | @@ -30,13 +30,13 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 30 | 30 | import { cn } from "../../lib/cn"; |
| 31 | 31 | import { AppText as Text } from "../../components/AppText"; |
| 32 | 32 | import { EmptyState } from "../../components/EmptyState"; |
| 33 | import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; | |
| 33 | 34 | import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; |
| 34 | 35 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 35 | 36 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 36 | 37 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 37 | 38 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 38 | 39 | import { useThreadSearch } from "../../state/queries"; |
| 39 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | |
| 40 | 40 | import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; |
| 41 | 41 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; |
| 42 | 42 | import { usePendingThreadOrder } from "../../state/thread-order"; |
| ... | ... | @@ -83,6 +83,7 @@ import { |
| 83 | 83 | type HomeProjectSortOrder, |
| 84 | 84 | } from "./homeThreadList"; |
| 85 | 85 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; |
| 86 | import { useMaterialFabScroll } from "./MaterialFabScrollContext"; | |
| 86 | 87 | |
| 87 | 88 | /* ─── Types ──────────────────────────────────────────────────────────── */ |
| 88 | 89 | |
| ... | ... | @@ -219,7 +220,6 @@ function HomeTopContentSpacer() { |
| 219 | 220 | /* ─── Main screen ────────────────────────────────────────────────────── */ |
| 220 | 221 | |
| 221 | 222 | export function HomeScreen(props: HomeScreenProps) { |
| 222 | const { materialYouStyleLayoutActive } = useAppearancePreferences(); | |
| 223 | 223 | const [groupDisplayStates, setGroupDisplayStates] = useState< |
| 224 | 224 | ReadonlyMap<string, HomeGroupDisplayState> |
| 225 | 225 | >(() => new Map()); |
| ... | ... | @@ -315,7 +315,9 @@ export function HomeScreen(props: HomeScreenProps) { |
| 315 | 315 | const handleScrollBeginDrag = useCallback(() => { |
| 316 | 316 | openSwipeableRef.current?.close(); |
| 317 | 317 | }, []); |
| 318 | const onMaterialFabScroll = useMaterialFabScroll(); | |
| 318 | 319 | const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ |
| 320 | onScroll: onMaterialFabScroll, | |
| 319 | 321 | onScrollBeginDrag: handleScrollBeginDrag, |
| 320 | 322 | }); |
| 321 | 323 | |
| ... | ... | @@ -1104,11 +1106,11 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1104 | 1106 | |
| 1105 | 1107 | if (!hasAnyThreads) { |
| 1106 | 1108 | return ( |
| 1107 | <View className={materialYouStyleLayoutActive ? "flex-1 bg-header" : "flex-1 bg-screen"}> | |
| 1109 | <View className={Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 bg-screen"}> | |
| 1108 | 1110 | <View |
| 1109 | 1111 | className={cn( |
| 1110 | 1112 | "flex-1 items-center justify-center bg-screen px-8", |
| 1111 | materialYouStyleLayoutActive && "overflow-hidden rounded-t-[28px]", | |
| 1113 | Platform.OS === "android" && "overflow-hidden rounded-t-[28px]", | |
| 1112 | 1114 | )} |
| 1113 | 1115 | style={{ |
| 1114 | 1116 | paddingBottom: Math.max(insets.bottom, 24) + iosBottomToolbarClearance, |
| ... | ... | @@ -1121,11 +1123,22 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1121 | 1123 | detail={emptyState.detail} |
| 1122 | 1124 | actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined} |
| 1123 | 1125 | onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} |
| 1126 | action={ | |
| 1127 | Platform.OS === "android" && !props.catalogState.hasReadyEnvironment ? ( | |
| 1128 | <MaterialFloatingActionButton | |
| 1129 | label="Add environment" | |
| 1130 | icon="plus" | |
| 1131 | variant="extended" | |
| 1132 | tone="primary" | |
| 1133 | onPress={props.onAddConnection} | |
| 1134 | /> | |
| 1135 | ) : undefined | |
| 1136 | } | |
| 1124 | 1137 | variant="plain" |
| 1125 | 1138 | /> |
| 1126 | 1139 | {emptyState.loading ? ( |
| 1127 | 1140 | <View className="mt-4 items-center"> |
| 1128 | <ActivityIndicator colorClassName={"accent-icon-muted"} /> | |
| 1141 | <ActivityIndicator colorClassName="accent-icon-muted" /> | |
| 1129 | 1142 | </View> |
| 1130 | 1143 | ) : null} |
| 1131 | 1144 | </View> |
| ... | ... | @@ -1142,41 +1155,72 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1142 | 1155 | |
| 1143 | 1156 | const listEmpty = !hasResults ? ( |
| 1144 | 1157 | hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( |
| 1145 | <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> | |
| 1158 | <EmptyState | |
| 1159 | title="No results" | |
| 1160 | detail={`No threads matching "${props.searchQuery}".`} | |
| 1161 | variant={Platform.OS === "android" ? "plain" : undefined} | |
| 1162 | /> | |
| 1146 | 1163 | ) : selectedProjectScope !== null ? ( |
| 1147 | 1164 | <EmptyState |
| 1148 | 1165 | title={`No threads in ${selectedProjectScope.title}`} |
| 1149 | 1166 | detail="Choose another project or create a new task." |
| 1167 | variant={Platform.OS === "android" ? "plain" : undefined} | |
| 1150 | 1168 | /> |
| 1151 | 1169 | ) : selectedEnvironmentLabel ? ( |
| 1152 | 1170 | <EmptyState |
| 1153 | 1171 | title={`No threads in ${selectedEnvironmentLabel}`} |
| 1154 | 1172 | detail="Choose another environment or create a new task." |
| 1173 | variant={Platform.OS === "android" ? "plain" : undefined} | |
| 1155 | 1174 | /> |
| 1156 | 1175 | ) : ( |
| 1157 | <EmptyState title="No threads yet" detail="Create a task to start a new coding session." /> | |
| 1176 | <EmptyState | |
| 1177 | title="No threads yet" | |
| 1178 | detail="Create a task to start a new coding session." | |
| 1179 | variant={Platform.OS === "android" ? "plain" : undefined} | |
| 1180 | /> | |
| 1158 | 1181 | ) |
| 1159 | 1182 | ) : null; |
| 1160 | 1183 | // Use the v2 project scope for its empty state. Snoozed threads need no |
| 1161 | 1184 | // special empty state: their shelf header is a list row even while collapsed. |
| 1162 | 1185 | const v2ListEmpty = |
| 1163 | 1186 | hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( |
| 1164 | <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> | |
| 1187 | <EmptyState | |
| 1188 | title="No results" | |
| 1189 | detail={`No threads matching "${props.searchQuery}".`} | |
| 1190 | variant={Platform.OS === "android" ? "plain" : undefined} | |
| 1191 | /> | |
| 1165 | 1192 | ) : v2ScopedProjectGroup !== null ? ( |
| 1166 | 1193 | <EmptyState |
| 1167 | 1194 | title={`No threads in ${v2ScopedProjectGroup.title}`} |
| 1168 | 1195 | detail="Choose another project or create a new task." |
| 1196 | variant={Platform.OS === "android" ? "plain" : undefined} | |
| 1169 | 1197 | /> |
| 1170 | 1198 | ) : ( |
| 1171 | 1199 | listEmpty |
| 1172 | 1200 | ); |
| 1173 | 1201 | |
| 1202 | if ( | |
| 1203 | Platform.OS === "android" && | |
| 1204 | (threadListV2Enabled ? threadListV2Items.length === 0 : listLayout.items.length === 0) | |
| 1205 | ) { | |
| 1206 | return ( | |
| 1207 | <View className="flex-1 bg-header"> | |
| 1208 | <View | |
| 1209 | className="flex-1 items-center justify-center overflow-hidden rounded-t-[28px] bg-screen px-4" | |
| 1210 | style={{ paddingBottom: insets.bottom }} | |
| 1211 | > | |
| 1212 | {threadListV2Enabled ? v2ListEmpty : listEmpty} | |
| 1213 | </View> | |
| 1214 | </View> | |
| 1215 | ); | |
| 1216 | } | |
| 1217 | ||
| 1174 | 1218 | if (threadListV2Enabled) { |
| 1175 | 1219 | return ( |
| 1176 | <View className={materialYouStyleLayoutActive ? "flex-1 bg-header" : "flex-1 bg-screen"}> | |
| 1220 | <View className={Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 bg-screen"}> | |
| 1177 | 1221 | <View |
| 1178 | 1222 | className={ |
| 1179 | materialYouStyleLayoutActive | |
| 1223 | Platform.OS === "android" | |
| 1180 | 1224 | ? "flex-1 overflow-hidden rounded-t-[28px] bg-screen" |
| 1181 | 1225 | : "flex-1 bg-screen" |
| 1182 | 1226 | } |
| ... | ... | @@ -1216,7 +1260,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1216 | 1260 | paddingBottom: |
| 1217 | 1261 | Platform.OS === "ios" |
| 1218 | 1262 | ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance |
| 1219 | : Math.max(insets.bottom, 16) + 88, | |
| 1263 | : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88), | |
| 1220 | 1264 | }} |
| 1221 | 1265 | /> |
| 1222 | 1266 | </SwipeableScrollGateProvider> |
| ... | ... | @@ -1226,10 +1270,10 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1226 | 1270 | } |
| 1227 | 1271 | |
| 1228 | 1272 | return ( |
| 1229 | <View className={materialYouStyleLayoutActive ? "flex-1 bg-header" : "flex-1 bg-screen"}> | |
| 1273 | <View className={Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 bg-screen"}> | |
| 1230 | 1274 | <View |
| 1231 | 1275 | className={ |
| 1232 | materialYouStyleLayoutActive | |
| 1276 | Platform.OS === "android" | |
| 1233 | 1277 | ? "flex-1 overflow-hidden rounded-t-[28px] bg-screen" |
| 1234 | 1278 | : "flex-1 bg-screen" |
| 1235 | 1279 | } |
| ... | ... | @@ -1270,7 +1314,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1270 | 1314 | paddingBottom: |
| 1271 | 1315 | Platform.OS === "ios" |
| 1272 | 1316 | ? Math.max(insets.bottom, 24) + 24 + iosBottomToolbarClearance |
| 1273 | : Math.max(insets.bottom, 16) + 88, | |
| 1317 | : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88), | |
| 1274 | 1318 | }} |
| 1275 | 1319 | scrollIndicatorInsets={ |
| 1276 | 1320 | Platform.OS === "ios" |
apps/mobile/src/features/home/MaterialFabScrollContext.ts created+10| ... | ... | @@ -0,0 +1,10 @@ |
| 1 | import { createContext, useContext } from "react"; | |
| 2 | import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native"; | |
| 3 | ||
| 4 | export const MaterialFabScrollContext = createContext< | |
| 5 | ((event: NativeSyntheticEvent<NativeScrollEvent>) => void) | undefined | |
| 6 | >(undefined); | |
| 7 | ||
| 8 | export function useMaterialFabScroll() { | |
| 9 | return useContext(MaterialFabScrollContext); | |
| 10 | } |
apps/mobile/src/features/home/MaterialThreadListToolbar.tsx created+178| ... | ... | @@ -0,0 +1,178 @@ |
| 1 | import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react"; | |
| 2 | import { | |
| 3 | BackHandler, | |
| 4 | Keyboard, | |
| 5 | Pressable, | |
| 6 | TextInput, | |
| 7 | View, | |
| 8 | type LayoutChangeEvent, | |
| 9 | } from "react-native"; | |
| 10 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 11 | import type { MenuAction } from "@react-native-menu/menu"; | |
| 12 | ||
| 13 | import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader"; | |
| 14 | import { CompactBrandTitle } from "../../components/CompactBrandTitle"; | |
| 15 | import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; | |
| 16 | import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu"; | |
| 17 | import { ControlPillMenu } from "../../components/ControlPill"; | |
| 18 | import { SymbolView } from "../../components/AppSymbol"; | |
| 19 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | |
| 20 | import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle"; | |
| 21 | import { useWorkspaceState } from "../../state/workspace"; | |
| 22 | import { useMaterialToolbarHeight } from "../../components/useMaterialToolbarHeight"; | |
| 23 | ||
| 24 | /** One toolbar height for the compact list and expanded sidebar, including search. */ | |
| 25 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { updateMaterialFabScroll, type MaterialFabScrollState } from "./material-fab-scroll"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 1 | export 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. */ | |
| 7 | export function updateMaterialFabScroll( | |
| 8 | state: MaterialFabScrollState, | |
| 9 | offset: number, | |
| 10 | maxOffset: number, | |
| 11 | ): MaterialFabScrollState { | |
| 12 | const y = Math.max(0, Math.min(offset, Math.max(0, maxOffset))); | |
| 13 | if (y <= 8) return { anchor: y, expanded: true }; | |
| 14 | const anchor = state.expanded ? Math.min(state.anchor, y) : Math.max(state.anchor, y); | |
| 15 | if (Math.abs(y - anchor) >= 12) return { anchor: y, expanded: !state.expanded }; | |
| 16 | return { anchor, expanded: state.expanded }; | |
| 17 | } |
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+9-6| ... | ... | @@ -22,7 +22,7 @@ import { |
| 22 | 22 | useState, |
| 23 | 23 | type ReactNode, |
| 24 | 24 | } from "react"; |
| 25 | import { useWindowDimensions, View } from "react-native"; | |
| 25 | import { Platform, useWindowDimensions, View } from "react-native"; | |
| 26 | 26 | import Animated, { |
| 27 | 27 | useAnimatedStyle, |
| 28 | 28 | useDerivedValue, |
| ... | ... | @@ -56,7 +56,6 @@ import { |
| 56 | 56 | } from "../keyboard/hardwareKeyboardCommands"; |
| 57 | 57 | import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; |
| 58 | 58 | import { HomeListOptionsProvider } from "../home/home-list-options"; |
| 59 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | |
| 60 | 59 | import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; |
| 61 | 60 | import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; |
| 62 | 61 | import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; |
| ... | ... | @@ -234,7 +233,6 @@ function AdaptiveWorkspaceLayoutContent( |
| 234 | 233 | }, |
| 235 | 234 | ) { |
| 236 | 235 | const projectGroupingMode = props.projectGroupingMode; |
| 237 | const { materialYouStyleLayoutActive } = useAppearancePreferences(); | |
| 238 | 236 | const { width, height } = useWindowDimensions(); |
| 239 | 237 | const pathname = props.pathname; |
| 240 | 238 | const navigation = useNavigation(); |
| ... | ... | @@ -582,7 +580,7 @@ function AdaptiveWorkspaceLayoutContent( |
| 582 | 580 | style={sidebarAnimatedStyle} |
| 583 | 581 | > |
| 584 | 582 | <View className="flex-1" style={{ width: layout.listPaneWidth }}> |
| 585 | <AndroidHomeFabLayout onStartNewTask={handleStartNewTask}> | |
| 583 | <AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}> | |
| 586 | 584 | <ThreadNavigationSidebar |
| 587 | 585 | width={layout.listPaneWidth} |
| 588 | 586 | visible={panes.primarySidebarVisible} |
| ... | ... | @@ -602,7 +600,7 @@ function AdaptiveWorkspaceLayoutContent( |
| 602 | 600 | ) : null} |
| 603 | 601 | <View |
| 604 | 602 | className={ |
| 605 | materialYouStyleLayoutActive | |
| 603 | Platform.OS === "android" | |
| 606 | 604 | ? "flex-1 overflow-hidden bg-header" |
| 607 | 605 | : "flex-1 overflow-hidden bg-screen" |
| 608 | 606 | } |
| ... | ... | @@ -611,7 +609,12 @@ function AdaptiveWorkspaceLayoutContent( |
| 611 | 609 | <View |
| 612 | 610 | collapsable={false} |
| 613 | 611 | style={ |
| 614 | contentSettledWidth !== null ? { flex: 1, width: contentSettledWidth } : { flex: 1 } | |
| 612 | contentSettledWidth !== null | |
| 613 | ? { | |
| 614 | flex: 1, | |
| 615 | width: contentSettledWidth, | |
| 616 | } | |
| 617 | : { flex: 1 } | |
| 615 | 618 | } |
| 616 | 619 | > |
| 617 | 620 | <WorkspaceContentWidthContext |
apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx+55-26| ... | ... | @@ -1,40 +1,69 @@ |
| 1 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | |
| 2 | 1 | import { SymbolView } from "../../components/AppSymbol"; |
| 3 | import { Pressable, View } from "react-native"; | |
| 2 | import { Platform, Pressable, View } from "react-native"; | |
| 4 | 3 | |
| 5 | 4 | import { AppText as Text } from "../../components/AppText"; |
| 5 | import { MaterialNewThreadButton } from "../../components/MaterialNewThreadButton"; | |
| 6 | import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; | |
| 7 | import { EmptyState } from "../../components/EmptyState"; | |
| 6 | 8 | |
| 7 | export function WorkspaceEmptyDetail(props: { readonly onStartNewTask?: () => void }) { | |
| 8 | const { materialYouStyleLayoutActive } = useAppearancePreferences(); | |
| 9 | export function WorkspaceEmptyDetail(props: { | |
| 10 | readonly onStartNewTask?: () => void; | |
| 11 | readonly onAddConnection?: () => void; | |
| 12 | }) { | |
| 9 | 13 | return ( |
| 10 | 14 | <View |
| 11 | 15 | className={ |
| 12 | materialYouStyleLayoutActive | |
| 16 | Platform.OS === "android" | |
| 13 | 17 | ? "flex-1 items-center justify-center px-10" |
| 14 | 18 | : "flex-1 items-center justify-center bg-screen px-10" |
| 15 | 19 | } |
| 16 | 20 | > |
| 17 | <View className="max-w-[360px] items-center gap-3"> | |
| 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 | )} | |
| 38 | 67 | </View> |
| 39 | 68 | ); |
| 40 | 69 | } |
apps/mobile/src/features/layout/workspace-pane-divider.tsx+2-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { useCallback, useMemo, useRef, useState } from "react"; |
| 2 | import { Pressable, StyleSheet, View, type AccessibilityActionEvent } from "react-native"; | |
| 2 | import { Platform, Pressable, StyleSheet, View, type AccessibilityActionEvent } from "react-native"; | |
| 3 | 3 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; |
| 4 | 4 | import { runOnJS } from "react-native-reanimated"; |
| 5 | 5 | import { cn } from "../../lib/cn"; |
| ... | ... | @@ -79,6 +79,7 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { |
| 79 | 79 | className={cn( |
| 80 | 80 | "h-full self-center bg-border opacity-70", |
| 81 | 81 | dragging ? "w-0.5 bg-primary opacity-100" : "w-px", |
| 82 | Platform.OS === "android" && !dragging && "opacity-0", | |
| 82 | 83 | )} |
| 83 | 84 | style={[styles.line, dragging && styles.activeLine]} |
| 84 | 85 | /> |
apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx+18| ... | ... | @@ -2,8 +2,26 @@ import { NativeHeaderToolbar } from "../../native/StackHeader"; |
| 2 | 2 | import type { ReactNode } from "react"; |
| 3 | 3 | import { Platform } from "react-native"; |
| 4 | 4 | |
| 5 | import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader"; | |
| 6 | ||
| 5 | 7 | import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; |
| 6 | 8 | |
| 9 | export 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 | ||
| 7 | 25 | export function WorkspaceSidebarToolbar( |
| 8 | 26 | props: { |
| 9 | 27 | readonly children?: ReactNode; |
apps/mobile/src/features/projects/AddProjectScreen.tsx+71-28| ... | ... | @@ -1,3 +1,7 @@ |
| 1 | import { MaterialListRow } from "../../components/MaterialListRow"; | |
| 2 | import { SettingsScreen } from "../settings/components/SettingsScreen"; | |
| 3 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | |
| 4 | import { MaterialButton } from "../../components/MaterialButton"; | |
| 1 | 5 | import { |
| 2 | 6 | addProjectRemoteSourceLabel, |
| 3 | 7 | addProjectRemoteSourcePathHint, |
| ... | ... | @@ -41,14 +45,13 @@ import { |
| 41 | 45 | import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; |
| 42 | 46 | import { SymbolView } from "../../components/AppSymbol"; |
| 43 | 47 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 44 | import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; | |
| 48 | import { Platform, ActivityIndicator, Alert, Pressable, View } from "react-native"; | |
| 45 | 49 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 46 | 50 | import * as Arr from "effect/Array"; |
| 47 | 51 | import * as Cause from "effect/Cause"; |
| 48 | 52 | import * as Order from "effect/Order"; |
| 49 | 53 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 50 | 54 | import { cn } from "../../lib/cn"; |
| 51 | ||
| 52 | 55 | import { useProjects, useServerConfigs, waitForProject } from "../../state/entities"; |
| 53 | 56 | import { filesystemEnvironment } from "../../state/filesystem"; |
| 54 | 57 | import { projectEnvironment } from "../../state/projects"; |
| ... | ... | @@ -123,13 +126,19 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote |
| 123 | 126 | |
| 124 | 127 | function SectionTitle(props: { readonly children: string }) { |
| 125 | 128 | return ( |
| 126 | <Text className="px-1 text-2xs font-t3-bold tracking-[0.7px] uppercase text-foreground-muted"> | |
| 129 | <Text | |
| 130 | className={ | |
| 131 | Platform.OS === "android" | |
| 132 | ? "px-4 text-sm font-t3-medium text-primary" | |
| 133 | : "px-1 text-2xs font-t3-bold tracking-[0.7px] uppercase text-foreground-muted" | |
| 134 | } | |
| 135 | > | |
| 127 | 136 | {props.children} |
| 128 | 137 | </Text> |
| 129 | 138 | ); |
| 130 | 139 | } |
| 131 | 140 | |
| 132 | function AddProjectShell(props: { readonly children: ReactNode }) { | |
| 141 | function AddProjectShell(props: { readonly children: ReactNode; readonly title: string }) { | |
| 133 | 142 | const insets = useSafeAreaInsets(); |
| 134 | 143 | |
| 135 | 144 | return ( |
| ... | ... | @@ -138,25 +147,35 @@ function AddProjectShell(props: { readonly children: ReactNode }) { |
| 138 | 147 | // scroll-view frame correction mistakes this full-height wrapper for a |
| 139 | 148 | // "header" sibling, coercing the ScrollView to zero height (blank sheet |
| 140 | 149 | // as soon as the sheet re-lays-out, e.g. when the keyboard opens). |
| 141 | <View collapsable={false} className="flex-1 bg-sheet"> | |
| 150 | <SettingsScreen title={props.title}> | |
| 142 | 151 | <ScrollView |
| 143 | 152 | keyboardShouldPersistTaps="handled" |
| 144 | 153 | contentInsetAdjustmentBehavior="automatic" |
| 145 | 154 | contentContainerStyle={{ |
| 146 | paddingHorizontal: 20, | |
| 155 | paddingHorizontal: Platform.OS === "android" ? 16 : 20, | |
| 147 | 156 | paddingTop: 16, |
| 148 | 157 | paddingBottom: Math.max(insets.bottom, 18) + 18, |
| 149 | gap: 10, | |
| 158 | gap: Platform.OS === "android" ? 16 : 10, | |
| 150 | 159 | }} |
| 151 | 160 | > |
| 152 | 161 | {props.children} |
| 153 | 162 | </ScrollView> |
| 154 | </View> | |
| 163 | </SettingsScreen> | |
| 155 | 164 | ); |
| 156 | 165 | } |
| 157 | 166 | |
| 158 | 167 | function ListSection(props: { readonly children: ReactNode }) { |
| 159 | return <View className="overflow-hidden rounded-[24px] bg-card">{props.children}</View>; | |
| 168 | return ( | |
| 169 | <View | |
| 170 | className={ | |
| 171 | Platform.OS === "android" | |
| 172 | ? "overflow-hidden rounded-[28px] bg-card" | |
| 173 | : "overflow-hidden rounded-[24px] bg-card" | |
| 174 | } | |
| 175 | > | |
| 176 | {props.children} | |
| 177 | </View> | |
| 178 | ); | |
| 160 | 179 | } |
| 161 | 180 | |
| 162 | 181 | function ListRow(props: { |
| ... | ... | @@ -169,6 +188,20 @@ function ListRow(props: { |
| 169 | 188 | readonly right?: ReactNode; |
| 170 | 189 | readonly onPress?: () => void; |
| 171 | 190 | }) { |
| 191 | if (Platform.OS === "android") { | |
| 192 | return ( | |
| 193 | <MaterialListRow | |
| 194 | title={props.title} | |
| 195 | subtitle={props.subtitle} | |
| 196 | leading={props.icon} | |
| 197 | trailing={props.right} | |
| 198 | disabled={props.disabled} | |
| 199 | onPress={props.onPress} | |
| 200 | accessibilityRole={props.selected !== undefined ? "radio" : "button"} | |
| 201 | accessibilityState={props.selected !== undefined ? { checked: props.selected } : undefined} | |
| 202 | /> | |
| 203 | ); | |
| 204 | } | |
| 172 | 205 | return ( |
| 173 | 206 | <Pressable |
| 174 | 207 | disabled={props.disabled} |
| ... | ... | @@ -203,7 +236,7 @@ function ListRow(props: { |
| 203 | 236 | <SymbolView |
| 204 | 237 | name="chevron.right" |
| 205 | 238 | size={13} |
| 206 | tintColorClassName={"accent-chevron"} | |
| 239 | tintColorClassName="accent-chevron" | |
| 207 | 240 | type="monochrome" |
| 208 | 241 | /> |
| 209 | 242 | ) : null} |
| ... | ... | @@ -218,6 +251,7 @@ function PrimaryActionButton(props: { |
| 218 | 251 | readonly loading?: boolean; |
| 219 | 252 | readonly onPress: () => void; |
| 220 | 253 | }) { |
| 254 | if (Platform.OS === "android") return <MaterialButton {...props} tone="primary" fullWidth />; | |
| 221 | 255 | return ( |
| 222 | 256 | <Pressable |
| 223 | 257 | disabled={props.disabled} |
| ... | ... | @@ -435,9 +469,18 @@ function SourceControlRow(props: { |
| 435 | 469 | : `Clone ${addProjectRemoteSourceLabel(props.source)} ${props.hint}`; |
| 436 | 470 | const icon = |
| 437 | 471 | props.source === "url" ? ( |
| 438 | <SymbolView name="link" size={17} tintColorClassName={"accent-icon"} type="monochrome" /> | |
| 472 | <SymbolView | |
| 473 | name="link" | |
| 474 | size={Platform.OS === "android" ? 24 : 17} | |
| 475 | tintColorClassName="accent-icon" | |
| 476 | type="monochrome" | |
| 477 | /> | |
| 439 | 478 | ) : ( |
| 440 | <SourceControlIcon kind={props.source} size={18} colorClassName="accent-icon" /> | |
| 479 | <SourceControlIcon | |
| 480 | kind={props.source} | |
| 481 | size={Platform.OS === "android" ? 24 : 18} | |
| 482 | colorClassName="accent-icon" | |
| 483 | /> | |
| 441 | 484 | ); |
| 442 | 485 | |
| 443 | 486 | if (!props.ready) { |
| ... | ... | @@ -482,7 +525,7 @@ export function AddProjectSourceScreen() { |
| 482 | 525 | ); |
| 483 | 526 | |
| 484 | 527 | return ( |
| 485 | <AddProjectShell> | |
| 528 | <AddProjectShell title="Add project"> | |
| 486 | 529 | {selectedEnvironment === null ? <EmptyEnvironmentState /> : null} |
| 487 | 530 | |
| 488 | 531 | {environmentOptions.length > 1 ? ( |
| ... | ... | @@ -495,7 +538,7 @@ export function AddProjectSourceScreen() { |
| 495 | 538 | title={environment.label} |
| 496 | 539 | subtitle={ |
| 497 | 540 | canCreateProjectInEnvironment(environment.connectionState) |
| 498 | ? environment.environmentId | |
| 541 | ? undefined | |
| 499 | 542 | : connectionStatusText({ |
| 500 | 543 | phase: environment.connectionState, |
| 501 | 544 | error: environment.connectionError, |
| ... | ... | @@ -505,7 +548,7 @@ export function AddProjectSourceScreen() { |
| 505 | 548 | icon={ |
| 506 | 549 | <EnvironmentMachineSymbol |
| 507 | 550 | kind={environment.machine} |
| 508 | size={17} | |
| 551 | size={Platform.OS === "android" ? 24 : 17} | |
| 509 | 552 | tintColorClassName="accent-icon" |
| 510 | 553 | /> |
| 511 | 554 | } |
| ... | ... | @@ -516,8 +559,8 @@ export function AddProjectSourceScreen() { |
| 516 | 559 | environment.environmentId === selectedEnvironment?.environmentId ? ( |
| 517 | 560 | <SymbolView |
| 518 | 561 | name="checkmark" |
| 519 | size={14} | |
| 520 | tintColorClassName={"accent-icon"} | |
| 562 | size={Platform.OS === "android" ? 20 : 14} | |
| 563 | tintColorClassName="accent-icon" | |
| 521 | 564 | type="monochrome" |
| 522 | 565 | /> |
| 523 | 566 | ) : null |
| ... | ... | @@ -538,8 +581,8 @@ export function AddProjectSourceScreen() { |
| 538 | 581 | icon={ |
| 539 | 582 | <SymbolView |
| 540 | 583 | name="folder.badge.plus" |
| 541 | size={17} | |
| 542 | tintColorClassName={"accent-icon"} | |
| 584 | size={Platform.OS === "android" ? 24 : 17} | |
| 585 | tintColorClassName="accent-icon" | |
| 543 | 586 | type="monochrome" |
| 544 | 587 | /> |
| 545 | 588 | } |
| ... | ... | @@ -570,7 +613,7 @@ export function AddProjectSourceScreen() { |
| 570 | 613 | )} |
| 571 | 614 | </ListSection> |
| 572 | 615 | {discoveryState.isPending ? ( |
| 573 | <ActivityIndicator colorClassName={"accent-icon-muted"} /> | |
| 616 | <ActivityIndicator colorClassName="accent-icon-muted" /> | |
| 574 | 617 | ) : null} |
| 575 | 618 | </> |
| 576 | 619 | ) : null} |
| ... | ... | @@ -723,7 +766,7 @@ export function AddProjectRepositoryScreen(props: { |
| 723 | 766 | }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]); |
| 724 | 767 | |
| 725 | 768 | return ( |
| 726 | <AddProjectShell> | |
| 769 | <AddProjectShell title={source === "url" ? "Git URL" : addProjectRemoteSourceLabel(source)}> | |
| 727 | 770 | {error ? <ErrorBanner message={error} /> : null} |
| 728 | 771 | {environment ? ( |
| 729 | 772 | <> |
| ... | ... | @@ -800,7 +843,7 @@ function FolderBrowser(props: { |
| 800 | 843 | <ListSection> |
| 801 | 844 | {browseState.isPending && browseState.data === null ? ( |
| 802 | 845 | <View className="items-center py-5"> |
| 803 | <ActivityIndicator colorClassName={"accent-icon-muted"} /> | |
| 846 | <ActivityIndicator colorClassName="accent-icon-muted" /> | |
| 804 | 847 | </View> |
| 805 | 848 | ) : null} |
| 806 | 849 | {browsePath.canBrowseUp ? ( |
| ... | ... | @@ -809,8 +852,8 @@ function FolderBrowser(props: { |
| 809 | 852 | icon={ |
| 810 | 853 | <SymbolView |
| 811 | 854 | 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" | |
| 814 | 857 | type="monochrome" |
| 815 | 858 | /> |
| 816 | 859 | } |
| ... | ... | @@ -832,8 +875,8 @@ function FolderBrowser(props: { |
| 832 | 875 | icon={ |
| 833 | 876 | <SymbolView |
| 834 | 877 | name="folder" |
| 835 | size={17} | |
| 836 | tintColorClassName={"accent-icon-muted"} | |
| 878 | size={Platform.OS === "android" ? 24 : 17} | |
| 879 | tintColorClassName="accent-icon-muted" | |
| 837 | 880 | type="monochrome" |
| 838 | 881 | /> |
| 839 | 882 | } |
| ... | ... | @@ -882,7 +925,7 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st |
| 882 | 925 | }, [createProject, environment, isBrowseNavigating, isSubmitting, pathInput]); |
| 883 | 926 | |
| 884 | 927 | return ( |
| 885 | <AddProjectShell> | |
| 928 | <AddProjectShell title="Local folder"> | |
| 886 | 929 | {error ? <ErrorBanner message={error} /> : null} |
| 887 | 930 | {environment ? ( |
| 888 | 931 | <> |
| ... | ... | @@ -1025,7 +1068,7 @@ export function AddProjectDestinationScreen(props: { |
| 1025 | 1068 | ]); |
| 1026 | 1069 | |
| 1027 | 1070 | return ( |
| 1028 | <AddProjectShell> | |
| 1071 | <AddProjectShell title="Clone destination"> | |
| 1029 | 1072 | {error ? <ErrorBanner message={error} /> : null} |
| 1030 | 1073 | {repositoryTitle ? ( |
| 1031 | 1074 | <View className="rounded-[24px] bg-card px-4 py-3"> |
apps/mobile/src/features/review/ReviewSheet.tsx+250-165| ... | ... | @@ -35,6 +35,8 @@ import { AppText as Text } from "../../components/AppText"; |
| 35 | 35 | import { SymbolView } from "../../components/AppSymbol"; |
| 36 | 36 | import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 37 | 37 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 38 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; | |
| 39 | import { cn } from "../../lib/cn"; | |
| 38 | 40 | import { environmentCatalog } from "../../connection/catalog"; |
| 39 | 41 | import { useEnvironmentPresentation } from "../../state/presentation"; |
| 40 | 42 | import { useAtomCommand } from "../../state/use-atom-command"; |
| ... | ... | @@ -54,7 +56,10 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s |
| 54 | 56 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; |
| 55 | 57 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 56 | 58 | import { vcsEnvironment } from "../../state/vcs"; |
| 57 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; | |
| 59 | import { | |
| 60 | AndroidWorkspaceSidebarButton, | |
| 61 | WorkspaceSidebarToolbar, | |
| 62 | } from "../layout/workspace-sidebar-toolbar"; | |
| 58 | 63 | import { ThreadGitMenu } from "../threads/ThreadGitControls"; |
| 59 | 64 | import { useReviewCacheForThread } from "./reviewState"; |
| 60 | 65 | import { |
| ... | ... | @@ -81,7 +86,12 @@ const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; |
| 81 | 86 | |
| 82 | 87 | const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { |
| 83 | 88 | return ( |
| 84 | <View className="border-b border-warning-border bg-warning px-4 py-3"> | |
| 89 | <View | |
| 90 | className={cn( | |
| 91 | "bg-warning px-4 py-3", | |
| 92 | Platform.OS === "android" ? "m-2 rounded-[20px]" : "border-b border-warning-border", | |
| 93 | )} | |
| 94 | > | |
| 85 | 95 | <Text className="text-xs font-t3-bold uppercase text-warning-foreground">Partial diff</Text> |
| 86 | 96 | <Text className="text-xs leading-normal text-warning-foreground">{props.notice}</Text> |
| 87 | 97 | </View> |
| ... | ... | @@ -103,7 +113,7 @@ function ReviewSelectionActionBar(props: { |
| 103 | 113 | <SymbolView |
| 104 | 114 | name={props.onOpenComment ? "text.bubble" : "line.3.horizontal.decrease.circle"} |
| 105 | 115 | size={16} |
| 106 | tintColorClassName={"accent-primary-foreground"} | |
| 116 | tintColorClassName="accent-primary-foreground" | |
| 107 | 117 | type="monochrome" |
| 108 | 118 | /> |
| 109 | 119 | <Text className="text-base font-t3-bold text-primary-foreground">{props.title}</Text> |
| ... | ... | @@ -143,7 +153,7 @@ function ReviewSelectionActionBar(props: { |
| 143 | 153 | <SymbolView |
| 144 | 154 | name="xmark" |
| 145 | 155 | size={16} |
| 146 | tintColorClassName={"accent-primary-foreground"} | |
| 156 | tintColorClassName="accent-primary-foreground" | |
| 147 | 157 | type="monochrome" |
| 148 | 158 | /> |
| 149 | 159 | </Pressable> |
| ... | ... | @@ -174,9 +184,14 @@ const ReviewFileNavigatorRow = memo(function ReviewFileNavigatorRow(props: { |
| 174 | 184 | accessibilityRole="button" |
| 175 | 185 | accessibilityState={{ selected }} |
| 176 | 186 | 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" | |
| 180 | 195 | } |
| 181 | 196 | onPress={handlePress} |
| 182 | 197 | > |
| ... | ... | @@ -323,16 +338,28 @@ function ReviewFileNavigator({ |
| 323 | 338 | } |
| 324 | 339 | |
| 325 | 340 | 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> | |
| 333 | 360 | </View> |
| 334 | </View> | |
| 335 | {fileList} | |
| 361 | )} | |
| 362 | <MaterialScreenContent insetHorizontal>{fileList}</MaterialScreenContent> | |
| 336 | 363 | </View> |
| 337 | 364 | ); |
| 338 | 365 | } |
| ... | ... | @@ -632,23 +659,21 @@ export function ReviewSheet(props: ReviewSheetProps) { |
| 632 | 659 | parsedDiff.kind === "files" && |
| 633 | 660 | NativeReviewDiffView !== null; |
| 634 | 661 | 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; | |
| 645 | 664 | |
| 646 | 665 | const listHeader = useMemo(() => { |
| 647 | 666 | const children: ReactElement[] = []; |
| 648 | 667 | |
| 649 | 668 | if (error) { |
| 650 | 669 | children.push( |
| 651 | <View key="review-error" className="border-b border-border bg-card px-4 py-3"> | |
| 670 | <View | |
| 671 | key="review-error" | |
| 672 | className={cn( | |
| 673 | "bg-card px-4 py-3", | |
| 674 | Platform.OS === "android" ? "m-2 rounded-[20px]" : "border-b border-border", | |
| 675 | )} | |
| 676 | > | |
| 652 | 677 | <Text className="text-sm font-t3-bold text-foreground">Review unavailable</Text> |
| 653 | 678 | <Text className="text-xs leading-normal text-foreground-muted">{error}</Text> |
| 654 | 679 | </View>, |
| ... | ... | @@ -699,21 +724,35 @@ export function ReviewSheet(props: ReviewSheetProps) { |
| 699 | 724 | {isAndroid ? ( |
| 700 | 725 | <AndroidScreenHeader |
| 701 | 726 | title="Review changes" |
| 727 | leading={<AndroidWorkspaceSidebarButton />} | |
| 728 | hideBottomBorder | |
| 702 | 729 | subtitle={androidHeaderSubtitle || "Select a diff"} |
| 703 | 730 | onBack={handleReturnToThread} |
| 704 | 731 | trailing={ |
| 705 | showSectionToolbar ? ( | |
| 706 | <ControlPillMenu | |
| 707 | actions={androidSectionMenuActions} | |
| 708 | isAnchoredToRight | |
| 709 | onPressAction={handleAndroidSectionMenuAction} | |
| 710 | > | |
| 732 | <> | |
| 733 | {showChangedFilesToggle ? ( | |
| 711 | 734 | <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} | |
| 714 | 741 | /> |
| 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 | </> | |
| 717 | 756 | } |
| 718 | 757 | /> |
| 719 | 758 | ) : null} |
| ... | ... | @@ -805,141 +844,187 @@ export function ReviewSheet(props: ReviewSheetProps) { |
| 805 | 844 | </NativeHeaderToolbar> |
| 806 | 845 | ) : null} |
| 807 | 846 | |
| 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 | } | |
| 818 | 859 | } |
| 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 ? ( | |
| 831 | 865 | <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 | }} | |
| 834 | 870 | > |
| 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> | |
| 864 | 907 | </View> |
| 865 | 908 | </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. | |
| 917 | 953 | </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."} | |
| 930 | 981 | </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> | |
| 943 | 1028 | </> |
| 944 | 1029 | ); |
| 945 | 1030 | } |
apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts+25| ... | ... | @@ -255,6 +255,31 @@ describe("createNativeReviewDiffTheme", () => { |
| 255 | 255 | } |
| 256 | 256 | }); |
| 257 | 257 | |
| 258 | it.each(["light", "dark"] as const)( | |
| 259 | "preserves Material You RGBA hex channels and composites alpha in %s", | |
| 260 | (appearance) => { | |
| 261 | const variables = { | |
| 262 | ...appTheme("material-you", appearance), | |
| 263 | "--color-screen": "#101214FF", | |
| 264 | "--color-sheet": "#20222480", | |
| 265 | "--color-md-code-text": "#E3E2E6FF", | |
| 266 | "--color-foreground-muted": "#C7C5D080", | |
| 267 | "--color-border": "#44464F80", | |
| 268 | "--color-primary": "#A8C7FAFF", | |
| 269 | }; | |
| 270 | const theme = createNativeReviewDiffTheme(appearance, "material-you", variables); | |
| 271 | expect(theme.background).toBe("#181a1c"); | |
| 272 | expect(theme.headerBackground).toBe(theme.background); | |
| 273 | expect(theme.text).toBe("#e3e2e6"); | |
| 274 | expect(theme.mutedText).toBe("#707076"); | |
| 275 | expect(theme.border).toBe("#2e3036"); | |
| 276 | expect(theme.hunkText).toBe("#a8c7fa"); | |
| 277 | for (const color of Object.values(theme)) { | |
| 278 | expect(color).toMatch(/^#[\da-f]{6}$/i); | |
| 279 | } | |
| 280 | }, | |
| 281 | ); | |
| 282 | ||
| 258 | 283 | it("uses the selected app palette for native code surfaces", () => { |
| 259 | 284 | const standard = createNativeReviewDiffTheme("dark", "t3-code", appTheme("t3-code", "dark")); |
| 260 | 285 | const iris = createNativeReviewDiffTheme("dark", "iris", appTheme("iris", "dark")); |
apps/mobile/src/features/review/nativeReviewDiffAdapter.ts+14-6| ... | ... | @@ -19,7 +19,7 @@ import type { ReviewInlineComment } from "./reviewCommentSelection"; |
| 19 | 19 | |
| 20 | 20 | const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4; |
| 21 | 21 | const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45; |
| 22 | const NATIVE_HEX_COLOR = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i; | |
| 22 | const NATIVE_HEX_COLOR = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})([\da-f]{2})?$/i; | |
| 23 | 23 | const NATIVE_RGBA_COLOR = |
| 24 | 24 | /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/; |
| 25 | 25 | |
| ... | ... | @@ -44,15 +44,19 @@ export function buildNativeReviewSnippetRows( |
| 44 | 44 | |
| 45 | 45 | function opaqueNativeHexColor(color: string, background: string): string { |
| 46 | 46 | const hex = NATIVE_HEX_COLOR.exec(color); |
| 47 | if (hex) return color; | |
| 47 | if (hex && !hex[4]) return color; | |
| 48 | 48 | |
| 49 | 49 | const rgba = NATIVE_RGBA_COLOR.exec(color); |
| 50 | 50 | const backgroundHex = NATIVE_HEX_COLOR.exec(background); |
| 51 | if (!rgba || !backgroundHex) return background; | |
| 51 | if ((!hex && !rgba) || !backgroundHex) return background; | |
| 52 | 52 | |
| 53 | const alpha = rgba[4] === undefined ? 1 : Math.min(1, Math.max(0, Number(rgba[4]))); | |
| 53 | const alpha = hex | |
| 54 | ? Number.parseInt(hex[4] ?? "ff", 16) / 255 | |
| 55 | : rgba?.[4] === undefined | |
| 56 | ? 1 | |
| 57 | : Math.min(1, Math.max(0, Number(rgba[4]))); | |
| 54 | 58 | const channels = [1, 2, 3].map((index) => { |
| 55 | const foreground = Number(rgba[index]); | |
| 59 | const foreground = hex ? Number.parseInt(hex[index] ?? "0", 16) : Number(rgba?.[index]); | |
| 56 | 60 | const behind = Number.parseInt(backgroundHex[index] ?? "0", 16); |
| 57 | 61 | return Math.round(foreground * alpha + behind * (1 - alpha)); |
| 58 | 62 | }); |
| ... | ... | @@ -169,7 +173,11 @@ export function createNativeReviewDiffTheme( |
| 169 | 173 | // Swift expects #RRGGBB/#RRGGBBAA while Android expects #RRGGBB/#AARRGGBB. |
| 170 | 174 | // Flatten translucent app tokens onto the code surface so both native |
| 171 | 175 | // implementations receive the one unambiguous shared format. |
| 172 | const background = opaqueNativeHexColor(appTheme["--color-sheet"], appTheme["--color-screen"]); | |
| 176 | const screen = opaqueNativeHexColor( | |
| 177 | appTheme["--color-screen"], | |
| 178 | scheme === "dark" ? "#000000" : "#ffffff", | |
| 179 | ); | |
| 180 | const background = opaqueNativeHexColor(appTheme["--color-sheet"], screen); | |
| 173 | 181 | const nativeColor = (color: string) => opaqueNativeHexColor(color, background); |
| 174 | 182 | |
| 175 | 183 | if (scheme === "dark") { |
apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx+4-13| ... | ... | @@ -1,26 +1,17 @@ |
| 1 | import { useNavigation } from "@react-navigation/native"; | |
| 2 | import { Platform, ScrollView, View } from "react-native"; | |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | |
| 3 | 2 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 4 | 3 | |
| 5 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | |
| 6 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 4 | import { SettingsScreen } from "./components/SettingsScreen"; | |
| 7 | 5 | import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection"; |
| 8 | 6 | import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection"; |
| 9 | 7 | import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection"; |
| 10 | 8 | import { ThemeAppearanceSection } from "./appearance/sections/ThemeAppearanceSection"; |
| 11 | 9 | |
| 12 | 10 | export function SettingsAppearanceRouteScreen() { |
| 13 | const navigation = useNavigation(); | |
| 14 | 11 | const insets = useSafeAreaInsets(); |
| 15 | 12 | |
| 16 | 13 | 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"> | |
| 24 | 15 | <ScrollView |
| 25 | 16 | contentInsetAdjustmentBehavior="automatic" |
| 26 | 17 | showsVerticalScrollIndicator={false} |
| ... | ... | @@ -35,6 +26,6 @@ export function SettingsAppearanceRouteScreen() { |
| 35 | 26 | <TerminalAppearanceSection /> |
| 36 | 27 | <CodeAppearanceSection /> |
| 37 | 28 | </ScrollView> |
| 38 | </View> | |
| 29 | </SettingsScreen> | |
| 39 | 30 | ); |
| 40 | 31 | } |
apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx+9-9| ... | ... | @@ -1,8 +1,9 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | |
| 1 | 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 2 | 3 | import { type EnvironmentMachineKind, resolveEnvironmentMachineKind } from "@t3tools/contracts"; |
| 3 | 4 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 4 | 5 | import { useMemo } from "react"; |
| 5 | import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; | |
| 6 | import { ActivityIndicator, Alert, Pressable, View } from "react-native"; | |
| 6 | 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | 8 | |
| 8 | 9 | import { AppText as Text } from "../../components/AppText"; |
| ... | ... | @@ -16,6 +17,7 @@ import { |
| 16 | 17 | import { useServerConfigs } from "../../state/entities"; |
| 17 | 18 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 18 | 19 | import { SettingsSection } from "./components/SettingsSection"; |
| 20 | import { SettingsScreen } from "./components/SettingsScreen"; | |
| 19 | 21 | |
| 20 | 22 | export function SettingsClientStorageRouteScreen() { |
| 21 | 23 | const insets = useSafeAreaInsets(); |
| ... | ... | @@ -71,7 +73,7 @@ export function SettingsClientStorageRouteScreen() { |
| 71 | 73 | }; |
| 72 | 74 | |
| 73 | 75 | return ( |
| 74 | <View collapsable={false} className="flex-1 bg-sheet"> | |
| 76 | <SettingsScreen title="Client Storage"> | |
| 75 | 77 | <ScrollView |
| 76 | 78 | contentInsetAdjustmentBehavior="automatic" |
| 77 | 79 | contentInset={{ bottom: Math.max(insets.bottom, 18) }} |
| ... | ... | @@ -85,7 +87,7 @@ export function SettingsClientStorageRouteScreen() { |
| 85 | 87 | <SymbolView |
| 86 | 88 | name="exclamationmark.triangle" |
| 87 | 89 | size={28} |
| 88 | tintColorClassName={"accent-danger-foreground"} | |
| 90 | tintColorClassName="accent-danger-foreground" | |
| 89 | 91 | type="monochrome" |
| 90 | 92 | weight="regular" |
| 91 | 93 | /> |
| ... | ... | @@ -123,7 +125,7 @@ export function SettingsClientStorageRouteScreen() { |
| 123 | 125 | <SymbolView |
| 124 | 126 | name="checkmark.circle" |
| 125 | 127 | size={28} |
| 126 | tintColorClassName={"accent-icon"} | |
| 128 | tintColorClassName="accent-icon" | |
| 127 | 129 | type="monochrome" |
| 128 | 130 | weight="regular" |
| 129 | 131 | /> |
| ... | ... | @@ -146,16 +148,14 @@ export function SettingsClientStorageRouteScreen() { |
| 146 | 148 | <SymbolView |
| 147 | 149 | name="trash" |
| 148 | 150 | size={22} |
| 149 | tintColorClassName={"accent-danger-foreground"} | |
| 151 | tintColorClassName="accent-danger-foreground" | |
| 150 | 152 | type="monochrome" |
| 151 | 153 | weight="regular" |
| 152 | 154 | /> |
| 153 | 155 | <Text className="flex-1 text-lg tabular-nums text-danger-foreground"> |
| 154 | 156 | {summary ? `Clear ${formatBytes(summary.payloadBytes)}` : "Clear caches"} |
| 155 | 157 | </Text> |
| 156 | {isClearing ? ( | |
| 157 | <ActivityIndicator colorClassName={"accent-danger-foreground"} /> | |
| 158 | ) : null} | |
| 158 | {isClearing ? <ActivityIndicator colorClassName="accent-danger-foreground" /> : null} | |
| 159 | 159 | </Pressable> |
| 160 | 160 | </SettingsSection> |
| 161 | 161 | <Text className="px-2 text-sm leading-normal text-foreground-muted"> |
| ... | ... | @@ -169,7 +169,7 @@ export function SettingsClientStorageRouteScreen() { |
| 169 | 169 | ) : null} |
| 170 | 170 | </View> |
| 171 | 171 | </ScrollView> |
| 172 | </View> | |
| 172 | </SettingsScreen> | |
| 173 | 173 | ); |
| 174 | 174 | } |
| 175 | 175 |
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+21-27| ... | ... | @@ -1,13 +1,14 @@ |
| 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | |
| 2 | import { NativeHeaderToolbar } from "../../native/StackHeader"; | |
| 2 | 3 | import { useNavigation } from "@react-navigation/native"; |
| 3 | 4 | import { SymbolView } from "../../components/AppSymbol"; |
| 4 | 5 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 5 | 6 | import { useCallback, useState } from "react"; |
| 6 | import { Platform, ScrollView, View } from "react-native"; | |
| 7 | import { Platform, View } from "react-native"; | |
| 7 | 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | 9 | |
| 9 | 10 | import { AppText as Text } from "../../components/AppText"; |
| 10 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | |
| 11 | import { SettingsScreen } from "./components/SettingsScreen"; | |
| 11 | 12 | import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; |
| 12 | 13 | import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; |
| 13 | 14 | import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings"; |
| ... | ... | @@ -79,28 +80,21 @@ export function SettingsEnvironmentsRouteScreen() { |
| 79 | 80 | ); |
| 80 | 81 | |
| 81 | 82 | 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" ? ( | |
| 104 | 98 | <NativeHeaderToolbar placement="right"> |
| 105 | 99 | <NativeHeaderToolbar.Button |
| 106 | 100 | icon="plus" |
| ... | ... | @@ -114,7 +108,7 @@ export function SettingsEnvironmentsRouteScreen() { |
| 114 | 108 | tintColor={headerIconColor} |
| 115 | 109 | /> |
| 116 | 110 | </NativeHeaderToolbar> |
| 117 | )} | |
| 111 | ) : null} | |
| 118 | 112 | <ScrollView |
| 119 | 113 | contentInsetAdjustmentBehavior="automatic" |
| 120 | 114 | showsVerticalScrollIndicator={false} |
| ... | ... | @@ -177,6 +171,6 @@ export function SettingsEnvironmentsRouteScreen() { |
| 177 | 171 | /> |
| 178 | 172 | <GitHubRoutingSettings /> |
| 179 | 173 | </ScrollView> |
| 180 | </View> | |
| 174 | </SettingsScreen> | |
| 181 | 175 | ); |
| 182 | 176 | } |
apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx+13-35| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | |
| 1 | 2 | import { LegendList } from "@legendapp/list/react-native"; |
| 2 | 3 | import { type StaticScreenProps, useNavigation } from "@react-navigation/native"; |
| 3 | 4 | import { |
| ... | ... | @@ -8,18 +9,19 @@ import { |
| 8 | 9 | type ThirdPartyLicenseEntry, |
| 9 | 10 | } from "@t3tools/shared/thirdPartyLicenses"; |
| 10 | 11 | import { useCallback, useMemo, useState } from "react"; |
| 11 | import { Linking, Platform, Pressable, ScrollView, View } from "react-native"; | |
| 12 | import { Linking, Platform, Pressable, View } from "react-native"; | |
| 12 | 13 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 13 | 14 | |
| 14 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | |
| 15 | 15 | import { SymbolView } from "../../components/AppSymbol"; |
| 16 | 16 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 17 | import { SettingsScreen } from "./components/SettingsScreen"; | |
| 17 | 18 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 18 | 19 | import { |
| 19 | 20 | createNativeMailSearchToolbarItem, |
| 20 | 21 | NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, |
| 21 | 22 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, |
| 22 | 23 | } from "../layout/native-mail-search-toolbar"; |
| 24 | ||
| 23 | 25 | import { getMobileThirdPartyLicenses } from "./mobileThirdPartyLicenses"; |
| 24 | 26 | |
| 25 | 27 | function useMobileThirdPartyLicenses() { |
| ... | ... | @@ -97,24 +99,18 @@ export function SettingsOpenSourceLicensesRouteScreen() { |
| 97 | 99 | |
| 98 | 100 | if (!manifest) { |
| 99 | 101 | 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"> | |
| 107 | 103 | <View className="flex-1 items-center justify-center px-6"> |
| 108 | 104 | <Text className="text-center text-base text-foreground-muted"> |
| 109 | 105 | License notices are unavailable in this build. |
| 110 | 106 | </Text> |
| 111 | 107 | </View> |
| 112 | </View> | |
| 108 | </SettingsScreen> | |
| 113 | 109 | ); |
| 114 | 110 | } |
| 115 | 111 | |
| 116 | 112 | return ( |
| 117 | <View collapsable={false} className="flex-1 bg-sheet"> | |
| 113 | <SettingsScreen title="Open source licenses"> | |
| 118 | 114 | {Platform.OS === "ios" ? ( |
| 119 | 115 | <NativeStackScreenOptions |
| 120 | 116 | options={{ |
| ... | ... | @@ -148,12 +144,7 @@ export function SettingsOpenSourceLicensesRouteScreen() { |
| 148 | 144 | <NativeHeaderToolbar.SearchBarSlot /> |
| 149 | 145 | </NativeHeaderToolbar> |
| 150 | 146 | ) : null} |
| 151 | {Platform.OS === "android" ? ( | |
| 152 | <> | |
| 153 | <NativeStackScreenOptions options={{ headerShown: false }} /> | |
| 154 | <AndroidScreenHeader title="Open source licenses" onBack={() => navigation.goBack()} /> | |
| 155 | </> | |
| 156 | ) : null} | |
| 147 | ||
| 157 | 148 | <LegendList |
| 158 | 149 | className="flex-1" |
| 159 | 150 | contentContainerStyle={{ |
| ... | ... | @@ -195,14 +186,13 @@ export function SettingsOpenSourceLicensesRouteScreen() { |
| 195 | 186 | renderItem={renderItem} |
| 196 | 187 | showsVerticalScrollIndicator={false} |
| 197 | 188 | /> |
| 198 | </View> | |
| 189 | </SettingsScreen> | |
| 199 | 190 | ); |
| 200 | 191 | } |
| 201 | 192 | |
| 202 | 193 | type LicenseDetailProps = StaticScreenProps<{ readonly entryKey: string }>; |
| 203 | 194 | |
| 204 | 195 | export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailProps) { |
| 205 | const navigation = useNavigation(); | |
| 206 | 196 | const insets = useSafeAreaInsets(); |
| 207 | 197 | const manifest = useMobileThirdPartyLicenses(); |
| 208 | 198 | const entry = manifest |
| ... | ... | @@ -212,30 +202,18 @@ export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailPro |
| 212 | 202 | |
| 213 | 203 | if (!entry) { |
| 214 | 204 | 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"> | |
| 222 | 206 | <View className="flex-1 items-center justify-center px-6"> |
| 223 | 207 | <Text className="text-center text-base text-foreground-muted"> |
| 224 | 208 | This license notice is unavailable. |
| 225 | 209 | </Text> |
| 226 | 210 | </View> |
| 227 | </View> | |
| 211 | </SettingsScreen> | |
| 228 | 212 | ); |
| 229 | 213 | } |
| 230 | 214 | |
| 231 | 215 | 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"> | |
| 239 | 217 | <ScrollView |
| 240 | 218 | className="flex-1" |
| 241 | 219 | contentInsetAdjustmentBehavior="automatic" |
| ... | ... | @@ -275,6 +253,6 @@ export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailPro |
| 275 | 253 | </Text> |
| 276 | 254 | </View> |
| 277 | 255 | </ScrollView> |
| 278 | </View> | |
| 256 | </SettingsScreen> | |
| 279 | 257 | ); |
| 280 | 258 | } |
apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx+5-13| ... | ... | @@ -1,14 +1,13 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | |
| 1 | 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 2 | import { useNavigation } from "@react-navigation/native"; | |
| 3 | 3 | import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; |
| 4 | 4 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 5 | import { Platform, Pressable, ScrollView, View } from "react-native"; | |
| 5 | import { Pressable, View } from "react-native"; | |
| 6 | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | 7 | |
| 8 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | |
| 9 | 8 | import { AppText as Text } from "../../components/AppText"; |
| 10 | 9 | import { SymbolView } from "../../components/AppSymbol"; |
| 11 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 10 | import { SettingsScreen } from "./components/SettingsScreen"; | |
| 12 | 11 | import { |
| 13 | 12 | mobileProjectGroupingModePatch, |
| 14 | 13 | resolveMobileProjectGroupingSettings, |
| ... | ... | @@ -39,7 +38,6 @@ const GROUPING_OPTIONS: ReadonlyArray<{ |
| 39 | 38 | ]; |
| 40 | 39 | |
| 41 | 40 | export function SettingsProjectGroupingRouteScreen() { |
| 42 | const navigation = useNavigation(); | |
| 43 | 41 | const insets = useSafeAreaInsets(); |
| 44 | 42 | const preferencesResult = useAtomValue(mobilePreferencesAtom); |
| 45 | 43 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); |
| ... | ... | @@ -49,13 +47,7 @@ export function SettingsProjectGroupingRouteScreen() { |
| 49 | 47 | : null; |
| 50 | 48 | |
| 51 | 49 | 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"> | |
| 59 | 51 | <ScrollView |
| 60 | 52 | contentInsetAdjustmentBehavior="automatic" |
| 61 | 53 | showsVerticalScrollIndicator={false} |
| ... | ... | @@ -99,6 +91,6 @@ export function SettingsProjectGroupingRouteScreen() { |
| 99 | 91 | ))} |
| 100 | 92 | </SettingsSection> |
| 101 | 93 | </ScrollView> |
| 102 | </View> | |
| 94 | </SettingsScreen> | |
| 103 | 95 | ); |
| 104 | 96 | } |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+48-58| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { AutoSettleDaysField } from "./components/AutoSettleDaysField"; | |
| 2 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | |
| 1 | 3 | import { useAuth, useUser } from "@clerk/expo"; |
| 2 | 4 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 3 | 5 | import Constants from "expo-constants"; |
| ... | ... | @@ -8,7 +10,7 @@ import { SymbolView } from "../../components/AppSymbol"; |
| 8 | 10 | import * as Effect from "effect/Effect"; |
| 9 | 11 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 10 | 12 | import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; |
| 11 | import { Alert, Linking, Platform, Pressable, ScrollView, View } from "react-native"; | |
| 13 | import { Alert, Linking, Platform, Pressable, View } from "react-native"; | |
| 12 | 14 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 13 | 15 | |
| 14 | 16 | import { |
| ... | ... | @@ -18,8 +20,7 @@ import { |
| 18 | 20 | settlePromise, |
| 19 | 21 | squashAtomCommandFailure, |
| 20 | 22 | } from "@t3tools/client-runtime/state/runtime"; |
| 21 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | |
| 22 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | |
| 23 | import { AppText as Text } from "../../components/AppText"; | |
| 23 | 24 | import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; |
| 24 | 25 | import { |
| 25 | 26 | openAndroidLiveUpdateSettings, |
| ... | ... | @@ -37,15 +38,12 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu |
| 37 | 38 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; |
| 38 | 39 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; |
| 39 | 40 | import { runtime } from "../../lib/runtime"; |
| 41 | import { cn } from "../../lib/cn"; | |
| 40 | 42 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 41 | 43 | import { serverEnvironment } from "../../state/server"; |
| 42 | 44 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 43 | 45 | import { useEnvironments } from "../../state/environments"; |
| 44 | import { | |
| 45 | DEFAULT_SERVER_SETTINGS, | |
| 46 | MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | |
| 47 | MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | |
| 48 | } from "@t3tools/contracts"; | |
| 46 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; | |
| 49 | 47 | import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; |
| 50 | 48 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; |
| 51 | 49 | import { |
| ... | ... | @@ -58,6 +56,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re |
| 58 | 56 | import { SettingsRow } from "./components/SettingsRow"; |
| 59 | 57 | import { SettingsSection } from "./components/SettingsSection"; |
| 60 | 58 | import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; |
| 59 | import { SettingsScreen } from "./components/SettingsScreen"; | |
| 61 | 60 | import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; |
| 62 | 61 | import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; |
| 63 | 62 | |
| ... | ... | @@ -79,17 +78,16 @@ function useDeviceRegistered(): boolean { |
| 79 | 78 | |
| 80 | 79 | export function SettingsRouteScreen() { |
| 81 | 80 | const navigation = useNavigation(); |
| 81 | const content = hasCloudPublicConfig() ? ( | |
| 82 | <ConfiguredSettingsRouteScreen /> | |
| 83 | ) : ( | |
| 84 | <LocalSettingsRouteScreen /> | |
| 85 | ); | |
| 82 | 86 | |
| 83 | 87 | return ( |
| 84 | 88 | <> |
| 85 | 89 | <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" ? ( | |
| 93 | 91 | <NativeStackScreenOptions |
| 94 | 92 | options={{ |
| 95 | 93 | unstable_headerRightItems: |
| ... | ... | @@ -107,8 +105,12 @@ export function SettingsRouteScreen() { |
| 107 | 105 | : undefined, |
| 108 | 106 | }} |
| 109 | 107 | /> |
| 108 | ) : null} | |
| 109 | {Platform.OS === "android" ? ( | |
| 110 | <SettingsScreen title="Settings">{content}</SettingsScreen> | |
| 111 | ) : ( | |
| 112 | content | |
| 110 | 113 | )} |
| 111 | {hasCloudPublicConfig() ? <ConfiguredSettingsRouteScreen /> : <LocalSettingsRouteScreen />} | |
| 112 | 114 | </> |
| 113 | 115 | ); |
| 114 | 116 | } |
| ... | ... | @@ -134,6 +136,7 @@ function LocalSettingsRouteScreen() { |
| 134 | 136 | icon="desktopcomputer" |
| 135 | 137 | label="Environments" |
| 136 | 138 | value={`${environmentCount}`} |
| 139 | valuePosition="trailing" | |
| 137 | 140 | target="SettingsEnvironments" |
| 138 | 141 | /> |
| 139 | 142 | </SettingsSection> |
| ... | ... | @@ -516,6 +519,7 @@ function ConfiguredSettingsRouteScreen() { |
| 516 | 519 | icon="desktopcomputer" |
| 517 | 520 | label="Environments" |
| 518 | 521 | value={`${environmentCount}`} |
| 522 | valuePosition="trailing" | |
| 519 | 523 | target="SettingsEnvironments" |
| 520 | 524 | /> |
| 521 | 525 | <SettingsSwitchRow |
| ... | ... | @@ -616,6 +620,7 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD |
| 616 | 620 | */ |
| 617 | 621 | function AutoSettleSettingsRows() { |
| 618 | 622 | const { environments } = useEnvironments(); |
| 623 | const [pendingWrites, setPendingWrites] = useState(0); | |
| 619 | 624 | const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { |
| 620 | 625 | label: "server settings update", |
| 621 | 626 | reportFailure: true, |
| ... | ... | @@ -625,16 +630,17 @@ function AutoSettleSettingsRows() { |
| 625 | 630 | const reference = syncTargets[0] ?? null; |
| 626 | 631 | const referenceSettings = reference?.serverConfig?.settings ?? null; |
| 627 | 632 | |
| 628 | const [daysDraft, setDaysDraft] = useState<string | null>(null); | |
| 629 | ||
| 630 | 633 | if (reference === null || referenceSettings === null) { |
| 631 | 634 | return null; |
| 632 | 635 | } |
| 633 | 636 | |
| 634 | 637 | 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)); | |
| 638 | 644 | }; |
| 639 | 645 | |
| 640 | 646 | const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync( |
| ... | ... | @@ -647,21 +653,6 @@ function AutoSettleSettingsRows() { |
| 647 | 653 | ); |
| 648 | 654 | |
| 649 | 655 | const afterDays = referenceSettings.sidebarAutoSettleAfterDays; |
| 650 | const commitDays = () => { | |
| 651 | const draft = (daysDraft ?? "").trim(); | |
| 652 | setDaysDraft(null); | |
| 653 | // Whole-string check so "3.5" and "3days" are rejected instead of | |
| 654 | // silently becoming 3 on every eligible sync target. | |
| 655 | const parsed = /^\d+$/.test(draft) ? Number(draft) : Number.NaN; | |
| 656 | if ( | |
| 657 | Number.isInteger(parsed) && | |
| 658 | parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && | |
| 659 | parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && | |
| 660 | parsed !== afterDays | |
| 661 | ) { | |
| 662 | writeToAll({ sidebarAutoSettleAfterDays: parsed }); | |
| 663 | } | |
| 664 | }; | |
| 665 | 656 | |
| 666 | 657 | return ( |
| 667 | 658 | <> |
| ... | ... | @@ -674,28 +665,34 @@ function AutoSettleSettingsRows() { |
| 674 | 665 | <SettingsSwitchRow |
| 675 | 666 | icon="clock" |
| 676 | 667 | label="Auto-settle inactive threads" |
| 677 | subtitle={afterDays === null ? undefined : `After ${afterDays} days without activity`} | |
| 678 | 668 | value={afterDays !== null} |
| 679 | 669 | onValueChange={(value) => |
| 680 | 670 | writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null }) |
| 681 | 671 | } |
| 682 | 672 | /> |
| 683 | 673 | {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 })} | |
| 695 | 692 | /> |
| 696 | 693 | </View> |
| 697 | 694 | ) : null} |
| 698 | {mismatches.length > 0 ? ( | |
| 695 | {pendingWrites === 0 && mismatches.length > 0 ? ( | |
| 699 | 696 | <View className="flex-row items-center gap-4 border-t border-border-subtle p-4"> |
| 700 | 697 | <View className="min-w-0 flex-1"> |
| 701 | 698 | <Text className="text-lg text-foreground">Auto-settle defaults differ</Text> |
| ... | ... | @@ -705,14 +702,7 @@ function AutoSettleSettingsRows() { |
| 705 | 702 | </View> |
| 706 | 703 | <Pressable |
| 707 | 704 | 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)} | |
| 716 | 706 | className="rounded-full bg-subtle px-4 py-2 active:opacity-70" |
| 717 | 707 | > |
| 718 | 708 | <Text className="text-base font-t3-medium text-foreground"> |
| ... | ... | @@ -831,7 +821,7 @@ function AppSettingsSection() { |
| 831 | 821 | <SymbolView |
| 832 | 822 | name="info.circle" |
| 833 | 823 | size={22} |
| 834 | tintColorClassName={"accent-icon"} | |
| 824 | tintColorClassName="accent-icon" | |
| 835 | 825 | type="monochrome" |
| 836 | 826 | weight="regular" |
| 837 | 827 | /> |
apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx+1-19| ... | ... | @@ -51,9 +51,6 @@ interface AppearancePreferencesContextValue { |
| 51 | 51 | readonly themeIds: MobileThemeIds; |
| 52 | 52 | readonly themeMode: MobileThemeMode; |
| 53 | 53 | readonly themeAppearance: MobileThemeAppearance; |
| 54 | readonly materialYouStyleLayoutEnabled: boolean; | |
| 55 | readonly materialYouStyleLayoutActive: boolean; | |
| 56 | readonly setMaterialYouStyleLayoutEnabled: (value: boolean) => void; | |
| 57 | 54 | readonly systemColorsAvailable: boolean; |
| 58 | 55 | readonly systemColorsActive: boolean; |
| 59 | 56 | readonly themeVariables: MobileThemeVariables; |
| ... | ... | @@ -97,8 +94,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN |
| 97 | 94 | [resolvedThemeIds.dark, resolvedThemeIds.light], |
| 98 | 95 | ); |
| 99 | 96 | const themeId = themeIds[themeAppearance]; |
| 100 | const materialYouStyleLayoutEnabled = storedPreferences?.materialYouStyleLayoutEnabled ?? false; | |
| 101 | const materialYouStyleLayoutActive = Platform.OS === "android" && materialYouStyleLayoutEnabled; | |
| 102 | 97 | const systemColorsActive = themeId === "material-you" && isSystemColorsAvailable; |
| 103 | 98 | const [systemColorPalettes, setSystemColorPalettes] = useState(readSystemColorPalettes); |
| 104 | 99 | useEffect(() => { |
| ... | ... | @@ -120,7 +115,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN |
| 120 | 115 | }, []); |
| 121 | 116 | const themeVariablesByAppearance = useMemo(() => { |
| 122 | 117 | const resolve = (appearance: MobileThemeAppearance) => { |
| 123 | const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance); | |
| 118 | const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance, Platform.OS); | |
| 124 | 119 | return themeIds[appearance] === "material-you" && systemColorPalettes |
| 125 | 120 | ? materialYouPaletteToMobileThemeVariables( |
| 126 | 121 | systemColorPalettes[appearance], |
| ... | ... | @@ -247,13 +242,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN |
| 247 | 242 | [runtimeState, syncThemeRuntime, updateThemePreferences], |
| 248 | 243 | ); |
| 249 | 244 | |
| 250 | const setMaterialYouStyleLayoutEnabled = useCallback( | |
| 251 | (value: boolean) => { | |
| 252 | updatePreferences({ materialYouStyleLayoutEnabled: value }); | |
| 253 | }, | |
| 254 | [updatePreferences], | |
| 255 | ); | |
| 256 | ||
| 257 | 245 | const setBaseFontSize = useCallback( |
| 258 | 246 | (value: number) => { |
| 259 | 247 | const current = appliedRuntimeStateRef.current ?? runtimeState; |
| ... | ... | @@ -293,9 +281,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN |
| 293 | 281 | themeAppearance, |
| 294 | 282 | systemColorsAvailable: isSystemColorsAvailable, |
| 295 | 283 | systemColorsActive, |
| 296 | materialYouStyleLayoutEnabled, | |
| 297 | materialYouStyleLayoutActive, | |
| 298 | setMaterialYouStyleLayoutEnabled, | |
| 299 | 284 | themeVariables, |
| 300 | 285 | themeVariablesByAppearance, |
| 301 | 286 | systemColorPalettes, |
| ... | ... | @@ -315,9 +300,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN |
| 315 | 300 | themeMode, |
| 316 | 301 | themeAppearance, |
| 317 | 302 | systemColorsActive, |
| 318 | materialYouStyleLayoutEnabled, | |
| 319 | materialYouStyleLayoutActive, | |
| 320 | setMaterialYouStyleLayoutEnabled, | |
| 321 | 303 | themeVariables, |
| 322 | 304 | themeVariablesByAppearance, |
| 323 | 305 | systemColorPalettes, |
apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.android.tsx created+81| ... | ... | @@ -0,0 +1,81 @@ |
| 1 | import { Host, Slider } from "@expo/ui/jetpack-compose"; | |
| 2 | import { fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; | |
| 3 | import * as Haptics from "expo-haptics"; | |
| 4 | import { useRef, type ComponentProps } from "react"; | |
| 5 | import { View } from "react-native"; | |
| 6 | ||
| 7 | import { AppText as Text } from "../../../../components/AppText"; | |
| 8 | import { SymbolView } from "../../../../components/AppSymbol"; | |
| 9 | import { useAppearancePreferences } from "../AppearancePreferencesProvider"; | |
| 10 | import type { FontSizeSliderRow as SharedFontSizeSliderRow } from "./FontSizeSliderRow.shared"; | |
| 11 | ||
| 12 | export 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 @@ |
| 1 | import * as Haptics from "expo-haptics"; | |
| 2 | import { SymbolView } from "../../../../components/AppSymbol"; | |
| 3 | import { useCallback, useEffect, useMemo, useRef } from "react"; | |
| 4 | import { View, type AccessibilityActionEvent } from "react-native"; | |
| 5 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | |
| 6 | import Animated, { | |
| 7 | runOnJS, | |
| 8 | useAnimatedStyle, | |
| 9 | useSharedValue, | |
| 10 | withTiming, | |
| 11 | } from "react-native-reanimated"; | |
| 12 | import type { ComponentProps } from "react"; | |
| 13 | ||
| 14 | import { AppText as Text } from "../../../../components/AppText"; | |
| 15 | ||
| 16 | type SymbolName = ComponentProps<typeof SymbolView>["name"]; | |
| 17 | ||
| 18 | const THUMB_SIZE = 26; | |
| 19 | const TRACK_HEIGHT = 4; | |
| 20 | const SNAP_ANIMATION = { duration: 120 } as const; | |
| 21 | ||
| 22 | function clampFraction(value: number): number { | |
| 23 | "worklet"; | |
| 24 | return Math.min(1, Math.max(0, value)); | |
| 25 | } | |
| 26 | ||
| 27 | export function FontSizeSliderRow(props: { | |
| 28 | readonly disabled?: boolean; | |
| 29 | readonly icon: SymbolName; | |
| 30 | readonly label: string; | |
| 31 | readonly valueLabel: string; | |
| 32 | readonly min: number; | |
| 33 | readonly max: number; | |
| 34 | readonly step: number; | |
| 35 | readonly value: number; | |
| 36 | readonly onChange: (value: number) => void; | |
| 37 | }) { | |
| 38 | const latest = useRef(props); | |
| 39 | latest.current = props; | |
| 40 | ||
| 41 | const { min, max, step, value, disabled } = props; | |
| 42 | const fraction = (value - min) / (max - min); | |
| 43 | ||
| 44 | const progress = useSharedValue(clampFraction(fraction)); | |
| 45 | const trackWidth = useSharedValue(0); | |
| 46 | const dragging = useSharedValue(false); | |
| 47 | ||
| 48 | useEffect(() => { | |
| 49 | if (!dragging.value) { | |
| 50 | progress.value = withTiming(clampFraction(fraction), SNAP_ANIMATION); | |
| 51 | } | |
| 52 | }, [dragging, fraction, progress]); | |
| 53 | ||
| 54 | const commit = useCallback((next: number) => { | |
| 55 | const current = latest.current; | |
| 56 | if (current.disabled || next === current.value) { | |
| 57 | return; | |
| 58 | } | |
| 59 | Haptics.selectionAsync().catch(() => undefined); | |
| 60 | current.onChange(next); | |
| 61 | }, []); | |
| 62 | ||
| 63 | const gesture = useMemo(() => { | |
| 64 | const snapValue = (raw: number): number => { | |
| 65 | "worklet"; | |
| 66 | const stepped = Math.round((raw - min) / step) * step + min; | |
| 67 | return Math.min(max, Math.max(min, stepped)); | |
| 68 | }; | |
| 69 | const fractionAt = (x: number): number => { | |
| 70 | "worklet"; | |
| 71 | const usable = trackWidth.value - THUMB_SIZE; | |
| 72 | if (usable <= 0) { | |
| 73 | return 0; | |
| 74 | } | |
| 75 | return clampFraction((x - THUMB_SIZE / 2) / usable); | |
| 76 | }; | |
| 77 | const valueAtFraction = (f: number): number => { | |
| 78 | "worklet"; | |
| 79 | return snapValue(min + f * (max - min)); | |
| 80 | }; | |
| 81 | const fractionOfValue = (v: number): number => { | |
| 82 | "worklet"; | |
| 83 | return clampFraction((v - min) / (max - min)); | |
| 84 | }; | |
| 85 | ||
| 86 | const pan = Gesture.Pan() | |
| 87 | .enabled(!disabled) | |
| 88 | .activeOffsetX([-8, 8]) | |
| 89 | .failOffsetY([-12, 12]) | |
| 90 | .onUpdate((event) => { | |
| 91 | dragging.value = true; | |
| 92 | const f = fractionAt(event.x); | |
| 93 | progress.value = f; | |
| 94 | }) | |
| 95 | .onFinalize((_event, success) => { | |
| 96 | if (!dragging.value) { | |
| 97 | return; | |
| 98 | } | |
| 99 | dragging.value = false; | |
| 100 | if (!success) { | |
| 101 | progress.value = withTiming(fractionOfValue(value), SNAP_ANIMATION); | |
| 102 | return; | |
| 103 | } | |
| 104 | const next = valueAtFraction(progress.value); | |
| 105 | progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION); | |
| 106 | runOnJS(commit)(next); | |
| 107 | }); | |
| 108 | ||
| 109 | const tap = Gesture.Tap() | |
| 110 | .enabled(!disabled) | |
| 111 | .onEnd((event) => { | |
| 112 | const next = valueAtFraction(fractionAt(event.x)); | |
| 113 | progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION); | |
| 114 | runOnJS(commit)(next); | |
| 115 | }); | |
| 116 | ||
| 117 | return Gesture.Race(pan, tap); | |
| 118 | }, [commit, disabled, dragging, max, min, progress, step, trackWidth, value]); | |
| 119 | ||
| 120 | const fillStyle = useAnimatedStyle(() => ({ | |
| 121 | width: THUMB_SIZE / 2 + progress.value * Math.max(0, trackWidth.value - THUMB_SIZE), | |
| 122 | })); | |
| 123 | const thumbStyle = useAnimatedStyle(() => ({ | |
| 124 | transform: [{ translateX: progress.value * Math.max(0, trackWidth.value - THUMB_SIZE) }], | |
| 125 | })); | |
| 126 | ||
| 127 | const handleAccessibilityAction = (event: AccessibilityActionEvent) => { | |
| 128 | if (event.nativeEvent.actionName === "increment") { | |
| 129 | commit(Math.min(max, value + step)); | |
| 130 | } else if (event.nativeEvent.actionName === "decrement") { | |
| 131 | commit(Math.max(min, value - step)); | |
| 132 | } | |
| 133 | }; | |
| 134 | ||
| 135 | return ( | |
| 136 | <View className={disabled ? "gap-1 p-4 opacity-[0.45]" : "gap-1 p-4"}> | |
| 137 | <View className="flex-row items-center gap-4"> | |
| 138 | <SymbolView | |
| 139 | name={props.icon} | |
| 140 | size={22} | |
| 141 | tintColorClassName={"accent-icon"} | |
| 142 | type="monochrome" | |
| 143 | weight="regular" | |
| 144 | /> | |
| 145 | <Text className="flex-1 text-lg text-foreground">{props.label}</Text> | |
| 146 | <Text className="text-base font-t3-medium text-foreground-muted">{props.valueLabel}</Text> | |
| 147 | </View> | |
| 148 | <View className="flex-row items-center gap-3"> | |
| 149 | <SymbolView | |
| 150 | name="textformat.size.smaller" | |
| 151 | size={15} | |
| 152 | tintColorClassName={"accent-icon-muted"} | |
| 153 | type="monochrome" | |
| 154 | weight="regular" | |
| 155 | /> | |
| 156 | <GestureDetector gesture={gesture}> | |
| 157 | <View | |
| 158 | accessible | |
| 159 | accessibilityActions={[ | |
| 160 | { name: "increment", label: `Increase ${props.label}` }, | |
| 161 | { name: "decrement", label: `Decrease ${props.label}` }, | |
| 162 | ]} | |
| 163 | accessibilityLabel={props.label} | |
| 164 | accessibilityRole="adjustable" | |
| 165 | accessibilityState={{ disabled: Boolean(disabled) }} | |
| 166 | accessibilityValue={{ min, max, now: value, text: props.valueLabel }} | |
| 167 | className="h-11 flex-1 justify-center" | |
| 168 | onAccessibilityAction={handleAccessibilityAction} | |
| 169 | onLayout={(event) => { | |
| 170 | trackWidth.value = event.nativeEvent.layout.width; | |
| 171 | }} | |
| 172 | > | |
| 173 | <View | |
| 174 | className="w-full rounded-full bg-secondary-border" | |
| 175 | style={{ height: TRACK_HEIGHT }} | |
| 176 | > | |
| 177 | <Animated.View | |
| 178 | className="absolute inset-y-0 left-0 rounded-full bg-primary" | |
| 179 | style={fillStyle} | |
| 180 | /> | |
| 181 | </View> | |
| 182 | <Animated.View | |
| 183 | className="absolute left-0 rounded-full border-border bg-primary-foreground" | |
| 184 | style={[ | |
| 185 | { | |
| 186 | borderWidth: 1, | |
| 187 | height: THUMB_SIZE, | |
| 188 | marginTop: -THUMB_SIZE / 2, | |
| 189 | shadowColor: "#000000", | |
| 190 | shadowOffset: { height: 2, width: 0 }, | |
| 191 | shadowOpacity: 0.18, | |
| 192 | shadowRadius: 3, | |
| 193 | top: "50%", | |
| 194 | width: THUMB_SIZE, | |
| 195 | }, | |
| 196 | thumbStyle, | |
| 197 | ]} | |
| 198 | /> | |
| 199 | </View> | |
| 200 | </GestureDetector> | |
| 201 | <SymbolView | |
| 202 | name="textformat.size.larger" | |
| 203 | size={22} | |
| 204 | tintColorClassName={"accent-icon-muted"} | |
| 205 | type="monochrome" | |
| 206 | weight="regular" | |
| 207 | /> | |
| 208 | </View> | |
| 209 | </View> | |
| 210 | ); | |
| 211 | } |
apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx+1-210| ... | ... | @@ -1,210 +1 @@ |
| 1 | import * as Haptics from "expo-haptics"; | |
| 2 | import { SymbolView } from "../../../../components/AppSymbol"; | |
| 3 | import { useCallback, useEffect, useMemo, useRef } from "react"; | |
| 4 | import { View, type AccessibilityActionEvent } from "react-native"; | |
| 5 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | |
| 6 | import Animated, { | |
| 7 | runOnJS, | |
| 8 | useAnimatedStyle, | |
| 9 | useSharedValue, | |
| 10 | withTiming, | |
| 11 | } from "react-native-reanimated"; | |
| 12 | import type { ComponentProps } from "react"; | |
| 13 | ||
| 14 | import { AppText as Text } from "../../../../components/AppText"; | |
| 15 | ||
| 16 | type SymbolName = ComponentProps<typeof SymbolView>["name"]; | |
| 17 | ||
| 18 | const THUMB_SIZE = 26; | |
| 19 | const TRACK_HEIGHT = 4; | |
| 20 | const SNAP_ANIMATION = { duration: 120 } as const; | |
| 21 | ||
| 22 | function clampFraction(value: number): number { | |
| 23 | "worklet"; | |
| 24 | return Math.min(1, Math.max(0, value)); | |
| 25 | } | |
| 26 | ||
| 27 | export 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 | } | |
| 1 | export { FontSizeSliderRow } from "./FontSizeSliderRow.shared"; |
apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx+1-18| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { memo, useId } from "react"; |
| 2 | import { Platform, Pressable, View } from "react-native"; | |
| 2 | import { Pressable, View } from "react-native"; | |
| 3 | 3 | import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; |
| 4 | 4 | import { ScopedTheme, ScopedVariables } from "uniwind"; |
| 5 | 5 | |
| ... | ... | @@ -19,9 +19,6 @@ import { getMobileUniwindThemeName } from "../../../../lib/mobileThemeRuntime"; |
| 19 | 19 | import { cn } from "../../../../lib/cn"; |
| 20 | 20 | import { useAppearancePreferences } from "../AppearancePreferencesProvider"; |
| 21 | 21 | |
| 22 | import { SettingsSection } from "../../components/SettingsSection"; | |
| 23 | import { SettingsSwitchRow } from "../../components/SettingsSwitchRow"; | |
| 24 | ||
| 25 | 22 | const APPEARANCE_MODES: ReadonlyArray<{ |
| 26 | 23 | readonly id: MobileThemeMode; |
| 27 | 24 | readonly label: string; |
| ... | ... | @@ -293,25 +290,11 @@ export function ThemeAppearanceSection() { |
| 293 | 290 | setThemeMode, |
| 294 | 291 | themeIds, |
| 295 | 292 | themeMode, |
| 296 | materialYouStyleLayoutEnabled, | |
| 297 | setMaterialYouStyleLayoutEnabled, | |
| 298 | 293 | systemColorsAvailable, |
| 299 | 294 | } = useAppearancePreferences(); |
| 300 | 295 | |
| 301 | 296 | return ( |
| 302 | 297 | <View className="gap-6"> |
| 303 | {Platform.OS === "android" ? ( | |
| 304 | <SettingsSection card title="Android"> | |
| 305 | <SettingsSwitchRow | |
| 306 | disabled={!isReady} | |
| 307 | icon="square.grid.2x2" | |
| 308 | label="Material You Layout" | |
| 309 | onValueChange={setMaterialYouStyleLayoutEnabled} | |
| 310 | subtitle="Use Material You surfaces, shapes, and component styling." | |
| 311 | value={materialYouStyleLayoutEnabled} | |
| 312 | /> | |
| 313 | </SettingsSection> | |
| 314 | ) : null} | |
| 315 | 298 | <View className="gap-2"> |
| 316 | 299 | <SectionLabel>Color scheme</SectionLabel> |
| 317 | 300 | <View accessibilityRole="radiogroup" className="flex-row gap-2"> |
apps/mobile/src/features/settings/components/AutoSettleDaysField.android.tsx created+50| ... | ... | @@ -0,0 +1,50 @@ |
| 1 | import { | |
| 2 | MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | |
| 3 | MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | |
| 4 | } from "@t3tools/contracts"; | |
| 5 | import { View } from "react-native"; | |
| 6 | ||
| 7 | import { AppText } from "../../../components/AppText"; | |
| 8 | import { MaterialIconButton } from "../../../components/MaterialIconButton"; | |
| 9 | import type { AutoSettleDaysFieldProps } from "./AutoSettleDaysField"; | |
| 10 | ||
| 11 | export 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 @@ |
| 1 | import { Button, Host, HStack, Picker, Popover, Text, VStack } from "@expo/ui/swift-ui"; | |
| 2 | import { | |
| 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"; | |
| 13 | import { | |
| 14 | MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | |
| 15 | MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | |
| 16 | } from "@t3tools/contracts"; | |
| 17 | import { useState } from "react"; | |
| 18 | ||
| 19 | import { useAppearancePreferences } from "../appearance/AppearancePreferencesProvider"; | |
| 20 | import type { AutoSettleDaysFieldProps } from "./AutoSettleDaysField"; | |
| 21 | ||
| 22 | const 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 | ||
| 27 | export 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 @@ |
| 1 | import { useState } from "react"; | |
| 2 | import { | |
| 3 | MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | |
| 4 | MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | ||
| 7 | import { AppTextInput } from "../../../components/AppText"; | |
| 8 | ||
| 9 | export interface AutoSettleDaysFieldProps { | |
| 10 | readonly value: number; | |
| 11 | readonly onValueChange: (value: number) => void; | |
| 12 | } | |
| 13 | ||
| 14 | export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { | |
| 15 | const [draft, setDraft] = useState<string | null>(null); | |
| 16 | const commit = () => { | |
| 17 | const text = (draft ?? "").trim(); | |
| 18 | setDraft(null); | |
| 19 | // Validate the whole input; decimals and trailing text must not become whole days. | |
| 20 | const parsed = /^\d+$/.test(text) ? Number(text) : Number.NaN; | |
| 21 | if ( | |
| 22 | Number.isInteger(parsed) && | |
| 23 | parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && | |
| 24 | parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && | |
| 25 | parsed !== props.value | |
| 26 | ) { | |
| 27 | props.onValueChange(parsed); | |
| 28 | } | |
| 29 | }; | |
| 30 | return ( | |
| 31 | <AppTextInput | |
| 32 | className="min-h-10 w-20 rounded-xl px-3 py-2 text-center text-base" | |
| 33 | keyboardType="number-pad" | |
| 34 | returnKeyType="done" | |
| 35 | value={draft ?? String(props.value)} | |
| 36 | onChangeText={setDraft} | |
| 37 | onBlur={commit} | |
| 38 | onSubmitEditing={commit} | |
| 39 | accessibilityLabel="Days before auto-settle" | |
| 40 | /> | |
| 41 | ); | |
| 42 | } |
apps/mobile/src/features/settings/components/SettingsRow.tsx+65-26| ... | ... | @@ -1,11 +1,12 @@ |
| 1 | import { MaterialListRow } from "../../../components/MaterialListRow"; | |
| 1 | 2 | import { useNavigation } from "@react-navigation/native"; |
| 2 | 3 | import type { ComponentProps } from "react"; |
| 3 | import { Pressable, View } from "react-native"; | |
| 4 | import { Platform, Pressable, View } from "react-native"; | |
| 4 | 5 | |
| 5 | 6 | import { SymbolView } from "../../../components/AppSymbol"; |
| 6 | ||
| 7 | 7 | import { AppText as Text } from "../../../components/AppText"; |
| 8 | 8 | import type { SettingsLegalDocumentTarget, SettingsSheetTarget } from "./settings-sheet-targets"; |
| 9 | import { cn } from "../../../lib/cn"; | |
| 9 | 10 | |
| 10 | 11 | type SymbolName = ComponentProps<typeof SymbolView>["name"]; |
| 11 | 12 | |
| ... | ... | @@ -14,44 +15,77 @@ export function SettingsRow(props: { |
| 14 | 15 | readonly icon: SymbolName; |
| 15 | 16 | readonly label: string; |
| 16 | 17 | readonly value?: string; |
| 18 | readonly valuePosition?: "below" | "trailing"; | |
| 17 | 19 | readonly target?: SettingsSheetTarget; |
| 18 | 20 | readonly fullScreenTarget?: SettingsLegalDocumentTarget; |
| 19 | 21 | readonly onPress?: () => void; |
| 20 | 22 | }) { |
| 21 | 23 | const navigation = useNavigation(); |
| 24 | if (Platform.OS === "android") { | |
| 25 | return ( | |
| 26 | <MaterialListRow | |
| 27 | title={props.label} | |
| 28 | subtitle={props.valuePosition === "trailing" ? undefined : props.value} | |
| 29 | accessibilityLabel={[props.label, props.value].filter(Boolean).join(", ")} | |
| 30 | trailing={ | |
| 31 | props.valuePosition === "trailing" && props.value ? ( | |
| 32 | <View className="flex-row items-center gap-3"> | |
| 33 | <Text className="text-sm text-foreground-muted">{props.value}</Text> | |
| 34 | <SymbolView name="chevron.right" size={16} tintColorClassName="accent-chevron" /> | |
| 35 | </View> | |
| 36 | ) : undefined | |
| 37 | } | |
| 38 | disabled={props.disabled} | |
| 39 | leading={ | |
| 40 | <SymbolView | |
| 41 | name={props.icon} | |
| 42 | size={24} | |
| 43 | tintColorClassName="accent-icon" | |
| 44 | type="monochrome" | |
| 45 | weight="regular" | |
| 46 | /> | |
| 47 | } | |
| 48 | onPress={() => { | |
| 49 | if (props.target) | |
| 50 | navigation.navigate("SettingsSheet", { | |
| 51 | screen: "SettingsContent", | |
| 52 | params: { screen: props.target }, | |
| 53 | }); | |
| 54 | else if (props.fullScreenTarget) navigation.navigate(props.fullScreenTarget); | |
| 55 | else props.onPress?.(); | |
| 56 | }} | |
| 57 | /> | |
| 58 | ); | |
| 59 | } | |
| 22 | 60 | 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]")}> | |
| 30 | 62 | <SymbolView |
| 31 | 63 | name={props.icon} |
| 32 | 64 | size={22} |
| 33 | tintColorClassName={"accent-icon"} | |
| 65 | tintColorClassName="accent-icon" | |
| 34 | 66 | type="monochrome" |
| 35 | 67 | weight="regular" |
| 36 | 68 | /> |
| 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 | </> | |
| 51 | 85 | <SymbolView |
| 52 | 86 | name="chevron.right" |
| 53 | 87 | size={16} |
| 54 | tintColorClassName={"accent-chevron"} | |
| 88 | tintColorClassName="accent-chevron" | |
| 55 | 89 | type="monochrome" |
| 56 | 90 | weight="semibold" |
| 57 | 91 | /> |
| ... | ... | @@ -92,7 +126,12 @@ export function SettingsRow(props: { |
| 92 | 126 | } |
| 93 | 127 | |
| 94 | 128 | return ( |
| 95 | <Pressable accessibilityRole="button" disabled={props.disabled} onPress={props.onPress}> | |
| 129 | <Pressable | |
| 130 | accessibilityLabel={props.label} | |
| 131 | accessibilityRole="button" | |
| 132 | disabled={props.disabled} | |
| 133 | onPress={props.onPress} | |
| 134 | > | |
| 96 | 135 | {content} |
| 97 | 136 | </Pressable> |
| 98 | 137 | ); |
apps/mobile/src/features/settings/components/SettingsScreen.tsx created+43| ... | ... | @@ -0,0 +1,43 @@ |
| 1 | import { useNavigation } from "@react-navigation/native"; | |
| 2 | import type { ComponentProps, ReactNode } from "react"; | |
| 3 | import { Platform, View } from "react-native"; | |
| 4 | ||
| 5 | import { AndroidScreenHeader } from "../../../components/AndroidScreenHeader"; | |
| 6 | import { MaterialScreenContent as SettingsScreenContent } from "../../../components/MaterialScreenContent"; | |
| 7 | import { NativeStackScreenOptions } from "../../../native/StackHeader"; | |
| 8 | import { AndroidWorkspaceSidebarButton } from "../../layout/workspace-sidebar-toolbar"; | |
| 9 | ||
| 10 | export { SettingsScreenContent }; | |
| 11 | ||
| 12 | export function SettingsScreen( | |
| 13 | props: Pick<ComponentProps<typeof AndroidScreenHeader>, "title" | "actions" | "trailing"> & { | |
| 14 | readonly children: ReactNode; | |
| 15 | /** A native form sheet already owns its rounded outer frame. */ | |
| 16 | readonly formSheet?: boolean; | |
| 17 | }, | |
| 18 | ) { | |
| 19 | const navigation = useNavigation(); | |
| 20 | ||
| 21 | return ( | |
| 22 | <View collapsable={false} className="flex-1 bg-sheet"> | |
| 23 | {Platform.OS === "android" ? ( | |
| 24 | <> | |
| 25 | <NativeStackScreenOptions options={{ headerShown: false }} /> | |
| 26 | <AndroidScreenHeader | |
| 27 | title={props.title} | |
| 28 | leading={props.formSheet ? undefined : <AndroidWorkspaceSidebarButton />} | |
| 29 | actions={props.actions} | |
| 30 | trailing={props.trailing} | |
| 31 | onBack={() => navigation.goBack()} | |
| 32 | hideBottomBorder={!props.formSheet} | |
| 33 | /> | |
| 34 | </> | |
| 35 | ) : null} | |
| 36 | {props.formSheet ? ( | |
| 37 | props.children | |
| 38 | ) : ( | |
| 39 | <SettingsScreenContent>{props.children}</SettingsScreenContent> | |
| 40 | )} | |
| 41 | </View> | |
| 42 | ); | |
| 43 | } |
apps/mobile/src/features/settings/components/SettingsSection.tsx+15-5| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import type { ReactNode } from "react"; |
| 2 | import { View } from "react-native"; | |
| 2 | import { Platform, View } from "react-native"; | |
| 3 | 3 | |
| 4 | 4 | import { AppText as Text } from "../../../components/AppText"; |
| 5 | 5 | |
| ... | ... | @@ -12,13 +12,23 @@ export function SettingsSection(props: { |
| 12 | 12 | return ( |
| 13 | 13 | <View className="gap-2"> |
| 14 | 14 | {props.title ? ( |
| 15 | <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{props.title}</Text> | |
| 15 | <Text | |
| 16 | className={ | |
| 17 | Platform.OS === "android" | |
| 18 | ? "px-4 text-sm font-t3-medium text-primary" | |
| 19 | : "px-2 text-sm font-t3-medium text-foreground-muted" | |
| 20 | } | |
| 21 | > | |
| 22 | {props.title} | |
| 23 | </Text> | |
| 16 | 24 | ) : null} |
| 17 | 25 | <View |
| 18 | 26 | 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" | |
| 22 | 32 | } |
| 23 | 33 | > |
| 24 | 34 | {props.children} |
apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx+18-10| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | import { cn } from "../../../lib/cn"; | |
| 1 | 2 | import type { ComponentProps } from "react"; |
| 2 | import { View } from "react-native"; | |
| 3 | import { Platform, View } from "react-native"; | |
| 3 | 4 | |
| 4 | 5 | import { SymbolView } from "../../../components/AppSymbol"; |
| 5 | 6 | import { AppText as Text } from "../../../components/AppText"; |
| ... | ... | @@ -17,21 +18,28 @@ export function SettingsSwitchRow(props: { |
| 17 | 18 | }) { |
| 18 | 19 | return ( |
| 19 | 20 | <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 | )} | |
| 25 | 27 | > |
| 26 | 28 | <SymbolView |
| 27 | 29 | name={props.icon} |
| 28 | size={22} | |
| 29 | tintColorClassName={"accent-icon"} | |
| 30 | size={Platform.OS === "android" ? 24 : 22} | |
| 31 | tintColorClassName="accent-icon" | |
| 30 | 32 | type="monochrome" |
| 31 | 33 | weight="regular" |
| 32 | 34 | /> |
| 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> | |
| 35 | 43 | {props.subtitle ? ( |
| 36 | 44 | <Text className="text-sm text-foreground-muted">{props.subtitle}</Text> |
| 37 | 45 | ) : null} |
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+184-158| ... | ... | @@ -1,4 +1,3 @@ |
| 1 | import { BlurTargetView } from "expo-blur"; | |
| 2 | 1 | import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | 2 | import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; |
| 4 | 3 | import type { MenuAction } from "@react-native-menu/menu"; |
| ... | ... | @@ -7,6 +6,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac |
| 7 | 6 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 8 | 7 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 9 | 8 | import { Alert, Platform, Pressable, View } from "react-native"; |
| 9 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 10 | 10 | import { AppText as Text } from "../../components/AppText"; |
| 11 | 11 | import { TerminalContextSheet } from "./TerminalContextSheet"; |
| 12 | 12 | import { hasNativeTerminalSurface } from "./nativeTerminalModule"; |
| ... | ... | @@ -29,6 +29,9 @@ import { ControlPillMenu } from "../../components/ControlPill"; |
| 29 | 29 | import { EmptyState } from "../../components/EmptyState"; |
| 30 | 30 | import { GlassSurface } from "../../components/GlassSurface"; |
| 31 | 31 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 32 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; | |
| 33 | import { MaterialButton } from "../../components/MaterialButton"; | |
| 34 | import { MaterialIconButton } from "../../components/MaterialIconButton"; | |
| 32 | 35 | import { environmentCatalog } from "../../connection/catalog"; |
| 33 | 36 | import { useEnvironmentPresentation } from "../../state/presentation"; |
| 34 | 37 | import { terminalEnvironment } from "../../state/terminal"; |
| ... | ... | @@ -50,6 +53,7 @@ import { useThreadSelection } from "../../state/use-thread-selection"; |
| 50 | 53 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; |
| 51 | 54 | import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; |
| 52 | 55 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 56 | import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar"; | |
| 53 | 57 | import { TerminalSurface } from "./NativeTerminalSurface"; |
| 54 | 58 | import { getMobileTerminalTheme } from "./terminalTheme"; |
| 55 | 59 | import { terminalDebugLog } from "./terminalDebugLog"; |
| ... | ... | @@ -158,7 +162,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{ |
| 158 | 162 | }>; |
| 159 | 163 | |
| 160 | 164 | export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { |
| 161 | const terminalBlurTarget = useRef<View>(null); | |
| 165 | const insets = useSafeAreaInsets(); | |
| 162 | 166 | const navigation = useNavigation(); |
| 163 | 167 | const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); |
| 164 | 168 | const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); |
| ... | ... | @@ -190,6 +194,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 190 | 194 | themeAppearance: appearanceScheme, |
| 191 | 195 | themeId, |
| 192 | 196 | setTerminalFontSize, |
| 197 | themeVariables, | |
| 193 | 198 | } = useAppearancePreferences(); |
| 194 | 199 | const fontSize = appearance.terminalFontSize; |
| 195 | 200 | const cachedRouteGridSize = |
| ... | ... | @@ -515,6 +520,12 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 515 | 520 | isVisible: state.isVisible, |
| 516 | 521 | })); |
| 517 | 522 | const isAccessoryVisible = keyboardState.isVisible && !isAccessoryDismissed; |
| 523 | // Android's terminal owns an EditText; turning off autoFocus also clears its native focus. | |
| 524 | const terminalAutoFocus = | |
| 525 | Platform.OS === "android" | |
| 526 | ? !isAccessoryDismissed && | |
| 527 | (!SHOWCASE_ENABLED || keyboardFocusRequest > 0 || keyboardState.isVisible) | |
| 528 | : !SHOWCASE_ENABLED; | |
| 518 | 529 | const terminalBottomInset = |
| 519 | 530 | (keyboardState.isVisible ? keyboardState.height : 0) + |
| 520 | 531 | (isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0); |
| ... | ... | @@ -1103,6 +1114,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 1103 | 1114 | }, []); |
| 1104 | 1115 | |
| 1105 | 1116 | const handleShowKeyboard = useCallback(() => { |
| 1117 | setIsAccessoryDismissed(false); | |
| 1106 | 1118 | setKeyboardFocusRequest((current) => current + 1); |
| 1107 | 1119 | }, []); |
| 1108 | 1120 | const handleRetryEnvironment = useCallback(() => { |
| ... | ... | @@ -1175,22 +1187,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 1175 | 1187 | <AndroidScreenHeader |
| 1176 | 1188 | title="Terminal" |
| 1177 | 1189 | subtitle={headerSubtitle} |
| 1190 | leading={<AndroidWorkspaceSidebarButton />} | |
| 1178 | 1191 | onBack={handleCloseTerminal} |
| 1179 | 1192 | trailing={ |
| 1180 | 1193 | <> |
| 1181 | {layout.usesSplitView ? ( | |
| 1182 | <AndroidHeaderIconButton | |
| 1183 | accessibilityLabel={ | |
| 1184 | panes.primarySidebarVisible ? "Maximize terminal" : "Show threads" | |
| 1185 | } | |
| 1186 | icon={ | |
| 1187 | panes.primarySidebarVisible | |
| 1188 | ? "arrow.up.left.and.arrow.down.right" | |
| 1189 | : "sidebar.left" | |
| 1190 | } | |
| 1191 | onPress={togglePrimarySidebar} | |
| 1192 | /> | |
| 1193 | ) : null} | |
| 1194 | 1194 | {isEnvironmentReady ? ( |
| 1195 | 1195 | <ControlPillMenu |
| 1196 | 1196 | actions={androidTerminalMenuActions} |
| ... | ... | @@ -1280,162 +1280,188 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 1280 | 1280 | </NativeHeaderToolbar> |
| 1281 | 1281 | ) : null} |
| 1282 | 1282 | |
| 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" | |
| 1296 | 1301 | } |
| 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 | <> | |
| 1310 | 1314 | <View |
| 1311 | pointerEvents="none" | |
| 1312 | 1315 | 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, | |
| 1328 | 1318 | }} |
| 1329 | onInput={handleInput} | |
| 1330 | onResize={handleResize} | |
| 1331 | style={{ flex: 1 }} | |
| 1332 | terminalKey={terminalKey} | |
| 1333 | theme={terminalTheme} | |
| 1334 | /> | |
| 1335 | </BlurTargetView> | |
| 1336 | ||
| 1337 | {selectedThread && hasNativeTerminalSurface() ? ( | |
| 1338 | <Pressable | |
| 1339 | accessibilityRole="button" | |
| 1340 | onPress={() => { | |
| 1341 | KeyboardController.dismiss(); | |
| 1342 | setCaptureRequest((value) => value + 1); | |
| 1343 | }} | |
| 1344 | className="px-4 py-2" | |
| 1345 | > | |
| 1346 | <Text style={{ color: terminalTheme.foreground }}>Attach visible output</Text> | |
| 1347 | </Pressable> | |
| 1348 | ) : null} | |
| 1349 | {isAccessoryVisible ? ( | |
| 1350 | <KeyboardStickyView | |
| 1351 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} | |
| 1352 | offset={{ closed: 0, opened: 0 }} | |
| 1353 | 1319 | > |
| 1354 | 1320 | <View |
| 1355 | className="border-t" | |
| 1321 | pointerEvents="none" | |
| 1356 | 1322 | style={{ |
| 1323 | position: "absolute", | |
| 1324 | inset: 0, | |
| 1357 | 1325 | backgroundColor: terminalTheme.background, |
| 1358 | borderTopColor: terminalTheme.border, | |
| 1359 | minHeight: TERMINAL_ACCESSORY_HEIGHT, | |
| 1360 | 1326 | }} |
| 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)} | |
| 1395 | 1354 | /> |
| 1396 | </ComposerToolbarRow> | |
| 1355 | ) : null} | |
| 1356 | <View className="flex-1" /> | |
| 1357 | <MaterialIconButton | |
| 1358 | accessibilityLabel="Show keyboard" | |
| 1359 | icon="keyboard" | |
| 1360 | onPress={handleShowKeyboard} | |
| 1361 | /> | |
| 1397 | 1362 | </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); | |
| 1424 | 1369 | }} |
| 1425 | pointerEvents="none" | |
| 1370 | className="px-4 py-2" | |
| 1426 | 1371 | > |
| 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> | |
| 1439 | 1465 | </> |
| 1440 | 1466 | ); |
| 1441 | 1467 | } |
apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx created+287| ... | ... | @@ -0,0 +1,287 @@ |
| 1 | import { MaterialSegmentedButtons } from "../../components/MaterialSegmentedButtons.android"; | |
| 2 | import { | |
| 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"; | |
| 15 | import { | |
| 16 | padding, | |
| 17 | size, | |
| 18 | width, | |
| 19 | fillMaxWidth, | |
| 20 | testID, | |
| 21 | verticalScroll, | |
| 22 | } from "@expo/ui/jetpack-compose/modifiers"; | |
| 23 | import { | |
| 24 | localSnoozeDate, | |
| 25 | localSnoozeTime, | |
| 26 | resolveCustomSnooze, | |
| 27 | type CustomSnoozeInput, | |
| 28 | } from "@t3tools/client-runtime/state/thread-settled"; | |
| 29 | import { requireNativeModule } from "expo"; | |
| 30 | import { useEffect, useState } from "react"; | |
| 31 | import { AppState, useWindowDimensions } from "react-native"; | |
| 32 | ||
| 33 | import { OverlayPortal } from "../../components/OverlayPortal"; | |
| 34 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | |
| 35 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; | |
| 36 | import type { CustomSnoozeSheet as SharedCustomSnoozeSheet } from "./CustomSnoozeSheet.shared"; | |
| 37 | import { | |
| 38 | applySnoozePickerDate, | |
| 39 | applySnoozePickerTime, | |
| 40 | snoozeDateToPickerDate, | |
| 41 | } from "./customSnoozeDate"; | |
| 42 | ||
| 43 | type Props = Parameters<typeof SharedCustomSnoozeSheet>[0]; | |
| 44 | ||
| 45 | const roundedCorner = Shape.RoundedCorner; | |
| 46 | ||
| 47 | const modes = [ | |
| 48 | { value: "date", label: "Date and time" }, | |
| 49 | { value: "duration", label: "Duration" }, | |
| 50 | ] as const; | |
| 51 | const units = [ | |
| 52 | { value: "minutes", label: "Minutes" }, | |
| 53 | { value: "hours", label: "Hours" }, | |
| 54 | { value: "days", label: "Days" }, | |
| 55 | ] as const; | |
| 56 | ||
| 57 | function systemUses24HourClock() { | |
| 58 | return requireNativeModule<{ is24HourFormat(): boolean }>("T3NativeControls").is24HourFormat(); | |
| 59 | } | |
| 60 | ||
| 61 | export 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 | ||
| 266 | function SnoozeDateTimePicker(props: { | |
| 267 | readonly date: Date; | |
| 268 | readonly picker: "date" | "time"; | |
| 269 | readonly is24Hour: boolean; | |
| 270 | readonly colors: React.ComponentProps<typeof DateTimePicker>["elementColors"]; | |
| 271 | readonly onChange: (date: Date) => void; | |
| 272 | }) { | |
| 273 | // Changing initialDate resets Compose's picker state, including its active clock dial. | |
| 274 | const [initialDate] = useState(() => | |
| 275 | props.picker === "date" ? snoozeDateToPickerDate(props.date) : props.date.toISOString(), | |
| 276 | ); | |
| 277 | return ( | |
| 278 | <DateTimePicker | |
| 279 | initialDate={initialDate} | |
| 280 | displayedComponents={props.picker === "date" ? "date" : "hourAndMinute"} | |
| 281 | is24Hour={props.is24Hour} | |
| 282 | showVariantToggle={false} | |
| 283 | elementColors={props.colors} | |
| 284 | onDateSelected={props.onChange} | |
| 285 | /> | |
| 286 | ); | |
| 287 | } |
apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx+154-192| ... | ... | @@ -3,7 +3,6 @@ import { |
| 3 | 3 | DatePicker, |
| 4 | 4 | Host, |
| 5 | 5 | HStack, |
| 6 | Menu, | |
| 7 | 6 | Picker, |
| 8 | 7 | Popover, |
| 9 | 8 | Spacer, |
| ... | ... | @@ -11,16 +10,20 @@ import { |
| 11 | 10 | VStack, |
| 12 | 11 | } from "@expo/ui/swift-ui"; |
| 13 | 12 | import { |
| 14 | background, | |
| 13 | accessibilityAddTraits, | |
| 14 | accessibilityHidden, | |
| 15 | buttonBorderShape, | |
| 15 | 16 | buttonStyle, |
| 16 | datePickerStyle, | |
| 17 | clipped, | |
| 18 | controlSize, | |
| 17 | 19 | font, |
| 20 | datePickerStyle, | |
| 18 | 21 | foregroundStyle, |
| 19 | 22 | frame, |
| 23 | labelsHidden, | |
| 24 | labelStyle, | |
| 20 | 25 | padding, |
| 21 | 26 | pickerStyle, |
| 22 | presentationBackground, | |
| 23 | shapes, | |
| 24 | 27 | tag, |
| 25 | 28 | } from "@expo/ui/swift-ui/modifiers"; |
| 26 | 29 | import { |
| ... | ... | @@ -30,9 +33,8 @@ import { |
| 30 | 33 | type CustomSnoozeInput, |
| 31 | 34 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 32 | 35 | import { useState } from "react"; |
| 33 | import { Modal, Pressable, ScrollView, View } from "react-native"; | |
| 34 | import { AppText } from "../../components/AppText"; | |
| 35 | import { SegmentedControl } from "../../components/SegmentedControl"; | |
| 36 | import { useWindowDimensions } from "react-native"; | |
| 37 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | |
| 36 | 38 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 37 | 39 | |
| 38 | 40 | const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1); |
| ... | ... | @@ -50,13 +52,13 @@ export function CustomSnoozeSheet(props: { |
| 50 | 52 | readonly onClose: () => void; |
| 51 | 53 | readonly onSnooze: (snoozedUntil: string) => void; |
| 52 | 54 | }) { |
| 55 | const { width } = useWindowDimensions(); | |
| 53 | 56 | const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date"); |
| 54 | 57 | const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000)); |
| 55 | 58 | const [amount, setAmount] = useState(2); |
| 56 | const [amountOpen, setAmountOpen] = useState(false); | |
| 57 | 59 | const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); |
| 58 | 60 | const [error, setError] = useState<string | null>(null); |
| 59 | const { themeVariables: colors, themeAppearance, appearance } = useAppearancePreferences(); | |
| 61 | const { themeVariables: colors, themeAppearance } = useAppearancePreferences(); | |
| 60 | 62 | const updateDate = (value: Date) => { |
| 61 | 63 | setDate(value); |
| 62 | 64 | setError(null); |
| ... | ... | @@ -79,190 +81,150 @@ export function CustomSnoozeSheet(props: { |
| 79 | 81 | }; |
| 80 | 82 | |
| 81 | 83 | 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()]} | |
| 107 | 98 | /> |
| 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) })]} | |
| 113 | 104 | > |
| 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 | /> | |
| 197 | 137 | </HStack> |
| 198 | </Host> | |
| 199 | {error && <AppText className="text-sm text-danger-foreground">{error}</AppText>} | |
| 200 | </ScrollView> | |
| 201 | </View> | |
| 202 | </Modal> | |
| 203 | ); | |
| 204 | } | |
| 205 | ||
| 206 | function 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 | ||
| 221 | function 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> | |
| 267 | 229 | ); |
| 268 | 230 | } |
apps/mobile/src/features/threads/CustomSnoozeSheet.shared.tsx created+181| ... | ... | @@ -0,0 +1,181 @@ |
| 1 | import { DateTimePicker } from "@expo/ui/community/datetime-picker"; | |
| 2 | import { | |
| 3 | localSnoozeDate, | |
| 4 | localSnoozeTime, | |
| 5 | resolveCustomSnooze, | |
| 6 | type CustomSnoozeInput, | |
| 7 | } from "@t3tools/client-runtime/state/thread-settled"; | |
| 8 | import { useState } from "react"; | |
| 9 | import { | |
| 10 | KeyboardAvoidingView, | |
| 11 | Modal, | |
| 12 | Platform, | |
| 13 | Pressable, | |
| 14 | ScrollView, | |
| 15 | TextInput, | |
| 16 | View, | |
| 17 | } from "react-native"; | |
| 18 | import { AppText } from "../../components/AppText"; | |
| 19 | import { SegmentedControl } from "../../components/SegmentedControl"; | |
| 20 | ||
| 21 | export function CustomSnoozeSheet(props: { | |
| 22 | readonly onClose: () => void; | |
| 23 | readonly onSnooze: (snoozedUntil: string) => void; | |
| 24 | }) { | |
| 25 | const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date"); | |
| 26 | const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000)); | |
| 27 | const [picker, setPicker] = useState<"date" | "time" | null>(null); | |
| 28 | const [amount, setAmount] = useState("2"); | |
| 29 | const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); | |
| 30 | const [error, setError] = useState<string | null>(null); | |
| 31 | return ( | |
| 32 | <Modal visible transparent animationType="fade" onRequestClose={props.onClose}> | |
| 33 | <KeyboardAvoidingView | |
| 34 | className="flex-1 items-center justify-center bg-black/40 px-6" | |
| 35 | behavior={Platform.OS === "ios" ? "padding" : undefined} | |
| 36 | > | |
| 37 | <ScrollView | |
| 38 | className="max-h-[80%] w-full max-w-md grow-0 rounded-3xl bg-screen" | |
| 39 | keyboardShouldPersistTaps="handled" | |
| 40 | contentInsetAdjustmentBehavior="automatic" | |
| 41 | contentContainerStyle={{ padding: 24, paddingBottom: 24, gap: 20 }} | |
| 42 | > | |
| 43 | <AppText accessibilityRole="header" className="text-xl font-t3-semibold"> | |
| 44 | Custom snooze | |
| 45 | </AppText> | |
| 46 | <AppText className="text-base text-foreground-secondary"> | |
| 47 | Choose when snoozed threads return to your inbox. | |
| 48 | </AppText> | |
| 49 | <SegmentedControl | |
| 50 | options={ | |
| 51 | [ | |
| 52 | { value: "date", label: "Date and time" }, | |
| 53 | { value: "duration", label: "Duration" }, | |
| 54 | ] as const | |
| 55 | } | |
| 56 | selected={mode} | |
| 57 | onSelect={(value) => { | |
| 58 | setMode(value); | |
| 59 | setPicker(null); | |
| 60 | setError(null); | |
| 61 | }} | |
| 62 | role="tab" | |
| 63 | /> | |
| 64 | {mode === "date" ? ( | |
| 65 | <View className="gap-3"> | |
| 66 | {(["date", "time"] as const).map((value) => ( | |
| 67 | <Pressable | |
| 68 | key={value} | |
| 69 | accessibilityRole="button" | |
| 70 | accessibilityLabel={value === "date" ? "Choose date" : "Choose time"} | |
| 71 | className="min-h-12 flex-row items-center justify-between rounded-xl bg-subtle px-3" | |
| 72 | onPress={() => setPicker(value)} | |
| 73 | > | |
| 74 | <AppText>{value === "date" ? "Date" : "Time"}</AppText> | |
| 75 | <AppText> | |
| 76 | {value === "date" | |
| 77 | ? date.toLocaleDateString() | |
| 78 | : date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} | |
| 79 | </AppText> | |
| 80 | </Pressable> | |
| 81 | ))} | |
| 82 | {picker && ( | |
| 83 | <DateTimePicker | |
| 84 | value={date} | |
| 85 | mode={picker} | |
| 86 | display={Platform.OS === "ios" ? "spinner" : "default"} | |
| 87 | onDismiss={() => setPicker(null)} | |
| 88 | onValueChange={(_, selected) => { | |
| 89 | const next = new Date(date); | |
| 90 | if (picker === "date") | |
| 91 | next.setFullYear( | |
| 92 | selected.getFullYear(), | |
| 93 | selected.getMonth(), | |
| 94 | selected.getDate(), | |
| 95 | ); | |
| 96 | else next.setHours(selected.getHours(), selected.getMinutes(), 0, 0); | |
| 97 | setDate(next); | |
| 98 | setError(null); | |
| 99 | }} | |
| 100 | /> | |
| 101 | )} | |
| 102 | </View> | |
| 103 | ) : ( | |
| 104 | <View className="gap-3"> | |
| 105 | <AppText>Snooze for</AppText> | |
| 106 | <TextInput | |
| 107 | accessibilityLabel="Duration" | |
| 108 | className="min-h-12 rounded-xl bg-subtle px-3 text-base text-foreground" | |
| 109 | keyboardType="decimal-pad" | |
| 110 | value={amount} | |
| 111 | onChangeText={(value) => { | |
| 112 | setAmount(value); | |
| 113 | setError(null); | |
| 114 | }} | |
| 115 | /> | |
| 116 | <View className="flex-row gap-2"> | |
| 117 | {(["minutes", "hours", "days"] as const).map((value) => ( | |
| 118 | <Pressable | |
| 119 | key={value} | |
| 120 | accessibilityRole="radio" | |
| 121 | accessibilityState={{ checked: unit === value }} | |
| 122 | className={ | |
| 123 | unit === value | |
| 124 | ? "min-h-12 flex-1 items-center justify-center rounded-xl bg-subtle" | |
| 125 | : "min-h-12 flex-1 items-center justify-center rounded-xl" | |
| 126 | } | |
| 127 | onPress={() => { | |
| 128 | setUnit(value); | |
| 129 | setError(null); | |
| 130 | }} | |
| 131 | > | |
| 132 | <AppText> | |
| 133 | {value === "minutes" ? "Minutes" : value === "hours" ? "Hours" : "Days"} | |
| 134 | </AppText> | |
| 135 | </Pressable> | |
| 136 | ))} | |
| 137 | </View> | |
| 138 | </View> | |
| 139 | )} | |
| 140 | {error && ( | |
| 141 | <AppText accessibilityRole="alert" className="text-danger-foreground"> | |
| 142 | {error} | |
| 143 | </AppText> | |
| 144 | )} | |
| 145 | <View className="flex-row justify-end gap-3"> | |
| 146 | <Pressable | |
| 147 | accessibilityRole="button" | |
| 148 | className="min-h-12 justify-center px-3" | |
| 149 | onPress={props.onClose} | |
| 150 | > | |
| 151 | <AppText>Cancel</AppText> | |
| 152 | </Pressable> | |
| 153 | <Pressable | |
| 154 | accessibilityRole="button" | |
| 155 | className="min-h-12 justify-center rounded-xl bg-primary px-3" | |
| 156 | onPress={() => { | |
| 157 | const input: CustomSnoozeInput = | |
| 158 | mode === "date" | |
| 159 | ? { mode, date: localSnoozeDate(date), time: localSnoozeTime(date) } | |
| 160 | : { mode, amount: amount.replace(",", "."), unit }; | |
| 161 | const snoozedUntil = resolveCustomSnooze(input, new Date()); | |
| 162 | if (!snoozedUntil) { | |
| 163 | setError( | |
| 164 | mode === "date" | |
| 165 | ? "Choose a date and time in the future." | |
| 166 | : "Enter a positive duration.", | |
| 167 | ); | |
| 168 | return; | |
| 169 | } | |
| 170 | props.onSnooze(snoozedUntil); | |
| 171 | props.onClose(); | |
| 172 | }} | |
| 173 | > | |
| 174 | <AppText className="text-primary-foreground">Snooze</AppText> | |
| 175 | </Pressable> | |
| 176 | </View> | |
| 177 | </ScrollView> | |
| 178 | </KeyboardAvoidingView> | |
| 179 | </Modal> | |
| 180 | ); | |
| 181 | } |
apps/mobile/src/features/threads/CustomSnoozeSheet.tsx+1-182| ... | ... | @@ -1,182 +1 @@ |
| 1 | import { DateTimePicker } from "@expo/ui/community/datetime-picker"; | |
| 2 | import { | |
| 3 | localSnoozeDate, | |
| 4 | localSnoozeTime, | |
| 5 | resolveCustomSnooze, | |
| 6 | type CustomSnoozeInput, | |
| 7 | } from "@t3tools/client-runtime/state/thread-settled"; | |
| 8 | import { useState } from "react"; | |
| 9 | import { | |
| 10 | KeyboardAvoidingView, | |
| 11 | Modal, | |
| 12 | Platform, | |
| 13 | Pressable, | |
| 14 | ScrollView, | |
| 15 | TextInput, | |
| 16 | View, | |
| 17 | } from "react-native"; | |
| 18 | import { AppText } from "../../components/AppText"; | |
| 19 | import { SegmentedControl } from "../../components/SegmentedControl"; | |
| 20 | ||
| 21 | export 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 | } | |
| 1 | export { CustomSnoozeSheet } from "./CustomSnoozeSheet.shared"; |
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+122-51| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { MaterialListRow } from "../../components/MaterialListRow"; | |
| 1 | 2 | import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; |
| 2 | 3 | import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; |
| 3 | 4 | import { LegendList } from "@legendapp/list/react-native"; |
| ... | ... | @@ -20,6 +21,7 @@ import { |
| 20 | 21 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 21 | 22 | |
| 22 | 23 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 24 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; | |
| 23 | 25 | import { SymbolView } from "../../components/AppSymbol"; |
| 24 | 26 | import { AppText as Text } from "../../components/AppText"; |
| 25 | 27 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; |
| ... | ... | @@ -46,6 +48,34 @@ function SelectionRow(props: { |
| 46 | 48 | readonly subtitle?: string; |
| 47 | 49 | readonly title: string; |
| 48 | 50 | }) { |
| 51 | if (Platform.OS === "android") { | |
| 52 | return ( | |
| 53 | <MaterialListRow | |
| 54 | title={props.title} | |
| 55 | subtitle={props.subtitle} | |
| 56 | leading={ | |
| 57 | props.icon === "arrow.triangle.branch" ? ( | |
| 58 | <SymbolView | |
| 59 | name="arrow.triangle.branch" | |
| 60 | size={24} | |
| 61 | tintColorClassName="accent-icon-muted" | |
| 62 | /> | |
| 63 | ) : ( | |
| 64 | props.icon | |
| 65 | ) | |
| 66 | } | |
| 67 | trailing={ | |
| 68 | props.selected ? ( | |
| 69 | <SymbolView name="checkmark" size={20} tintColorClassName="accent-primary" /> | |
| 70 | ) : null | |
| 71 | } | |
| 72 | accessibilityRole="radio" | |
| 73 | accessibilityState={{ checked: props.selected }} | |
| 74 | disabled={props.disabled} | |
| 75 | onPress={props.onPress} | |
| 76 | /> | |
| 77 | ); | |
| 78 | } | |
| 49 | 79 | return ( |
| 50 | 80 | <Pressable |
| 51 | 81 | accessibilityLabel={[props.title, props.subtitle].filter(Boolean).join(", ")} |
| ... | ... | @@ -63,7 +93,7 @@ function SelectionRow(props: { |
| 63 | 93 | <SymbolView |
| 64 | 94 | name="arrow.triangle.branch" |
| 65 | 95 | size={17} |
| 66 | tintColorClassName={"accent-icon-muted"} | |
| 96 | tintColorClassName="accent-icon-muted" | |
| 67 | 97 | type="monochrome" |
| 68 | 98 | /> |
| 69 | 99 | ) : ( |
| ... | ... | @@ -83,7 +113,7 @@ function SelectionRow(props: { |
| 83 | 113 | <SymbolView |
| 84 | 114 | name="checkmark" |
| 85 | 115 | size={16} |
| 86 | tintColorClassName={"accent-icon"} | |
| 116 | tintColorClassName="accent-icon" | |
| 87 | 117 | type="monochrome" |
| 88 | 118 | weight="semibold" |
| 89 | 119 | /> |
| ... | ... | @@ -99,7 +129,13 @@ function ToggleRow(props: { |
| 99 | 129 | }) { |
| 100 | 130 | return ( |
| 101 | 131 | <View className="min-h-14 flex-row items-center gap-3 bg-card px-4 py-3"> |
| 102 | <Text className="min-w-0 flex-1 text-base font-t3-medium text-foreground" numberOfLines={1}> | |
| 132 | <Text | |
| 133 | className={cn( | |
| 134 | "min-w-0 flex-1 text-base text-foreground", | |
| 135 | Platform.OS !== "android" && "font-t3-medium", | |
| 136 | )} | |
| 137 | numberOfLines={1} | |
| 138 | > | |
| 103 | 139 | {props.title} |
| 104 | 140 | </Text> |
| 105 | 141 | <ThemedSwitch |
| ... | ... | @@ -125,8 +161,14 @@ function BranchSelectionRow(props: { |
| 125 | 161 | return ( |
| 126 | 162 | <View |
| 127 | 163 | 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"), | |
| 130 | 172 | )} |
| 131 | 173 | > |
| 132 | 174 | <SelectionRow |
| ... | ... | @@ -143,7 +185,17 @@ function BranchSelectionRow(props: { |
| 143 | 185 | } |
| 144 | 186 | |
| 145 | 187 | function PickerSurface(props: { readonly children: ReactNode }) { |
| 146 | return <View className="overflow-hidden rounded-2xl bg-card">{props.children}</View>; | |
| 188 | return ( | |
| 189 | <View | |
| 190 | className={ | |
| 191 | Platform.OS === "android" | |
| 192 | ? "overflow-hidden rounded-[28px] bg-card" | |
| 193 | : "overflow-hidden rounded-2xl bg-card" | |
| 194 | } | |
| 195 | > | |
| 196 | {props.children} | |
| 197 | </View> | |
| 198 | ); | |
| 147 | 199 | } |
| 148 | 200 | |
| 149 | 201 | export function NewTaskEnvironmentPickerRouteScreen() { |
| ... | ... | @@ -161,42 +213,48 @@ export function NewTaskEnvironmentPickerRouteScreen() { |
| 161 | 213 | }} |
| 162 | 214 | /> |
| 163 | 215 | {Platform.OS === "android" ? ( |
| 164 | <AndroidScreenHeader title="Environment" onBack={() => navigation.goBack()} /> | |
| 216 | <AndroidScreenHeader | |
| 217 | title="Environment" | |
| 218 | hideBottomBorder | |
| 219 | onBack={() => navigation.goBack()} | |
| 220 | /> | |
| 165 | 221 | ) : 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> | |
| 200 | 258 | </View> |
| 201 | 259 | ); |
| 202 | 260 | } |
| ... | ... | @@ -225,7 +283,7 @@ export function NewTaskBranchPickerRouteScreen() { |
| 225 | 283 | ? 16 |
| 226 | 284 | : Math.max(insets.bottom, 16) + 16, |
| 227 | 285 | paddingHorizontal: 16, |
| 228 | paddingTop: 12, | |
| 286 | paddingTop: 16, | |
| 229 | 287 | }), |
| 230 | 288 | [insets.bottom, usesNativeMailSearchToolbar], |
| 231 | 289 | ); |
| ... | ... | @@ -327,7 +385,12 @@ export function NewTaskBranchPickerRouteScreen() { |
| 327 | 385 | |
| 328 | 386 | const branchListHeader = |
| 329 | 387 | flow.workspaceMode === "worktree" ? ( |
| 330 | <View className="mb-3 overflow-hidden rounded-2xl"> | |
| 388 | <View | |
| 389 | className={cn( | |
| 390 | "mb-3 overflow-hidden", | |
| 391 | Platform.OS === "android" ? "rounded-[28px]" : "rounded-2xl", | |
| 392 | )} | |
| 393 | > | |
| 331 | 394 | <ToggleRow |
| 332 | 395 | onValueChange={flow.setStartFromOrigin} |
| 333 | 396 | title="Start from origin" |
| ... | ... | @@ -339,9 +402,9 @@ export function NewTaskBranchPickerRouteScreen() { |
| 339 | 402 | const branchContent = |
| 340 | 403 | flow.filteredBranches.length === 0 ? ( |
| 341 | 404 | <ScrollView |
| 342 | className="flex-1 bg-sheet" | |
| 405 | className={Platform.OS === "android" ? "flex-1 bg-sheet-solid" : "flex-1 bg-sheet"} | |
| 343 | 406 | contentInsetAdjustmentBehavior="automatic" |
| 344 | contentContainerStyle={{ flexGrow: 1, paddingHorizontal: 16, paddingTop: 12 }} | |
| 407 | contentContainerStyle={{ flexGrow: 1, paddingHorizontal: 16, paddingTop: 16 }} | |
| 345 | 408 | scrollEnabled={false} |
| 346 | 409 | showsVerticalScrollIndicator={false} |
| 347 | 410 | > |
| ... | ... | @@ -380,7 +443,7 @@ export function NewTaskBranchPickerRouteScreen() { |
| 380 | 443 | alwaysBounceVertical={false} |
| 381 | 444 | automaticallyAdjustsScrollIndicatorInsets |
| 382 | 445 | automaticallyAdjustKeyboardInsets={Platform.OS === "ios"} |
| 383 | className="flex-1 bg-sheet" | |
| 446 | className={Platform.OS === "android" ? "flex-1 bg-sheet-solid" : "flex-1 bg-sheet"} | |
| 384 | 447 | contentInsetAdjustmentBehavior="automatic" |
| 385 | 448 | contentContainerStyle={branchListContentStyle} |
| 386 | 449 | data={flow.filteredBranches} |
| ... | ... | @@ -408,19 +471,27 @@ export function NewTaskBranchPickerRouteScreen() { |
| 408 | 471 | return ( |
| 409 | 472 | <View className="flex-1 bg-sheet" collapsable={false}> |
| 410 | 473 | <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"> | |
| 413 | 480 | <TextInput |
| 414 | 481 | autoCapitalize="none" |
| 415 | 482 | autoCorrect={false} |
| 416 | className="h-11 rounded-xl bg-card px-4 font-sans text-base text-foreground" | |
| 483 | accessibilityLabel="Find a branch" | |
| 484 | className="h-12 rounded-full border border-input-border bg-input px-4 font-sans text-base text-foreground" | |
| 485 | selectionColorClassName="accent-primary/32" | |
| 486 | cursorColorClassName="accent-primary" | |
| 487 | selectionHandleColorClassName="accent-primary" | |
| 417 | 488 | onChangeText={flow.setBranchQuery} |
| 418 | 489 | placeholder="Find a branch" |
| 419 | placeholderTextColorClassName={"accent-placeholder"} | |
| 490 | placeholderTextColorClassName="accent-placeholder" | |
| 420 | 491 | value={flow.branchQuery} |
| 421 | 492 | /> |
| 422 | 493 | </View> |
| 423 | {branchContent} | |
| 494 | <MaterialScreenContent>{branchContent}</MaterialScreenContent> | |
| 424 | 495 | </View> |
| 425 | 496 | ); |
| 426 | 497 | } |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+25-16| ... | ... | @@ -27,12 +27,10 @@ import Animated from "react-native-reanimated"; |
| 27 | 27 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 28 | 28 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 29 | 29 | import { useFontFamily } from "../../lib/useFontFamily"; |
| 30 | ||
| 31 | 30 | import { |
| 32 | 31 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 33 | 32 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 34 | 33 | resolveEnvironmentMachineKind, |
| 35 | type EnvironmentId, | |
| 36 | 34 | } from "@t3tools/contracts"; |
| 37 | 35 | |
| 38 | 36 | import { |
| ... | ... | @@ -51,6 +49,7 @@ import { |
| 51 | 49 | ComposerToolbarRow, |
| 52 | 50 | } from "../../components/ComposerToolbar"; |
| 53 | 51 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 52 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; | |
| 54 | 53 | import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; |
| 55 | 54 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 56 | 55 | import { composerStripAttachments } from "../../lib/composerImages"; |
| ... | ... | @@ -81,7 +80,6 @@ import { |
| 81 | 80 | useThreadSettingsSheetPresentation, |
| 82 | 81 | type NavigationWithFinishTransitioning, |
| 83 | 82 | } from "./use-thread-settings-sheet-presentation"; |
| 84 | ||
| 85 | 83 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; |
| 86 | 84 | import { |
| 87 | 85 | convertPastedImagesToAttachments, |
| ... | ... | @@ -140,7 +138,7 @@ function NewTaskWorkspaceIcon(props: { |
| 140 | 138 | <SymbolView |
| 141 | 139 | name="folder" |
| 142 | 140 | size={16} |
| 143 | tintColorClassName={"accent-icon-muted"} | |
| 141 | tintColorClassName="accent-icon-muted" | |
| 144 | 142 | type="monochrome" |
| 145 | 143 | /> |
| 146 | 144 | ); |
| ... | ... | @@ -151,14 +149,14 @@ function NewTaskWorkspaceIcon(props: { |
| 151 | 149 | <SymbolView |
| 152 | 150 | name="folder" |
| 153 | 151 | size={16} |
| 154 | tintColorClassName={"accent-icon-muted"} | |
| 152 | tintColorClassName="accent-icon-muted" | |
| 155 | 153 | type="monochrome" |
| 156 | 154 | /> |
| 157 | 155 | <View className="absolute -right-1 -bottom-1"> |
| 158 | 156 | <SymbolView |
| 159 | 157 | name="arrow.triangle.branch" |
| 160 | 158 | size={9} |
| 161 | tintColorClassName={"accent-icon-muted"} | |
| 159 | tintColorClassName="accent-icon-muted" | |
| 162 | 160 | type="monochrome" |
| 163 | 161 | /> |
| 164 | 162 | </View> |
| ... | ... | @@ -1322,7 +1320,11 @@ export function NewTaskDraftScreen(props: { |
| 1322 | 1320 | {Platform.OS === "android" ? ( |
| 1323 | 1321 | <> |
| 1324 | 1322 | <NativeStackScreenOptions options={{ headerShown: false }} /> |
| 1325 | <AndroidScreenHeader title="New Thread" onBack={() => navigation.goBack()} /> | |
| 1323 | <AndroidScreenHeader | |
| 1324 | title="New thread" | |
| 1325 | hideBottomBorder | |
| 1326 | onBack={() => navigation.goBack()} | |
| 1327 | /> | |
| 1326 | 1328 | </> |
| 1327 | 1329 | ) : ( |
| 1328 | 1330 | <NativeStackScreenOptions options={{ title: "Loading task" }} /> |
| ... | ... | @@ -1540,7 +1542,12 @@ export function NewTaskDraftScreen(props: { |
| 1540 | 1542 | ); |
| 1541 | 1543 | |
| 1542 | 1544 | const composerDock = ( |
| 1543 | <View className="bg-sheet px-[12px] pt-1" style={{ paddingBottom: controlsBottomPadding }}> | |
| 1545 | <View | |
| 1546 | className={ | |
| 1547 | Platform.OS === "android" ? "bg-sheet-solid px-[12px] pt-1" : "bg-sheet px-[12px] pt-1" | |
| 1548 | } | |
| 1549 | style={{ paddingBottom: controlsBottomPadding }} | |
| 1550 | > | |
| 1544 | 1551 | {!voiceInput.isBusy && |
| 1545 | 1552 | composerMenu.trigger && |
| 1546 | 1553 | (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request") ? ( |
| ... | ... | @@ -1754,15 +1761,17 @@ export function NewTaskDraftScreen(props: { |
| 1754 | 1761 | return ( |
| 1755 | 1762 | <View className="flex-1 bg-sheet" collapsable={false}> |
| 1756 | 1763 | <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} | |
| 1759 | 1767 | |
| 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> | |
| 1766 | 1775 | </View> |
| 1767 | 1776 | ); |
| 1768 | 1777 | } |
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+154-91| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { MaterialListRow } from "../../components/MaterialListRow"; | |
| 1 | 2 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | 3 | import { |
| 3 | 4 | StackActions, |
| ... | ... | @@ -8,11 +9,13 @@ import { |
| 8 | 9 | import { SymbolView } from "../../components/AppSymbol"; |
| 9 | 10 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 10 | 11 | import { useEffect, useRef } from "react"; |
| 11 | import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native"; | |
| 12 | import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native"; | |
| 12 | 13 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 13 | 14 | import { cn } from "../../lib/cn"; |
| 14 | ||
| 15 | 15 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 16 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; | |
| 17 | import { MaterialButton } from "../../components/MaterialButton"; | |
| 18 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | |
| 16 | 19 | import { AppText as Text } from "../../components/AppText"; |
| 17 | 20 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 18 | 21 | import { useProjects } from "../../state/entities"; |
| ... | ... | @@ -183,8 +186,9 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 183 | 186 | <NativeStackScreenOptions options={{ headerShown: false }} /> |
| 184 | 187 | <AndroidScreenHeader |
| 185 | 188 | title={screenTitle} |
| 189 | hideBottomBorder | |
| 186 | 190 | subtitle={incomingShareSubtitle} |
| 187 | onBack={layout.usesSplitView ? () => navigation.goBack() : undefined} | |
| 191 | onBack={() => navigation.goBack()} | |
| 188 | 192 | actions={ |
| 189 | 193 | catalogState.hasReadyEnvironment |
| 190 | 194 | ? [ |
| ... | ... | @@ -226,97 +230,156 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 226 | 230 | </> |
| 227 | 231 | )} |
| 228 | 232 | |
| 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")} | |
| 280 | 279 | > |
| 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 | |
| 303 | 317 | ? `${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 | } | |
| 312 | 331 | /> |
| 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> | |
| 320 | 383 | </View> |
| 321 | 384 | ); |
| 322 | 385 | } |
apps/mobile/src/features/threads/ThreadComposer.tsx+8-12| ... | ... | @@ -49,6 +49,7 @@ import Animated, { |
| 49 | 49 | withTiming, |
| 50 | 50 | } from "react-native-reanimated"; |
| 51 | 51 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 52 | import { themeColorWithAlpha } from "../../lib/mobileTheme"; | |
| 52 | 53 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; |
| 53 | 54 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 54 | 55 | import { |
| ... | ... | @@ -208,7 +209,6 @@ export function ComposerSurface(props: { |
| 208 | 209 | /** Morphs between the compact and expanded composer layouts. */ |
| 209 | 210 | readonly animateLayout?: boolean; |
| 210 | 211 | }) { |
| 211 | const { materialYouStyleLayoutActive } = useAppearancePreferences(); | |
| 212 | 212 | const colors = useUniwindTheme(); |
| 213 | 213 | const targetBorderRadius = |
| 214 | 214 | typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0; |
| ... | ... | @@ -232,27 +232,23 @@ export function ComposerSurface(props: { |
| 232 | 232 | return ( |
| 233 | 233 | <Animated.View |
| 234 | 234 | 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" | |
| 238 | 236 | } |
| 239 | 237 | layout={layoutTransition} |
| 240 | 238 | style={[ |
| 241 | 239 | animatedShapeStyle, |
| 242 | 240 | { |
| 243 | 241 | overflow: "hidden", |
| 244 | // Android versions before 9 do not support outset box shadows. | |
| 245 | elevation: Platform.OS === "android" && Platform.Version < 28 ? 10 : undefined, | |
| 246 | 242 | }, |
| 247 | 243 | ]} |
| 248 | 244 | > |
| 249 | 245 | <AnimatedGlassSurface |
| 250 | 246 | chrome="none" |
| 251 | 247 | fallbackColor={ |
| 252 | materialYouStyleLayoutActive ? colors["--color-composer-surface"] : colors["--color-card"] | |
| 248 | Platform.OS === "android" ? colors["--color-composer-surface"] : colors["--color-card"] | |
| 253 | 249 | } |
| 254 | 250 | fallbackClassName={ |
| 255 | materialYouStyleLayoutActive ? "border border-composer-border" : "border border-border" | |
| 251 | Platform.OS === "android" ? "border border-composer-border" : "border border-border" | |
| 256 | 252 | } |
| 257 | 253 | glassEffectStyle="regular" |
| 258 | 254 | // The composer is a passive material containing interactive controls. |
| ... | ... | @@ -277,8 +273,7 @@ export function ComposerSurface(props: { |
| 277 | 273 | |
| 278 | 274 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { |
| 279 | 275 | const project = useProject(scopeProjectRef(props.environmentId, props.selectedThread.projectId)); |
| 280 | const { materialYouStyleLayoutActive, themeVariables: materialTheme } = | |
| 281 | useAppearancePreferences(); | |
| 276 | const { themeVariables: materialTheme } = useAppearancePreferences(); | |
| 282 | 277 | const composerPanel = materialTheme["--color-composer-panel"]; |
| 283 | 278 | const navigation = useNavigation(); |
| 284 | 279 | const foregroundColor = useUniwindTheme()["--color-foreground"]; |
| ... | ... | @@ -627,7 +622,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 627 | 622 | style={{ |
| 628 | 623 | paddingTop: isExpanded ? 8 : 6, |
| 629 | 624 | paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6), |
| 630 | backgroundColor: materialYouStyleLayoutActive ? composerPanel : undefined, | |
| 625 | backgroundColor: | |
| 626 | Platform.OS === "android" ? themeColorWithAlpha(composerPanel, 1) : undefined, | |
| 631 | 627 | }} |
| 632 | 628 | > |
| 633 | 629 | {/* The backdrop gradient lives on a plain View: Reanimated's Animated.View |
| ... | ... | @@ -635,7 +631,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 635 | 631 | strip fully transparent and the feed text legible through the composer. */} |
| 636 | 632 | <View |
| 637 | 633 | className={ |
| 638 | materialYouStyleLayoutActive | |
| 634 | Platform.OS === "android" | |
| 639 | 635 | ? "hidden" |
| 640 | 636 | : "absolute inset-0 bg-linear-to-b from-screen/0 via-screen/60 to-screen/90" |
| 641 | 637 | } |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+40-47| ... | ... | @@ -27,8 +27,6 @@ import type { |
| 27 | 27 | UserInputQuestion, |
| 28 | 28 | } from "@t3tools/contracts"; |
| 29 | 29 | import * as Haptics from "expo-haptics"; |
| 30 | import { BlurTargetView } from "expo-blur"; | |
| 31 | import { GlassBlurTargetContext } from "../../lib/glassBlurTarget"; | |
| 32 | 30 | import { |
| 33 | 31 | memo, |
| 34 | 32 | useCallback, |
| ... | ... | @@ -65,7 +63,6 @@ import Animated, { |
| 65 | 63 | } from "react-native-reanimated"; |
| 66 | 64 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 67 | 65 | import { useWorkspaceContentWidth } from "../layout/workspace-content-width"; |
| 68 | ||
| 69 | 66 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 70 | 67 | import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; |
| 71 | 68 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| ... | ... | @@ -819,7 +816,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 819 | 816 | }, [freeze, scrollMessageToEnd]); |
| 820 | 817 | |
| 821 | 818 | const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled; |
| 822 | const { themeAppearance, materialYouStyleLayoutActive } = useAppearancePreferences(); | |
| 819 | const { themeAppearance } = useAppearancePreferences(); | |
| 823 | 820 | const isDarkMode = themeAppearance === "dark"; |
| 824 | 821 | |
| 825 | 822 | const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { |
| ... | ... | @@ -851,13 +848,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 851 | 848 | const handleFeedTouchCancel = useCallback(() => { |
| 852 | 849 | feedTouchStartRef.current = null; |
| 853 | 850 | }, []); |
| 854 | const feedBlurTarget = useRef<View>(null); | |
| 855 | 851 | |
| 856 | 852 | return ( |
| 857 | 853 | <View className="flex-1"> |
| 858 | 854 | {showContent ? ( |
| 859 | <BlurTargetView | |
| 860 | ref={feedBlurTarget} | |
| 855 | <View | |
| 861 | 856 | style={{ flex: 1 }} |
| 862 | 857 | onTouchStart={handleFeedTouchStart} |
| 863 | 858 | onTouchMove={handleFeedTouchMove} |
| ... | ... | @@ -867,7 +862,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 867 | 862 | <View |
| 868 | 863 | pointerEvents="none" |
| 869 | 864 | className={ |
| 870 | materialYouStyleLayoutActive | |
| 865 | Platform.OS === "android" | |
| 871 | 866 | ? "absolute inset-0 bg-thread-canvas" |
| 872 | 867 | : "absolute inset-0 bg-screen" |
| 873 | 868 | } |
| ... | ... | @@ -903,7 +898,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 903 | 898 | onUseArtifactTemplate={handleUseArtifactTemplate} |
| 904 | 899 | loadEarlier={props.loadEarlier ?? null} |
| 905 | 900 | /> |
| 906 | </BlurTargetView> | |
| 901 | </View> | |
| 907 | 902 | ) : ( |
| 908 | 903 | <View className="flex-1" /> |
| 909 | 904 | )} |
| ... | ... | @@ -1025,44 +1020,42 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 1025 | 1020 | : undefined |
| 1026 | 1021 | } |
| 1027 | 1022 | > |
| 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 | /> | |
| 1066 | 1059 | </View> |
| 1067 | 1060 | </View> |
| 1068 | 1061 | </Animated.View> |
apps/mobile/src/features/threads/ThreadFeed.tsx+1| ... | ... | @@ -880,6 +880,7 @@ function MarkdownCodeBlock(props: { |
| 880 | 880 | > |
| 881 | 881 | <NativeText |
| 882 | 882 | selectable |
| 883 | selectionColorClassName={Platform.OS === "android" ? "accent-primary/32" : undefined} | |
| 883 | 884 | className="font-mono" |
| 884 | 885 | style={{ |
| 885 | 886 | color: props.textColor, |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+146-126| ... | ... | @@ -65,6 +65,9 @@ import { |
| 65 | 65 | WorkspaceConnectionTitle, |
| 66 | 66 | } from "../home/WorkspaceConnectionTitle"; |
| 67 | 67 | import { SidebarHeaderActions } from "./sidebar-header-actions"; |
| 68 | import { MaterialThreadListToolbar } from "../home/MaterialThreadListToolbar"; | |
| 69 | import { useMaterialToolbarHeight } from "../../components/useMaterialToolbarHeight"; | |
| 70 | import { useMaterialFabScroll } from "../home/MaterialFabScrollContext"; | |
| 68 | 71 | import { SidebarFilterButton } from "./sidebar-filter-button"; |
| 69 | 72 | import { createSidebarHeaderItems } from "./sidebar-native-header-items"; |
| 70 | 73 | import { SidebarNavigationShell } from "./sidebar-navigation-shell"; |
| ... | ... | @@ -147,10 +150,8 @@ function NativeSidebarContainer(props: ThreadNavigationSidebarProps) { |
| 147 | 150 | function ThreadNavigationSidebarPane( |
| 148 | 151 | props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean }, |
| 149 | 152 | ) { |
| 150 | const { materialYouStyleLayoutActive, themeVariables: materialTheme } = | |
| 151 | useAppearancePreferences(); | |
| 153 | const { themeVariables: materialTheme } = useAppearancePreferences(); | |
| 152 | 154 | const screenColor = materialTheme["--color-screen"]; |
| 153 | const mutedColor = materialTheme["--color-foreground-muted"]; | |
| 154 | 155 | |
| 155 | 156 | const insets = useSafeAreaInsets(); |
| 156 | 157 | const projects = useProjects(); |
| ... | ... | @@ -747,13 +748,18 @@ function ThreadNavigationSidebarPane( |
| 747 | 748 | ); |
| 748 | 749 | |
| 749 | 750 | const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null); |
| 751 | const materialToolbarHeight = useMaterialToolbarHeight(); | |
| 750 | 752 | // The sticky header (title row, search field, optional connection status) |
| 751 | 753 | // is measured so the list inset always matches its real height — no |
| 752 | 754 | // hardcoded per-variant constants. |
| 753 | const stickyHeaderHeight = measuredHeaderHeight ?? insets.top + SIDEBAR_STICKY_HEADER_HEIGHT; | |
| 755 | const stickyHeaderHeight = | |
| 756 | measuredHeaderHeight ?? | |
| 757 | (Platform.OS === "android" | |
| 758 | ? Math.max(insets.top, 12) + materialToolbarHeight + 8 | |
| 759 | : insets.top + SIDEBAR_STICKY_HEADER_HEIGHT); | |
| 754 | 760 | const topListInset = stickyHeaderHeight + 6; |
| 755 | 761 | const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => { |
| 756 | const nextHeight = Math.round(event.nativeEvent.layout.height); | |
| 762 | const nextHeight = event.nativeEvent.layout.height; | |
| 757 | 763 | setMeasuredHeaderHeight((current) => (current === nextHeight ? current : nextHeight)); |
| 758 | 764 | }, []); |
| 759 | 765 | const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { |
| ... | ... | @@ -777,7 +783,9 @@ function ThreadNavigationSidebarPane( |
| 777 | 783 | const handleScrollBeginDrag = useCallback(() => { |
| 778 | 784 | openSwipeableRef.current?.close(); |
| 779 | 785 | }, []); |
| 786 | const onMaterialFabScroll = useMaterialFabScroll(); | |
| 780 | 787 | const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ |
| 788 | onScroll: onMaterialFabScroll, | |
| 781 | 789 | onScrollBeginDrag: handleScrollBeginDrag, |
| 782 | 790 | }); |
| 783 | 791 | // Project shells load after the first rows draw, so the maps they feed have |
| ... | ... | @@ -850,6 +858,7 @@ function ThreadNavigationSidebarPane( |
| 850 | 858 | [], |
| 851 | 859 | ); |
| 852 | 860 | const focusSearch = useCallback(() => { |
| 861 | if (Platform.OS === "android") return false; | |
| 853 | 862 | const focus = () => { |
| 854 | 863 | if (props.nativeChrome) { |
| 855 | 864 | searchBarRef.current?.focus(); |
| ... | ... | @@ -925,7 +934,7 @@ function ThreadNavigationSidebarPane( |
| 925 | 934 | }), |
| 926 | 935 | )} |
| 927 | 936 | searchQuery={props.searchQuery} |
| 928 | pane={materialYouStyleLayoutActive ? "screen" : "sidebar"} | |
| 937 | pane={Platform.OS === "android" ? "screen" : "sidebar"} | |
| 929 | 938 | selected={ |
| 930 | 939 | scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey |
| 931 | 940 | } |
| ... | ... | @@ -966,7 +975,7 @@ function ThreadNavigationSidebarPane( |
| 966 | 975 | disabled={!shelfPreferencesLoaded} |
| 967 | 976 | expanded={item.expanded} |
| 968 | 977 | onToggle={toggleSnoozedShelf} |
| 969 | pane={materialYouStyleLayoutActive ? "screen" : "sidebar"} | |
| 978 | pane={Platform.OS === "android" ? "screen" : "sidebar"} | |
| 970 | 979 | /> |
| 971 | 980 | ); |
| 972 | 981 | case "v2-settled-shelf": |
| ... | ... | @@ -976,7 +985,7 @@ function ThreadNavigationSidebarPane( |
| 976 | 985 | disabled={!shelfPreferencesLoaded} |
| 977 | 986 | expanded={item.expanded} |
| 978 | 987 | onToggle={toggleSettledShelf} |
| 979 | pane={materialYouStyleLayoutActive ? "screen" : "sidebar"} | |
| 988 | pane={Platform.OS === "android" ? "screen" : "sidebar"} | |
| 980 | 989 | /> |
| 981 | 990 | ); |
| 982 | 991 | case "v2-show-more": |
| ... | ... | @@ -996,7 +1005,7 @@ function ThreadNavigationSidebarPane( |
| 996 | 1005 | case "header": |
| 997 | 1006 | return ( |
| 998 | 1007 | <ThreadListGroupHeader |
| 999 | variant={materialYouStyleLayoutActive ? "compact" : "sidebar"} | |
| 1008 | variant={Platform.OS === "android" ? "compact" : "sidebar"} | |
| 1000 | 1009 | collapsed={item.collapsed} |
| 1001 | 1010 | isFirst={item.isFirst} |
| 1002 | 1011 | groupKey={item.group.key} |
| ... | ... | @@ -1014,7 +1023,7 @@ function ThreadNavigationSidebarPane( |
| 1014 | 1023 | case "pending-task": |
| 1015 | 1024 | return ( |
| 1016 | 1025 | <PendingTaskListRow |
| 1017 | variant={materialYouStyleLayoutActive ? "compact" : "sidebar"} | |
| 1026 | variant={Platform.OS === "android" ? "compact" : "sidebar"} | |
| 1018 | 1027 | pendingTask={item.pendingTask} |
| 1019 | 1028 | environmentLabel={ |
| 1020 | 1029 | savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null |
| ... | ... | @@ -1064,7 +1073,7 @@ function ThreadNavigationSidebarPane( |
| 1064 | 1073 | case "show-more": |
| 1065 | 1074 | return ( |
| 1066 | 1075 | <ThreadListShowMoreRow |
| 1067 | variant={materialYouStyleLayoutActive ? "compact" : "sidebar"} | |
| 1076 | variant={Platform.OS === "android" ? "compact" : "sidebar"} | |
| 1068 | 1077 | hiddenCount={item.hiddenCount} |
| 1069 | 1078 | canShowLess={item.canShowLess} |
| 1070 | 1079 | groupKey={item.groupKey} |
| ... | ... | @@ -1074,7 +1083,6 @@ function ThreadNavigationSidebarPane( |
| 1074 | 1083 | } |
| 1075 | 1084 | }, |
| 1076 | 1085 | [ |
| 1077 | materialYouStyleLayoutActive, | |
| 1078 | 1086 | archiveThread, |
| 1079 | 1087 | activeReorderEnvironmentIds, |
| 1080 | 1088 | threadMovePlanners, |
| ... | ... | @@ -1166,16 +1174,24 @@ function ThreadNavigationSidebarPane( |
| 1166 | 1174 | // Snoozed threads need no special case: the shelf header is a list row |
| 1167 | 1175 | // even while collapsed. |
| 1168 | 1176 | const listEmpty = ( |
| 1169 | <Text className="px-2 py-4 text-sm text-foreground-muted"> | |
| 1177 | <Text | |
| 1178 | className={ | |
| 1179 | Platform.OS === "android" | |
| 1180 | ? "px-4 py-4 text-center text-sm text-foreground-muted" | |
| 1181 | : "px-2 py-4 text-sm text-foreground-muted" | |
| 1182 | } | |
| 1183 | > | |
| 1170 | 1184 | {catalogState.isLoadingConnections |
| 1171 | 1185 | ? "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"} | |
| 1179 | 1195 | </Text> |
| 1180 | 1196 | ); |
| 1181 | 1197 | |
| ... | ... | @@ -1232,7 +1248,7 @@ function ThreadNavigationSidebarPane( |
| 1232 | 1248 | } |
| 1233 | 1249 | contentContainerStyle={[ |
| 1234 | 1250 | styles.threadListContent, |
| 1235 | materialYouStyleLayoutActive ? { paddingHorizontal: 0 } : null, | |
| 1251 | Platform.OS === "android" ? { paddingHorizontal: 0 } : null, | |
| 1236 | 1252 | { |
| 1237 | 1253 | paddingBottom: Math.max(insets.bottom, 16) + 16, |
| 1238 | 1254 | paddingTop: 6, |
| ... | ... | @@ -1257,15 +1273,18 @@ function ThreadNavigationSidebarPane( |
| 1257 | 1273 | return ( |
| 1258 | 1274 | <View |
| 1259 | 1275 | testID="thread-navigation-sidebar" |
| 1260 | className="flex-1 border-r border-border bg-drawer" | |
| 1276 | className={ | |
| 1277 | Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 border-r border-border bg-drawer" | |
| 1278 | } | |
| 1261 | 1279 | style={{ width: props.width }} |
| 1262 | 1280 | > |
| 1263 | 1281 | <View |
| 1264 | 1282 | className="flex-1" |
| 1265 | 1283 | style={ |
| 1266 | materialYouStyleLayoutActive | |
| 1284 | Platform.OS === "android" | |
| 1267 | 1285 | ? { |
| 1268 | 1286 | marginTop: stickyHeaderHeight, |
| 1287 | marginHorizontal: 4, | |
| 1269 | 1288 | paddingBottom: insets.bottom, |
| 1270 | 1289 | backgroundColor: screenColor, |
| 1271 | 1290 | borderTopLeftRadius: 28, |
| ... | ... | @@ -1275,116 +1294,117 @@ function ThreadNavigationSidebarPane( |
| 1275 | 1294 | : { paddingBottom: insets.bottom } |
| 1276 | 1295 | } |
| 1277 | 1296 | > |
| 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 | )} | |
| 1311 | 1334 | </View> |
| 1312 | 1335 | |
| 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 | |
| 1322 | 1359 | environment reconnects, the brand fades to a status label in |
| 1323 | 1360 | 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> | |
| 1339 | 1380 | </View> |
| 1340 | </View> | |
| 1341 | 1381 | |
| 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> | |
| 1386 | 1406 | </View> |
| 1387 | </View> | |
| 1407 | )} | |
| 1388 | 1408 | </View> |
| 1389 | 1409 | ); |
| 1390 | 1410 | } |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+29-21| ... | ... | @@ -32,17 +32,17 @@ import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draf |
| 32 | 32 | import { useEnvironmentQuery } from "../../state/query"; |
| 33 | 33 | import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; |
| 34 | 34 | import { vcsEnvironment } from "../../state/vcs"; |
| 35 | ||
| 36 | 35 | import { EmptyState } from "../../components/EmptyState"; |
| 37 | 36 | import { |
| 37 | AndroidHeaderIconButton, | |
| 38 | 38 | AndroidScreenHeader, |
| 39 | 39 | type AndroidHeaderAction, |
| 40 | 40 | } from "../../components/AndroidScreenHeader"; |
| 41 | import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar"; | |
| 41 | 42 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 42 | 43 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 43 | 44 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 44 | 45 | import { connectionTone } from "../connection/connectionTone"; |
| 45 | ||
| 46 | 46 | import { |
| 47 | 47 | useRemoteConnections, |
| 48 | 48 | useRemoteConnectionStatus, |
| ... | ... | @@ -225,7 +225,7 @@ function ThreadRouteContent( |
| 225 | 225 | readonly selectedThreadDetailState: ReturnType<typeof useSelectedThreadDetailState>; |
| 226 | 226 | }, |
| 227 | 227 | ) { |
| 228 | const { materialYouStyleLayoutActive, themeVariables } = useAppearancePreferences(); | |
| 228 | const { themeVariables } = useAppearancePreferences(); | |
| 229 | 229 | const headerColor = themeVariables["--color-header"]; |
| 230 | 230 | const { |
| 231 | 231 | fileInspector, |
| ... | ... | @@ -746,10 +746,12 @@ function ThreadRouteContent( |
| 746 | 746 | }); |
| 747 | 747 | } |
| 748 | 748 | if (selectedThreadCwd !== null) { |
| 749 | const filesVisible = inspectorMode === "files" && panes.auxiliaryPaneVisible; | |
| 749 | 750 | actions.push({ |
| 750 | accessibilityLabel: "Open files", | |
| 751 | accessibilityLabel: filesVisible ? "Close files" : "Open files", | |
| 752 | selected: filesVisible, | |
| 751 | 753 | icon: "folder", |
| 752 | onPress: handleOpenFilesInspector, | |
| 754 | onPress: filesVisible ? toggleAuxiliaryPane : handleOpenFilesInspector, | |
| 753 | 755 | }); |
| 754 | 756 | } |
| 755 | 757 | if (selectedThreadProject?.workspaceRoot) { |
| ... | ... | @@ -764,20 +766,14 @@ function ThreadRouteContent( |
| 764 | 766 | icon: "point.topleft.down.curvedto.point.bottomright.up", |
| 765 | 767 | onPress: handleOpenGitInspector, |
| 766 | 768 | }); |
| 767 | if (fileInspector.supported && selectedThreadCwd !== null) { | |
| 768 | actions.push({ | |
| 769 | accessibilityLabel: "Toggle inspector", | |
| 770 | icon: "sidebar.right", | |
| 771 | onPress: handleToggleInspector, | |
| 772 | }); | |
| 773 | } | |
| 774 | 769 | return actions; |
| 775 | 770 | }, [ |
| 776 | fileInspector.supported, | |
| 771 | inspectorMode, | |
| 772 | panes.auxiliaryPaneVisible, | |
| 777 | 773 | handleOpenFilesInspector, |
| 778 | 774 | handleOpenTerminal, |
| 779 | 775 | handleOpenGitInspector, |
| 780 | handleToggleInspector, | |
| 776 | toggleAuxiliaryPane, | |
| 781 | 777 | props.onReturnToThread, |
| 782 | 778 | selectedThreadCwd, |
| 783 | 779 | selectedThreadProject?.workspaceRoot, |
| ... | ... | @@ -891,13 +887,12 @@ function ThreadRouteContent( |
| 891 | 887 | <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} /> |
| 892 | 888 | |
| 893 | 889 | <View |
| 894 | className={materialYouStyleLayoutActive ? "flex-1 bg-thread-canvas" : "flex-1 bg-screen"} | |
| 890 | className={Platform.OS === "android" ? "flex-1 bg-thread-canvas" : "flex-1 bg-screen"} | |
| 895 | 891 | style={ |
| 896 | materialYouStyleLayoutActive | |
| 892 | Platform.OS === "android" | |
| 897 | 893 | ? { |
| 898 | 894 | borderTopLeftRadius: 28, |
| 899 | 895 | borderTopRightRadius: 28, |
| 900 | marginRight: layout.usesSplitView ? 8 : 0, | |
| 901 | 896 | overflow: "hidden", |
| 902 | 897 | } |
| 903 | 898 | : undefined |
| ... | ... | @@ -996,9 +991,7 @@ function ThreadRouteContent( |
| 996 | 991 | : undefined, |
| 997 | 992 | unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, |
| 998 | 993 | contentStyle: |
| 999 | Platform.OS === "android" && materialYouStyleLayoutActive | |
| 1000 | ? { backgroundColor: headerColor } | |
| 1001 | : undefined, | |
| 994 | Platform.OS === "android" && true ? { backgroundColor: headerColor } : undefined, | |
| 1002 | 995 | }} |
| 1003 | 996 | /> |
| 1004 | 997 | |
| ... | ... | @@ -1006,6 +999,21 @@ function ThreadRouteContent( |
| 1006 | 999 | <AndroidScreenHeader |
| 1007 | 1000 | title={selectedThread.title} |
| 1008 | 1001 | subtitle={headerSubtitle} |
| 1002 | leading={<AndroidWorkspaceSidebarButton />} | |
| 1003 | trailing={ | |
| 1004 | fileInspector.supported && selectedThreadCwd !== null ? ( | |
| 1005 | <AndroidHeaderIconButton | |
| 1006 | accessibilityLabel={ | |
| 1007 | inspectorMode !== null && panes.auxiliaryPaneVisible | |
| 1008 | ? "Hide inspector" | |
| 1009 | : "Show inspector" | |
| 1010 | } | |
| 1011 | icon="sidebar.right" | |
| 1012 | selected={inspectorMode !== null && panes.auxiliaryPaneVisible} | |
| 1013 | onPress={handleToggleInspector} | |
| 1014 | /> | |
| 1015 | ) : null | |
| 1016 | } | |
| 1009 | 1017 | onBack={ |
| 1010 | 1018 | layout.usesSplitView |
| 1011 | 1019 | ? undefined |
| ... | ... | @@ -1017,7 +1025,7 @@ function ThreadRouteContent( |
| 1017 | 1025 | } |
| 1018 | 1026 | } |
| 1019 | 1027 | actions={androidHeaderActions} |
| 1020 | hideBottomBorder={materialYouStyleLayoutActive} | |
| 1028 | hideBottomBorder | |
| 1021 | 1029 | /> |
| 1022 | 1030 | ) : null} |
| 1023 | 1031 |
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+98-41| ... | ... | @@ -36,6 +36,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 36 | 36 | import { SymbolView } from "../../components/AppSymbol"; |
| 37 | 37 | import { AppText as Text } from "../../components/AppText"; |
| 38 | 38 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 39 | import { MaterialButton } from "../../components/MaterialButton"; | |
| 40 | import { MaterialIconButton } from "../../components/MaterialIconButton"; | |
| 41 | import { MaterialRadioIndicator } from "../../components/MaterialRadioIndicator"; | |
| 39 | 42 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 40 | 43 | import { ThemedSwitch } from "../../components/ThemedSwitch"; |
| 41 | 44 | import { cn } from "../../lib/cn"; |
| ... | ... | @@ -52,7 +55,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 52 | 55 | import { serverEnvironment } from "../../state/server"; |
| 53 | 56 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 54 | 57 | import { useNewTaskFlow } from "./new-task-flow-provider"; |
| 55 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | |
| 58 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; | |
| 56 | 59 | import { |
| 57 | 60 | createProviderCatalogRefreshRunner, |
| 58 | 61 | providerCatalogRefreshError, |
| ... | ... | @@ -105,8 +108,7 @@ function ModelRow(props: { |
| 105 | 108 | readonly isFirst: boolean; |
| 106 | 109 | readonly isLast: boolean; |
| 107 | 110 | }) { |
| 108 | const { materialYouStyleLayoutActive } = useAppearancePreferences(); | |
| 109 | const selectedMaterialRow = materialYouStyleLayoutActive && props.selected; | |
| 111 | const selectedMaterialRow = Platform.OS === "android" && props.selected; | |
| 110 | 112 | return ( |
| 111 | 113 | <Pressable |
| 112 | 114 | accessibilityLabel={[props.option.label, props.option.subtitle].filter(Boolean).join(", ")} |
| ... | ... | @@ -117,6 +119,7 @@ function ModelRow(props: { |
| 117 | 119 | }} |
| 118 | 120 | disabled={props.option.isUnavailable} |
| 119 | 121 | onPress={props.onPress} |
| 122 | style={Platform.OS === "android" ? { minHeight: 56, paddingVertical: 12 } : undefined} | |
| 120 | 123 | className={cn( |
| 121 | 124 | "mx-4 min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle", |
| 122 | 125 | selectedMaterialRow && "bg-thread-selected", |
| ... | ... | @@ -124,11 +127,12 @@ function ModelRow(props: { |
| 124 | 127 | props.isLast ? "rounded-b-2xl" : "border-b border-border-subtle", |
| 125 | 128 | )} |
| 126 | 129 | > |
| 130 | {Platform.OS === "android" ? <MaterialRadioIndicator selected={props.selected} /> : null} | |
| 127 | 131 | <View className="min-w-0 flex-1"> |
| 128 | 132 | <View className="flex-row items-center gap-2"> |
| 129 | 133 | <Text |
| 130 | 134 | className="min-w-0 shrink text-base font-t3-medium text-foreground" |
| 131 | numberOfLines={1} | |
| 135 | numberOfLines={Platform.OS === "android" ? 2 : 1} | |
| 132 | 136 | > |
| 133 | 137 | {props.option.label} |
| 134 | 138 | </Text> |
| ... | ... | @@ -147,16 +151,19 @@ function ModelRow(props: { |
| 147 | 151 | ) : null} |
| 148 | 152 | </View> |
| 149 | 153 | {props.option.subtitle ? ( |
| 150 | <Text className="text-xs text-foreground-muted" numberOfLines={1}> | |
| 154 | <Text | |
| 155 | className="text-xs text-foreground-muted" | |
| 156 | numberOfLines={Platform.OS === "android" ? 2 : 1} | |
| 157 | > | |
| 151 | 158 | {props.option.subtitle} |
| 152 | 159 | </Text> |
| 153 | 160 | ) : null} |
| 154 | 161 | </View> |
| 155 | {props.selected ? ( | |
| 162 | {props.selected && Platform.OS !== "android" ? ( | |
| 156 | 163 | <SymbolView |
| 157 | 164 | name="checkmark" |
| 158 | 165 | size={16} |
| 159 | tintColorClassName={"accent-icon"} | |
| 166 | tintColorClassName="accent-icon" | |
| 160 | 167 | type="monochrome" |
| 161 | 168 | weight="semibold" |
| 162 | 169 | /> |
| ... | ... | @@ -189,7 +196,7 @@ function ProviderHeader(props: { |
| 189 | 196 | <SymbolView |
| 190 | 197 | name={props.collapsed ? "chevron.down" : "chevron.up"} |
| 191 | 198 | size={12} |
| 192 | tintColorClassName={"accent-icon-subtle"} | |
| 199 | tintColorClassName="accent-icon-subtle" | |
| 193 | 200 | type="monochrome" |
| 194 | 201 | /> |
| 195 | 202 | </> |
| ... | ... | @@ -205,6 +212,7 @@ function ProviderHeader(props: { |
| 205 | 212 | accessibilityState={{ expanded: !props.collapsed }} |
| 206 | 213 | className="mx-4 mt-1 min-h-11 flex-row items-center gap-2 rounded-xl px-1 pt-2 active:opacity-60" |
| 207 | 214 | onPress={props.onToggle} |
| 215 | style={Platform.OS === "android" ? { minHeight: 48 } : undefined} | |
| 208 | 216 | > |
| 209 | 217 | {content} |
| 210 | 218 | </Pressable> |
| ... | ... | @@ -229,6 +237,7 @@ function DisclosureRow(props: { |
| 229 | 237 | <Pressable |
| 230 | 238 | accessibilityRole="button" |
| 231 | 239 | onPress={props.onPress} |
| 240 | style={Platform.OS === "android" ? { minHeight: 56 } : undefined} | |
| 232 | 241 | className={cn( |
| 233 | 242 | "min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle", |
| 234 | 243 | !props.isLast && "border-b border-border-subtle", |
| ... | ... | @@ -244,7 +253,7 @@ function DisclosureRow(props: { |
| 244 | 253 | <SymbolView |
| 245 | 254 | name="chevron.right" |
| 246 | 255 | size={12} |
| 247 | tintColorClassName={"accent-icon-subtle"} | |
| 256 | tintColorClassName="accent-icon-subtle" | |
| 248 | 257 | type="monochrome" |
| 249 | 258 | /> |
| 250 | 259 | </Pressable> |
| ... | ... | @@ -265,22 +274,24 @@ function ChoiceRow(props: { |
| 265 | 274 | accessibilityRole="radio" |
| 266 | 275 | accessibilityState={{ checked: props.selected }} |
| 267 | 276 | onPress={props.onPress} |
| 277 | style={Platform.OS === "android" ? { minHeight: 56 } : undefined} | |
| 268 | 278 | className={cn( |
| 269 | 279 | "min-h-14 flex-row items-center gap-3 bg-card px-4 py-3 active:bg-subtle", |
| 270 | 280 | !props.isLast && "border-b border-border-subtle", |
| 271 | 281 | )} |
| 272 | 282 | > |
| 283 | {Platform.OS === "android" ? <MaterialRadioIndicator selected={props.selected} /> : null} | |
| 273 | 284 | <View className="min-w-0 flex-1 gap-0.5"> |
| 274 | 285 | <Text className="text-base font-t3-medium text-foreground">{props.label}</Text> |
| 275 | 286 | {props.description ? ( |
| 276 | 287 | <Text className="text-sm leading-5 text-foreground-muted">{props.description}</Text> |
| 277 | 288 | ) : null} |
| 278 | 289 | </View> |
| 279 | {props.selected ? ( | |
| 290 | {props.selected && Platform.OS !== "android" ? ( | |
| 280 | 291 | <SymbolView |
| 281 | 292 | name="checkmark" |
| 282 | 293 | size={16} |
| 283 | tintColorClassName={"accent-icon"} | |
| 294 | tintColorClassName="accent-icon" | |
| 284 | 295 | type="monochrome" |
| 285 | 296 | weight="semibold" |
| 286 | 297 | /> |
| ... | ... | @@ -853,10 +864,15 @@ function ThreadSettingsMainContent(props: { |
| 853 | 864 | <AnimatedLegendList |
| 854 | 865 | automaticallyAdjustsScrollIndicatorInsets |
| 855 | 866 | className="flex-1 bg-sheet" |
| 867 | style={ | |
| 868 | Platform.OS === "android" | |
| 869 | ? { width: "100%", maxWidth: 720, alignSelf: "center" } | |
| 870 | : undefined | |
| 871 | } | |
| 856 | 872 | contentContainerStyle={{ paddingTop: 4 }} |
| 857 | 873 | contentInsetAdjustmentBehavior={usesTransparentNativeHeader ? "never" : "automatic"} |
| 858 | 874 | data={listItems} |
| 859 | estimatedItemSize={48} | |
| 875 | estimatedItemSize={Platform.OS === "android" ? 56 : 48} | |
| 860 | 876 | extraData={animationsReady} |
| 861 | 877 | getItemType={(item) => item.kind} |
| 862 | 878 | itemLayoutAnimation={THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION} |
| ... | ... | @@ -869,16 +885,39 @@ function ThreadSettingsMainContent(props: { |
| 869 | 885 | {usesTransparentNativeHeader ? <View style={{ height: nativeHeaderHeight }} /> : null} |
| 870 | 886 | {Platform.OS === "android" ? ( |
| 871 | 887 | <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> | |
| 882 | 921 | </View> |
| 883 | 922 | ) : null} |
| 884 | 923 | </> |
| ... | ... | @@ -945,6 +984,11 @@ function ThreadSettingsChoiceContent(props: { |
| 945 | 984 | return ( |
| 946 | 985 | <ScrollView |
| 947 | 986 | className="flex-1 bg-sheet" |
| 987 | style={ | |
| 988 | Platform.OS === "android" | |
| 989 | ? { width: "100%", maxWidth: 720, alignSelf: "center" } | |
| 990 | : undefined | |
| 991 | } | |
| 948 | 992 | contentContainerStyle={{ |
| 949 | 993 | paddingBottom: insets.bottom + 12, |
| 950 | 994 | paddingHorizontal: 16, |
| ... | ... | @@ -1068,14 +1112,15 @@ function ThreadSettingsModelsScreen() { |
| 1068 | 1112 | icon: "arrow.clockwise", |
| 1069 | 1113 | onPress: refreshProviders, |
| 1070 | 1114 | }, |
| 1071 | { | |
| 1072 | accessibilityLabel: session.pendingModel ? "Save thread settings" : "Done", | |
| 1073 | icon: "checkmark", | |
| 1074 | onPress: commitAndClose, | |
| 1075 | }, | |
| 1076 | 1115 | ]} |
| 1116 | trailing={ | |
| 1117 | session.pendingModel ? ( | |
| 1118 | <MaterialButton label="Save" tone="text" onPress={commitAndClose} /> | |
| 1119 | ) : undefined | |
| 1120 | } | |
| 1077 | 1121 | onBack={presentation.onClose} |
| 1078 | 1122 | title="Thread settings" |
| 1123 | hideBottomBorder | |
| 1079 | 1124 | /> |
| 1080 | 1125 | ) : null} |
| 1081 | 1126 | <NativeStackScreenOptions |
| ... | ... | @@ -1114,17 +1159,19 @@ function ThreadSettingsModelsScreen() { |
| 1114 | 1159 | : undefined, |
| 1115 | 1160 | }} |
| 1116 | 1161 | /> |
| 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> | |
| 1128 | 1175 | <NativeHeaderToolbar placement="left"> |
| 1129 | 1176 | <NativeHeaderToolbar.Button |
| 1130 | 1177 | accessibilityLabel="Cancel thread settings" |
| ... | ... | @@ -1199,9 +1246,18 @@ function ThreadSettingsChoiceScreen() { |
| 1199 | 1246 | <> |
| 1200 | 1247 | <NativeStackScreenOptions options={{ headerShown: Platform.OS !== "android" }} /> |
| 1201 | 1248 | {Platform.OS === "android" ? ( |
| 1202 | <AndroidScreenHeader title={route.params.title} onBack={() => navigation.goBack()} /> | |
| 1249 | <AndroidScreenHeader | |
| 1250 | title={route.params.title} | |
| 1251 | onBack={() => navigation.goBack()} | |
| 1252 | hideBottomBorder | |
| 1253 | /> | |
| 1203 | 1254 | ) : null} |
| 1204 | <ThreadSettingsChoiceContent submenu={route.params} onSelected={() => navigation.goBack()} /> | |
| 1255 | <MaterialScreenContent> | |
| 1256 | <ThreadSettingsChoiceContent | |
| 1257 | submenu={route.params} | |
| 1258 | onSelected={() => navigation.goBack()} | |
| 1259 | /> | |
| 1260 | </MaterialScreenContent> | |
| 1205 | 1261 | </> |
| 1206 | 1262 | ); |
| 1207 | 1263 | } |
| ... | ... | @@ -1222,11 +1278,12 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) |
| 1222 | 1278 | <ThreadSettingsPickerStack.Navigator |
| 1223 | 1279 | initialRouteName="ThreadSettingsModels" |
| 1224 | 1280 | screenOptions={{ |
| 1225 | animation: "slide_from_right", | |
| 1281 | animation: Platform.OS === "android" ? "default" : "slide_from_right", | |
| 1226 | 1282 | contentStyle: { backgroundColor: solidSheetBackground }, |
| 1227 | 1283 | gestureEnabled: true, |
| 1228 | 1284 | headerBackButtonDisplayMode: "minimal", |
| 1229 | 1285 | headerBackTitle: "", |
| 1286 | headerShown: Platform.OS !== "android", | |
| 1230 | 1287 | headerShadowVisible: false, |
| 1231 | 1288 | headerStyle: { |
| 1232 | 1289 | backgroundColor: NATIVE_LIQUID_GLASS_SUPPORTED ? "transparent" : solidSheetBackground, |
apps/mobile/src/features/threads/customSnoozeDate.test.ts created+36| ... | ... | @@ -0,0 +1,36 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | applySnoozePickerDate, | |
| 5 | applySnoozePickerTime, | |
| 6 | snoozeDateToPickerDate, | |
| 7 | } from "./customSnoozeDate"; | |
| 8 | ||
| 9 | describe("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. */ | |
| 2 | export function snoozeDateToPickerDate(date: Date): string { | |
| 3 | return new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())).toISOString(); | |
| 4 | } | |
| 5 | ||
| 6 | export 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 | ||
| 12 | export function applySnoozePickerTime(date: Date, selected: Date): Date { | |
| 13 | const next = new Date(date); | |
| 14 | next.setHours(selected.getHours(), selected.getMinutes(), 0, 0); | |
| 15 | return next; | |
| 16 | } |
apps/mobile/src/features/threads/git/GitBranchesSheet.tsx+208-111| ... | ... | @@ -1,10 +1,12 @@ |
| 1 | 1 | import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 2 | 2 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | 3 | import { useState } from "react"; |
| 4 | import { Platform, Pressable, ScrollView, View } from "react-native"; | |
| 4 | import { Platform, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; | |
| 5 | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | 6 | |
| 7 | 7 | import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; |
| 8 | import { MaterialScreenContent } from "../../../components/MaterialScreenContent"; | |
| 9 | import { NativeStackScreenOptions } from "../../../native/StackHeader"; | |
| 8 | 10 | import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; |
| 9 | 11 | import { cn } from "../../../lib/cn"; |
| 10 | 12 | import { useEnvironmentQuery } from "../../../state/query"; |
| ... | ... | @@ -23,6 +25,7 @@ type GitBranchesSheetProps = StaticScreenProps<{ |
| 23 | 25 | export function GitBranchesSheet(_props: GitBranchesSheetProps) { |
| 24 | 26 | const navigation = useNavigation(); |
| 25 | 27 | const insets = useSafeAreaInsets(); |
| 28 | const { height: windowHeight } = useWindowDimensions(); | |
| 26 | 29 | const { selectedThread } = useThreadSelection(); |
| 27 | 30 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); |
| 28 | 31 | const gitState = useSelectedThreadGitState(); |
| ... | ... | @@ -58,125 +61,219 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { |
| 58 | 61 | const disabledExistingBranches = new Set(disabledExistingBranchNames); |
| 59 | 62 | |
| 60 | 63 | return ( |
| 61 | <View collapsable={false} className="flex-1 bg-sheet"> | |
| 64 | <View | |
| 65 | collapsable={false} | |
| 66 | className={Platform.OS === "android" ? "bg-sheet" : "flex-1 bg-sheet"} | |
| 67 | style={Platform.OS === "android" ? { maxHeight: windowHeight * 0.92 } : undefined} | |
| 68 | > | |
| 62 | 69 | {Platform.OS === "android" ? ( |
| 63 | <AndroidSheetHeader title="Branches & worktrees" onBack={() => navigation.goBack()} /> | |
| 70 | <NativeStackScreenOptions | |
| 71 | options={{ | |
| 72 | sheetCornerRadius: 28, | |
| 73 | sheetAllowedDetents: "fitToContents", | |
| 74 | }} | |
| 75 | /> | |
| 64 | 76 | ) : 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" | |
| 121 | 102 | } |
| 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 | |
| 141 | 112 | </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 | |
| 146 | 151 | </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> | |
| 157 | 194 | |
| 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 | |
| 161 | 207 | 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", | |
| 164 | 210 | )} |
| 165 | disabled={busy || disabled} | |
| 166 | onPress={() => { | |
| 167 | void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { | |
| 168 | navigation.goBack(); | |
| 169 | }); | |
| 170 | }} | |
| 171 | 211 | > |
| 172 | <View className="absolute inset-0 rounded-[18px] bg-card" /> | |
| 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> | |
| 180 | 277 | </View> |
| 181 | 278 | ); |
| 182 | 279 | } |
apps/mobile/src/features/threads/git/GitCommitSheet.tsx+267-147| ... | ... | @@ -1,9 +1,12 @@ |
| 1 | 1 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 2 | 2 | import { useCallback, useState } from "react"; |
| 3 | import { Platform, Pressable, ScrollView, View } from "react-native"; | |
| 3 | import { Platform, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; | |
| 4 | 4 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 5 | 5 | |
| 6 | 6 | import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; |
| 7 | import { SymbolView } from "../../../components/AppSymbol"; | |
| 8 | import { MaterialScreenContent } from "../../../components/MaterialScreenContent"; | |
| 9 | import { NativeStackScreenOptions } from "../../../native/StackHeader"; | |
| 7 | 10 | import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; |
| 8 | 11 | import { cn } from "../../../lib/cn"; |
| 9 | 12 | import { useEnvironmentQuery } from "../../../state/query"; |
| ... | ... | @@ -22,6 +25,7 @@ type GitCommitSheetProps = StaticScreenProps<{ |
| 22 | 25 | export function GitCommitSheet(_props: GitCommitSheetProps) { |
| 23 | 26 | const navigation = useNavigation(); |
| 24 | 27 | const insets = useSafeAreaInsets(); |
| 28 | const { height: windowHeight } = useWindowDimensions(); | |
| 25 | 29 | const { selectedThread } = useThreadSelection(); |
| 26 | 30 | const { selectedThreadCwd } = useSelectedThreadWorktree(); |
| 27 | 31 | const gitState = useSelectedThreadGitState(); |
| ... | ... | @@ -66,168 +70,284 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { |
| 66 | 70 | ); |
| 67 | 71 | |
| 68 | 72 | return ( |
| 69 | <View collapsable={false} className="flex-1 bg-sheet"> | |
| 73 | <View | |
| 74 | collapsable={false} | |
| 75 | className={Platform.OS === "android" ? "bg-sheet" : "flex-1 bg-sheet"} | |
| 76 | style={Platform.OS === "android" ? { maxHeight: windowHeight * 0.92 } : undefined} | |
| 77 | > | |
| 70 | 78 | {Platform.OS === "android" ? ( |
| 71 | <AndroidSheetHeader title="Commit changes" onBack={() => navigation.goBack()} /> | |
| 79 | <NativeStackScreenOptions | |
| 80 | options={{ | |
| 81 | sheetCornerRadius: 28, | |
| 82 | sheetAllowedDetents: "fitToContents", | |
| 83 | }} | |
| 84 | /> | |
| 72 | 85 | ) : 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 | )} | |
| 114 | 123 | > |
| 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> | |
| 119 | 126 | </View> |
| 127 | {isDefaultRef ? ( | |
| 128 | <Text className="text-xs leading-normal text-warning-foreground"> | |
| 129 | Warning: this is the default branch. | |
| 130 | </Text> | |
| 131 | ) : null} | |
| 120 | 132 | </View> |
| 121 | 133 | |
| 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 | |
| 140 | 149 | </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 ? ( | |
| 148 | 156 | <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())} | |
| 165 | 163 | > |
| 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> | |
| 192 | 173 | </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> | |
| 195 | 194 | </View> |
| 196 | )} | |
| 197 | </View> | |
| 198 | 195 | |
| 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> | |
| 210 | 303 | |
| 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 | )} | |
| 218 | 327 | /> |
| 219 | 328 | </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> | |
| 228 | 348 | </View> |
| 229 | </View> | |
| 230 | </ScrollView> | |
| 349 | </ScrollView> | |
| 350 | </MaterialScreenContent> | |
| 231 | 351 | </View> |
| 232 | 352 | ); |
| 233 | 353 | } |
apps/mobile/src/features/threads/git/GitConfirmSheet.tsx+86-27| ... | ... | @@ -4,11 +4,13 @@ import * as Arr from "effect/Array"; |
| 4 | 4 | import * as Result from "effect/Result"; |
| 5 | 5 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 6 | 6 | import { useCallback, useMemo } from "react"; |
| 7 | import { Platform, View } from "react-native"; | |
| 7 | import { Platform, ScrollView, useWindowDimensions, View } from "react-native"; | |
| 8 | 8 | |
| 9 | 9 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 10 | 10 | |
| 11 | 11 | import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; |
| 12 | import { MaterialScreenContent } from "../../../components/MaterialScreenContent"; | |
| 13 | import { NativeStackScreenOptions } from "../../../native/StackHeader"; | |
| 12 | 14 | import { AppText as Text } from "../../../components/AppText"; |
| 13 | 15 | import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; |
| 14 | 16 | import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; |
| ... | ... | @@ -27,6 +29,7 @@ type GitConfirmSheetProps = StaticScreenProps<{ |
| 27 | 29 | export function GitConfirmSheet(props: GitConfirmSheetProps) { |
| 28 | 30 | const navigation = useNavigation(); |
| 29 | 31 | const insets = useSafeAreaInsets(); |
| 32 | const { height: windowHeight } = useWindowDimensions(); | |
| 30 | 33 | const gitState = useSelectedThreadGitState(); |
| 31 | 34 | const gitActions = useSelectedThreadGitActions(); |
| 32 | 35 | |
| ... | ... | @@ -102,38 +105,94 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { |
| 102 | 105 | ]); |
| 103 | 106 | |
| 104 | 107 | return ( |
| 105 | <View collapsable={false} className="flex-1 bg-sheet"> | |
| 108 | <View | |
| 109 | collapsable={false} | |
| 110 | className={Platform.OS === "android" ? "bg-sheet" : "flex-1 bg-sheet"} | |
| 111 | style={Platform.OS === "android" ? { maxHeight: windowHeight * 0.92 } : undefined} | |
| 112 | > | |
| 106 | 113 | {Platform.OS === "android" ? ( |
| 107 | <AndroidSheetHeader title="Confirm action" onBack={() => navigation.goBack()} /> | |
| 114 | <NativeStackScreenOptions | |
| 115 | options={{ | |
| 116 | sheetCornerRadius: 28, | |
| 117 | sheetAllowedDetents: "fitToContents", | |
| 118 | }} | |
| 119 | /> | |
| 120 | ) : null} | |
| 121 | {Platform.OS === "android" ? ( | |
| 122 | <AndroidSheetHeader | |
| 123 | title="Confirm action" | |
| 124 | onBack={() => navigation.goBack()} | |
| 125 | hideBottomBorder | |
| 126 | /> | |
| 108 | 127 | ) : ( |
| 109 | 128 | <View className="min-h-4 pt-2" /> |
| 110 | 129 | )} |
| 111 | 130 | |
| 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> | |
| 123 | 173 | |
| 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> | |
| 137 | 196 | </View> |
| 138 | 197 | ); |
| 139 | 198 | } |
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+84-35| ... | ... | @@ -22,10 +22,17 @@ import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "re |
| 22 | 22 | import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; |
| 23 | 23 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 24 | 24 | import { useUniwindTheme } from "../../../lib/useUniwindTheme"; |
| 25 | ||
| 26 | import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; | |
| 25 | import { | |
| 26 | AndroidHeaderIconButton, | |
| 27 | AndroidSheetHeader, | |
| 28 | } from "../../../components/AndroidScreenHeader"; | |
| 29 | import { MaterialScreenContent } from "../../../components/MaterialScreenContent"; | |
| 30 | import { useAdaptiveWorkspaceLayout } from "../../layout/AdaptiveWorkspaceLayout"; | |
| 27 | 31 | import { AppText as Text } from "../../../components/AppText"; |
| 28 | import { nativeHeaderScrollEdgeEffects } from "../../../native/StackHeader"; | |
| 32 | import { | |
| 33 | NativeStackScreenOptions, | |
| 34 | nativeHeaderScrollEdgeEffects, | |
| 35 | } from "../../../native/StackHeader"; | |
| 29 | 36 | import { tryOpenExternalUrl } from "../../../lib/openExternalUrl"; |
| 30 | 37 | import { useEnvironmentQuery } from "../../../state/query"; |
| 31 | 38 | import { useThreadSelection } from "../../../state/use-thread-selection"; |
| ... | ... | @@ -47,6 +54,7 @@ type GitOverviewSheetProps = StaticScreenProps<{ |
| 47 | 54 | }; |
| 48 | 55 | |
| 49 | 56 | export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 57 | const { layout } = useAdaptiveWorkspaceLayout(); | |
| 50 | 58 | const navigation = useNavigation(); |
| 51 | 59 | const insets = useSafeAreaInsets(); |
| 52 | 60 | const presentation = props.presentation ?? "sheet"; |
| ... | ... | @@ -224,14 +232,14 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 224 | 232 | |
| 225 | 233 | const content = ( |
| 226 | 234 | <ScrollView |
| 227 | className="flex-1 bg-screen" | |
| 235 | className={Platform.OS === "android" ? "flex-1 bg-sheet-solid" : "flex-1 bg-screen"} | |
| 228 | 236 | contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} |
| 229 | 237 | showsVerticalScrollIndicator={false} |
| 230 | 238 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} |
| 231 | 239 | contentContainerStyle={{ |
| 232 | paddingHorizontal: isInspector ? 12 : 20, | |
| 240 | paddingHorizontal: Platform.OS === "android" ? 8 : isInspector ? 12 : 20, | |
| 233 | 241 | paddingTop: 8, |
| 234 | gap: 14, | |
| 242 | gap: Platform.OS === "android" ? 8 : 14, | |
| 235 | 243 | }} |
| 236 | 244 | refreshControl={ |
| 237 | 245 | <RefreshControl refreshing={isPullRefreshing} onRefresh={() => void handlePullRefresh()} /> |
| ... | ... | @@ -239,14 +247,18 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 239 | 247 | > |
| 240 | 248 | <View |
| 241 | 249 | 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" | |
| 245 | 255 | } |
| 246 | 256 | > |
| 247 | 257 | {sheetMenuItems.map(({ item, disabledReason }, index) => ( |
| 248 | 258 | <View key={`${item.id}-${item.label}`}> |
| 249 | {index > 0 ? <View className="ml-12 h-px bg-border" /> : null} | |
| 259 | {index > 0 && Platform.OS !== "android" ? ( | |
| 260 | <View className="ml-12 h-px bg-border" /> | |
| 261 | ) : null} | |
| 250 | 262 | <SheetListRow |
| 251 | 263 | icon={menuItemIconName(item.icon)} |
| 252 | 264 | title={item.label} |
| ... | ... | @@ -258,7 +270,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 258 | 270 | ))} |
| 259 | 271 | {behindCount > 0 ? ( |
| 260 | 272 | <> |
| 261 | <View className="ml-12 h-px bg-border" /> | |
| 273 | {Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null} | |
| 262 | 274 | <SheetListRow |
| 263 | 275 | icon="arrow.down.circle" |
| 264 | 276 | title="Pull latest" |
| ... | ... | @@ -268,7 +280,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 268 | 280 | /> |
| 269 | 281 | </> |
| 270 | 282 | ) : null} |
| 271 | <View className="ml-12 h-px bg-border" /> | |
| 283 | {Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null} | |
| 272 | 284 | <SheetListRow |
| 273 | 285 | icon="text.bubble" |
| 274 | 286 | title="Review changes" |
| ... | ... | @@ -283,7 +295,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 283 | 295 | ); |
| 284 | 296 | }} |
| 285 | 297 | /> |
| 286 | <View className="ml-12 h-px bg-border" /> | |
| 298 | {Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null} | |
| 287 | 299 | <SheetListRow |
| 288 | 300 | icon="point.topleft.down.curvedto.point.bottomright.up" |
| 289 | 301 | title="Branches & worktrees" |
| ... | ... | @@ -306,7 +318,11 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 306 | 318 | {linkedPrChains.map((chain) => ( |
| 307 | 319 | <View |
| 308 | 320 | key={threadPullRequestKeyOf(chain.layers[0]!)} |
| 309 | className="overflow-hidden rounded-2xl border border-border bg-card px-3 py-1" | |
| 321 | className={ | |
| 322 | Platform.OS === "android" | |
| 323 | ? "overflow-hidden rounded-[20px] bg-card" | |
| 324 | : "overflow-hidden rounded-2xl border border-border bg-card px-3 py-1" | |
| 325 | } | |
| 310 | 326 | > |
| 311 | 327 | {chain.layers.length > 1 ? ( |
| 312 | 328 | <View className="flex-row items-center gap-2 px-1 pt-2 pb-1"> |
| ... | ... | @@ -323,7 +339,9 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 323 | 339 | ) : null} |
| 324 | 340 | {chain.layers.map((link, index) => ( |
| 325 | 341 | <View key={threadPullRequestKeyOf(link)}> |
| 326 | {index > 0 ? <View className="ml-12 h-px bg-border" /> : null} | |
| 342 | {index > 0 && Platform.OS !== "android" ? ( | |
| 343 | <View className="ml-12 h-px bg-border" /> | |
| 344 | ) : null} | |
| 327 | 345 | <SheetListRow |
| 328 | 346 | icon="arrow.triangle.pull" |
| 329 | 347 | title={`#${link.number} ${link.snapshot?.title ?? "Pull request"}`} |
| ... | ... | @@ -416,8 +434,19 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 416 | 434 | return ( |
| 417 | 435 | <View |
| 418 | 436 | collapsable={false} |
| 419 | className={isInspector ? "flex-1 border-l border-border bg-sheet" : "flex-1 bg-sheet"} | |
| 437 | className={ | |
| 438 | Platform.OS === "android" | |
| 439 | ? "flex-1 bg-header" | |
| 440 | : isInspector | |
| 441 | ? "flex-1 border-l border-border bg-sheet" | |
| 442 | : "flex-1 bg-sheet" | |
| 443 | } | |
| 420 | 444 | > |
| 445 | {!isInspector ? ( | |
| 446 | <NativeStackScreenOptions | |
| 447 | options={{ sheetCornerRadius: Platform.OS === "android" ? 28 : undefined }} | |
| 448 | /> | |
| 449 | ) : null} | |
| 421 | 450 | {isInspector ? ( |
| 422 | 451 | <View |
| 423 | 452 | style={{ |
| ... | ... | @@ -428,24 +457,41 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 428 | 457 | ) : null} |
| 429 | 458 | |
| 430 | 459 | {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 | )} | |
| 449 | 495 | <Text className="text-xs font-t3-bold tracking-[1px] uppercase text-foreground-muted"> |
| 450 | 496 | Repository |
| 451 | 497 | </Text> |
| ... | ... | @@ -457,6 +503,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 457 | 503 | ) : ( |
| 458 | 504 | <AndroidSheetHeader |
| 459 | 505 | title={currentBranchLabel} |
| 506 | hideBottomBorder={Platform.OS === "android"} | |
| 460 | 507 | subtitle={currentStatusSummary} |
| 461 | 508 | onBack={() => navigation.goBack()} |
| 462 | 509 | actions={[ |
| ... | ... | @@ -470,7 +517,9 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 470 | 517 | /> |
| 471 | 518 | )} |
| 472 | 519 | |
| 473 | {content} | |
| 520 | <MaterialScreenContent insetHorizontal={layout.usesSplitView}> | |
| 521 | {content} | |
| 522 | </MaterialScreenContent> | |
| 474 | 523 | </View> |
| 475 | 524 | ); |
| 476 | 525 | } |
apps/mobile/src/features/threads/git/gitSheetComponents.tsx+58-19| ... | ... | @@ -1,7 +1,8 @@ |
| 1 | 1 | import { SymbolView } from "../../../components/AppSymbol"; |
| 2 | 2 | import type { ComponentProps } from "react"; |
| 3 | import { Pressable, View } from "react-native"; | |
| 3 | import { Platform, Pressable, View } from "react-native"; | |
| 4 | 4 | import { AppText as Text } from "../../../components/AppText"; |
| 5 | import { MaterialButton } from "../../../components/MaterialButton"; | |
| 5 | 6 | import { cn } from "../../../lib/cn"; |
| 6 | 7 | |
| 7 | 8 | /* ─── Shared sheet components ──────────────────────────────────────── */ |
| ... | ... | @@ -13,6 +14,18 @@ export function SheetActionButton(props: { |
| 13 | 14 | readonly tone?: "primary" | "secondary" | "danger"; |
| 14 | 15 | readonly onPress: () => void; |
| 15 | 16 | }) { |
| 17 | if (Platform.OS === "android") | |
| 18 | return ( | |
| 19 | <View className="flex-1"> | |
| 20 | <MaterialButton | |
| 21 | label={props.label} | |
| 22 | tone={props.tone} | |
| 23 | disabled={props.disabled} | |
| 24 | onPress={props.onPress} | |
| 25 | fullWidth | |
| 26 | /> | |
| 27 | </View> | |
| 28 | ); | |
| 16 | 29 | const tone = props.tone ?? "secondary"; |
| 17 | 30 | const textColorClassName = |
| 18 | 31 | tone === "primary" |
| ... | ... | @@ -24,13 +37,13 @@ export function SheetActionButton(props: { |
| 24 | 37 | return ( |
| 25 | 38 | <Pressable |
| 26 | 39 | className={cn( |
| 27 | "min-h-[48px] flex-1 flex-row items-center justify-center gap-2 rounded-[18px] px-4 py-3 disabled:opacity-[0.45]", | |
| 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"), | |
| 33 | 45 | )} |
| 46 | accessibilityRole="button" | |
| 34 | 47 | disabled={props.disabled} |
| 35 | 48 | onPress={props.onPress} |
| 36 | 49 | > |
| ... | ... | @@ -58,7 +71,13 @@ export function SheetActionButton(props: { |
| 58 | 71 | |
| 59 | 72 | export function MetaCard(props: { readonly label: string; readonly value: string }) { |
| 60 | 73 | return ( |
| 61 | <View className="rounded-[18px] border border-border bg-card px-4 py-3"> | |
| 74 | <View | |
| 75 | className={ | |
| 76 | Platform.OS === "android" | |
| 77 | ? "rounded-[20px] bg-card px-4 py-3" | |
| 78 | : "rounded-[18px] border border-border bg-card px-4 py-3" | |
| 79 | } | |
| 80 | > | |
| 62 | 81 | <Text className="text-foreground-muted text-2xs font-t3-bold tracking-[0.9px] uppercase"> |
| 63 | 82 | {props.label} |
| 64 | 83 | </Text> |
| ... | ... | @@ -78,30 +97,50 @@ export function SheetListRow(props: { |
| 78 | 97 | }) { |
| 79 | 98 | return ( |
| 80 | 99 | <Pressable |
| 81 | className="flex-row items-center gap-3 px-1 py-3 disabled:opacity-[0.45]" | |
| 100 | className={ | |
| 101 | Platform.OS === "android" | |
| 102 | ? "min-h-16 flex-row items-center gap-4 px-4 py-3 active:bg-subtle disabled:opacity-[0.45]" | |
| 103 | : "flex-row items-center gap-3 px-1 py-3 disabled:opacity-[0.45]" | |
| 104 | } | |
| 82 | 105 | disabled={props.disabled} |
| 83 | 106 | onPress={props.onPress} |
| 84 | 107 | > |
| 85 | <View className="bg-subtle h-9 w-9 items-center justify-center rounded-full"> | |
| 108 | <View | |
| 109 | className={ | |
| 110 | Platform.OS === "android" | |
| 111 | ? "size-6 items-center justify-center" | |
| 112 | : "bg-subtle h-9 w-9 items-center justify-center rounded-full" | |
| 113 | } | |
| 114 | > | |
| 86 | 115 | <SymbolView |
| 87 | 116 | name={props.icon} |
| 88 | size={16} | |
| 89 | tintColorClassName={"accent-icon"} | |
| 117 | size={Platform.OS === "android" ? 24 : 16} | |
| 118 | tintColorClassName="accent-icon" | |
| 90 | 119 | type="monochrome" |
| 91 | 120 | /> |
| 92 | 121 | </View> |
| 93 | 122 | <View className="flex-1 gap-0.5"> |
| 94 | <Text className="text-foreground text-base font-t3-bold">{props.title}</Text> | |
| 123 | <Text | |
| 124 | className={ | |
| 125 | Platform.OS === "android" | |
| 126 | ? "text-foreground text-base font-t3-medium" | |
| 127 | : "text-foreground text-base font-t3-bold" | |
| 128 | } | |
| 129 | > | |
| 130 | {props.title} | |
| 131 | </Text> | |
| 95 | 132 | {props.subtitle ? ( |
| 96 | 133 | <Text className="text-foreground-muted text-xs leading-snug">{props.subtitle}</Text> |
| 97 | 134 | ) : null} |
| 98 | 135 | </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} | |
| 105 | 144 | </Pressable> |
| 106 | 145 | ); |
| 107 | 146 | } |
apps/mobile/src/features/threads/thread-list-items.tsx+43-36| ... | ... | @@ -183,7 +183,7 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: |
| 183 | 183 | <SymbolView |
| 184 | 184 | name="plus" |
| 185 | 185 | size={compact ? 20 : 16} |
| 186 | tintColorClassName={"accent-icon-muted"} | |
| 186 | tintColorClassName="accent-icon-muted" | |
| 187 | 187 | type="monochrome" |
| 188 | 188 | weight="medium" |
| 189 | 189 | /> |
| ... | ... | @@ -232,7 +232,7 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: |
| 232 | 232 | <SymbolView |
| 233 | 233 | name={icon} |
| 234 | 234 | size={10} |
| 235 | tintColorClassName={"accent-icon-subtle"} | |
| 235 | tintColorClassName="accent-icon-subtle" | |
| 236 | 236 | type="monochrome" |
| 237 | 237 | weight="semibold" |
| 238 | 238 | /> |
| ... | ... | @@ -379,7 +379,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { |
| 379 | 379 | <SymbolView |
| 380 | 380 | name="chevron.right" |
| 381 | 381 | size={13} |
| 382 | tintColorClassName={"accent-icon-subtle"} | |
| 382 | tintColorClassName="accent-icon-subtle" | |
| 383 | 383 | type="monochrome" |
| 384 | 384 | /> |
| 385 | 385 | </View> |
| ... | ... | @@ -469,10 +469,10 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 469 | 469 | >["simultaneousWithExternalGesture"]; |
| 470 | 470 | }) { |
| 471 | 471 | const { width: windowWidth } = useWindowDimensions(); |
| 472 | const { themeAppearance: colorScheme, materialYouStyleLayoutActive } = useAppearancePreferences(); | |
| 472 | const { themeAppearance: colorScheme } = useAppearancePreferences(); | |
| 473 | 473 | const compact = props.variant === "compact"; |
| 474 | 474 | const selected = props.selected === true; |
| 475 | const visuallySelected = selected && (!compact || materialYouStyleLayoutActive); | |
| 475 | const visuallySelected = selected && (!compact || Platform.OS === "android"); | |
| 476 | 476 | const theme = useUniwindTheme(); |
| 477 | 477 | const screenColor = theme["--color-screen"]; |
| 478 | 478 | const drawerColor = theme["--color-drawer"]; |
| ... | ... | @@ -507,22 +507,22 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 507 | 507 | ); |
| 508 | 508 | |
| 509 | 509 | 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; | |
| 516 | 514 | const effectiveStatus = |
| 517 | 515 | visuallySelected && status |
| 518 | 516 | ? { |
| 519 | 517 | ...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", | |
| 526 | 526 | } |
| 527 | 527 | : status; |
| 528 | 528 | |
| ... | ... | @@ -596,7 +596,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 596 | 596 | compact |
| 597 | 597 | ? "accent-icon-subtle" |
| 598 | 598 | : selected |
| 599 | ? materialYouStyleLayoutActive | |
| 599 | ? Platform.OS === "android" | |
| 600 | 600 | ? "accent-thread-selected-foreground-muted" |
| 601 | 601 | : "accent-user-bubble-foreground-muted" |
| 602 | 602 | : "accent-foreground-muted" |
| ... | ... | @@ -608,7 +608,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 608 | 608 | "shrink", |
| 609 | 609 | compact ? "text-sm" : "text-xs", |
| 610 | 610 | visuallySelected |
| 611 | ? materialYouStyleLayoutActive | |
| 611 | ? Platform.OS === "android" | |
| 612 | 612 | ? "text-thread-selected-foreground-muted" |
| 613 | 613 | : "text-user-bubble-foreground-muted" |
| 614 | 614 | : "text-foreground-muted", |
| ... | ... | @@ -647,7 +647,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 647 | 647 | <Text |
| 648 | 648 | className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${ |
| 649 | 649 | visuallySelected |
| 650 | ? materialYouStyleLayoutActive | |
| 650 | ? Platform.OS === "android" | |
| 651 | 651 | ? "text-thread-selected-foreground" |
| 652 | 652 | : "text-user-bubble-foreground" |
| 653 | 653 | : pr.textClassName |
| ... | ... | @@ -666,7 +666,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 666 | 666 | key={`${thread.environmentId}:${thread.id}`} |
| 667 | 667 | interactionClassName={ |
| 668 | 668 | visuallySelected |
| 669 | ? materialYouStyleLayoutActive | |
| 669 | ? Platform.OS === "android" | |
| 670 | 670 | ? "bg-thread-selected-foreground" |
| 671 | 671 | : "bg-user-bubble-foreground" |
| 672 | 672 | : "bg-primary" |
| ... | ... | @@ -676,10 +676,10 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 676 | 676 | accessibilityRole="button" |
| 677 | 677 | className="bg-screen" |
| 678 | 678 | style={ |
| 679 | materialYouStyleLayoutActive | |
| 679 | Platform.OS === "android" | |
| 680 | 680 | ? { |
| 681 | 681 | backgroundColor: visuallySelected ? effectiveSelectedBackground : backgroundColor, |
| 682 | borderRadius: SIDEBAR_ROW_RADIUS, | |
| 682 | borderRadius: 20, | |
| 683 | 683 | } |
| 684 | 684 | : undefined |
| 685 | 685 | } |
| ... | ... | @@ -689,13 +689,18 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 689 | 689 | }} |
| 690 | 690 | > |
| 691 | 691 | <View className="pr-[18px] pt-[10px]" style={{ paddingLeft: THREAD_LIST_COMPACT_INSET }}> |
| 692 | <View className={cn("gap-[3px] pb-[10px]", !props.isLast && "border-b border-separator")}> | |
| 692 | <View | |
| 693 | className={cn( | |
| 694 | "gap-[3px] pb-[10px]", | |
| 695 | Platform.OS !== "android" && !props.isLast && "border-b border-separator", | |
| 696 | )} | |
| 697 | > | |
| 693 | 698 | <View className="flex-row items-center justify-between gap-2"> |
| 694 | 699 | <Text |
| 695 | 700 | className={cn( |
| 696 | 701 | "flex-1 text-lg font-t3-bold", |
| 697 | 702 | visuallySelected |
| 698 | ? materialYouStyleLayoutActive | |
| 703 | ? Platform.OS === "android" | |
| 699 | 704 | ? "text-thread-selected-foreground" |
| 700 | 705 | : "text-user-bubble-foreground" |
| 701 | 706 | : "text-foreground", |
| ... | ... | @@ -706,14 +711,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 706 | 711 | </Text> |
| 707 | 712 | <View className="flex-row items-center gap-2"> |
| 708 | 713 | {props.hasQueuedMessages ? ( |
| 709 | <QueuedMessageIcon selected={visuallySelected && !materialYouStyleLayoutActive} /> | |
| 714 | <QueuedMessageIcon selected={visuallySelected && Platform.OS !== "android"} /> | |
| 710 | 715 | ) : null} |
| 711 | 716 | {statusPill} |
| 712 | 717 | <Text |
| 713 | 718 | className={cn( |
| 714 | 719 | "text-base tabular-nums", |
| 715 | 720 | visuallySelected |
| 716 | ? materialYouStyleLayoutActive | |
| 721 | ? Platform.OS === "android" | |
| 717 | 722 | ? "text-thread-selected-foreground-muted" |
| 718 | 723 | : "text-user-bubble-foreground-muted" |
| 719 | 724 | : "text-foreground-tertiary", |
| ... | ... | @@ -724,7 +729,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 724 | 729 | <SymbolView |
| 725 | 730 | name="chevron.right" |
| 726 | 731 | size={13} |
| 727 | tintColorClassName={"accent-icon-subtle"} | |
| 732 | tintColorClassName="accent-icon-subtle" | |
| 728 | 733 | type="monochrome" |
| 729 | 734 | /> |
| 730 | 735 | </View> |
| ... | ... | @@ -746,7 +751,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 746 | 751 | key={`${thread.environmentId}:${thread.id}`} |
| 747 | 752 | interactionClassName={ |
| 748 | 753 | visuallySelected |
| 749 | ? materialYouStyleLayoutActive | |
| 754 | ? Platform.OS === "android" | |
| 750 | 755 | ? "bg-thread-selected-foreground" |
| 751 | 756 | : "bg-user-bubble-foreground" |
| 752 | 757 | : "bg-primary" |
| ... | ... | @@ -761,7 +766,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 761 | 766 | }} |
| 762 | 767 | style={{ |
| 763 | 768 | backgroundColor: visuallySelected ? effectiveSelectedBackground : backgroundColor, |
| 764 | borderRadius: SIDEBAR_ROW_RADIUS, | |
| 769 | borderRadius: Platform.OS === "android" ? 20 : SIDEBAR_ROW_RADIUS, | |
| 765 | 770 | minHeight: 64, |
| 766 | 771 | justifyContent: "center", |
| 767 | 772 | paddingHorizontal: 12, |
| ... | ... | @@ -774,7 +779,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 774 | 779 | className={cn( |
| 775 | 780 | "flex-1 text-base font-t3-medium", |
| 776 | 781 | visuallySelected |
| 777 | ? materialYouStyleLayoutActive | |
| 782 | ? Platform.OS === "android" | |
| 778 | 783 | ? "text-thread-selected-foreground" |
| 779 | 784 | : "text-user-bubble-foreground" |
| 780 | 785 | : "text-foreground", |
| ... | ... | @@ -785,14 +790,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 785 | 790 | </Text> |
| 786 | 791 | <View className="flex-row items-center gap-2"> |
| 787 | 792 | {props.hasQueuedMessages ? ( |
| 788 | <QueuedMessageIcon selected={visuallySelected && !materialYouStyleLayoutActive} /> | |
| 793 | <QueuedMessageIcon selected={visuallySelected && Platform.OS !== "android"} /> | |
| 789 | 794 | ) : null} |
| 790 | 795 | {statusPill} |
| 791 | 796 | <Text |
| 792 | 797 | className={cn( |
| 793 | 798 | "text-xs tabular-nums", |
| 794 | 799 | visuallySelected |
| 795 | ? materialYouStyleLayoutActive | |
| 800 | ? Platform.OS === "android" | |
| 796 | 801 | ? "text-thread-selected-foreground-muted" |
| 797 | 802 | : "text-user-bubble-foreground-muted" |
| 798 | 803 | : "text-foreground-muted", |
| ... | ... | @@ -820,9 +825,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 820 | 825 | threadKey={`${thread.environmentId}:${thread.id}`} |
| 821 | 826 | backgroundColor={backgroundColor} |
| 822 | 827 | 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" } | |
| 826 | 833 | } |
| 827 | 834 | enableTrackpadSwipe |
| 828 | 835 | fullSwipeWidth={props.fullSwipeWidth ?? windowWidth - 32} |
apps/mobile/src/features/threads/thread-list-v2-items.tsx+42-36| ... | ... | @@ -1,6 +1,5 @@ |
| 1 | 1 | import { RowPressable } from "../../components/RowPressable"; |
| 2 | 2 | import { CustomSnoozeSheet } from "./CustomSnoozeSheet"; |
| 3 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | |
| 4 | 3 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 5 | 4 | import { threadArrangementOpenAtom } from "../../state/thread-order"; |
| 6 | 5 | import type { ThreadMoveDestination } from "./threadOrder"; |
| ... | ... | @@ -182,7 +181,7 @@ export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledS |
| 182 | 181 | <SymbolView |
| 183 | 182 | name="chevron.down" |
| 184 | 183 | size={10} |
| 185 | tintColorClassName={"accent-foreground-muted"} | |
| 184 | tintColorClassName="accent-foreground-muted" | |
| 186 | 185 | type="monochrome" |
| 187 | 186 | style={{ transform: [{ rotate: props.expanded ? "180deg" : "0deg" }] }} |
| 188 | 187 | /> |
| ... | ... | @@ -441,12 +440,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 441 | 440 | |
| 442 | 441 | const pr = useThreadPr(thread); |
| 443 | 442 | |
| 444 | const { materialYouStyleLayoutActive } = useAppearancePreferences(); | |
| 445 | 443 | const theme = useUniwindTheme(); |
| 446 | 444 | const screenColor = theme["--color-screen"]; |
| 447 | 445 | const drawerColor = theme["--color-drawer"]; |
| 448 | 446 | const selectedBackgroundColor = |
| 449 | theme[materialYouStyleLayoutActive ? "--color-thread-selected" : "--color-user-bubble"]; | |
| 447 | theme[Platform.OS === "android" ? "--color-thread-selected" : "--color-user-bubble"]; | |
| 450 | 448 | const sidebarPane = props.pane === "sidebar"; |
| 451 | 449 | const selected = props.selected === true; |
| 452 | 450 | // The provider badge's border blends into the row's own surface, which |
| ... | ... | @@ -456,7 +454,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 456 | 454 | const providerIconSurfaceColor = sidebarPane |
| 457 | 455 | ? selected |
| 458 | 456 | ? selectedBackgroundColor |
| 459 | : drawerColor | |
| 457 | : Platform.OS === "android" | |
| 458 | ? screenColor | |
| 459 | : drawerColor | |
| 460 | 460 | : screenColor; |
| 461 | 461 | |
| 462 | 462 | const status = resolveThreadListV2Status(thread); |
| ... | ... | @@ -750,7 +750,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 750 | 750 | className={cn( |
| 751 | 751 | "flex-1 text-sm font-t3-medium", |
| 752 | 752 | selected |
| 753 | ? materialYouStyleLayoutActive | |
| 753 | ? Platform.OS === "android" | |
| 754 | 754 | ? "text-thread-selected-foreground-muted" |
| 755 | 755 | : "text-user-bubble-foreground-muted" |
| 756 | 756 | : "text-foreground-muted", |
| ... | ... | @@ -764,7 +764,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 764 | 764 | <SymbolView |
| 765 | 765 | name="pin" |
| 766 | 766 | size={11} |
| 767 | tintColorClassName={"accent-foreground-muted"} | |
| 767 | tintColorClassName="accent-foreground-muted" | |
| 768 | 768 | type="monochrome" |
| 769 | 769 | /> |
| 770 | 770 | ) : null} |
| ... | ... | @@ -772,7 +772,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 772 | 772 | className={cn( |
| 773 | 773 | "text-xs tabular-nums", |
| 774 | 774 | selected |
| 775 | ? materialYouStyleLayoutActive | |
| 775 | ? Platform.OS === "android" | |
| 776 | 776 | ? "text-thread-selected-foreground" |
| 777 | 777 | : "text-user-bubble-foreground" |
| 778 | 778 | : (statusLabel?.className ?? "text-foreground-tertiary"), |
| ... | ... | @@ -785,7 +785,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 785 | 785 | className={cn( |
| 786 | 786 | "mt-1 text-base font-t3-medium", |
| 787 | 787 | selected |
| 788 | ? materialYouStyleLayoutActive | |
| 788 | ? Platform.OS === "android" | |
| 789 | 789 | ? "text-thread-selected-foreground" |
| 790 | 790 | : "text-user-bubble-foreground" |
| 791 | 791 | : "text-foreground", |
| ... | ... | @@ -809,7 +809,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 809 | 809 | className={cn( |
| 810 | 810 | "flex-1 text-xs", |
| 811 | 811 | selected |
| 812 | ? materialYouStyleLayoutActive | |
| 812 | ? Platform.OS === "android" | |
| 813 | 813 | ? "text-thread-selected-foreground-muted" |
| 814 | 814 | : "text-user-bubble-foreground-muted" |
| 815 | 815 | : "text-danger-foreground", |
| ... | ... | @@ -830,7 +830,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 830 | 830 | className={cn( |
| 831 | 831 | "shrink text-xs", |
| 832 | 832 | selected |
| 833 | ? materialYouStyleLayoutActive | |
| 833 | ? Platform.OS === "android" | |
| 834 | 834 | ? "text-thread-selected-foreground-muted" |
| 835 | 835 | : "text-user-bubble-foreground-muted" |
| 836 | 836 | : "text-foreground-muted", |
| ... | ... | @@ -842,7 +842,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 842 | 842 | className={cn( |
| 843 | 843 | "text-xs", |
| 844 | 844 | selected |
| 845 | ? materialYouStyleLayoutActive | |
| 845 | ? Platform.OS === "android" | |
| 846 | 846 | ? "text-thread-selected-foreground-muted" |
| 847 | 847 | : "text-user-bubble-foreground-muted" |
| 848 | 848 | : "text-foreground-muted", |
| ... | ... | @@ -858,7 +858,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 858 | 858 | className={cn( |
| 859 | 859 | "text-xs", |
| 860 | 860 | selected |
| 861 | ? materialYouStyleLayoutActive | |
| 861 | ? Platform.OS === "android" | |
| 862 | 862 | ? "text-thread-selected-foreground-muted" |
| 863 | 863 | : "text-user-bubble-foreground-muted" |
| 864 | 864 | : "text-foreground-tertiary", |
| ... | ... | @@ -874,7 +874,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 874 | 874 | size={11} |
| 875 | 875 | tintColorClassName={ |
| 876 | 876 | selected |
| 877 | ? materialYouStyleLayoutActive | |
| 877 | ? Platform.OS === "android" | |
| 878 | 878 | ? "accent-thread-selected-foreground-muted" |
| 879 | 879 | : "accent-user-bubble-foreground-muted" |
| 880 | 880 | : "accent-foreground-tertiary" |
| ... | ... | @@ -893,7 +893,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 893 | 893 | size={12} |
| 894 | 894 | tintColorClassName={ |
| 895 | 895 | selected |
| 896 | ? materialYouStyleLayoutActive | |
| 896 | ? Platform.OS === "android" | |
| 897 | 897 | ? "accent-thread-selected-foreground" |
| 898 | 898 | : "accent-user-bubble-foreground" |
| 899 | 899 | : pr.state === null || pr.isDraft |
| ... | ... | @@ -911,7 +911,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 911 | 911 | className={cn( |
| 912 | 912 | "text-xs", |
| 913 | 913 | selected |
| 914 | ? materialYouStyleLayoutActive | |
| 914 | ? Platform.OS === "android" | |
| 915 | 915 | ? "text-thread-selected-foreground" |
| 916 | 916 | : "text-user-bubble-foreground" |
| 917 | 917 | : pr.textClassName, |
| ... | ... | @@ -941,13 +941,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 941 | 941 | <RowPressable |
| 942 | 942 | key={`${thread.environmentId}:${thread.id}`} |
| 943 | 943 | interactionClassName={ |
| 944 | selected && (sidebarPane || materialYouStyleLayoutActive) | |
| 945 | ? materialYouStyleLayoutActive | |
| 944 | selected && (sidebarPane || Platform.OS === "android") | |
| 945 | ? Platform.OS === "android" | |
| 946 | 946 | ? "bg-thread-selected-foreground" |
| 947 | 947 | : "bg-user-bubble-foreground" |
| 948 | 948 | : "bg-primary" |
| 949 | 949 | } |
| 950 | className={sidebarPane || materialYouStyleLayoutActive ? undefined : "bg-screen"} | |
| 950 | className={sidebarPane || Platform.OS === "android" ? undefined : "bg-screen"} | |
| 951 | 951 | accessibilityHint={swipeAccessibilityHint} |
| 952 | 952 | accessibilityLabel={ |
| 953 | 953 | props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title |
| ... | ... | @@ -959,14 +959,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 959 | 959 | onSelectThread(thread); |
| 960 | 960 | }} |
| 961 | 961 | style={ |
| 962 | sidebarPane || materialYouStyleLayoutActive | |
| 962 | sidebarPane || Platform.OS === "android" | |
| 963 | 963 | ? { |
| 964 | 964 | backgroundColor: selected |
| 965 | 965 | ? selectedBackgroundColor |
| 966 | : sidebarPane | |
| 966 | : sidebarPane && Platform.OS !== "android" | |
| 967 | 967 | ? drawerColor |
| 968 | 968 | : screenColor, |
| 969 | borderRadius: SIDEBAR_V2_ROW_RADIUS, | |
| 969 | borderRadius: Platform.OS === "android" ? 20 : SIDEBAR_V2_ROW_RADIUS, | |
| 970 | 970 | ...(sidebarPane ? { paddingHorizontal: 12, paddingVertical: 10 } : null), |
| 971 | 971 | } |
| 972 | 972 | : undefined |
| ... | ... | @@ -980,8 +980,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 980 | 980 | separates rows. The opaque screen background stays so swipe |
| 981 | 981 | actions reveal behind the row. */ |
| 982 | 982 | <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 ? ( | |
| 985 | 987 | <View className="ml-5 h-px bg-border-subtle" /> |
| 986 | 988 | ) : null} |
| 987 | 989 | </View> |
| ... | ... | @@ -991,8 +993,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 991 | 993 | <RowPressable |
| 992 | 994 | key={`${thread.environmentId}:${thread.id}`} |
| 993 | 995 | interactionClassName={ |
| 994 | selected && (sidebarPane || materialYouStyleLayoutActive) | |
| 995 | ? materialYouStyleLayoutActive | |
| 996 | selected && (sidebarPane || Platform.OS === "android") | |
| 997 | ? Platform.OS === "android" | |
| 996 | 998 | ? "bg-thread-selected-foreground" |
| 997 | 999 | : "bg-user-bubble-foreground" |
| 998 | 1000 | : "bg-primary" |
| ... | ... | @@ -1003,20 +1005,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 1003 | 1005 | } |
| 1004 | 1006 | accessibilityRole="button" |
| 1005 | 1007 | accessibilityState={{ selected }} |
| 1006 | className={sidebarPane || materialYouStyleLayoutActive ? undefined : "bg-screen"} | |
| 1008 | className={sidebarPane || Platform.OS === "android" ? undefined : "bg-screen"} | |
| 1007 | 1009 | onPress={() => { |
| 1008 | 1010 | close(); |
| 1009 | 1011 | onSelectThread(thread); |
| 1010 | 1012 | }} |
| 1011 | 1013 | style={ |
| 1012 | sidebarPane || materialYouStyleLayoutActive | |
| 1014 | sidebarPane || Platform.OS === "android" | |
| 1013 | 1015 | ? { |
| 1014 | 1016 | backgroundColor: selected |
| 1015 | 1017 | ? selectedBackgroundColor |
| 1016 | : sidebarPane | |
| 1018 | : sidebarPane && Platform.OS !== "android" | |
| 1017 | 1019 | ? drawerColor |
| 1018 | 1020 | : screenColor, |
| 1019 | borderRadius: SIDEBAR_V2_ROW_RADIUS, | |
| 1021 | borderRadius: Platform.OS === "android" ? 20 : SIDEBAR_V2_ROW_RADIUS, | |
| 1020 | 1022 | } |
| 1021 | 1023 | : undefined |
| 1022 | 1024 | } |
| ... | ... | @@ -1044,7 +1046,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 1044 | 1046 | className={cn( |
| 1045 | 1047 | "text-base", |
| 1046 | 1048 | selected |
| 1047 | ? materialYouStyleLayoutActive | |
| 1049 | ? Platform.OS === "android" | |
| 1048 | 1050 | ? "text-thread-selected-foreground" |
| 1049 | 1051 | : "text-user-bubble-foreground" |
| 1050 | 1052 | : "text-foreground-muted", |
| ... | ... | @@ -1066,7 +1068,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 1066 | 1068 | className={cn( |
| 1067 | 1069 | "text-sm tabular-nums", |
| 1068 | 1070 | selected |
| 1069 | ? materialYouStyleLayoutActive | |
| 1071 | ? Platform.OS === "android" | |
| 1070 | 1072 | ? "text-thread-selected-foreground-muted" |
| 1071 | 1073 | : "text-user-bubble-foreground-muted" |
| 1072 | 1074 | : snoozedRow |
| ... | ... | @@ -1084,16 +1086,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 1084 | 1086 | ); |
| 1085 | 1087 | |
| 1086 | 1088 | return ( |
| 1087 | <> | |
| 1089 | <View collapsable={false}> | |
| 1088 | 1090 | {customSnoozeOpen && ( |
| 1089 | 1091 | <CustomSnoozeSheet onClose={() => setCustomSnoozeOpen(false)} onSnooze={handleSnooze} /> |
| 1090 | 1092 | )} |
| 1091 | 1093 | <ThreadSwipeable |
| 1092 | 1094 | threadKey={`${thread.environmentId}:${thread.id}`} |
| 1093 | backgroundColor={sidebarPane ? drawerColor : screenColor} | |
| 1095 | backgroundColor={sidebarPane && Platform.OS !== "android" ? drawerColor : screenColor} | |
| 1094 | 1096 | compactActions={variant === "slim"} |
| 1095 | 1097 | 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 | |
| 1097 | 1103 | } |
| 1098 | 1104 | enableTrackpadSwipe |
| 1099 | 1105 | // Full swipe commits the advertised lifecycle action (Settle / |
| ... | ... | @@ -1141,6 +1147,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 1141 | 1147 | </ControlPillMenu> |
| 1142 | 1148 | )} |
| 1143 | 1149 | </ThreadSwipeable> |
| 1144 | </> | |
| 1150 | </View> | |
| 1145 | 1151 | ); |
| 1146 | 1152 | }); |
apps/mobile/src/features/threads/thread-search-match.tsx+4-5| ... | ... | @@ -1,8 +1,8 @@ |
| 1 | import { Platform } from "react-native"; | |
| 1 | 2 | import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; |
| 2 | 3 | |
| 3 | 4 | import { AppText as Text } from "../../components/AppText"; |
| 4 | 5 | import { cn } from "../../lib/cn"; |
| 5 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | |
| 6 | 6 | |
| 7 | 7 | function foldAsciiCase(value: string): string { |
| 8 | 8 | return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); |
| ... | ... | @@ -51,14 +51,13 @@ export function ThreadSearchMatchExcerpt(props: { |
| 51 | 51 | readonly compact?: boolean; |
| 52 | 52 | }) { |
| 53 | 53 | const isUser = props.match.source === "user"; |
| 54 | const { materialYouStyleLayoutActive } = useAppearancePreferences(); | |
| 55 | 54 | const parts = splitHighlightParts(props.match.snippet, props.query); |
| 56 | 55 | return ( |
| 57 | 56 | <Text |
| 58 | 57 | className={cn( |
| 59 | 58 | props.compact ? "text-sm" : "text-xs", |
| 60 | 59 | props.selected |
| 61 | ? materialYouStyleLayoutActive | |
| 60 | ? Platform.OS === "android" | |
| 62 | 61 | ? "text-thread-selected-foreground-muted" |
| 63 | 62 | : "text-user-bubble-foreground-muted" |
| 64 | 63 | : "text-foreground-muted", |
| ... | ... | @@ -69,7 +68,7 @@ export function ThreadSearchMatchExcerpt(props: { |
| 69 | 68 | className={cn( |
| 70 | 69 | props.compact ? "text-sm font-t3-medium" : "text-xs font-t3-medium", |
| 71 | 70 | props.selected |
| 72 | ? materialYouStyleLayoutActive | |
| 71 | ? Platform.OS === "android" | |
| 73 | 72 | ? "text-thread-selected-foreground" |
| 74 | 73 | : "text-user-bubble-foreground" |
| 75 | 74 | : isUser |
| ... | ... | @@ -85,7 +84,7 @@ export function ThreadSearchMatchExcerpt(props: { |
| 85 | 84 | props.compact ? "text-sm" : "text-xs", |
| 86 | 85 | part.highlighted && "font-t3-bold", |
| 87 | 86 | props.selected |
| 88 | ? materialYouStyleLayoutActive | |
| 87 | ? Platform.OS === "android" | |
| 89 | 88 | ? "text-thread-selected-foreground" |
| 90 | 89 | : "text-user-bubble-foreground" |
| 91 | 90 | : part.highlighted |
apps/mobile/src/features/usage/UsageLimitsPooled.tsx+4-12| ... | ... | @@ -12,15 +12,14 @@ import { |
| 12 | 12 | type LimitPoolWindow, |
| 13 | 13 | } from "@t3tools/shared/usageLimits"; |
| 14 | 14 | import { useId, useState } from "react"; |
| 15 | import { Platform, Pressable, ScrollView, View } from "react-native"; | |
| 15 | import { Pressable, ScrollView, View } from "react-native"; | |
| 16 | 16 | import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg"; |
| 17 | 17 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 18 | 18 | |
| 19 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | |
| 20 | 19 | import { SymbolView } from "../../components/AppSymbol"; |
| 21 | 20 | import { AppText as Text } from "../../components/AppText"; |
| 22 | 21 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 23 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 22 | import { SettingsScreen } from "../settings/components/SettingsScreen"; | |
| 24 | 23 | import { environmentPresentations } from "../../state/presentation"; |
| 25 | 24 | import { ResetCredits } from "./UsageLimitsSection"; |
| 26 | 25 | import { useProviderColors } from "./usageProviders"; |
| ... | ... | @@ -278,7 +277,6 @@ type AccountScreenProps = StaticScreenProps<{ |
| 278 | 277 | |
| 279 | 278 | /** Resolve the account again so live quota and credit updates reach the open detail screen. */ |
| 280 | 279 | export function UsageLimitAccountScreen({ route }: AccountScreenProps) { |
| 281 | const navigation = useNavigation(); | |
| 282 | 280 | const insets = useSafeAreaInsets(); |
| 283 | 281 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); |
| 284 | 282 | const { accountKey, windowId, windowKind, environmentIds, now } = route.params; |
| ... | ... | @@ -297,13 +295,7 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) { |
| 297 | 295 | const reset = pool?.resets.find((candidate) => candidate.member.account.key === accountKey); |
| 298 | 296 | const [revealed, setRevealed] = useState(false); |
| 299 | 297 | 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"> | |
| 307 | 299 | <ScrollView |
| 308 | 300 | contentInsetAdjustmentBehavior="automatic" |
| 309 | 301 | contentContainerClassName="gap-5 p-5" |
| ... | ... | @@ -389,6 +381,6 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) { |
| 389 | 381 | </> |
| 390 | 382 | )} |
| 391 | 383 | </ScrollView> |
| 392 | </View> | |
| 384 | </SettingsScreen> | |
| 393 | 385 | ); |
| 394 | 386 | } |
apps/mobile/src/features/usage/UsageRouteScreen.tsx+8-18| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | |
| 1 | 2 | import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; |
| 2 | 3 | import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native"; |
| 3 | 4 | import { |
| ... | ... | @@ -18,15 +19,14 @@ import { |
| 18 | 19 | makeWindow, |
| 19 | 20 | } from "@t3tools/shared/usageFormat"; |
| 20 | 21 | import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 21 | import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; | |
| 22 | import { Platform, Pressable, RefreshControl, View } from "react-native"; | |
| 22 | 23 | import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated"; |
| 23 | 24 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 24 | 25 | |
| 25 | 26 | import { SegmentedControl } from "../../components/SegmentedControl"; |
| 26 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | |
| 27 | 27 | import { AppText as Text } from "../../components/AppText"; |
| 28 | 28 | import { cn } from "../../lib/cn"; |
| 29 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 29 | import { SettingsScreen } from "../settings/components/SettingsScreen"; | |
| 30 | 30 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; |
| 31 | 31 | import { SettingsSection } from "../settings/components/SettingsSection"; |
| 32 | 32 | import { UsageDailyChart } from "./UsageDailyChart"; |
| ... | ... | @@ -236,17 +236,7 @@ export function UsageRouteScreen() { |
| 236 | 236 | }, [navigation, environmentFilter]); |
| 237 | 237 | |
| 238 | 238 | 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}> | |
| 250 | 240 | <ScrollView |
| 251 | 241 | contentInsetAdjustmentBehavior="automatic" |
| 252 | 242 | showsVerticalScrollIndicator={false} |
| ... | ... | @@ -277,20 +267,20 @@ export function UsageRouteScreen() { |
| 277 | 267 | <> |
| 278 | 268 | {/* Period and metric together: neither applies to Limits, and |
| 279 | 269 | both change every number below, so they share one bar. */} |
| 280 | <View className="flex-row items-center gap-3"> | |
| 270 | <View className={cn("gap-3", Platform.OS !== "android" && "flex-row items-center")}> | |
| 281 | 271 | <SegmentedControl |
| 282 | 272 | options={WINDOW_OPTIONS} |
| 283 | 273 | selected={windowDays} |
| 284 | 274 | onSelect={selectWindow} |
| 285 | 275 | size="compact" |
| 286 | className="flex-1" | |
| 276 | className={Platform.OS === "android" ? "w-full" : "flex-1"} | |
| 287 | 277 | /> |
| 288 | 278 | <SegmentedControl |
| 289 | 279 | options={METRIC_OPTIONS} |
| 290 | 280 | selected={metric} |
| 291 | 281 | onSelect={setMetric} |
| 292 | 282 | size="compact" |
| 293 | className="w-36" | |
| 283 | className={Platform.OS === "android" ? "w-full" : "w-36"} | |
| 294 | 284 | /> |
| 295 | 285 | </View> |
| 296 | 286 | {merged.duplicateSources.length > 0 ? ( |
| ... | ... | @@ -330,7 +320,7 @@ export function UsageRouteScreen() { |
| 330 | 320 | )} |
| 331 | 321 | </Animated.View> |
| 332 | 322 | </ScrollView> |
| 333 | </View> | |
| 323 | </SettingsScreen> | |
| 334 | 324 | ); |
| 335 | 325 | } |
| 336 | 326 |
apps/mobile/src/lib/appBlurTarget.ts deleted-8| ... | ... | @@ -1,8 +0,0 @@ |
| 1 | import { createRef } from "react"; | |
| 2 | import 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. | |
| 8 | export const appBlurTargetRef = createRef<View>(); |
apps/mobile/src/lib/glassBlurTarget.ts deleted-6| ... | ... | @@ -1,6 +0,0 @@ |
| 1 | import { createContext, type RefObject } from "react"; | |
| 2 | import 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. | |
| 6 | export const GlassBlurTargetContext = createContext<RefObject<View | null> | undefined>(undefined); |
apps/mobile/src/lib/materialYouTheme.test.ts+15-3| ... | ... | @@ -31,8 +31,20 @@ const palette: MaterialYouPalette = { |
| 31 | 31 | }; |
| 32 | 32 | |
| 33 | 33 | describe("Material You system colors", () => { |
| 34 | it.each(["light", "dark"] as const)( | |
| 35 | "uses the system surface for the Android frame in %s", | |
| 36 | (appearance) => { | |
| 37 | const variables = materialYouPaletteToMobileThemeVariables( | |
| 38 | palette, | |
| 39 | appearance, | |
| 40 | getMobileThemeRuntimeVariables("material-you", appearance, "android"), | |
| 41 | ); | |
| 42 | expect(variables["--color-header"]).toBe(palette.surfaceContainerHigh); | |
| 43 | }, | |
| 44 | ); | |
| 45 | ||
| 34 | 46 | it("overrides the selected theme without mutating its base variables", () => { |
| 35 | const base = getMobileThemeRuntimeVariables("t3-code", "dark"); | |
| 47 | const base = getMobileThemeRuntimeVariables("t3-code", "dark", "android"); | |
| 36 | 48 | const snapshot = { ...base }; |
| 37 | 49 | |
| 38 | 50 | const variables = materialYouPaletteToMobileThemeVariables(palette, "dark", base); |
| ... | ... | @@ -47,7 +59,7 @@ describe("Material You system colors", () => { |
| 47 | 59 | }); |
| 48 | 60 | |
| 49 | 61 | it("uses the Messages-style RCS tones for sent messages", () => { |
| 50 | const base = getMobileThemeRuntimeVariables("t3-code", "dark"); | |
| 62 | const base = getMobileThemeRuntimeVariables("t3-code", "dark", "android"); | |
| 51 | 63 | const dark = materialYouPaletteToMobileThemeVariables( |
| 52 | 64 | { ...palette, inversePrimary: "#A31D8DFF" }, |
| 53 | 65 | "dark", |
| ... | ... | @@ -56,7 +68,7 @@ describe("Material You system colors", () => { |
| 56 | 68 | const light = materialYouPaletteToMobileThemeVariables( |
| 57 | 69 | { ...palette, inversePrimary: "#A31D8DFF" }, |
| 58 | 70 | "light", |
| 59 | getMobileThemeRuntimeVariables("t3-code", "light"), | |
| 71 | getMobileThemeRuntimeVariables("t3-code", "light", "android"), | |
| 60 | 72 | ); |
| 61 | 73 | |
| 62 | 74 | expect(dark["--color-user-bubble"]).toBe("#850073FF"); |
apps/mobile/src/lib/mobileThemeVariables.test.ts+38-5| ... | ... | @@ -1,25 +1,58 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | 3 | import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support"; |
| 4 | import { getMobileThemeVariables } from "./mobileTheme"; | |
| 4 | import { getMobileThemeVariables, MOBILE_THEME_IDS, themeColorWithAlpha } from "./mobileTheme"; | |
| 5 | 5 | import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; |
| 6 | 6 | |
| 7 | 7 | describe("mobile theme runtime variables", () => { |
| 8 | 8 | it("derives the standard runtime palette from global.css", () => { |
| 9 | expect(getMobileThemeRuntimeVariables("t3-code", "light")).toEqual( | |
| 9 | expect(getMobileThemeRuntimeVariables("t3-code", "light", "ios")).toEqual( | |
| 10 | 10 | readDefaultMobileThemeVariables("light"), |
| 11 | 11 | ); |
| 12 | expect(getMobileThemeRuntimeVariables("t3-code", "dark")).toEqual( | |
| 12 | expect(getMobileThemeRuntimeVariables("t3-code", "dark", "ios")).toEqual( | |
| 13 | 13 | readDefaultMobileThemeVariables("dark"), |
| 14 | 14 | ); |
| 15 | 15 | }); |
| 16 | 16 | |
| 17 | 17 | it("uses the same shared palette source as generated custom themes", () => { |
| 18 | expect(getMobileThemeRuntimeVariables("ocean", "light")).toEqual( | |
| 18 | expect(getMobileThemeRuntimeVariables("ocean", "light", "ios")).toEqual( | |
| 19 | 19 | getMobileThemeVariables("ocean", "light"), |
| 20 | 20 | ); |
| 21 | expect(getMobileThemeRuntimeVariables("iris", "dark")).toEqual( | |
| 21 | expect(getMobileThemeRuntimeVariables("iris", "dark", "ios")).toEqual( | |
| 22 | 22 | getMobileThemeVariables("iris", "dark"), |
| 23 | 23 | ); |
| 24 | 24 | }); |
| 25 | ||
| 26 | it.each(MOBILE_THEME_IDS)( | |
| 27 | "keeps %s colors on Android with an opaque Material frame", | |
| 28 | (themeId) => { | |
| 29 | for (const appearance of ["light", "dark"] as const) { | |
| 30 | const ios = getMobileThemeRuntimeVariables(themeId, appearance, "ios"); | |
| 31 | const android = getMobileThemeRuntimeVariables(themeId, appearance, "android"); | |
| 32 | expect(android).toEqual({ | |
| 33 | ...ios, | |
| 34 | "--color-header": themeColorWithAlpha( | |
| 35 | ios[ | |
| 36 | themeId === "t3-code" || themeId === "material-you" | |
| 37 | ? "--color-card" | |
| 38 | : "--color-drawer" | |
| 39 | ], | |
| 40 | 1, | |
| 41 | ), | |
| 42 | }); | |
| 43 | expect(android["--color-header"]).toMatch(/^rgba\(\d+, \d+, \d+, 1\)$/); | |
| 44 | } | |
| 45 | }, | |
| 46 | ); | |
| 47 | ||
| 48 | it.each(["t3-code", "material-you"] as const)( | |
| 49 | "keeps the %s default dark frame distinct from the rounded settings body", | |
| 50 | (themeId) => { | |
| 51 | const variables = getMobileThemeRuntimeVariables(themeId, "dark", "android"); | |
| 52 | expect(variables["--color-header"]).toBe("rgba(23, 23, 23, 1)"); | |
| 53 | expect(variables["--color-header"]).not.toBe( | |
| 54 | themeColorWithAlpha(variables["--color-sheet-solid"], 1), | |
| 55 | ); | |
| 56 | }, | |
| 57 | ); | |
| 25 | 58 | }); |
apps/mobile/src/lib/mobileThemeVariables.ts+16-2| ... | ... | @@ -1,8 +1,8 @@ |
| 1 | 1 | import defaultThemeVariables from "../../generated-uniwind-default-theme-variables.json"; |
| 2 | ||
| 3 | 2 | import { |
| 4 | 3 | DEFAULT_MOBILE_THEME_ID, |
| 5 | 4 | getMobileThemeVariables, |
| 5 | themeColorWithAlpha, | |
| 6 | 6 | type MobileThemeAppearance, |
| 7 | 7 | type MobileThemeId, |
| 8 | 8 | type MobileThemeVariables, |
| ... | ... | @@ -20,8 +20,22 @@ const defaults = defaultThemeVariables as Readonly< |
| 20 | 20 | export function getMobileThemeRuntimeVariables( |
| 21 | 21 | themeId: MobileThemeId, |
| 22 | 22 | appearance: MobileThemeAppearance, |
| 23 | platform: string, | |
| 23 | 24 | ): MobileThemeVariables { |
| 24 | return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you" | |
| 25 | const usesDefaultPalette = themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you"; | |
| 26 | const variables = usesDefaultPalette | |
| 25 | 27 | ? defaults[appearance] |
| 26 | 28 | : getMobileThemeVariables(themeId, appearance); |
| 29 | if (platform !== "android") return variables; | |
| 30 | ||
| 31 | // Rounded panes share one opaque frame. The default dark drawer matches the | |
| 32 | // settings body, so use its card tone to keep the rounded edge visible. | |
| 33 | // System colors replace this with their own surfaceContainerHigh afterwards. | |
| 34 | return { | |
| 35 | ...variables, | |
| 36 | "--color-header": themeColorWithAlpha( | |
| 37 | variables[usesDefaultPalette ? "--color-card" : "--color-drawer"], | |
| 38 | 1, | |
| 39 | ), | |
| 40 | }; | |
| 27 | 41 | } |
apps/mobile/src/lib/storage.test.ts+9-15| ... | ... | @@ -204,21 +204,15 @@ describe("mobile connection storage", () => { |
| 204 | 204 | await expect(loadPreferences()).resolves.toEqual({ ...themes, lightThemeId: "t3-chat" }); |
| 205 | 205 | }); |
| 206 | 206 | |
| 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" }); | |
| 222 | 216 | }); |
| 223 | 217 | |
| 224 | 218 | it("drops the removed theme transition preference", async () => { |
apps/mobile/src/native/SelectableMarkdownText.android.tsx+17-1| ... | ... | @@ -2,8 +2,11 @@ import { |
| 2 | 2 | SelectableMarkdownText as T3SelectableMarkdownText, |
| 3 | 3 | type SelectableMarkdownTextProps, |
| 4 | 4 | } from "@t3tools/mobile-markdown-text/renderer"; |
| 5 | import { useMemo } from "react"; | |
| 5 | 6 | |
| 6 | 7 | import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"; |
| 8 | import { themeColorWithAlpha } from "../lib/mobileTheme"; | |
| 9 | import { useUniwindTheme } from "../lib/useUniwindTheme"; | |
| 7 | 10 | |
| 8 | 11 | type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">; |
| 9 | 12 | |
| ... | ... | @@ -20,5 +23,18 @@ export function hasNativeSelectableMarkdownText(): boolean { |
| 20 | 23 | } |
| 21 | 24 | |
| 22 | 25 | export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) { |
| 23 | return <T3SelectableMarkdownText {...props} highlightCode={highlightCodeSnippet} />; | |
| 26 | const theme = useUniwindTheme(); | |
| 27 | const selectionColor = themeColorWithAlpha(theme["--color-primary"], 0.32); | |
| 28 | const selectionHandleColor = theme["--color-primary"]; | |
| 29 | const textStyle = useMemo( | |
| 30 | () => ({ selectionColor, selectionHandleColor, ...props.textStyle }), | |
| 31 | [props.textStyle, selectionColor, selectionHandleColor], | |
| 32 | ); | |
| 33 | return ( | |
| 34 | <T3SelectableMarkdownText | |
| 35 | {...props} | |
| 36 | textStyle={textStyle} | |
| 37 | highlightCode={highlightCodeSnippet} | |
| 38 | /> | |
| 39 | ); | |
| 24 | 40 | } |
apps/mobile/src/native/T3ComposerEditor.native.tsx+2-2| ... | ... | @@ -14,7 +14,7 @@ import { |
| 14 | 14 | type Ref, |
| 15 | 15 | } from "react"; |
| 16 | 16 | import type { NativeSyntheticEvent, ViewProps } from "react-native"; |
| 17 | import { Image, StyleSheet } from "react-native"; | |
| 17 | import { Image, Platform, StyleSheet } from "react-native"; | |
| 18 | 18 | |
| 19 | 19 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; |
| 20 | 20 | import { |
| ... | ... | @@ -259,7 +259,7 @@ export function ComposerEditor({ |
| 259 | 259 | ); |
| 260 | 260 | const { systemColorsActive } = useAppearancePreferences(); |
| 261 | 261 | const themeJson = JSON.stringify({ |
| 262 | selection: systemColorsActive ? theme["--color-primary"] : null, | |
| 262 | selection: Platform.OS === "android" || systemColorsActive ? theme["--color-primary"] : null, | |
| 263 | 263 | text: theme["--color-foreground"], |
| 264 | 264 | placeholder: theme["--color-placeholder"], |
| 265 | 265 | chipBackground: theme["--color-subtle"], |
apps/mobile/src/persistence/mobile-preferences.ts-6| ... | ... | @@ -8,7 +8,6 @@ import * as Semaphore from "effect/Semaphore"; |
| 8 | 8 | import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; |
| 9 | 9 | import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior"; |
| 10 | 10 | import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme"; |
| 11 | ||
| 12 | 11 | import * as MobileDatabase from "./mobile-database"; |
| 13 | 12 | import * as MobileSecureStorage from "./mobile-secure-storage"; |
| 14 | 13 | import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage"; |
| ... | ... | @@ -22,7 +21,6 @@ export interface Preferences { |
| 22 | 21 | readonly lightThemeId?: MobileThemeId; |
| 23 | 22 | readonly darkThemeId?: MobileThemeId; |
| 24 | 23 | readonly themeMode?: MobileThemeMode; |
| 25 | readonly materialYouStyleLayoutEnabled?: boolean; | |
| 26 | 24 | readonly baseFontSize?: number; |
| 27 | 25 | readonly terminalFontSize?: number | null; |
| 28 | 26 | readonly markdownFontSize?: number; |
| ... | ... | @@ -94,7 +92,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 94 | 92 | lightThemeId?: MobileThemeId; |
| 95 | 93 | darkThemeId?: MobileThemeId; |
| 96 | 94 | themeMode?: MobileThemeMode; |
| 97 | materialYouStyleLayoutEnabled?: boolean; | |
| 98 | 95 | baseFontSize?: number; |
| 99 | 96 | terminalFontSize?: number | null; |
| 100 | 97 | markdownFontSize?: number; |
| ... | ... | @@ -139,9 +136,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 139 | 136 | ) { |
| 140 | 137 | preferences.themeMode = parsed.themeMode; |
| 141 | 138 | } |
| 142 | if (typeof parsed.materialYouStyleLayoutEnabled === "boolean") { | |
| 143 | preferences.materialYouStyleLayoutEnabled = parsed.materialYouStyleLayoutEnabled; | |
| 144 | } | |
| 145 | 139 | if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize; |
| 146 | 140 | if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) { |
| 147 | 141 | preferences.terminalFontSize = parsed.terminalFontSize; |
docs/user/appearance.md+2-1| ... | ... | @@ -10,7 +10,8 @@ themes or defaults. |
| 10 | 10 | On Android 12 or newer, choose the **Material You** theme in Appearance to use colors from |
| 11 | 11 | your wallpaper. Selecting another theme replaces those colors. Like other themes, Material You |
| 12 | 12 | can be selected separately for light and dark appearances. |
| 13 | Android also offers **Material You Layout**, which changes shapes and spacing independently | |
| 13 | Android uses **Material You Layout** by default unless you have turned it off in Appearance. | |
| 14 | It changes shapes, spacing, and controls independently | |
| 14 | 15 | of the selected theme. |
| 15 | 16 | |
| 16 | 17 | ## Motion |
vite.config.ts+2| ... | ... | @@ -184,6 +184,7 @@ export default defineConfig({ |
| 184 | 184 | "apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx", |
| 185 | 185 | "apps/mobile/src/features/files/FileMarkdownPreview.tsx", |
| 186 | 186 | "apps/mobile/src/features/files/SourceFileSurface.tsx", |
| 187 | "apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx", | |
| 187 | 188 | "apps/mobile/src/features/files/AttachmentFileScreen.tsx", |
| 188 | 189 | "apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx", |
| 189 | 190 | "apps/mobile/src/features/files/thread-file-navigator-pane.tsx", |
| ... | ... | @@ -204,6 +205,7 @@ export default defineConfig({ |
| 204 | 205 | "apps/mobile/src/lib/useMobileNavigationTheme.ts", |
| 205 | 206 | "apps/mobile/src/native/T3ComposerEditor.ios.tsx", |
| 206 | 207 | "apps/mobile/src/native/T3ComposerEditor.native.tsx", |
| 208 | "apps/mobile/src/native/SelectableMarkdownText.android.tsx", | |
| 207 | 209 | ], |
| 208 | 210 | rules: { |
| 209 | 211 | "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }], |