| 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,6 +8,7 @@ import android.graphics.Canvas |
| 8 | import android.graphics.Color | 8 | import android.graphics.Color |
| 9 | import android.graphics.Paint | 9 | import android.graphics.Paint |
| 10 | import android.graphics.Typeface | 10 | import android.graphics.Typeface |
| 11 | import android.os.Build | ||
| 11 | import android.text.Spannable | 12 | import android.text.Spannable |
| 12 | import android.text.SpannableStringBuilder | 13 | import android.text.SpannableStringBuilder |
| 13 | import android.text.Spanned | 14 | import android.text.Spanned |
| ... | @@ -164,6 +165,17 @@ private class SanitizingSelectionActionModeCallback( | ... | @@ -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 | class T3MarkdownTextSelectionModule : Module() { | 179 | class T3MarkdownTextSelectionModule : Module() { |
| 168 | private val chipImages = LruCache<String, Map<String, Any>>(128) | 180 | private val chipImages = LruCache<String, Map<String, Any>>(128) |
| 169 | 181 | ||
| ... | @@ -183,9 +195,23 @@ class T3MarkdownTextSelectionModule : Module() { | ... | @@ -183,9 +195,23 @@ class T3MarkdownTextSelectionModule : Module() { |
| 183 | } | 195 | } |
| 184 | }.fontMetricsInt | 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 | override fun definition() = ModuleDefinition { | 208 | override fun definition() = ModuleDefinition { |
| 187 | Name("T3MarkdownTextSelection") | 209 | Name("T3MarkdownTextSelection") |
| 188 | 210 | ||
| 211 | Function("setSelectionHandleColor") { reactTag: Int, color: Int -> | ||
| 212 | setSelectionHandleColor(reactTag, color) | ||
| 213 | } | ||
| 214 | |||
| 189 | Function("renderContextChip") { payloadJson: String -> | 215 | Function("renderContextChip") { payloadJson: String -> |
| 190 | val resources = appContext.reactContext?.resources ?: return@Function null | 216 | val resources = appContext.reactContext?.resources ?: return@Function null |
| 191 | val metrics = resources.displayMetrics | 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,5 +1,15 @@ |
| 1 | import React, { type Ref } from "react"; | 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 | import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent"; | 13 | import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent"; |
| 4 | import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent"; | 14 | import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent"; |
| 5 | import { flattenStyles } from "./util"; | 15 | import { flattenStyles } from "./util"; |
| ... | @@ -34,6 +44,7 @@ export type ContextMenuActionEvent = { | ... | @@ -34,6 +44,7 @@ export type ContextMenuActionEvent = { |
| 34 | */ | 44 | */ |
| 35 | export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & { | 45 | export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & { |
| 36 | nativeTextRef?: Ref<RNText>; | 46 | nativeTextRef?: Ref<RNText>; |
| 47 | selectionHandleColor?: ColorValue; | ||
| 37 | uiTextView?: boolean; | 48 | uiTextView?: boolean; |
| 38 | contextMenuConfig?: string; | 49 | contextMenuConfig?: string; |
| 39 | contextClipboardConfig?: string; | 50 | contextClipboardConfig?: string; |
| ... | @@ -116,7 +127,45 @@ function MarkdownTextPrimitiveInner({ nativeTextRef, ...props }: MarkdownTextPri | ... | @@ -116,7 +127,45 @@ function MarkdownTextPrimitiveInner({ nativeTextRef, ...props }: MarkdownTextPri |
| 116 | return <MarkdownTextPrimitiveChild {...props} />; | 127 | return <MarkdownTextPrimitiveChild {...props} />; |
| 117 | } | 128 | } |
| 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 | if (Platform.OS !== "ios") { | 169 | if (Platform.OS !== "ios") { |
| 121 | const { nativeTextRef, contextClipboardConfig: _contextClipboardConfig, ...textProps } = props; | 170 | const { nativeTextRef, contextClipboardConfig: _contextClipboardConfig, ...textProps } = props; |
| 122 | return <RNText ref={nativeTextRef} {...textProps} />; | 171 | return <RNText ref={nativeTextRef} {...textProps} />; |
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx+15-5| ... | @@ -133,7 +133,13 @@ function HighlightedCodeText(props: { | ... | @@ -133,7 +133,13 @@ function HighlightedCodeText(props: { |
| 133 | } | 133 | } |
| 134 | } | 134 | } |
| 135 | return ( | 135 | return ( |
| 136 | <MarkdownTextPrimitive uiTextView selectable style={style}> | 136 | <MarkdownTextPrimitive |
| 137 | uiTextView | ||
| 138 | selectable | ||
| 139 | selectionColor={props.textStyle.selectionColor} | ||
| 140 | selectionHandleColor={props.textStyle.selectionHandleColor} | ||
| 141 | style={style} | ||
| 142 | > | ||
| 137 | {props.highlighted ? lines : props.content} | 143 | {props.highlighted ? lines : props.content} |
| 138 | </MarkdownTextPrimitive> | 144 | </MarkdownTextPrimitive> |
| 139 | ); | 145 | ); |
| ... | @@ -174,8 +180,10 @@ function NativeCodeBlock(props: { | ... | @@ -174,8 +180,10 @@ function NativeCodeBlock(props: { |
| 174 | justifyContent: "space-between", | 180 | justifyContent: "space-between", |
| 175 | }} | 181 | }} |
| 176 | > | 182 | > |
| 177 | <Text | 183 | <MarkdownTextPrimitive |
| 178 | selectable | 184 | selectable |
| 185 | selectionColor={props.textStyle.selectionColor} | ||
| 186 | selectionHandleColor={props.textStyle.selectionHandleColor} | ||
| 179 | style={{ | 187 | style={{ |
| 180 | flex: 1, | 188 | flex: 1, |
| 181 | color: props.textStyle.mutedColor, | 189 | color: props.textStyle.mutedColor, |
| ... | @@ -184,7 +192,7 @@ function NativeCodeBlock(props: { | ... | @@ -184,7 +192,7 @@ function NativeCodeBlock(props: { |
| 184 | }} | 192 | }} |
| 185 | > | 193 | > |
| 186 | {languageLabel} | 194 | {languageLabel} |
| 187 | </Text> | 195 | </MarkdownTextPrimitive> |
| 188 | <CopyTextButton | 196 | <CopyTextButton |
| 189 | accessibilityLabel={`Copy ${languageLabel.toLowerCase()} code`} | 197 | accessibilityLabel={`Copy ${languageLabel.toLowerCase()} code`} |
| 190 | text={content} | 198 | text={content} |
| ... | @@ -332,8 +340,10 @@ function NativeMarkdownImage(props: { | ... | @@ -332,8 +340,10 @@ function NativeMarkdownImage(props: { |
| 332 | }} | 340 | }} |
| 333 | /> | 341 | /> |
| 334 | {props.node.alt ? ( | 342 | {props.node.alt ? ( |
| 335 | <Text | 343 | <MarkdownTextPrimitive |
| 336 | selectable | 344 | selectable |
| 345 | selectionColor={props.textStyle.selectionColor} | ||
| 346 | selectionHandleColor={props.textStyle.selectionHandleColor} | ||
| 337 | style={{ | 347 | style={{ |
| 338 | color: props.textStyle.mutedColor, | 348 | color: props.textStyle.mutedColor, |
| 339 | fontFamily: props.textStyle.fontFamily, | 349 | fontFamily: props.textStyle.fontFamily, |
| ... | @@ -342,7 +352,7 @@ function NativeMarkdownImage(props: { | ... | @@ -342,7 +352,7 @@ function NativeMarkdownImage(props: { |
| 342 | }} | 352 | }} |
| 343 | > | 353 | > |
| 344 | {props.node.alt} | 354 | {props.node.alt} |
| 345 | </Text> | 355 | </MarkdownTextPrimitive> |
| 346 | ) : null} | 356 | ) : null} |
| 347 | </View> | 357 | </View> |
| 348 | ); | 358 | ); |
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx+2| ... | @@ -338,6 +338,8 @@ export function NativeMarkdownSelectableText(props: { | ... | @@ -338,6 +338,8 @@ export function NativeMarkdownSelectableText(props: { |
| 338 | } | 338 | } |
| 339 | uiTextView | 339 | uiTextView |
| 340 | selectable | 340 | selectable |
| 341 | selectionColor={props.textStyle.selectionColor} | ||
| 342 | selectionHandleColor={props.textStyle.selectionHandleColor} | ||
| 341 | style={{ | 343 | style={{ |
| 342 | flexShrink: 1, | 344 | flexShrink: 1, |
| 343 | minWidth: 0, | 345 | minWidth: 0, |
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts+2| ... | @@ -1,4 +1,6 @@ | ... | @@ -1,4 +1,6 @@ |
| 1 | export interface NativeMarkdownTextStyle { | 1 | export interface NativeMarkdownTextStyle { |
| 2 | readonly selectionColor?: string; | ||
| 3 | readonly selectionHandleColor?: string; | ||
| 2 | readonly color: string; | 4 | readonly color: string; |
| 3 | readonly strongColor: string; | 5 | readonly strongColor: string; |
| 4 | readonly mutedColor: string; | 6 | readonly mutedColor: string; |
apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts+5| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { requireOptionalNativeModule } from "expo"; | 1 | import { requireOptionalNativeModule } from "expo"; |
| 2 | 2 | ||
| 3 | interface T3MarkdownTextSelectionNativeModule { | 3 | interface T3MarkdownTextSelectionNativeModule { |
| 4 | readonly setSelectionHandleColor?: (reactTag: number, color: number) => void; | ||
| 4 | readonly installCopySanitizer: (reactTag: number, contextClipboardConfig: string) => void; | 5 | readonly installCopySanitizer: (reactTag: number, contextClipboardConfig: string) => void; |
| 5 | readonly renderContextChip?: (payloadJson: string) => { | 6 | readonly renderContextChip?: (payloadJson: string) => { |
| 6 | readonly uri: string; | 7 | readonly uri: string; |
| ... | @@ -20,6 +21,10 @@ export function installMarkdownCopySanitizer(reactTag: number, contextClipboardC | ... | @@ -20,6 +21,10 @@ export function installMarkdownCopySanitizer(reactTag: number, contextClipboardC |
| 20 | nativeModule?.installCopySanitizer(reactTag, contextClipboardConfig); | 21 | nativeModule?.installCopySanitizer(reactTag, contextClipboardConfig); |
| 21 | } | 22 | } |
| 22 | 23 | ||
| 24 | export function setMarkdownSelectionHandleColor(reactTag: number, color: number): void { | ||
| 25 | nativeModule?.setSelectionHandleColor?.(reactTag, color); | ||
| 26 | } | ||
| 27 | |||
| 23 | export function renderAndroidContextChip(payloadJson: string) { | 28 | export function renderAndroidContextChip(payloadJson: string) { |
| 24 | return nativeModule?.renderContextChip?.(payloadJson) ?? null; | 29 | return nativeModule?.renderContextChip?.(payloadJson) ?? null; |
| 25 | } | 30 | } |
apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt+6| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | package expo.modules.t3nativecontrols | 1 | package expo.modules.t3nativecontrols |
| 2 | 2 | ||
| 3 | import android.content.Intent | 3 | import android.content.Intent |
| 4 | import android.text.format.DateFormat | ||
| 4 | import androidx.core.content.FileProvider | 5 | import androidx.core.content.FileProvider |
| 5 | import expo.modules.kotlin.Promise | 6 | import expo.modules.kotlin.Promise |
| 6 | import expo.modules.kotlin.modules.Module | 7 | import expo.modules.kotlin.modules.Module |
| ... | @@ -15,6 +16,11 @@ class T3NativeControlsModule : Module() { | ... | @@ -15,6 +16,11 @@ class T3NativeControlsModule : Module() { |
| 15 | override fun definition() = ModuleDefinition { | 16 | override fun definition() = ModuleDefinition { |
| 16 | Name("T3NativeControls") | 17 | Name("T3NativeControls") |
| 17 | 18 | ||
| 19 | Function("is24HourFormat") { | ||
| 20 | val context = appContext.reactContext ?: error("The app is not active.") | ||
| 21 | DateFormat.is24HourFormat(context) | ||
| 22 | } | ||
| 23 | |||
| 18 | AsyncFunction("openFile") { uri: String, mimeType: String, promise: Promise -> | 24 | AsyncFunction("openFile") { uri: String, mimeType: String, promise: Promise -> |
| 19 | check(filePreviewPromise == null) { "A document viewer is already open." } | 25 | check(filePreviewPromise == null) { "A document viewer is already open." } |
| 20 | val activity = appContext.currentActivity ?: error("The app is not active.") | 26 | val activity = appContext.currentActivity ?: error("The app is not active.") |
apps/mobile/src/App.tsx+3-7| ... | @@ -1,8 +1,7 @@ | ... | @@ -1,8 +1,7 @@ |
| 1 | import { BlurTargetView } from "expo-blur"; | ||
| 2 | import * as Linking from "expo-linking"; | 1 | import * as Linking from "expo-linking"; |
| 3 | import * as SplashScreen from "expo-splash-screen"; | 2 | import * as SplashScreen from "expo-splash-screen"; |
| 4 | import { useEffect } from "react"; | 3 | import { useEffect } from "react"; |
| 5 | import { StatusBar } from "react-native"; | 4 | import { StatusBar, View } from "react-native"; |
| 6 | import { GestureHandlerRootView } from "react-native-gesture-handler"; | 5 | import { GestureHandlerRootView } from "react-native-gesture-handler"; |
| 7 | import { KeyboardProvider } from "react-native-keyboard-controller"; | 6 | import { KeyboardProvider } from "react-native-keyboard-controller"; |
| 8 | import { SafeAreaProvider } from "react-native-safe-area-context"; | 7 | import { SafeAreaProvider } from "react-native-safe-area-context"; |
| ... | @@ -21,10 +20,8 @@ import { | ... | @@ -21,10 +20,8 @@ import { |
| 21 | import { RootStack } from "./Stack"; | 20 | import { RootStack } from "./Stack"; |
| 22 | import { appAtomRegistry } from "./state/atom-registry"; | 21 | import { appAtomRegistry } from "./state/atom-registry"; |
| 23 | import { OverlayPortalHost } from "./components/OverlayPortal"; | 22 | import { OverlayPortalHost } from "./components/OverlayPortal"; |
| 24 | import { appBlurTargetRef } from "./lib/appBlurTarget"; | ||
| 25 | import { shouldHandleAppLink } from "./lib/appLinking"; | 23 | import { shouldHandleAppLink } from "./lib/appLinking"; |
| 26 | import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; | 24 | import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; |
| 27 | |||
| 28 | import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; | 25 | import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; |
| 29 | 26 | ||
| 30 | import "../global.css"; | 27 | import "../global.css"; |
| ... | @@ -88,14 +85,13 @@ function AppContent() { | ... | @@ -88,14 +85,13 @@ function AppContent() { |
| 88 | this, React Navigation defaults to its light theme and every native | 85 | this, React Navigation defaults to its light theme and every native |
| 89 | header (glass buttons, title, materials) is forced light even when | 86 | header (glass buttons, title, materials) is forced light even when |
| 90 | the system is in dark mode. */} | 87 | the system is in dark mode. */} |
| 91 | {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */} | 88 | <View style={{ flex: 1 }}> |
| 92 | <BlurTargetView ref={appBlurTargetRef} style={{ flex: 1 }}> | ||
| 93 | <IncomingShareProvider> | 89 | <IncomingShareProvider> |
| 94 | <Navigation linking={appLinking} theme={navigationTheme} /> | 90 | <Navigation linking={appLinking} theme={navigationTheme} /> |
| 95 | </IncomingShareProvider> | 91 | </IncomingShareProvider> |
| 96 | <ConfirmDialogHost /> | 92 | <ConfirmDialogHost /> |
| 97 | <ThreadArrangementHost /> | 93 | <ThreadArrangementHost /> |
| 98 | </BlurTargetView> | 94 | </View> |
| 99 | {/* Anchored-menu overlays render here — in-window, so the | 95 | {/* Anchored-menu overlays render here — in-window, so the |
| 100 | keyboard stays up while a dropdown is open. */} | 96 | keyboard stays up while a dropdown is open. */} |
| 101 | <OverlayPortalHost /> | 97 | <OverlayPortalHost /> |
apps/mobile/src/components/AndroidAnchoredMenu.tsx+36-105| ... | @@ -6,12 +6,8 @@ import { BackHandler, Pressable, ScrollView, View } from "react-native"; | ... | @@ -6,12 +6,8 @@ import { BackHandler, Pressable, ScrollView, View } from "react-native"; |
| 6 | import { useKeyboardState } from "react-native-keyboard-controller"; | 6 | import { useKeyboardState } from "react-native-keyboard-controller"; |
| 7 | import Animated, { FadeIn } from "react-native-reanimated"; | 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 | import { OverlayPortal } from "./OverlayPortal"; | 9 | import { OverlayPortal } from "./OverlayPortal"; |
| 14 | import { GlassBackdrop } from "./GlassBackdrop"; | 10 | import { MaterialMenuPopup } from "./MaterialMenuPopup"; |
| 15 | 11 | ||
| 16 | const MENU_WIDTH = 250; | 12 | const MENU_WIDTH = 250; |
| 17 | const SCREEN_MARGIN = 12; | 13 | const SCREEN_MARGIN = 12; |
| ... | @@ -27,6 +23,7 @@ type AnchorSnapshot = { | ... | @@ -27,6 +23,7 @@ type AnchorSnapshot = { |
| 27 | readonly y: number; | 23 | readonly y: number; |
| 28 | readonly width: number; | 24 | readonly width: number; |
| 29 | readonly height: number; | 25 | readonly height: number; |
| 26 | readonly keyboardWasVisible: boolean; | ||
| 30 | }; | 27 | }; |
| 31 | 28 | ||
| 32 | type OverlayFrame = { | 29 | type OverlayFrame = { |
| ... | @@ -53,13 +50,9 @@ export type AndroidAnchoredMenuProps = { | ... | @@ -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 | 53 | * Adapts the app's MenuView actions to Material dropdowns on Android. Editor |
| 57 | * MenuView contract the app uses (actions with state/subtitle/image/ | 54 | * menus render native Material rows in-window to retain keyboard focus; other |
| 58 | * attributes, one level of subactions). The native AppCompat PopupMenu caps | 55 | * menus use the native popup for placement, animation and dismissal. |
| 59 | * out on theming — stock animation, item metrics, and submenu chrome — so | ||
| 60 | * ControlPillMenu renders this instead on Android while iOS keeps the native | ||
| 61 | * UIMenu. Styling follows the themed native popup (12dp radius, plain rows, | ||
| 62 | * trailing check glyph); submenus drill in under a muted parent-title header. | ||
| 63 | */ | 56 | */ |
| 64 | export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { | 57 | export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { |
| 65 | const [anchor, setAnchor] = useState<AnchorSnapshot | null>(null); | 58 | const [anchor, setAnchor] = useState<AnchorSnapshot | null>(null); |
| ... | @@ -89,9 +82,9 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { | ... | @@ -89,9 +82,9 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { |
| 89 | 82 | ||
| 90 | const open = useCallback(() => { | 83 | const open = useCallback(() => { |
| 91 | anchorRef.current?.measureInWindow((x, y, width, height) => { | 84 | anchorRef.current?.measureInWindow((x, y, width, height) => { |
| 92 | setAnchor({ x, y, width, height }); | 85 | setAnchor({ x, y, width, height, keyboardWasVisible: keyboardVisible }); |
| 93 | }); | 86 | }); |
| 94 | }, []); | 87 | }, [keyboardVisible]); |
| 95 | 88 | ||
| 96 | const measureOverlay = useCallback(() => { | 89 | const measureOverlay = useCallback(() => { |
| 97 | overlayRef.current?.measureInWindow((x, y, width, height) => { | 90 | overlayRef.current?.measureInWindow((x, y, width, height) => { |
| ... | @@ -100,18 +93,11 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { | ... | @@ -100,18 +93,11 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { |
| 100 | }); | 93 | }); |
| 101 | }, []); | 94 | }, []); |
| 102 | 95 | ||
| 103 | // The dropdown renders in-window (no Modal takes focus), so the hardware | 96 | // The native popup owns back dismissal. In-window menus need a handler; |
| 104 | // back gesture needs explicit handling while it is open. Back steps out of | 97 | // back returns to the parent submenu before closing the overlay. |
| 105 | // a drilled-in submenu one level at a time (mirroring the tappable parent | ||
| 106 | // header) before closing the menu. Under predictive back | ||
| 107 | // (enableOnBackInvokedCallback) this stays correct: back reaches JS | ||
| 108 | // through always-registered OnBackPressedDispatcher callbacks (react-native | ||
| 109 | // core on Android 16+, withAndroidPredictiveBackCompat on 13-15), which | ||
| 110 | // also keeps the system from playing a "leave app" preview while the menu | ||
| 111 | // merely closes. | ||
| 112 | const submenuDepth = path.length; | 98 | const submenuDepth = path.length; |
| 113 | useEffect(() => { | 99 | useEffect(() => { |
| 114 | if (anchor === null) { | 100 | if (anchor === null || !anchor.keyboardWasVisible) { |
| 115 | return; | 101 | return; |
| 116 | } | 102 | } |
| 117 | const subscription = BackHandler.addEventListener("hardwareBackPress", () => { | 103 | const subscription = BackHandler.addEventListener("hardwareBackPress", () => { |
| ... | @@ -212,10 +198,20 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { | ... | @@ -212,10 +198,20 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { |
| 212 | onLayout={measureOverlay} | 198 | onLayout={measureOverlay} |
| 213 | > | 199 | > |
| 214 | <Pressable accessible={false} className="absolute inset-0" onPress={close} /> | 200 | <Pressable accessible={false} className="absolute inset-0" onPress={close} /> |
| 215 | {!placeable || local === null ? null : ( | 201 | {!placeable || local === null ? null : !anchor.keyboardWasVisible ? ( |
| 202 | <MaterialMenuPopup | ||
| 203 | anchor={local} | ||
| 204 | actions={levelActions} | ||
| 205 | title={props.title} | ||
| 206 | parent={parent} | ||
| 207 | onPress={onPressItem} | ||
| 208 | onBack={() => setPath((current) => current.slice(0, -1))} | ||
| 209 | onClose={close} | ||
| 210 | /> | ||
| 211 | ) : ( | ||
| 216 | <Animated.View | 212 | <Animated.View |
| 217 | entering={FadeIn.duration(120)} | 213 | entering={FadeIn.duration(120)} |
| 218 | className="absolute w-[250px] overflow-hidden rounded-[12px] border border-border shadow-2xl" | 214 | className="absolute w-[250px] overflow-hidden rounded-[4px] bg-card-alt shadow-md" |
| 219 | style={{ | 215 | style={{ |
| 220 | left, | 216 | left, |
| 221 | maxHeight, | 217 | maxHeight, |
| ... | @@ -224,93 +220,28 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { | ... | @@ -224,93 +220,28 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { |
| 224 | : { bottom: (rootHeight ?? 0) - local.y + ANCHOR_GAP }), | 220 | : { bottom: (rootHeight ?? 0) - local.y + ANCHOR_GAP }), |
| 225 | }} | 221 | }} |
| 226 | > | 222 | > |
| 227 | <GlassBackdrop blurTarget={appBlurTargetRef} /> | 223 | {/* Compose DropdownMenu takes popup focus in the pinned Expo UI version. |
| 224 | Keep editor menus in-window so opening one preserves the keyboard. */} | ||
| 225 | |||
| 228 | {/* keyboardShouldPersistTaps: the menu often opens over an | 226 | {/* keyboardShouldPersistTaps: the menu often opens over an |
| 229 | active editor; the first item tap must act, not just | 227 | active editor; the first item tap must act, not just |
| 230 | dismiss the keyboard. */} | 228 | dismiss the keyboard. */} |
| 231 | <ScrollView | 229 | <ScrollView |
| 230 | contentContainerClassName="py-2" | ||
| 232 | bounces={false} | 231 | bounces={false} |
| 233 | keyboardShouldPersistTaps="always" | 232 | keyboardShouldPersistTaps="always" |
| 234 | showsVerticalScrollIndicator={false} | 233 | showsVerticalScrollIndicator={false} |
| 235 | > | 234 | > |
| 236 | {parent !== null ? ( | 235 | <MaterialMenuPopup |
| 237 | // Muted parent title as the submenu header; tapping it | 236 | inline |
| 238 | // steps back, but it reads as a label, not a button. | 237 | anchor={local} |
| 239 | <Pressable | 238 | actions={levelActions} |
| 240 | className="px-3.5 pb-1 pt-2.5" | 239 | title={props.title} |
| 241 | onPress={() => setPath((current) => current.slice(0, -1))} | 240 | parent={parent} |
| 242 | > | 241 | onPress={onPressItem} |
| 243 | <Text className="text-xs font-t3-bold text-foreground-muted"> | 242 | onBack={() => setPath((current) => current.slice(0, -1))} |
| 244 | {parent.title} | 243 | onClose={close} |
| 245 | </Text> | 244 | /> |
| 246 | </Pressable> | ||
| 247 | ) : props.title ? ( | ||
| 248 | <> | ||
| 249 | <View className="px-3.5 py-2"> | ||
| 250 | <Text className="text-center text-xs text-foreground-muted"> | ||
| 251 | {props.title} | ||
| 252 | </Text> | ||
| 253 | </View> | ||
| 254 | <View className="h-px bg-border" /> | ||
| 255 | </> | ||
| 256 | ) : null} | ||
| 257 | {levelActions.map((action, index) => { | ||
| 258 | const destructive = action.attributes?.destructive ?? false; | ||
| 259 | const disabled = action.attributes?.disabled ?? false; | ||
| 260 | const hasSubmenu = (action.subactions?.length ?? 0) > 0; | ||
| 261 | return ( | ||
| 262 | <Pressable | ||
| 263 | key={action.id ?? `${index}-${action.title}`} | ||
| 264 | disabled={disabled} | ||
| 265 | className={cn( | ||
| 266 | "min-h-11 flex-row items-center gap-2.5 px-3.5 py-2.5 active:bg-subtle", | ||
| 267 | disabled && "opacity-45", | ||
| 268 | )} | ||
| 269 | onPress={() => onPressItem(action)} | ||
| 270 | > | ||
| 271 | <View className="flex-1 gap-0.5"> | ||
| 272 | <Text | ||
| 273 | className={cn( | ||
| 274 | // Same face as the pill labels that open these menus. | ||
| 275 | "text-sm font-t3-bold", | ||
| 276 | destructive && "text-danger-foreground", | ||
| 277 | )} | ||
| 278 | > | ||
| 279 | {action.title} | ||
| 280 | </Text> | ||
| 281 | {action.subtitle ? ( | ||
| 282 | <Text className="text-xs leading-snug text-foreground-muted"> | ||
| 283 | {action.subtitle} | ||
| 284 | </Text> | ||
| 285 | ) : null} | ||
| 286 | </View> | ||
| 287 | {hasSubmenu ? ( | ||
| 288 | <SymbolView | ||
| 289 | name="chevron.right" | ||
| 290 | size={13} | ||
| 291 | tintColorClassName={"accent-icon-subtle"} | ||
| 292 | type="monochrome" | ||
| 293 | /> | ||
| 294 | ) : action.state === "on" ? ( | ||
| 295 | <SymbolView | ||
| 296 | name="checkmark" | ||
| 297 | size={15} | ||
| 298 | tintColorClassName={"accent-icon"} | ||
| 299 | type="monochrome" | ||
| 300 | /> | ||
| 301 | ) : action.image ? ( | ||
| 302 | <SymbolView | ||
| 303 | name={action.image as AppSymbolName} | ||
| 304 | size={15} | ||
| 305 | tintColorClassName={ | ||
| 306 | destructive ? "accent-danger-foreground" : "accent-icon" | ||
| 307 | } | ||
| 308 | type="monochrome" | ||
| 309 | /> | ||
| 310 | ) : null} | ||
| 311 | </Pressable> | ||
| 312 | ); | ||
| 313 | })} | ||
| 314 | </ScrollView> | 245 | </ScrollView> |
| 315 | </Animated.View> | 246 | </Animated.View> |
| 316 | )} | 247 | )} |
apps/mobile/src/components/AndroidScreenHeader.tsx+57-39| ... | @@ -1,16 +1,21 @@ | ... | @@ -1,16 +1,21 @@ |
| 1 | import type { ReactNode } from "react"; | 1 | import { useState, type ReactNode } from "react"; |
| 2 | import { Pressable, View } from "react-native"; | 2 | import { View } from "react-native"; |
| 3 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 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 | import { AppText as Text } from "./AppText"; | 6 | import { AppText as Text } from "./AppText"; |
| 7 | import { cn } from "../lib/cn"; | 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 | export interface AndroidHeaderAction { | 13 | export interface AndroidHeaderAction { |
| 10 | readonly accessibilityLabel: string; | 14 | readonly accessibilityLabel: string; |
| 11 | readonly icon: AppSymbolName; | 15 | readonly icon: AppSymbolName; |
| 12 | readonly onPress: () => void; | 16 | readonly onPress: () => void; |
| 13 | readonly disabled?: boolean; | 17 | readonly disabled?: boolean; |
| 18 | readonly selected?: boolean; | ||
| 14 | } | 19 | } |
| 15 | 20 | ||
| 16 | export function AndroidHeaderIconButton(props: { | 21 | export function AndroidHeaderIconButton(props: { |
| ... | @@ -18,73 +23,62 @@ export function AndroidHeaderIconButton(props: { | ... | @@ -18,73 +23,62 @@ export function AndroidHeaderIconButton(props: { |
| 18 | readonly icon: AppSymbolName; | 23 | readonly icon: AppSymbolName; |
| 19 | readonly onPress?: () => void; | 24 | readonly onPress?: () => void; |
| 20 | readonly disabled?: boolean; | 25 | readonly disabled?: boolean; |
| 26 | readonly selected?: boolean; | ||
| 21 | }) { | 27 | }) { |
| 22 | return ( | 28 | return <MaterialIconButton {...props} variant={props.selected ? "tonal" : "standard"} />; |
| 23 | <Pressable | ||
| 24 | accessibilityLabel={props.accessibilityLabel} | ||
| 25 | accessibilityRole="button" | ||
| 26 | disabled={props.disabled} | ||
| 27 | hitSlop={8} | ||
| 28 | onPress={props.onPress} | ||
| 29 | className={cn( | ||
| 30 | "size-11 items-center justify-center rounded-full bg-subtle", | ||
| 31 | props.disabled && "opacity-55", | ||
| 32 | )} | ||
| 33 | > | ||
| 34 | <SymbolView | ||
| 35 | name={props.icon} | ||
| 36 | size={20} | ||
| 37 | tintColorClassName={props.disabled ? "accent-icon-subtle" : "accent-foreground"} | ||
| 38 | type="monochrome" | ||
| 39 | /> | ||
| 40 | </Pressable> | ||
| 41 | ); | ||
| 42 | } | 29 | } |
| 43 | 30 | ||
| 44 | export function AndroidScreenHeader(props: { | 31 | export function AndroidScreenHeader(props: { |
| 45 | readonly title: string; | 32 | readonly title: string; |
| 46 | readonly subtitle?: string | null; | 33 | readonly subtitle?: string | null; |
| 47 | readonly actions?: ReadonlyArray<AndroidHeaderAction>; | 34 | readonly actions?: ReadonlyArray<AndroidHeaderAction>; |
| 35 | readonly leading?: ReactNode; | ||
| 48 | readonly trailing?: ReactNode; | 36 | readonly trailing?: ReactNode; |
| 49 | readonly onBack?: () => void; | 37 | readonly onBack?: () => void; |
| 50 | readonly embedded?: boolean; | 38 | readonly embedded?: boolean; |
| 51 | readonly hideBottomBorder?: boolean; | 39 | readonly hideBottomBorder?: boolean; |
| 52 | }) { | 40 | }) { |
| 53 | const insets = useSafeAreaInsets(); | 41 | const insets = useSafeAreaInsets(); |
| 42 | const titleTypography = useScaledTextRole("title"); | ||
| 43 | const subtitleTypography = useScaledTextRole("label"); | ||
| 44 | const materialToolbarHeight = useMaterialToolbarHeight(); | ||
| 45 | const [headerWidth, setHeaderWidth] = useState(0); | ||
| 46 | const actions = props.actions ?? []; | ||
| 47 | const directCount = actions.length > 2 ? (headerWidth >= 600 ? 3 : 1) : actions.length; | ||
| 48 | const visibleActions = actions.slice(0, directCount); | ||
| 49 | const overflowActions = actions.slice(directCount); | ||
| 54 | 50 | ||
| 55 | return ( | 51 | return ( |
| 56 | <View | 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 | style={{ | 55 | style={{ |
| 59 | paddingTop: props.embedded ? 8 : Math.max(insets.top, 12), | 56 | paddingTop: props.embedded ? 8 : Math.max(insets.top, 12), |
| 60 | borderBottomWidth: props.hideBottomBorder ? 0 : undefined, | 57 | borderBottomWidth: props.hideBottomBorder ? 0 : undefined, |
| 61 | }} | 58 | }} |
| 62 | > | 59 | > |
| 63 | <View className="min-h-12 flex-row items-center gap-2"> | 60 | <View |
| 61 | style={{ minHeight: materialToolbarHeight }} | ||
| 62 | className="min-h-14 flex-row items-center gap-1" | ||
| 63 | > | ||
| 64 | {props.onBack ? ( | 64 | {props.onBack ? ( |
| 65 | <Pressable | 65 | <MaterialIconButton |
| 66 | accessibilityLabel="Navigate up" | 66 | accessibilityLabel="Navigate up" |
| 67 | accessibilityRole="button" | 67 | icon="arrow.left" |
| 68 | hitSlop={8} | ||
| 69 | onPress={props.onBack} | 68 | onPress={props.onBack} |
| 70 | className="-mr-2 size-11 items-center justify-center" | 69 | /> |
| 71 | > | ||
| 72 | <SymbolView | ||
| 73 | name="chevron.left" | ||
| 74 | size={24} | ||
| 75 | tintColorClassName={"accent-foreground"} | ||
| 76 | type="monochrome" | ||
| 77 | /> | ||
| 78 | </Pressable> | ||
| 79 | ) : null} | 70 | ) : null} |
| 80 | 71 | ||
| 72 | {props.leading} | ||
| 73 | |||
| 81 | <View className={cn("min-w-0 flex-1", !props.onBack && "pl-1")}> | 74 | <View className={cn("min-w-0 flex-1", !props.onBack && "pl-1")}> |
| 82 | <Text numberOfLines={1} className="text-lg font-t3-bold text-foreground"> | 75 | <Text numberOfLines={1} style={titleTypography} className="text-foreground"> |
| 83 | {props.title} | 76 | {props.title} |
| 84 | </Text> | 77 | </Text> |
| 85 | {props.subtitle ? ( | 78 | {props.subtitle ? ( |
| 86 | <Text | 79 | <Text |
| 87 | numberOfLines={1} | 80 | numberOfLines={1} |
| 81 | style={subtitleTypography} | ||
| 88 | className="mt-px text-[13px] font-t3-medium text-foreground-muted" | 82 | className="mt-px text-[13px] font-t3-medium text-foreground-muted" |
| 89 | > | 83 | > |
| 90 | {props.subtitle} | 84 | {props.subtitle} |
| ... | @@ -92,15 +86,39 @@ export function AndroidScreenHeader(props: { | ... | @@ -92,15 +86,39 @@ export function AndroidScreenHeader(props: { |
| 92 | ) : null} | 86 | ) : null} |
| 93 | </View> | 87 | </View> |
| 94 | 88 | ||
| 95 | {props.actions?.map((action) => ( | 89 | {visibleActions.map((action) => ( |
| 96 | <AndroidHeaderIconButton | 90 | <AndroidHeaderIconButton |
| 97 | key={action.accessibilityLabel} | 91 | key={action.accessibilityLabel} |
| 98 | accessibilityLabel={action.accessibilityLabel} | 92 | accessibilityLabel={action.accessibilityLabel} |
| 99 | disabled={action.disabled} | 93 | disabled={action.disabled} |
| 94 | selected={action.selected} | ||
| 100 | icon={action.icon} | 95 | icon={action.icon} |
| 101 | onPress={action.onPress} | 96 | onPress={action.onPress} |
| 102 | /> | 97 | /> |
| 103 | ))} | 98 | ))} |
| 99 | {overflowActions.length > 0 ? ( | ||
| 100 | <AndroidAnchoredMenu | ||
| 101 | actions={overflowActions.map((action, index) => ({ | ||
| 102 | id: String(index), | ||
| 103 | title: action.accessibilityLabel, | ||
| 104 | attributes: { | ||
| 105 | disabled: Boolean(action.disabled), | ||
| 106 | state: action.selected ? "on" : undefined, | ||
| 107 | }, | ||
| 108 | }))} | ||
| 109 | onPressAction={({ nativeEvent }) => | ||
| 110 | overflowActions[Number(nativeEvent.event)]?.onPress() | ||
| 111 | } | ||
| 112 | > | ||
| 113 | {(open) => ( | ||
| 114 | <MaterialIconButton | ||
| 115 | accessibilityLabel="More actions" | ||
| 116 | icon="ellipsis" | ||
| 117 | onPress={open} | ||
| 118 | /> | ||
| 119 | )} | ||
| 120 | </AndroidAnchoredMenu> | ||
| 121 | ) : null} | ||
| 104 | {props.trailing} | 122 | {props.trailing} |
| 105 | </View> | 123 | </View> |
| 106 | </View> | 124 | </View> |
apps/mobile/src/components/AppSymbol.tsx+9| ... | @@ -9,6 +9,7 @@ import IconAlertTriangle from "@tabler/icons-react-native/IconAlertTriangle"; | ... | @@ -9,6 +9,7 @@ import IconAlertTriangle from "@tabler/icons-react-native/IconAlertTriangle"; |
| 9 | import IconApps from "@tabler/icons-react-native/IconApps"; | 9 | import IconApps from "@tabler/icons-react-native/IconApps"; |
| 10 | import IconArchive from "@tabler/icons-react-native/IconArchive"; | 10 | import IconArchive from "@tabler/icons-react-native/IconArchive"; |
| 11 | import IconArrowBackUp from "@tabler/icons-react-native/IconArrowBackUp"; | 11 | import IconArrowBackUp from "@tabler/icons-react-native/IconArrowBackUp"; |
| 12 | import IconArrowLeft from "@tabler/icons-react-native/IconArrowLeft"; | ||
| 12 | import IconArrowDownCircle from "@tabler/icons-react-native/IconArrowDownCircle"; | 13 | import IconArrowDownCircle from "@tabler/icons-react-native/IconArrowDownCircle"; |
| 13 | import IconArrowRightCircle from "@tabler/icons-react-native/IconArrowRightCircle"; | 14 | import IconArrowRightCircle from "@tabler/icons-react-native/IconArrowRightCircle"; |
| 14 | import IconArrowUp from "@tabler/icons-react-native/IconArrowUp"; | 15 | import IconArrowUp from "@tabler/icons-react-native/IconArrowUp"; |
| ... | @@ -36,8 +37,10 @@ import IconClock from "@tabler/icons-react-native/IconClock"; | ... | @@ -36,8 +37,10 @@ import IconClock from "@tabler/icons-react-native/IconClock"; |
| 36 | import IconCode from "@tabler/icons-react-native/IconCode"; | 37 | import IconCode from "@tabler/icons-react-native/IconCode"; |
| 37 | import IconCopy from "@tabler/icons-react-native/IconCopy"; | 38 | import IconCopy from "@tabler/icons-react-native/IconCopy"; |
| 38 | import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop"; | 39 | import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop"; |
| 40 | import IconDatabase from "@tabler/icons-react-native/IconDatabase"; | ||
| 39 | import IconDeviceLaptop from "@tabler/icons-react-native/IconDeviceLaptop"; | 41 | import IconDeviceLaptop from "@tabler/icons-react-native/IconDeviceLaptop"; |
| 40 | import IconDots from "@tabler/icons-react-native/IconDots"; | 42 | import IconDots from "@tabler/icons-react-native/IconDots"; |
| 43 | import IconDotsVertical from "@tabler/icons-react-native/IconDotsVertical"; | ||
| 41 | import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal"; | 44 | import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal"; |
| 42 | import IconEdit from "@tabler/icons-react-native/IconEdit"; | 45 | import IconEdit from "@tabler/icons-react-native/IconEdit"; |
| 43 | import IconExternalLink from "@tabler/icons-react-native/IconExternalLink"; | 46 | import IconExternalLink from "@tabler/icons-react-native/IconExternalLink"; |
| ... | @@ -97,6 +100,7 @@ import { withUniwind } from "uniwind"; | ... | @@ -97,6 +100,7 @@ import { withUniwind } from "uniwind"; |
| 97 | 100 | ||
| 98 | const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { | 101 | const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 99 | "arrow.branch": IconGitBranch, | 102 | "arrow.branch": IconGitBranch, |
| 103 | "arrow.left": IconArrowLeft, | ||
| 100 | "arrow.clockwise": IconRefresh, | 104 | "arrow.clockwise": IconRefresh, |
| 101 | "arrow.down.circle": IconArrowDownCircle, | 105 | "arrow.down.circle": IconArrowDownCircle, |
| 102 | "arrow.right.circle": IconArrowRightCircle, | 106 | "arrow.right.circle": IconArrowRightCircle, |
| ... | @@ -142,7 +146,10 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { | ... | @@ -142,7 +146,10 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 142 | "folder.badge.plus": IconFolderPlus, | 146 | "folder.badge.plus": IconFolderPlus, |
| 143 | "folder.fill": IconFolder, | 147 | "folder.fill": IconFolder, |
| 144 | gearshape: IconSettings, | 148 | gearshape: IconSettings, |
| 149 | hammer: IconHammer, | ||
| 145 | "info.circle": IconInfoCircle, | 150 | "info.circle": IconInfoCircle, |
| 151 | internaldrive: IconDatabase, | ||
| 152 | keyboard: IconKeyboard, | ||
| 146 | laptopcomputer: IconDeviceLaptop, | 153 | laptopcomputer: IconDeviceLaptop, |
| 147 | link: IconLink, | 154 | link: IconLink, |
| 148 | "line.3.horizontal": IconMenu2, | 155 | "line.3.horizontal": IconMenu2, |
| ... | @@ -160,6 +167,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { | ... | @@ -160,6 +167,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 160 | "pin.slash": IconPinnedOff, | 167 | "pin.slash": IconPinnedOff, |
| 161 | play: IconPlayerPlay, | 168 | play: IconPlayerPlay, |
| 162 | plus: IconPlus, | 169 | plus: IconPlus, |
| 170 | minus: IconMinus, | ||
| 163 | "qrcode.viewfinder": IconQrcode, | 171 | "qrcode.viewfinder": IconQrcode, |
| 164 | "point.3.connected.trianglepath.dotted": IconNetwork, | 172 | "point.3.connected.trianglepath.dotted": IconNetwork, |
| 165 | "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge, | 173 | "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge, |
| ... | @@ -209,6 +217,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME: Record<string, Icon> = { | ... | @@ -209,6 +217,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME: Record<string, Icon> = { |
| 209 | keyboard_arrow_down: IconChevronDown, | 217 | keyboard_arrow_down: IconChevronDown, |
| 210 | keyboard_arrow_up: IconChevronUp, | 218 | keyboard_arrow_up: IconChevronUp, |
| 211 | keyboard_hide: IconKeyboardHide, | 219 | keyboard_hide: IconKeyboardHide, |
| 220 | more_vert: IconDotsVertical, | ||
| 212 | public: IconWorld, | 221 | public: IconWorld, |
| 213 | remove: IconMinus, | 222 | remove: IconMinus, |
| 214 | terminal: IconTerminal2, | 223 | terminal: IconTerminal2, |
apps/mobile/src/components/AppText.tsx+15-3| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | Platform, | ||
| 2 | Text as RNText, | 3 | Text as RNText, |
| 3 | TextInput as RNTextInput, | 4 | TextInput as RNTextInput, |
| 4 | type TextInputProps as RNTextInputProps, | 5 | type TextInputProps as RNTextInputProps, |
| ... | @@ -14,7 +15,13 @@ export type AppTextProps = RNTextProps & { readonly className?: string }; | ... | @@ -14,7 +15,13 @@ export type AppTextProps = RNTextProps & { readonly className?: string }; |
| 14 | * Uses Uniwind className — no manual style parsing. | 15 | * Uses Uniwind className — no manual style parsing. |
| 15 | */ | 16 | */ |
| 16 | export function AppText({ className, ...props }: AppTextProps) { | 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 | export type AppTextInputProps = Omit<RNTextInputProps, "placeholderTextColor"> & { | 27 | export type AppTextInputProps = Omit<RNTextInputProps, "placeholderTextColor"> & { |
| ... | @@ -35,8 +42,13 @@ export function AppTextInput({ className, ref, ...props }: AppTextInputProps) { | ... | @@ -35,8 +42,13 @@ export function AppTextInput({ className, ref, ...props }: AppTextInputProps) { |
| 35 | className, | 42 | className, |
| 36 | )} | 43 | )} |
| 37 | placeholderTextColorClassName="accent-placeholder" | 44 | placeholderTextColorClassName="accent-placeholder" |
| 38 | selectionColorClassName="accent-foreground-secondary" | 45 | selectionColorClassName={ |
| 39 | cursorColorClassName="accent-foreground-secondary" | 46 | Platform.OS === "android" ? "accent-primary/32" : "accent-foreground-secondary" |
| 47 | } | ||
| 48 | cursorColorClassName={ | ||
| 49 | Platform.OS === "android" ? "accent-primary" : "accent-foreground-secondary" | ||
| 50 | } | ||
| 51 | selectionHandleColorClassName={Platform.OS === "android" ? "accent-primary" : undefined} | ||
| 40 | {...props} | 52 | {...props} |
| 41 | /> | 53 | /> |
| 42 | ); | 54 | ); |
apps/mobile/src/components/ConfirmDialogHost.tsx+30-11| ... | @@ -1,8 +1,9 @@ | ... | @@ -1,8 +1,9 @@ |
| 1 | import { useCallback, useEffect, useState } from "react"; | 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 | import { cn } from "../lib/cn"; | 4 | import { cn } from "../lib/cn"; |
| 5 | import { AppText } from "./AppText"; | 5 | import { AppText } from "./AppText"; |
| 6 | import { MaterialConfirmDialog } from "./MaterialConfirmDialog"; | ||
| 6 | 7 | ||
| 7 | export type ConfirmDialogRequest = { | 8 | export type ConfirmDialogRequest = { |
| 8 | readonly title: string; | 9 | readonly title: string; |
| ... | @@ -67,17 +68,35 @@ export function ConfirmDialogHost() { | ... | @@ -67,17 +68,35 @@ export function ConfirmDialogHost() { |
| 67 | setPresented(null); | 68 | setPresented(null); |
| 68 | }, [presented]); | 69 | }, [presented]); |
| 69 | 70 | ||
| 70 | const handleConfirm = useCallback(() => { | 71 | const handleConfirm = useCallback( |
| 71 | if (presented?.kind === "confirm") { | 72 | (nativeInputValue?: string) => { |
| 72 | presented.request.onConfirm(); | 73 | if (presented?.kind === "confirm") { |
| 73 | } else if (presented?.kind === "text-input") { | 74 | presented.request.onConfirm(); |
| 74 | presented.request.onConfirm(inputValue); | 75 | } else if (presented?.kind === "text-input") { |
| 75 | } | 76 | presented.request.onConfirm(nativeInputValue ?? inputValue); |
| 76 | setPresented(null); | 77 | } |
| 77 | }, [inputValue, presented]); | 78 | setPresented(null); |
| 79 | }, | ||
| 80 | [inputValue, presented], | ||
| 81 | ); | ||
| 78 | 82 | ||
| 79 | const confirmDisabled = presented?.kind === "text-input" && inputValue.trim().length === 0; | 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 | return ( | 100 | return ( |
| 82 | <Modal | 101 | <Modal |
| 83 | visible={presented !== null} | 102 | visible={presented !== null} |
| ... | @@ -102,7 +121,7 @@ export function ConfirmDialogHost() { | ... | @@ -102,7 +121,7 @@ export function ConfirmDialogHost() { |
| 102 | autoFocus | 121 | autoFocus |
| 103 | className="mt-4 rounded-xl border border-border bg-screen px-3 py-2.5 text-base text-foreground" | 122 | className="mt-4 rounded-xl border border-border bg-screen px-3 py-2.5 text-base text-foreground" |
| 104 | onChangeText={setInputValue} | 123 | onChangeText={setInputValue} |
| 105 | onSubmitEditing={confirmDisabled ? undefined : handleConfirm} | 124 | onSubmitEditing={confirmDisabled ? undefined : () => handleConfirm()} |
| 106 | returnKeyType="done" | 125 | returnKeyType="done" |
| 107 | selectTextOnFocus | 126 | selectTextOnFocus |
| 108 | value={inputValue} | 127 | value={inputValue} |
| ... | @@ -125,7 +144,7 @@ export function ConfirmDialogHost() { | ... | @@ -125,7 +144,7 @@ export function ConfirmDialogHost() { |
| 125 | accessibilityRole="button" | 144 | accessibilityRole="button" |
| 126 | disabled={confirmDisabled} | 145 | disabled={confirmDisabled} |
| 127 | className="min-h-10 items-center justify-center px-4 active:bg-subtle" | 146 | className="min-h-10 items-center justify-center px-4 active:bg-subtle" |
| 128 | onPress={handleConfirm} | 147 | onPress={() => handleConfirm()} |
| 129 | > | 148 | > |
| 130 | <AppText | 149 | <AppText |
| 131 | className={cn( | 150 | className={cn( |
apps/mobile/src/components/ControlPill.tsx+45-5| ... | @@ -19,12 +19,13 @@ import { | ... | @@ -19,12 +19,13 @@ import { |
| 19 | } from "react-native"; | 19 | } from "react-native"; |
| 20 | import { withUniwind } from "uniwind"; | 20 | import { withUniwind } from "uniwind"; |
| 21 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | 21 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 22 | |||
| 23 | import { cn } from "../lib/cn"; | 22 | import { cn } from "../lib/cn"; |
| 24 | import { withMenuActionIconColors } from "../lib/menu-action-colors"; | 23 | import { withMenuActionIconColors } from "../lib/menu-action-colors"; |
| 25 | import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; | 24 | import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; |
| 26 | import { SymbolView } from "./AppSymbol"; | 25 | import { SymbolView } from "./AppSymbol"; |
| 27 | import { AppText as Text } from "./AppText"; | 26 | import { AppText as Text } from "./AppText"; |
| 27 | import { MaterialIconButton } from "./MaterialIconButton"; | ||
| 28 | import { MaterialButton } from "./MaterialButton"; | ||
| 28 | 29 | ||
| 29 | const ThemedMenuView = withUniwind( | 30 | const ThemedMenuView = withUniwind( |
| 30 | function NativeMenuView({ | 31 | function NativeMenuView({ |
| ... | @@ -121,6 +122,47 @@ export function ControlPill(props: { | ... | @@ -121,6 +122,47 @@ export function ControlPill(props: { |
| 121 | : "", | 122 | : "", |
| 122 | ); | 123 | ); |
| 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 | return ( | 166 | return ( |
| 125 | <Pressable | 167 | <Pressable |
| 126 | accessibilityLabel={props.accessibilityLabel ?? props.label} | 168 | accessibilityLabel={props.accessibilityLabel ?? props.label} |
| ... | @@ -146,10 +188,8 @@ export function ControlPill(props: { | ... | @@ -146,10 +188,8 @@ export function ControlPill(props: { |
| 146 | ); | 188 | ); |
| 147 | } | 189 | } |
| 148 | 190 | ||
| 149 | // iOS renders the native UIMenu (standard checkmark for `state: "on"`); | 191 | // iOS renders UIMenu; AndroidAnchoredMenu adapts the same actions to the |
| 150 | // Android renders the token-styled AndroidAnchoredMenu, since the native | 192 | // selected Android appearance and keeps editor menus above the keyboard. |
| 151 | // AppCompat popup can't be themed past its stock animation, metrics, and | ||
| 152 | // submenu chrome. | ||
| 153 | export function ControlPillMenu( | 193 | export function ControlPillMenu( |
| 154 | props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> & | 194 | props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> & |
| 155 | Pick<AccessibilityProps, "accessible" | "accessibilityLabel" | "accessibilityRole"> & { | 195 | Pick<AccessibilityProps, "accessible" | "accessibilityLabel" | "accessibilityRole"> & { |
apps/mobile/src/components/EmptyState.tsx+8-2| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { Pressable, View } from "react-native"; | 1 | import { Pressable, View } from "react-native"; |
| 2 | import type { ReactNode } from "react"; | ||
| 2 | 3 | ||
| 3 | import { AppText as Text } from "./AppText"; | 4 | import { AppText as Text } from "./AppText"; |
| 4 | 5 | ||
| ... | @@ -7,6 +8,7 @@ export function EmptyState(props: { | ... | @@ -7,6 +8,7 @@ export function EmptyState(props: { |
| 7 | readonly detail: string; | 8 | readonly detail: string; |
| 8 | readonly actionLabel?: string; | 9 | readonly actionLabel?: string; |
| 9 | readonly onAction?: () => void; | 10 | readonly onAction?: () => void; |
| 11 | readonly action?: ReactNode; | ||
| 10 | readonly variant?: "card" | "plain"; | 12 | readonly variant?: "card" | "plain"; |
| 11 | }) { | 13 | }) { |
| 12 | if (props.variant === "plain") { | 14 | if (props.variant === "plain") { |
| ... | @@ -16,7 +18,9 @@ export function EmptyState(props: { | ... | @@ -16,7 +18,9 @@ export function EmptyState(props: { |
| 16 | <Text className="mt-2 text-center font-sans text-base leading-normal text-foreground-muted"> | 18 | <Text className="mt-2 text-center font-sans text-base leading-normal text-foreground-muted"> |
| 17 | {props.detail} | 19 | {props.detail} |
| 18 | </Text> | 20 | </Text> |
| 19 | {props.actionLabel && props.onAction ? ( | 21 | {props.action ? ( |
| 22 | <View className="mt-5">{props.action}</View> | ||
| 23 | ) : props.actionLabel && props.onAction ? ( | ||
| 20 | <Pressable | 24 | <Pressable |
| 21 | className="mt-5 rounded-full bg-primary px-5 py-3 active:opacity-70" | 25 | className="mt-5 rounded-full bg-primary px-5 py-3 active:opacity-70" |
| 22 | onPress={props.onAction} | 26 | onPress={props.onAction} |
| ... | @@ -36,7 +40,9 @@ export function EmptyState(props: { | ... | @@ -36,7 +40,9 @@ export function EmptyState(props: { |
| 36 | <Text className="mt-2 font-sans text-sm leading-relaxed text-foreground-muted"> | 40 | <Text className="mt-2 font-sans text-sm leading-relaxed text-foreground-muted"> |
| 37 | {props.detail} | 41 | {props.detail} |
| 38 | </Text> | 42 | </Text> |
| 39 | {props.actionLabel && props.onAction ? ( | 43 | {props.action ? ( |
| 44 | <View className="mt-4 self-start">{props.action}</View> | ||
| 45 | ) : props.actionLabel && props.onAction ? ( | ||
| 40 | <Pressable | 46 | <Pressable |
| 41 | className="mt-4 self-start rounded-full bg-primary px-4 py-2.5 active:opacity-70" | 47 | className="mt-4 self-start rounded-full bg-primary px-4 py-2.5 active:opacity-70" |
| 42 | onPress={props.onAction} | 48 | onPress={props.onAction} |
apps/mobile/src/components/GlassBackdrop.tsx+2-19| ... | @@ -1,22 +1,13 @@ | ... | @@ -1,22 +1,13 @@ |
| 1 | import { BlurView } from "expo-blur"; | 1 | import { BlurView } from "expo-blur"; |
| 2 | import { useContext, type RefObject } from "react"; | ||
| 3 | import { Platform, StyleSheet, View, type ColorValue } from "react-native"; | 2 | import { Platform, StyleSheet, View, type ColorValue } from "react-native"; |
| 4 | 3 | ||
| 5 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | 4 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 6 | import { GlassBlurTargetContext } from "../lib/glassBlurTarget"; | ||
| 7 | import { themeColorWithAlpha } from "../lib/mobileTheme"; | 5 | import { themeColorWithAlpha } from "../lib/mobileTheme"; |
| 8 | 6 | ||
| 9 | /** Frosted backdrop for containers that clip their children to their shape. */ | 7 | /** Frosted backdrop for containers that clip their children to their shape. */ |
| 10 | export function GlassBackdrop(props: { | 8 | export function GlassBackdrop(props: { readonly fallbackColor?: ColorValue }) { |
| 11 | readonly fallbackColor?: ColorValue; | ||
| 12 | readonly blurTarget?: RefObject<View | null>; | ||
| 13 | }) { | ||
| 14 | const { themeAppearance } = useAppearancePreferences(); | 9 | const { themeAppearance } = useAppearancePreferences(); |
| 15 | const inheritedBlurTarget = useContext(GlassBlurTargetContext); | 10 | const supportsBlur = Platform.OS === "ios"; |
| 16 | const target = props.blurTarget ?? inheritedBlurTarget; | ||
| 17 | const supportsBlur = | ||
| 18 | Platform.OS === "ios" || | ||
| 19 | (Platform.OS === "android" && Platform.Version >= 31 && target !== undefined); | ||
| 20 | const colorStyle = | 11 | const colorStyle = |
| 21 | props.fallbackColor === undefined | 12 | props.fallbackColor === undefined |
| 22 | ? undefined | 13 | ? undefined |
| ... | @@ -24,17 +15,9 @@ export function GlassBackdrop(props: { | ... | @@ -24,17 +15,9 @@ export function GlassBackdrop(props: { |
| 24 | 15 | ||
| 25 | return ( | 16 | return ( |
| 26 | <> | 17 | <> |
| 27 | {/* Android samples a separate target. An opaque backing prevents any | ||
| 28 | transparent pixels in that sample from exposing the unblurred feed. | ||
| 29 | iOS samples its actual backdrop, so a backing there would hide it. */} | ||
| 30 | {Platform.OS === "android" ? ( | ||
| 31 | <View pointerEvents="none" className="absolute inset-0 bg-card" style={colorStyle} /> | ||
| 32 | ) : null} | ||
| 33 | {supportsBlur ? ( | 18 | {supportsBlur ? ( |
| 34 | <BlurView | 19 | <BlurView |
| 35 | pointerEvents="none" | 20 | pointerEvents="none" |
| 36 | blurTarget={target} | ||
| 37 | blurMethod="dimezisBlurViewSdk31Plus" | ||
| 38 | intensity={80} | 21 | intensity={80} |
| 39 | tint={themeAppearance === "dark" ? "dark" : "default"} | 22 | tint={themeAppearance === "dark" ? "dark" : "default"} |
| 40 | style={StyleSheet.absoluteFill} | 23 | style={StyleSheet.absoluteFill} |
apps/mobile/src/components/GlassSurface.tsx+8-18| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect"; | 1 | import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect"; |
| 2 | import type { ReactNode, Ref, RefObject } from "react"; | 2 | import type { ReactNode, Ref } from "react"; |
| 3 | import { | 3 | import { |
| 4 | Platform, | 4 | Platform, |
| 5 | useColorScheme, | 5 | useColorScheme, |
| ... | @@ -28,7 +28,6 @@ interface GlassSurfaceProps extends ViewProps { | ... | @@ -28,7 +28,6 @@ interface GlassSurfaceProps extends ViewProps { |
| 28 | readonly chrome?: "default" | "none"; | 28 | readonly chrome?: "default" | "none"; |
| 29 | /** Base color for the frosted tint, or solid fill when blur is unavailable. */ | 29 | /** Base color for the frosted tint, or solid fill when blur is unavailable. */ |
| 30 | readonly fallbackColor?: ColorValue; | 30 | readonly fallbackColor?: ColorValue; |
| 31 | readonly blurTarget?: RefObject<View | null>; | ||
| 32 | /** Uniwind styling used only when native Liquid Glass is unavailable. */ | 31 | /** Uniwind styling used only when native Liquid Glass is unavailable. */ |
| 33 | readonly fallbackClassName?: string; | 32 | readonly fallbackClassName?: string; |
| 34 | } | 33 | } |
| ... | @@ -41,7 +40,6 @@ export function GlassSurface({ | ... | @@ -41,7 +40,6 @@ export function GlassSurface({ |
| 41 | tintColor, | 40 | tintColor, |
| 42 | tintColorClassName, | 41 | tintColorClassName, |
| 43 | fallbackColor, | 42 | fallbackColor, |
| 44 | blurTarget, | ||
| 45 | fallbackClassName, | 43 | fallbackClassName, |
| 46 | className, | 44 | className, |
| 47 | style, | 45 | style, |
| ... | @@ -49,23 +47,15 @@ export function GlassSurface({ | ... | @@ -49,23 +47,15 @@ export function GlassSurface({ |
| 49 | }: GlassSurfaceProps) { | 47 | }: GlassSurfaceProps) { |
| 50 | const isDarkMode = useColorScheme() === "dark"; | 48 | const isDarkMode = useColorScheme() === "dark"; |
| 51 | const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable(); | 49 | const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable(); |
| 50 | const hasShadow = chrome !== "none" && Platform.OS !== "android"; | ||
| 52 | const surfaceStyle: ViewStyle = { | 51 | const surfaceStyle: ViewStyle = { |
| 53 | borderRadius: 32, | 52 | borderRadius: 32, |
| 54 | overflow: "hidden", | 53 | overflow: "hidden", |
| 55 | shadowColor: chrome === "none" ? "transparent" : "#000000", | 54 | shadowColor: hasShadow ? "#000000" : "transparent", |
| 56 | shadowOpacity: chrome === "none" ? 0 : isDarkMode ? 0.22 : 0.08, | 55 | shadowOpacity: hasShadow ? (isDarkMode ? 0.22 : 0.08) : 0, |
| 57 | shadowRadius: chrome === "none" ? 0 : 28, | 56 | shadowRadius: hasShadow ? 28 : 0, |
| 58 | shadowOffset: | 57 | shadowOffset: { width: 0, height: hasShadow ? 14 : 0 }, |
| 59 | chrome === "none" | 58 | elevation: hasShadow ? 12 : 0, |
| 60 | ? { | ||
| 61 | width: 0, | ||
| 62 | height: 0, | ||
| 63 | } | ||
| 64 | : { | ||
| 65 | width: 0, | ||
| 66 | height: 14, | ||
| 67 | }, | ||
| 68 | elevation: chrome === "none" ? 0 : 12, | ||
| 69 | }; | 59 | }; |
| 70 | 60 | ||
| 71 | if (supportsGlass) { | 61 | if (supportsGlass) { |
| ... | @@ -103,7 +93,7 @@ export function GlassSurface({ | ... | @@ -103,7 +93,7 @@ export function GlassSurface({ |
| 103 | )} | 93 | )} |
| 104 | style={[surfaceStyle, style]} | 94 | style={[surfaceStyle, style]} |
| 105 | > | 95 | > |
| 106 | <GlassBackdrop blurTarget={blurTarget} fallbackColor={fallbackColor} /> | 96 | <GlassBackdrop fallbackColor={fallbackColor} /> |
| 107 | {children} | 97 | {children} |
| 108 | </View> | 98 | </View> |
| 109 | ); | 99 | ); |
apps/mobile/src/components/MaterialButton.android.tsx created+94| ... | @@ -0,0 +1,94 @@ | ||
| 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,8 +2,9 @@ import { Platform, Pressable, View } from "react-native"; |
| 2 | import Animated, { Easing, LinearTransition, ReduceMotion } from "react-native-reanimated"; | 2 | import Animated, { Easing, LinearTransition, ReduceMotion } from "react-native-reanimated"; |
| 3 | import { AppText as Text } from "./AppText"; | 3 | import { AppText as Text } from "./AppText"; |
| 4 | import { cn } from "../lib/cn"; | 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 | readonly options: readonly { | 8 | readonly options: readonly { |
| 8 | readonly value: Value; | 9 | readonly value: Value; |
| 9 | readonly label: string; | 10 | readonly label: string; |
| ... | @@ -11,18 +12,26 @@ export function SegmentedControl<Value extends number | string>(props: { | ... | @@ -11,18 +12,26 @@ export function SegmentedControl<Value extends number | string>(props: { |
| 11 | }[]; | 12 | }[]; |
| 12 | readonly selected: Value; | 13 | readonly selected: Value; |
| 13 | readonly onSelect: (value: Value) => void; | 14 | readonly onSelect: (value: Value) => void; |
| 14 | /** The tab bar is full height; filters under it are shorter so it stays primary. */ | 15 | /** Compact sizing applies to the non-Material control. */ |
| 15 | readonly size?: "default" | "compact"; | 16 | readonly size?: "default" | "compact"; |
| 16 | /** "tab" for the view switcher; filters stay plain buttons. */ | 17 | /** "tab" for the view switcher; filters stay plain buttons. */ |
| 17 | readonly role?: "tab" | "button"; | 18 | readonly role?: "tab" | "button"; |
| 18 | readonly className?: string; | 19 | readonly className?: string; |
| 19 | }) { | 20 | } |
| 21 | |||
| 22 | export function SegmentedControl<Value extends number | string>( | ||
| 23 | props: SegmentedControlProps<Value>, | ||
| 24 | ) { | ||
| 20 | const compact = props.size === "compact"; | 25 | const compact = props.size === "compact"; |
| 26 | if (Platform.OS === "android") { | ||
| 27 | return <MaterialSegmentedControl {...props} />; | ||
| 28 | } | ||
| 21 | return ( | 29 | return ( |
| 22 | <View | 30 | <View |
| 23 | accessible={false} | 31 | accessible={false} |
| 24 | className={cn( | 32 | className={cn( |
| 25 | "flex-row overflow-hidden rounded-full border-continuous bg-card", | 33 | "flex-row overflow-hidden", |
| 34 | "rounded-full border-continuous bg-card", | ||
| 26 | props.className, | 35 | props.className, |
| 27 | )} | 36 | )} |
| 28 | > | 37 | > |
| ... | @@ -31,7 +40,7 @@ export function SegmentedControl<Value extends number | string>(props: { | ... | @@ -31,7 +40,7 @@ export function SegmentedControl<Value extends number | string>(props: { |
| 31 | layout={LinearTransition.duration(200) | 40 | layout={LinearTransition.duration(200) |
| 32 | .easing(Easing.out(Easing.cubic)) | 41 | .easing(Easing.out(Easing.cubic)) |
| 33 | .reduceMotion(ReduceMotion.System)} | 42 | .reduceMotion(ReduceMotion.System)} |
| 34 | className="absolute bottom-0 top-0 rounded-full bg-subtle-strong" | 43 | className="absolute inset-y-0 rounded-full bg-subtle-strong" |
| 35 | style={{ | 44 | style={{ |
| 36 | width: `${100 / props.options.length}%`, | 45 | width: `${100 / props.options.length}%`, |
| 37 | start: `${ | 46 | start: `${ |
apps/mobile/src/components/ThemedSwitch.tsx+16-52| ... | @@ -1,63 +1,27 @@ | ... | @@ -1,63 +1,27 @@ |
| 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"; | 3 | import { MaterialSwitch } from "./MaterialSwitch"; |
| 4 | import { SymbolView } from "./AppSymbol"; | ||
| 5 | 4 | ||
| 6 | export function ThemedSwitch(props: SwitchProps) { | 5 | export type ThemedSwitchProps = Pick< |
| 7 | const { materialYouStyleLayoutActive } = useAppearancePreferences(); | 6 | SwitchProps, |
| 8 | if (materialYouStyleLayoutActive) { | 7 | | "accessibilityHint" |
| 9 | return ( | 8 | | "accessibilityLabel" |
| 10 | <Pressable | 9 | | "disabled" |
| 11 | accessibilityHint={props.accessibilityHint} | 10 | | "onValueChange" |
| 12 | accessibilityLabel={props.accessibilityLabel} | 11 | | "style" |
| 13 | accessibilityRole="switch" | 12 | | "testID" |
| 14 | accessibilityState={{ checked: Boolean(props.value), disabled: props.disabled }} | 13 | | "value" |
| 15 | disabled={props.disabled} | 14 | >; |
| 16 | hitSlop={8} | 15 | |
| 17 | onPress={() => props.onValueChange?.(!props.value)} | 16 | export function ThemedSwitch(props: ThemedSwitchProps) { |
| 18 | style={props.style} | 17 | if (Platform.OS === "android") { |
| 19 | testID={props.testID} | 18 | return <MaterialSwitch {...props} />; |
| 20 | className={props.disabled ? "opacity-40" : "active:opacity-70"} | ||
| 21 | > | ||
| 22 | <View | ||
| 23 | className={ | ||
| 24 | props.value | ||
| 25 | ? "h-8 w-[52px] items-end justify-center rounded-full border-2 border-switch-active-track bg-switch-active-track px-0.5" | ||
| 26 | : "h-8 w-[52px] items-start justify-center rounded-full border-2 border-switch-inactive-thumb bg-switch-inactive-track px-0.5" | ||
| 27 | } | ||
| 28 | > | ||
| 29 | <View | ||
| 30 | className={ | ||
| 31 | props.value | ||
| 32 | ? "size-6 items-center justify-center rounded-full bg-switch-active-thumb" | ||
| 33 | : "size-6 items-center justify-center rounded-full bg-switch-inactive-thumb" | ||
| 34 | } | ||
| 35 | > | ||
| 36 | <SymbolView | ||
| 37 | name={props.value ? "checkmark" : "xmark"} | ||
| 38 | size={13} | ||
| 39 | tintColorClassName={ | ||
| 40 | props.value ? "accent-switch-active-track" : "accent-switch-inactive-track" | ||
| 41 | } | ||
| 42 | type="monochrome" | ||
| 43 | /> | ||
| 44 | </View> | ||
| 45 | </View> | ||
| 46 | </Pressable> | ||
| 47 | ); | ||
| 48 | } | 19 | } |
| 49 | 20 | ||
| 50 | return ( | 21 | return ( |
| 51 | <Switch | 22 | <Switch |
| 52 | {...props} | 23 | {...props} |
| 53 | ios_backgroundColorClassName="accent-switch-inactive-track" | 24 | ios_backgroundColorClassName="accent-switch-inactive-track" |
| 54 | thumbColorClassName={ | ||
| 55 | Platform.OS === "android" | ||
| 56 | ? props.value | ||
| 57 | ? "accent-switch-active-thumb" | ||
| 58 | : "accent-switch-inactive-thumb" | ||
| 59 | : undefined | ||
| 60 | } | ||
| 61 | trackColorOffClassName="accent-switch-inactive-track" | 25 | trackColorOffClassName="accent-switch-inactive-track" |
| 62 | trackColorOnClassName="accent-switch-active-track" | 26 | trackColorOnClassName="accent-switch-active-track" |
| 63 | /> | 27 | /> |
apps/mobile/src/components/useMaterialToolbarHeight.ts created+11| ... | @@ -0,0 +1,11 @@ | ||
| 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,6 +40,7 @@ import { |
| 40 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, | 40 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, |
| 41 | } from "../layout/native-mail-search-toolbar"; | 41 | } from "../layout/native-mail-search-toolbar"; |
| 42 | import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; | 42 | import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; |
| 43 | import { SettingsScreenContent } from "../settings/components/SettingsScreen"; | ||
| 43 | 44 | ||
| 44 | export interface ArchivedThreadsHeaderEnvironment { | 45 | export interface ArchivedThreadsHeaderEnvironment { |
| 45 | readonly environmentId: EnvironmentId; | 46 | readonly environmentId: EnvironmentId; |
| ... | @@ -146,6 +147,7 @@ function ArchivedThreadsHeader(props: { | ... | @@ -146,6 +147,7 @@ function ArchivedThreadsHeader(props: { |
| 146 | className="border-b border-header-border bg-header px-3 pb-2.5" | 147 | className="border-b border-header-border bg-header px-3 pb-2.5" |
| 147 | style={{ | 148 | style={{ |
| 148 | paddingTop: Math.max(insets.top, 12), | 149 | paddingTop: Math.max(insets.top, 12), |
| 150 | borderBottomWidth: 0, | ||
| 149 | }} | 151 | }} |
| 150 | > | 152 | > |
| 151 | <View className="min-h-12 flex-row items-center gap-2"> | 153 | <View className="min-h-12 flex-row items-center gap-2"> |
| ... | @@ -159,7 +161,7 @@ function ArchivedThreadsHeader(props: { | ... | @@ -159,7 +161,7 @@ function ArchivedThreadsHeader(props: { |
| 159 | <SymbolView | 161 | <SymbolView |
| 160 | name="chevron.left" | 162 | name="chevron.left" |
| 161 | size={24} | 163 | size={24} |
| 162 | tintColorClassName={"accent-foreground"} | 164 | tintColorClassName="accent-foreground" |
| 163 | type="monochrome" | 165 | type="monochrome" |
| 164 | /> | 166 | /> |
| 165 | </Pressable> | 167 | </Pressable> |
| ... | @@ -167,7 +169,7 @@ function ArchivedThreadsHeader(props: { | ... | @@ -167,7 +169,7 @@ function ArchivedThreadsHeader(props: { |
| 167 | <SymbolView | 169 | <SymbolView |
| 168 | name="magnifyingglass" | 170 | name="magnifyingglass" |
| 169 | size={17} | 171 | size={17} |
| 170 | tintColorClassName={"accent-icon"} | 172 | tintColorClassName="accent-icon" |
| 171 | type="monochrome" | 173 | type="monochrome" |
| 172 | /> | 174 | /> |
| 173 | <TextInput | 175 | <TextInput |
| ... | @@ -197,7 +199,7 @@ function ArchivedThreadsHeader(props: { | ... | @@ -197,7 +199,7 @@ function ArchivedThreadsHeader(props: { |
| 197 | : "line.3.horizontal.decrease.circle" | 199 | : "line.3.horizontal.decrease.circle" |
| 198 | } | 200 | } |
| 199 | size={16} | 201 | size={16} |
| 200 | tintColorClassName={"accent-icon"} | 202 | tintColorClassName="accent-icon" |
| 201 | type="monochrome" | 203 | type="monochrome" |
| 202 | /> | 204 | /> |
| 203 | </Pressable> | 205 | </Pressable> |
| ... | @@ -455,7 +457,7 @@ function ArchivedThreadRow(props: { | ... | @@ -455,7 +457,7 @@ function ArchivedThreadRow(props: { |
| 455 | <SymbolView | 457 | <SymbolView |
| 456 | name="archivebox.fill" | 458 | name="archivebox.fill" |
| 457 | size={15} | 459 | size={15} |
| 458 | tintColorClassName={"accent-icon-subtle"} | 460 | tintColorClassName="accent-icon-subtle" |
| 459 | type="monochrome" | 461 | type="monochrome" |
| 460 | /> | 462 | /> |
| 461 | </View> | 463 | </View> |
| ... | @@ -477,7 +479,7 @@ function ArchivedThreadRow(props: { | ... | @@ -477,7 +479,7 @@ function ArchivedThreadRow(props: { |
| 477 | <SymbolView | 479 | <SymbolView |
| 478 | name="arrow.triangle.branch" | 480 | name="arrow.triangle.branch" |
| 479 | size={10} | 481 | size={10} |
| 480 | tintColorClassName={"accent-icon-subtle"} | 482 | tintColorClassName="accent-icon-subtle" |
| 481 | type="monochrome" | 483 | type="monochrome" |
| 482 | /> | 484 | /> |
| 483 | <Text | 485 | <Text |
| ... | @@ -615,7 +617,7 @@ export function ArchivedThreadsScreen(props: { | ... | @@ -615,7 +617,7 @@ export function ArchivedThreadsScreen(props: { |
| 615 | if (isInitialLoad) { | 617 | if (isInitialLoad) { |
| 616 | return ( | 618 | return ( |
| 617 | <View className="items-center py-16"> | 619 | <View className="items-center py-16"> |
| 618 | <ActivityIndicator colorClassName={"accent-icon"} /> | 620 | <ActivityIndicator colorClassName="accent-icon" /> |
| 619 | <Text className="mt-3 text-sm text-foreground-muted">Loading archive...</Text> | 621 | <Text className="mt-3 text-sm text-foreground-muted">Loading archive...</Text> |
| 620 | </View> | 622 | </View> |
| 621 | ); | 623 | ); |
| ... | @@ -646,37 +648,39 @@ export function ArchivedThreadsScreen(props: { | ... | @@ -646,37 +648,39 @@ export function ArchivedThreadsScreen(props: { |
| 646 | sortOrder={props.sortOrder} | 648 | sortOrder={props.sortOrder} |
| 647 | /> | 649 | /> |
| 648 | 650 | ||
| 649 | <GestureDetector gesture={archiveScrollGesture}> | 651 | <SettingsScreenContent> |
| 650 | <LegendList | 652 | <GestureDetector gesture={archiveScrollGesture}> |
| 651 | className="flex-1" | 653 | <LegendList |
| 652 | contentContainerStyle={{ | 654 | className="flex-1" |
| 653 | paddingBottom: 32, | 655 | contentContainerStyle={{ |
| 654 | paddingHorizontal: 16, | 656 | paddingBottom: 32, |
| 655 | paddingTop: 4, | 657 | paddingHorizontal: 16, |
| 656 | }} | 658 | paddingTop: 4, |
| 657 | contentInsetAdjustmentBehavior="automatic" | 659 | }} |
| 658 | data={listItems} | 660 | contentInsetAdjustmentBehavior="automatic" |
| 659 | estimatedItemSize={62} | 661 | data={listItems} |
| 660 | getItemType={(item) => item.kind} | 662 | estimatedItemSize={62} |
| 661 | keyboardDismissMode="on-drag" | 663 | getItemType={(item) => item.kind} |
| 662 | keyboardShouldPersistTaps="handled" | 664 | keyboardDismissMode="on-drag" |
| 663 | keyExtractor={(item) => item.key} | 665 | keyboardShouldPersistTaps="handled" |
| 664 | ListEmptyComponent={listEmptyComponent} | 666 | keyExtractor={(item) => item.key} |
| 665 | ListHeaderComponent={ | 667 | ListEmptyComponent={listEmptyComponent} |
| 666 | props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null | 668 | ListHeaderComponent={ |
| 667 | } | 669 | props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null |
| 668 | onScrollBeginDrag={() => openSwipeableRef.current?.close()} | 670 | } |
| 669 | refreshControl={ | 671 | onScrollBeginDrag={() => openSwipeableRef.current?.close()} |
| 670 | <RefreshControl | 672 | refreshControl={ |
| 671 | onRefresh={props.onRefresh} | 673 | <RefreshControl |
| 672 | refreshing={props.isLoading && !isInitialLoad} | 674 | onRefresh={props.onRefresh} |
| 673 | tintColorClassName={String("accent-icon")} | 675 | refreshing={props.isLoading && !isInitialLoad} |
| 674 | /> | 676 | tintColorClassName={String("accent-icon")} |
| 675 | } | 677 | /> |
| 676 | renderItem={renderListItem} | 678 | } |
| 677 | showsVerticalScrollIndicator={false} | 679 | renderItem={renderListItem} |
| 678 | /> | 680 | showsVerticalScrollIndicator={false} |
| 679 | </GestureDetector> | 681 | /> |
| 682 | </GestureDetector> | ||
| 683 | </SettingsScreenContent> | ||
| 680 | </View> | 684 | </View> |
| 681 | ); | 685 | ); |
| 682 | } | 686 | } |
apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx+2-1| ... | @@ -1,8 +1,9 @@ | ... | @@ -1,8 +1,9 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | ||
| 1 | import { NativeHeaderToolbar } from "../../native/StackHeader"; | 2 | import { NativeHeaderToolbar } from "../../native/StackHeader"; |
| 2 | import { useAuth } from "@clerk/expo"; | 3 | import { useAuth } from "@clerk/expo"; |
| 3 | import { StackActions, useNavigation } from "@react-navigation/native"; | 4 | import { StackActions, useNavigation } from "@react-navigation/native"; |
| 4 | import { useCallback, useEffect, useState } from "react"; | 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 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | 8 | ||
| 8 | import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime"; | 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,11 +6,13 @@ import { useAtomValue } from "@effect/atom-react"; |
| 6 | import * as Cause from "effect/Cause"; | 6 | import * as Cause from "effect/Cause"; |
| 7 | import { AsyncResult } from "effect/unstable/reactivity"; | 7 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 8 | import { useCallback, useState } from "react"; | 8 | import { useCallback, useState } from "react"; |
| 9 | import { Alert, Pressable, View } from "react-native"; | 9 | import { Platform, Alert, Pressable, View } from "react-native"; |
| 10 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; | 10 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; |
| 11 | 11 | ||
| 12 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 12 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 13 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; | 13 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; |
| 14 | import { MaterialButton } from "../../components/MaterialButton"; | ||
| 15 | import { MaterialIconButton } from "../../components/MaterialIconButton"; | ||
| 14 | import { ThemedSwitch } from "../../components/ThemedSwitch"; | 16 | import { ThemedSwitch } from "../../components/ThemedSwitch"; |
| 15 | import { cn } from "../../lib/cn"; | 17 | import { cn } from "../../lib/cn"; |
| 16 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | 18 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| ... | @@ -142,7 +144,7 @@ export function ConnectionEnvironmentRow(props: { | ... | @@ -142,7 +144,7 @@ export function ConnectionEnvironmentRow(props: { |
| 142 | <SymbolView | 144 | <SymbolView |
| 143 | name="chevron.down" | 145 | name="chevron.down" |
| 144 | size={12} | 146 | size={12} |
| 145 | tintColorClassName={"accent-icon-subtle"} | 147 | tintColorClassName="accent-icon-subtle" |
| 146 | type="monochrome" | 148 | type="monochrome" |
| 147 | style={{ | 149 | style={{ |
| 148 | transform: [{ rotate: props.expanded ? "180deg" : "0deg" }], | 150 | transform: [{ rotate: props.expanded ? "180deg" : "0deg" }], |
| ... | @@ -193,49 +195,79 @@ export function ConnectionEnvironmentRow(props: { | ... | @@ -193,49 +195,79 @@ export function ConnectionEnvironmentRow(props: { |
| 193 | </> | 195 | </> |
| 194 | )} | 196 | )} |
| 195 | 197 | ||
| 196 | <View className="flex-row justify-end gap-2"> | 198 | {Platform.OS === "android" ? ( |
| 197 | {props.environment.isRelayManaged ? null : ( | 199 | <View className="flex-row items-center justify-end gap-2"> |
| 200 | {props.environment.isRelayManaged ? null : ( | ||
| 201 | <View className="flex-1"> | ||
| 202 | <MaterialButton | ||
| 203 | label="Save" | ||
| 204 | tone="primary" | ||
| 205 | fullWidth | ||
| 206 | onPress={() => { | ||
| 207 | void handleSave(); | ||
| 208 | }} | ||
| 209 | /> | ||
| 210 | </View> | ||
| 211 | )} | ||
| 212 | <MaterialIconButton | ||
| 213 | accessibilityLabel="Reconnect environment" | ||
| 214 | icon="arrow.clockwise" | ||
| 215 | variant="tonal" | ||
| 216 | disabled={!enabled} | ||
| 217 | onPress={() => props.onReconnect(props.environment.environmentId)} | ||
| 218 | /> | ||
| 219 | <MaterialIconButton | ||
| 220 | accessibilityLabel="Remove environment" | ||
| 221 | icon="trash" | ||
| 222 | variant="danger" | ||
| 223 | onPress={() => props.onRemove(props.environment.environmentId)} | ||
| 224 | /> | ||
| 225 | </View> | ||
| 226 | ) : ( | ||
| 227 | <View className="flex-row justify-end gap-2"> | ||
| 228 | {props.environment.isRelayManaged ? null : ( | ||
| 229 | <Pressable | ||
| 230 | className="min-h-[42px] flex-1 flex-row items-center justify-center gap-1.5 rounded-[14px] bg-primary px-3.5 py-2.5 active:opacity-70" | ||
| 231 | onPress={handleSave} | ||
| 232 | > | ||
| 233 | <SymbolView | ||
| 234 | name="checkmark" | ||
| 235 | size={13} | ||
| 236 | tintColorClassName="accent-primary-foreground" | ||
| 237 | type="monochrome" | ||
| 238 | /> | ||
| 239 | <Text className="text-xs font-t3-bold tracking-[0.8px] uppercase text-primary-foreground"> | ||
| 240 | Save | ||
| 241 | </Text> | ||
| 242 | </Pressable> | ||
| 243 | )} | ||
| 244 | |||
| 198 | <Pressable | 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" | 246 | className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-input-border bg-input active:opacity-70 disabled:opacity-40" |
| 200 | onPress={handleSave} | 247 | disabled={!enabled} |
| 248 | onPress={() => props.onReconnect(props.environment.environmentId)} | ||
| 201 | > | 249 | > |
| 202 | <SymbolView | 250 | <SymbolView |
| 203 | name="checkmark" | 251 | name="arrow.clockwise" |
| 204 | size={13} | 252 | size={14} |
| 205 | tintColorClassName={"accent-primary-foreground"} | 253 | tintColorClassName="accent-icon-subtle" |
| 206 | type="monochrome" | 254 | type="monochrome" |
| 207 | /> | 255 | /> |
| 208 | <Text className="text-xs font-t3-bold tracking-[0.8px] uppercase text-primary-foreground"> | ||
| 209 | Save | ||
| 210 | </Text> | ||
| 211 | </Pressable> | 256 | </Pressable> |
| 212 | )} | ||
| 213 | |||
| 214 | <Pressable | ||
| 215 | className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-input-border bg-input active:opacity-70 disabled:opacity-40" | ||
| 216 | disabled={!enabled} | ||
| 217 | onPress={() => props.onReconnect(props.environment.environmentId)} | ||
| 218 | > | ||
| 219 | <SymbolView | ||
| 220 | name="arrow.clockwise" | ||
| 221 | size={14} | ||
| 222 | tintColorClassName={"accent-icon-subtle"} | ||
| 223 | type="monochrome" | ||
| 224 | /> | ||
| 225 | </Pressable> | ||
| 226 | 257 | ||
| 227 | <Pressable | 258 | <Pressable |
| 228 | className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-danger-border bg-danger active:opacity-70" | 259 | className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-danger-border bg-danger active:opacity-70" |
| 229 | onPress={() => props.onRemove(props.environment.environmentId)} | 260 | onPress={() => props.onRemove(props.environment.environmentId)} |
| 230 | > | 261 | > |
| 231 | <SymbolView | 262 | <SymbolView |
| 232 | name="trash" | 263 | name="trash" |
| 233 | size={14} | 264 | size={14} |
| 234 | tintColorClassName={"accent-danger-foreground"} | 265 | tintColorClassName="accent-danger-foreground" |
| 235 | type="monochrome" | 266 | type="monochrome" |
| 236 | /> | 267 | /> |
| 237 | </Pressable> | 268 | </Pressable> |
| 238 | </View> | 269 | </View> |
| 270 | )} | ||
| 239 | </Animated.View> | 271 | </Animated.View> |
| 240 | ) : null} | 272 | ) : null} |
| 241 | </Animated.View> | 273 | </Animated.View> |
apps/mobile/src/features/connection/ConnectionSheetButton.tsx+12| ... | @@ -3,6 +3,7 @@ import { Platform, Pressable } from "react-native"; | ... | @@ -3,6 +3,7 @@ import { Platform, Pressable } from "react-native"; |
| 3 | 3 | ||
| 4 | import { AppText as Text } from "../../components/AppText"; | 4 | import { AppText as Text } from "../../components/AppText"; |
| 5 | import { cn } from "../../lib/cn"; | 5 | import { cn } from "../../lib/cn"; |
| 6 | import { MaterialButton } from "../../components/MaterialButton"; | ||
| 6 | 7 | ||
| 7 | const CARD_SHADOW = Platform.select({ | 8 | const CARD_SHADOW = Platform.select({ |
| 8 | ios: { | 9 | ios: { |
| ... | @@ -32,8 +33,19 @@ export function ConnectionSheetButton(props: { | ... | @@ -32,8 +33,19 @@ export function ConnectionSheetButton(props: { |
| 32 | readonly disabled?: boolean; | 33 | readonly disabled?: boolean; |
| 33 | readonly tone?: "primary" | "secondary" | "danger"; | 34 | readonly tone?: "primary" | "secondary" | "danger"; |
| 34 | readonly compact?: boolean; | 35 | readonly compact?: boolean; |
| 36 | readonly fullWidth?: boolean; | ||
| 35 | readonly onPress: () => void; | 37 | readonly onPress: () => void; |
| 36 | }) { | 38 | }) { |
| 39 | if (Platform.OS === "android") | ||
| 40 | return ( | ||
| 41 | <MaterialButton | ||
| 42 | label={props.label} | ||
| 43 | disabled={props.disabled} | ||
| 44 | onPress={props.onPress} | ||
| 45 | tone={props.tone} | ||
| 46 | fullWidth={props.fullWidth} | ||
| 47 | /> | ||
| 48 | ); | ||
| 37 | const tone = props.tone ?? "secondary"; | 49 | const tone = props.tone ?? "secondary"; |
| 38 | 50 | ||
| 39 | const textColorClassName = | 51 | const textColorClassName = |
apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx+42-40| ... | @@ -1,13 +1,18 @@ | ... | @@ -1,13 +1,18 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | ||
| 1 | import { CameraView, useCameraPermissions } from "expo-camera"; | 2 | import { CameraView, useCameraPermissions } from "expo-camera"; |
| 2 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 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 | import { AsyncResult } from "effect/unstable/reactivity"; | 10 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 5 | import { useCallback, useEffect, useRef, useState } from "react"; | 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 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 13 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 14 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 9 | 15 | import { SettingsScreen } from "../settings/components/SettingsScreen"; | |
| 10 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | ||
| 11 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 16 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 12 | import { ErrorBanner } from "../../components/ErrorBanner"; | 17 | import { ErrorBanner } from "../../components/ErrorBanner"; |
| 13 | import { ConnectionSheetButton } from "./ConnectionSheetButton"; | 18 | import { ConnectionSheetButton } from "./ConnectionSheetButton"; |
| ... | @@ -30,6 +35,7 @@ export function ConnectionsNewRouteScreen({ | ... | @@ -30,6 +35,7 @@ export function ConnectionsNewRouteScreen({ |
| 30 | pairingConnectionError, | 35 | pairingConnectionError, |
| 31 | } = useRemoteConnections(); | 36 | } = useRemoteConnections(); |
| 32 | const navigation = useNavigation(); | 37 | const navigation = useNavigation(); |
| 38 | const routeName = useRoute().name; | ||
| 33 | const params = route.params ?? {}; | 39 | const params = route.params ?? {}; |
| 34 | // Deep-link prefill exists for development automation only. A production | 40 | // Deep-link prefill exists for development automation only. A production |
| 35 | // link must not arrive with attacker-chosen host and token already filled. | 41 | // link must not arrive with attacker-chosen host and token already filled. |
| ... | @@ -181,33 +187,27 @@ export function ConnectionsNewRouteScreen({ | ... | @@ -181,33 +187,27 @@ export function ConnectionsNewRouteScreen({ |
| 181 | }, [connectAndClose, routePairingUrl, shouldAutoConnect]); | 187 | }, [connectAndClose, routePairingUrl, shouldAutoConnect]); |
| 182 | 188 | ||
| 183 | return ( | 189 | return ( |
| 184 | <View collapsable={false} className="flex-1 bg-sheet"> | 190 | <SettingsScreen |
| 191 | formSheet={routeName === "ConnectionsNew"} | ||
| 192 | title={showScanner ? "Scan QR Code" : "Add Environment"} | ||
| 193 | actions={[ | ||
| 194 | { | ||
| 195 | accessibilityLabel: showScanner ? "Close scanner" : "Scan QR code", | ||
| 196 | icon: showScanner ? "xmark" : "camera", | ||
| 197 | onPress: () => { | ||
| 198 | if (showScanner) { | ||
| 199 | closeScanner(); | ||
| 200 | } else { | ||
| 201 | void openScanner(); | ||
| 202 | } | ||
| 203 | }, | ||
| 204 | }, | ||
| 205 | ]} | ||
| 206 | > | ||
| 185 | <NativeStackScreenOptions | 207 | <NativeStackScreenOptions |
| 186 | options={{ | 208 | options={{ title: showScanner ? "Scan QR Code" : "Add Environment" }} |
| 187 | // Android renders its own in-screen header below instead of the native bar. | ||
| 188 | ...(Platform.OS === "android" ? { headerShown: false } : null), | ||
| 189 | title: showScanner ? "Scan QR Code" : "Add Environment", | ||
| 190 | }} | ||
| 191 | /> | 209 | /> |
| 192 | {Platform.OS === "android" ? ( | 210 | {Platform.OS !== "android" ? ( |
| 193 | <AndroidScreenHeader | ||
| 194 | title={showScanner ? "Scan QR Code" : "Add Environment"} | ||
| 195 | onBack={() => navigation.goBack()} | ||
| 196 | actions={[ | ||
| 197 | { | ||
| 198 | accessibilityLabel: showScanner ? "Close scanner" : "Scan QR code", | ||
| 199 | icon: showScanner ? "xmark" : "camera", | ||
| 200 | onPress: () => { | ||
| 201 | if (showScanner) { | ||
| 202 | closeScanner(); | ||
| 203 | } else { | ||
| 204 | void openScanner(); | ||
| 205 | } | ||
| 206 | }, | ||
| 207 | }, | ||
| 208 | ]} | ||
| 209 | /> | ||
| 210 | ) : ( | ||
| 211 | <NativeHeaderToolbar placement="right"> | 211 | <NativeHeaderToolbar placement="right"> |
| 212 | <NativeHeaderToolbar.Button | 212 | <NativeHeaderToolbar.Button |
| 213 | icon={showScanner ? "xmark" : "qrcode.viewfinder"} | 213 | icon={showScanner ? "xmark" : "qrcode.viewfinder"} |
| ... | @@ -222,7 +222,7 @@ export function ConnectionsNewRouteScreen({ | ... | @@ -222,7 +222,7 @@ export function ConnectionsNewRouteScreen({ |
| 222 | tintColor={headerIconColor} | 222 | tintColor={headerIconColor} |
| 223 | /> | 223 | /> |
| 224 | </NativeHeaderToolbar> | 224 | </NativeHeaderToolbar> |
| 225 | )} | 225 | ) : null} |
| 226 | 226 | ||
| 227 | <ScrollView | 227 | <ScrollView |
| 228 | contentInsetAdjustmentBehavior="automatic" | 228 | contentInsetAdjustmentBehavior="automatic" |
| ... | @@ -293,19 +293,21 @@ export function ConnectionsNewRouteScreen({ | ... | @@ -293,19 +293,21 @@ export function ConnectionsNewRouteScreen({ |
| 293 | 293 | ||
| 294 | {pairingConnectionError ? <ErrorBanner message={pairingConnectionError} /> : null} | 294 | {pairingConnectionError ? <ErrorBanner message={pairingConnectionError} /> : null} |
| 295 | 295 | ||
| 296 | <ConnectionSheetButton | 296 | <View className={Platform.OS === "android" ? "flex-row justify-end" : undefined}> |
| 297 | icon="plus" | 297 | <ConnectionSheetButton |
| 298 | label={isSubmitting ? "Pairing..." : "Add environment"} | 298 | icon="plus" |
| 299 | disabled={connectDisabled} | 299 | label={isSubmitting ? "Pairing..." : "Add environment"} |
| 300 | tone="primary" | 300 | disabled={connectDisabled} |
| 301 | onPress={() => { | 301 | tone="primary" |
| 302 | void handleSubmit(); | 302 | onPress={() => { |
| 303 | }} | 303 | void handleSubmit(); |
| 304 | /> | 304 | }} |
| 305 | /> | ||
| 306 | </View> | ||
| 305 | </View> | 307 | </View> |
| 306 | )} | 308 | )} |
| 307 | </View> | 309 | </View> |
| 308 | </ScrollView> | 310 | </ScrollView> |
| 309 | </View> | 311 | </SettingsScreen> |
| 310 | ); | 312 | ); |
| 311 | } | 313 | } |
apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx+2-1| ... | @@ -1,9 +1,10 @@ | ... | @@ -1,9 +1,10 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | ||
| 1 | import { NativeHeaderToolbar } from "../../native/StackHeader"; | 2 | import { NativeHeaderToolbar } from "../../native/StackHeader"; |
| 2 | import { useNavigation } from "@react-navigation/native"; | 3 | import { useNavigation } from "@react-navigation/native"; |
| 3 | import { SymbolView } from "../../components/AppSymbol"; | 4 | import { SymbolView } from "../../components/AppSymbol"; |
| 4 | import type { EnvironmentId } from "@t3tools/contracts"; | 5 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 5 | import { useCallback, useState } from "react"; | 6 | import { useCallback, useState } from "react"; |
| 6 | import { Platform, ScrollView, View } from "react-native"; | 7 | import { Platform, View } from "react-native"; |
| 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | 9 | ||
| 9 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 10 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx+7-5| ... | @@ -1,12 +1,14 @@ | ... | @@ -1,12 +1,14 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | ||
| 1 | import Constants from "expo-constants"; | 2 | import Constants from "expo-constants"; |
| 2 | import * as Updates from "expo-updates"; | 3 | import * as Updates from "expo-updates"; |
| 3 | import { useEffect, useState } from "react"; | 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 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | 7 | ||
| 7 | import { SymbolView } from "../../components/AppSymbol"; | 8 | import { SymbolView } from "../../components/AppSymbol"; |
| 8 | import { AppText as Text } from "../../components/AppText"; | 9 | import { AppText as Text } from "../../components/AppText"; |
| 9 | import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | 10 | import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 11 | import { SettingsScreen } from "../settings/components/SettingsScreen"; | ||
| 10 | import { SettingsSection } from "../settings/components/SettingsSection"; | 12 | import { SettingsSection } from "../settings/components/SettingsSection"; |
| 11 | import { | 13 | import { |
| 12 | formatStartupCrashReport, | 14 | formatStartupCrashReport, |
| ... | @@ -71,7 +73,7 @@ export function SettingsDiagnosticsRouteScreen() { | ... | @@ -71,7 +73,7 @@ export function SettingsDiagnosticsRouteScreen() { |
| 71 | }; | 73 | }; |
| 72 | 74 | ||
| 73 | return ( | 75 | return ( |
| 74 | <View collapsable={false} className="flex-1 bg-sheet"> | 76 | <SettingsScreen title="Diagnostics"> |
| 75 | <ScrollView | 77 | <ScrollView |
| 76 | contentInsetAdjustmentBehavior="automatic" | 78 | contentInsetAdjustmentBehavior="automatic" |
| 77 | contentInset={{ bottom: Math.max(insets.bottom, 18) }} | 79 | contentInset={{ bottom: Math.max(insets.bottom, 18) }} |
| ... | @@ -115,7 +117,7 @@ export function SettingsDiagnosticsRouteScreen() { | ... | @@ -115,7 +117,7 @@ export function SettingsDiagnosticsRouteScreen() { |
| 115 | <SymbolView | 117 | <SymbolView |
| 116 | name={copied ? "checkmark" : "doc.on.doc"} | 118 | name={copied ? "checkmark" : "doc.on.doc"} |
| 117 | size={22} | 119 | size={22} |
| 118 | tintColorClassName={"accent-icon"} | 120 | tintColorClassName="accent-icon" |
| 119 | type="monochrome" | 121 | type="monochrome" |
| 120 | weight="regular" | 122 | weight="regular" |
| 121 | /> | 123 | /> |
| ... | @@ -131,7 +133,7 @@ export function SettingsDiagnosticsRouteScreen() { | ... | @@ -131,7 +133,7 @@ export function SettingsDiagnosticsRouteScreen() { |
| 131 | </Text> | 133 | </Text> |
| 132 | </View> | 134 | </View> |
| 133 | </ScrollView> | 135 | </ScrollView> |
| 134 | </View> | 136 | </SettingsScreen> |
| 135 | ); | 137 | ); |
| 136 | } | 138 | } |
| 137 | 139 | ||
| ... | @@ -145,7 +147,7 @@ function EmptyState(props: { | ... | @@ -145,7 +147,7 @@ function EmptyState(props: { |
| 145 | <SymbolView | 147 | <SymbolView |
| 146 | name={props.icon} | 148 | name={props.icon} |
| 147 | size={28} | 149 | size={28} |
| 148 | tintColorClassName={"accent-icon"} | 150 | tintColorClassName="accent-icon" |
| 149 | type="monochrome" | 151 | type="monochrome" |
| 150 | weight="regular" | 152 | weight="regular" |
| 151 | /> | 153 | /> |
apps/mobile/src/features/files/MaterialFilesHeader.tsx created+122| ... | @@ -0,0 +1,122 @@ | ||
| 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,6 +4,7 @@ import type { ComponentType } from "react"; |
| 4 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | 4 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 5 | import { | 5 | import { |
| 6 | FlatList, | 6 | FlatList, |
| 7 | Platform, | ||
| 7 | RefreshControl, | 8 | RefreshControl, |
| 8 | ScrollView, | 9 | ScrollView, |
| 9 | Text as NativeText, | 10 | Text as NativeText, |
| ... | @@ -73,6 +74,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { | ... | @@ -73,6 +74,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { |
| 73 | </NativeText> | 74 | </NativeText> |
| 74 | <NativeText | 75 | <NativeText |
| 75 | selectable | 76 | selectable |
| 77 | selectionColorClassName={Platform.OS === "android" ? "accent-primary/32" : undefined} | ||
| 76 | numberOfLines={props.wordBreak ? undefined : 1} | 78 | numberOfLines={props.wordBreak ? undefined : 1} |
| 77 | className="flex-1 font-normal text-foreground" | 79 | className="flex-1 font-normal text-foreground" |
| 78 | style={{ | 80 | style={{ |
| ... | @@ -102,6 +104,9 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { | ... | @@ -102,6 +104,9 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { |
| 102 | <NativeText | 104 | <NativeText |
| 103 | key={`${start}:${token.content.length}:${token.color ?? ""}`} | 105 | key={`${start}:${token.content.length}:${token.color ?? ""}`} |
| 104 | selectable | 106 | selectable |
| 107 | selectionColorClassName={ | ||
| 108 | Platform.OS === "android" ? "accent-primary/32" : undefined | ||
| 109 | } | ||
| 105 | style={{ | 110 | style={{ |
| 106 | color: token.color ?? undefined, | 111 | color: token.color ?? undefined, |
| 107 | fontFamily: REVIEW_MONO_FONT_FAMILY, | 112 | fontFamily: REVIEW_MONO_FONT_FAMILY, |
| ... | @@ -274,6 +279,7 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { | ... | @@ -274,6 +279,7 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { |
| 274 | <MarkdownTextPrimitive | 279 | <MarkdownTextPrimitive |
| 275 | uiTextView | 280 | uiTextView |
| 276 | selectable | 281 | selectable |
| 282 | selectionColorClassName={Platform.OS === "android" ? "accent-primary/32" : undefined} | ||
| 277 | style={{ | 283 | style={{ |
| 278 | color: foreground, | 284 | color: foreground, |
| 279 | fontFamily: REVIEW_MONO_FONT_FAMILY, | 285 | fontFamily: REVIEW_MONO_FONT_FAMILY, |
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+62-82| ... | @@ -15,8 +15,8 @@ import { | ... | @@ -15,8 +15,8 @@ import { |
| 15 | import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; | 15 | import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; |
| 16 | 16 | ||
| 17 | import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 17 | import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 18 | import { SymbolView } from "../../components/AppSymbol"; | 18 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; |
| 19 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 19 | import { AppText as Text } from "../../components/AppText"; |
| 20 | import { AudioFilePreview } from "../../components/AudioFilePreview"; | 20 | import { AudioFilePreview } from "../../components/AudioFilePreview"; |
| 21 | import { ControlPillMenu } from "../../components/ControlPill"; | 21 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 22 | import { EmptyState } from "../../components/EmptyState"; | 22 | import { EmptyState } from "../../components/EmptyState"; |
| ... | @@ -43,7 +43,10 @@ import { | ... | @@ -43,7 +43,10 @@ import { |
| 43 | createNativeMailSearchToolbarItem, | 43 | createNativeMailSearchToolbarItem, |
| 44 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, | 44 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, |
| 45 | } from "../layout/native-mail-search-toolbar"; | 45 | } from "../layout/native-mail-search-toolbar"; |
| 46 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; | 46 | import { |
| 47 | AndroidWorkspaceSidebarButton, | ||
| 48 | WorkspaceSidebarToolbar, | ||
| 49 | } from "../layout/workspace-sidebar-toolbar"; | ||
| 47 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 50 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 48 | import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; | 51 | import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; |
| 49 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; | 52 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; |
| ... | @@ -52,6 +55,7 @@ import { useFileTreeEntries } from "./useFileTreeEntries"; | ... | @@ -52,6 +55,7 @@ import { useFileTreeEntries } from "./useFileTreeEntries"; |
| 52 | import { preloadWorkspaceFileContents } from "./preload-workspace-file"; | 55 | import { preloadWorkspaceFileContents } from "./preload-workspace-file"; |
| 53 | import { SourceFileSurface } from "./SourceFileSurface"; | 56 | import { SourceFileSurface } from "./SourceFileSurface"; |
| 54 | import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; | 57 | import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; |
| 58 | import { MaterialFilesHeader } from "./MaterialFilesHeader"; | ||
| 55 | import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; | 59 | import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; |
| 56 | import { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError"; | 60 | import { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError"; |
| 57 | import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; | 61 | import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; |
| ... | @@ -328,10 +332,9 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { | ... | @@ -328,10 +332,9 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { |
| 328 | useAdaptiveWorkspaceLayout(); | 332 | useAdaptiveWorkspaceLayout(); |
| 329 | const [searchQuery, setSearchQuery] = useState(""); | 333 | const [searchQuery, setSearchQuery] = useState(""); |
| 330 | const isAndroid = Platform.OS === "android"; | 334 | const isAndroid = Platform.OS === "android"; |
| 331 | const { themeAppearance: highlightTheme, materialYouStyleLayoutActive } = | 335 | const { themeAppearance: highlightTheme } = useAppearancePreferences(); |
| 332 | useAppearancePreferences(); | ||
| 333 | const theme = useUniwindTheme(); | 336 | const theme = useUniwindTheme(); |
| 334 | const screenColor = theme["--color-screen"]; | 337 | const headerColor = theme["--color-header"]; |
| 335 | const sheetSurfaceColor = theme["--color-sheet-solid"]; | 338 | const sheetSurfaceColor = theme["--color-sheet-solid"]; |
| 336 | const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( | 339 | const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( |
| 337 | props.route.params, | 340 | props.route.params, |
| ... | @@ -451,7 +454,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { | ... | @@ -451,7 +454,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { |
| 451 | <NativeStackScreenOptions | 454 | <NativeStackScreenOptions |
| 452 | options={{ | 455 | options={{ |
| 453 | contentStyle: { | 456 | contentStyle: { |
| 454 | backgroundColor: materialYouStyleLayoutActive ? screenColor : sheetSurfaceColor, | 457 | backgroundColor: Platform.OS === "android" ? headerColor : sheetSurfaceColor, |
| 455 | }, | 458 | }, |
| 456 | headerShown: !isAndroid, | 459 | headerShown: !isAndroid, |
| 457 | unstable_headerSubtitle: | 460 | unstable_headerSubtitle: |
| ... | @@ -484,46 +487,16 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { | ... | @@ -484,46 +487,16 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { |
| 484 | /> | 487 | /> |
| 485 | {isAndroid ? ( | 488 | {isAndroid ? ( |
| 486 | <> | 489 | <> |
| 487 | <AndroidScreenHeader | 490 | { |
| 488 | title="Files" | 491 | <MaterialFilesHeader |
| 489 | subtitle={projectName} | 492 | projectName={projectName} |
| 490 | onBack={handleReturnToThread} | 493 | leading={<AndroidWorkspaceSidebarButton />} |
| 491 | hideBottomBorder={materialYouStyleLayoutActive} | 494 | searchQuery={searchQuery} |
| 492 | actions={[ | 495 | onSearchQueryChange={setSearchQuery} |
| 493 | { | 496 | onRefresh={entriesQuery.refresh} |
| 494 | accessibilityLabel: "Refresh files", | 497 | onBack={handleReturnToThread} |
| 495 | icon: "arrow.clockwise", | ||
| 496 | onPress: entriesQuery.refresh, | ||
| 497 | }, | ||
| 498 | ]} | ||
| 499 | /> | ||
| 500 | <View | ||
| 501 | className={ | ||
| 502 | materialYouStyleLayoutActive | ||
| 503 | ? "mx-4 my-2 min-h-12 flex-row items-center gap-2 rounded-full border border-input-border bg-input px-3.5" | ||
| 504 | : "flex-row items-center gap-2 border-b border-border px-3 py-2" | ||
| 505 | } | ||
| 506 | > | ||
| 507 | <SymbolView | ||
| 508 | name="magnifyingglass" | ||
| 509 | size={17} | ||
| 510 | tintColorClassName={"accent-icon-muted"} | ||
| 511 | type="monochrome" | ||
| 512 | /> | 498 | /> |
| 513 | <TextInput | 499 | } |
| 514 | accessibilityLabel="Search files" | ||
| 515 | autoCapitalize="none" | ||
| 516 | autoCorrect={false} | ||
| 517 | className={ | ||
| 518 | materialYouStyleLayoutActive | ||
| 519 | ? "min-h-10 flex-1 py-2 text-sm text-foreground" | ||
| 520 | : "min-h-10 flex-1 rounded-xl py-2 text-sm" | ||
| 521 | } | ||
| 522 | placeholder="Search files" | ||
| 523 | value={searchQuery} | ||
| 524 | onChangeText={setSearchQuery} | ||
| 525 | /> | ||
| 526 | </View> | ||
| 527 | </> | 500 | </> |
| 528 | ) : ( | 501 | ) : ( |
| 529 | <> | 502 | <> |
| ... | @@ -548,26 +521,28 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { | ... | @@ -548,26 +521,28 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { |
| 548 | )} | 521 | )} |
| 549 | </> | 522 | </> |
| 550 | )} | 523 | )} |
| 551 | <FileTreeBrowser | 524 | <MaterialScreenContent insetHorizontal={layout.usesSplitView}> |
| 552 | key={JSON.stringify([environmentId, cwd])} | 525 | <FileTreeBrowser |
| 553 | entries={entriesQuery.entries} | 526 | key={JSON.stringify([environmentId, cwd])} |
| 554 | loadedDirectories={entriesQuery.loadedDirectories} | 527 | entries={entriesQuery.entries} |
| 555 | onLoadDirectory={entriesQuery.loadDirectory} | 528 | loadedDirectories={entriesQuery.loadedDirectories} |
| 556 | error={entriesQuery.error} | 529 | onLoadDirectory={entriesQuery.loadDirectory} |
| 557 | isPending={entriesQuery.isPending} | 530 | error={entriesQuery.error} |
| 558 | searchQuery={searchQuery} | 531 | isPending={entriesQuery.isPending} |
| 559 | searchTruncated={entriesQuery.searchTruncated} | 532 | searchQuery={searchQuery} |
| 560 | selectedPath={null} | 533 | searchTruncated={entriesQuery.searchTruncated} |
| 561 | onPreviewFile={handlePreviewFile} | 534 | selectedPath={null} |
| 562 | onRefresh={entriesQuery.refresh} | 535 | onPreviewFile={handlePreviewFile} |
| 563 | onSelectFile={handleSelectFile} | 536 | onRefresh={entriesQuery.refresh} |
| 564 | /> | 537 | onSelectFile={handleSelectFile} |
| 565 | <FilesToolbarBottomFade /> | 538 | /> |
| 539 | <FilesToolbarBottomFade /> | ||
| 540 | </MaterialScreenContent> | ||
| 566 | </> | 541 | </> |
| 567 | ); | 542 | ); |
| 568 | 543 | ||
| 569 | return materialYouStyleLayoutActive ? ( | 544 | return Platform.OS === "android" ? ( |
| 570 | <View className="flex-1" style={{ backgroundColor: screenColor }}> | 545 | <View className="flex-1" style={{ backgroundColor: headerColor }}> |
| 571 | {content} | 546 | {content} |
| 572 | </View> | 547 | </View> |
| 573 | ) : ( | 548 | ) : ( |
| ... | @@ -933,6 +908,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | ... | @@ -933,6 +908,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 933 | <AndroidScreenHeader | 908 | <AndroidScreenHeader |
| 934 | title={basename(relativePath)} | 909 | title={basename(relativePath)} |
| 935 | subtitle={headerSubtitle} | 910 | subtitle={headerSubtitle} |
| 911 | leading={<AndroidWorkspaceSidebarButton />} | ||
| 912 | hideBottomBorder | ||
| 936 | onBack={handleBack} | 913 | onBack={handleBack} |
| 937 | trailing={ | 914 | trailing={ |
| 938 | <> | 915 | <> |
| ... | @@ -942,6 +919,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | ... | @@ -942,6 +919,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 942 | panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator" | 919 | panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator" |
| 943 | } | 920 | } |
| 944 | icon="sidebar.right" | 921 | icon="sidebar.right" |
| 922 | selected={panes.auxiliaryPaneVisible} | ||
| 945 | onPress={toggleAuxiliaryPane} | 923 | onPress={toggleAuxiliaryPane} |
| 946 | /> | 924 | /> |
| 947 | ) : null} | 925 | ) : null} |
| ... | @@ -1007,25 +985,27 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | ... | @@ -1007,25 +985,27 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 1007 | ))} | 985 | ))} |
| 1008 | </NativeHeaderToolbar.Menu> | 986 | </NativeHeaderToolbar.Menu> |
| 1009 | </NativeHeaderToolbar> | 987 | </NativeHeaderToolbar> |
| 1010 | <FileContent | 988 | <MaterialScreenContent> |
| 1011 | key={previewKey} | 989 | <FileContent |
| 1012 | activeMode={resolvedActiveMode} | 990 | key={previewKey} |
| 1013 | cwd={cwd} | 991 | activeMode={resolvedActiveMode} |
| 1014 | environmentId={environmentId} | 992 | cwd={cwd} |
| 1015 | previewUri={previewUri} | 993 | environmentId={environmentId} |
| 1016 | previewFailure={assetPreview._tag === "Failure" ? assetPreview.reason : null} | 994 | previewUri={previewUri} |
| 1017 | onRetryPreview={handleRetryPreview} | 995 | previewFailure={assetPreview._tag === "Failure" ? assetPreview.reason : null} |
| 1018 | videoSource={videoSource} | 996 | onRetryPreview={handleRetryPreview} |
| 1019 | mediaSource={mediaSource} | 997 | videoSource={videoSource} |
| 1020 | resolveVideoUri={assetPreview.refresh} | 998 | mediaSource={mediaSource} |
| 1021 | fileContents={fileData?.contents ?? null} | 999 | resolveVideoUri={assetPreview.refresh} |
| 1022 | fileError={fileQuery.error} | 1000 | fileContents={fileData?.contents ?? null} |
| 1023 | initialLine={targetLine} | 1001 | fileError={fileQuery.error} |
| 1024 | relativePath={relativePath} | 1002 | initialLine={targetLine} |
| 1025 | threadId={threadId} | 1003 | relativePath={relativePath} |
| 1026 | truncated={fileData?.truncated ?? false} | 1004 | threadId={threadId} |
| 1027 | onRefresh={() => fileQuery.refresh()} | 1005 | truncated={fileData?.truncated ?? false} |
| 1028 | /> | 1006 | onRefresh={() => fileQuery.refresh()} |
| 1007 | /> | ||
| 1008 | </MaterialScreenContent> | ||
| 1029 | <FilePreviewModal | 1009 | <FilePreviewModal |
| 1030 | source={fullScreenPreview} | 1010 | source={fullScreenPreview} |
| 1031 | onRequestClose={() => setFullScreenPreview(null)} | 1011 | onRequestClose={() => setFullScreenPreview(null)} |
apps/mobile/src/features/files/thread-file-navigator-pane.tsx+59-48| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { SymbolView } from "../../components/AppSymbol"; | 2 | import { SymbolView } from "../../components/AppSymbol"; |
| 3 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; | ||
| 3 | import { useCallback, useMemo, useState, type ComponentProps } from "react"; | 4 | import { useCallback, useMemo, useState, type ComponentProps } from "react"; |
| 4 | import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native"; | 5 | import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native"; |
| 5 | import { | 6 | import { |
| ... | @@ -11,6 +12,7 @@ import { | ... | @@ -11,6 +12,7 @@ import { |
| 11 | } from "react-native-screens"; | 12 | } from "react-native-screens"; |
| 12 | 13 | ||
| 13 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 14 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 15 | import { MaterialFilesHeader } from "./MaterialFilesHeader"; | ||
| 14 | import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; | 16 | import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; |
| 15 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 17 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 16 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 18 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| ... | @@ -149,59 +151,68 @@ export function ThreadFileNavigatorPane(props: { | ... | @@ -149,59 +151,68 @@ export function ThreadFileNavigatorPane(props: { |
| 149 | } | 151 | } |
| 150 | 152 | ||
| 151 | return ( | 153 | return ( |
| 152 | <View className="flex-1 border-l border-border bg-sheet"> | 154 | <View |
| 153 | <View className="border-b border-border" style={{ paddingTop: props.headerInset }}> | 155 | className={ |
| 154 | <View className="h-12 flex-row items-center gap-2 px-3"> | 156 | Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 border-l border-border bg-sheet" |
| 155 | <View className="min-w-0 flex-1"> | 157 | } |
| 156 | <Text className="text-sm font-t3-bold text-foreground">Files</Text> | 158 | > |
| 157 | <Text className="text-xs text-foreground-muted" numberOfLines={1}> | 159 | <View |
| 158 | {props.projectName} | 160 | className={Platform.OS === "android" ? "bg-header" : "border-b border-border"} |
| 159 | </Text> | 161 | style={{ paddingTop: Platform.OS === "android" ? 0 : props.headerInset }} |
| 162 | > | ||
| 163 | {Platform.OS === "android" ? ( | ||
| 164 | <MaterialFilesHeader | ||
| 165 | projectName={props.projectName} | ||
| 166 | searchQuery={searchQuery} | ||
| 167 | onSearchQueryChange={setSearchQuery} | ||
| 168 | onRefresh={entriesQuery.refresh} | ||
| 169 | /> | ||
| 170 | ) : ( | ||
| 171 | <View className="h-12 flex-row items-center gap-2 px-3"> | ||
| 172 | <View className="min-w-0 flex-1"> | ||
| 173 | <Text className="text-sm font-t3-bold text-foreground">Files</Text> | ||
| 174 | <Text className="text-xs text-foreground-muted" numberOfLines={1}> | ||
| 175 | {props.projectName} | ||
| 176 | </Text> | ||
| 177 | </View> | ||
| 178 | <Pressable | ||
| 179 | accessibilityRole="button" | ||
| 180 | accessibilityLabel="Refresh files" | ||
| 181 | hitSlop={8} | ||
| 182 | className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle" | ||
| 183 | onPress={entriesQuery.refresh} | ||
| 184 | > | ||
| 185 | <SymbolView | ||
| 186 | name="arrow.clockwise" | ||
| 187 | size={14} | ||
| 188 | tintColorClassName="accent-icon-muted" | ||
| 189 | type="monochrome" | ||
| 190 | /> | ||
| 191 | </Pressable> | ||
| 160 | </View> | 192 | </View> |
| 161 | <Pressable | 193 | )} |
| 162 | accessibilityRole="button" | 194 | {Platform.OS !== "android" ? ( |
| 163 | accessibilityLabel="Refresh files" | 195 | <View className="flex-row items-center gap-2 border-t border-border px-3 py-2"> |
| 164 | hitSlop={8} | ||
| 165 | className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle" | ||
| 166 | onPress={entriesQuery.refresh} | ||
| 167 | > | ||
| 168 | <SymbolView | 196 | <SymbolView |
| 169 | name="arrow.clockwise" | 197 | name="magnifyingglass" |
| 170 | size={14} | 198 | size={15} |
| 171 | tintColorClassName={"accent-icon-muted"} | 199 | tintColorClassName="accent-icon-muted" |
| 172 | type="monochrome" | 200 | type="monochrome" |
| 173 | /> | 201 | /> |
| 174 | </Pressable> | 202 | <TextInput |
| 175 | <Pressable | 203 | accessibilityLabel="Search files" |
| 176 | accessibilityRole="button" | 204 | autoCapitalize="none" |
| 177 | accessibilityLabel="Close files" | 205 | autoCorrect={false} |
| 178 | hitSlop={8} | 206 | clearButtonMode="while-editing" |
| 179 | className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle" | 207 | className="min-h-10 flex-1 rounded-xl py-2 text-sm" |
| 180 | onPress={toggleAuxiliaryPane} | 208 | placeholder="Search files" |
| 181 | > | 209 | value={searchQuery} |
| 182 | <SymbolView name="xmark" size={14} tintColorClassName="accent-icon-muted" /> | 210 | onChangeText={setSearchQuery} |
| 183 | </Pressable> | 211 | /> |
| 184 | </View> | 212 | </View> |
| 185 | <View className="flex-row items-center gap-2 border-t border-border px-3 py-2"> | 213 | ) : null} |
| 186 | <SymbolView | ||
| 187 | name="magnifyingglass" | ||
| 188 | size={15} | ||
| 189 | tintColorClassName={"accent-icon-muted"} | ||
| 190 | type="monochrome" | ||
| 191 | /> | ||
| 192 | <TextInput | ||
| 193 | accessibilityLabel="Search files" | ||
| 194 | autoCapitalize="none" | ||
| 195 | autoCorrect={false} | ||
| 196 | clearButtonMode="while-editing" | ||
| 197 | className="min-h-10 flex-1 rounded-xl py-2 text-sm" | ||
| 198 | placeholder="Search files" | ||
| 199 | value={searchQuery} | ||
| 200 | onChangeText={setSearchQuery} | ||
| 201 | /> | ||
| 202 | </View> | ||
| 203 | </View> | 214 | </View> |
| 204 | {fileTree} | 215 | <MaterialScreenContent insetHorizontal>{fileTree}</MaterialScreenContent> |
| 205 | </View> | 216 | </View> |
| 206 | ); | 217 | ); |
| 207 | } | 218 | } |
apps/mobile/src/features/home/AndroidHomeFab.android.tsx created+44| ... | @@ -0,0 +1,44 @@ | ||
| 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,48 +1 @@ |
| 1 | import type { ReactNode } from "react"; | 1 | export { AndroidHomeFabLayout } from "./AndroidHomeFab.shared"; |
| 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 | } |
apps/mobile/src/features/home/HomeHeader.tsx+12-127| ... | @@ -1,18 +1,11 @@ | ... | @@ -1,18 +1,11 @@ |
| 1 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 2 | import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; |
| 3 | import type { MenuAction } from "@react-native-menu/menu"; | 2 | import type { MenuAction } from "@react-native-menu/menu"; |
| 4 | import Constants from "expo-constants"; | 3 | |
| 5 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 4 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 6 | import { useCallback, useMemo, useRef } from "react"; | 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 | import type { SearchBarCommands } from "react-native-screens"; | 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 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 9 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 17 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | 10 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; |
| 18 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | 11 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| ... | @@ -22,7 +15,7 @@ import { | ... | @@ -22,7 +15,7 @@ import { |
| 22 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, | 15 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, |
| 23 | } from "../layout/native-mail-search-toolbar"; | 16 | } from "../layout/native-mail-search-toolbar"; |
| 24 | import type { HomeProjectSortOrder } from "./homeThreadList"; | 17 | import type { HomeProjectSortOrder } from "./homeThreadList"; |
| 25 | import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle"; | 18 | import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar"; |
| 26 | import { | 19 | import { |
| 27 | buildHomeListFilterMenu, | 20 | buildHomeListFilterMenu, |
| 28 | type HomeListFilterMenuEnvironment, | 21 | type HomeListFilterMenuEnvironment, |
| ... | @@ -67,9 +60,6 @@ function checkedMenuState(checked: boolean) { | ... | @@ -67,9 +60,6 @@ function checkedMenuState(checked: boolean) { |
| 67 | } | 60 | } |
| 68 | 61 | ||
| 69 | function AndroidHomeHeader(props: HomeHeaderProps) { | 62 | function AndroidHomeHeader(props: HomeHeaderProps) { |
| 70 | const { materialYouStyleLayoutActive } = useAppearancePreferences(); | ||
| 71 | const insets = useSafeAreaInsets(); | ||
| 72 | const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); | ||
| 73 | // Thread List v2 lays the list out in fixed creation order, so the | 63 | // Thread List v2 lays the list out in fixed creation order, so the |
| 74 | // sort/group filter controls would be silently ignored — hide them and | 64 | // sort/group filter controls would be silently ignored — hide them and |
| 75 | // key the "customized" icon state off the environment filter alone. | 65 | // key the "customized" icon state off the environment filter alone. |
| ... | @@ -200,120 +190,15 @@ function AndroidHomeHeader(props: HomeHeaderProps) { | ... | @@ -200,120 +190,15 @@ function AndroidHomeHeader(props: HomeHeaderProps) { |
| 200 | return ( | 190 | return ( |
| 201 | <> | 191 | <> |
| 202 | <NativeStackScreenOptions options={{ headerShown: false }} /> | 192 | <NativeStackScreenOptions options={{ headerShown: false }} /> |
| 203 | <View | 193 | <MaterialThreadListToolbar |
| 204 | className={ | 194 | searchQuery={props.searchQuery} |
| 205 | materialYouStyleLayoutActive | 195 | onSearchQueryChange={props.onSearchQueryChange} |
| 206 | ? "bg-header pb-3" | 196 | filterActions={menuActions} |
| 207 | : "border-b border-header-border bg-header pb-3" | 197 | filterCustomized={hasCustomListOptions} |
| 208 | } | 198 | onFilterAction={handleMenuAction} |
| 209 | style={{ | 199 | onOpenSettings={props.onOpenSettings} |
| 210 | paddingHorizontal: HOME_HORIZONTAL_INSET, | 200 | onOpenEnvironments={props.onOpenEnvironments} |
| 211 | paddingTop: Math.max(insets.top, 12), | 201 | /> |
| 212 | }} | ||
| 213 | > | ||
| 214 | <View className="w-full max-w-[720px] self-center gap-3"> | ||
| 215 | <View className="flex-row items-center gap-2.5"> | ||
| 216 | {/* Brand slot doubles as the connection status surface: while an | ||
| 217 | environment reconnects, the lockup fades to a status label in | ||
| 218 | place (no layout shift in the list below). */} | ||
| 219 | <WorkspaceConnectionTitle | ||
| 220 | grow | ||
| 221 | onPress={props.onOpenEnvironments} | ||
| 222 | brand={ | ||
| 223 | <View className="flex-row items-center gap-2"> | ||
| 224 | {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */} | ||
| 225 | <T3Wordmark colorClassName="accent-icon" height={15} /> | ||
| 226 | <RNText className="-ml-0.5 text-[21px] font-t3-medium tracking-[-0.5px] text-foreground-muted"> | ||
| 227 | Code | ||
| 228 | </RNText> | ||
| 229 | <View className="rounded-full bg-subtle px-2 py-0.75"> | ||
| 230 | <RNText className="text-[11px] font-t3-bold tracking-[1.1px] text-foreground-muted uppercase"> | ||
| 231 | {stageLabel} | ||
| 232 | </RNText> | ||
| 233 | </View> | ||
| 234 | </View> | ||
| 235 | } | ||
| 236 | /> | ||
| 237 | |||
| 238 | <ControlPillMenu | ||
| 239 | actions={menuActions} | ||
| 240 | isAnchoredToRight | ||
| 241 | onPressAction={handleMenuAction} | ||
| 242 | > | ||
| 243 | <Pressable | ||
| 244 | accessibilityLabel="Filter and sort threads" | ||
| 245 | accessibilityRole="button" | ||
| 246 | className="size-11 items-center justify-center rounded-full bg-subtle" | ||
| 247 | > | ||
| 248 | <SymbolView | ||
| 249 | name={ | ||
| 250 | hasCustomListOptions | ||
| 251 | ? "line.3.horizontal.decrease.circle.fill" | ||
| 252 | : "line.3.horizontal.decrease.circle" | ||
| 253 | } | ||
| 254 | size={16} | ||
| 255 | tintColorClassName={"accent-icon"} | ||
| 256 | type="monochrome" | ||
| 257 | /> | ||
| 258 | </Pressable> | ||
| 259 | </ControlPillMenu> | ||
| 260 | {/* Built identically to the filter button so the two circles | ||
| 261 | match exactly (ControlPill sizes via Tailwind classes and | ||
| 262 | resolves to a different box). */} | ||
| 263 | <Pressable | ||
| 264 | accessibilityLabel="Open settings" | ||
| 265 | accessibilityRole="button" | ||
| 266 | onPress={props.onOpenSettings} | ||
| 267 | className="size-11 items-center justify-center rounded-full bg-subtle" | ||
| 268 | > | ||
| 269 | <SymbolView | ||
| 270 | name="gearshape" | ||
| 271 | size={18} | ||
| 272 | tintColorClassName={"accent-icon"} | ||
| 273 | type="monochrome" | ||
| 274 | /> | ||
| 275 | </Pressable> | ||
| 276 | </View> | ||
| 277 | |||
| 278 | <View | ||
| 279 | className={ | ||
| 280 | materialYouStyleLayoutActive | ||
| 281 | ? "min-h-12 flex-row items-center gap-2.5 rounded-full border border-input-border bg-input px-3.5" | ||
| 282 | : "min-h-12 flex-row items-center gap-2.5 rounded-2xl border border-input-border bg-input px-3.5" | ||
| 283 | } | ||
| 284 | > | ||
| 285 | <SymbolView | ||
| 286 | name="magnifyingglass" | ||
| 287 | size={17} | ||
| 288 | tintColorClassName={"accent-foreground-muted"} | ||
| 289 | type="monochrome" | ||
| 290 | /> | ||
| 291 | <TextInput | ||
| 292 | accessibilityLabel="Search threads" | ||
| 293 | autoCapitalize="none" | ||
| 294 | onChangeText={props.onSearchQueryChange} | ||
| 295 | placeholder="Search threads" | ||
| 296 | placeholderTextColorClassName="accent-placeholder" | ||
| 297 | className="flex-1 py-2.5 text-base font-sans text-foreground" | ||
| 298 | value={props.searchQuery} | ||
| 299 | /> | ||
| 300 | {props.searchQuery.length > 0 ? ( | ||
| 301 | <Pressable | ||
| 302 | accessibilityLabel="Clear search" | ||
| 303 | hitSlop={10} | ||
| 304 | onPress={() => props.onSearchQueryChange("")} | ||
| 305 | > | ||
| 306 | <SymbolView | ||
| 307 | name="xmark.circle.fill" | ||
| 308 | size={17} | ||
| 309 | tintColorClassName={"accent-foreground-muted"} | ||
| 310 | type="monochrome" | ||
| 311 | /> | ||
| 312 | </Pressable> | ||
| 313 | ) : null} | ||
| 314 | </View> | ||
| 315 | </View> | ||
| 316 | </View> | ||
| 317 | </> | 202 | </> |
| 318 | ); | 203 | ); |
| 319 | } | 204 | } |
apps/mobile/src/features/home/HomeRouteScreen.tsx+23-3| ... | @@ -11,7 +11,11 @@ import { useWorkspaceState } from "../../state/workspace"; | ... | @@ -11,7 +11,11 @@ import { useWorkspaceState } from "../../state/workspace"; |
| 11 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | 11 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 12 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | 12 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 13 | import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; | 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 | import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates"; | 19 | import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates"; |
| 16 | import { AndroidHomeFabLayout } from "./AndroidHomeFab"; | 20 | import { AndroidHomeFabLayout } from "./AndroidHomeFab"; |
| 17 | import { HomeScreen } from "./HomeScreen"; | 21 | import { HomeScreen } from "./HomeScreen"; |
| ... | @@ -27,7 +31,7 @@ import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle | ... | @@ -27,7 +31,7 @@ import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle |
| 27 | 31 | ||
| 28 | export function HomeRouteScreen() { | 32 | export function HomeRouteScreen() { |
| 29 | const { width: windowWidth } = useWindowDimensions(); | 33 | const { width: windowWidth } = useWindowDimensions(); |
| 30 | const { layout } = useAdaptiveWorkspaceLayout(); | 34 | const { layout, panes } = useAdaptiveWorkspaceLayout(); |
| 31 | const projects = useProjects(); | 35 | const projects = useProjects(); |
| 32 | const threads = useThreadShells(); | 36 | const threads = useThreadShells(); |
| 33 | const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); | 37 | const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); |
| ... | @@ -126,8 +130,24 @@ export function HomeRouteScreen() { | ... | @@ -126,8 +130,24 @@ export function HomeRouteScreen() { |
| 126 | /> | 130 | /> |
| 127 | } | 131 | } |
| 128 | /> | 132 | /> |
| 133 | {Platform.OS === "android" ? ( | ||
| 134 | <AndroidScreenHeader title="Threads" leading={<AndroidWorkspaceSidebarButton />} /> | ||
| 135 | ) : null} | ||
| 129 | <WorkspaceEmptyDetail | 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,13 +30,13 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 30 | import { cn } from "../../lib/cn"; | 30 | import { cn } from "../../lib/cn"; |
| 31 | import { AppText as Text } from "../../components/AppText"; | 31 | import { AppText as Text } from "../../components/AppText"; |
| 32 | import { EmptyState } from "../../components/EmptyState"; | 32 | import { EmptyState } from "../../components/EmptyState"; |
| 33 | import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; | ||
| 33 | import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; | 34 | import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; |
| 34 | import type { SavedRemoteConnection } from "../../lib/connection"; | 35 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 35 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 36 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 36 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | 37 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 37 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | 38 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 38 | import { useThreadSearch } from "../../state/queries"; | 39 | import { useThreadSearch } from "../../state/queries"; |
| 39 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 40 | import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; | 40 | import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; |
| 41 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | 41 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; |
| 42 | import { usePendingThreadOrder } from "../../state/thread-order"; | 42 | import { usePendingThreadOrder } from "../../state/thread-order"; |
| ... | @@ -83,6 +83,7 @@ import { | ... | @@ -83,6 +83,7 @@ import { |
| 83 | type HomeProjectSortOrder, | 83 | type HomeProjectSortOrder, |
| 84 | } from "./homeThreadList"; | 84 | } from "./homeThreadList"; |
| 85 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; | 85 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; |
| 86 | import { useMaterialFabScroll } from "./MaterialFabScrollContext"; | ||
| 86 | 87 | ||
| 87 | /* ─── Types ──────────────────────────────────────────────────────────── */ | 88 | /* ─── Types ──────────────────────────────────────────────────────────── */ |
| 88 | 89 | ||
| ... | @@ -219,7 +220,6 @@ function HomeTopContentSpacer() { | ... | @@ -219,7 +220,6 @@ function HomeTopContentSpacer() { |
| 219 | /* ─── Main screen ────────────────────────────────────────────────────── */ | 220 | /* ─── Main screen ────────────────────────────────────────────────────── */ |
| 220 | 221 | ||
| 221 | export function HomeScreen(props: HomeScreenProps) { | 222 | export function HomeScreen(props: HomeScreenProps) { |
| 222 | const { materialYouStyleLayoutActive } = useAppearancePreferences(); | ||
| 223 | const [groupDisplayStates, setGroupDisplayStates] = useState< | 223 | const [groupDisplayStates, setGroupDisplayStates] = useState< |
| 224 | ReadonlyMap<string, HomeGroupDisplayState> | 224 | ReadonlyMap<string, HomeGroupDisplayState> |
| 225 | >(() => new Map()); | 225 | >(() => new Map()); |
| ... | @@ -315,7 +315,9 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -315,7 +315,9 @@ export function HomeScreen(props: HomeScreenProps) { |
| 315 | const handleScrollBeginDrag = useCallback(() => { | 315 | const handleScrollBeginDrag = useCallback(() => { |
| 316 | openSwipeableRef.current?.close(); | 316 | openSwipeableRef.current?.close(); |
| 317 | }, []); | 317 | }, []); |
| 318 | const onMaterialFabScroll = useMaterialFabScroll(); | ||
| 318 | const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ | 319 | const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ |
| 320 | onScroll: onMaterialFabScroll, | ||
| 319 | onScrollBeginDrag: handleScrollBeginDrag, | 321 | onScrollBeginDrag: handleScrollBeginDrag, |
| 320 | }); | 322 | }); |
| 321 | 323 | ||
| ... | @@ -1104,11 +1106,11 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -1104,11 +1106,11 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1104 | 1106 | ||
| 1105 | if (!hasAnyThreads) { | 1107 | if (!hasAnyThreads) { |
| 1106 | return ( | 1108 | return ( |
| 1107 | <View className={materialYouStyleLayoutActive ? "flex-1 bg-header" : "flex-1 bg-screen"}> | 1109 | <View className={Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 bg-screen"}> |
| 1108 | <View | 1110 | <View |
| 1109 | className={cn( | 1111 | className={cn( |
| 1110 | "flex-1 items-center justify-center bg-screen px-8", | 1112 | "flex-1 items-center justify-center bg-screen px-8", |
| 1111 | materialYouStyleLayoutActive && "overflow-hidden rounded-t-[28px]", | 1113 | Platform.OS === "android" && "overflow-hidden rounded-t-[28px]", |
| 1112 | )} | 1114 | )} |
| 1113 | style={{ | 1115 | style={{ |
| 1114 | paddingBottom: Math.max(insets.bottom, 24) + iosBottomToolbarClearance, | 1116 | paddingBottom: Math.max(insets.bottom, 24) + iosBottomToolbarClearance, |
| ... | @@ -1121,11 +1123,22 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -1121,11 +1123,22 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1121 | detail={emptyState.detail} | 1123 | detail={emptyState.detail} |
| 1122 | actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined} | 1124 | actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined} |
| 1123 | onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} | 1125 | onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} |
| 1126 | action={ | ||
| 1127 | Platform.OS === "android" && !props.catalogState.hasReadyEnvironment ? ( | ||
| 1128 | <MaterialFloatingActionButton | ||
| 1129 | label="Add environment" | ||
| 1130 | icon="plus" | ||
| 1131 | variant="extended" | ||
| 1132 | tone="primary" | ||
| 1133 | onPress={props.onAddConnection} | ||
| 1134 | /> | ||
| 1135 | ) : undefined | ||
| 1136 | } | ||
| 1124 | variant="plain" | 1137 | variant="plain" |
| 1125 | /> | 1138 | /> |
| 1126 | {emptyState.loading ? ( | 1139 | {emptyState.loading ? ( |
| 1127 | <View className="mt-4 items-center"> | 1140 | <View className="mt-4 items-center"> |
| 1128 | <ActivityIndicator colorClassName={"accent-icon-muted"} /> | 1141 | <ActivityIndicator colorClassName="accent-icon-muted" /> |
| 1129 | </View> | 1142 | </View> |
| 1130 | ) : null} | 1143 | ) : null} |
| 1131 | </View> | 1144 | </View> |
| ... | @@ -1142,41 +1155,72 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -1142,41 +1155,72 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1142 | 1155 | ||
| 1143 | const listEmpty = !hasResults ? ( | 1156 | const listEmpty = !hasResults ? ( |
| 1144 | hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( | 1157 | hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( |
| 1145 | <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> | 1158 | <EmptyState |
| 1159 | title="No results" | ||
| 1160 | detail={`No threads matching "${props.searchQuery}".`} | ||
| 1161 | variant={Platform.OS === "android" ? "plain" : undefined} | ||
| 1162 | /> | ||
| 1146 | ) : selectedProjectScope !== null ? ( | 1163 | ) : selectedProjectScope !== null ? ( |
| 1147 | <EmptyState | 1164 | <EmptyState |
| 1148 | title={`No threads in ${selectedProjectScope.title}`} | 1165 | title={`No threads in ${selectedProjectScope.title}`} |
| 1149 | detail="Choose another project or create a new task." | 1166 | detail="Choose another project or create a new task." |
| 1167 | variant={Platform.OS === "android" ? "plain" : undefined} | ||
| 1150 | /> | 1168 | /> |
| 1151 | ) : selectedEnvironmentLabel ? ( | 1169 | ) : selectedEnvironmentLabel ? ( |
| 1152 | <EmptyState | 1170 | <EmptyState |
| 1153 | title={`No threads in ${selectedEnvironmentLabel}`} | 1171 | title={`No threads in ${selectedEnvironmentLabel}`} |
| 1154 | detail="Choose another environment or create a new task." | 1172 | detail="Choose another environment or create a new task." |
| 1173 | variant={Platform.OS === "android" ? "plain" : undefined} | ||
| 1155 | /> | 1174 | /> |
| 1156 | ) : ( | 1175 | ) : ( |
| 1157 | <EmptyState title="No threads yet" detail="Create a task to start a new coding session." /> | 1176 | <EmptyState |
| 1177 | title="No threads yet" | ||
| 1178 | detail="Create a task to start a new coding session." | ||
| 1179 | variant={Platform.OS === "android" ? "plain" : undefined} | ||
| 1180 | /> | ||
| 1158 | ) | 1181 | ) |
| 1159 | ) : null; | 1182 | ) : null; |
| 1160 | // Use the v2 project scope for its empty state. Snoozed threads need no | 1183 | // Use the v2 project scope for its empty state. Snoozed threads need no |
| 1161 | // special empty state: their shelf header is a list row even while collapsed. | 1184 | // special empty state: their shelf header is a list row even while collapsed. |
| 1162 | const v2ListEmpty = | 1185 | const v2ListEmpty = |
| 1163 | hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( | 1186 | hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( |
| 1164 | <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> | 1187 | <EmptyState |
| 1188 | title="No results" | ||
| 1189 | detail={`No threads matching "${props.searchQuery}".`} | ||
| 1190 | variant={Platform.OS === "android" ? "plain" : undefined} | ||
| 1191 | /> | ||
| 1165 | ) : v2ScopedProjectGroup !== null ? ( | 1192 | ) : v2ScopedProjectGroup !== null ? ( |
| 1166 | <EmptyState | 1193 | <EmptyState |
| 1167 | title={`No threads in ${v2ScopedProjectGroup.title}`} | 1194 | title={`No threads in ${v2ScopedProjectGroup.title}`} |
| 1168 | detail="Choose another project or create a new task." | 1195 | detail="Choose another project or create a new task." |
| 1196 | variant={Platform.OS === "android" ? "plain" : undefined} | ||
| 1169 | /> | 1197 | /> |
| 1170 | ) : ( | 1198 | ) : ( |
| 1171 | listEmpty | 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 | if (threadListV2Enabled) { | 1218 | if (threadListV2Enabled) { |
| 1175 | return ( | 1219 | return ( |
| 1176 | <View className={materialYouStyleLayoutActive ? "flex-1 bg-header" : "flex-1 bg-screen"}> | 1220 | <View className={Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 bg-screen"}> |
| 1177 | <View | 1221 | <View |
| 1178 | className={ | 1222 | className={ |
| 1179 | materialYouStyleLayoutActive | 1223 | Platform.OS === "android" |
| 1180 | ? "flex-1 overflow-hidden rounded-t-[28px] bg-screen" | 1224 | ? "flex-1 overflow-hidden rounded-t-[28px] bg-screen" |
| 1181 | : "flex-1 bg-screen" | 1225 | : "flex-1 bg-screen" |
| 1182 | } | 1226 | } |
| ... | @@ -1216,7 +1260,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -1216,7 +1260,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1216 | paddingBottom: | 1260 | paddingBottom: |
| 1217 | Platform.OS === "ios" | 1261 | Platform.OS === "ios" |
| 1218 | ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance | 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 | </SwipeableScrollGateProvider> | 1266 | </SwipeableScrollGateProvider> |
| ... | @@ -1226,10 +1270,10 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -1226,10 +1270,10 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1226 | } | 1270 | } |
| 1227 | 1271 | ||
| 1228 | return ( | 1272 | return ( |
| 1229 | <View className={materialYouStyleLayoutActive ? "flex-1 bg-header" : "flex-1 bg-screen"}> | 1273 | <View className={Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 bg-screen"}> |
| 1230 | <View | 1274 | <View |
| 1231 | className={ | 1275 | className={ |
| 1232 | materialYouStyleLayoutActive | 1276 | Platform.OS === "android" |
| 1233 | ? "flex-1 overflow-hidden rounded-t-[28px] bg-screen" | 1277 | ? "flex-1 overflow-hidden rounded-t-[28px] bg-screen" |
| 1234 | : "flex-1 bg-screen" | 1278 | : "flex-1 bg-screen" |
| 1235 | } | 1279 | } |
| ... | @@ -1270,7 +1314,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -1270,7 +1314,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1270 | paddingBottom: | 1314 | paddingBottom: |
| 1271 | Platform.OS === "ios" | 1315 | Platform.OS === "ios" |
| 1272 | ? Math.max(insets.bottom, 24) + 24 + iosBottomToolbarClearance | 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 | scrollIndicatorInsets={ | 1319 | scrollIndicatorInsets={ |
| 1276 | Platform.OS === "ios" | 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,7 +22,7 @@ import { |
| 22 | useState, | 22 | useState, |
| 23 | type ReactNode, | 23 | type ReactNode, |
| 24 | } from "react"; | 24 | } from "react"; |
| 25 | import { useWindowDimensions, View } from "react-native"; | 25 | import { Platform, useWindowDimensions, View } from "react-native"; |
| 26 | import Animated, { | 26 | import Animated, { |
| 27 | useAnimatedStyle, | 27 | useAnimatedStyle, |
| 28 | useDerivedValue, | 28 | useDerivedValue, |
| ... | @@ -56,7 +56,6 @@ import { | ... | @@ -56,7 +56,6 @@ import { |
| 56 | } from "../keyboard/hardwareKeyboardCommands"; | 56 | } from "../keyboard/hardwareKeyboardCommands"; |
| 57 | import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; | 57 | import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; |
| 58 | import { HomeListOptionsProvider } from "../home/home-list-options"; | 58 | import { HomeListOptionsProvider } from "../home/home-list-options"; |
| 59 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 60 | import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; | 59 | import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; |
| 61 | import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; | 60 | import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; |
| 62 | import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; | 61 | import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; |
| ... | @@ -234,7 +233,6 @@ function AdaptiveWorkspaceLayoutContent( | ... | @@ -234,7 +233,6 @@ function AdaptiveWorkspaceLayoutContent( |
| 234 | }, | 233 | }, |
| 235 | ) { | 234 | ) { |
| 236 | const projectGroupingMode = props.projectGroupingMode; | 235 | const projectGroupingMode = props.projectGroupingMode; |
| 237 | const { materialYouStyleLayoutActive } = useAppearancePreferences(); | ||
| 238 | const { width, height } = useWindowDimensions(); | 236 | const { width, height } = useWindowDimensions(); |
| 239 | const pathname = props.pathname; | 237 | const pathname = props.pathname; |
| 240 | const navigation = useNavigation(); | 238 | const navigation = useNavigation(); |
| ... | @@ -582,7 +580,7 @@ function AdaptiveWorkspaceLayoutContent( | ... | @@ -582,7 +580,7 @@ function AdaptiveWorkspaceLayoutContent( |
| 582 | style={sidebarAnimatedStyle} | 580 | style={sidebarAnimatedStyle} |
| 583 | > | 581 | > |
| 584 | <View className="flex-1" style={{ width: layout.listPaneWidth }}> | 582 | <View className="flex-1" style={{ width: layout.listPaneWidth }}> |
| 585 | <AndroidHomeFabLayout onStartNewTask={handleStartNewTask}> | 583 | <AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}> |
| 586 | <ThreadNavigationSidebar | 584 | <ThreadNavigationSidebar |
| 587 | width={layout.listPaneWidth} | 585 | width={layout.listPaneWidth} |
| 588 | visible={panes.primarySidebarVisible} | 586 | visible={panes.primarySidebarVisible} |
| ... | @@ -602,7 +600,7 @@ function AdaptiveWorkspaceLayoutContent( | ... | @@ -602,7 +600,7 @@ function AdaptiveWorkspaceLayoutContent( |
| 602 | ) : null} | 600 | ) : null} |
| 603 | <View | 601 | <View |
| 604 | className={ | 602 | className={ |
| 605 | materialYouStyleLayoutActive | 603 | Platform.OS === "android" |
| 606 | ? "flex-1 overflow-hidden bg-header" | 604 | ? "flex-1 overflow-hidden bg-header" |
| 607 | : "flex-1 overflow-hidden bg-screen" | 605 | : "flex-1 overflow-hidden bg-screen" |
| 608 | } | 606 | } |
| ... | @@ -611,7 +609,12 @@ function AdaptiveWorkspaceLayoutContent( | ... | @@ -611,7 +609,12 @@ function AdaptiveWorkspaceLayoutContent( |
| 611 | <View | 609 | <View |
| 612 | collapsable={false} | 610 | collapsable={false} |
| 613 | style={ | 611 | style={ |
| 614 | contentSettledWidth !== null ? { flex: 1, width: contentSettledWidth } : { flex: 1 } | 612 | contentSettledWidth !== null |
| 613 | ? { | ||
| 614 | flex: 1, | ||
| 615 | width: contentSettledWidth, | ||
| 616 | } | ||
| 617 | : { flex: 1 } | ||
| 615 | } | 618 | } |
| 616 | > | 619 | > |
| 617 | <WorkspaceContentWidthContext | 620 | <WorkspaceContentWidthContext |
apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx+55-26| ... | @@ -1,40 +1,69 @@ | ... | @@ -1,40 +1,69 @@ |
| 1 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 2 | import { SymbolView } from "../../components/AppSymbol"; | 1 | import { SymbolView } from "../../components/AppSymbol"; |
| 3 | import { Pressable, View } from "react-native"; | 2 | import { Platform, Pressable, View } from "react-native"; |
| 4 | 3 | ||
| 5 | import { AppText as Text } from "../../components/AppText"; | 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 }) { | 9 | export function WorkspaceEmptyDetail(props: { |
| 8 | const { materialYouStyleLayoutActive } = useAppearancePreferences(); | 10 | readonly onStartNewTask?: () => void; |
| 11 | readonly onAddConnection?: () => void; | ||
| 12 | }) { | ||
| 9 | return ( | 13 | return ( |
| 10 | <View | 14 | <View |
| 11 | className={ | 15 | className={ |
| 12 | materialYouStyleLayoutActive | 16 | Platform.OS === "android" |
| 13 | ? "flex-1 items-center justify-center px-10" | 17 | ? "flex-1 items-center justify-center px-10" |
| 14 | : "flex-1 items-center justify-center bg-screen px-10" | 18 | : "flex-1 items-center justify-center bg-screen px-10" |
| 15 | } | 19 | } |
| 16 | > | 20 | > |
| 17 | <View className="max-w-[360px] items-center gap-3"> | 21 | {props.onAddConnection ? ( |
| 18 | <SymbolView | 22 | <View className="w-full max-w-[430px]"> |
| 19 | name="sidebar.left" | 23 | <EmptyState |
| 20 | size={34} | 24 | title="No environments connected" |
| 21 | tintColorClassName={"accent-icon-subtle"} | 25 | detail="Add an environment to load projects and start coding sessions." |
| 22 | type="hierarchical" | 26 | variant="plain" |
| 23 | /> | 27 | action={ |
| 24 | <Text className="text-center text-xl font-t3-bold">Select a thread</Text> | 28 | <MaterialFloatingActionButton |
| 25 | <Text className="text-center text-base text-foreground-muted"> | 29 | label="Add environment" |
| 26 | Choose a thread from the sidebar or start a new task. | 30 | icon="plus" |
| 27 | </Text> | 31 | variant="extended" |
| 28 | {props.onStartNewTask ? ( | 32 | tone="primary" |
| 29 | <Pressable | 33 | onPress={props.onAddConnection} |
| 30 | accessibilityRole="button" | 34 | /> |
| 31 | className="mt-2 flex-row items-center gap-2 rounded-full bg-primary px-5 py-3 active:opacity-70" | 35 | } |
| 32 | onPress={props.onStartNewTask} | 36 | /> |
| 33 | > | 37 | </View> |
| 34 | <Text className="text-base font-t3-bold text-primary-foreground">New Task</Text> | 38 | ) : ( |
| 35 | </Pressable> | 39 | <View className="max-w-[360px] items-center gap-3"> |
| 36 | ) : null} | 40 | <SymbolView |
| 37 | </View> | 41 | name="sidebar.left" |
| 42 | size={34} | ||
| 43 | tintColorClassName="accent-icon-subtle" | ||
| 44 | type="hierarchical" | ||
| 45 | /> | ||
| 46 | <Text className="text-center text-xl font-t3-bold">Select a thread</Text> | ||
| 47 | <Text className="text-center text-base text-foreground-muted"> | ||
| 48 | {Platform.OS === "android" | ||
| 49 | ? "Choose a thread from the sidebar or start a new thread." | ||
| 50 | : "Choose a thread from the sidebar or start a new task."} | ||
| 51 | </Text> | ||
| 52 | {props.onStartNewTask ? ( | ||
| 53 | Platform.OS === "android" ? ( | ||
| 54 | <MaterialNewThreadButton extended className="mt-2" onPress={props.onStartNewTask} /> | ||
| 55 | ) : ( | ||
| 56 | <Pressable | ||
| 57 | accessibilityRole="button" | ||
| 58 | className="mt-2 flex-row items-center gap-2 rounded-full bg-primary px-5 py-3 active:opacity-70" | ||
| 59 | onPress={props.onStartNewTask} | ||
| 60 | > | ||
| 61 | <Text className="text-base font-t3-bold text-primary-foreground">New Task</Text> | ||
| 62 | </Pressable> | ||
| 63 | ) | ||
| 64 | ) : null} | ||
| 65 | </View> | ||
| 66 | )} | ||
| 38 | </View> | 67 | </View> |
| 39 | ); | 68 | ); |
| 40 | } | 69 | } |
apps/mobile/src/features/layout/workspace-pane-divider.tsx+2-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { useCallback, useMemo, useRef, useState } from "react"; | 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 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | 3 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; |
| 4 | import { runOnJS } from "react-native-reanimated"; | 4 | import { runOnJS } from "react-native-reanimated"; |
| 5 | import { cn } from "../../lib/cn"; | 5 | import { cn } from "../../lib/cn"; |
| ... | @@ -79,6 +79,7 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { | ... | @@ -79,6 +79,7 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { |
| 79 | className={cn( | 79 | className={cn( |
| 80 | "h-full self-center bg-border opacity-70", | 80 | "h-full self-center bg-border opacity-70", |
| 81 | dragging ? "w-0.5 bg-primary opacity-100" : "w-px", | 81 | dragging ? "w-0.5 bg-primary opacity-100" : "w-px", |
| 82 | Platform.OS === "android" && !dragging && "opacity-0", | ||
| 82 | )} | 83 | )} |
| 83 | style={[styles.line, dragging && styles.activeLine]} | 84 | style={[styles.line, dragging && styles.activeLine]} |
| 84 | /> | 85 | /> |
apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx+18| ... | @@ -2,8 +2,26 @@ import { NativeHeaderToolbar } from "../../native/StackHeader"; | ... | @@ -2,8 +2,26 @@ import { NativeHeaderToolbar } from "../../native/StackHeader"; |
| 2 | import type { ReactNode } from "react"; | 2 | import type { ReactNode } from "react"; |
| 3 | import { Platform } from "react-native"; | 3 | import { Platform } from "react-native"; |
| 4 | 4 | ||
| 5 | import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader"; | ||
| 6 | |||
| 5 | import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; | 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 | export function WorkspaceSidebarToolbar( | 25 | export function WorkspaceSidebarToolbar( |
| 8 | props: { | 26 | props: { |
| 9 | readonly children?: ReactNode; | 27 | readonly children?: ReactNode; |
apps/mobile/src/features/projects/AddProjectScreen.tsx+71-28| ... | @@ -1,3 +1,7 @@ | ... | @@ -1,3 +1,7 @@ |
| 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 | import { | 5 | import { |
| 2 | addProjectRemoteSourceLabel, | 6 | addProjectRemoteSourceLabel, |
| 3 | addProjectRemoteSourcePathHint, | 7 | addProjectRemoteSourcePathHint, |
| ... | @@ -41,14 +45,13 @@ import { | ... | @@ -41,14 +45,13 @@ import { |
| 41 | import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; | 45 | import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; |
| 42 | import { SymbolView } from "../../components/AppSymbol"; | 46 | import { SymbolView } from "../../components/AppSymbol"; |
| 43 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | 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 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 49 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 46 | import * as Arr from "effect/Array"; | 50 | import * as Arr from "effect/Array"; |
| 47 | import * as Cause from "effect/Cause"; | 51 | import * as Cause from "effect/Cause"; |
| 48 | import * as Order from "effect/Order"; | 52 | import * as Order from "effect/Order"; |
| 49 | import { AsyncResult } from "effect/unstable/reactivity"; | 53 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 50 | import { cn } from "../../lib/cn"; | 54 | import { cn } from "../../lib/cn"; |
| 51 | |||
| 52 | import { useProjects, useServerConfigs, waitForProject } from "../../state/entities"; | 55 | import { useProjects, useServerConfigs, waitForProject } from "../../state/entities"; |
| 53 | import { filesystemEnvironment } from "../../state/filesystem"; | 56 | import { filesystemEnvironment } from "../../state/filesystem"; |
| 54 | import { projectEnvironment } from "../../state/projects"; | 57 | import { projectEnvironment } from "../../state/projects"; |
| ... | @@ -123,13 +126,19 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote | ... | @@ -123,13 +126,19 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote |
| 123 | 126 | ||
| 124 | function SectionTitle(props: { readonly children: string }) { | 127 | function SectionTitle(props: { readonly children: string }) { |
| 125 | return ( | 128 | return ( |
| 126 | <Text className="px-1 text-2xs font-t3-bold tracking-[0.7px] uppercase text-foreground-muted"> | 129 | <Text |
| 130 | className={ | ||
| 131 | Platform.OS === "android" | ||
| 132 | ? "px-4 text-sm font-t3-medium text-primary" | ||
| 133 | : "px-1 text-2xs font-t3-bold tracking-[0.7px] uppercase text-foreground-muted" | ||
| 134 | } | ||
| 135 | > | ||
| 127 | {props.children} | 136 | {props.children} |
| 128 | </Text> | 137 | </Text> |
| 129 | ); | 138 | ); |
| 130 | } | 139 | } |
| 131 | 140 | ||
| 132 | function AddProjectShell(props: { readonly children: ReactNode }) { | 141 | function AddProjectShell(props: { readonly children: ReactNode; readonly title: string }) { |
| 133 | const insets = useSafeAreaInsets(); | 142 | const insets = useSafeAreaInsets(); |
| 134 | 143 | ||
| 135 | return ( | 144 | return ( |
| ... | @@ -138,25 +147,35 @@ function AddProjectShell(props: { readonly children: ReactNode }) { | ... | @@ -138,25 +147,35 @@ function AddProjectShell(props: { readonly children: ReactNode }) { |
| 138 | // scroll-view frame correction mistakes this full-height wrapper for a | 147 | // scroll-view frame correction mistakes this full-height wrapper for a |
| 139 | // "header" sibling, coercing the ScrollView to zero height (blank sheet | 148 | // "header" sibling, coercing the ScrollView to zero height (blank sheet |
| 140 | // as soon as the sheet re-lays-out, e.g. when the keyboard opens). | 149 | // as soon as the sheet re-lays-out, e.g. when the keyboard opens). |
| 141 | <View collapsable={false} className="flex-1 bg-sheet"> | 150 | <SettingsScreen title={props.title}> |
| 142 | <ScrollView | 151 | <ScrollView |
| 143 | keyboardShouldPersistTaps="handled" | 152 | keyboardShouldPersistTaps="handled" |
| 144 | contentInsetAdjustmentBehavior="automatic" | 153 | contentInsetAdjustmentBehavior="automatic" |
| 145 | contentContainerStyle={{ | 154 | contentContainerStyle={{ |
| 146 | paddingHorizontal: 20, | 155 | paddingHorizontal: Platform.OS === "android" ? 16 : 20, |
| 147 | paddingTop: 16, | 156 | paddingTop: 16, |
| 148 | paddingBottom: Math.max(insets.bottom, 18) + 18, | 157 | paddingBottom: Math.max(insets.bottom, 18) + 18, |
| 149 | gap: 10, | 158 | gap: Platform.OS === "android" ? 16 : 10, |
| 150 | }} | 159 | }} |
| 151 | > | 160 | > |
| 152 | {props.children} | 161 | {props.children} |
| 153 | </ScrollView> | 162 | </ScrollView> |
| 154 | </View> | 163 | </SettingsScreen> |
| 155 | ); | 164 | ); |
| 156 | } | 165 | } |
| 157 | 166 | ||
| 158 | function ListSection(props: { readonly children: ReactNode }) { | 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 | function ListRow(props: { | 181 | function ListRow(props: { |
| ... | @@ -169,6 +188,20 @@ function ListRow(props: { | ... | @@ -169,6 +188,20 @@ function ListRow(props: { |
| 169 | readonly right?: ReactNode; | 188 | readonly right?: ReactNode; |
| 170 | readonly onPress?: () => void; | 189 | readonly onPress?: () => void; |
| 171 | }) { | 190 | }) { |
| 191 | if (Platform.OS === "android") { | ||
| 192 | return ( | ||
| 193 | <MaterialListRow | ||
| 194 | title={props.title} | ||
| 195 | subtitle={props.subtitle} | ||
| 196 | leading={props.icon} | ||
| 197 | trailing={props.right} | ||
| 198 | disabled={props.disabled} | ||
| 199 | onPress={props.onPress} | ||
| 200 | accessibilityRole={props.selected !== undefined ? "radio" : "button"} | ||
| 201 | accessibilityState={props.selected !== undefined ? { checked: props.selected } : undefined} | ||
| 202 | /> | ||
| 203 | ); | ||
| 204 | } | ||
| 172 | return ( | 205 | return ( |
| 173 | <Pressable | 206 | <Pressable |
| 174 | disabled={props.disabled} | 207 | disabled={props.disabled} |
| ... | @@ -203,7 +236,7 @@ function ListRow(props: { | ... | @@ -203,7 +236,7 @@ function ListRow(props: { |
| 203 | <SymbolView | 236 | <SymbolView |
| 204 | name="chevron.right" | 237 | name="chevron.right" |
| 205 | size={13} | 238 | size={13} |
| 206 | tintColorClassName={"accent-chevron"} | 239 | tintColorClassName="accent-chevron" |
| 207 | type="monochrome" | 240 | type="monochrome" |
| 208 | /> | 241 | /> |
| 209 | ) : null} | 242 | ) : null} |
| ... | @@ -218,6 +251,7 @@ function PrimaryActionButton(props: { | ... | @@ -218,6 +251,7 @@ function PrimaryActionButton(props: { |
| 218 | readonly loading?: boolean; | 251 | readonly loading?: boolean; |
| 219 | readonly onPress: () => void; | 252 | readonly onPress: () => void; |
| 220 | }) { | 253 | }) { |
| 254 | if (Platform.OS === "android") return <MaterialButton {...props} tone="primary" fullWidth />; | ||
| 221 | return ( | 255 | return ( |
| 222 | <Pressable | 256 | <Pressable |
| 223 | disabled={props.disabled} | 257 | disabled={props.disabled} |
| ... | @@ -435,9 +469,18 @@ function SourceControlRow(props: { | ... | @@ -435,9 +469,18 @@ function SourceControlRow(props: { |
| 435 | : `Clone ${addProjectRemoteSourceLabel(props.source)} ${props.hint}`; | 469 | : `Clone ${addProjectRemoteSourceLabel(props.source)} ${props.hint}`; |
| 436 | const icon = | 470 | const icon = |
| 437 | props.source === "url" ? ( | 471 | props.source === "url" ? ( |
| 438 | <SymbolView name="link" size={17} tintColorClassName={"accent-icon"} type="monochrome" /> | 472 | <SymbolView |
| 473 | name="link" | ||
| 474 | size={Platform.OS === "android" ? 24 : 17} | ||
| 475 | tintColorClassName="accent-icon" | ||
| 476 | type="monochrome" | ||
| 477 | /> | ||
| 439 | ) : ( | 478 | ) : ( |
| 440 | <SourceControlIcon kind={props.source} size={18} colorClassName="accent-icon" /> | 479 | <SourceControlIcon |
| 480 | kind={props.source} | ||
| 481 | size={Platform.OS === "android" ? 24 : 18} | ||
| 482 | colorClassName="accent-icon" | ||
| 483 | /> | ||
| 441 | ); | 484 | ); |
| 442 | 485 | ||
| 443 | if (!props.ready) { | 486 | if (!props.ready) { |
| ... | @@ -482,7 +525,7 @@ export function AddProjectSourceScreen() { | ... | @@ -482,7 +525,7 @@ export function AddProjectSourceScreen() { |
| 482 | ); | 525 | ); |
| 483 | 526 | ||
| 484 | return ( | 527 | return ( |
| 485 | <AddProjectShell> | 528 | <AddProjectShell title="Add project"> |
| 486 | {selectedEnvironment === null ? <EmptyEnvironmentState /> : null} | 529 | {selectedEnvironment === null ? <EmptyEnvironmentState /> : null} |
| 487 | 530 | ||
| 488 | {environmentOptions.length > 1 ? ( | 531 | {environmentOptions.length > 1 ? ( |
| ... | @@ -495,7 +538,7 @@ export function AddProjectSourceScreen() { | ... | @@ -495,7 +538,7 @@ export function AddProjectSourceScreen() { |
| 495 | title={environment.label} | 538 | title={environment.label} |
| 496 | subtitle={ | 539 | subtitle={ |
| 497 | canCreateProjectInEnvironment(environment.connectionState) | 540 | canCreateProjectInEnvironment(environment.connectionState) |
| 498 | ? environment.environmentId | 541 | ? undefined |
| 499 | : connectionStatusText({ | 542 | : connectionStatusText({ |
| 500 | phase: environment.connectionState, | 543 | phase: environment.connectionState, |
| 501 | error: environment.connectionError, | 544 | error: environment.connectionError, |
| ... | @@ -505,7 +548,7 @@ export function AddProjectSourceScreen() { | ... | @@ -505,7 +548,7 @@ export function AddProjectSourceScreen() { |
| 505 | icon={ | 548 | icon={ |
| 506 | <EnvironmentMachineSymbol | 549 | <EnvironmentMachineSymbol |
| 507 | kind={environment.machine} | 550 | kind={environment.machine} |
| 508 | size={17} | 551 | size={Platform.OS === "android" ? 24 : 17} |
| 509 | tintColorClassName="accent-icon" | 552 | tintColorClassName="accent-icon" |
| 510 | /> | 553 | /> |
| 511 | } | 554 | } |
| ... | @@ -516,8 +559,8 @@ export function AddProjectSourceScreen() { | ... | @@ -516,8 +559,8 @@ export function AddProjectSourceScreen() { |
| 516 | environment.environmentId === selectedEnvironment?.environmentId ? ( | 559 | environment.environmentId === selectedEnvironment?.environmentId ? ( |
| 517 | <SymbolView | 560 | <SymbolView |
| 518 | name="checkmark" | 561 | name="checkmark" |
| 519 | size={14} | 562 | size={Platform.OS === "android" ? 20 : 14} |
| 520 | tintColorClassName={"accent-icon"} | 563 | tintColorClassName="accent-icon" |
| 521 | type="monochrome" | 564 | type="monochrome" |
| 522 | /> | 565 | /> |
| 523 | ) : null | 566 | ) : null |
| ... | @@ -538,8 +581,8 @@ export function AddProjectSourceScreen() { | ... | @@ -538,8 +581,8 @@ export function AddProjectSourceScreen() { |
| 538 | icon={ | 581 | icon={ |
| 539 | <SymbolView | 582 | <SymbolView |
| 540 | name="folder.badge.plus" | 583 | name="folder.badge.plus" |
| 541 | size={17} | 584 | size={Platform.OS === "android" ? 24 : 17} |
| 542 | tintColorClassName={"accent-icon"} | 585 | tintColorClassName="accent-icon" |
| 543 | type="monochrome" | 586 | type="monochrome" |
| 544 | /> | 587 | /> |
| 545 | } | 588 | } |
| ... | @@ -570,7 +613,7 @@ export function AddProjectSourceScreen() { | ... | @@ -570,7 +613,7 @@ export function AddProjectSourceScreen() { |
| 570 | )} | 613 | )} |
| 571 | </ListSection> | 614 | </ListSection> |
| 572 | {discoveryState.isPending ? ( | 615 | {discoveryState.isPending ? ( |
| 573 | <ActivityIndicator colorClassName={"accent-icon-muted"} /> | 616 | <ActivityIndicator colorClassName="accent-icon-muted" /> |
| 574 | ) : null} | 617 | ) : null} |
| 575 | </> | 618 | </> |
| 576 | ) : null} | 619 | ) : null} |
| ... | @@ -723,7 +766,7 @@ export function AddProjectRepositoryScreen(props: { | ... | @@ -723,7 +766,7 @@ export function AddProjectRepositoryScreen(props: { |
| 723 | }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]); | 766 | }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]); |
| 724 | 767 | ||
| 725 | return ( | 768 | return ( |
| 726 | <AddProjectShell> | 769 | <AddProjectShell title={source === "url" ? "Git URL" : addProjectRemoteSourceLabel(source)}> |
| 727 | {error ? <ErrorBanner message={error} /> : null} | 770 | {error ? <ErrorBanner message={error} /> : null} |
| 728 | {environment ? ( | 771 | {environment ? ( |
| 729 | <> | 772 | <> |
| ... | @@ -800,7 +843,7 @@ function FolderBrowser(props: { | ... | @@ -800,7 +843,7 @@ function FolderBrowser(props: { |
| 800 | <ListSection> | 843 | <ListSection> |
| 801 | {browseState.isPending && browseState.data === null ? ( | 844 | {browseState.isPending && browseState.data === null ? ( |
| 802 | <View className="items-center py-5"> | 845 | <View className="items-center py-5"> |
| 803 | <ActivityIndicator colorClassName={"accent-icon-muted"} /> | 846 | <ActivityIndicator colorClassName="accent-icon-muted" /> |
| 804 | </View> | 847 | </View> |
| 805 | ) : null} | 848 | ) : null} |
| 806 | {browsePath.canBrowseUp ? ( | 849 | {browsePath.canBrowseUp ? ( |
| ... | @@ -809,8 +852,8 @@ function FolderBrowser(props: { | ... | @@ -809,8 +852,8 @@ function FolderBrowser(props: { |
| 809 | icon={ | 852 | icon={ |
| 810 | <SymbolView | 853 | <SymbolView |
| 811 | name="arrow.turn.left.up" | 854 | name="arrow.turn.left.up" |
| 812 | size={17} | 855 | size={Platform.OS === "android" ? 24 : 17} |
| 813 | tintColorClassName={"accent-icon-muted"} | 856 | tintColorClassName="accent-icon-muted" |
| 814 | type="monochrome" | 857 | type="monochrome" |
| 815 | /> | 858 | /> |
| 816 | } | 859 | } |
| ... | @@ -832,8 +875,8 @@ function FolderBrowser(props: { | ... | @@ -832,8 +875,8 @@ function FolderBrowser(props: { |
| 832 | icon={ | 875 | icon={ |
| 833 | <SymbolView | 876 | <SymbolView |
| 834 | name="folder" | 877 | name="folder" |
| 835 | size={17} | 878 | size={Platform.OS === "android" ? 24 : 17} |
| 836 | tintColorClassName={"accent-icon-muted"} | 879 | tintColorClassName="accent-icon-muted" |
| 837 | type="monochrome" | 880 | type="monochrome" |
| 838 | /> | 881 | /> |
| 839 | } | 882 | } |
| ... | @@ -882,7 +925,7 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st | ... | @@ -882,7 +925,7 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st |
| 882 | }, [createProject, environment, isBrowseNavigating, isSubmitting, pathInput]); | 925 | }, [createProject, environment, isBrowseNavigating, isSubmitting, pathInput]); |
| 883 | 926 | ||
| 884 | return ( | 927 | return ( |
| 885 | <AddProjectShell> | 928 | <AddProjectShell title="Local folder"> |
| 886 | {error ? <ErrorBanner message={error} /> : null} | 929 | {error ? <ErrorBanner message={error} /> : null} |
| 887 | {environment ? ( | 930 | {environment ? ( |
| 888 | <> | 931 | <> |
| ... | @@ -1025,7 +1068,7 @@ export function AddProjectDestinationScreen(props: { | ... | @@ -1025,7 +1068,7 @@ export function AddProjectDestinationScreen(props: { |
| 1025 | ]); | 1068 | ]); |
| 1026 | 1069 | ||
| 1027 | return ( | 1070 | return ( |
| 1028 | <AddProjectShell> | 1071 | <AddProjectShell title="Clone destination"> |
| 1029 | {error ? <ErrorBanner message={error} /> : null} | 1072 | {error ? <ErrorBanner message={error} /> : null} |
| 1030 | {repositoryTitle ? ( | 1073 | {repositoryTitle ? ( |
| 1031 | <View className="rounded-[24px] bg-card px-4 py-3"> | 1074 | <View className="rounded-[24px] bg-card px-4 py-3"> |
apps/mobile/src/features/review/ReviewSheet.tsx+250-165| ... | @@ -35,6 +35,8 @@ import { AppText as Text } from "../../components/AppText"; | ... | @@ -35,6 +35,8 @@ import { AppText as Text } from "../../components/AppText"; |
| 35 | import { SymbolView } from "../../components/AppSymbol"; | 35 | import { SymbolView } from "../../components/AppSymbol"; |
| 36 | import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 36 | import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 37 | import { ControlPillMenu } from "../../components/ControlPill"; | 37 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 38 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; | ||
| 39 | import { cn } from "../../lib/cn"; | ||
| 38 | import { environmentCatalog } from "../../connection/catalog"; | 40 | import { environmentCatalog } from "../../connection/catalog"; |
| 39 | import { useEnvironmentPresentation } from "../../state/presentation"; | 41 | import { useEnvironmentPresentation } from "../../state/presentation"; |
| 40 | import { useAtomCommand } from "../../state/use-atom-command"; | 42 | import { useAtomCommand } from "../../state/use-atom-command"; |
| ... | @@ -54,7 +56,10 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s | ... | @@ -54,7 +56,10 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s |
| 54 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; | 56 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; |
| 55 | import { useThreadSelection } from "../../state/use-thread-selection"; | 57 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 56 | import { vcsEnvironment } from "../../state/vcs"; | 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 | import { ThreadGitMenu } from "../threads/ThreadGitControls"; | 63 | import { ThreadGitMenu } from "../threads/ThreadGitControls"; |
| 59 | import { useReviewCacheForThread } from "./reviewState"; | 64 | import { useReviewCacheForThread } from "./reviewState"; |
| 60 | import { | 65 | import { |
| ... | @@ -81,7 +86,12 @@ const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; | ... | @@ -81,7 +86,12 @@ const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; |
| 81 | 86 | ||
| 82 | const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { | 87 | const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { |
| 83 | return ( | 88 | return ( |
| 84 | <View className="border-b border-warning-border bg-warning px-4 py-3"> | 89 | <View |
| 90 | className={cn( | ||
| 91 | "bg-warning px-4 py-3", | ||
| 92 | Platform.OS === "android" ? "m-2 rounded-[20px]" : "border-b border-warning-border", | ||
| 93 | )} | ||
| 94 | > | ||
| 85 | <Text className="text-xs font-t3-bold uppercase text-warning-foreground">Partial diff</Text> | 95 | <Text className="text-xs font-t3-bold uppercase text-warning-foreground">Partial diff</Text> |
| 86 | <Text className="text-xs leading-normal text-warning-foreground">{props.notice}</Text> | 96 | <Text className="text-xs leading-normal text-warning-foreground">{props.notice}</Text> |
| 87 | </View> | 97 | </View> |
| ... | @@ -103,7 +113,7 @@ function ReviewSelectionActionBar(props: { | ... | @@ -103,7 +113,7 @@ function ReviewSelectionActionBar(props: { |
| 103 | <SymbolView | 113 | <SymbolView |
| 104 | name={props.onOpenComment ? "text.bubble" : "line.3.horizontal.decrease.circle"} | 114 | name={props.onOpenComment ? "text.bubble" : "line.3.horizontal.decrease.circle"} |
| 105 | size={16} | 115 | size={16} |
| 106 | tintColorClassName={"accent-primary-foreground"} | 116 | tintColorClassName="accent-primary-foreground" |
| 107 | type="monochrome" | 117 | type="monochrome" |
| 108 | /> | 118 | /> |
| 109 | <Text className="text-base font-t3-bold text-primary-foreground">{props.title}</Text> | 119 | <Text className="text-base font-t3-bold text-primary-foreground">{props.title}</Text> |
| ... | @@ -143,7 +153,7 @@ function ReviewSelectionActionBar(props: { | ... | @@ -143,7 +153,7 @@ function ReviewSelectionActionBar(props: { |
| 143 | <SymbolView | 153 | <SymbolView |
| 144 | name="xmark" | 154 | name="xmark" |
| 145 | size={16} | 155 | size={16} |
| 146 | tintColorClassName={"accent-primary-foreground"} | 156 | tintColorClassName="accent-primary-foreground" |
| 147 | type="monochrome" | 157 | type="monochrome" |
| 148 | /> | 158 | /> |
| 149 | </Pressable> | 159 | </Pressable> |
| ... | @@ -174,9 +184,14 @@ const ReviewFileNavigatorRow = memo(function ReviewFileNavigatorRow(props: { | ... | @@ -174,9 +184,14 @@ const ReviewFileNavigatorRow = memo(function ReviewFileNavigatorRow(props: { |
| 174 | accessibilityRole="button" | 184 | accessibilityRole="button" |
| 175 | accessibilityState={{ selected }} | 185 | accessibilityState={{ selected }} |
| 176 | className={ | 186 | className={ |
| 177 | selected | 187 | Platform.OS === "android" |
| 178 | ? "mt-1 min-h-12 justify-center rounded-xl bg-subtle-strong px-3 py-2" | 188 | ? cn( |
| 179 | : "mt-1 min-h-12 justify-center rounded-xl px-3 py-2 active:bg-subtle" | 189 | "mt-1 min-h-12 justify-center rounded-[20px] px-3 py-2 active:bg-subtle", |
| 190 | selected && "bg-thread-selected", | ||
| 191 | ) | ||
| 192 | : selected | ||
| 193 | ? "mt-1 min-h-12 justify-center rounded-xl bg-subtle-strong px-3 py-2" | ||
| 194 | : "mt-1 min-h-12 justify-center rounded-xl px-3 py-2 active:bg-subtle" | ||
| 180 | } | 195 | } |
| 181 | onPress={handlePress} | 196 | onPress={handlePress} |
| 182 | > | 197 | > |
| ... | @@ -323,16 +338,28 @@ function ReviewFileNavigator({ | ... | @@ -323,16 +338,28 @@ function ReviewFileNavigator({ |
| 323 | } | 338 | } |
| 324 | 339 | ||
| 325 | return ( | 340 | return ( |
| 326 | <View className="flex-1 border-l border-border bg-sheet"> | 341 | <View |
| 327 | <View className="border-b border-border" style={{ paddingTop: headerInset }}> | 342 | className={ |
| 328 | <View className="px-4 py-3"> | 343 | Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 border-l border-border bg-sheet" |
| 329 | <Text className="text-sm font-t3-bold text-foreground">Changed files</Text> | 344 | } |
| 330 | <Text className="text-xs text-foreground-muted"> | 345 | > |
| 331 | {files.length} {files.length === 1 ? "file" : "files"} | 346 | {Platform.OS === "android" ? ( |
| 332 | </Text> | 347 | <AndroidScreenHeader |
| 348 | title="Changed files" | ||
| 349 | subtitle={`${files.length} ${files.length === 1 ? "file" : "files"}`} | ||
| 350 | hideBottomBorder | ||
| 351 | /> | ||
| 352 | ) : ( | ||
| 353 | <View className="border-b border-border" style={{ paddingTop: headerInset }}> | ||
| 354 | <View className="px-4 py-3"> | ||
| 355 | <Text className="text-sm font-t3-bold text-foreground">Changed files</Text> | ||
| 356 | <Text className="text-xs text-foreground-muted"> | ||
| 357 | {files.length} {files.length === 1 ? "file" : "files"} | ||
| 358 | </Text> | ||
| 359 | </View> | ||
| 333 | </View> | 360 | </View> |
| 334 | </View> | 361 | )} |
| 335 | {fileList} | 362 | <MaterialScreenContent insetHorizontal>{fileList}</MaterialScreenContent> |
| 336 | </View> | 363 | </View> |
| 337 | ); | 364 | ); |
| 338 | } | 365 | } |
| ... | @@ -632,23 +659,21 @@ export function ReviewSheet(props: ReviewSheetProps) { | ... | @@ -632,23 +659,21 @@ export function ReviewSheet(props: ReviewSheetProps) { |
| 632 | parsedDiff.kind === "files" && | 659 | parsedDiff.kind === "files" && |
| 633 | NativeReviewDiffView !== null; | 660 | NativeReviewDiffView !== null; |
| 634 | useRegisterWorkspaceInspector(showChangedFilesPane ? renderInspector : undefined); | 661 | useRegisterWorkspaceInspector(showChangedFilesPane ? renderInspector : undefined); |
| 635 | // Raw fallback renders the patch inline with no inspector content, so the | 662 | // A toggle needs registered content; loading, errors and raw patches have no navigator pane. |
| 636 | // pane toggle would open an empty column — hide it in exactly that case. | 663 | const showChangedFilesToggle = panes.supportsAuxiliaryPane && showChangedFilesPane; |
| 637 | const showChangedFilesToggle = | ||
| 638 | panes.supportsAuxiliaryPane && | ||
| 639 | !( | ||
| 640 | !showConnectionNotice && | ||
| 641 | selectedSection !== null && | ||
| 642 | parsedDiff.kind === "files" && | ||
| 643 | NativeReviewDiffView === null | ||
| 644 | ); | ||
| 645 | 664 | ||
| 646 | const listHeader = useMemo(() => { | 665 | const listHeader = useMemo(() => { |
| 647 | const children: ReactElement[] = []; | 666 | const children: ReactElement[] = []; |
| 648 | 667 | ||
| 649 | if (error) { | 668 | if (error) { |
| 650 | children.push( | 669 | children.push( |
| 651 | <View key="review-error" className="border-b border-border bg-card px-4 py-3"> | 670 | <View |
| 671 | key="review-error" | ||
| 672 | className={cn( | ||
| 673 | "bg-card px-4 py-3", | ||
| 674 | Platform.OS === "android" ? "m-2 rounded-[20px]" : "border-b border-border", | ||
| 675 | )} | ||
| 676 | > | ||
| 652 | <Text className="text-sm font-t3-bold text-foreground">Review unavailable</Text> | 677 | <Text className="text-sm font-t3-bold text-foreground">Review unavailable</Text> |
| 653 | <Text className="text-xs leading-normal text-foreground-muted">{error}</Text> | 678 | <Text className="text-xs leading-normal text-foreground-muted">{error}</Text> |
| 654 | </View>, | 679 | </View>, |
| ... | @@ -699,21 +724,35 @@ export function ReviewSheet(props: ReviewSheetProps) { | ... | @@ -699,21 +724,35 @@ export function ReviewSheet(props: ReviewSheetProps) { |
| 699 | {isAndroid ? ( | 724 | {isAndroid ? ( |
| 700 | <AndroidScreenHeader | 725 | <AndroidScreenHeader |
| 701 | title="Review changes" | 726 | title="Review changes" |
| 727 | leading={<AndroidWorkspaceSidebarButton />} | ||
| 728 | hideBottomBorder | ||
| 702 | subtitle={androidHeaderSubtitle || "Select a diff"} | 729 | subtitle={androidHeaderSubtitle || "Select a diff"} |
| 703 | onBack={handleReturnToThread} | 730 | onBack={handleReturnToThread} |
| 704 | trailing={ | 731 | trailing={ |
| 705 | showSectionToolbar ? ( | 732 | <> |
| 706 | <ControlPillMenu | 733 | {showChangedFilesToggle ? ( |
| 707 | actions={androidSectionMenuActions} | ||
| 708 | isAnchoredToRight | ||
| 709 | onPressAction={handleAndroidSectionMenuAction} | ||
| 710 | > | ||
| 711 | <AndroidHeaderIconButton | 734 | <AndroidHeaderIconButton |
| 712 | accessibilityLabel="Select review diff" | 735 | accessibilityLabel={ |
| 713 | icon="ellipsis.circle" | 736 | panes.auxiliaryPaneVisible ? "Hide changed files" : "Show changed files" |
| 737 | } | ||
| 738 | icon="sidebar.right" | ||
| 739 | selected={panes.auxiliaryPaneVisible} | ||
| 740 | onPress={toggleAuxiliaryPane} | ||
| 714 | /> | 741 | /> |
| 715 | </ControlPillMenu> | 742 | ) : null} |
| 716 | ) : 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 | ) : null} | 758 | ) : null} |
| ... | @@ -805,141 +844,187 @@ export function ReviewSheet(props: ReviewSheetProps) { | ... | @@ -805,141 +844,187 @@ export function ReviewSheet(props: ReviewSheetProps) { |
| 805 | </NativeHeaderToolbar> | 844 | </NativeHeaderToolbar> |
| 806 | ) : null} | 845 | ) : null} |
| 807 | 846 | ||
| 808 | <View className="flex-1 bg-sheet"> | 847 | <MaterialScreenContent> |
| 809 | {showConnectionNotice ? ( | 848 | <View className={Platform.OS === "android" ? "flex-1 bg-sheet-solid" : "flex-1 bg-sheet"}> |
| 810 | <View className="flex-1" style={{ paddingTop: topContentInset }}> | 849 | {showConnectionNotice ? ( |
| 811 | <EnvironmentConnectionNotice | 850 | <View className="flex-1" style={{ paddingTop: topContentInset }}> |
| 812 | environmentLabel={environment.presentation?.entry.target.label ?? "Environment"} | 851 | <EnvironmentConnectionNotice |
| 813 | connection={ | 852 | environmentLabel={environment.presentation?.entry.target.label ?? "Environment"} |
| 814 | environment.presentation?.connection ?? { | 853 | connection={ |
| 815 | phase: "available", | 854 | environment.presentation?.connection ?? { |
| 816 | error: null, | 855 | phase: "available", |
| 817 | traceId: null, | 856 | error: null, |
| 857 | traceId: null, | ||
| 858 | } | ||
| 818 | } | 859 | } |
| 819 | } | 860 | resourceName="review" |
| 820 | resourceName="review" | 861 | onRetry={handleRetryEnvironment} |
| 821 | onRetry={handleRetryEnvironment} | 862 | /> |
| 822 | /> | 863 | </View> |
| 823 | </View> | 864 | ) : selectedSection && parsedDiff.kind === "files" && NativeReviewDiffView ? ( |
| 824 | ) : selectedSection && parsedDiff.kind === "files" && NativeReviewDiffView ? ( | ||
| 825 | <View | ||
| 826 | className="flex-1" | ||
| 827 | style={{ | ||
| 828 | backgroundColor: nativeBridge.theme.background, | ||
| 829 | }} | ||
| 830 | > | ||
| 831 | <View | 865 | <View |
| 832 | className="min-w-0 flex-1" | 866 | className="flex-1" |
| 833 | style={{ paddingTop: topContentInset + REVIEW_HEADER_SPACING }} | 867 | style={{ |
| 868 | backgroundColor: nativeBridge.theme.background, | ||
| 869 | }} | ||
| 834 | > | 870 | > |
| 835 | {listHeader} | 871 | <View |
| 836 | <View className="min-w-0 flex-1" collapsable={false}> | 872 | className="min-w-0 flex-1" |
| 837 | <NativeReviewDiffView | 873 | style={{ paddingTop: topContentInset + REVIEW_HEADER_SPACING }} |
| 838 | collapsable={false} | 874 | > |
| 839 | testID="review-native-diff-view" | 875 | {listHeader} |
| 840 | refreshing={isPullRefreshing || isSelectedSectionPending || areFilePatchesPending} | 876 | <View className="min-w-0 flex-1" collapsable={false}> |
| 841 | onPullToRefresh={() => void handlePullToRefresh()} | 877 | <NativeReviewDiffView |
| 842 | style={StyleSheet.absoluteFill} | 878 | collapsable={false} |
| 843 | appearanceScheme={selectedTheme} | 879 | testID="review-native-diff-view" |
| 844 | collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson} | 880 | refreshing={ |
| 845 | collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson} | 881 | isPullRefreshing || isSelectedSectionPending || areFilePatchesPending |
| 846 | contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`} | 882 | } |
| 847 | contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} | 883 | onPullToRefresh={() => void handlePullToRefresh()} |
| 848 | nativeViewRef={nativeReviewDiffViewRef} | 884 | style={StyleSheet.absoluteFill} |
| 849 | rowHeight={nativeReviewDiffStyle.rowHeight} | 885 | appearanceScheme={selectedTheme} |
| 850 | rowsJson={nativeBridge.rowsJson} | 886 | collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson} |
| 851 | selectedRowIdsJson={nativeBridge.selectedRowIdsJson} | 887 | collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson} |
| 852 | styleJson={nativeBridge.styleJson} | 888 | contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`} |
| 853 | themeJson={nativeBridge.themeJson} | 889 | contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} |
| 854 | tokensPatchJson={nativeBridge.tokensPatchJson} | 890 | nativeViewRef={nativeReviewDiffViewRef} |
| 855 | tokensResetKey={nativeBridge.tokensResetKey} | 891 | rowHeight={nativeReviewDiffStyle.rowHeight} |
| 856 | viewedFileIdsJson={nativeBridge.viewedFileIdsJson} | 892 | rowsJson={nativeBridge.rowsJson} |
| 857 | onDebug={handleNativeDebug} | 893 | selectedRowIdsJson={nativeBridge.selectedRowIdsJson} |
| 858 | onPressLine={commentSelection.onPressLine} | 894 | styleJson={nativeBridge.styleJson} |
| 859 | onVisibleFileChange={handleVisibleFileChange} | 895 | themeJson={nativeBridge.themeJson} |
| 860 | onToggleComment={nativeBridge.onToggleComment} | 896 | tokensPatchJson={nativeBridge.tokensPatchJson} |
| 861 | onToggleFile={handleNativeToggleFile} | 897 | tokensResetKey={nativeBridge.tokensResetKey} |
| 862 | onToggleViewedFile={handleNativeToggleViewedFile} | 898 | viewedFileIdsJson={nativeBridge.viewedFileIdsJson} |
| 863 | /> | 899 | onDebug={handleNativeDebug} |
| 900 | onPressLine={commentSelection.onPressLine} | ||
| 901 | onVisibleFileChange={handleVisibleFileChange} | ||
| 902 | onToggleComment={nativeBridge.onToggleComment} | ||
| 903 | onToggleFile={handleNativeToggleFile} | ||
| 904 | onToggleViewedFile={handleNativeToggleViewedFile} | ||
| 905 | /> | ||
| 906 | </View> | ||
| 864 | </View> | 907 | </View> |
| 865 | </View> | 908 | </View> |
| 866 | </View> | 909 | ) : ( |
| 867 | ) : ( | 910 | <ScrollView |
| 868 | <ScrollView | 911 | contentContainerStyle={ |
| 869 | contentInsetAdjustmentBehavior="never" | 912 | Platform.OS === "android" && (parsedDiff.kind === "empty" || !selectedSection) |
| 870 | contentInset={{ top: topContentInset, bottom: Math.max(insets.bottom, 18) + 18 }} | 913 | ? { flexGrow: 1, justifyContent: "center" } |
| 871 | contentOffset={{ x: 0, y: -topContentInset }} | 914 | : undefined |
| 872 | scrollIndicatorInsets={{ | 915 | } |
| 873 | top: topContentInset, | 916 | contentInsetAdjustmentBehavior="never" |
| 874 | bottom: Math.max(insets.bottom, 18) + 18, | 917 | contentInset={{ top: topContentInset, bottom: Math.max(insets.bottom, 18) + 18 }} |
| 875 | }} | 918 | contentOffset={{ x: 0, y: -topContentInset }} |
| 876 | showsVerticalScrollIndicator={false} | 919 | scrollIndicatorInsets={{ |
| 877 | className="flex-1" | 920 | top: topContentInset, |
| 878 | refreshControl={ | 921 | bottom: Math.max(insets.bottom, 18) + 18, |
| 879 | // The native diff surface owns pull-to-refresh via onPullToRefresh; | 922 | }} |
| 880 | // the raw fallback (and empty states) need an explicit control — | 923 | showsVerticalScrollIndicator={false} |
| 881 | // iOS has no other refresh affordance here (the explicit | 924 | className="flex-1" |
| 882 | // "Refresh current diff" menu is Android-only). | 925 | refreshControl={ |
| 883 | <RefreshControl | 926 | // The native diff surface owns pull-to-refresh via onPullToRefresh; |
| 884 | refreshing={isPullRefreshing || isSelectedSectionPending || areFilePatchesPending} | 927 | // the raw fallback (and empty states) need an explicit control — |
| 885 | onRefresh={() => void handlePullToRefresh()} | 928 | // iOS has no other refresh affordance here (the explicit |
| 886 | /> | 929 | // "Refresh current diff" menu is Android-only). |
| 887 | } | 930 | <RefreshControl |
| 888 | > | 931 | refreshing={isPullRefreshing || isSelectedSectionPending || areFilePatchesPending} |
| 889 | {listHeader} | 932 | onRefresh={() => void handlePullToRefresh()} |
| 890 | {!selectedSection ? ( | 933 | /> |
| 891 | <View className="border-b border-border bg-card px-4 py-5"> | 934 | } |
| 892 | <Text className="text-sm font-t3-bold text-foreground">No review diffs</Text> | 935 | > |
| 893 | <Text className="text-xs leading-normal text-foreground-muted"> | 936 | {listHeader} |
| 894 | This thread has no ready turn diffs and the worktree diff is empty. | 937 | {!selectedSection ? ( |
| 895 | </Text> | 938 | <View |
| 896 | </View> | 939 | className={ |
| 897 | ) : selectedSection.isLoading && selectedSection.diff === null ? ( | 940 | Platform.OS === "android" |
| 898 | <View className="items-center gap-3 border-b border-border bg-card px-4 py-6"> | 941 | ? "items-center px-6 py-5" |
| 899 | <ActivityIndicator size="small" /> | 942 | : "border-b border-border bg-card px-4 py-5" |
| 900 | <Text className="text-xs text-foreground-muted">Loading diff…</Text> | 943 | } |
| 901 | </View> | 944 | > |
| 902 | ) : parsedDiff.kind === "empty" ? ( | 945 | <Text className="text-sm font-t3-bold text-foreground">No review diffs</Text> |
| 903 | <View className="border-b border-border bg-card px-4 py-5"> | 946 | <Text |
| 904 | <Text className="text-sm font-t3-bold text-foreground">No changes</Text> | 947 | className={cn( |
| 905 | <Text className="text-xs leading-normal text-foreground-muted"> | 948 | "text-xs leading-normal text-foreground-muted", |
| 906 | {selectedSection.subtitle ?? "This diff is empty."} | 949 | Platform.OS === "android" && "mt-2 text-center", |
| 907 | </Text> | 950 | )} |
| 908 | </View> | 951 | > |
| 909 | ) : parsedDiff.kind === "raw" ? ( | 952 | This thread has no ready turn diffs and the worktree diff is empty. |
| 910 | <View className="gap-3 border-b border-border bg-card px-4 py-4"> | ||
| 911 | <Text className="text-xs leading-normal text-foreground-muted"> | ||
| 912 | {parsedDiff.reason} | ||
| 913 | </Text> | ||
| 914 | <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}> | ||
| 915 | <Text selectable className="font-mono text-xs leading-relaxed text-foreground"> | ||
| 916 | {parsedDiff.text} | ||
| 917 | </Text> | 953 | </Text> |
| 918 | </ScrollView> | 954 | </View> |
| 919 | </View> | 955 | ) : selectedSection.isLoading && selectedSection.diff === null ? ( |
| 920 | ) : parsedDiff.kind === "files" ? ( | 956 | <View |
| 921 | // The native diff surface could not be resolved on this binary; | 957 | className={cn( |
| 922 | // degrade to the raw patch instead of crashing the app. | 958 | "items-center gap-3 px-4 py-6", |
| 923 | <View className="gap-3 border-b border-border bg-card px-4 py-4"> | 959 | Platform.OS !== "android" && "border-b border-border bg-card", |
| 924 | <Text className="text-xs leading-normal text-foreground-muted"> | 960 | )} |
| 925 | Native diff view unavailable. Showing the raw patch. | 961 | > |
| 926 | </Text> | 962 | <ActivityIndicator size="small" /> |
| 927 | <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}> | 963 | <Text className="text-xs text-foreground-muted">Loading diff…</Text> |
| 928 | <Text selectable className="font-mono text-xs leading-relaxed text-foreground"> | 964 | </View> |
| 929 | {selectedSection?.diff ?? ""} | 965 | ) : parsedDiff.kind === "empty" ? ( |
| 966 | <View | ||
| 967 | className={ | ||
| 968 | Platform.OS === "android" | ||
| 969 | ? "items-center px-6 py-5" | ||
| 970 | : "border-b border-border bg-card px-4 py-5" | ||
| 971 | } | ||
| 972 | > | ||
| 973 | <Text className="text-sm font-t3-bold text-foreground">No changes</Text> | ||
| 974 | <Text | ||
| 975 | className={cn( | ||
| 976 | "text-xs leading-normal text-foreground-muted", | ||
| 977 | Platform.OS === "android" && "mt-2 text-center", | ||
| 978 | )} | ||
| 979 | > | ||
| 980 | {selectedSection.subtitle ?? "This diff is empty."} | ||
| 930 | </Text> | 981 | </Text> |
| 931 | </ScrollView> | 982 | </View> |
| 932 | </View> | 983 | ) : parsedDiff.kind === "raw" ? ( |
| 933 | ) : null} | 984 | <View |
| 934 | </ScrollView> | 985 | className={cn( |
| 935 | )} | 986 | "gap-3 bg-card px-4 py-4", |
| 936 | <ReviewSelectionActionBar | 987 | Platform.OS === "android" ? "m-2 rounded-[20px]" : "border-b border-border", |
| 937 | bottomInset={insets.bottom} | 988 | )} |
| 938 | title={commentSelection.selectionAction?.title ?? null} | 989 | > |
| 939 | onOpenComment={commentSelection.selectionAction?.onOpenComment ?? null} | 990 | <Text className="text-xs leading-normal text-foreground-muted"> |
| 940 | onClear={commentSelection.clearSelection} | 991 | {parsedDiff.reason} |
| 941 | /> | 992 | </Text> |
| 942 | </View> | 993 | <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}> |
| 994 | <Text selectable className="font-mono text-xs leading-relaxed text-foreground"> | ||
| 995 | {parsedDiff.text} | ||
| 996 | </Text> | ||
| 997 | </ScrollView> | ||
| 998 | </View> | ||
| 999 | ) : parsedDiff.kind === "files" ? ( | ||
| 1000 | // The native diff surface could not be resolved on this binary; | ||
| 1001 | // degrade to the raw patch instead of crashing the app. | ||
| 1002 | <View | ||
| 1003 | className={cn( | ||
| 1004 | "gap-3 bg-card px-4 py-4", | ||
| 1005 | Platform.OS === "android" ? "m-2 rounded-[20px]" : "border-b border-border", | ||
| 1006 | )} | ||
| 1007 | > | ||
| 1008 | <Text className="text-xs leading-normal text-foreground-muted"> | ||
| 1009 | Native diff view unavailable. Showing the raw patch. | ||
| 1010 | </Text> | ||
| 1011 | <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}> | ||
| 1012 | <Text selectable className="font-mono text-xs leading-relaxed text-foreground"> | ||
| 1013 | {selectedSection?.diff ?? ""} | ||
| 1014 | </Text> | ||
| 1015 | </ScrollView> | ||
| 1016 | </View> | ||
| 1017 | ) : null} | ||
| 1018 | </ScrollView> | ||
| 1019 | )} | ||
| 1020 | <ReviewSelectionActionBar | ||
| 1021 | bottomInset={insets.bottom} | ||
| 1022 | title={commentSelection.selectionAction?.title ?? null} | ||
| 1023 | onOpenComment={commentSelection.selectionAction?.onOpenComment ?? null} | ||
| 1024 | onClear={commentSelection.clearSelection} | ||
| 1025 | /> | ||
| 1026 | </View> | ||
| 1027 | </MaterialScreenContent> | ||
| 943 | </> | 1028 | </> |
| 944 | ); | 1029 | ); |
| 945 | } | 1030 | } |
apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts+25| ... | @@ -255,6 +255,31 @@ describe("createNativeReviewDiffTheme", () => { | ... | @@ -255,6 +255,31 @@ describe("createNativeReviewDiffTheme", () => { |
| 255 | } | 255 | } |
| 256 | }); | 256 | }); |
| 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 | it("uses the selected app palette for native code surfaces", () => { | 283 | it("uses the selected app palette for native code surfaces", () => { |
| 259 | const standard = createNativeReviewDiffTheme("dark", "t3-code", appTheme("t3-code", "dark")); | 284 | const standard = createNativeReviewDiffTheme("dark", "t3-code", appTheme("t3-code", "dark")); |
| 260 | const iris = createNativeReviewDiffTheme("dark", "iris", appTheme("iris", "dark")); | 285 | const iris = createNativeReviewDiffTheme("dark", "iris", appTheme("iris", "dark")); |
apps/mobile/src/features/review/nativeReviewDiffAdapter.ts+14-6| ... | @@ -19,7 +19,7 @@ import type { ReviewInlineComment } from "./reviewCommentSelection"; | ... | @@ -19,7 +19,7 @@ import type { ReviewInlineComment } from "./reviewCommentSelection"; |
| 19 | 19 | ||
| 20 | const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4; | 20 | const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4; |
| 21 | const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45; | 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 | const NATIVE_RGBA_COLOR = | 23 | const NATIVE_RGBA_COLOR = |
| 24 | /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/; | 24 | /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/; |
| 25 | 25 | ||
| ... | @@ -44,15 +44,19 @@ export function buildNativeReviewSnippetRows( | ... | @@ -44,15 +44,19 @@ export function buildNativeReviewSnippetRows( |
| 44 | 44 | ||
| 45 | function opaqueNativeHexColor(color: string, background: string): string { | 45 | function opaqueNativeHexColor(color: string, background: string): string { |
| 46 | const hex = NATIVE_HEX_COLOR.exec(color); | 46 | const hex = NATIVE_HEX_COLOR.exec(color); |
| 47 | if (hex) return color; | 47 | if (hex && !hex[4]) return color; |
| 48 | 48 | ||
| 49 | const rgba = NATIVE_RGBA_COLOR.exec(color); | 49 | const rgba = NATIVE_RGBA_COLOR.exec(color); |
| 50 | const backgroundHex = NATIVE_HEX_COLOR.exec(background); | 50 | const backgroundHex = NATIVE_HEX_COLOR.exec(background); |
| 51 | if (!rgba || !backgroundHex) return background; | 51 | if ((!hex && !rgba) || !backgroundHex) return background; |
| 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 | const channels = [1, 2, 3].map((index) => { | 58 | const channels = [1, 2, 3].map((index) => { |
| 55 | const foreground = Number(rgba[index]); | 59 | const foreground = hex ? Number.parseInt(hex[index] ?? "0", 16) : Number(rgba?.[index]); |
| 56 | const behind = Number.parseInt(backgroundHex[index] ?? "0", 16); | 60 | const behind = Number.parseInt(backgroundHex[index] ?? "0", 16); |
| 57 | return Math.round(foreground * alpha + behind * (1 - alpha)); | 61 | return Math.round(foreground * alpha + behind * (1 - alpha)); |
| 58 | }); | 62 | }); |
| ... | @@ -169,7 +173,11 @@ export function createNativeReviewDiffTheme( | ... | @@ -169,7 +173,11 @@ export function createNativeReviewDiffTheme( |
| 169 | // Swift expects #RRGGBB/#RRGGBBAA while Android expects #RRGGBB/#AARRGGBB. | 173 | // Swift expects #RRGGBB/#RRGGBBAA while Android expects #RRGGBB/#AARRGGBB. |
| 170 | // Flatten translucent app tokens onto the code surface so both native | 174 | // Flatten translucent app tokens onto the code surface so both native |
| 171 | // implementations receive the one unambiguous shared format. | 175 | // implementations receive the one unambiguous shared format. |
| 172 | const background = opaqueNativeHexColor(appTheme["--color-sheet"], appTheme["--color-screen"]); | 176 | const screen = opaqueNativeHexColor( |
| 177 | appTheme["--color-screen"], | ||
| 178 | scheme === "dark" ? "#000000" : "#ffffff", | ||
| 179 | ); | ||
| 180 | const background = opaqueNativeHexColor(appTheme["--color-sheet"], screen); | ||
| 173 | const nativeColor = (color: string) => opaqueNativeHexColor(color, background); | 181 | const nativeColor = (color: string) => opaqueNativeHexColor(color, background); |
| 174 | 182 | ||
| 175 | if (scheme === "dark") { | 183 | if (scheme === "dark") { |
apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx+4-13| ... | @@ -1,26 +1,17 @@ | ... | @@ -1,26 +1,17 @@ |
| 1 | import { useNavigation } from "@react-navigation/native"; | 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 2 | import { Platform, ScrollView, View } from "react-native"; | ||
| 3 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 2 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 4 | 3 | ||
| 5 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 4 | import { SettingsScreen } from "./components/SettingsScreen"; |
| 6 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | ||
| 7 | import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection"; | 5 | import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection"; |
| 8 | import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection"; | 6 | import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection"; |
| 9 | import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection"; | 7 | import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection"; |
| 10 | import { ThemeAppearanceSection } from "./appearance/sections/ThemeAppearanceSection"; | 8 | import { ThemeAppearanceSection } from "./appearance/sections/ThemeAppearanceSection"; |
| 11 | 9 | ||
| 12 | export function SettingsAppearanceRouteScreen() { | 10 | export function SettingsAppearanceRouteScreen() { |
| 13 | const navigation = useNavigation(); | ||
| 14 | const insets = useSafeAreaInsets(); | 11 | const insets = useSafeAreaInsets(); |
| 15 | 12 | ||
| 16 | return ( | 13 | return ( |
| 17 | <View collapsable={false} className="flex-1 bg-sheet"> | 14 | <SettingsScreen title="Appearance"> |
| 18 | {Platform.OS === "android" ? ( | ||
| 19 | <> | ||
| 20 | <NativeStackScreenOptions options={{ headerShown: false }} /> | ||
| 21 | <AndroidScreenHeader title="Appearance" onBack={() => navigation.goBack()} /> | ||
| 22 | </> | ||
| 23 | ) : null} | ||
| 24 | <ScrollView | 15 | <ScrollView |
| 25 | contentInsetAdjustmentBehavior="automatic" | 16 | contentInsetAdjustmentBehavior="automatic" |
| 26 | showsVerticalScrollIndicator={false} | 17 | showsVerticalScrollIndicator={false} |
| ... | @@ -35,6 +26,6 @@ export function SettingsAppearanceRouteScreen() { | ... | @@ -35,6 +26,6 @@ export function SettingsAppearanceRouteScreen() { |
| 35 | <TerminalAppearanceSection /> | 26 | <TerminalAppearanceSection /> |
| 36 | <CodeAppearanceSection /> | 27 | <CodeAppearanceSection /> |
| 37 | </ScrollView> | 28 | </ScrollView> |
| 38 | </View> | 29 | </SettingsScreen> |
| 39 | ); | 30 | ); |
| 40 | } | 31 | } |
apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx+9-9| ... | @@ -1,8 +1,9 @@ | ... | @@ -1,8 +1,9 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | ||
| 1 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 2 | import { type EnvironmentMachineKind, resolveEnvironmentMachineKind } from "@t3tools/contracts"; | 3 | import { type EnvironmentMachineKind, resolveEnvironmentMachineKind } from "@t3tools/contracts"; |
| 3 | import { AsyncResult } from "effect/unstable/reactivity"; | 4 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 4 | import { useMemo } from "react"; | 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 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | 8 | ||
| 8 | import { AppText as Text } from "../../components/AppText"; | 9 | import { AppText as Text } from "../../components/AppText"; |
| ... | @@ -16,6 +17,7 @@ import { | ... | @@ -16,6 +17,7 @@ import { |
| 16 | import { useServerConfigs } from "../../state/entities"; | 17 | import { useServerConfigs } from "../../state/entities"; |
| 17 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | 18 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 18 | import { SettingsSection } from "./components/SettingsSection"; | 19 | import { SettingsSection } from "./components/SettingsSection"; |
| 20 | import { SettingsScreen } from "./components/SettingsScreen"; | ||
| 19 | 21 | ||
| 20 | export function SettingsClientStorageRouteScreen() { | 22 | export function SettingsClientStorageRouteScreen() { |
| 21 | const insets = useSafeAreaInsets(); | 23 | const insets = useSafeAreaInsets(); |
| ... | @@ -71,7 +73,7 @@ export function SettingsClientStorageRouteScreen() { | ... | @@ -71,7 +73,7 @@ export function SettingsClientStorageRouteScreen() { |
| 71 | }; | 73 | }; |
| 72 | 74 | ||
| 73 | return ( | 75 | return ( |
| 74 | <View collapsable={false} className="flex-1 bg-sheet"> | 76 | <SettingsScreen title="Client Storage"> |
| 75 | <ScrollView | 77 | <ScrollView |
| 76 | contentInsetAdjustmentBehavior="automatic" | 78 | contentInsetAdjustmentBehavior="automatic" |
| 77 | contentInset={{ bottom: Math.max(insets.bottom, 18) }} | 79 | contentInset={{ bottom: Math.max(insets.bottom, 18) }} |
| ... | @@ -85,7 +87,7 @@ export function SettingsClientStorageRouteScreen() { | ... | @@ -85,7 +87,7 @@ export function SettingsClientStorageRouteScreen() { |
| 85 | <SymbolView | 87 | <SymbolView |
| 86 | name="exclamationmark.triangle" | 88 | name="exclamationmark.triangle" |
| 87 | size={28} | 89 | size={28} |
| 88 | tintColorClassName={"accent-danger-foreground"} | 90 | tintColorClassName="accent-danger-foreground" |
| 89 | type="monochrome" | 91 | type="monochrome" |
| 90 | weight="regular" | 92 | weight="regular" |
| 91 | /> | 93 | /> |
| ... | @@ -123,7 +125,7 @@ export function SettingsClientStorageRouteScreen() { | ... | @@ -123,7 +125,7 @@ export function SettingsClientStorageRouteScreen() { |
| 123 | <SymbolView | 125 | <SymbolView |
| 124 | name="checkmark.circle" | 126 | name="checkmark.circle" |
| 125 | size={28} | 127 | size={28} |
| 126 | tintColorClassName={"accent-icon"} | 128 | tintColorClassName="accent-icon" |
| 127 | type="monochrome" | 129 | type="monochrome" |
| 128 | weight="regular" | 130 | weight="regular" |
| 129 | /> | 131 | /> |
| ... | @@ -146,16 +148,14 @@ export function SettingsClientStorageRouteScreen() { | ... | @@ -146,16 +148,14 @@ export function SettingsClientStorageRouteScreen() { |
| 146 | <SymbolView | 148 | <SymbolView |
| 147 | name="trash" | 149 | name="trash" |
| 148 | size={22} | 150 | size={22} |
| 149 | tintColorClassName={"accent-danger-foreground"} | 151 | tintColorClassName="accent-danger-foreground" |
| 150 | type="monochrome" | 152 | type="monochrome" |
| 151 | weight="regular" | 153 | weight="regular" |
| 152 | /> | 154 | /> |
| 153 | <Text className="flex-1 text-lg tabular-nums text-danger-foreground"> | 155 | <Text className="flex-1 text-lg tabular-nums text-danger-foreground"> |
| 154 | {summary ? `Clear ${formatBytes(summary.payloadBytes)}` : "Clear caches"} | 156 | {summary ? `Clear ${formatBytes(summary.payloadBytes)}` : "Clear caches"} |
| 155 | </Text> | 157 | </Text> |
| 156 | {isClearing ? ( | 158 | {isClearing ? <ActivityIndicator colorClassName="accent-danger-foreground" /> : null} |
| 157 | <ActivityIndicator colorClassName={"accent-danger-foreground"} /> | ||
| 158 | ) : null} | ||
| 159 | </Pressable> | 159 | </Pressable> |
| 160 | </SettingsSection> | 160 | </SettingsSection> |
| 161 | <Text className="px-2 text-sm leading-normal text-foreground-muted"> | 161 | <Text className="px-2 text-sm leading-normal text-foreground-muted"> |
| ... | @@ -169,7 +169,7 @@ export function SettingsClientStorageRouteScreen() { | ... | @@ -169,7 +169,7 @@ export function SettingsClientStorageRouteScreen() { |
| 169 | ) : null} | 169 | ) : null} |
| 170 | </View> | 170 | </View> |
| 171 | </ScrollView> | 171 | </ScrollView> |
| 172 | </View> | 172 | </SettingsScreen> |
| 173 | ); | 173 | ); |
| 174 | } | 174 | } |
| 175 | 175 |
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+21-27| ... | @@ -1,13 +1,14 @@ | ... | @@ -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 | import { useNavigation } from "@react-navigation/native"; | 3 | import { useNavigation } from "@react-navigation/native"; |
| 3 | import { SymbolView } from "../../components/AppSymbol"; | 4 | import { SymbolView } from "../../components/AppSymbol"; |
| 4 | import type { EnvironmentId } from "@t3tools/contracts"; | 5 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 5 | import { useCallback, useState } from "react"; | 6 | import { useCallback, useState } from "react"; |
| 6 | import { Platform, ScrollView, View } from "react-native"; | 7 | import { Platform, View } from "react-native"; |
| 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | 9 | ||
| 9 | import { AppText as Text } from "../../components/AppText"; | 10 | import { AppText as Text } from "../../components/AppText"; |
| 10 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 11 | import { SettingsScreen } from "./components/SettingsScreen"; |
| 11 | import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; | 12 | import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; |
| 12 | import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; | 13 | import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; |
| 13 | import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings"; | 14 | import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings"; |
| ... | @@ -79,28 +80,21 @@ export function SettingsEnvironmentsRouteScreen() { | ... | @@ -79,28 +80,21 @@ export function SettingsEnvironmentsRouteScreen() { |
| 79 | ); | 80 | ); |
| 80 | 81 | ||
| 81 | return ( | 82 | return ( |
| 82 | <View collapsable={false} className="flex-1 bg-sheet"> | 83 | <SettingsScreen |
| 83 | {Platform.OS === "android" ? ( | 84 | title="Environments" |
| 84 | <> | 85 | actions={[ |
| 85 | {/* Android renders its own in-screen header instead of the native bar. */} | 86 | { |
| 86 | <NativeStackScreenOptions options={{ headerShown: false }} /> | 87 | accessibilityLabel: "Add environment", |
| 87 | <AndroidScreenHeader | 88 | icon: "plus", |
| 88 | title="Environments" | 89 | onPress: () => |
| 89 | onBack={() => navigation.goBack()} | 90 | navigation.navigate("SettingsSheet", { |
| 90 | actions={[ | 91 | screen: "SettingsContent", |
| 91 | { | 92 | params: { screen: "SettingsEnvironmentNew" }, |
| 92 | accessibilityLabel: "Add environment", | 93 | }), |
| 93 | icon: "plus", | 94 | }, |
| 94 | onPress: () => | 95 | ]} |
| 95 | navigation.navigate("SettingsSheet", { | 96 | > |
| 96 | screen: "SettingsContent", | 97 | {Platform.OS !== "android" ? ( |
| 97 | params: { screen: "SettingsEnvironmentNew" }, | ||
| 98 | }), | ||
| 99 | }, | ||
| 100 | ]} | ||
| 101 | /> | ||
| 102 | </> | ||
| 103 | ) : ( | ||
| 104 | <NativeHeaderToolbar placement="right"> | 98 | <NativeHeaderToolbar placement="right"> |
| 105 | <NativeHeaderToolbar.Button | 99 | <NativeHeaderToolbar.Button |
| 106 | icon="plus" | 100 | icon="plus" |
| ... | @@ -114,7 +108,7 @@ export function SettingsEnvironmentsRouteScreen() { | ... | @@ -114,7 +108,7 @@ export function SettingsEnvironmentsRouteScreen() { |
| 114 | tintColor={headerIconColor} | 108 | tintColor={headerIconColor} |
| 115 | /> | 109 | /> |
| 116 | </NativeHeaderToolbar> | 110 | </NativeHeaderToolbar> |
| 117 | )} | 111 | ) : null} |
| 118 | <ScrollView | 112 | <ScrollView |
| 119 | contentInsetAdjustmentBehavior="automatic" | 113 | contentInsetAdjustmentBehavior="automatic" |
| 120 | showsVerticalScrollIndicator={false} | 114 | showsVerticalScrollIndicator={false} |
| ... | @@ -177,6 +171,6 @@ export function SettingsEnvironmentsRouteScreen() { | ... | @@ -177,6 +171,6 @@ export function SettingsEnvironmentsRouteScreen() { |
| 177 | /> | 171 | /> |
| 178 | <GitHubRoutingSettings /> | 172 | <GitHubRoutingSettings /> |
| 179 | </ScrollView> | 173 | </ScrollView> |
| 180 | </View> | 174 | </SettingsScreen> |
| 181 | ); | 175 | ); |
| 182 | } | 176 | } |
apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx+13-35| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | ||
| 1 | import { LegendList } from "@legendapp/list/react-native"; | 2 | import { LegendList } from "@legendapp/list/react-native"; |
| 2 | import { type StaticScreenProps, useNavigation } from "@react-navigation/native"; | 3 | import { type StaticScreenProps, useNavigation } from "@react-navigation/native"; |
| 3 | import { | 4 | import { |
| ... | @@ -8,18 +9,19 @@ import { | ... | @@ -8,18 +9,19 @@ import { |
| 8 | type ThirdPartyLicenseEntry, | 9 | type ThirdPartyLicenseEntry, |
| 9 | } from "@t3tools/shared/thirdPartyLicenses"; | 10 | } from "@t3tools/shared/thirdPartyLicenses"; |
| 10 | import { useCallback, useMemo, useState } from "react"; | 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 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 13 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 13 | 14 | ||
| 14 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | ||
| 15 | import { SymbolView } from "../../components/AppSymbol"; | 15 | import { SymbolView } from "../../components/AppSymbol"; |
| 16 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 16 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 17 | import { SettingsScreen } from "./components/SettingsScreen"; | ||
| 17 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 18 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 18 | import { | 19 | import { |
| 19 | createNativeMailSearchToolbarItem, | 20 | createNativeMailSearchToolbarItem, |
| 20 | NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, | 21 | NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, |
| 21 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, | 22 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, |
| 22 | } from "../layout/native-mail-search-toolbar"; | 23 | } from "../layout/native-mail-search-toolbar"; |
| 24 | |||
| 23 | import { getMobileThirdPartyLicenses } from "./mobileThirdPartyLicenses"; | 25 | import { getMobileThirdPartyLicenses } from "./mobileThirdPartyLicenses"; |
| 24 | 26 | ||
| 25 | function useMobileThirdPartyLicenses() { | 27 | function useMobileThirdPartyLicenses() { |
| ... | @@ -97,24 +99,18 @@ export function SettingsOpenSourceLicensesRouteScreen() { | ... | @@ -97,24 +99,18 @@ export function SettingsOpenSourceLicensesRouteScreen() { |
| 97 | 99 | ||
| 98 | if (!manifest) { | 100 | if (!manifest) { |
| 99 | return ( | 101 | return ( |
| 100 | <View collapsable={false} className="flex-1 bg-sheet"> | 102 | <SettingsScreen title="Open source licenses"> |
| 101 | {Platform.OS === "android" ? ( | ||
| 102 | <> | ||
| 103 | <NativeStackScreenOptions options={{ headerShown: false }} /> | ||
| 104 | <AndroidScreenHeader title="Open source licenses" onBack={() => navigation.goBack()} /> | ||
| 105 | </> | ||
| 106 | ) : null} | ||
| 107 | <View className="flex-1 items-center justify-center px-6"> | 103 | <View className="flex-1 items-center justify-center px-6"> |
| 108 | <Text className="text-center text-base text-foreground-muted"> | 104 | <Text className="text-center text-base text-foreground-muted"> |
| 109 | License notices are unavailable in this build. | 105 | License notices are unavailable in this build. |
| 110 | </Text> | 106 | </Text> |
| 111 | </View> | 107 | </View> |
| 112 | </View> | 108 | </SettingsScreen> |
| 113 | ); | 109 | ); |
| 114 | } | 110 | } |
| 115 | 111 | ||
| 116 | return ( | 112 | return ( |
| 117 | <View collapsable={false} className="flex-1 bg-sheet"> | 113 | <SettingsScreen title="Open source licenses"> |
| 118 | {Platform.OS === "ios" ? ( | 114 | {Platform.OS === "ios" ? ( |
| 119 | <NativeStackScreenOptions | 115 | <NativeStackScreenOptions |
| 120 | options={{ | 116 | options={{ |
| ... | @@ -148,12 +144,7 @@ export function SettingsOpenSourceLicensesRouteScreen() { | ... | @@ -148,12 +144,7 @@ export function SettingsOpenSourceLicensesRouteScreen() { |
| 148 | <NativeHeaderToolbar.SearchBarSlot /> | 144 | <NativeHeaderToolbar.SearchBarSlot /> |
| 149 | </NativeHeaderToolbar> | 145 | </NativeHeaderToolbar> |
| 150 | ) : null} | 146 | ) : null} |
| 151 | {Platform.OS === "android" ? ( | 147 | |
| 152 | <> | ||
| 153 | <NativeStackScreenOptions options={{ headerShown: false }} /> | ||
| 154 | <AndroidScreenHeader title="Open source licenses" onBack={() => navigation.goBack()} /> | ||
| 155 | </> | ||
| 156 | ) : null} | ||
| 157 | <LegendList | 148 | <LegendList |
| 158 | className="flex-1" | 149 | className="flex-1" |
| 159 | contentContainerStyle={{ | 150 | contentContainerStyle={{ |
| ... | @@ -195,14 +186,13 @@ export function SettingsOpenSourceLicensesRouteScreen() { | ... | @@ -195,14 +186,13 @@ export function SettingsOpenSourceLicensesRouteScreen() { |
| 195 | renderItem={renderItem} | 186 | renderItem={renderItem} |
| 196 | showsVerticalScrollIndicator={false} | 187 | showsVerticalScrollIndicator={false} |
| 197 | /> | 188 | /> |
| 198 | </View> | 189 | </SettingsScreen> |
| 199 | ); | 190 | ); |
| 200 | } | 191 | } |
| 201 | 192 | ||
| 202 | type LicenseDetailProps = StaticScreenProps<{ readonly entryKey: string }>; | 193 | type LicenseDetailProps = StaticScreenProps<{ readonly entryKey: string }>; |
| 203 | 194 | ||
| 204 | export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailProps) { | 195 | export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailProps) { |
| 205 | const navigation = useNavigation(); | ||
| 206 | const insets = useSafeAreaInsets(); | 196 | const insets = useSafeAreaInsets(); |
| 207 | const manifest = useMobileThirdPartyLicenses(); | 197 | const manifest = useMobileThirdPartyLicenses(); |
| 208 | const entry = manifest | 198 | const entry = manifest |
| ... | @@ -212,30 +202,18 @@ export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailPro | ... | @@ -212,30 +202,18 @@ export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailPro |
| 212 | 202 | ||
| 213 | if (!entry) { | 203 | if (!entry) { |
| 214 | return ( | 204 | return ( |
| 215 | <View collapsable={false} className="flex-1 bg-sheet"> | 205 | <SettingsScreen title="License notice"> |
| 216 | {Platform.OS === "android" ? ( | ||
| 217 | <> | ||
| 218 | <NativeStackScreenOptions options={{ headerShown: false }} /> | ||
| 219 | <AndroidScreenHeader title="License notice" onBack={() => navigation.goBack()} /> | ||
| 220 | </> | ||
| 221 | ) : null} | ||
| 222 | <View className="flex-1 items-center justify-center px-6"> | 206 | <View className="flex-1 items-center justify-center px-6"> |
| 223 | <Text className="text-center text-base text-foreground-muted"> | 207 | <Text className="text-center text-base text-foreground-muted"> |
| 224 | This license notice is unavailable. | 208 | This license notice is unavailable. |
| 225 | </Text> | 209 | </Text> |
| 226 | </View> | 210 | </View> |
| 227 | </View> | 211 | </SettingsScreen> |
| 228 | ); | 212 | ); |
| 229 | } | 213 | } |
| 230 | 214 | ||
| 231 | return ( | 215 | return ( |
| 232 | <View collapsable={false} className="flex-1 bg-sheet"> | 216 | <SettingsScreen title="License notice"> |
| 233 | {Platform.OS === "android" ? ( | ||
| 234 | <> | ||
| 235 | <NativeStackScreenOptions options={{ headerShown: false }} /> | ||
| 236 | <AndroidScreenHeader title="License notice" onBack={() => navigation.goBack()} /> | ||
| 237 | </> | ||
| 238 | ) : null} | ||
| 239 | <ScrollView | 217 | <ScrollView |
| 240 | className="flex-1" | 218 | className="flex-1" |
| 241 | contentInsetAdjustmentBehavior="automatic" | 219 | contentInsetAdjustmentBehavior="automatic" |
| ... | @@ -275,6 +253,6 @@ export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailPro | ... | @@ -275,6 +253,6 @@ export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailPro |
| 275 | </Text> | 253 | </Text> |
| 276 | </View> | 254 | </View> |
| 277 | </ScrollView> | 255 | </ScrollView> |
| 278 | </View> | 256 | </SettingsScreen> |
| 279 | ); | 257 | ); |
| 280 | } | 258 | } |
apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx+5-13| ... | @@ -1,14 +1,13 @@ | ... | @@ -1,14 +1,13 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | ||
| 1 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 2 | import { useNavigation } from "@react-navigation/native"; | ||
| 3 | import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; | 3 | import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; |
| 4 | import { AsyncResult } from "effect/unstable/reactivity"; | 4 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 5 | import { Platform, Pressable, ScrollView, View } from "react-native"; | 5 | import { Pressable, View } from "react-native"; |
| 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | 7 | ||
| 8 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | ||
| 9 | import { AppText as Text } from "../../components/AppText"; | 8 | import { AppText as Text } from "../../components/AppText"; |
| 10 | import { SymbolView } from "../../components/AppSymbol"; | 9 | import { SymbolView } from "../../components/AppSymbol"; |
| 11 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 10 | import { SettingsScreen } from "./components/SettingsScreen"; |
| 12 | import { | 11 | import { |
| 13 | mobileProjectGroupingModePatch, | 12 | mobileProjectGroupingModePatch, |
| 14 | resolveMobileProjectGroupingSettings, | 13 | resolveMobileProjectGroupingSettings, |
| ... | @@ -39,7 +38,6 @@ const GROUPING_OPTIONS: ReadonlyArray<{ | ... | @@ -39,7 +38,6 @@ const GROUPING_OPTIONS: ReadonlyArray<{ |
| 39 | ]; | 38 | ]; |
| 40 | 39 | ||
| 41 | export function SettingsProjectGroupingRouteScreen() { | 40 | export function SettingsProjectGroupingRouteScreen() { |
| 42 | const navigation = useNavigation(); | ||
| 43 | const insets = useSafeAreaInsets(); | 41 | const insets = useSafeAreaInsets(); |
| 44 | const preferencesResult = useAtomValue(mobilePreferencesAtom); | 42 | const preferencesResult = useAtomValue(mobilePreferencesAtom); |
| 45 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | 43 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); |
| ... | @@ -49,13 +47,7 @@ export function SettingsProjectGroupingRouteScreen() { | ... | @@ -49,13 +47,7 @@ export function SettingsProjectGroupingRouteScreen() { |
| 49 | : null; | 47 | : null; |
| 50 | 48 | ||
| 51 | return ( | 49 | return ( |
| 52 | <View collapsable={false} className="flex-1 bg-sheet"> | 50 | <SettingsScreen title="Project Grouping"> |
| 53 | {Platform.OS === "android" ? ( | ||
| 54 | <> | ||
| 55 | <NativeStackScreenOptions options={{ headerShown: false }} /> | ||
| 56 | <AndroidScreenHeader title="Project Grouping" onBack={() => navigation.goBack()} /> | ||
| 57 | </> | ||
| 58 | ) : null} | ||
| 59 | <ScrollView | 51 | <ScrollView |
| 60 | contentInsetAdjustmentBehavior="automatic" | 52 | contentInsetAdjustmentBehavior="automatic" |
| 61 | showsVerticalScrollIndicator={false} | 53 | showsVerticalScrollIndicator={false} |
| ... | @@ -99,6 +91,6 @@ export function SettingsProjectGroupingRouteScreen() { | ... | @@ -99,6 +91,6 @@ export function SettingsProjectGroupingRouteScreen() { |
| 99 | ))} | 91 | ))} |
| 100 | </SettingsSection> | 92 | </SettingsSection> |
| 101 | </ScrollView> | 93 | </ScrollView> |
| 102 | </View> | 94 | </SettingsScreen> |
| 103 | ); | 95 | ); |
| 104 | } | 96 | } |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+48-58| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import { AutoSettleDaysField } from "./components/AutoSettleDaysField"; | ||
| 2 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | ||
| 1 | import { useAuth, useUser } from "@clerk/expo"; | 3 | import { useAuth, useUser } from "@clerk/expo"; |
| 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | 4 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 3 | import Constants from "expo-constants"; | 5 | import Constants from "expo-constants"; |
| ... | @@ -8,7 +10,7 @@ import { SymbolView } from "../../components/AppSymbol"; | ... | @@ -8,7 +10,7 @@ import { SymbolView } from "../../components/AppSymbol"; |
| 8 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 9 | import { AsyncResult } from "effect/unstable/reactivity"; | 11 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 10 | import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; | 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 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 14 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 13 | 15 | ||
| 14 | import { | 16 | import { |
| ... | @@ -18,8 +20,7 @@ import { | ... | @@ -18,8 +20,7 @@ import { |
| 18 | settlePromise, | 20 | settlePromise, |
| 19 | squashAtomCommandFailure, | 21 | squashAtomCommandFailure, |
| 20 | } from "@t3tools/client-runtime/state/runtime"; | 22 | } from "@t3tools/client-runtime/state/runtime"; |
| 21 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 23 | import { AppText as Text } from "../../components/AppText"; |
| 22 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | ||
| 23 | import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; | 24 | import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; |
| 24 | import { | 25 | import { |
| 25 | openAndroidLiveUpdateSettings, | 26 | openAndroidLiveUpdateSettings, |
| ... | @@ -37,15 +38,12 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu | ... | @@ -37,15 +38,12 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu |
| 37 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; | 38 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; |
| 38 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; | 39 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; |
| 39 | import { runtime } from "../../lib/runtime"; | 40 | import { runtime } from "../../lib/runtime"; |
| 41 | import { cn } from "../../lib/cn"; | ||
| 40 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | 42 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 41 | import { serverEnvironment } from "../../state/server"; | 43 | import { serverEnvironment } from "../../state/server"; |
| 42 | import { useAtomCommand } from "../../state/use-atom-command"; | 44 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 43 | import { useEnvironments } from "../../state/environments"; | 45 | import { useEnvironments } from "../../state/environments"; |
| 44 | import { | 46 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; |
| 45 | DEFAULT_SERVER_SETTINGS, | ||
| 46 | MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | ||
| 47 | MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | ||
| 48 | } from "@t3tools/contracts"; | ||
| 49 | import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; | 47 | import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; |
| 50 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | 48 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; |
| 51 | import { | 49 | import { |
| ... | @@ -58,6 +56,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re | ... | @@ -58,6 +56,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re |
| 58 | import { SettingsRow } from "./components/SettingsRow"; | 56 | import { SettingsRow } from "./components/SettingsRow"; |
| 59 | import { SettingsSection } from "./components/SettingsSection"; | 57 | import { SettingsSection } from "./components/SettingsSection"; |
| 60 | import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; | 58 | import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; |
| 59 | import { SettingsScreen } from "./components/SettingsScreen"; | ||
| 61 | import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; | 60 | import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; |
| 62 | import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; | 61 | import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; |
| 63 | 62 | ||
| ... | @@ -79,17 +78,16 @@ function useDeviceRegistered(): boolean { | ... | @@ -79,17 +78,16 @@ function useDeviceRegistered(): boolean { |
| 79 | 78 | ||
| 80 | export function SettingsRouteScreen() { | 79 | export function SettingsRouteScreen() { |
| 81 | const navigation = useNavigation(); | 80 | const navigation = useNavigation(); |
| 81 | const content = hasCloudPublicConfig() ? ( | ||
| 82 | <ConfiguredSettingsRouteScreen /> | ||
| 83 | ) : ( | ||
| 84 | <LocalSettingsRouteScreen /> | ||
| 85 | ); | ||
| 82 | 86 | ||
| 83 | return ( | 87 | return ( |
| 84 | <> | 88 | <> |
| 85 | <WorkspaceSidebarToolbar /> | 89 | <WorkspaceSidebarToolbar /> |
| 86 | {Platform.OS === "android" ? ( | 90 | {Platform.OS !== "android" ? ( |
| 87 | <> | ||
| 88 | {/* Android renders its own in-screen header instead of the native bar. */} | ||
| 89 | <NativeStackScreenOptions options={{ headerShown: false }} /> | ||
| 90 | <AndroidScreenHeader title="Settings" onBack={() => navigation.goBack()} /> | ||
| 91 | </> | ||
| 92 | ) : ( | ||
| 93 | <NativeStackScreenOptions | 91 | <NativeStackScreenOptions |
| 94 | options={{ | 92 | options={{ |
| 95 | unstable_headerRightItems: | 93 | unstable_headerRightItems: |
| ... | @@ -107,8 +105,12 @@ export function SettingsRouteScreen() { | ... | @@ -107,8 +105,12 @@ export function SettingsRouteScreen() { |
| 107 | : undefined, | 105 | : undefined, |
| 108 | }} | 106 | }} |
| 109 | /> | 107 | /> |
| 108 | ) : null} | ||
| 109 | {Platform.OS === "android" ? ( | ||
| 110 | <SettingsScreen title="Settings">{content}</SettingsScreen> | ||
| 111 | ) : ( | ||
| 112 | content | ||
| 110 | )} | 113 | )} |
| 111 | {hasCloudPublicConfig() ? <ConfiguredSettingsRouteScreen /> : <LocalSettingsRouteScreen />} | ||
| 112 | </> | 114 | </> |
| 113 | ); | 115 | ); |
| 114 | } | 116 | } |
| ... | @@ -134,6 +136,7 @@ function LocalSettingsRouteScreen() { | ... | @@ -134,6 +136,7 @@ function LocalSettingsRouteScreen() { |
| 134 | icon="desktopcomputer" | 136 | icon="desktopcomputer" |
| 135 | label="Environments" | 137 | label="Environments" |
| 136 | value={`${environmentCount}`} | 138 | value={`${environmentCount}`} |
| 139 | valuePosition="trailing" | ||
| 137 | target="SettingsEnvironments" | 140 | target="SettingsEnvironments" |
| 138 | /> | 141 | /> |
| 139 | </SettingsSection> | 142 | </SettingsSection> |
| ... | @@ -516,6 +519,7 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -516,6 +519,7 @@ function ConfiguredSettingsRouteScreen() { |
| 516 | icon="desktopcomputer" | 519 | icon="desktopcomputer" |
| 517 | label="Environments" | 520 | label="Environments" |
| 518 | value={`${environmentCount}`} | 521 | value={`${environmentCount}`} |
| 522 | valuePosition="trailing" | ||
| 519 | target="SettingsEnvironments" | 523 | target="SettingsEnvironments" |
| 520 | /> | 524 | /> |
| 521 | <SettingsSwitchRow | 525 | <SettingsSwitchRow |
| ... | @@ -616,6 +620,7 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD | ... | @@ -616,6 +620,7 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD |
| 616 | */ | 620 | */ |
| 617 | function AutoSettleSettingsRows() { | 621 | function AutoSettleSettingsRows() { |
| 618 | const { environments } = useEnvironments(); | 622 | const { environments } = useEnvironments(); |
| 623 | const [pendingWrites, setPendingWrites] = useState(0); | ||
| 619 | const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { | 624 | const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { |
| 620 | label: "server settings update", | 625 | label: "server settings update", |
| 621 | reportFailure: true, | 626 | reportFailure: true, |
| ... | @@ -625,16 +630,17 @@ function AutoSettleSettingsRows() { | ... | @@ -625,16 +630,17 @@ function AutoSettleSettingsRows() { |
| 625 | const reference = syncTargets[0] ?? null; | 630 | const reference = syncTargets[0] ?? null; |
| 626 | const referenceSettings = reference?.serverConfig?.settings ?? null; | 631 | const referenceSettings = reference?.serverConfig?.settings ?? null; |
| 627 | 632 | ||
| 628 | const [daysDraft, setDaysDraft] = useState<string | null>(null); | ||
| 629 | |||
| 630 | if (reference === null || referenceSettings === null) { | 633 | if (reference === null || referenceSettings === null) { |
| 631 | return null; | 634 | return null; |
| 632 | } | 635 | } |
| 633 | 636 | ||
| 634 | const writeToAll = (patch: Partial<AutoSettleSettings>) => { | 637 | const writeToAll = (patch: Partial<AutoSettleSettings>) => { |
| 635 | for (const environment of syncTargets) { | 638 | setPendingWrites((count) => count + 1); |
| 636 | void updateSettings({ environmentId: environment.environmentId, input: { patch } }); | 639 | void Promise.allSettled( |
| 637 | } | 640 | syncTargets.map((environment) => |
| 641 | updateSettings({ environmentId: environment.environmentId, input: { patch } }), | ||
| 642 | ), | ||
| 643 | ).finally(() => setPendingWrites((count) => count - 1)); | ||
| 638 | }; | 644 | }; |
| 639 | 645 | ||
| 640 | const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync( | 646 | const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync( |
| ... | @@ -647,21 +653,6 @@ function AutoSettleSettingsRows() { | ... | @@ -647,21 +653,6 @@ function AutoSettleSettingsRows() { |
| 647 | ); | 653 | ); |
| 648 | 654 | ||
| 649 | const afterDays = referenceSettings.sidebarAutoSettleAfterDays; | 655 | const afterDays = referenceSettings.sidebarAutoSettleAfterDays; |
| 650 | const commitDays = () => { | ||
| 651 | const draft = (daysDraft ?? "").trim(); | ||
| 652 | setDaysDraft(null); | ||
| 653 | // Whole-string check so "3.5" and "3days" are rejected instead of | ||
| 654 | // silently becoming 3 on every eligible sync target. | ||
| 655 | const parsed = /^\d+$/.test(draft) ? Number(draft) : Number.NaN; | ||
| 656 | if ( | ||
| 657 | Number.isInteger(parsed) && | ||
| 658 | parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && | ||
| 659 | parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && | ||
| 660 | parsed !== afterDays | ||
| 661 | ) { | ||
| 662 | writeToAll({ sidebarAutoSettleAfterDays: parsed }); | ||
| 663 | } | ||
| 664 | }; | ||
| 665 | 656 | ||
| 666 | return ( | 657 | return ( |
| 667 | <> | 658 | <> |
| ... | @@ -674,28 +665,34 @@ function AutoSettleSettingsRows() { | ... | @@ -674,28 +665,34 @@ function AutoSettleSettingsRows() { |
| 674 | <SettingsSwitchRow | 665 | <SettingsSwitchRow |
| 675 | icon="clock" | 666 | icon="clock" |
| 676 | label="Auto-settle inactive threads" | 667 | label="Auto-settle inactive threads" |
| 677 | subtitle={afterDays === null ? undefined : `After ${afterDays} days without activity`} | ||
| 678 | value={afterDays !== null} | 668 | value={afterDays !== null} |
| 679 | onValueChange={(value) => | 669 | onValueChange={(value) => |
| 680 | writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null }) | 670 | writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null }) |
| 681 | } | 671 | } |
| 682 | /> | 672 | /> |
| 683 | {afterDays !== null ? ( | 673 | {afterDays !== null ? ( |
| 684 | <View className="flex-row items-center gap-4 border-t border-border-subtle p-4"> | 674 | <View |
| 685 | <Text className="flex-1 text-lg text-foreground">Days before auto-settle</Text> | 675 | className={cn( |
| 686 | <TextInput | 676 | "flex-row items-center gap-4 px-4", |
| 687 | className="min-h-10 w-20 rounded-xl px-3 py-2 text-center text-base" | 677 | Platform.OS === "android" ? "min-h-14 py-3" : "py-4", |
| 688 | keyboardType="number-pad" | 678 | )} |
| 689 | returnKeyType="done" | 679 | > |
| 690 | value={daysDraft ?? String(afterDays)} | 680 | <View style={{ width: Platform.OS === "android" ? 24 : 22 }} /> |
| 691 | onChangeText={setDaysDraft} | 681 | <Text |
| 692 | onBlur={commitDays} | 682 | className={cn( |
| 693 | onSubmitEditing={commitDays} | 683 | "flex-1 text-foreground", |
| 694 | accessibilityLabel="Days before auto-settle" | 684 | Platform.OS === "android" ? "text-base" : "text-lg", |
| 685 | )} | ||
| 686 | > | ||
| 687 | Inactive days | ||
| 688 | </Text> | ||
| 689 | <AutoSettleDaysField | ||
| 690 | value={afterDays} | ||
| 691 | onValueChange={(value) => writeToAll({ sidebarAutoSettleAfterDays: value })} | ||
| 695 | /> | 692 | /> |
| 696 | </View> | 693 | </View> |
| 697 | ) : null} | 694 | ) : null} |
| 698 | {mismatches.length > 0 ? ( | 695 | {pendingWrites === 0 && mismatches.length > 0 ? ( |
| 699 | <View className="flex-row items-center gap-4 border-t border-border-subtle p-4"> | 696 | <View className="flex-row items-center gap-4 border-t border-border-subtle p-4"> |
| 700 | <View className="min-w-0 flex-1"> | 697 | <View className="min-w-0 flex-1"> |
| 701 | <Text className="text-lg text-foreground">Auto-settle defaults differ</Text> | 698 | <Text className="text-lg text-foreground">Auto-settle defaults differ</Text> |
| ... | @@ -705,14 +702,7 @@ function AutoSettleSettingsRows() { | ... | @@ -705,14 +702,7 @@ function AutoSettleSettingsRows() { |
| 705 | </View> | 702 | </View> |
| 706 | <Pressable | 703 | <Pressable |
| 707 | accessibilityRole="button" | 704 | accessibilityRole="button" |
| 708 | onPress={() => { | 705 | onPress={() => writeToAll(autoSettlePatch)} |
| 709 | for (const mismatch of mismatches) { | ||
| 710 | void updateSettings({ | ||
| 711 | environmentId: mismatch.environmentId, | ||
| 712 | input: { patch: autoSettlePatch }, | ||
| 713 | }); | ||
| 714 | } | ||
| 715 | }} | ||
| 716 | className="rounded-full bg-subtle px-4 py-2 active:opacity-70" | 706 | className="rounded-full bg-subtle px-4 py-2 active:opacity-70" |
| 717 | > | 707 | > |
| 718 | <Text className="text-base font-t3-medium text-foreground"> | 708 | <Text className="text-base font-t3-medium text-foreground"> |
| ... | @@ -831,7 +821,7 @@ function AppSettingsSection() { | ... | @@ -831,7 +821,7 @@ function AppSettingsSection() { |
| 831 | <SymbolView | 821 | <SymbolView |
| 832 | name="info.circle" | 822 | name="info.circle" |
| 833 | size={22} | 823 | size={22} |
| 834 | tintColorClassName={"accent-icon"} | 824 | tintColorClassName="accent-icon" |
| 835 | type="monochrome" | 825 | type="monochrome" |
| 836 | weight="regular" | 826 | weight="regular" |
| 837 | /> | 827 | /> |
apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx+1-19| ... | @@ -51,9 +51,6 @@ interface AppearancePreferencesContextValue { | ... | @@ -51,9 +51,6 @@ interface AppearancePreferencesContextValue { |
| 51 | readonly themeIds: MobileThemeIds; | 51 | readonly themeIds: MobileThemeIds; |
| 52 | readonly themeMode: MobileThemeMode; | 52 | readonly themeMode: MobileThemeMode; |
| 53 | readonly themeAppearance: MobileThemeAppearance; | 53 | readonly themeAppearance: MobileThemeAppearance; |
| 54 | readonly materialYouStyleLayoutEnabled: boolean; | ||
| 55 | readonly materialYouStyleLayoutActive: boolean; | ||
| 56 | readonly setMaterialYouStyleLayoutEnabled: (value: boolean) => void; | ||
| 57 | readonly systemColorsAvailable: boolean; | 54 | readonly systemColorsAvailable: boolean; |
| 58 | readonly systemColorsActive: boolean; | 55 | readonly systemColorsActive: boolean; |
| 59 | readonly themeVariables: MobileThemeVariables; | 56 | readonly themeVariables: MobileThemeVariables; |
| ... | @@ -97,8 +94,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN | ... | @@ -97,8 +94,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN |
| 97 | [resolvedThemeIds.dark, resolvedThemeIds.light], | 94 | [resolvedThemeIds.dark, resolvedThemeIds.light], |
| 98 | ); | 95 | ); |
| 99 | const themeId = themeIds[themeAppearance]; | 96 | const themeId = themeIds[themeAppearance]; |
| 100 | const materialYouStyleLayoutEnabled = storedPreferences?.materialYouStyleLayoutEnabled ?? false; | ||
| 101 | const materialYouStyleLayoutActive = Platform.OS === "android" && materialYouStyleLayoutEnabled; | ||
| 102 | const systemColorsActive = themeId === "material-you" && isSystemColorsAvailable; | 97 | const systemColorsActive = themeId === "material-you" && isSystemColorsAvailable; |
| 103 | const [systemColorPalettes, setSystemColorPalettes] = useState(readSystemColorPalettes); | 98 | const [systemColorPalettes, setSystemColorPalettes] = useState(readSystemColorPalettes); |
| 104 | useEffect(() => { | 99 | useEffect(() => { |
| ... | @@ -120,7 +115,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN | ... | @@ -120,7 +115,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN |
| 120 | }, []); | 115 | }, []); |
| 121 | const themeVariablesByAppearance = useMemo(() => { | 116 | const themeVariablesByAppearance = useMemo(() => { |
| 122 | const resolve = (appearance: MobileThemeAppearance) => { | 117 | const resolve = (appearance: MobileThemeAppearance) => { |
| 123 | const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance); | 118 | const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance, Platform.OS); |
| 124 | return themeIds[appearance] === "material-you" && systemColorPalettes | 119 | return themeIds[appearance] === "material-you" && systemColorPalettes |
| 125 | ? materialYouPaletteToMobileThemeVariables( | 120 | ? materialYouPaletteToMobileThemeVariables( |
| 126 | systemColorPalettes[appearance], | 121 | systemColorPalettes[appearance], |
| ... | @@ -247,13 +242,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN | ... | @@ -247,13 +242,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN |
| 247 | [runtimeState, syncThemeRuntime, updateThemePreferences], | 242 | [runtimeState, syncThemeRuntime, updateThemePreferences], |
| 248 | ); | 243 | ); |
| 249 | 244 | ||
| 250 | const setMaterialYouStyleLayoutEnabled = useCallback( | ||
| 251 | (value: boolean) => { | ||
| 252 | updatePreferences({ materialYouStyleLayoutEnabled: value }); | ||
| 253 | }, | ||
| 254 | [updatePreferences], | ||
| 255 | ); | ||
| 256 | |||
| 257 | const setBaseFontSize = useCallback( | 245 | const setBaseFontSize = useCallback( |
| 258 | (value: number) => { | 246 | (value: number) => { |
| 259 | const current = appliedRuntimeStateRef.current ?? runtimeState; | 247 | const current = appliedRuntimeStateRef.current ?? runtimeState; |
| ... | @@ -293,9 +281,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN | ... | @@ -293,9 +281,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN |
| 293 | themeAppearance, | 281 | themeAppearance, |
| 294 | systemColorsAvailable: isSystemColorsAvailable, | 282 | systemColorsAvailable: isSystemColorsAvailable, |
| 295 | systemColorsActive, | 283 | systemColorsActive, |
| 296 | materialYouStyleLayoutEnabled, | ||
| 297 | materialYouStyleLayoutActive, | ||
| 298 | setMaterialYouStyleLayoutEnabled, | ||
| 299 | themeVariables, | 284 | themeVariables, |
| 300 | themeVariablesByAppearance, | 285 | themeVariablesByAppearance, |
| 301 | systemColorPalettes, | 286 | systemColorPalettes, |
| ... | @@ -315,9 +300,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN | ... | @@ -315,9 +300,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN |
| 315 | themeMode, | 300 | themeMode, |
| 316 | themeAppearance, | 301 | themeAppearance, |
| 317 | systemColorsActive, | 302 | systemColorsActive, |
| 318 | materialYouStyleLayoutEnabled, | ||
| 319 | materialYouStyleLayoutActive, | ||
| 320 | setMaterialYouStyleLayoutEnabled, | ||
| 321 | themeVariables, | 303 | themeVariables, |
| 322 | themeVariablesByAppearance, | 304 | themeVariablesByAppearance, |
| 323 | systemColorPalettes, | 305 | systemColorPalettes, |
apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.android.tsx created+81| ... | @@ -0,0 +1,81 @@ | ||
| 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,210 +1 @@ |
| 1 | import * as Haptics from "expo-haptics"; | 1 | export { FontSizeSliderRow } from "./FontSizeSliderRow.shared"; |
| 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 | } |
apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx+1-18| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { memo, useId } from "react"; | 1 | import { memo, useId } from "react"; |
| 2 | import { Platform, Pressable, View } from "react-native"; | 2 | import { Pressable, View } from "react-native"; |
| 3 | import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; | 3 | import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; |
| 4 | import { ScopedTheme, ScopedVariables } from "uniwind"; | 4 | import { ScopedTheme, ScopedVariables } from "uniwind"; |
| 5 | 5 | ||
| ... | @@ -19,9 +19,6 @@ import { getMobileUniwindThemeName } from "../../../../lib/mobileThemeRuntime"; | ... | @@ -19,9 +19,6 @@ import { getMobileUniwindThemeName } from "../../../../lib/mobileThemeRuntime"; |
| 19 | import { cn } from "../../../../lib/cn"; | 19 | import { cn } from "../../../../lib/cn"; |
| 20 | import { useAppearancePreferences } from "../AppearancePreferencesProvider"; | 20 | import { useAppearancePreferences } from "../AppearancePreferencesProvider"; |
| 21 | 21 | ||
| 22 | import { SettingsSection } from "../../components/SettingsSection"; | ||
| 23 | import { SettingsSwitchRow } from "../../components/SettingsSwitchRow"; | ||
| 24 | |||
| 25 | const APPEARANCE_MODES: ReadonlyArray<{ | 22 | const APPEARANCE_MODES: ReadonlyArray<{ |
| 26 | readonly id: MobileThemeMode; | 23 | readonly id: MobileThemeMode; |
| 27 | readonly label: string; | 24 | readonly label: string; |
| ... | @@ -293,25 +290,11 @@ export function ThemeAppearanceSection() { | ... | @@ -293,25 +290,11 @@ export function ThemeAppearanceSection() { |
| 293 | setThemeMode, | 290 | setThemeMode, |
| 294 | themeIds, | 291 | themeIds, |
| 295 | themeMode, | 292 | themeMode, |
| 296 | materialYouStyleLayoutEnabled, | ||
| 297 | setMaterialYouStyleLayoutEnabled, | ||
| 298 | systemColorsAvailable, | 293 | systemColorsAvailable, |
| 299 | } = useAppearancePreferences(); | 294 | } = useAppearancePreferences(); |
| 300 | 295 | ||
| 301 | return ( | 296 | return ( |
| 302 | <View className="gap-6"> | 297 | <View className="gap-6"> |
| 303 | {Platform.OS === "android" ? ( | ||
| 304 | <SettingsSection card title="Android"> | ||
| 305 | <SettingsSwitchRow | ||
| 306 | disabled={!isReady} | ||
| 307 | icon="square.grid.2x2" | ||
| 308 | label="Material You Layout" | ||
| 309 | onValueChange={setMaterialYouStyleLayoutEnabled} | ||
| 310 | subtitle="Use Material You surfaces, shapes, and component styling." | ||
| 311 | value={materialYouStyleLayoutEnabled} | ||
| 312 | /> | ||
| 313 | </SettingsSection> | ||
| 314 | ) : null} | ||
| 315 | <View className="gap-2"> | 298 | <View className="gap-2"> |
| 316 | <SectionLabel>Color scheme</SectionLabel> | 299 | <SectionLabel>Color scheme</SectionLabel> |
| 317 | <View accessibilityRole="radiogroup" className="flex-row gap-2"> | 300 | <View accessibilityRole="radiogroup" className="flex-row gap-2"> |
apps/mobile/src/features/settings/components/AutoSettleDaysField.android.tsx created+50| ... | @@ -0,0 +1,50 @@ | ||
| 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,11 +1,12 @@ |
| 1 | import { MaterialListRow } from "../../../components/MaterialListRow"; | ||
| 1 | import { useNavigation } from "@react-navigation/native"; | 2 | import { useNavigation } from "@react-navigation/native"; |
| 2 | import type { ComponentProps } from "react"; | 3 | import type { ComponentProps } from "react"; |
| 3 | import { Pressable, View } from "react-native"; | 4 | import { Platform, Pressable, View } from "react-native"; |
| 4 | 5 | ||
| 5 | import { SymbolView } from "../../../components/AppSymbol"; | 6 | import { SymbolView } from "../../../components/AppSymbol"; |
| 6 | |||
| 7 | import { AppText as Text } from "../../../components/AppText"; | 7 | import { AppText as Text } from "../../../components/AppText"; |
| 8 | import type { SettingsLegalDocumentTarget, SettingsSheetTarget } from "./settings-sheet-targets"; | 8 | import type { SettingsLegalDocumentTarget, SettingsSheetTarget } from "./settings-sheet-targets"; |
| 9 | import { cn } from "../../../lib/cn"; | ||
| 9 | 10 | ||
| 10 | type SymbolName = ComponentProps<typeof SymbolView>["name"]; | 11 | type SymbolName = ComponentProps<typeof SymbolView>["name"]; |
| 11 | 12 | ||
| ... | @@ -14,44 +15,77 @@ export function SettingsRow(props: { | ... | @@ -14,44 +15,77 @@ export function SettingsRow(props: { |
| 14 | readonly icon: SymbolName; | 15 | readonly icon: SymbolName; |
| 15 | readonly label: string; | 16 | readonly label: string; |
| 16 | readonly value?: string; | 17 | readonly value?: string; |
| 18 | readonly valuePosition?: "below" | "trailing"; | ||
| 17 | readonly target?: SettingsSheetTarget; | 19 | readonly target?: SettingsSheetTarget; |
| 18 | readonly fullScreenTarget?: SettingsLegalDocumentTarget; | 20 | readonly fullScreenTarget?: SettingsLegalDocumentTarget; |
| 19 | readonly onPress?: () => void; | 21 | readonly onPress?: () => void; |
| 20 | }) { | 22 | }) { |
| 21 | const navigation = useNavigation(); | 23 | const navigation = useNavigation(); |
| 24 | if (Platform.OS === "android") { | ||
| 25 | return ( | ||
| 26 | <MaterialListRow | ||
| 27 | title={props.label} | ||
| 28 | subtitle={props.valuePosition === "trailing" ? undefined : props.value} | ||
| 29 | accessibilityLabel={[props.label, props.value].filter(Boolean).join(", ")} | ||
| 30 | trailing={ | ||
| 31 | props.valuePosition === "trailing" && props.value ? ( | ||
| 32 | <View className="flex-row items-center gap-3"> | ||
| 33 | <Text className="text-sm text-foreground-muted">{props.value}</Text> | ||
| 34 | <SymbolView name="chevron.right" size={16} tintColorClassName="accent-chevron" /> | ||
| 35 | </View> | ||
| 36 | ) : undefined | ||
| 37 | } | ||
| 38 | disabled={props.disabled} | ||
| 39 | leading={ | ||
| 40 | <SymbolView | ||
| 41 | name={props.icon} | ||
| 42 | size={24} | ||
| 43 | tintColorClassName="accent-icon" | ||
| 44 | type="monochrome" | ||
| 45 | weight="regular" | ||
| 46 | /> | ||
| 47 | } | ||
| 48 | onPress={() => { | ||
| 49 | if (props.target) | ||
| 50 | navigation.navigate("SettingsSheet", { | ||
| 51 | screen: "SettingsContent", | ||
| 52 | params: { screen: props.target }, | ||
| 53 | }); | ||
| 54 | else if (props.fullScreenTarget) navigation.navigate(props.fullScreenTarget); | ||
| 55 | else props.onPress?.(); | ||
| 56 | }} | ||
| 57 | /> | ||
| 58 | ); | ||
| 59 | } | ||
| 22 | const content = ( | 60 | const content = ( |
| 23 | <View | 61 | <View className={cn("flex-row items-center gap-4 p-4", props.disabled && "opacity-[0.45]")}> |
| 24 | className={ | ||
| 25 | props.disabled | ||
| 26 | ? "flex-row items-center gap-4 p-4 opacity-[0.45]" | ||
| 27 | : "flex-row items-center gap-4 p-4" | ||
| 28 | } | ||
| 29 | > | ||
| 30 | <SymbolView | 62 | <SymbolView |
| 31 | name={props.icon} | 63 | name={props.icon} |
| 32 | size={22} | 64 | size={22} |
| 33 | tintColorClassName={"accent-icon"} | 65 | tintColorClassName="accent-icon" |
| 34 | type="monochrome" | 66 | type="monochrome" |
| 35 | weight="regular" | 67 | weight="regular" |
| 36 | /> | 68 | /> |
| 37 | <Text className="shrink-0 text-lg text-foreground" numberOfLines={1}> | 69 | <> |
| 38 | {props.label} | 70 | <Text className="shrink-0 text-lg text-foreground" numberOfLines={1}> |
| 39 | </Text> | 71 | {props.label} |
| 40 | <View className="min-w-0 flex-1 items-end"> | 72 | </Text> |
| 41 | {props.value ? ( | 73 | <View className="min-w-0 flex-1 items-end"> |
| 42 | <Text | 74 | {props.value ? ( |
| 43 | className="max-w-[180px] text-right text-base text-foreground-muted" | 75 | <Text |
| 44 | ellipsizeMode="middle" | 76 | className="max-w-[180px] text-right text-base text-foreground-muted" |
| 45 | numberOfLines={1} | 77 | ellipsizeMode="middle" |
| 46 | > | 78 | numberOfLines={1} |
| 47 | {props.value} | 79 | > |
| 48 | </Text> | 80 | {props.value} |
| 49 | ) : null} | 81 | </Text> |
| 50 | </View> | 82 | ) : null} |
| 83 | </View> | ||
| 84 | </> | ||
| 51 | <SymbolView | 85 | <SymbolView |
| 52 | name="chevron.right" | 86 | name="chevron.right" |
| 53 | size={16} | 87 | size={16} |
| 54 | tintColorClassName={"accent-chevron"} | 88 | tintColorClassName="accent-chevron" |
| 55 | type="monochrome" | 89 | type="monochrome" |
| 56 | weight="semibold" | 90 | weight="semibold" |
| 57 | /> | 91 | /> |
| ... | @@ -92,7 +126,12 @@ export function SettingsRow(props: { | ... | @@ -92,7 +126,12 @@ export function SettingsRow(props: { |
| 92 | } | 126 | } |
| 93 | 127 | ||
| 94 | return ( | 128 | return ( |
| 95 | <Pressable accessibilityRole="button" disabled={props.disabled} onPress={props.onPress}> | 129 | <Pressable |
| 130 | accessibilityLabel={props.label} | ||
| 131 | accessibilityRole="button" | ||
| 132 | disabled={props.disabled} | ||
| 133 | onPress={props.onPress} | ||
| 134 | > | ||
| 96 | {content} | 135 | {content} |
| 97 | </Pressable> | 136 | </Pressable> |
| 98 | ); | 137 | ); |
apps/mobile/src/features/settings/components/SettingsScreen.tsx created+43| ... | @@ -0,0 +1,43 @@ | ||
| 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,5 +1,5 @@ |
| 1 | import type { ReactNode } from "react"; | 1 | import type { ReactNode } from "react"; |
| 2 | import { View } from "react-native"; | 2 | import { Platform, View } from "react-native"; |
| 3 | 3 | ||
| 4 | import { AppText as Text } from "../../../components/AppText"; | 4 | import { AppText as Text } from "../../../components/AppText"; |
| 5 | 5 | ||
| ... | @@ -12,13 +12,23 @@ export function SettingsSection(props: { | ... | @@ -12,13 +12,23 @@ export function SettingsSection(props: { |
| 12 | return ( | 12 | return ( |
| 13 | <View className="gap-2"> | 13 | <View className="gap-2"> |
| 14 | {props.title ? ( | 14 | {props.title ? ( |
| 15 | <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{props.title}</Text> | 15 | <Text |
| 16 | className={ | ||
| 17 | Platform.OS === "android" | ||
| 18 | ? "px-4 text-sm font-t3-medium text-primary" | ||
| 19 | : "px-2 text-sm font-t3-medium text-foreground-muted" | ||
| 20 | } | ||
| 21 | > | ||
| 22 | {props.title} | ||
| 23 | </Text> | ||
| 16 | ) : null} | 24 | ) : null} |
| 17 | <View | 25 | <View |
| 18 | className={ | 26 | className={ |
| 19 | props.card | 27 | Platform.OS === "android" |
| 20 | ? "overflow-hidden rounded-[24px] border-continuous bg-card" | 28 | ? "overflow-hidden rounded-[28px] bg-card" |
| 21 | : "overflow-hidden rounded-[24px] border-continuous bg-card android:bg-transparent" | 29 | : props.card |
| 30 | ? "overflow-hidden rounded-[24px] border-continuous bg-card" | ||
| 31 | : "overflow-hidden rounded-[24px] border-continuous bg-card android:bg-transparent" | ||
| 22 | } | 32 | } |
| 23 | > | 33 | > |
| 24 | {props.children} | 34 | {props.children} |
apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx+18-10| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { cn } from "../../../lib/cn"; | ||
| 1 | import type { ComponentProps } from "react"; | 2 | import type { ComponentProps } from "react"; |
| 2 | import { View } from "react-native"; | 3 | import { Platform, View } from "react-native"; |
| 3 | 4 | ||
| 4 | import { SymbolView } from "../../../components/AppSymbol"; | 5 | import { SymbolView } from "../../../components/AppSymbol"; |
| 5 | import { AppText as Text } from "../../../components/AppText"; | 6 | import { AppText as Text } from "../../../components/AppText"; |
| ... | @@ -17,21 +18,28 @@ export function SettingsSwitchRow(props: { | ... | @@ -17,21 +18,28 @@ export function SettingsSwitchRow(props: { |
| 17 | }) { | 18 | }) { |
| 18 | return ( | 19 | return ( |
| 19 | <View | 20 | <View |
| 20 | className={ | 21 | className={cn( |
| 21 | props.disabled | 22 | "flex-row items-center gap-4", |
| 22 | ? "flex-row items-center gap-4 p-4 opacity-[0.45]" | 23 | Platform.OS === "android" ? "min-h-14 px-4 py-3" : "p-4", |
| 23 | : "flex-row items-center gap-4 p-4" | 24 | Platform.OS === "android" && props.subtitle && "min-h-18", |
| 24 | } | 25 | props.disabled && "opacity-[0.45]", |
| 26 | )} | ||
| 25 | > | 27 | > |
| 26 | <SymbolView | 28 | <SymbolView |
| 27 | name={props.icon} | 29 | name={props.icon} |
| 28 | size={22} | 30 | size={Platform.OS === "android" ? 24 : 22} |
| 29 | tintColorClassName={"accent-icon"} | 31 | tintColorClassName="accent-icon" |
| 30 | type="monochrome" | 32 | type="monochrome" |
| 31 | weight="regular" | 33 | weight="regular" |
| 32 | /> | 34 | /> |
| 33 | <View className="min-w-0 flex-1"> | 35 | <View className={cn("min-w-0 flex-1", Platform.OS === "android" && "gap-1")}> |
| 34 | <Text className="text-lg text-foreground">{props.label}</Text> | 36 | <Text |
| 37 | className={ | ||
| 38 | Platform.OS === "android" ? "text-base text-foreground" : "text-lg text-foreground" | ||
| 39 | } | ||
| 40 | > | ||
| 41 | {props.label} | ||
| 42 | </Text> | ||
| 35 | {props.subtitle ? ( | 43 | {props.subtitle ? ( |
| 36 | <Text className="text-sm text-foreground-muted">{props.subtitle}</Text> | 44 | <Text className="text-sm text-foreground-muted">{props.subtitle}</Text> |
| 37 | ) : null} | 45 | ) : null} |
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+184-158| ... | @@ -1,4 +1,3 @@ | ... | @@ -1,4 +1,3 @@ |
| 1 | import { BlurTargetView } from "expo-blur"; | ||
| 2 | import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; | 1 | import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; | 2 | import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; |
| 4 | import type { MenuAction } from "@react-native-menu/menu"; | 3 | import type { MenuAction } from "@react-native-menu/menu"; |
| ... | @@ -7,6 +6,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac | ... | @@ -7,6 +6,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac |
| 7 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | 6 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 8 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 7 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 9 | import { Alert, Platform, Pressable, View } from "react-native"; | 8 | import { Alert, Platform, Pressable, View } from "react-native"; |
| 9 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 10 | import { AppText as Text } from "../../components/AppText"; | 10 | import { AppText as Text } from "../../components/AppText"; |
| 11 | import { TerminalContextSheet } from "./TerminalContextSheet"; | 11 | import { TerminalContextSheet } from "./TerminalContextSheet"; |
| 12 | import { hasNativeTerminalSurface } from "./nativeTerminalModule"; | 12 | import { hasNativeTerminalSurface } from "./nativeTerminalModule"; |
| ... | @@ -29,6 +29,9 @@ import { ControlPillMenu } from "../../components/ControlPill"; | ... | @@ -29,6 +29,9 @@ import { ControlPillMenu } from "../../components/ControlPill"; |
| 29 | import { EmptyState } from "../../components/EmptyState"; | 29 | import { EmptyState } from "../../components/EmptyState"; |
| 30 | import { GlassSurface } from "../../components/GlassSurface"; | 30 | import { GlassSurface } from "../../components/GlassSurface"; |
| 31 | import { LoadingScreen } from "../../components/LoadingScreen"; | 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 | import { environmentCatalog } from "../../connection/catalog"; | 35 | import { environmentCatalog } from "../../connection/catalog"; |
| 33 | import { useEnvironmentPresentation } from "../../state/presentation"; | 36 | import { useEnvironmentPresentation } from "../../state/presentation"; |
| 34 | import { terminalEnvironment } from "../../state/terminal"; | 37 | import { terminalEnvironment } from "../../state/terminal"; |
| ... | @@ -50,6 +53,7 @@ import { useThreadSelection } from "../../state/use-thread-selection"; | ... | @@ -50,6 +53,7 @@ import { useThreadSelection } from "../../state/use-thread-selection"; |
| 50 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; | 53 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; |
| 51 | import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; | 54 | import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; |
| 52 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | 55 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 56 | import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar"; | ||
| 53 | import { TerminalSurface } from "./NativeTerminalSurface"; | 57 | import { TerminalSurface } from "./NativeTerminalSurface"; |
| 54 | import { getMobileTerminalTheme } from "./terminalTheme"; | 58 | import { getMobileTerminalTheme } from "./terminalTheme"; |
| 55 | import { terminalDebugLog } from "./terminalDebugLog"; | 59 | import { terminalDebugLog } from "./terminalDebugLog"; |
| ... | @@ -158,7 +162,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{ | ... | @@ -158,7 +162,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{ |
| 158 | }>; | 162 | }>; |
| 159 | 163 | ||
| 160 | export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { | 164 | export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { |
| 161 | const terminalBlurTarget = useRef<View>(null); | 165 | const insets = useSafeAreaInsets(); |
| 162 | const navigation = useNavigation(); | 166 | const navigation = useNavigation(); |
| 163 | const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); | 167 | const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); |
| 164 | const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); | 168 | const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); |
| ... | @@ -190,6 +194,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) | ... | @@ -190,6 +194,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 190 | themeAppearance: appearanceScheme, | 194 | themeAppearance: appearanceScheme, |
| 191 | themeId, | 195 | themeId, |
| 192 | setTerminalFontSize, | 196 | setTerminalFontSize, |
| 197 | themeVariables, | ||
| 193 | } = useAppearancePreferences(); | 198 | } = useAppearancePreferences(); |
| 194 | const fontSize = appearance.terminalFontSize; | 199 | const fontSize = appearance.terminalFontSize; |
| 195 | const cachedRouteGridSize = | 200 | const cachedRouteGridSize = |
| ... | @@ -515,6 +520,12 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) | ... | @@ -515,6 +520,12 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 515 | isVisible: state.isVisible, | 520 | isVisible: state.isVisible, |
| 516 | })); | 521 | })); |
| 517 | const isAccessoryVisible = keyboardState.isVisible && !isAccessoryDismissed; | 522 | const isAccessoryVisible = keyboardState.isVisible && !isAccessoryDismissed; |
| 523 | // Android's terminal owns an EditText; turning off autoFocus also clears its native focus. | ||
| 524 | const terminalAutoFocus = | ||
| 525 | Platform.OS === "android" | ||
| 526 | ? !isAccessoryDismissed && | ||
| 527 | (!SHOWCASE_ENABLED || keyboardFocusRequest > 0 || keyboardState.isVisible) | ||
| 528 | : !SHOWCASE_ENABLED; | ||
| 518 | const terminalBottomInset = | 529 | const terminalBottomInset = |
| 519 | (keyboardState.isVisible ? keyboardState.height : 0) + | 530 | (keyboardState.isVisible ? keyboardState.height : 0) + |
| 520 | (isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0); | 531 | (isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0); |
| ... | @@ -1103,6 +1114,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) | ... | @@ -1103,6 +1114,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 1103 | }, []); | 1114 | }, []); |
| 1104 | 1115 | ||
| 1105 | const handleShowKeyboard = useCallback(() => { | 1116 | const handleShowKeyboard = useCallback(() => { |
| 1117 | setIsAccessoryDismissed(false); | ||
| 1106 | setKeyboardFocusRequest((current) => current + 1); | 1118 | setKeyboardFocusRequest((current) => current + 1); |
| 1107 | }, []); | 1119 | }, []); |
| 1108 | const handleRetryEnvironment = useCallback(() => { | 1120 | const handleRetryEnvironment = useCallback(() => { |
| ... | @@ -1175,22 +1187,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) | ... | @@ -1175,22 +1187,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 1175 | <AndroidScreenHeader | 1187 | <AndroidScreenHeader |
| 1176 | title="Terminal" | 1188 | title="Terminal" |
| 1177 | subtitle={headerSubtitle} | 1189 | subtitle={headerSubtitle} |
| 1190 | leading={<AndroidWorkspaceSidebarButton />} | ||
| 1178 | onBack={handleCloseTerminal} | 1191 | onBack={handleCloseTerminal} |
| 1179 | trailing={ | 1192 | trailing={ |
| 1180 | <> | 1193 | <> |
| 1181 | {layout.usesSplitView ? ( | ||
| 1182 | <AndroidHeaderIconButton | ||
| 1183 | accessibilityLabel={ | ||
| 1184 | panes.primarySidebarVisible ? "Maximize terminal" : "Show threads" | ||
| 1185 | } | ||
| 1186 | icon={ | ||
| 1187 | panes.primarySidebarVisible | ||
| 1188 | ? "arrow.up.left.and.arrow.down.right" | ||
| 1189 | : "sidebar.left" | ||
| 1190 | } | ||
| 1191 | onPress={togglePrimarySidebar} | ||
| 1192 | /> | ||
| 1193 | ) : null} | ||
| 1194 | {isEnvironmentReady ? ( | 1194 | {isEnvironmentReady ? ( |
| 1195 | <ControlPillMenu | 1195 | <ControlPillMenu |
| 1196 | actions={androidTerminalMenuActions} | 1196 | actions={androidTerminalMenuActions} |
| ... | @@ -1280,162 +1280,188 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) | ... | @@ -1280,162 +1280,188 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 1280 | </NativeHeaderToolbar> | 1280 | </NativeHeaderToolbar> |
| 1281 | ) : null} | 1281 | ) : null} |
| 1282 | 1282 | ||
| 1283 | <View className="flex-1" style={{ backgroundColor: terminalTheme.background }}> | 1283 | <MaterialScreenContent> |
| 1284 | {!isEnvironmentReady ? ( | 1284 | <View |
| 1285 | <EnvironmentConnectionNotice | 1285 | className="flex-1" |
| 1286 | environmentLabel={ | 1286 | style={{ |
| 1287 | environment.presentation?.entry.target.label ?? | 1287 | backgroundColor: |
| 1288 | selectedEnvironmentConnection?.environmentLabel ?? | 1288 | Platform.OS === "android" |
| 1289 | "Environment" | 1289 | ? themeVariables["--color-card-alt"] |
| 1290 | } | 1290 | : terminalTheme.background, |
| 1291 | connection={ | 1291 | paddingBottom: |
| 1292 | environment.presentation?.connection ?? { | 1292 | Platform.OS === "android" && !keyboardState.isVisible ? insets.bottom : 0, |
| 1293 | phase: "available", | 1293 | }} |
| 1294 | error: null, | 1294 | > |
| 1295 | traceId: null, | 1295 | {!isEnvironmentReady ? ( |
| 1296 | <EnvironmentConnectionNotice | ||
| 1297 | environmentLabel={ | ||
| 1298 | environment.presentation?.entry.target.label ?? | ||
| 1299 | selectedEnvironmentConnection?.environmentLabel ?? | ||
| 1300 | "Environment" | ||
| 1296 | } | 1301 | } |
| 1297 | } | 1302 | connection={ |
| 1298 | resourceName="terminal" | 1303 | environment.presentation?.connection ?? { |
| 1299 | onRetry={handleRetryEnvironment} | 1304 | phase: "available", |
| 1300 | /> | 1305 | error: null, |
| 1301 | ) : ( | 1306 | traceId: null, |
| 1302 | <> | 1307 | } |
| 1303 | <BlurTargetView | 1308 | } |
| 1304 | ref={terminalBlurTarget} | 1309 | resourceName="terminal" |
| 1305 | style={{ | 1310 | onRetry={handleRetryEnvironment} |
| 1306 | flex: 1, | 1311 | /> |
| 1307 | paddingBottom: terminalBottomInset, | 1312 | ) : ( |
| 1308 | }} | 1313 | <> |
| 1309 | > | ||
| 1310 | <View | 1314 | <View |
| 1311 | pointerEvents="none" | ||
| 1312 | style={{ | 1315 | style={{ |
| 1313 | position: "absolute", | 1316 | flex: 1, |
| 1314 | inset: 0, | 1317 | paddingBottom: terminalBottomInset, |
| 1315 | backgroundColor: terminalTheme.background, | ||
| 1316 | }} | ||
| 1317 | /> | ||
| 1318 | <TerminalSurface | ||
| 1319 | autoFocus={!SHOWCASE_ENABLED} | ||
| 1320 | buffer={terminalSurfaceBuffer} | ||
| 1321 | fontSize={fontSize} | ||
| 1322 | isRunning={isRunning} | ||
| 1323 | keyboardFocusRequest={keyboardFocusRequest} | ||
| 1324 | captureRequest={captureRequest} | ||
| 1325 | onCapture={(text) => { | ||
| 1326 | if (text.trim()) setCapturedOutput(text); | ||
| 1327 | else Alert.alert("No terminal output", "There is no visible output to attach."); | ||
| 1328 | }} | 1318 | }} |
| 1329 | onInput={handleInput} | ||
| 1330 | onResize={handleResize} | ||
| 1331 | style={{ flex: 1 }} | ||
| 1332 | terminalKey={terminalKey} | ||
| 1333 | theme={terminalTheme} | ||
| 1334 | /> | ||
| 1335 | </BlurTargetView> | ||
| 1336 | |||
| 1337 | {selectedThread && hasNativeTerminalSurface() ? ( | ||
| 1338 | <Pressable | ||
| 1339 | accessibilityRole="button" | ||
| 1340 | onPress={() => { | ||
| 1341 | KeyboardController.dismiss(); | ||
| 1342 | setCaptureRequest((value) => value + 1); | ||
| 1343 | }} | ||
| 1344 | className="px-4 py-2" | ||
| 1345 | > | ||
| 1346 | <Text style={{ color: terminalTheme.foreground }}>Attach visible output</Text> | ||
| 1347 | </Pressable> | ||
| 1348 | ) : null} | ||
| 1349 | {isAccessoryVisible ? ( | ||
| 1350 | <KeyboardStickyView | ||
| 1351 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} | ||
| 1352 | offset={{ closed: 0, opened: 0 }} | ||
| 1353 | > | 1319 | > |
| 1354 | <View | 1320 | <View |
| 1355 | className="border-t" | 1321 | pointerEvents="none" |
| 1356 | style={{ | 1322 | style={{ |
| 1323 | position: "absolute", | ||
| 1324 | inset: 0, | ||
| 1357 | backgroundColor: terminalTheme.background, | 1325 | backgroundColor: terminalTheme.background, |
| 1358 | borderTopColor: terminalTheme.border, | ||
| 1359 | minHeight: TERMINAL_ACCESSORY_HEIGHT, | ||
| 1360 | }} | 1326 | }} |
| 1361 | > | 1327 | /> |
| 1362 | <ComposerToolbarRow paddingBottom={4} paddingHorizontal={8} paddingTop={4}> | 1328 | <TerminalSurface |
| 1363 | <ComposerToolbarScroller | 1329 | autoFocus={terminalAutoFocus} |
| 1364 | contentPaddingRight={2} | 1330 | buffer={terminalSurfaceBuffer} |
| 1365 | fadeOpaque={terminalTheme.background} | 1331 | fontSize={fontSize} |
| 1366 | fadeTransparent={`${terminalTheme.background}00`} | 1332 | isRunning={isRunning} |
| 1367 | > | 1333 | keyboardFocusRequest={keyboardFocusRequest} |
| 1368 | {terminalToolbarActions.map((action) => { | 1334 | captureRequest={captureRequest} |
| 1369 | const active = | 1335 | onCapture={(text) => { |
| 1370 | action.kind === "modifier" && pendingModifier === action.modifier; | 1336 | if (text.trim()) setCapturedOutput(text); |
| 1371 | 1337 | else Alert.alert("No terminal output", "There is no visible output to attach."); | |
| 1372 | return ( | 1338 | }} |
| 1373 | <ComposerToolbarButton | 1339 | onInput={handleInput} |
| 1374 | key={action.key} | 1340 | onResize={handleResize} |
| 1375 | active={active} | 1341 | style={{ flex: 1 }} |
| 1376 | label={action.label} | 1342 | terminalKey={terminalKey} |
| 1377 | maxWidth={120} | 1343 | theme={terminalTheme} |
| 1378 | minWidth={action.label.length > 1 ? 56 : 44} | 1344 | /> |
| 1379 | onPress={() => handleToolbarActionPress(action)} | 1345 | </View> |
| 1380 | showChevron={false} | 1346 | |
| 1381 | textTransform={ | 1347 | {Platform.OS === "android" && !keyboardState.isVisible ? ( |
| 1382 | action.kind === "modifier" || action.kind === "clear" | 1348 | <View className="min-h-14 flex-row items-center gap-2 bg-card-alt px-2"> |
| 1383 | ? "uppercase" | 1349 | {selectedThread && hasNativeTerminalSurface() ? ( |
| 1384 | : "none" | 1350 | <MaterialButton |
| 1385 | } | 1351 | label="Attach output" |
| 1386 | /> | 1352 | tone="text" |
| 1387 | ); | 1353 | onPress={() => setCaptureRequest((value) => value + 1)} |
| 1388 | })} | ||
| 1389 | </ComposerToolbarScroller> | ||
| 1390 | <ComposerToolbarButton | ||
| 1391 | accessibilityLabel="Dismiss keyboard" | ||
| 1392 | icon={{ ios: "keyboard.chevron.compact.down", android: "keyboard_hide" }} | ||
| 1393 | onPress={handleDismissKeyboard} | ||
| 1394 | showChevron={false} | ||
| 1395 | /> | 1354 | /> |
| 1396 | </ComposerToolbarRow> | 1355 | ) : null} |
| 1356 | <View className="flex-1" /> | ||
| 1357 | <MaterialIconButton | ||
| 1358 | accessibilityLabel="Show keyboard" | ||
| 1359 | icon="keyboard" | ||
| 1360 | onPress={handleShowKeyboard} | ||
| 1361 | /> | ||
| 1397 | </View> | 1362 | </View> |
| 1398 | </KeyboardStickyView> | 1363 | ) : Platform.OS !== "android" && selectedThread && hasNativeTerminalSurface() ? ( |
| 1399 | ) : !keyboardState.isVisible ? ( | 1364 | <Pressable |
| 1400 | <Pressable | 1365 | accessibilityRole="button" |
| 1401 | accessibilityLabel="Show keyboard" | 1366 | onPress={() => { |
| 1402 | accessibilityRole="button" | 1367 | KeyboardController.dismiss(); |
| 1403 | onPress={handleShowKeyboard} | 1368 | setCaptureRequest((value) => value + 1); |
| 1404 | style={({ pressed }) => ({ | ||
| 1405 | bottom: 16, | ||
| 1406 | borderRadius: 28, | ||
| 1407 | opacity: pressed ? 0.72 : 1, | ||
| 1408 | position: "absolute", | ||
| 1409 | right: 16, | ||
| 1410 | })} | ||
| 1411 | > | ||
| 1412 | <GlassSurface | ||
| 1413 | chrome="none" | ||
| 1414 | blurTarget={terminalBlurTarget} | ||
| 1415 | fallbackColor={terminalTheme.background} | ||
| 1416 | glassEffectStyle="regular" | ||
| 1417 | tintColor="transparent" | ||
| 1418 | style={{ | ||
| 1419 | alignItems: "center", | ||
| 1420 | borderRadius: 24, | ||
| 1421 | height: 48, | ||
| 1422 | justifyContent: "center", | ||
| 1423 | width: 48, | ||
| 1424 | }} | 1369 | }} |
| 1425 | pointerEvents="none" | 1370 | className="px-4 py-2" |
| 1426 | > | 1371 | > |
| 1427 | <SymbolView | 1372 | <Text style={{ color: terminalTheme.foreground }}>Attach visible output</Text> |
| 1428 | name={{ ios: "keyboard", android: "keyboard" }} | 1373 | </Pressable> |
| 1429 | size={20} | 1374 | ) : null} |
| 1430 | tintColor={terminalTheme.foreground} | 1375 | {isAccessoryVisible ? ( |
| 1431 | type="monochrome" | 1376 | <KeyboardStickyView |
| 1432 | /> | 1377 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} |
| 1433 | </GlassSurface> | 1378 | offset={{ closed: 0, opened: 0 }} |
| 1434 | </Pressable> | 1379 | > |
| 1435 | ) : null} | 1380 | <View |
| 1436 | </> | 1381 | className="border-t" |
| 1437 | )} | 1382 | style={{ |
| 1438 | </View> | 1383 | backgroundColor: terminalTheme.background, |
| 1384 | borderTopColor: terminalTheme.border, | ||
| 1385 | minHeight: TERMINAL_ACCESSORY_HEIGHT, | ||
| 1386 | }} | ||
| 1387 | > | ||
| 1388 | <ComposerToolbarRow paddingBottom={4} paddingHorizontal={8} paddingTop={4}> | ||
| 1389 | <ComposerToolbarScroller | ||
| 1390 | contentPaddingRight={2} | ||
| 1391 | fadeOpaque={terminalTheme.background} | ||
| 1392 | fadeTransparent={`${terminalTheme.background}00`} | ||
| 1393 | > | ||
| 1394 | {terminalToolbarActions.map((action) => { | ||
| 1395 | const active = | ||
| 1396 | action.kind === "modifier" && pendingModifier === action.modifier; | ||
| 1397 | |||
| 1398 | return ( | ||
| 1399 | <ComposerToolbarButton | ||
| 1400 | key={action.key} | ||
| 1401 | active={active} | ||
| 1402 | label={action.label} | ||
| 1403 | maxWidth={120} | ||
| 1404 | minWidth={action.label.length > 1 ? 56 : 44} | ||
| 1405 | onPress={() => handleToolbarActionPress(action)} | ||
| 1406 | showChevron={false} | ||
| 1407 | textTransform={ | ||
| 1408 | action.kind === "modifier" || action.kind === "clear" | ||
| 1409 | ? "uppercase" | ||
| 1410 | : "none" | ||
| 1411 | } | ||
| 1412 | /> | ||
| 1413 | ); | ||
| 1414 | })} | ||
| 1415 | </ComposerToolbarScroller> | ||
| 1416 | <ComposerToolbarButton | ||
| 1417 | accessibilityLabel="Dismiss keyboard" | ||
| 1418 | icon={{ ios: "keyboard.chevron.compact.down", android: "keyboard_hide" }} | ||
| 1419 | onPress={handleDismissKeyboard} | ||
| 1420 | showChevron={false} | ||
| 1421 | /> | ||
| 1422 | </ComposerToolbarRow> | ||
| 1423 | </View> | ||
| 1424 | </KeyboardStickyView> | ||
| 1425 | ) : !keyboardState.isVisible && Platform.OS !== "android" ? ( | ||
| 1426 | <Pressable | ||
| 1427 | accessibilityLabel="Show keyboard" | ||
| 1428 | accessibilityRole="button" | ||
| 1429 | onPress={handleShowKeyboard} | ||
| 1430 | style={({ pressed }) => ({ | ||
| 1431 | bottom: 16, | ||
| 1432 | borderRadius: 28, | ||
| 1433 | opacity: pressed ? 0.72 : 1, | ||
| 1434 | position: "absolute", | ||
| 1435 | right: 16, | ||
| 1436 | })} | ||
| 1437 | > | ||
| 1438 | <GlassSurface | ||
| 1439 | chrome="none" | ||
| 1440 | fallbackColor={terminalTheme.background} | ||
| 1441 | glassEffectStyle="regular" | ||
| 1442 | tintColor="transparent" | ||
| 1443 | style={{ | ||
| 1444 | alignItems: "center", | ||
| 1445 | borderRadius: 24, | ||
| 1446 | height: 48, | ||
| 1447 | justifyContent: "center", | ||
| 1448 | width: 48, | ||
| 1449 | }} | ||
| 1450 | pointerEvents="none" | ||
| 1451 | > | ||
| 1452 | <SymbolView | ||
| 1453 | name={{ ios: "keyboard", android: "keyboard" }} | ||
| 1454 | size={20} | ||
| 1455 | tintColor={terminalTheme.foreground} | ||
| 1456 | type="monochrome" | ||
| 1457 | /> | ||
| 1458 | </GlassSurface> | ||
| 1459 | </Pressable> | ||
| 1460 | ) : null} | ||
| 1461 | </> | ||
| 1462 | )} | ||
| 1463 | </View> | ||
| 1464 | </MaterialScreenContent> | ||
| 1439 | </> | 1465 | </> |
| 1440 | ); | 1466 | ); |
| 1441 | } | 1467 | } |
apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx created+287| ... | @@ -0,0 +1,287 @@ | ||
| 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,7 +3,6 @@ import { |
| 3 | DatePicker, | 3 | DatePicker, |
| 4 | Host, | 4 | Host, |
| 5 | HStack, | 5 | HStack, |
| 6 | Menu, | ||
| 7 | Picker, | 6 | Picker, |
| 8 | Popover, | 7 | Popover, |
| 9 | Spacer, | 8 | Spacer, |
| ... | @@ -11,16 +10,20 @@ import { | ... | @@ -11,16 +10,20 @@ import { |
| 11 | VStack, | 10 | VStack, |
| 12 | } from "@expo/ui/swift-ui"; | 11 | } from "@expo/ui/swift-ui"; |
| 13 | import { | 12 | import { |
| 14 | background, | 13 | accessibilityAddTraits, |
| 14 | accessibilityHidden, | ||
| 15 | buttonBorderShape, | ||
| 15 | buttonStyle, | 16 | buttonStyle, |
| 16 | datePickerStyle, | 17 | clipped, |
| 18 | controlSize, | ||
| 17 | font, | 19 | font, |
| 20 | datePickerStyle, | ||
| 18 | foregroundStyle, | 21 | foregroundStyle, |
| 19 | frame, | 22 | frame, |
| 23 | labelsHidden, | ||
| 24 | labelStyle, | ||
| 20 | padding, | 25 | padding, |
| 21 | pickerStyle, | 26 | pickerStyle, |
| 22 | presentationBackground, | ||
| 23 | shapes, | ||
| 24 | tag, | 27 | tag, |
| 25 | } from "@expo/ui/swift-ui/modifiers"; | 28 | } from "@expo/ui/swift-ui/modifiers"; |
| 26 | import { | 29 | import { |
| ... | @@ -30,9 +33,8 @@ import { | ... | @@ -30,9 +33,8 @@ import { |
| 30 | type CustomSnoozeInput, | 33 | type CustomSnoozeInput, |
| 31 | } from "@t3tools/client-runtime/state/thread-settled"; | 34 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 32 | import { useState } from "react"; | 35 | import { useState } from "react"; |
| 33 | import { Modal, Pressable, ScrollView, View } from "react-native"; | 36 | import { useWindowDimensions } from "react-native"; |
| 34 | import { AppText } from "../../components/AppText"; | 37 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 35 | import { SegmentedControl } from "../../components/SegmentedControl"; | ||
| 36 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 38 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 37 | 39 | ||
| 38 | const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1); | 40 | const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1); |
| ... | @@ -50,13 +52,13 @@ export function CustomSnoozeSheet(props: { | ... | @@ -50,13 +52,13 @@ export function CustomSnoozeSheet(props: { |
| 50 | readonly onClose: () => void; | 52 | readonly onClose: () => void; |
| 51 | readonly onSnooze: (snoozedUntil: string) => void; | 53 | readonly onSnooze: (snoozedUntil: string) => void; |
| 52 | }) { | 54 | }) { |
| 55 | const { width } = useWindowDimensions(); | ||
| 53 | const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date"); | 56 | const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date"); |
| 54 | const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000)); | 57 | const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000)); |
| 55 | const [amount, setAmount] = useState(2); | 58 | const [amount, setAmount] = useState(2); |
| 56 | const [amountOpen, setAmountOpen] = useState(false); | ||
| 57 | const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); | 59 | const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); |
| 58 | const [error, setError] = useState<string | null>(null); | 60 | const [error, setError] = useState<string | null>(null); |
| 59 | const { themeVariables: colors, themeAppearance, appearance } = useAppearancePreferences(); | 61 | const { themeVariables: colors, themeAppearance } = useAppearancePreferences(); |
| 60 | const updateDate = (value: Date) => { | 62 | const updateDate = (value: Date) => { |
| 61 | setDate(value); | 63 | setDate(value); |
| 62 | setError(null); | 64 | setError(null); |
| ... | @@ -79,190 +81,150 @@ export function CustomSnoozeSheet(props: { | ... | @@ -79,190 +81,150 @@ export function CustomSnoozeSheet(props: { |
| 79 | }; | 81 | }; |
| 80 | 82 | ||
| 81 | return ( | 83 | return ( |
| 82 | <Modal visible transparent animationType="fade" onRequestClose={props.onClose}> | 84 | <Host |
| 83 | <View className="flex-1 items-center justify-center bg-black/40 px-6"> | 85 | style={{ position: "absolute", top: 0, right: 0, bottom: 0, left: 0 }} |
| 84 | <ScrollView | 86 | colorScheme={themeAppearance} |
| 85 | className="max-h-[80%] w-full max-w-sm grow-0 rounded-3xl border border-border bg-sheet-solid" | 87 | seedColor={colors["--color-primary"]} |
| 86 | contentContainerStyle={{ padding: 20, gap: 16 }} | 88 | > |
| 87 | > | 89 | <Popover |
| 88 | <View className="flex-row items-center justify-between gap-2"> | 90 | isPresented |
| 89 | <Pressable accessibilityRole="button" onPress={props.onClose} hitSlop={10}> | 91 | onIsPresentedChange={(presented) => { |
| 90 | <AppText className="text-base">Cancel</AppText> | 92 | if (!presented) props.onClose(); |
| 91 | </Pressable> | 93 | }} |
| 92 | <AppText accessibilityRole="header" className="text-base font-t3-semibold"> | 94 | > |
| 93 | Custom snooze | 95 | <Popover.Trigger> |
| 94 | </AppText> | 96 | <Spacer |
| 95 | <Pressable accessibilityRole="button" onPress={submit} hitSlop={10}> | 97 | modifiers={[frame({ maxWidth: Infinity, maxHeight: Infinity }), accessibilityHidden()]} |
| 96 | <AppText className="text-base text-primary">Snooze</AppText> | ||
| 97 | </Pressable> | ||
| 98 | </View> | ||
| 99 | <SegmentedControl | ||
| 100 | options={modes} | ||
| 101 | selected={mode} | ||
| 102 | onSelect={(value) => { | ||
| 103 | setMode(value); | ||
| 104 | setError(null); | ||
| 105 | }} | ||
| 106 | role="tab" | ||
| 107 | /> | 98 | /> |
| 108 | <Host | 99 | </Popover.Trigger> |
| 109 | matchContents={{ vertical: true }} | 100 | <Popover.Content> |
| 110 | style={{ width: "100%" }} | 101 | <VStack |
| 111 | colorScheme={themeAppearance} | 102 | spacing={16} |
| 112 | seedColor={colors["--color-primary"]} | 103 | modifiers={[padding({ all: 16 }), frame({ width: Math.min(360, width - 32) })]} |
| 113 | > | 104 | > |
| 114 | <HStack | 105 | <HStack spacing={12}> |
| 115 | spacing={6} | 106 | <Button |
| 116 | modifiers={[ | 107 | label="Cancel" |
| 117 | frame({ minHeight: 44 }), | 108 | systemImage="xmark" |
| 118 | foregroundStyle(colors["--color-foreground"]), | 109 | role="cancel" |
| 119 | font({ size: appearance.baseFontSize }), | 110 | onPress={props.onClose} |
| 120 | ]} | 111 | modifiers={[ |
| 121 | > | 112 | labelStyle("iconOnly"), |
| 122 | <Text>{mode === "date" ? "Until" : "Snooze for"}</Text> | 113 | buttonStyle(NATIVE_LIQUID_GLASS_SUPPORTED ? "glass" : "bordered"), |
| 123 | <Spacer /> | 114 | buttonBorderShape("circle"), |
| 124 | {mode === "date" ? ( | 115 | controlSize("large"), |
| 125 | <> | 116 | ]} |
| 126 | <SnoozeDateField date={date} component="date" onChange={updateDate} /> | 117 | /> |
| 127 | <SnoozeDateField date={date} component="hourAndMinute" onChange={updateDate} /> | 118 | <Text |
| 128 | </> | 119 | modifiers={[ |
| 129 | ) : ( | 120 | font({ textStyle: "headline" }), |
| 130 | <> | 121 | frame({ maxWidth: Infinity, alignment: "leading" }), |
| 131 | <Popover isPresented={amountOpen} onIsPresentedChange={setAmountOpen}> | 122 | accessibilityAddTraits(["isHeader"]), |
| 132 | <Popover.Trigger> | 123 | ]} |
| 133 | <Button | 124 | > |
| 134 | onPress={() => setAmountOpen(true)} | 125 | Custom snooze |
| 135 | modifiers={[buttonStyle("plain")]} | 126 | </Text> |
| 136 | > | 127 | <Button |
| 137 | <FieldLabel label={String(amount)} /> | 128 | label="Snooze" |
| 138 | </Button> | 129 | onPress={submit} |
| 139 | </Popover.Trigger> | 130 | modifiers={[ |
| 140 | <Popover.Content> | 131 | buttonStyle( |
| 141 | <VStack | 132 | NATIVE_LIQUID_GLASS_SUPPORTED ? "glassProminent" : "borderedProminent", |
| 142 | modifiers={[ | 133 | ), |
| 143 | padding({ all: 12 }), | 134 | controlSize("large"), |
| 144 | frame({ width: 180 }), | 135 | ]} |
| 145 | presentationBackground(colors["--color-sheet-solid"]), | 136 | /> |
| 146 | ]} | ||
| 147 | > | ||
| 148 | <Picker | ||
| 149 | label="Duration" | ||
| 150 | selection={amount} | ||
| 151 | onSelectionChange={(value: number) => { | ||
| 152 | setAmount(value); | ||
| 153 | setError(null); | ||
| 154 | }} | ||
| 155 | modifiers={[pickerStyle("wheel"), frame({ height: 160 })]} | ||
| 156 | > | ||
| 157 | {durationAmounts.map((value) => ( | ||
| 158 | <Text | ||
| 159 | key={value} | ||
| 160 | modifiers={[ | ||
| 161 | tag(value), | ||
| 162 | foregroundStyle(colors["--color-foreground"]), | ||
| 163 | ]} | ||
| 164 | > | ||
| 165 | {String(value)} | ||
| 166 | </Text> | ||
| 167 | ))} | ||
| 168 | </Picker> | ||
| 169 | <Button | ||
| 170 | label="Done" | ||
| 171 | onPress={() => setAmountOpen(false)} | ||
| 172 | modifiers={[foregroundStyle(colors["--color-primary"])]} | ||
| 173 | /> | ||
| 174 | </VStack> | ||
| 175 | </Popover.Content> | ||
| 176 | </Popover> | ||
| 177 | <Menu | ||
| 178 | label={ | ||
| 179 | <FieldLabel | ||
| 180 | label={unit === "minutes" ? "Minutes" : unit === "hours" ? "Hours" : "Days"} | ||
| 181 | /> | ||
| 182 | } | ||
| 183 | > | ||
| 184 | {units.map((option) => ( | ||
| 185 | <Button | ||
| 186 | key={option.value} | ||
| 187 | label={option.label} | ||
| 188 | onPress={() => { | ||
| 189 | setUnit(option.value); | ||
| 190 | setError(null); | ||
| 191 | }} | ||
| 192 | /> | ||
| 193 | ))} | ||
| 194 | </Menu> | ||
| 195 | </> | ||
| 196 | )} | ||
| 197 | </HStack> | 137 | </HStack> |
| 198 | </Host> | 138 | <Picker |
| 199 | {error && <AppText className="text-sm text-danger-foreground">{error}</AppText>} | 139 | label="Snooze mode" |
| 200 | </ScrollView> | 140 | selection={mode} |
| 201 | </View> | 141 | onSelectionChange={(value: CustomSnoozeInput["mode"]) => { |
| 202 | </Modal> | 142 | setMode(value); |
| 203 | ); | 143 | setError(null); |
| 204 | } | 144 | }} |
| 205 | 145 | modifiers={[pickerStyle("segmented")]} | |
| 206 | function FieldLabel(props: { readonly label: string }) { | 146 | > |
| 207 | const { themeVariables: colors } = useAppearancePreferences(); | 147 | {modes.map((option) => ( |
| 208 | return ( | 148 | <Text key={option.value} modifiers={[tag(option.value)]}> |
| 209 | <Text | 149 | {option.label} |
| 210 | modifiers={[ | 150 | </Text> |
| 211 | padding({ horizontal: 12, vertical: 8 }), | 151 | ))} |
| 212 | foregroundStyle(colors["--color-foreground"]), | 152 | </Picker> |
| 213 | background(colors["--color-card"], shapes.capsule()), | 153 | {mode === "date" ? ( |
| 214 | ]} | 154 | <DatePicker |
| 215 | > | 155 | title="Snooze until" |
| 216 | {props.label} | 156 | selection={date} |
| 217 | </Text> | 157 | displayedComponents={["date", "hourAndMinute"]} |
| 218 | ); | 158 | onDateChange={updateDate} |
| 219 | } | 159 | modifiers={[ |
| 220 | 160 | datePickerStyle("wheel"), | |
| 221 | function SnoozeDateField(props: { | 161 | labelsHidden(), |
| 222 | readonly date: Date; | 162 | frame({ maxWidth: Infinity, height: 180 }), |
| 223 | readonly component: "date" | "hourAndMinute"; | 163 | ]} |
| 224 | readonly onChange: (date: Date) => void; | 164 | /> |
| 225 | }) { | 165 | ) : ( |
| 226 | const [open, setOpen] = useState(false); | 166 | <HStack spacing={0}> |
| 227 | const { themeVariables: colors } = useAppearancePreferences(); | 167 | <Picker |
| 228 | return ( | 168 | label="Duration amount" |
| 229 | <Popover isPresented={open} onIsPresentedChange={setOpen}> | 169 | selection={amount} |
| 230 | <Popover.Trigger> | 170 | onSelectionChange={(value: number) => { |
| 231 | <Button onPress={() => setOpen(true)} modifiers={[buttonStyle("plain")]}> | 171 | setAmount(value); |
| 232 | <FieldLabel | 172 | setError(null); |
| 233 | label={ | 173 | }} |
| 234 | props.component === "date" | 174 | modifiers={[ |
| 235 | ? props.date.toLocaleDateString(undefined, { | 175 | pickerStyle("wheel"), |
| 236 | month: "short", | 176 | labelsHidden(), |
| 237 | day: "numeric", | 177 | frame({ minWidth: 0, maxWidth: Infinity, height: 180 }), |
| 238 | year: "numeric", | 178 | clipped(), |
| 239 | }) | 179 | ]} |
| 240 | : props.date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }) | 180 | > |
| 241 | } | 181 | {durationAmounts.map((value) => ( |
| 242 | /> | 182 | <Text |
| 243 | </Button> | 183 | key={value} |
| 244 | </Popover.Trigger> | 184 | modifiers={[tag(value), foregroundStyle(colors["--color-foreground"])]} |
| 245 | <Popover.Content> | 185 | > |
| 246 | <VStack | 186 | {String(value)} |
| 247 | modifiers={[ | 187 | </Text> |
| 248 | padding({ all: 12 }), | 188 | ))} |
| 249 | frame({ width: props.component === "date" ? 340 : 300 }), | 189 | </Picker> |
| 250 | presentationBackground(colors["--color-sheet-solid"]), | 190 | <Picker |
| 251 | ]} | 191 | label="Duration unit" |
| 252 | > | 192 | selection={unit} |
| 253 | <DatePicker | 193 | onSelectionChange={(value: typeof unit) => { |
| 254 | selection={props.date} | 194 | setUnit(value); |
| 255 | displayedComponents={[props.component]} | 195 | setError(null); |
| 256 | onDateChange={props.onChange} | 196 | }} |
| 257 | modifiers={[datePickerStyle(props.component === "date" ? "graphical" : "wheel")]} | 197 | modifiers={[ |
| 258 | /> | 198 | pickerStyle("wheel"), |
| 259 | <Button | 199 | labelsHidden(), |
| 260 | label="Done" | 200 | frame({ minWidth: 0, maxWidth: Infinity, height: 180 }), |
| 261 | onPress={() => setOpen(false)} | 201 | clipped(), |
| 262 | modifiers={[foregroundStyle(colors["--color-primary"])]} | 202 | ]} |
| 263 | /> | 203 | > |
| 264 | </VStack> | 204 | {units.map((option) => ( |
| 265 | </Popover.Content> | 205 | <Text |
| 266 | </Popover> | 206 | key={option.value} |
| 207 | modifiers={[tag(option.value), foregroundStyle(colors["--color-foreground"])]} | ||
| 208 | > | ||
| 209 | {option.label} | ||
| 210 | </Text> | ||
| 211 | ))} | ||
| 212 | </Picker> | ||
| 213 | </HStack> | ||
| 214 | )} | ||
| 215 | {error ? ( | ||
| 216 | <Text | ||
| 217 | modifiers={[ | ||
| 218 | font({ textStyle: "footnote" }), | ||
| 219 | foregroundStyle(colors["--color-danger-foreground"]), | ||
| 220 | ]} | ||
| 221 | > | ||
| 222 | {error} | ||
| 223 | </Text> | ||
| 224 | ) : null} | ||
| 225 | </VStack> | ||
| 226 | </Popover.Content> | ||
| 227 | </Popover> | ||
| 228 | </Host> | ||
| 267 | ); | 229 | ); |
| 268 | } | 230 | } |
apps/mobile/src/features/threads/CustomSnoozeSheet.shared.tsx created+181| ... | @@ -0,0 +1,181 @@ | ||
| 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,182 +1 @@ |
| 1 | import { DateTimePicker } from "@expo/ui/community/datetime-picker"; | 1 | export { CustomSnoozeSheet } from "./CustomSnoozeSheet.shared"; |
| 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 | } |
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+122-51| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { MaterialListRow } from "../../components/MaterialListRow"; | ||
| 1 | import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; | 2 | import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; |
| 2 | import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; | 3 | import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; |
| 3 | import { LegendList } from "@legendapp/list/react-native"; | 4 | import { LegendList } from "@legendapp/list/react-native"; |
| ... | @@ -20,6 +21,7 @@ import { | ... | @@ -20,6 +21,7 @@ import { |
| 20 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 21 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 21 | 22 | ||
| 22 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 23 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 24 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; | ||
| 23 | import { SymbolView } from "../../components/AppSymbol"; | 25 | import { SymbolView } from "../../components/AppSymbol"; |
| 24 | import { AppText as Text } from "../../components/AppText"; | 26 | import { AppText as Text } from "../../components/AppText"; |
| 25 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; | 27 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; |
| ... | @@ -46,6 +48,34 @@ function SelectionRow(props: { | ... | @@ -46,6 +48,34 @@ function SelectionRow(props: { |
| 46 | readonly subtitle?: string; | 48 | readonly subtitle?: string; |
| 47 | readonly title: string; | 49 | readonly title: string; |
| 48 | }) { | 50 | }) { |
| 51 | if (Platform.OS === "android") { | ||
| 52 | return ( | ||
| 53 | <MaterialListRow | ||
| 54 | title={props.title} | ||
| 55 | subtitle={props.subtitle} | ||
| 56 | leading={ | ||
| 57 | props.icon === "arrow.triangle.branch" ? ( | ||
| 58 | <SymbolView | ||
| 59 | name="arrow.triangle.branch" | ||
| 60 | size={24} | ||
| 61 | tintColorClassName="accent-icon-muted" | ||
| 62 | /> | ||
| 63 | ) : ( | ||
| 64 | props.icon | ||
| 65 | ) | ||
| 66 | } | ||
| 67 | trailing={ | ||
| 68 | props.selected ? ( | ||
| 69 | <SymbolView name="checkmark" size={20} tintColorClassName="accent-primary" /> | ||
| 70 | ) : null | ||
| 71 | } | ||
| 72 | accessibilityRole="radio" | ||
| 73 | accessibilityState={{ checked: props.selected }} | ||
| 74 | disabled={props.disabled} | ||
| 75 | onPress={props.onPress} | ||
| 76 | /> | ||
| 77 | ); | ||
| 78 | } | ||
| 49 | return ( | 79 | return ( |
| 50 | <Pressable | 80 | <Pressable |
| 51 | accessibilityLabel={[props.title, props.subtitle].filter(Boolean).join(", ")} | 81 | accessibilityLabel={[props.title, props.subtitle].filter(Boolean).join(", ")} |
| ... | @@ -63,7 +93,7 @@ function SelectionRow(props: { | ... | @@ -63,7 +93,7 @@ function SelectionRow(props: { |
| 63 | <SymbolView | 93 | <SymbolView |
| 64 | name="arrow.triangle.branch" | 94 | name="arrow.triangle.branch" |
| 65 | size={17} | 95 | size={17} |
| 66 | tintColorClassName={"accent-icon-muted"} | 96 | tintColorClassName="accent-icon-muted" |
| 67 | type="monochrome" | 97 | type="monochrome" |
| 68 | /> | 98 | /> |
| 69 | ) : ( | 99 | ) : ( |
| ... | @@ -83,7 +113,7 @@ function SelectionRow(props: { | ... | @@ -83,7 +113,7 @@ function SelectionRow(props: { |
| 83 | <SymbolView | 113 | <SymbolView |
| 84 | name="checkmark" | 114 | name="checkmark" |
| 85 | size={16} | 115 | size={16} |
| 86 | tintColorClassName={"accent-icon"} | 116 | tintColorClassName="accent-icon" |
| 87 | type="monochrome" | 117 | type="monochrome" |
| 88 | weight="semibold" | 118 | weight="semibold" |
| 89 | /> | 119 | /> |
| ... | @@ -99,7 +129,13 @@ function ToggleRow(props: { | ... | @@ -99,7 +129,13 @@ function ToggleRow(props: { |
| 99 | }) { | 129 | }) { |
| 100 | return ( | 130 | return ( |
| 101 | <View className="min-h-14 flex-row items-center gap-3 bg-card px-4 py-3"> | 131 | <View className="min-h-14 flex-row items-center gap-3 bg-card px-4 py-3"> |
| 102 | <Text className="min-w-0 flex-1 text-base font-t3-medium text-foreground" numberOfLines={1}> | 132 | <Text |
| 133 | className={cn( | ||
| 134 | "min-w-0 flex-1 text-base text-foreground", | ||
| 135 | Platform.OS !== "android" && "font-t3-medium", | ||
| 136 | )} | ||
| 137 | numberOfLines={1} | ||
| 138 | > | ||
| 103 | {props.title} | 139 | {props.title} |
| 104 | </Text> | 140 | </Text> |
| 105 | <ThemedSwitch | 141 | <ThemedSwitch |
| ... | @@ -125,8 +161,14 @@ function BranchSelectionRow(props: { | ... | @@ -125,8 +161,14 @@ function BranchSelectionRow(props: { |
| 125 | return ( | 161 | return ( |
| 126 | <View | 162 | <View |
| 127 | className={cn( | 163 | className={cn( |
| 128 | props.isFirst && "overflow-hidden rounded-t-2xl", | 164 | props.isFirst && |
| 129 | props.isLast && "overflow-hidden rounded-b-2xl", | 165 | (Platform.OS === "android" |
| 166 | ? "overflow-hidden rounded-t-[28px]" | ||
| 167 | : "overflow-hidden rounded-t-2xl"), | ||
| 168 | props.isLast && | ||
| 169 | (Platform.OS === "android" | ||
| 170 | ? "overflow-hidden rounded-b-[28px]" | ||
| 171 | : "overflow-hidden rounded-b-2xl"), | ||
| 130 | )} | 172 | )} |
| 131 | > | 173 | > |
| 132 | <SelectionRow | 174 | <SelectionRow |
| ... | @@ -143,7 +185,17 @@ function BranchSelectionRow(props: { | ... | @@ -143,7 +185,17 @@ function BranchSelectionRow(props: { |
| 143 | } | 185 | } |
| 144 | 186 | ||
| 145 | function PickerSurface(props: { readonly children: ReactNode }) { | 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 | export function NewTaskEnvironmentPickerRouteScreen() { | 201 | export function NewTaskEnvironmentPickerRouteScreen() { |
| ... | @@ -161,42 +213,48 @@ export function NewTaskEnvironmentPickerRouteScreen() { | ... | @@ -161,42 +213,48 @@ export function NewTaskEnvironmentPickerRouteScreen() { |
| 161 | }} | 213 | }} |
| 162 | /> | 214 | /> |
| 163 | {Platform.OS === "android" ? ( | 215 | {Platform.OS === "android" ? ( |
| 164 | <AndroidScreenHeader title="Environment" onBack={() => navigation.goBack()} /> | 216 | <AndroidScreenHeader |
| 217 | title="Environment" | ||
| 218 | hideBottomBorder | ||
| 219 | onBack={() => navigation.goBack()} | ||
| 220 | /> | ||
| 165 | ) : null} | 221 | ) : null} |
| 166 | <ScrollView | 222 | <MaterialScreenContent> |
| 167 | contentInsetAdjustmentBehavior="automatic" | 223 | <ScrollView |
| 168 | contentContainerStyle={{ | 224 | contentInsetAdjustmentBehavior="automatic" |
| 169 | paddingBottom: Math.max(insets.bottom, 16) + 16, | 225 | contentContainerStyle={{ |
| 170 | paddingHorizontal: 16, | 226 | paddingBottom: Math.max(insets.bottom, 16) + 16, |
| 171 | paddingTop: 16, | 227 | paddingHorizontal: 16, |
| 172 | }} | 228 | paddingTop: 16, |
| 173 | showsVerticalScrollIndicator={false} | 229 | }} |
| 174 | > | 230 | showsVerticalScrollIndicator={false} |
| 175 | <PickerSurface> | 231 | > |
| 176 | {flow.environments.map((environment, index) => ( | 232 | <PickerSurface> |
| 177 | <SelectionRow | 233 | {flow.environments.map((environment, index) => ( |
| 178 | key={String(environment.environmentId)} | 234 | <SelectionRow |
| 179 | icon={ | 235 | key={String(environment.environmentId)} |
| 180 | <EnvironmentMachineSymbol | 236 | icon={ |
| 181 | kind={resolveEnvironmentMachineKind( | 237 | <EnvironmentMachineSymbol |
| 182 | serverConfigs.get(environment.environmentId) ?? null, | 238 | kind={resolveEnvironmentMachineKind( |
| 183 | )} | 239 | serverConfigs.get(environment.environmentId) ?? null, |
| 184 | size={17} | 240 | )} |
| 185 | tintColorClassName="accent-icon-muted" | 241 | size={Platform.OS === "android" ? 24 : 17} |
| 186 | /> | 242 | tintColorClassName="accent-icon-muted" |
| 187 | } | 243 | /> |
| 188 | isLast={index === flow.environments.length - 1} | 244 | } |
| 189 | onPress={() => { | 245 | isLast={index === flow.environments.length - 1} |
| 190 | void Haptics.selectionAsync(); | 246 | onPress={() => { |
| 191 | flow.selectEnvironment(environment.environmentId); | 247 | void Haptics.selectionAsync(); |
| 192 | navigation.goBack(); | 248 | flow.selectEnvironment(environment.environmentId); |
| 193 | }} | 249 | navigation.goBack(); |
| 194 | selected={flow.selectedEnvironmentId === environment.environmentId} | 250 | }} |
| 195 | title={environment.environmentLabel} | 251 | selected={flow.selectedEnvironmentId === environment.environmentId} |
| 196 | /> | 252 | title={environment.environmentLabel} |
| 197 | ))} | 253 | /> |
| 198 | </PickerSurface> | 254 | ))} |
| 199 | </ScrollView> | 255 | </PickerSurface> |
| 256 | </ScrollView> | ||
| 257 | </MaterialScreenContent> | ||
| 200 | </View> | 258 | </View> |
| 201 | ); | 259 | ); |
| 202 | } | 260 | } |
| ... | @@ -225,7 +283,7 @@ export function NewTaskBranchPickerRouteScreen() { | ... | @@ -225,7 +283,7 @@ export function NewTaskBranchPickerRouteScreen() { |
| 225 | ? 16 | 283 | ? 16 |
| 226 | : Math.max(insets.bottom, 16) + 16, | 284 | : Math.max(insets.bottom, 16) + 16, |
| 227 | paddingHorizontal: 16, | 285 | paddingHorizontal: 16, |
| 228 | paddingTop: 12, | 286 | paddingTop: 16, |
| 229 | }), | 287 | }), |
| 230 | [insets.bottom, usesNativeMailSearchToolbar], | 288 | [insets.bottom, usesNativeMailSearchToolbar], |
| 231 | ); | 289 | ); |
| ... | @@ -327,7 +385,12 @@ export function NewTaskBranchPickerRouteScreen() { | ... | @@ -327,7 +385,12 @@ export function NewTaskBranchPickerRouteScreen() { |
| 327 | 385 | ||
| 328 | const branchListHeader = | 386 | const branchListHeader = |
| 329 | flow.workspaceMode === "worktree" ? ( | 387 | flow.workspaceMode === "worktree" ? ( |
| 330 | <View className="mb-3 overflow-hidden rounded-2xl"> | 388 | <View |
| 389 | className={cn( | ||
| 390 | "mb-3 overflow-hidden", | ||
| 391 | Platform.OS === "android" ? "rounded-[28px]" : "rounded-2xl", | ||
| 392 | )} | ||
| 393 | > | ||
| 331 | <ToggleRow | 394 | <ToggleRow |
| 332 | onValueChange={flow.setStartFromOrigin} | 395 | onValueChange={flow.setStartFromOrigin} |
| 333 | title="Start from origin" | 396 | title="Start from origin" |
| ... | @@ -339,9 +402,9 @@ export function NewTaskBranchPickerRouteScreen() { | ... | @@ -339,9 +402,9 @@ export function NewTaskBranchPickerRouteScreen() { |
| 339 | const branchContent = | 402 | const branchContent = |
| 340 | flow.filteredBranches.length === 0 ? ( | 403 | flow.filteredBranches.length === 0 ? ( |
| 341 | <ScrollView | 404 | <ScrollView |
| 342 | className="flex-1 bg-sheet" | 405 | className={Platform.OS === "android" ? "flex-1 bg-sheet-solid" : "flex-1 bg-sheet"} |
| 343 | contentInsetAdjustmentBehavior="automatic" | 406 | contentInsetAdjustmentBehavior="automatic" |
| 344 | contentContainerStyle={{ flexGrow: 1, paddingHorizontal: 16, paddingTop: 12 }} | 407 | contentContainerStyle={{ flexGrow: 1, paddingHorizontal: 16, paddingTop: 16 }} |
| 345 | scrollEnabled={false} | 408 | scrollEnabled={false} |
| 346 | showsVerticalScrollIndicator={false} | 409 | showsVerticalScrollIndicator={false} |
| 347 | > | 410 | > |
| ... | @@ -380,7 +443,7 @@ export function NewTaskBranchPickerRouteScreen() { | ... | @@ -380,7 +443,7 @@ export function NewTaskBranchPickerRouteScreen() { |
| 380 | alwaysBounceVertical={false} | 443 | alwaysBounceVertical={false} |
| 381 | automaticallyAdjustsScrollIndicatorInsets | 444 | automaticallyAdjustsScrollIndicatorInsets |
| 382 | automaticallyAdjustKeyboardInsets={Platform.OS === "ios"} | 445 | automaticallyAdjustKeyboardInsets={Platform.OS === "ios"} |
| 383 | className="flex-1 bg-sheet" | 446 | className={Platform.OS === "android" ? "flex-1 bg-sheet-solid" : "flex-1 bg-sheet"} |
| 384 | contentInsetAdjustmentBehavior="automatic" | 447 | contentInsetAdjustmentBehavior="automatic" |
| 385 | contentContainerStyle={branchListContentStyle} | 448 | contentContainerStyle={branchListContentStyle} |
| 386 | data={flow.filteredBranches} | 449 | data={flow.filteredBranches} |
| ... | @@ -408,19 +471,27 @@ export function NewTaskBranchPickerRouteScreen() { | ... | @@ -408,19 +471,27 @@ export function NewTaskBranchPickerRouteScreen() { |
| 408 | return ( | 471 | return ( |
| 409 | <View className="flex-1 bg-sheet" collapsable={false}> | 472 | <View className="flex-1 bg-sheet" collapsable={false}> |
| 410 | <NativeStackScreenOptions options={{ headerShown: false }} /> | 473 | <NativeStackScreenOptions options={{ headerShown: false }} /> |
| 411 | <AndroidScreenHeader title={screenTitle} onBack={() => navigation.goBack()} /> | 474 | <AndroidScreenHeader |
| 412 | <View className="px-4 pb-2 pt-3"> | 475 | title={screenTitle} |
| 476 | hideBottomBorder | ||
| 477 | onBack={() => navigation.goBack()} | ||
| 478 | /> | ||
| 479 | <View className="bg-header px-4 pb-3 pt-1"> | ||
| 413 | <TextInput | 480 | <TextInput |
| 414 | autoCapitalize="none" | 481 | autoCapitalize="none" |
| 415 | autoCorrect={false} | 482 | autoCorrect={false} |
| 416 | className="h-11 rounded-xl bg-card px-4 font-sans text-base text-foreground" | 483 | accessibilityLabel="Find a branch" |
| 484 | className="h-12 rounded-full border border-input-border bg-input px-4 font-sans text-base text-foreground" | ||
| 485 | selectionColorClassName="accent-primary/32" | ||
| 486 | cursorColorClassName="accent-primary" | ||
| 487 | selectionHandleColorClassName="accent-primary" | ||
| 417 | onChangeText={flow.setBranchQuery} | 488 | onChangeText={flow.setBranchQuery} |
| 418 | placeholder="Find a branch" | 489 | placeholder="Find a branch" |
| 419 | placeholderTextColorClassName={"accent-placeholder"} | 490 | placeholderTextColorClassName="accent-placeholder" |
| 420 | value={flow.branchQuery} | 491 | value={flow.branchQuery} |
| 421 | /> | 492 | /> |
| 422 | </View> | 493 | </View> |
| 423 | {branchContent} | 494 | <MaterialScreenContent>{branchContent}</MaterialScreenContent> |
| 424 | </View> | 495 | </View> |
| 425 | ); | 496 | ); |
| 426 | } | 497 | } |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+25-16| ... | @@ -27,12 +27,10 @@ import Animated from "react-native-reanimated"; | ... | @@ -27,12 +27,10 @@ import Animated from "react-native-reanimated"; |
| 27 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 27 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 28 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 28 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 29 | import { useFontFamily } from "../../lib/useFontFamily"; | 29 | import { useFontFamily } from "../../lib/useFontFamily"; |
| 30 | |||
| 31 | import { | 30 | import { |
| 32 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 31 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 33 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | 32 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 34 | resolveEnvironmentMachineKind, | 33 | resolveEnvironmentMachineKind, |
| 35 | type EnvironmentId, | ||
| 36 | } from "@t3tools/contracts"; | 34 | } from "@t3tools/contracts"; |
| 37 | 35 | ||
| 38 | import { | 36 | import { |
| ... | @@ -51,6 +49,7 @@ import { | ... | @@ -51,6 +49,7 @@ import { |
| 51 | ComposerToolbarRow, | 49 | ComposerToolbarRow, |
| 52 | } from "../../components/ComposerToolbar"; | 50 | } from "../../components/ComposerToolbar"; |
| 53 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 51 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 52 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; | ||
| 54 | import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; | 53 | import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; |
| 55 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; | 54 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 56 | import { composerStripAttachments } from "../../lib/composerImages"; | 55 | import { composerStripAttachments } from "../../lib/composerImages"; |
| ... | @@ -81,7 +80,6 @@ import { | ... | @@ -81,7 +80,6 @@ import { |
| 81 | useThreadSettingsSheetPresentation, | 80 | useThreadSettingsSheetPresentation, |
| 82 | type NavigationWithFinishTransitioning, | 81 | type NavigationWithFinishTransitioning, |
| 83 | } from "./use-thread-settings-sheet-presentation"; | 82 | } from "./use-thread-settings-sheet-presentation"; |
| 84 | |||
| 85 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; | 83 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; |
| 86 | import { | 84 | import { |
| 87 | convertPastedImagesToAttachments, | 85 | convertPastedImagesToAttachments, |
| ... | @@ -140,7 +138,7 @@ function NewTaskWorkspaceIcon(props: { | ... | @@ -140,7 +138,7 @@ function NewTaskWorkspaceIcon(props: { |
| 140 | <SymbolView | 138 | <SymbolView |
| 141 | name="folder" | 139 | name="folder" |
| 142 | size={16} | 140 | size={16} |
| 143 | tintColorClassName={"accent-icon-muted"} | 141 | tintColorClassName="accent-icon-muted" |
| 144 | type="monochrome" | 142 | type="monochrome" |
| 145 | /> | 143 | /> |
| 146 | ); | 144 | ); |
| ... | @@ -151,14 +149,14 @@ function NewTaskWorkspaceIcon(props: { | ... | @@ -151,14 +149,14 @@ function NewTaskWorkspaceIcon(props: { |
| 151 | <SymbolView | 149 | <SymbolView |
| 152 | name="folder" | 150 | name="folder" |
| 153 | size={16} | 151 | size={16} |
| 154 | tintColorClassName={"accent-icon-muted"} | 152 | tintColorClassName="accent-icon-muted" |
| 155 | type="monochrome" | 153 | type="monochrome" |
| 156 | /> | 154 | /> |
| 157 | <View className="absolute -right-1 -bottom-1"> | 155 | <View className="absolute -right-1 -bottom-1"> |
| 158 | <SymbolView | 156 | <SymbolView |
| 159 | name="arrow.triangle.branch" | 157 | name="arrow.triangle.branch" |
| 160 | size={9} | 158 | size={9} |
| 161 | tintColorClassName={"accent-icon-muted"} | 159 | tintColorClassName="accent-icon-muted" |
| 162 | type="monochrome" | 160 | type="monochrome" |
| 163 | /> | 161 | /> |
| 164 | </View> | 162 | </View> |
| ... | @@ -1322,7 +1320,11 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1322,7 +1320,11 @@ export function NewTaskDraftScreen(props: { |
| 1322 | {Platform.OS === "android" ? ( | 1320 | {Platform.OS === "android" ? ( |
| 1323 | <> | 1321 | <> |
| 1324 | <NativeStackScreenOptions options={{ headerShown: false }} /> | 1322 | <NativeStackScreenOptions options={{ headerShown: false }} /> |
| 1325 | <AndroidScreenHeader title="New Thread" onBack={() => navigation.goBack()} /> | 1323 | <AndroidScreenHeader |
| 1324 | title="New thread" | ||
| 1325 | hideBottomBorder | ||
| 1326 | onBack={() => navigation.goBack()} | ||
| 1327 | /> | ||
| 1326 | </> | 1328 | </> |
| 1327 | ) : ( | 1329 | ) : ( |
| 1328 | <NativeStackScreenOptions options={{ title: "Loading task" }} /> | 1330 | <NativeStackScreenOptions options={{ title: "Loading task" }} /> |
| ... | @@ -1540,7 +1542,12 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1540,7 +1542,12 @@ export function NewTaskDraftScreen(props: { |
| 1540 | ); | 1542 | ); |
| 1541 | 1543 | ||
| 1542 | const composerDock = ( | 1544 | const composerDock = ( |
| 1543 | <View className="bg-sheet px-[12px] pt-1" style={{ paddingBottom: controlsBottomPadding }}> | 1545 | <View |
| 1546 | className={ | ||
| 1547 | Platform.OS === "android" ? "bg-sheet-solid px-[12px] pt-1" : "bg-sheet px-[12px] pt-1" | ||
| 1548 | } | ||
| 1549 | style={{ paddingBottom: controlsBottomPadding }} | ||
| 1550 | > | ||
| 1544 | {!voiceInput.isBusy && | 1551 | {!voiceInput.isBusy && |
| 1545 | composerMenu.trigger && | 1552 | composerMenu.trigger && |
| 1546 | (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request") ? ( | 1553 | (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request") ? ( |
| ... | @@ -1754,15 +1761,17 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1754,15 +1761,17 @@ export function NewTaskDraftScreen(props: { |
| 1754 | return ( | 1761 | return ( |
| 1755 | <View className="flex-1 bg-sheet" collapsable={false}> | 1762 | <View className="flex-1 bg-sheet" collapsable={false}> |
| 1756 | <NativeStackScreenOptions options={{ headerShown: false }} /> | 1763 | <NativeStackScreenOptions options={{ headerShown: false }} /> |
| 1757 | <AndroidScreenHeader title="New task" onBack={closeNewTask} /> | 1764 | <AndroidScreenHeader title="New thread" hideBottomBorder onBack={closeNewTask} /> |
| 1758 | {heroViewport} | 1765 | <MaterialScreenContent> |
| 1766 | {heroViewport} | ||
| 1759 | 1767 | ||
| 1760 | <KeyboardStickyView | 1768 | <KeyboardStickyView |
| 1761 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} | 1769 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} |
| 1762 | offset={{ closed: 0, opened: keyboardOpenedOffset }} | 1770 | offset={{ closed: 0, opened: keyboardOpenedOffset }} |
| 1763 | > | 1771 | > |
| 1764 | {composerDock} | 1772 | {composerDock} |
| 1765 | </KeyboardStickyView> | 1773 | </KeyboardStickyView> |
| 1774 | </MaterialScreenContent> | ||
| 1766 | </View> | 1775 | </View> |
| 1767 | ); | 1776 | ); |
| 1768 | } | 1777 | } |
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+154-91| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { MaterialListRow } from "../../components/MaterialListRow"; | ||
| 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 2 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | import { | 3 | import { |
| 3 | StackActions, | 4 | StackActions, |
| ... | @@ -8,11 +9,13 @@ import { | ... | @@ -8,11 +9,13 @@ import { |
| 8 | import { SymbolView } from "../../components/AppSymbol"; | 9 | import { SymbolView } from "../../components/AppSymbol"; |
| 9 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | 10 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 10 | import { useEffect, useRef } from "react"; | 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 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 13 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 13 | import { cn } from "../../lib/cn"; | 14 | import { cn } from "../../lib/cn"; |
| 14 | |||
| 15 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 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 | import { AppText as Text } from "../../components/AppText"; | 19 | import { AppText as Text } from "../../components/AppText"; |
| 17 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | 20 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 18 | import { useProjects } from "../../state/entities"; | 21 | import { useProjects } from "../../state/entities"; |
| ... | @@ -183,8 +186,9 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara | ... | @@ -183,8 +186,9 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 183 | <NativeStackScreenOptions options={{ headerShown: false }} /> | 186 | <NativeStackScreenOptions options={{ headerShown: false }} /> |
| 184 | <AndroidScreenHeader | 187 | <AndroidScreenHeader |
| 185 | title={screenTitle} | 188 | title={screenTitle} |
| 189 | hideBottomBorder | ||
| 186 | subtitle={incomingShareSubtitle} | 190 | subtitle={incomingShareSubtitle} |
| 187 | onBack={layout.usesSplitView ? () => navigation.goBack() : undefined} | 191 | onBack={() => navigation.goBack()} |
| 188 | actions={ | 192 | actions={ |
| 189 | catalogState.hasReadyEnvironment | 193 | catalogState.hasReadyEnvironment |
| 190 | ? [ | 194 | ? [ |
| ... | @@ -226,97 +230,156 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara | ... | @@ -226,97 +230,156 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 226 | </> | 230 | </> |
| 227 | )} | 231 | )} |
| 228 | 232 | ||
| 229 | <ScrollView | 233 | <MaterialScreenContent> |
| 230 | contentInsetAdjustmentBehavior="automatic" | 234 | <ScrollView |
| 231 | showsVerticalScrollIndicator={false} | 235 | contentInsetAdjustmentBehavior="automatic" |
| 232 | className="flex-1" | 236 | showsVerticalScrollIndicator={false} |
| 233 | contentContainerStyle={{ | 237 | className="flex-1" |
| 234 | gap: 12, | 238 | contentContainerStyle={{ |
| 235 | paddingBottom: Math.max(insets.bottom, 18) + 18, | 239 | gap: Platform.OS === "android" ? 8 : 12, |
| 236 | paddingHorizontal: 20, | 240 | paddingBottom: Math.max(insets.bottom, 18) + 18, |
| 237 | paddingTop: 8, | 241 | paddingHorizontal: Platform.OS === "android" ? 16 : 20, |
| 238 | }} | 242 | paddingTop: Platform.OS === "android" ? 16 : 8, |
| 239 | > | 243 | ...(Platform.OS === "android" && projectScopes.length === 0 |
| 240 | {projectScopes.length === 0 ? ( | 244 | ? { flexGrow: 1, justifyContent: "center" as const } |
| 241 | <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8"> | 245 | : {}), |
| 242 | {projectEmptyState.loading ? ( | 246 | }} |
| 243 | <ActivityIndicator colorClassName={"accent-icon-muted"} /> | 247 | > |
| 244 | ) : null} | 248 | {projectScopes.length === 0 ? ( |
| 245 | <Text className="text-center text-lg font-t3-bold text-foreground"> | 249 | <View |
| 246 | {projectEmptyState.title} | 250 | collapsable={false} |
| 247 | </Text> | 251 | className={cn( |
| 248 | <Text className="text-center text-sm leading-normal text-foreground-muted"> | 252 | "items-center gap-3 px-6 py-8", |
| 249 | {projectEmptyState.detail} | 253 | Platform.OS !== "android" && "rounded-[24px] bg-card", |
| 250 | </Text> | 254 | )} |
| 251 | {!catalogState.hasReadyEnvironment ? ( | 255 | > |
| 252 | <Pressable | 256 | {projectEmptyState.loading ? ( |
| 253 | className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" | 257 | <ActivityIndicator colorClassName="accent-icon-muted" /> |
| 254 | onPress={() => navigation.navigate("ConnectionsNew")} | 258 | ) : null} |
| 255 | > | 259 | <Text className="text-center text-lg font-t3-bold text-foreground"> |
| 256 | <Text className="text-sm font-t3-bold text-primary-foreground"> | 260 | {projectEmptyState.title} |
| 257 | Add environment | 261 | </Text> |
| 258 | </Text> | 262 | <Text className="text-center text-sm leading-normal text-foreground-muted"> |
| 259 | </Pressable> | 263 | {projectEmptyState.detail} |
| 260 | ) : ( | 264 | </Text> |
| 261 | <Pressable | 265 | {Platform.OS === "android" ? ( |
| 262 | className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" | 266 | <MaterialButton |
| 263 | onPress={() => navigation.dispatch(StackActions.push("AddProject"))} | 267 | label={catalogState.hasReadyEnvironment ? "Add new project" : "Add environment"} |
| 264 | > | 268 | tone="primary" |
| 265 | <Text className="text-sm font-t3-bold text-primary-foreground"> | 269 | onPress={() => |
| 266 | Add new project | 270 | catalogState.hasReadyEnvironment |
| 267 | </Text> | 271 | ? navigation.dispatch(StackActions.push("AddProject")) |
| 268 | </Pressable> | 272 | : navigation.navigate("ConnectionsNew") |
| 269 | )} | 273 | } |
| 270 | </View> | 274 | /> |
| 271 | ) : ( | 275 | ) : !catalogState.hasReadyEnvironment ? ( |
| 272 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | 276 | <Pressable |
| 273 | {projectScopes.map((scope, scopeIndex) => { | 277 | className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" |
| 274 | const hasMultipleProjects = scope.projects.length > 1; | 278 | onPress={() => navigation.navigate("ConnectionsNew")} |
| 275 | const selectionTarget = getProjectScopeSelectionTarget(scope, selectedEnvironmentId); | ||
| 276 | return ( | ||
| 277 | <View | ||
| 278 | key={scope.key} | ||
| 279 | className={cn(scopeIndex > 0 && "border-t border-border-subtle")} | ||
| 280 | > | 279 | > |
| 281 | <Pressable | 280 | <Text className="text-sm font-t3-bold text-primary-foreground"> |
| 282 | disabled={reservedDestinationProject !== null} | 281 | Add environment |
| 283 | onPress={() => void selectProject(selectionTarget)} | 282 | </Text> |
| 284 | className="flex-row items-center gap-3 bg-card px-4 py-3.5" | 283 | </Pressable> |
| 285 | > | 284 | ) : ( |
| 286 | <View className="h-7 w-7 items-center justify-center"> | 285 | <Pressable |
| 287 | <ProjectFavicon | 286 | className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" |
| 288 | environmentId={scope.representative.environmentId} | 287 | onPress={() => navigation.dispatch(StackActions.push("AddProject"))} |
| 289 | faviconPath={scope.representative.faviconPath} | 288 | > |
| 290 | size={20} | 289 | <Text className="text-sm font-t3-bold text-primary-foreground"> |
| 291 | projectTitle={scope.title} | 290 | Add new project |
| 292 | workspaceRoot={scope.representative.workspaceRoot} | 291 | </Text> |
| 293 | /> | 292 | </Pressable> |
| 294 | </View> | 293 | )} |
| 295 | <View className="min-w-0 flex-1"> | 294 | </View> |
| 296 | <Text className="text-base leading-snug font-t3-bold">{scope.title}</Text> | 295 | ) : ( |
| 297 | <Text | 296 | <View |
| 298 | className="text-xs leading-snug text-foreground-muted" | 297 | collapsable={false} |
| 299 | ellipsizeMode="middle" | 298 | className={ |
| 300 | numberOfLines={1} | 299 | Platform.OS === "android" |
| 301 | > | 300 | ? "overflow-hidden rounded-[28px] bg-card" |
| 302 | {hasMultipleProjects | 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 | ? `${scope.projects.length} workspaces` | 317 | ? `${scope.projects.length} workspaces` |
| 304 | : selectionTarget.workspaceRoot} | 318 | : selectionTarget.workspaceRoot |
| 305 | </Text> | 319 | } |
| 306 | </View> | 320 | disabled={reservedDestinationProject !== null} |
| 307 | <SymbolView | 321 | onPress={() => void selectProject(selectionTarget)} |
| 308 | name="chevron.right" | 322 | leading={ |
| 309 | size={14} | 323 | <ProjectFavicon |
| 310 | tintColorClassName={"accent-chevron"} | 324 | environmentId={scope.representative.environmentId} |
| 311 | type="monochrome" | 325 | faviconPath={scope.representative.faviconPath} |
| 326 | size={24} | ||
| 327 | projectTitle={scope.title} | ||
| 328 | workspaceRoot={scope.representative.workspaceRoot} | ||
| 329 | /> | ||
| 330 | } | ||
| 312 | /> | 331 | /> |
| 313 | </Pressable> | 332 | ); |
| 314 | </View> | 333 | } |
| 315 | ); | 334 | return ( |
| 316 | })} | 335 | <View |
| 317 | </View> | 336 | key={scope.key} |
| 318 | )} | 337 | className={cn(scopeIndex > 0 && "border-t border-border-subtle")} |
| 319 | </ScrollView> | 338 | > |
| 339 | <Pressable | ||
| 340 | accessibilityRole="button" | ||
| 341 | accessibilityLabel={scope.title} | ||
| 342 | disabled={reservedDestinationProject !== null} | ||
| 343 | onPress={() => void selectProject(selectionTarget)} | ||
| 344 | className="flex-row items-center gap-3 bg-card px-4 py-3.5" | ||
| 345 | > | ||
| 346 | <View className="h-7 w-7 items-center justify-center"> | ||
| 347 | <ProjectFavicon | ||
| 348 | environmentId={scope.representative.environmentId} | ||
| 349 | faviconPath={scope.representative.faviconPath} | ||
| 350 | size={20} | ||
| 351 | projectTitle={scope.title} | ||
| 352 | workspaceRoot={scope.representative.workspaceRoot} | ||
| 353 | /> | ||
| 354 | </View> | ||
| 355 | <View className="min-w-0 flex-1"> | ||
| 356 | <Text className={cn("text-base leading-snug", "font-t3-bold")}> | ||
| 357 | {scope.title} | ||
| 358 | </Text> | ||
| 359 | <Text | ||
| 360 | className="text-xs leading-snug text-foreground-muted" | ||
| 361 | ellipsizeMode="middle" | ||
| 362 | numberOfLines={1} | ||
| 363 | > | ||
| 364 | {hasMultipleProjects | ||
| 365 | ? `${scope.projects.length} workspaces` | ||
| 366 | : selectionTarget.workspaceRoot} | ||
| 367 | </Text> | ||
| 368 | </View> | ||
| 369 | <SymbolView | ||
| 370 | name="chevron.right" | ||
| 371 | size={14} | ||
| 372 | tintColorClassName="accent-chevron" | ||
| 373 | type="monochrome" | ||
| 374 | /> | ||
| 375 | </Pressable> | ||
| 376 | </View> | ||
| 377 | ); | ||
| 378 | })} | ||
| 379 | </View> | ||
| 380 | )} | ||
| 381 | </ScrollView> | ||
| 382 | </MaterialScreenContent> | ||
| 320 | </View> | 383 | </View> |
| 321 | ); | 384 | ); |
| 322 | } | 385 | } |
apps/mobile/src/features/threads/ThreadComposer.tsx+8-12| ... | @@ -49,6 +49,7 @@ import Animated, { | ... | @@ -49,6 +49,7 @@ import Animated, { |
| 49 | withTiming, | 49 | withTiming, |
| 50 | } from "react-native-reanimated"; | 50 | } from "react-native-reanimated"; |
| 51 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 51 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 52 | import { themeColorWithAlpha } from "../../lib/mobileTheme"; | ||
| 52 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; | 53 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; |
| 53 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 54 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 54 | import { | 55 | import { |
| ... | @@ -208,7 +209,6 @@ export function ComposerSurface(props: { | ... | @@ -208,7 +209,6 @@ export function ComposerSurface(props: { |
| 208 | /** Morphs between the compact and expanded composer layouts. */ | 209 | /** Morphs between the compact and expanded composer layouts. */ |
| 209 | readonly animateLayout?: boolean; | 210 | readonly animateLayout?: boolean; |
| 210 | }) { | 211 | }) { |
| 211 | const { materialYouStyleLayoutActive } = useAppearancePreferences(); | ||
| 212 | const colors = useUniwindTheme(); | 212 | const colors = useUniwindTheme(); |
| 213 | const targetBorderRadius = | 213 | const targetBorderRadius = |
| 214 | typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0; | 214 | typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0; |
| ... | @@ -232,27 +232,23 @@ export function ComposerSurface(props: { | ... | @@ -232,27 +232,23 @@ export function ComposerSurface(props: { |
| 232 | return ( | 232 | return ( |
| 233 | <Animated.View | 233 | <Animated.View |
| 234 | className={ | 234 | className={ |
| 235 | materialYouStyleLayoutActive | 235 | Platform.OS === "android" ? undefined : "shadow-[0_6px_28px] shadow-adaptive-black-a15-a35" |
| 236 | ? undefined | ||
| 237 | : "shadow-[0_6px_28px] shadow-adaptive-black-a15-a35" | ||
| 238 | } | 236 | } |
| 239 | layout={layoutTransition} | 237 | layout={layoutTransition} |
| 240 | style={[ | 238 | style={[ |
| 241 | animatedShapeStyle, | 239 | animatedShapeStyle, |
| 242 | { | 240 | { |
| 243 | overflow: "hidden", | 241 | overflow: "hidden", |
| 244 | // Android versions before 9 do not support outset box shadows. | ||
| 245 | elevation: Platform.OS === "android" && Platform.Version < 28 ? 10 : undefined, | ||
| 246 | }, | 242 | }, |
| 247 | ]} | 243 | ]} |
| 248 | > | 244 | > |
| 249 | <AnimatedGlassSurface | 245 | <AnimatedGlassSurface |
| 250 | chrome="none" | 246 | chrome="none" |
| 251 | fallbackColor={ | 247 | fallbackColor={ |
| 252 | materialYouStyleLayoutActive ? colors["--color-composer-surface"] : colors["--color-card"] | 248 | Platform.OS === "android" ? colors["--color-composer-surface"] : colors["--color-card"] |
| 253 | } | 249 | } |
| 254 | fallbackClassName={ | 250 | fallbackClassName={ |
| 255 | materialYouStyleLayoutActive ? "border border-composer-border" : "border border-border" | 251 | Platform.OS === "android" ? "border border-composer-border" : "border border-border" |
| 256 | } | 252 | } |
| 257 | glassEffectStyle="regular" | 253 | glassEffectStyle="regular" |
| 258 | // The composer is a passive material containing interactive controls. | 254 | // The composer is a passive material containing interactive controls. |
| ... | @@ -277,8 +273,7 @@ export function ComposerSurface(props: { | ... | @@ -277,8 +273,7 @@ export function ComposerSurface(props: { |
| 277 | 273 | ||
| 278 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { | 274 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { |
| 279 | const project = useProject(scopeProjectRef(props.environmentId, props.selectedThread.projectId)); | 275 | const project = useProject(scopeProjectRef(props.environmentId, props.selectedThread.projectId)); |
| 280 | const { materialYouStyleLayoutActive, themeVariables: materialTheme } = | 276 | const { themeVariables: materialTheme } = useAppearancePreferences(); |
| 281 | useAppearancePreferences(); | ||
| 282 | const composerPanel = materialTheme["--color-composer-panel"]; | 277 | const composerPanel = materialTheme["--color-composer-panel"]; |
| 283 | const navigation = useNavigation(); | 278 | const navigation = useNavigation(); |
| 284 | const foregroundColor = useUniwindTheme()["--color-foreground"]; | 279 | const foregroundColor = useUniwindTheme()["--color-foreground"]; |
| ... | @@ -627,7 +622,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -627,7 +622,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 627 | style={{ | 622 | style={{ |
| 628 | paddingTop: isExpanded ? 8 : 6, | 623 | paddingTop: isExpanded ? 8 : 6, |
| 629 | paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6), | 624 | paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6), |
| 630 | backgroundColor: materialYouStyleLayoutActive ? composerPanel : undefined, | 625 | backgroundColor: |
| 626 | Platform.OS === "android" ? themeColorWithAlpha(composerPanel, 1) : undefined, | ||
| 631 | }} | 627 | }} |
| 632 | > | 628 | > |
| 633 | {/* The backdrop gradient lives on a plain View: Reanimated's Animated.View | 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,7 +631,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 635 | strip fully transparent and the feed text legible through the composer. */} | 631 | strip fully transparent and the feed text legible through the composer. */} |
| 636 | <View | 632 | <View |
| 637 | className={ | 633 | className={ |
| 638 | materialYouStyleLayoutActive | 634 | Platform.OS === "android" |
| 639 | ? "hidden" | 635 | ? "hidden" |
| 640 | : "absolute inset-0 bg-linear-to-b from-screen/0 via-screen/60 to-screen/90" | 636 | : "absolute inset-0 bg-linear-to-b from-screen/0 via-screen/60 to-screen/90" |
| 641 | } | 637 | } |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+40-47| ... | @@ -27,8 +27,6 @@ import type { | ... | @@ -27,8 +27,6 @@ import type { |
| 27 | UserInputQuestion, | 27 | UserInputQuestion, |
| 28 | } from "@t3tools/contracts"; | 28 | } from "@t3tools/contracts"; |
| 29 | import * as Haptics from "expo-haptics"; | 29 | import * as Haptics from "expo-haptics"; |
| 30 | import { BlurTargetView } from "expo-blur"; | ||
| 31 | import { GlassBlurTargetContext } from "../../lib/glassBlurTarget"; | ||
| 32 | import { | 30 | import { |
| 33 | memo, | 31 | memo, |
| 34 | useCallback, | 32 | useCallback, |
| ... | @@ -65,7 +63,6 @@ import Animated, { | ... | @@ -65,7 +63,6 @@ import Animated, { |
| 65 | } from "react-native-reanimated"; | 63 | } from "react-native-reanimated"; |
| 66 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 64 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 67 | import { useWorkspaceContentWidth } from "../layout/workspace-content-width"; | 65 | import { useWorkspaceContentWidth } from "../layout/workspace-content-width"; |
| 68 | |||
| 69 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 66 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 70 | import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; | 67 | import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; |
| 71 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; | 68 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| ... | @@ -819,7 +816,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -819,7 +816,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 819 | }, [freeze, scrollMessageToEnd]); | 816 | }, [freeze, scrollMessageToEnd]); |
| 820 | 817 | ||
| 821 | const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled; | 818 | const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled; |
| 822 | const { themeAppearance, materialYouStyleLayoutActive } = useAppearancePreferences(); | 819 | const { themeAppearance } = useAppearancePreferences(); |
| 823 | const isDarkMode = themeAppearance === "dark"; | 820 | const isDarkMode = themeAppearance === "dark"; |
| 824 | 821 | ||
| 825 | const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { | 822 | const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { |
| ... | @@ -851,13 +848,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -851,13 +848,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 851 | const handleFeedTouchCancel = useCallback(() => { | 848 | const handleFeedTouchCancel = useCallback(() => { |
| 852 | feedTouchStartRef.current = null; | 849 | feedTouchStartRef.current = null; |
| 853 | }, []); | 850 | }, []); |
| 854 | const feedBlurTarget = useRef<View>(null); | ||
| 855 | 851 | ||
| 856 | return ( | 852 | return ( |
| 857 | <View className="flex-1"> | 853 | <View className="flex-1"> |
| 858 | {showContent ? ( | 854 | {showContent ? ( |
| 859 | <BlurTargetView | 855 | <View |
| 860 | ref={feedBlurTarget} | ||
| 861 | style={{ flex: 1 }} | 856 | style={{ flex: 1 }} |
| 862 | onTouchStart={handleFeedTouchStart} | 857 | onTouchStart={handleFeedTouchStart} |
| 863 | onTouchMove={handleFeedTouchMove} | 858 | onTouchMove={handleFeedTouchMove} |
| ... | @@ -867,7 +862,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -867,7 +862,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 867 | <View | 862 | <View |
| 868 | pointerEvents="none" | 863 | pointerEvents="none" |
| 869 | className={ | 864 | className={ |
| 870 | materialYouStyleLayoutActive | 865 | Platform.OS === "android" |
| 871 | ? "absolute inset-0 bg-thread-canvas" | 866 | ? "absolute inset-0 bg-thread-canvas" |
| 872 | : "absolute inset-0 bg-screen" | 867 | : "absolute inset-0 bg-screen" |
| 873 | } | 868 | } |
| ... | @@ -903,7 +898,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -903,7 +898,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 903 | onUseArtifactTemplate={handleUseArtifactTemplate} | 898 | onUseArtifactTemplate={handleUseArtifactTemplate} |
| 904 | loadEarlier={props.loadEarlier ?? null} | 899 | loadEarlier={props.loadEarlier ?? null} |
| 905 | /> | 900 | /> |
| 906 | </BlurTargetView> | 901 | </View> |
| 907 | ) : ( | 902 | ) : ( |
| 908 | <View className="flex-1" /> | 903 | <View className="flex-1" /> |
| 909 | )} | 904 | )} |
| ... | @@ -1025,44 +1020,42 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -1025,44 +1020,42 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 1025 | : undefined | 1020 | : undefined |
| 1026 | } | 1021 | } |
| 1027 | > | 1022 | > |
| 1028 | <GlassBlurTargetContext value={feedBlurTarget}> | 1023 | <ThreadComposer |
| 1029 | <ThreadComposer | 1024 | editorRef={composerEditorRef} |
| 1030 | editorRef={composerEditorRef} | 1025 | draftMessage={props.draftMessage} |
| 1031 | draftMessage={props.draftMessage} | 1026 | draftAttachments={props.draftAttachments} |
| 1032 | draftAttachments={props.draftAttachments} | 1027 | placeholder="Ask the repo agent, or run a command…" |
| 1033 | placeholder="Ask the repo agent, or run a command…" | 1028 | contentMaxWidth={contentMaxWidth} |
| 1034 | contentMaxWidth={contentMaxWidth} | 1029 | connectionState={props.connectionStateLabel} |
| 1035 | connectionState={props.connectionStateLabel} | 1030 | environmentLabel={props.environmentLabel} |
| 1036 | environmentLabel={props.environmentLabel} | 1031 | selectedThread={props.selectedThread} |
| 1037 | selectedThread={props.selectedThread} | 1032 | hasCompactableConversation={hasCompactableConversation && !props.isCompacting} |
| 1038 | hasCompactableConversation={hasCompactableConversation && !props.isCompacting} | 1033 | serverConfig={props.serverConfig} |
| 1039 | serverConfig={props.serverConfig} | 1034 | queueCount={props.selectedThreadQueueCount} |
| 1040 | queueCount={props.selectedThreadQueueCount} | 1035 | environmentId={props.environmentId} |
| 1041 | environmentId={props.environmentId} | 1036 | projectCwd={props.threadCwd ?? props.projectWorkspaceRoot} |
| 1042 | projectCwd={props.threadCwd ?? props.projectWorkspaceRoot} | 1037 | // Follow-ups typed during setup wait in the draft: queueing |
| 1043 | // Follow-ups typed during setup wait in the draft: queueing | 1038 | // them against a thread id the server may still reject |
| 1044 | // them against a thread id the server may still reject | 1039 | // would strand them in the outbox. |
| 1045 | // would strand them in the outbox. | 1040 | sendBlockedReason={ |
| 1046 | sendBlockedReason={ | 1041 | props.creationState?.kind === "preparing" ? "Starting the task…" : null |
| 1047 | props.creationState?.kind === "preparing" ? "Starting the task…" : null | 1042 | } |
| 1048 | } | 1043 | bottomInset={composerBottomInset} |
| 1049 | bottomInset={composerBottomInset} | 1044 | onChangeDraftMessage={props.onChangeDraftMessage} |
| 1050 | onChangeDraftMessage={props.onChangeDraftMessage} | 1045 | onPickDraftMedia={props.onPickDraftMedia} |
| 1051 | onPickDraftMedia={props.onPickDraftMedia} | 1046 | onPickDraftFiles={props.onPickDraftFiles} |
| 1052 | onPickDraftFiles={props.onPickDraftFiles} | 1047 | onNativePasteImages={props.onNativePasteImages} |
| 1053 | onNativePasteImages={props.onNativePasteImages} | 1048 | onNativePasteText={props.onNativePasteText} |
| 1054 | onNativePasteText={props.onNativePasteText} | 1049 | onRemoveDraftImage={props.onRemoveDraftImage} |
| 1055 | onRemoveDraftImage={props.onRemoveDraftImage} | 1050 | onStopThread={props.onStopThread} |
| 1056 | onStopThread={props.onStopThread} | 1051 | onSendMessage={handleSendMessage} |
| 1057 | onSendMessage={handleSendMessage} | 1052 | onShowUsageLimits={showUsageLimits} |
| 1058 | onShowUsageLimits={showUsageLimits} | 1053 | onUpdateModelSelection={props.onUpdateThreadModelSelection} |
| 1059 | onUpdateModelSelection={props.onUpdateThreadModelSelection} | 1054 | onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} |
| 1060 | onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} | 1055 | onUpdateInteractionMode={props.onUpdateThreadInteractionMode} |
| 1061 | onUpdateInteractionMode={props.onUpdateThreadInteractionMode} | 1056 | onExpandedChange={setComposerExpanded} |
| 1062 | onExpandedChange={setComposerExpanded} | 1057 | onEditorFocusChange={handleComposerFocusChange} |
| 1063 | onEditorFocusChange={handleComposerFocusChange} | 1058 | /> |
| 1064 | /> | ||
| 1065 | </GlassBlurTargetContext> | ||
| 1066 | </View> | 1059 | </View> |
| 1067 | </View> | 1060 | </View> |
| 1068 | </Animated.View> | 1061 | </Animated.View> |
apps/mobile/src/features/threads/ThreadFeed.tsx+1| ... | @@ -880,6 +880,7 @@ function MarkdownCodeBlock(props: { | ... | @@ -880,6 +880,7 @@ function MarkdownCodeBlock(props: { |
| 880 | > | 880 | > |
| 881 | <NativeText | 881 | <NativeText |
| 882 | selectable | 882 | selectable |
| 883 | selectionColorClassName={Platform.OS === "android" ? "accent-primary/32" : undefined} | ||
| 883 | className="font-mono" | 884 | className="font-mono" |
| 884 | style={{ | 885 | style={{ |
| 885 | color: props.textColor, | 886 | color: props.textColor, |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+146-126| ... | @@ -65,6 +65,9 @@ import { | ... | @@ -65,6 +65,9 @@ import { |
| 65 | WorkspaceConnectionTitle, | 65 | WorkspaceConnectionTitle, |
| 66 | } from "../home/WorkspaceConnectionTitle"; | 66 | } from "../home/WorkspaceConnectionTitle"; |
| 67 | import { SidebarHeaderActions } from "./sidebar-header-actions"; | 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 | import { SidebarFilterButton } from "./sidebar-filter-button"; | 71 | import { SidebarFilterButton } from "./sidebar-filter-button"; |
| 69 | import { createSidebarHeaderItems } from "./sidebar-native-header-items"; | 72 | import { createSidebarHeaderItems } from "./sidebar-native-header-items"; |
| 70 | import { SidebarNavigationShell } from "./sidebar-navigation-shell"; | 73 | import { SidebarNavigationShell } from "./sidebar-navigation-shell"; |
| ... | @@ -147,10 +150,8 @@ function NativeSidebarContainer(props: ThreadNavigationSidebarProps) { | ... | @@ -147,10 +150,8 @@ function NativeSidebarContainer(props: ThreadNavigationSidebarProps) { |
| 147 | function ThreadNavigationSidebarPane( | 150 | function ThreadNavigationSidebarPane( |
| 148 | props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean }, | 151 | props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean }, |
| 149 | ) { | 152 | ) { |
| 150 | const { materialYouStyleLayoutActive, themeVariables: materialTheme } = | 153 | const { themeVariables: materialTheme } = useAppearancePreferences(); |
| 151 | useAppearancePreferences(); | ||
| 152 | const screenColor = materialTheme["--color-screen"]; | 154 | const screenColor = materialTheme["--color-screen"]; |
| 153 | const mutedColor = materialTheme["--color-foreground-muted"]; | ||
| 154 | 155 | ||
| 155 | const insets = useSafeAreaInsets(); | 156 | const insets = useSafeAreaInsets(); |
| 156 | const projects = useProjects(); | 157 | const projects = useProjects(); |
| ... | @@ -747,13 +748,18 @@ function ThreadNavigationSidebarPane( | ... | @@ -747,13 +748,18 @@ function ThreadNavigationSidebarPane( |
| 747 | ); | 748 | ); |
| 748 | 749 | ||
| 749 | const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null); | 750 | const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null); |
| 751 | const materialToolbarHeight = useMaterialToolbarHeight(); | ||
| 750 | // The sticky header (title row, search field, optional connection status) | 752 | // The sticky header (title row, search field, optional connection status) |
| 751 | // is measured so the list inset always matches its real height — no | 753 | // is measured so the list inset always matches its real height — no |
| 752 | // hardcoded per-variant constants. | 754 | // hardcoded per-variant constants. |
| 753 | const stickyHeaderHeight = measuredHeaderHeight ?? insets.top + SIDEBAR_STICKY_HEADER_HEIGHT; | 755 | const stickyHeaderHeight = |
| 756 | measuredHeaderHeight ?? | ||
| 757 | (Platform.OS === "android" | ||
| 758 | ? Math.max(insets.top, 12) + materialToolbarHeight + 8 | ||
| 759 | : insets.top + SIDEBAR_STICKY_HEADER_HEIGHT); | ||
| 754 | const topListInset = stickyHeaderHeight + 6; | 760 | const topListInset = stickyHeaderHeight + 6; |
| 755 | const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => { | 761 | const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => { |
| 756 | const nextHeight = Math.round(event.nativeEvent.layout.height); | 762 | const nextHeight = event.nativeEvent.layout.height; |
| 757 | setMeasuredHeaderHeight((current) => (current === nextHeight ? current : nextHeight)); | 763 | setMeasuredHeaderHeight((current) => (current === nextHeight ? current : nextHeight)); |
| 758 | }, []); | 764 | }, []); |
| 759 | const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { | 765 | const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { |
| ... | @@ -777,7 +783,9 @@ function ThreadNavigationSidebarPane( | ... | @@ -777,7 +783,9 @@ function ThreadNavigationSidebarPane( |
| 777 | const handleScrollBeginDrag = useCallback(() => { | 783 | const handleScrollBeginDrag = useCallback(() => { |
| 778 | openSwipeableRef.current?.close(); | 784 | openSwipeableRef.current?.close(); |
| 779 | }, []); | 785 | }, []); |
| 786 | const onMaterialFabScroll = useMaterialFabScroll(); | ||
| 780 | const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ | 787 | const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ |
| 788 | onScroll: onMaterialFabScroll, | ||
| 781 | onScrollBeginDrag: handleScrollBeginDrag, | 789 | onScrollBeginDrag: handleScrollBeginDrag, |
| 782 | }); | 790 | }); |
| 783 | // Project shells load after the first rows draw, so the maps they feed have | 791 | // Project shells load after the first rows draw, so the maps they feed have |
| ... | @@ -850,6 +858,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -850,6 +858,7 @@ function ThreadNavigationSidebarPane( |
| 850 | [], | 858 | [], |
| 851 | ); | 859 | ); |
| 852 | const focusSearch = useCallback(() => { | 860 | const focusSearch = useCallback(() => { |
| 861 | if (Platform.OS === "android") return false; | ||
| 853 | const focus = () => { | 862 | const focus = () => { |
| 854 | if (props.nativeChrome) { | 863 | if (props.nativeChrome) { |
| 855 | searchBarRef.current?.focus(); | 864 | searchBarRef.current?.focus(); |
| ... | @@ -925,7 +934,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -925,7 +934,7 @@ function ThreadNavigationSidebarPane( |
| 925 | }), | 934 | }), |
| 926 | )} | 935 | )} |
| 927 | searchQuery={props.searchQuery} | 936 | searchQuery={props.searchQuery} |
| 928 | pane={materialYouStyleLayoutActive ? "screen" : "sidebar"} | 937 | pane={Platform.OS === "android" ? "screen" : "sidebar"} |
| 929 | selected={ | 938 | selected={ |
| 930 | scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey | 939 | scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey |
| 931 | } | 940 | } |
| ... | @@ -966,7 +975,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -966,7 +975,7 @@ function ThreadNavigationSidebarPane( |
| 966 | disabled={!shelfPreferencesLoaded} | 975 | disabled={!shelfPreferencesLoaded} |
| 967 | expanded={item.expanded} | 976 | expanded={item.expanded} |
| 968 | onToggle={toggleSnoozedShelf} | 977 | onToggle={toggleSnoozedShelf} |
| 969 | pane={materialYouStyleLayoutActive ? "screen" : "sidebar"} | 978 | pane={Platform.OS === "android" ? "screen" : "sidebar"} |
| 970 | /> | 979 | /> |
| 971 | ); | 980 | ); |
| 972 | case "v2-settled-shelf": | 981 | case "v2-settled-shelf": |
| ... | @@ -976,7 +985,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -976,7 +985,7 @@ function ThreadNavigationSidebarPane( |
| 976 | disabled={!shelfPreferencesLoaded} | 985 | disabled={!shelfPreferencesLoaded} |
| 977 | expanded={item.expanded} | 986 | expanded={item.expanded} |
| 978 | onToggle={toggleSettledShelf} | 987 | onToggle={toggleSettledShelf} |
| 979 | pane={materialYouStyleLayoutActive ? "screen" : "sidebar"} | 988 | pane={Platform.OS === "android" ? "screen" : "sidebar"} |
| 980 | /> | 989 | /> |
| 981 | ); | 990 | ); |
| 982 | case "v2-show-more": | 991 | case "v2-show-more": |
| ... | @@ -996,7 +1005,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -996,7 +1005,7 @@ function ThreadNavigationSidebarPane( |
| 996 | case "header": | 1005 | case "header": |
| 997 | return ( | 1006 | return ( |
| 998 | <ThreadListGroupHeader | 1007 | <ThreadListGroupHeader |
| 999 | variant={materialYouStyleLayoutActive ? "compact" : "sidebar"} | 1008 | variant={Platform.OS === "android" ? "compact" : "sidebar"} |
| 1000 | collapsed={item.collapsed} | 1009 | collapsed={item.collapsed} |
| 1001 | isFirst={item.isFirst} | 1010 | isFirst={item.isFirst} |
| 1002 | groupKey={item.group.key} | 1011 | groupKey={item.group.key} |
| ... | @@ -1014,7 +1023,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -1014,7 +1023,7 @@ function ThreadNavigationSidebarPane( |
| 1014 | case "pending-task": | 1023 | case "pending-task": |
| 1015 | return ( | 1024 | return ( |
| 1016 | <PendingTaskListRow | 1025 | <PendingTaskListRow |
| 1017 | variant={materialYouStyleLayoutActive ? "compact" : "sidebar"} | 1026 | variant={Platform.OS === "android" ? "compact" : "sidebar"} |
| 1018 | pendingTask={item.pendingTask} | 1027 | pendingTask={item.pendingTask} |
| 1019 | environmentLabel={ | 1028 | environmentLabel={ |
| 1020 | savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null | 1029 | savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null |
| ... | @@ -1064,7 +1073,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -1064,7 +1073,7 @@ function ThreadNavigationSidebarPane( |
| 1064 | case "show-more": | 1073 | case "show-more": |
| 1065 | return ( | 1074 | return ( |
| 1066 | <ThreadListShowMoreRow | 1075 | <ThreadListShowMoreRow |
| 1067 | variant={materialYouStyleLayoutActive ? "compact" : "sidebar"} | 1076 | variant={Platform.OS === "android" ? "compact" : "sidebar"} |
| 1068 | hiddenCount={item.hiddenCount} | 1077 | hiddenCount={item.hiddenCount} |
| 1069 | canShowLess={item.canShowLess} | 1078 | canShowLess={item.canShowLess} |
| 1070 | groupKey={item.groupKey} | 1079 | groupKey={item.groupKey} |
| ... | @@ -1074,7 +1083,6 @@ function ThreadNavigationSidebarPane( | ... | @@ -1074,7 +1083,6 @@ function ThreadNavigationSidebarPane( |
| 1074 | } | 1083 | } |
| 1075 | }, | 1084 | }, |
| 1076 | [ | 1085 | [ |
| 1077 | materialYouStyleLayoutActive, | ||
| 1078 | archiveThread, | 1086 | archiveThread, |
| 1079 | activeReorderEnvironmentIds, | 1087 | activeReorderEnvironmentIds, |
| 1080 | threadMovePlanners, | 1088 | threadMovePlanners, |
| ... | @@ -1166,16 +1174,24 @@ function ThreadNavigationSidebarPane( | ... | @@ -1166,16 +1174,24 @@ function ThreadNavigationSidebarPane( |
| 1166 | // Snoozed threads need no special case: the shelf header is a list row | 1174 | // Snoozed threads need no special case: the shelf header is a list row |
| 1167 | // even while collapsed. | 1175 | // even while collapsed. |
| 1168 | const listEmpty = ( | 1176 | const listEmpty = ( |
| 1169 | <Text className="px-2 py-4 text-sm text-foreground-muted"> | 1177 | <Text |
| 1178 | className={ | ||
| 1179 | Platform.OS === "android" | ||
| 1180 | ? "px-4 py-4 text-center text-sm text-foreground-muted" | ||
| 1181 | : "px-2 py-4 text-sm text-foreground-muted" | ||
| 1182 | } | ||
| 1183 | > | ||
| 1170 | {catalogState.isLoadingConnections | 1184 | {catalogState.isLoadingConnections |
| 1171 | ? "Loading threads…" | 1185 | ? "Loading threads…" |
| 1172 | : props.searchQuery.trim().length > 0 | 1186 | : Platform.OS === "android" && !catalogState.hasConnections |
| 1173 | ? threadSearch.isPending | 1187 | ? "No environments connected" |
| 1174 | ? "Searching thread messages…" | 1188 | : props.searchQuery.trim().length > 0 |
| 1175 | : "No matching threads" | 1189 | ? threadSearch.isPending |
| 1176 | : selectedProjectScope !== null | 1190 | ? "Searching thread messages…" |
| 1177 | ? `No threads in ${selectedProjectScope.title}` | 1191 | : "No matching threads" |
| 1178 | : "No threads yet"} | 1192 | : selectedProjectScope !== null |
| 1193 | ? `No threads in ${selectedProjectScope.title}` | ||
| 1194 | : "No threads yet"} | ||
| 1179 | </Text> | 1195 | </Text> |
| 1180 | ); | 1196 | ); |
| 1181 | 1197 | ||
| ... | @@ -1232,7 +1248,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -1232,7 +1248,7 @@ function ThreadNavigationSidebarPane( |
| 1232 | } | 1248 | } |
| 1233 | contentContainerStyle={[ | 1249 | contentContainerStyle={[ |
| 1234 | styles.threadListContent, | 1250 | styles.threadListContent, |
| 1235 | materialYouStyleLayoutActive ? { paddingHorizontal: 0 } : null, | 1251 | Platform.OS === "android" ? { paddingHorizontal: 0 } : null, |
| 1236 | { | 1252 | { |
| 1237 | paddingBottom: Math.max(insets.bottom, 16) + 16, | 1253 | paddingBottom: Math.max(insets.bottom, 16) + 16, |
| 1238 | paddingTop: 6, | 1254 | paddingTop: 6, |
| ... | @@ -1257,15 +1273,18 @@ function ThreadNavigationSidebarPane( | ... | @@ -1257,15 +1273,18 @@ function ThreadNavigationSidebarPane( |
| 1257 | return ( | 1273 | return ( |
| 1258 | <View | 1274 | <View |
| 1259 | testID="thread-navigation-sidebar" | 1275 | testID="thread-navigation-sidebar" |
| 1260 | className="flex-1 border-r border-border bg-drawer" | 1276 | className={ |
| 1277 | Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 border-r border-border bg-drawer" | ||
| 1278 | } | ||
| 1261 | style={{ width: props.width }} | 1279 | style={{ width: props.width }} |
| 1262 | > | 1280 | > |
| 1263 | <View | 1281 | <View |
| 1264 | className="flex-1" | 1282 | className="flex-1" |
| 1265 | style={ | 1283 | style={ |
| 1266 | materialYouStyleLayoutActive | 1284 | Platform.OS === "android" |
| 1267 | ? { | 1285 | ? { |
| 1268 | marginTop: stickyHeaderHeight, | 1286 | marginTop: stickyHeaderHeight, |
| 1287 | marginHorizontal: 4, | ||
| 1269 | paddingBottom: insets.bottom, | 1288 | paddingBottom: insets.bottom, |
| 1270 | backgroundColor: screenColor, | 1289 | backgroundColor: screenColor, |
| 1271 | borderTopLeftRadius: 28, | 1290 | borderTopLeftRadius: 28, |
| ... | @@ -1275,116 +1294,117 @@ function ThreadNavigationSidebarPane( | ... | @@ -1275,116 +1294,117 @@ function ThreadNavigationSidebarPane( |
| 1275 | : { paddingBottom: insets.bottom } | 1294 | : { paddingBottom: insets.bottom } |
| 1276 | } | 1295 | } |
| 1277 | > | 1296 | > |
| 1278 | <SwipeableScrollGateProvider enabled={swipeEnabled}> | 1297 | {Platform.OS === "android" && listItems.length === 0 ? ( |
| 1279 | <GestureDetector gesture={sidebarScrollGesture}> | 1298 | <View className="flex-1 items-center justify-center">{listEmpty}</View> |
| 1280 | <LegendList | 1299 | ) : ( |
| 1281 | data={listItems} | 1300 | <SwipeableScrollGateProvider enabled={swipeEnabled}> |
| 1282 | drawDistance={500} | 1301 | <GestureDetector gesture={sidebarScrollGesture}> |
| 1283 | estimatedItemSize={64} | 1302 | <LegendList |
| 1284 | extraData={listExtraData} | 1303 | data={listItems} |
| 1285 | getItemType={(item) => item.type} | 1304 | drawDistance={500} |
| 1286 | itemsAreEqual={sidebarItemsAreEqual} | 1305 | estimatedItemSize={64} |
| 1287 | keyExtractor={(item) => item.key} | 1306 | extraData={listExtraData} |
| 1288 | renderItem={renderListItem} | 1307 | getItemType={(item) => item.type} |
| 1289 | contentContainerStyle={[ | 1308 | itemsAreEqual={sidebarItemsAreEqual} |
| 1290 | styles.threadListContent, | 1309 | keyExtractor={(item) => item.key} |
| 1291 | materialYouStyleLayoutActive ? { paddingHorizontal: 0 } : null, | 1310 | renderItem={renderListItem} |
| 1292 | { | 1311 | contentContainerStyle={[ |
| 1293 | paddingBottom: | 1312 | styles.threadListContent, |
| 1294 | Platform.OS === "android" | 1313 | Platform.OS === "android" ? { paddingHorizontal: 0 } : null, |
| 1295 | ? Math.max(insets.bottom, 16) + 88 - insets.bottom | 1314 | { |
| 1296 | : 16 + insets.bottom, | 1315 | paddingBottom: |
| 1297 | paddingTop: materialYouStyleLayoutActive ? 6 : topListInset, | 1316 | Platform.OS === "android" |
| 1298 | }, | 1317 | ? Math.max(insets.bottom, 16) + 148 - insets.bottom |
| 1299 | ]} | 1318 | : 16 + insets.bottom, |
| 1300 | keyboardDismissMode="on-drag" | 1319 | paddingTop: Platform.OS === "android" ? 6 : topListInset, |
| 1301 | keyboardShouldPersistTaps="handled" | 1320 | }, |
| 1302 | {...scrollGateHandlers} | 1321 | ]} |
| 1303 | recycleItems | 1322 | keyboardDismissMode="on-drag" |
| 1304 | scrollEventThrottle={16} | 1323 | keyboardShouldPersistTaps="handled" |
| 1305 | showsVerticalScrollIndicator={false} | 1324 | {...scrollGateHandlers} |
| 1306 | style={styles.threadList} | 1325 | recycleItems |
| 1307 | ListEmptyComponent={listEmpty} | 1326 | scrollEventThrottle={16} |
| 1308 | /> | 1327 | showsVerticalScrollIndicator={false} |
| 1309 | </GestureDetector> | 1328 | style={styles.threadList} |
| 1310 | </SwipeableScrollGateProvider> | 1329 | ListEmptyComponent={listEmpty} |
| 1330 | /> | ||
| 1331 | </GestureDetector> | ||
| 1332 | </SwipeableScrollGateProvider> | ||
| 1333 | )} | ||
| 1311 | </View> | 1334 | </View> |
| 1312 | 1335 | ||
| 1313 | <View | 1336 | {Platform.OS === "android" ? ( |
| 1314 | className="absolute inset-x-0 top-0 z-[4] bg-drawer" | 1337 | <MaterialThreadListToolbar |
| 1315 | collapsable={false} | 1338 | sidebar |
| 1316 | onLayout={handleStickyHeaderLayout} | 1339 | onLayout={handleStickyHeaderLayout} |
| 1317 | pointerEvents="auto" | 1340 | searchQuery={props.searchQuery} |
| 1318 | style={{ paddingTop: insets.top }} | 1341 | onSearchQueryChange={props.onSearchQueryChange} |
| 1319 | > | 1342 | filterActions={listMenuActions} |
| 1320 | <View className="h-[50px] flex-row items-end gap-0.5 pr-2 pl-5"> | 1343 | filterCustomized={filterCustomized} |
| 1321 | {/* Title slot doubles as the connection status surface: while an | 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 | environment reconnects, the brand fades to a status label in | 1359 | environment reconnects, the brand fades to a status label in |
| 1323 | place (no layout shift in the list below). */} | 1360 | place (no layout shift in the list below). */} |
| 1324 | <WorkspaceConnectionTitle | 1361 | <WorkspaceConnectionTitle |
| 1325 | grow | 1362 | grow |
| 1326 | onPress={props.onOpenEnvironmentSettings} | 1363 | onPress={props.onOpenEnvironmentSettings} |
| 1327 | size="pageTitle" | 1364 | size="pageTitle" |
| 1328 | brand={ | 1365 | brand={ |
| 1329 | <View className="h-11 flex-1 justify-center"> | 1366 | <View className="h-11 flex-1 justify-center"> |
| 1330 | <CompactBrandTitle allowFontScaling={false} /> | 1367 | <CompactBrandTitle allowFontScaling={false} /> |
| 1331 | </View> | 1368 | </View> |
| 1332 | } | 1369 | } |
| 1333 | /> | 1370 | /> |
| 1334 | <View className="flex-row items-center gap-2.5"> | 1371 | <View className="flex-row items-center gap-2.5"> |
| 1335 | <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}> | 1372 | <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}> |
| 1336 | <SidebarFilterButton accessibilityLabel="Filter and sort threads" icon={filterIcon} /> | 1373 | <SidebarFilterButton |
| 1337 | </ControlPillMenu> | 1374 | accessibilityLabel="Filter and sort threads" |
| 1338 | <SidebarHeaderActions onOpenSettings={props.onOpenSettings} /> | 1375 | icon={filterIcon} |
| 1376 | /> | ||
| 1377 | </ControlPillMenu> | ||
| 1378 | <SidebarHeaderActions onOpenSettings={props.onOpenSettings} /> | ||
| 1379 | </View> | ||
| 1339 | </View> | 1380 | </View> |
| 1340 | </View> | ||
| 1341 | 1381 | ||
| 1342 | <View | 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]"> |
| 1343 | className={ | 1383 | <SymbolView |
| 1344 | materialYouStyleLayoutActive | 1384 | name="magnifyingglass" |
| 1345 | ? "mx-4 mt-[9px] min-h-12 flex-row items-center gap-2.5 rounded-full border border-input-border bg-input px-3.5" | 1385 | size={15} |
| 1346 | : "mx-4 mt-[9px] h-[38px] flex-row items-center gap-1.5 rounded-xl bg-sidebar-search pr-2.5 pl-[11px]" | 1386 | tintColorClassName="accent-foreground-muted" |
| 1347 | } | 1387 | type="monochrome" |
| 1348 | > | 1388 | /> |
| 1349 | <SymbolView | 1389 | <TextInput |
| 1350 | name="magnifyingglass" | 1390 | ref={searchInputRef} |
| 1351 | size={15} | 1391 | accessibilityLabel="Search threads" |
| 1352 | tintColorClassName={"accent-foreground-muted"} | 1392 | autoCapitalize="none" |
| 1353 | type="monochrome" | 1393 | autoCorrect={false} |
| 1354 | /> | 1394 | clearButtonMode="while-editing" |
| 1355 | <TextInput | 1395 | onChangeText={props.onSearchQueryChange} |
| 1356 | ref={searchInputRef} | 1396 | placeholder="Search" |
| 1357 | accessibilityLabel="Search threads" | 1397 | placeholderTextColorClassName="accent-placeholder" |
| 1358 | autoCapitalize="none" | 1398 | selectionColorClassName={undefined} |
| 1359 | autoCorrect={false} | 1399 | cursorColorClassName={undefined} |
| 1360 | clearButtonMode={materialYouStyleLayoutActive ? "never" : "while-editing"} | 1400 | selectionHandleColorClassName={undefined} |
| 1361 | onChangeText={props.onSearchQueryChange} | 1401 | returnKeyType="search" |
| 1362 | placeholder="Search" | 1402 | className="h-[34px] flex-1 px-0 py-0 font-sans text-base text-foreground" |
| 1363 | placeholderTextColorClassName={"accent-placeholder"} | 1403 | value={props.searchQuery} |
| 1364 | returnKeyType="search" | 1404 | /> |
| 1365 | className={ | 1405 | </View> |
| 1366 | materialYouStyleLayoutActive | ||
| 1367 | ? "flex-1 px-0 py-2.5 font-sans text-base text-foreground" | ||
| 1368 | : "h-[34px] flex-1 px-0 py-0 font-sans text-base text-foreground" | ||
| 1369 | } | ||
| 1370 | value={props.searchQuery} | ||
| 1371 | /> | ||
| 1372 | {materialYouStyleLayoutActive && props.searchQuery.length > 0 ? ( | ||
| 1373 | <Pressable | ||
| 1374 | accessibilityLabel="Clear search" | ||
| 1375 | hitSlop={10} | ||
| 1376 | onPress={() => props.onSearchQueryChange("")} | ||
| 1377 | > | ||
| 1378 | <SymbolView | ||
| 1379 | name="xmark.circle.fill" | ||
| 1380 | size={17} | ||
| 1381 | tintColor={mutedColor} | ||
| 1382 | type="monochrome" | ||
| 1383 | /> | ||
| 1384 | </Pressable> | ||
| 1385 | ) : null} | ||
| 1386 | </View> | 1406 | </View> |
| 1387 | </View> | 1407 | )} |
| 1388 | </View> | 1408 | </View> |
| 1389 | ); | 1409 | ); |
| 1390 | } | 1410 | } |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+29-21| ... | @@ -32,17 +32,17 @@ import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draf | ... | @@ -32,17 +32,17 @@ import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draf |
| 32 | import { useEnvironmentQuery } from "../../state/query"; | 32 | import { useEnvironmentQuery } from "../../state/query"; |
| 33 | import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; | 33 | import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; |
| 34 | import { vcsEnvironment } from "../../state/vcs"; | 34 | import { vcsEnvironment } from "../../state/vcs"; |
| 35 | |||
| 36 | import { EmptyState } from "../../components/EmptyState"; | 35 | import { EmptyState } from "../../components/EmptyState"; |
| 37 | import { | 36 | import { |
| 37 | AndroidHeaderIconButton, | ||
| 38 | AndroidScreenHeader, | 38 | AndroidScreenHeader, |
| 39 | type AndroidHeaderAction, | 39 | type AndroidHeaderAction, |
| 40 | } from "../../components/AndroidScreenHeader"; | 40 | } from "../../components/AndroidScreenHeader"; |
| 41 | import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar"; | ||
| 41 | import { LoadingScreen } from "../../components/LoadingScreen"; | 42 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 42 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 43 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 43 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | 44 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 44 | import { connectionTone } from "../connection/connectionTone"; | 45 | import { connectionTone } from "../connection/connectionTone"; |
| 45 | |||
| 46 | import { | 46 | import { |
| 47 | useRemoteConnections, | 47 | useRemoteConnections, |
| 48 | useRemoteConnectionStatus, | 48 | useRemoteConnectionStatus, |
| ... | @@ -225,7 +225,7 @@ function ThreadRouteContent( | ... | @@ -225,7 +225,7 @@ function ThreadRouteContent( |
| 225 | readonly selectedThreadDetailState: ReturnType<typeof useSelectedThreadDetailState>; | 225 | readonly selectedThreadDetailState: ReturnType<typeof useSelectedThreadDetailState>; |
| 226 | }, | 226 | }, |
| 227 | ) { | 227 | ) { |
| 228 | const { materialYouStyleLayoutActive, themeVariables } = useAppearancePreferences(); | 228 | const { themeVariables } = useAppearancePreferences(); |
| 229 | const headerColor = themeVariables["--color-header"]; | 229 | const headerColor = themeVariables["--color-header"]; |
| 230 | const { | 230 | const { |
| 231 | fileInspector, | 231 | fileInspector, |
| ... | @@ -746,10 +746,12 @@ function ThreadRouteContent( | ... | @@ -746,10 +746,12 @@ function ThreadRouteContent( |
| 746 | }); | 746 | }); |
| 747 | } | 747 | } |
| 748 | if (selectedThreadCwd !== null) { | 748 | if (selectedThreadCwd !== null) { |
| 749 | const filesVisible = inspectorMode === "files" && panes.auxiliaryPaneVisible; | ||
| 749 | actions.push({ | 750 | actions.push({ |
| 750 | accessibilityLabel: "Open files", | 751 | accessibilityLabel: filesVisible ? "Close files" : "Open files", |
| 752 | selected: filesVisible, | ||
| 751 | icon: "folder", | 753 | icon: "folder", |
| 752 | onPress: handleOpenFilesInspector, | 754 | onPress: filesVisible ? toggleAuxiliaryPane : handleOpenFilesInspector, |
| 753 | }); | 755 | }); |
| 754 | } | 756 | } |
| 755 | if (selectedThreadProject?.workspaceRoot) { | 757 | if (selectedThreadProject?.workspaceRoot) { |
| ... | @@ -764,20 +766,14 @@ function ThreadRouteContent( | ... | @@ -764,20 +766,14 @@ function ThreadRouteContent( |
| 764 | icon: "point.topleft.down.curvedto.point.bottomright.up", | 766 | icon: "point.topleft.down.curvedto.point.bottomright.up", |
| 765 | onPress: handleOpenGitInspector, | 767 | onPress: handleOpenGitInspector, |
| 766 | }); | 768 | }); |
| 767 | if (fileInspector.supported && selectedThreadCwd !== null) { | ||
| 768 | actions.push({ | ||
| 769 | accessibilityLabel: "Toggle inspector", | ||
| 770 | icon: "sidebar.right", | ||
| 771 | onPress: handleToggleInspector, | ||
| 772 | }); | ||
| 773 | } | ||
| 774 | return actions; | 769 | return actions; |
| 775 | }, [ | 770 | }, [ |
| 776 | fileInspector.supported, | 771 | inspectorMode, |
| 772 | panes.auxiliaryPaneVisible, | ||
| 777 | handleOpenFilesInspector, | 773 | handleOpenFilesInspector, |
| 778 | handleOpenTerminal, | 774 | handleOpenTerminal, |
| 779 | handleOpenGitInspector, | 775 | handleOpenGitInspector, |
| 780 | handleToggleInspector, | 776 | toggleAuxiliaryPane, |
| 781 | props.onReturnToThread, | 777 | props.onReturnToThread, |
| 782 | selectedThreadCwd, | 778 | selectedThreadCwd, |
| 783 | selectedThreadProject?.workspaceRoot, | 779 | selectedThreadProject?.workspaceRoot, |
| ... | @@ -891,13 +887,12 @@ function ThreadRouteContent( | ... | @@ -891,13 +887,12 @@ function ThreadRouteContent( |
| 891 | <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} /> | 887 | <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} /> |
| 892 | 888 | ||
| 893 | <View | 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 | style={ | 891 | style={ |
| 896 | materialYouStyleLayoutActive | 892 | Platform.OS === "android" |
| 897 | ? { | 893 | ? { |
| 898 | borderTopLeftRadius: 28, | 894 | borderTopLeftRadius: 28, |
| 899 | borderTopRightRadius: 28, | 895 | borderTopRightRadius: 28, |
| 900 | marginRight: layout.usesSplitView ? 8 : 0, | ||
| 901 | overflow: "hidden", | 896 | overflow: "hidden", |
| 902 | } | 897 | } |
| 903 | : undefined | 898 | : undefined |
| ... | @@ -996,9 +991,7 @@ function ThreadRouteContent( | ... | @@ -996,9 +991,7 @@ function ThreadRouteContent( |
| 996 | : undefined, | 991 | : undefined, |
| 997 | unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, | 992 | unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, |
| 998 | contentStyle: | 993 | contentStyle: |
| 999 | Platform.OS === "android" && materialYouStyleLayoutActive | 994 | Platform.OS === "android" && true ? { backgroundColor: headerColor } : undefined, |
| 1000 | ? { backgroundColor: headerColor } | ||
| 1001 | : undefined, | ||
| 1002 | }} | 995 | }} |
| 1003 | /> | 996 | /> |
| 1004 | 997 | ||
| ... | @@ -1006,6 +999,21 @@ function ThreadRouteContent( | ... | @@ -1006,6 +999,21 @@ function ThreadRouteContent( |
| 1006 | <AndroidScreenHeader | 999 | <AndroidScreenHeader |
| 1007 | title={selectedThread.title} | 1000 | title={selectedThread.title} |
| 1008 | subtitle={headerSubtitle} | 1001 | subtitle={headerSubtitle} |
| 1002 | leading={<AndroidWorkspaceSidebarButton />} | ||
| 1003 | trailing={ | ||
| 1004 | fileInspector.supported && selectedThreadCwd !== null ? ( | ||
| 1005 | <AndroidHeaderIconButton | ||
| 1006 | accessibilityLabel={ | ||
| 1007 | inspectorMode !== null && panes.auxiliaryPaneVisible | ||
| 1008 | ? "Hide inspector" | ||
| 1009 | : "Show inspector" | ||
| 1010 | } | ||
| 1011 | icon="sidebar.right" | ||
| 1012 | selected={inspectorMode !== null && panes.auxiliaryPaneVisible} | ||
| 1013 | onPress={handleToggleInspector} | ||
| 1014 | /> | ||
| 1015 | ) : null | ||
| 1016 | } | ||
| 1009 | onBack={ | 1017 | onBack={ |
| 1010 | layout.usesSplitView | 1018 | layout.usesSplitView |
| 1011 | ? undefined | 1019 | ? undefined |
| ... | @@ -1017,7 +1025,7 @@ function ThreadRouteContent( | ... | @@ -1017,7 +1025,7 @@ function ThreadRouteContent( |
| 1017 | } | 1025 | } |
| 1018 | } | 1026 | } |
| 1019 | actions={androidHeaderActions} | 1027 | actions={androidHeaderActions} |
| 1020 | hideBottomBorder={materialYouStyleLayoutActive} | 1028 | hideBottomBorder |
| 1021 | /> | 1029 | /> |
| 1022 | ) : null} | 1030 | ) : null} |
| 1023 | 1031 |
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+98-41| ... | @@ -36,6 +36,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; | ... | @@ -36,6 +36,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 36 | import { SymbolView } from "../../components/AppSymbol"; | 36 | import { SymbolView } from "../../components/AppSymbol"; |
| 37 | import { AppText as Text } from "../../components/AppText"; | 37 | import { AppText as Text } from "../../components/AppText"; |
| 38 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 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 | import { ProviderIcon } from "../../components/ProviderIcon"; | 42 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 40 | import { ThemedSwitch } from "../../components/ThemedSwitch"; | 43 | import { ThemedSwitch } from "../../components/ThemedSwitch"; |
| 41 | import { cn } from "../../lib/cn"; | 44 | import { cn } from "../../lib/cn"; |
| ... | @@ -52,7 +55,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | ... | @@ -52,7 +55,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 52 | import { serverEnvironment } from "../../state/server"; | 55 | import { serverEnvironment } from "../../state/server"; |
| 53 | import { useAtomCommand } from "../../state/use-atom-command"; | 56 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 54 | import { useNewTaskFlow } from "./new-task-flow-provider"; | 57 | import { useNewTaskFlow } from "./new-task-flow-provider"; |
| 55 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 58 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; |
| 56 | import { | 59 | import { |
| 57 | createProviderCatalogRefreshRunner, | 60 | createProviderCatalogRefreshRunner, |
| 58 | providerCatalogRefreshError, | 61 | providerCatalogRefreshError, |
| ... | @@ -105,8 +108,7 @@ function ModelRow(props: { | ... | @@ -105,8 +108,7 @@ function ModelRow(props: { |
| 105 | readonly isFirst: boolean; | 108 | readonly isFirst: boolean; |
| 106 | readonly isLast: boolean; | 109 | readonly isLast: boolean; |
| 107 | }) { | 110 | }) { |
| 108 | const { materialYouStyleLayoutActive } = useAppearancePreferences(); | 111 | const selectedMaterialRow = Platform.OS === "android" && props.selected; |
| 109 | const selectedMaterialRow = materialYouStyleLayoutActive && props.selected; | ||
| 110 | return ( | 112 | return ( |
| 111 | <Pressable | 113 | <Pressable |
| 112 | accessibilityLabel={[props.option.label, props.option.subtitle].filter(Boolean).join(", ")} | 114 | accessibilityLabel={[props.option.label, props.option.subtitle].filter(Boolean).join(", ")} |
| ... | @@ -117,6 +119,7 @@ function ModelRow(props: { | ... | @@ -117,6 +119,7 @@ function ModelRow(props: { |
| 117 | }} | 119 | }} |
| 118 | disabled={props.option.isUnavailable} | 120 | disabled={props.option.isUnavailable} |
| 119 | onPress={props.onPress} | 121 | onPress={props.onPress} |
| 122 | style={Platform.OS === "android" ? { minHeight: 56, paddingVertical: 12 } : undefined} | ||
| 120 | className={cn( | 123 | className={cn( |
| 121 | "mx-4 min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle", | 124 | "mx-4 min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle", |
| 122 | selectedMaterialRow && "bg-thread-selected", | 125 | selectedMaterialRow && "bg-thread-selected", |
| ... | @@ -124,11 +127,12 @@ function ModelRow(props: { | ... | @@ -124,11 +127,12 @@ function ModelRow(props: { |
| 124 | props.isLast ? "rounded-b-2xl" : "border-b border-border-subtle", | 127 | props.isLast ? "rounded-b-2xl" : "border-b border-border-subtle", |
| 125 | )} | 128 | )} |
| 126 | > | 129 | > |
| 130 | {Platform.OS === "android" ? <MaterialRadioIndicator selected={props.selected} /> : null} | ||
| 127 | <View className="min-w-0 flex-1"> | 131 | <View className="min-w-0 flex-1"> |
| 128 | <View className="flex-row items-center gap-2"> | 132 | <View className="flex-row items-center gap-2"> |
| 129 | <Text | 133 | <Text |
| 130 | className="min-w-0 shrink text-base font-t3-medium text-foreground" | 134 | className="min-w-0 shrink text-base font-t3-medium text-foreground" |
| 131 | numberOfLines={1} | 135 | numberOfLines={Platform.OS === "android" ? 2 : 1} |
| 132 | > | 136 | > |
| 133 | {props.option.label} | 137 | {props.option.label} |
| 134 | </Text> | 138 | </Text> |
| ... | @@ -147,16 +151,19 @@ function ModelRow(props: { | ... | @@ -147,16 +151,19 @@ function ModelRow(props: { |
| 147 | ) : null} | 151 | ) : null} |
| 148 | </View> | 152 | </View> |
| 149 | {props.option.subtitle ? ( | 153 | {props.option.subtitle ? ( |
| 150 | <Text className="text-xs text-foreground-muted" numberOfLines={1}> | 154 | <Text |
| 155 | className="text-xs text-foreground-muted" | ||
| 156 | numberOfLines={Platform.OS === "android" ? 2 : 1} | ||
| 157 | > | ||
| 151 | {props.option.subtitle} | 158 | {props.option.subtitle} |
| 152 | </Text> | 159 | </Text> |
| 153 | ) : null} | 160 | ) : null} |
| 154 | </View> | 161 | </View> |
| 155 | {props.selected ? ( | 162 | {props.selected && Platform.OS !== "android" ? ( |
| 156 | <SymbolView | 163 | <SymbolView |
| 157 | name="checkmark" | 164 | name="checkmark" |
| 158 | size={16} | 165 | size={16} |
| 159 | tintColorClassName={"accent-icon"} | 166 | tintColorClassName="accent-icon" |
| 160 | type="monochrome" | 167 | type="monochrome" |
| 161 | weight="semibold" | 168 | weight="semibold" |
| 162 | /> | 169 | /> |
| ... | @@ -189,7 +196,7 @@ function ProviderHeader(props: { | ... | @@ -189,7 +196,7 @@ function ProviderHeader(props: { |
| 189 | <SymbolView | 196 | <SymbolView |
| 190 | name={props.collapsed ? "chevron.down" : "chevron.up"} | 197 | name={props.collapsed ? "chevron.down" : "chevron.up"} |
| 191 | size={12} | 198 | size={12} |
| 192 | tintColorClassName={"accent-icon-subtle"} | 199 | tintColorClassName="accent-icon-subtle" |
| 193 | type="monochrome" | 200 | type="monochrome" |
| 194 | /> | 201 | /> |
| 195 | </> | 202 | </> |
| ... | @@ -205,6 +212,7 @@ function ProviderHeader(props: { | ... | @@ -205,6 +212,7 @@ function ProviderHeader(props: { |
| 205 | accessibilityState={{ expanded: !props.collapsed }} | 212 | accessibilityState={{ expanded: !props.collapsed }} |
| 206 | className="mx-4 mt-1 min-h-11 flex-row items-center gap-2 rounded-xl px-1 pt-2 active:opacity-60" | 213 | className="mx-4 mt-1 min-h-11 flex-row items-center gap-2 rounded-xl px-1 pt-2 active:opacity-60" |
| 207 | onPress={props.onToggle} | 214 | onPress={props.onToggle} |
| 215 | style={Platform.OS === "android" ? { minHeight: 48 } : undefined} | ||
| 208 | > | 216 | > |
| 209 | {content} | 217 | {content} |
| 210 | </Pressable> | 218 | </Pressable> |
| ... | @@ -229,6 +237,7 @@ function DisclosureRow(props: { | ... | @@ -229,6 +237,7 @@ function DisclosureRow(props: { |
| 229 | <Pressable | 237 | <Pressable |
| 230 | accessibilityRole="button" | 238 | accessibilityRole="button" |
| 231 | onPress={props.onPress} | 239 | onPress={props.onPress} |
| 240 | style={Platform.OS === "android" ? { minHeight: 56 } : undefined} | ||
| 232 | className={cn( | 241 | className={cn( |
| 233 | "min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle", | 242 | "min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle", |
| 234 | !props.isLast && "border-b border-border-subtle", | 243 | !props.isLast && "border-b border-border-subtle", |
| ... | @@ -244,7 +253,7 @@ function DisclosureRow(props: { | ... | @@ -244,7 +253,7 @@ function DisclosureRow(props: { |
| 244 | <SymbolView | 253 | <SymbolView |
| 245 | name="chevron.right" | 254 | name="chevron.right" |
| 246 | size={12} | 255 | size={12} |
| 247 | tintColorClassName={"accent-icon-subtle"} | 256 | tintColorClassName="accent-icon-subtle" |
| 248 | type="monochrome" | 257 | type="monochrome" |
| 249 | /> | 258 | /> |
| 250 | </Pressable> | 259 | </Pressable> |
| ... | @@ -265,22 +274,24 @@ function ChoiceRow(props: { | ... | @@ -265,22 +274,24 @@ function ChoiceRow(props: { |
| 265 | accessibilityRole="radio" | 274 | accessibilityRole="radio" |
| 266 | accessibilityState={{ checked: props.selected }} | 275 | accessibilityState={{ checked: props.selected }} |
| 267 | onPress={props.onPress} | 276 | onPress={props.onPress} |
| 277 | style={Platform.OS === "android" ? { minHeight: 56 } : undefined} | ||
| 268 | className={cn( | 278 | className={cn( |
| 269 | "min-h-14 flex-row items-center gap-3 bg-card px-4 py-3 active:bg-subtle", | 279 | "min-h-14 flex-row items-center gap-3 bg-card px-4 py-3 active:bg-subtle", |
| 270 | !props.isLast && "border-b border-border-subtle", | 280 | !props.isLast && "border-b border-border-subtle", |
| 271 | )} | 281 | )} |
| 272 | > | 282 | > |
| 283 | {Platform.OS === "android" ? <MaterialRadioIndicator selected={props.selected} /> : null} | ||
| 273 | <View className="min-w-0 flex-1 gap-0.5"> | 284 | <View className="min-w-0 flex-1 gap-0.5"> |
| 274 | <Text className="text-base font-t3-medium text-foreground">{props.label}</Text> | 285 | <Text className="text-base font-t3-medium text-foreground">{props.label}</Text> |
| 275 | {props.description ? ( | 286 | {props.description ? ( |
| 276 | <Text className="text-sm leading-5 text-foreground-muted">{props.description}</Text> | 287 | <Text className="text-sm leading-5 text-foreground-muted">{props.description}</Text> |
| 277 | ) : null} | 288 | ) : null} |
| 278 | </View> | 289 | </View> |
| 279 | {props.selected ? ( | 290 | {props.selected && Platform.OS !== "android" ? ( |
| 280 | <SymbolView | 291 | <SymbolView |
| 281 | name="checkmark" | 292 | name="checkmark" |
| 282 | size={16} | 293 | size={16} |
| 283 | tintColorClassName={"accent-icon"} | 294 | tintColorClassName="accent-icon" |
| 284 | type="monochrome" | 295 | type="monochrome" |
| 285 | weight="semibold" | 296 | weight="semibold" |
| 286 | /> | 297 | /> |
| ... | @@ -853,10 +864,15 @@ function ThreadSettingsMainContent(props: { | ... | @@ -853,10 +864,15 @@ function ThreadSettingsMainContent(props: { |
| 853 | <AnimatedLegendList | 864 | <AnimatedLegendList |
| 854 | automaticallyAdjustsScrollIndicatorInsets | 865 | automaticallyAdjustsScrollIndicatorInsets |
| 855 | className="flex-1 bg-sheet" | 866 | className="flex-1 bg-sheet" |
| 867 | style={ | ||
| 868 | Platform.OS === "android" | ||
| 869 | ? { width: "100%", maxWidth: 720, alignSelf: "center" } | ||
| 870 | : undefined | ||
| 871 | } | ||
| 856 | contentContainerStyle={{ paddingTop: 4 }} | 872 | contentContainerStyle={{ paddingTop: 4 }} |
| 857 | contentInsetAdjustmentBehavior={usesTransparentNativeHeader ? "never" : "automatic"} | 873 | contentInsetAdjustmentBehavior={usesTransparentNativeHeader ? "never" : "automatic"} |
| 858 | data={listItems} | 874 | data={listItems} |
| 859 | estimatedItemSize={48} | 875 | estimatedItemSize={Platform.OS === "android" ? 56 : 48} |
| 860 | extraData={animationsReady} | 876 | extraData={animationsReady} |
| 861 | getItemType={(item) => item.kind} | 877 | getItemType={(item) => item.kind} |
| 862 | itemLayoutAnimation={THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION} | 878 | itemLayoutAnimation={THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION} |
| ... | @@ -869,16 +885,39 @@ function ThreadSettingsMainContent(props: { | ... | @@ -869,16 +885,39 @@ function ThreadSettingsMainContent(props: { |
| 869 | {usesTransparentNativeHeader ? <View style={{ height: nativeHeaderHeight }} /> : null} | 885 | {usesTransparentNativeHeader ? <View style={{ height: nativeHeaderHeight }} /> : null} |
| 870 | {Platform.OS === "android" ? ( | 886 | {Platform.OS === "android" ? ( |
| 871 | <View className="px-4 pb-2 pt-3"> | 887 | <View className="px-4 pb-2 pt-3"> |
| 872 | <TextInput | 888 | <View |
| 873 | accessibilityLabel="Find a model" | 889 | className="flex-row items-center rounded-full bg-input px-2" |
| 874 | autoCapitalize="none" | 890 | style={{ minHeight: 56 }} |
| 875 | autoCorrect={false} | 891 | > |
| 876 | className="h-11 rounded-xl bg-card px-4 text-base text-foreground" | 892 | <View pointerEvents="none" className="px-2"> |
| 877 | onChangeText={session.setSearchQuery} | 893 | <SymbolView |
| 878 | placeholder="Find a model" | 894 | name="magnifyingglass" |
| 879 | placeholderTextColorClassName="accent-placeholder" | 895 | size={24} |
| 880 | value={session.searchQuery} | 896 | tintColorClassName="accent-icon-subtle" |
| 881 | /> | 897 | /> |
| 898 | </View> | ||
| 899 | <TextInput | ||
| 900 | accessibilityLabel="Find a model" | ||
| 901 | autoCapitalize="none" | ||
| 902 | autoCorrect={false} | ||
| 903 | className="min-w-0 flex-1 px-2 py-0 text-base text-foreground" | ||
| 904 | style={{ minHeight: 56, includeFontPadding: false, textAlignVertical: "center" }} | ||
| 905 | onChangeText={session.setSearchQuery} | ||
| 906 | placeholder="Find a model" | ||
| 907 | placeholderTextColorClassName="accent-placeholder" | ||
| 908 | selectionColorClassName="accent-primary/32" | ||
| 909 | cursorColorClassName="accent-primary" | ||
| 910 | selectionHandleColorClassName="accent-primary" | ||
| 911 | value={session.searchQuery} | ||
| 912 | /> | ||
| 913 | {session.searchQuery.length > 0 ? ( | ||
| 914 | <MaterialIconButton | ||
| 915 | accessibilityLabel="Clear model search" | ||
| 916 | icon="xmark" | ||
| 917 | onPress={() => session.setSearchQuery("")} | ||
| 918 | /> | ||
| 919 | ) : null} | ||
| 920 | </View> | ||
| 882 | </View> | 921 | </View> |
| 883 | ) : null} | 922 | ) : null} |
| 884 | </> | 923 | </> |
| ... | @@ -945,6 +984,11 @@ function ThreadSettingsChoiceContent(props: { | ... | @@ -945,6 +984,11 @@ function ThreadSettingsChoiceContent(props: { |
| 945 | return ( | 984 | return ( |
| 946 | <ScrollView | 985 | <ScrollView |
| 947 | className="flex-1 bg-sheet" | 986 | className="flex-1 bg-sheet" |
| 987 | style={ | ||
| 988 | Platform.OS === "android" | ||
| 989 | ? { width: "100%", maxWidth: 720, alignSelf: "center" } | ||
| 990 | : undefined | ||
| 991 | } | ||
| 948 | contentContainerStyle={{ | 992 | contentContainerStyle={{ |
| 949 | paddingBottom: insets.bottom + 12, | 993 | paddingBottom: insets.bottom + 12, |
| 950 | paddingHorizontal: 16, | 994 | paddingHorizontal: 16, |
| ... | @@ -1068,14 +1112,15 @@ function ThreadSettingsModelsScreen() { | ... | @@ -1068,14 +1112,15 @@ function ThreadSettingsModelsScreen() { |
| 1068 | icon: "arrow.clockwise", | 1112 | icon: "arrow.clockwise", |
| 1069 | onPress: refreshProviders, | 1113 | onPress: refreshProviders, |
| 1070 | }, | 1114 | }, |
| 1071 | { | ||
| 1072 | accessibilityLabel: session.pendingModel ? "Save thread settings" : "Done", | ||
| 1073 | icon: "checkmark", | ||
| 1074 | onPress: commitAndClose, | ||
| 1075 | }, | ||
| 1076 | ]} | 1115 | ]} |
| 1116 | trailing={ | ||
| 1117 | session.pendingModel ? ( | ||
| 1118 | <MaterialButton label="Save" tone="text" onPress={commitAndClose} /> | ||
| 1119 | ) : undefined | ||
| 1120 | } | ||
| 1077 | onBack={presentation.onClose} | 1121 | onBack={presentation.onClose} |
| 1078 | title="Thread settings" | 1122 | title="Thread settings" |
| 1123 | hideBottomBorder | ||
| 1079 | /> | 1124 | /> |
| 1080 | ) : null} | 1125 | ) : null} |
| 1081 | <NativeStackScreenOptions | 1126 | <NativeStackScreenOptions |
| ... | @@ -1114,17 +1159,19 @@ function ThreadSettingsModelsScreen() { | ... | @@ -1114,17 +1159,19 @@ function ThreadSettingsModelsScreen() { |
| 1114 | : undefined, | 1159 | : undefined, |
| 1115 | }} | 1160 | }} |
| 1116 | /> | 1161 | /> |
| 1117 | <ThreadSettingsMainContent | 1162 | <MaterialScreenContent> |
| 1118 | onOpenSubmenu={(submenu) => { | 1163 | <ThreadSettingsMainContent |
| 1119 | const title = | 1164 | onOpenSubmenu={(submenu) => { |
| 1120 | submenu.kind === "runtime" | 1165 | const title = |
| 1121 | ? "Runtime" | 1166 | submenu.kind === "runtime" |
| 1122 | : (session.displayedDescriptors.find( | 1167 | ? "Runtime" |
| 1123 | (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id, | 1168 | : (session.displayedDescriptors.find( |
| 1124 | )?.label ?? "Option"); | 1169 | (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id, |
| 1125 | navigation.navigate("ThreadSettingsChoice", { ...submenu, title }); | 1170 | )?.label ?? "Option"); |
| 1126 | }} | 1171 | navigation.navigate("ThreadSettingsChoice", { ...submenu, title }); |
| 1127 | /> | 1172 | }} |
| 1173 | /> | ||
| 1174 | </MaterialScreenContent> | ||
| 1128 | <NativeHeaderToolbar placement="left"> | 1175 | <NativeHeaderToolbar placement="left"> |
| 1129 | <NativeHeaderToolbar.Button | 1176 | <NativeHeaderToolbar.Button |
| 1130 | accessibilityLabel="Cancel thread settings" | 1177 | accessibilityLabel="Cancel thread settings" |
| ... | @@ -1199,9 +1246,18 @@ function ThreadSettingsChoiceScreen() { | ... | @@ -1199,9 +1246,18 @@ function ThreadSettingsChoiceScreen() { |
| 1199 | <> | 1246 | <> |
| 1200 | <NativeStackScreenOptions options={{ headerShown: Platform.OS !== "android" }} /> | 1247 | <NativeStackScreenOptions options={{ headerShown: Platform.OS !== "android" }} /> |
| 1201 | {Platform.OS === "android" ? ( | 1248 | {Platform.OS === "android" ? ( |
| 1202 | <AndroidScreenHeader title={route.params.title} onBack={() => navigation.goBack()} /> | 1249 | <AndroidScreenHeader |
| 1250 | title={route.params.title} | ||
| 1251 | onBack={() => navigation.goBack()} | ||
| 1252 | hideBottomBorder | ||
| 1253 | /> | ||
| 1203 | ) : null} | 1254 | ) : null} |
| 1204 | <ThreadSettingsChoiceContent submenu={route.params} onSelected={() => navigation.goBack()} /> | 1255 | <MaterialScreenContent> |
| 1256 | <ThreadSettingsChoiceContent | ||
| 1257 | submenu={route.params} | ||
| 1258 | onSelected={() => navigation.goBack()} | ||
| 1259 | /> | ||
| 1260 | </MaterialScreenContent> | ||
| 1205 | </> | 1261 | </> |
| 1206 | ); | 1262 | ); |
| 1207 | } | 1263 | } |
| ... | @@ -1222,11 +1278,12 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) | ... | @@ -1222,11 +1278,12 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) |
| 1222 | <ThreadSettingsPickerStack.Navigator | 1278 | <ThreadSettingsPickerStack.Navigator |
| 1223 | initialRouteName="ThreadSettingsModels" | 1279 | initialRouteName="ThreadSettingsModels" |
| 1224 | screenOptions={{ | 1280 | screenOptions={{ |
| 1225 | animation: "slide_from_right", | 1281 | animation: Platform.OS === "android" ? "default" : "slide_from_right", |
| 1226 | contentStyle: { backgroundColor: solidSheetBackground }, | 1282 | contentStyle: { backgroundColor: solidSheetBackground }, |
| 1227 | gestureEnabled: true, | 1283 | gestureEnabled: true, |
| 1228 | headerBackButtonDisplayMode: "minimal", | 1284 | headerBackButtonDisplayMode: "minimal", |
| 1229 | headerBackTitle: "", | 1285 | headerBackTitle: "", |
| 1286 | headerShown: Platform.OS !== "android", | ||
| 1230 | headerShadowVisible: false, | 1287 | headerShadowVisible: false, |
| 1231 | headerStyle: { | 1288 | headerStyle: { |
| 1232 | backgroundColor: NATIVE_LIQUID_GLASS_SUPPORTED ? "transparent" : solidSheetBackground, | 1289 | backgroundColor: NATIVE_LIQUID_GLASS_SUPPORTED ? "transparent" : solidSheetBackground, |
apps/mobile/src/features/threads/customSnoozeDate.test.ts created+36| ... | @@ -0,0 +1,36 @@ | ||
| 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,10 +1,12 @@ |
| 1 | import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; | 1 | import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 2 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; | 2 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | import { useState } from "react"; | 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 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | 6 | ||
| 7 | import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; | 7 | import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; |
| 8 | import { MaterialScreenContent } from "../../../components/MaterialScreenContent"; | ||
| 9 | import { NativeStackScreenOptions } from "../../../native/StackHeader"; | ||
| 8 | import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; | 10 | import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; |
| 9 | import { cn } from "../../../lib/cn"; | 11 | import { cn } from "../../../lib/cn"; |
| 10 | import { useEnvironmentQuery } from "../../../state/query"; | 12 | import { useEnvironmentQuery } from "../../../state/query"; |
| ... | @@ -23,6 +25,7 @@ type GitBranchesSheetProps = StaticScreenProps<{ | ... | @@ -23,6 +25,7 @@ type GitBranchesSheetProps = StaticScreenProps<{ |
| 23 | export function GitBranchesSheet(_props: GitBranchesSheetProps) { | 25 | export function GitBranchesSheet(_props: GitBranchesSheetProps) { |
| 24 | const navigation = useNavigation(); | 26 | const navigation = useNavigation(); |
| 25 | const insets = useSafeAreaInsets(); | 27 | const insets = useSafeAreaInsets(); |
| 28 | const { height: windowHeight } = useWindowDimensions(); | ||
| 26 | const { selectedThread } = useThreadSelection(); | 29 | const { selectedThread } = useThreadSelection(); |
| 27 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); | 30 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); |
| 28 | const gitState = useSelectedThreadGitState(); | 31 | const gitState = useSelectedThreadGitState(); |
| ... | @@ -58,125 +61,219 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { | ... | @@ -58,125 +61,219 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { |
| 58 | const disabledExistingBranches = new Set(disabledExistingBranchNames); | 61 | const disabledExistingBranches = new Set(disabledExistingBranchNames); |
| 59 | 62 | ||
| 60 | return ( | 63 | return ( |
| 61 | <View collapsable={false} className="flex-1 bg-sheet"> | 64 | <View |
| 65 | collapsable={false} | ||
| 66 | className={Platform.OS === "android" ? "bg-sheet" : "flex-1 bg-sheet"} | ||
| 67 | style={Platform.OS === "android" ? { maxHeight: windowHeight * 0.92 } : undefined} | ||
| 68 | > | ||
| 62 | {Platform.OS === "android" ? ( | 69 | {Platform.OS === "android" ? ( |
| 63 | <AndroidSheetHeader title="Branches & worktrees" onBack={() => navigation.goBack()} /> | 70 | <NativeStackScreenOptions |
| 71 | options={{ | ||
| 72 | sheetCornerRadius: 28, | ||
| 73 | sheetAllowedDetents: "fitToContents", | ||
| 74 | }} | ||
| 75 | /> | ||
| 64 | ) : null} | 76 | ) : null} |
| 65 | <ScrollView | 77 | {Platform.OS === "android" ? ( |
| 66 | className="flex-1" | 78 | <AndroidSheetHeader |
| 67 | showsVerticalScrollIndicator={false} | 79 | title="Branches & worktrees" |
| 68 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | 80 | onBack={() => navigation.goBack()} |
| 69 | contentContainerClassName="gap-4 px-5 pt-2" | 81 | hideBottomBorder |
| 70 | > | 82 | /> |
| 71 | <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4"> | 83 | ) : null} |
| 72 | <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase"> | 84 | <MaterialScreenContent fitToContents> |
| 73 | New branch | 85 | <ScrollView |
| 74 | </Text> | 86 | className={Platform.OS === "android" ? "shrink grow-0" : "flex-1"} |
| 75 | <TextInput | 87 | keyboardShouldPersistTaps="handled" |
| 76 | value={newBranchName} | 88 | showsVerticalScrollIndicator={false} |
| 77 | onChangeText={setNewBranchName} | 89 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} |
| 78 | placeholder="feature/mobile-polish" | 90 | contentContainerClassName={Platform.OS === "android" ? "gap-2 p-2" : "gap-4 px-5 pt-2"} |
| 79 | className="rounded-[18px]" | 91 | contentContainerStyle={ |
| 80 | /> | 92 | Platform.OS === "android" |
| 81 | <SheetActionButton | 93 | ? { paddingBottom: Math.max(insets.bottom, 18) + 18 } |
| 82 | icon="plus" | 94 | : undefined |
| 83 | label="Create & checkout" | 95 | } |
| 84 | tone="primary" | 96 | > |
| 85 | disabled={busy || newBranchName.trim().length === 0} | 97 | <View |
| 86 | onPress={() => { | 98 | className={ |
| 87 | const branch = sanitizeFeatureBranchName(newBranchName.trim()); | 99 | Platform.OS === "android" |
| 88 | if (branch.length === 0) return; | 100 | ? "gap-3 rounded-[20px] bg-card p-4" |
| 89 | void gitActions.onCreateSelectedThreadBranch(branch).then(() => { | 101 | : "gap-2 rounded-[18px] border border-border bg-card px-4 py-4" |
| 90 | setNewBranchName(""); | ||
| 91 | navigation.goBack(); | ||
| 92 | }); | ||
| 93 | }} | ||
| 94 | /> | ||
| 95 | </View> | ||
| 96 | |||
| 97 | <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4"> | ||
| 98 | <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase"> | ||
| 99 | New worktree | ||
| 100 | </Text> | ||
| 101 | <TextInput | ||
| 102 | value={worktreeBaseBranch} | ||
| 103 | onChangeText={setWorktreeBaseBranch} | ||
| 104 | placeholder="main" | ||
| 105 | className="rounded-[18px]" | ||
| 106 | /> | ||
| 107 | <TextInput | ||
| 108 | value={worktreeBranchName} | ||
| 109 | onChangeText={setWorktreeBranchName} | ||
| 110 | placeholder="feature/mobile-thread" | ||
| 111 | className="rounded-[18px]" | ||
| 112 | /> | ||
| 113 | <SheetActionButton | ||
| 114 | icon="square.split.2x1" | ||
| 115 | label="Create worktree" | ||
| 116 | tone="primary" | ||
| 117 | disabled={ | ||
| 118 | busy || | ||
| 119 | worktreeBaseBranch.trim().length === 0 || | ||
| 120 | worktreeBranchName.trim().length === 0 | ||
| 121 | } | 102 | } |
| 122 | onPress={() => { | 103 | > |
| 123 | const baseBranch = worktreeBaseBranch.trim(); | 104 | <Text |
| 124 | const newBranch = worktreeBranchName.trim(); | 105 | className={ |
| 125 | if (baseBranch.length === 0 || newBranch.length === 0) return; | 106 | Platform.OS === "android" |
| 126 | void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => { | 107 | ? "text-foreground text-base font-t3-medium" |
| 127 | setWorktreeBranchName(""); | 108 | : "text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase" |
| 128 | navigation.goBack(); | 109 | } |
| 129 | }); | 110 | > |
| 130 | }} | 111 | New branch |
| 131 | /> | ||
| 132 | </View> | ||
| 133 | |||
| 134 | <View className="gap-2"> | ||
| 135 | <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase"> | ||
| 136 | Existing branches | ||
| 137 | </Text> | ||
| 138 | {branchesLoading ? ( | ||
| 139 | <Text className="text-foreground-secondary text-sm font-medium"> | ||
| 140 | Loading branches... | ||
| 141 | </Text> | 112 | </Text> |
| 142 | ) : null} | 113 | <TextInput |
| 143 | {!branchesLoading && availableBranches.length === 0 ? ( | 114 | value={newBranchName} |
| 144 | <Text className="text-foreground-secondary text-sm font-medium"> | 115 | onChangeText={setNewBranchName} |
| 145 | No local branches found. | 116 | placeholder="feature/mobile-polish" |
| 117 | accessibilityLabel="New branch name" | ||
| 118 | className={Platform.OS === "android" ? "rounded-xl bg-sheet-solid" : "rounded-[18px]"} | ||
| 119 | /> | ||
| 120 | <SheetActionButton | ||
| 121 | icon="plus" | ||
| 122 | label="Create & checkout" | ||
| 123 | tone="primary" | ||
| 124 | disabled={busy || newBranchName.trim().length === 0} | ||
| 125 | onPress={() => { | ||
| 126 | const branch = sanitizeFeatureBranchName(newBranchName.trim()); | ||
| 127 | if (branch.length === 0) return; | ||
| 128 | void gitActions.onCreateSelectedThreadBranch(branch).then(() => { | ||
| 129 | setNewBranchName(""); | ||
| 130 | navigation.goBack(); | ||
| 131 | }); | ||
| 132 | }} | ||
| 133 | /> | ||
| 134 | </View> | ||
| 135 | |||
| 136 | <View | ||
| 137 | className={ | ||
| 138 | Platform.OS === "android" | ||
| 139 | ? "gap-3 rounded-[20px] bg-card p-4" | ||
| 140 | : "gap-2 rounded-[18px] border border-border bg-card px-4 py-4" | ||
| 141 | } | ||
| 142 | > | ||
| 143 | <Text | ||
| 144 | className={ | ||
| 145 | Platform.OS === "android" | ||
| 146 | ? "text-foreground text-base font-t3-medium" | ||
| 147 | : "text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase" | ||
| 148 | } | ||
| 149 | > | ||
| 150 | New worktree | ||
| 146 | </Text> | 151 | </Text> |
| 147 | ) : null} | 152 | {Platform.OS === "android" ? ( |
| 148 | {availableBranches.map((branch) => { | 153 | <Text className="text-foreground-secondary text-sm">Base branch</Text> |
| 149 | const disabled = disabledExistingBranches.has(branch.name); | 154 | ) : null} |
| 150 | const subtitle = branch.worktreePath | 155 | <TextInput |
| 151 | ? branch.worktreePath === currentWorktreePath | 156 | value={worktreeBaseBranch} |
| 152 | ? "Checked out in this thread" | 157 | onChangeText={setWorktreeBaseBranch} |
| 153 | : "Checked out in another worktree" | 158 | placeholder="main" |
| 154 | : branch.isDefault | 159 | accessibilityLabel="Worktree base branch" |
| 155 | ? "Default branch" | 160 | className={Platform.OS === "android" ? "rounded-xl bg-sheet-solid" : "rounded-[18px]"} |
| 156 | : "Local branch"; | 161 | /> |
| 162 | {Platform.OS === "android" ? ( | ||
| 163 | <Text className="text-foreground-secondary text-sm">New branch</Text> | ||
| 164 | ) : null} | ||
| 165 | <TextInput | ||
| 166 | value={worktreeBranchName} | ||
| 167 | onChangeText={setWorktreeBranchName} | ||
| 168 | placeholder="feature/mobile-thread" | ||
| 169 | accessibilityLabel="Worktree branch name" | ||
| 170 | className={Platform.OS === "android" ? "rounded-xl bg-sheet-solid" : "rounded-[18px]"} | ||
| 171 | /> | ||
| 172 | <SheetActionButton | ||
| 173 | icon="square.split.2x1" | ||
| 174 | label="Create worktree" | ||
| 175 | tone="primary" | ||
| 176 | disabled={ | ||
| 177 | busy || | ||
| 178 | worktreeBaseBranch.trim().length === 0 || | ||
| 179 | worktreeBranchName.trim().length === 0 | ||
| 180 | } | ||
| 181 | onPress={() => { | ||
| 182 | const baseBranch = worktreeBaseBranch.trim(); | ||
| 183 | const newBranch = worktreeBranchName.trim(); | ||
| 184 | if (baseBranch.length === 0 || newBranch.length === 0) return; | ||
| 185 | void gitActions | ||
| 186 | .onCreateSelectedThreadWorktree({ baseBranch, newBranch }) | ||
| 187 | .then(() => { | ||
| 188 | setWorktreeBranchName(""); | ||
| 189 | navigation.goBack(); | ||
| 190 | }); | ||
| 191 | }} | ||
| 192 | /> | ||
| 193 | </View> | ||
| 157 | 194 | ||
| 158 | return ( | 195 | <View className="gap-2"> |
| 159 | <Pressable | 196 | <Text |
| 160 | key={branch.name} | 197 | className={ |
| 198 | Platform.OS === "android" | ||
| 199 | ? "px-4 pb-1 pt-3 text-foreground-secondary text-sm font-t3-medium" | ||
| 200 | : "text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase" | ||
| 201 | } | ||
| 202 | > | ||
| 203 | Existing branches | ||
| 204 | </Text> | ||
| 205 | {branchesLoading ? ( | ||
| 206 | <Text | ||
| 161 | className={cn( | 207 | className={cn( |
| 162 | "gap-1 rounded-[18px] border px-4 py-3 disabled:opacity-[0.45]", | 208 | "text-foreground-secondary text-sm font-medium", |
| 163 | branch.current ? "border-subtle-strong" : "border-border", | 209 | Platform.OS === "android" && "px-4", |
| 164 | )} | 210 | )} |
| 165 | disabled={busy || disabled} | ||
| 166 | onPress={() => { | ||
| 167 | void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { | ||
| 168 | navigation.goBack(); | ||
| 169 | }); | ||
| 170 | }} | ||
| 171 | > | 211 | > |
| 172 | <View className="absolute inset-0 rounded-[18px] bg-card" /> | 212 | Loading branches... |
| 173 | <Text className="text-foreground text-base font-t3-bold">{branch.name}</Text> | 213 | </Text> |
| 174 | <Text className="text-foreground-secondary text-xs font-medium">{subtitle}</Text> | 214 | ) : null} |
| 175 | </Pressable> | 215 | {!branchesLoading && availableBranches.length === 0 ? ( |
| 176 | ); | 216 | <Text |
| 177 | })} | 217 | className={cn( |
| 178 | </View> | 218 | "text-foreground-secondary text-sm font-medium", |
| 179 | </ScrollView> | 219 | Platform.OS === "android" && "px-4", |
| 220 | )} | ||
| 221 | > | ||
| 222 | No local branches found. | ||
| 223 | </Text> | ||
| 224 | ) : null} | ||
| 225 | {availableBranches.map((branch) => { | ||
| 226 | const disabled = disabledExistingBranches.has(branch.name); | ||
| 227 | const subtitle = branch.worktreePath | ||
| 228 | ? branch.worktreePath === currentWorktreePath | ||
| 229 | ? "Checked out in this thread" | ||
| 230 | : "Checked out in another worktree" | ||
| 231 | : branch.isDefault | ||
| 232 | ? "Default branch" | ||
| 233 | : "Local branch"; | ||
| 234 | |||
| 235 | return ( | ||
| 236 | <Pressable | ||
| 237 | key={branch.name} | ||
| 238 | className={cn( | ||
| 239 | "gap-1 px-4 py-3 disabled:opacity-[0.45]", | ||
| 240 | Platform.OS === "android" | ||
| 241 | ? cn( | ||
| 242 | "rounded-[20px] active:bg-subtle", | ||
| 243 | branch.current ? "bg-secondary" : "bg-card", | ||
| 244 | ) | ||
| 245 | : cn( | ||
| 246 | "rounded-[18px] border", | ||
| 247 | branch.current ? "border-subtle-strong" : "border-border", | ||
| 248 | ), | ||
| 249 | )} | ||
| 250 | accessibilityRole="button" | ||
| 251 | accessibilityState={{ selected: branch.current, disabled: busy || disabled }} | ||
| 252 | disabled={busy || disabled} | ||
| 253 | onPress={() => { | ||
| 254 | void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { | ||
| 255 | navigation.goBack(); | ||
| 256 | }); | ||
| 257 | }} | ||
| 258 | > | ||
| 259 | {Platform.OS !== "android" ? ( | ||
| 260 | <View className="absolute inset-0 rounded-[18px] bg-card" /> | ||
| 261 | ) : null} | ||
| 262 | <Text | ||
| 263 | className={cn( | ||
| 264 | "text-foreground text-base", | ||
| 265 | Platform.OS === "android" ? "font-t3-medium" : "font-t3-bold", | ||
| 266 | )} | ||
| 267 | > | ||
| 268 | {branch.name} | ||
| 269 | </Text> | ||
| 270 | <Text className="text-foreground-secondary text-xs font-medium">{subtitle}</Text> | ||
| 271 | </Pressable> | ||
| 272 | ); | ||
| 273 | })} | ||
| 274 | </View> | ||
| 275 | </ScrollView> | ||
| 276 | </MaterialScreenContent> | ||
| 180 | </View> | 277 | </View> |
| 181 | ); | 278 | ); |
| 182 | } | 279 | } |
apps/mobile/src/features/threads/git/GitCommitSheet.tsx+267-147| ... | @@ -1,9 +1,12 @@ | ... | @@ -1,9 +1,12 @@ |
| 1 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; | 1 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 2 | import { useCallback, useState } from "react"; | 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 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 4 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 5 | 5 | ||
| 6 | import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; | 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 | import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; | 10 | import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; |
| 8 | import { cn } from "../../../lib/cn"; | 11 | import { cn } from "../../../lib/cn"; |
| 9 | import { useEnvironmentQuery } from "../../../state/query"; | 12 | import { useEnvironmentQuery } from "../../../state/query"; |
| ... | @@ -22,6 +25,7 @@ type GitCommitSheetProps = StaticScreenProps<{ | ... | @@ -22,6 +25,7 @@ type GitCommitSheetProps = StaticScreenProps<{ |
| 22 | export function GitCommitSheet(_props: GitCommitSheetProps) { | 25 | export function GitCommitSheet(_props: GitCommitSheetProps) { |
| 23 | const navigation = useNavigation(); | 26 | const navigation = useNavigation(); |
| 24 | const insets = useSafeAreaInsets(); | 27 | const insets = useSafeAreaInsets(); |
| 28 | const { height: windowHeight } = useWindowDimensions(); | ||
| 25 | const { selectedThread } = useThreadSelection(); | 29 | const { selectedThread } = useThreadSelection(); |
| 26 | const { selectedThreadCwd } = useSelectedThreadWorktree(); | 30 | const { selectedThreadCwd } = useSelectedThreadWorktree(); |
| 27 | const gitState = useSelectedThreadGitState(); | 31 | const gitState = useSelectedThreadGitState(); |
| ... | @@ -66,168 +70,284 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { | ... | @@ -66,168 +70,284 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { |
| 66 | ); | 70 | ); |
| 67 | 71 | ||
| 68 | return ( | 72 | return ( |
| 69 | <View collapsable={false} className="flex-1 bg-sheet"> | 73 | <View |
| 74 | collapsable={false} | ||
| 75 | className={Platform.OS === "android" ? "bg-sheet" : "flex-1 bg-sheet"} | ||
| 76 | style={Platform.OS === "android" ? { maxHeight: windowHeight * 0.92 } : undefined} | ||
| 77 | > | ||
| 70 | {Platform.OS === "android" ? ( | 78 | {Platform.OS === "android" ? ( |
| 71 | <AndroidSheetHeader title="Commit changes" onBack={() => navigation.goBack()} /> | 79 | <NativeStackScreenOptions |
| 80 | options={{ | ||
| 81 | sheetCornerRadius: 28, | ||
| 82 | sheetAllowedDetents: "fitToContents", | ||
| 83 | }} | ||
| 84 | /> | ||
| 72 | ) : null} | 85 | ) : null} |
| 73 | <ScrollView | 86 | {Platform.OS === "android" ? ( |
| 74 | className="flex-1" | 87 | <AndroidSheetHeader |
| 75 | keyboardShouldPersistTaps="handled" | 88 | title="Commit changes" |
| 76 | showsVerticalScrollIndicator={false} | 89 | onBack={() => navigation.goBack()} |
| 77 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | 90 | hideBottomBorder |
| 78 | contentContainerClassName="gap-4 px-5 pt-2" | 91 | /> |
| 79 | > | 92 | ) : null} |
| 80 | <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4"> | 93 | <MaterialScreenContent fitToContents> |
| 81 | <View className="flex-row items-center justify-between gap-3"> | 94 | <ScrollView |
| 82 | <Text className="text-foreground-muted text-sm font-medium">Branch</Text> | 95 | className={Platform.OS === "android" ? "shrink grow-0" : "flex-1"} |
| 83 | <Text className="text-foreground text-base font-t3-bold"> | 96 | keyboardShouldPersistTaps="handled" |
| 84 | {gitStatus.data?.refName ?? "(detached HEAD)"} | 97 | showsVerticalScrollIndicator={false} |
| 85 | </Text> | 98 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} |
| 86 | </View> | 99 | contentContainerClassName={Platform.OS === "android" ? "gap-2 p-2" : "gap-4 px-5 pt-2"} |
| 87 | {isDefaultRef ? ( | 100 | contentContainerStyle={ |
| 88 | <Text className="text-xs leading-normal text-warning-foreground"> | 101 | Platform.OS === "android" |
| 89 | Warning: this is the default branch. | 102 | ? { paddingBottom: Math.max(insets.bottom, 18) + 18 } |
| 90 | </Text> | 103 | : undefined |
| 91 | ) : null} | 104 | } |
| 92 | </View> | 105 | > |
| 93 | 106 | <View | |
| 94 | <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4"> | 107 | className={cn( |
| 95 | <View className="flex-row items-center justify-between gap-3"> | 108 | "gap-3 bg-card p-4", |
| 96 | <View className="gap-1"> | 109 | Platform.OS === "android" ? "rounded-[20px]" : "rounded-[22px] border border-border", |
| 97 | <Text className="text-foreground text-base font-t3-bold">Files</Text> | 110 | )} |
| 98 | <Text className="text-foreground-muted text-xs leading-normal"> | 111 | > |
| 99 | {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} | 112 | <View |
| 100 | </Text> | 113 | className={ |
| 101 | </View> | 114 | Platform.OS === "android" ? "gap-1" : "flex-row items-center justify-between gap-3" |
| 102 | <View className="flex-row items-center gap-2"> | 115 | } |
| 103 | {!allSelected && isEditingFiles ? ( | 116 | > |
| 104 | <Pressable | 117 | <Text className="text-foreground-muted text-sm font-medium">Branch</Text> |
| 105 | className="bg-subtle rounded-full px-3 py-2" | 118 | <Text |
| 106 | onPress={() => setExcludedFiles(new Set())} | 119 | className={cn( |
| 107 | > | 120 | "text-foreground text-base", |
| 108 | <Text className="text-foreground text-2xs font-t3-bold uppercase">Reset</Text> | 121 | Platform.OS === "android" ? "font-t3-medium" : "font-t3-bold", |
| 109 | </Pressable> | 122 | )} |
| 110 | ) : null} | ||
| 111 | <Pressable | ||
| 112 | className="bg-subtle rounded-full px-3 py-2" | ||
| 113 | onPress={() => setIsEditingFiles((current) => !current)} | ||
| 114 | > | 123 | > |
| 115 | <Text className="text-foreground text-2xs font-t3-bold uppercase"> | 124 | {gitStatus.data?.refName ?? "(detached HEAD)"} |
| 116 | {isEditingFiles ? "Done" : "Edit"} | 125 | </Text> |
| 117 | </Text> | ||
| 118 | </Pressable> | ||
| 119 | </View> | 126 | </View> |
| 127 | {isDefaultRef ? ( | ||
| 128 | <Text className="text-xs leading-normal text-warning-foreground"> | ||
| 129 | Warning: this is the default branch. | ||
| 130 | </Text> | ||
| 131 | ) : null} | ||
| 120 | </View> | 132 | </View> |
| 121 | 133 | ||
| 122 | {allFiles.length === 0 ? ( | 134 | <View |
| 123 | <Text className="text-foreground-secondary text-sm leading-normal"> | 135 | className={cn( |
| 124 | No changed files are available to commit. | 136 | "gap-3 bg-card p-4", |
| 125 | </Text> | 137 | Platform.OS === "android" ? "rounded-[20px]" : "rounded-[22px] border border-border", |
| 126 | ) : !isEditingFiles ? ( | 138 | )} |
| 127 | <View className="gap-2"> | 139 | > |
| 128 | {selectedFilePreview.map((file) => ( | 140 | <View className="flex-row items-center justify-between gap-3"> |
| 129 | <View key={file.path} className="flex-row items-center justify-between gap-3"> | 141 | <View className="gap-1"> |
| 130 | <Text className="text-foreground flex-1 text-sm font-medium" numberOfLines={1}> | 142 | <Text |
| 131 | {file.path} | 143 | className={cn( |
| 132 | </Text> | 144 | "text-foreground text-base", |
| 133 | <Text className="text-xs font-t3-bold text-emerald-500">+{file.insertions}</Text> | 145 | Platform.OS === "android" ? "font-t3-medium" : "font-t3-bold", |
| 134 | <Text className="text-xs font-t3-bold text-rose-500">-{file.deletions}</Text> | 146 | )} |
| 135 | </View> | 147 | > |
| 136 | ))} | 148 | Files |
| 137 | {selectedFiles.length > selectedFilePreview.length ? ( | ||
| 138 | <Text className="text-foreground-muted text-xs leading-snug"> | ||
| 139 | +{selectedFiles.length - selectedFilePreview.length} more files | ||
| 140 | </Text> | 149 | </Text> |
| 141 | ) : null} | 150 | <Text className="text-foreground-muted text-xs leading-normal"> |
| 142 | </View> | 151 | {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} |
| 143 | ) : ( | 152 | </Text> |
| 144 | <View className="gap-2"> | 153 | </View> |
| 145 | {allFiles.map((file) => { | 154 | <View className="flex-row items-center gap-2"> |
| 146 | const included = !excludedFiles.has(file.path); | 155 | {!allSelected && isEditingFiles ? ( |
| 147 | return ( | ||
| 148 | <Pressable | 156 | <Pressable |
| 149 | key={file.path} | 157 | className={ |
| 150 | className={cn( | 158 | Platform.OS === "android" |
| 151 | "rounded-[18px] border px-4 py-3", | 159 | ? "min-h-12 justify-center rounded-full px-3 active:bg-subtle" |
| 152 | included ? "border-border" : "border-border-subtle", | 160 | : "bg-subtle rounded-full px-3 py-2" |
| 153 | )} | 161 | } |
| 154 | onPress={() => { | 162 | onPress={() => setExcludedFiles(new Set())} |
| 155 | setExcludedFiles((current) => { | ||
| 156 | const next = new Set(current); | ||
| 157 | if (next.has(file.path)) { | ||
| 158 | next.delete(file.path); | ||
| 159 | } else { | ||
| 160 | next.add(file.path); | ||
| 161 | } | ||
| 162 | return next; | ||
| 163 | }); | ||
| 164 | }} | ||
| 165 | > | 163 | > |
| 166 | <View | 164 | <Text |
| 167 | className={`absolute inset-0 rounded-[18px] ${included ? "bg-card" : "bg-subtle"}`} | 165 | className={ |
| 168 | /> | 166 | Platform.OS === "android" |
| 169 | <View className="flex-row items-start justify-between gap-3"> | 167 | ? "text-primary text-sm font-t3-medium" |
| 170 | <View className="flex-1 gap-1"> | 168 | : "text-foreground text-2xs font-t3-bold uppercase" |
| 171 | <Text | 169 | } |
| 172 | selectable | 170 | > |
| 173 | className={`text-sm font-t3-bold ${included ? "text-foreground" : "text-foreground-muted"}`} | 171 | Reset |
| 174 | > | 172 | </Text> |
| 175 | {file.path} | ||
| 176 | </Text> | ||
| 177 | {!included ? ( | ||
| 178 | <Text className="text-foreground-muted text-2xs leading-normal"> | ||
| 179 | Excluded from this commit | ||
| 180 | </Text> | ||
| 181 | ) : null} | ||
| 182 | </View> | ||
| 183 | <View className="items-end gap-1"> | ||
| 184 | <Text className="text-xs font-t3-bold text-emerald-500"> | ||
| 185 | +{file.insertions} | ||
| 186 | </Text> | ||
| 187 | <Text className="text-xs font-t3-bold text-rose-500"> | ||
| 188 | -{file.deletions} | ||
| 189 | </Text> | ||
| 190 | </View> | ||
| 191 | </View> | ||
| 192 | </Pressable> | 173 | </Pressable> |
| 193 | ); | 174 | ) : null} |
| 194 | })} | 175 | <Pressable |
| 176 | className={ | ||
| 177 | Platform.OS === "android" | ||
| 178 | ? "min-h-12 justify-center rounded-full px-3 active:bg-subtle" | ||
| 179 | : "bg-subtle rounded-full px-3 py-2" | ||
| 180 | } | ||
| 181 | onPress={() => setIsEditingFiles((current) => !current)} | ||
| 182 | > | ||
| 183 | <Text | ||
| 184 | className={ | ||
| 185 | Platform.OS === "android" | ||
| 186 | ? "text-primary text-sm font-t3-medium" | ||
| 187 | : "text-foreground text-2xs font-t3-bold uppercase" | ||
| 188 | } | ||
| 189 | > | ||
| 190 | {isEditingFiles ? "Done" : "Edit"} | ||
| 191 | </Text> | ||
| 192 | </Pressable> | ||
| 193 | </View> | ||
| 195 | </View> | 194 | </View> |
| 196 | )} | ||
| 197 | </View> | ||
| 198 | 195 | ||
| 199 | <View className="gap-2"> | 196 | {allFiles.length === 0 ? ( |
| 200 | <Text className="text-foreground text-sm font-t3-bold">Commit message</Text> | 197 | <Text className="text-foreground-secondary text-sm leading-normal"> |
| 201 | <TextInput | 198 | No changed files are available to commit. |
| 202 | multiline | 199 | </Text> |
| 203 | value={dialogCommitMessage} | 200 | ) : !isEditingFiles ? ( |
| 204 | onChangeText={setDialogCommitMessage} | 201 | <View className="gap-2"> |
| 205 | placeholder="Leave empty to auto-generate" | 202 | {selectedFilePreview.map((file) => ( |
| 206 | textAlignVertical="top" | 203 | <View key={file.path} className="flex-row items-center justify-between gap-3"> |
| 207 | className="min-h-[128px] rounded-[20px] px-4 py-3.5" | 204 | <Text className="text-foreground flex-1 text-sm font-medium" numberOfLines={1}> |
| 208 | /> | 205 | {file.path} |
| 209 | </View> | 206 | </Text> |
| 207 | <Text className="text-xs font-t3-bold text-emerald-500"> | ||
| 208 | +{file.insertions} | ||
| 209 | </Text> | ||
| 210 | <Text className="text-xs font-t3-bold text-rose-500">-{file.deletions}</Text> | ||
| 211 | </View> | ||
| 212 | ))} | ||
| 213 | {selectedFiles.length > selectedFilePreview.length ? ( | ||
| 214 | <Text className="text-foreground-muted text-xs leading-snug"> | ||
| 215 | +{selectedFiles.length - selectedFilePreview.length} more files | ||
| 216 | </Text> | ||
| 217 | ) : null} | ||
| 218 | </View> | ||
| 219 | ) : ( | ||
| 220 | <View className="gap-2"> | ||
| 221 | {allFiles.map((file) => { | ||
| 222 | const included = !excludedFiles.has(file.path); | ||
| 223 | return ( | ||
| 224 | <Pressable | ||
| 225 | key={file.path} | ||
| 226 | className={cn( | ||
| 227 | "px-4 py-3", | ||
| 228 | Platform.OS === "android" | ||
| 229 | ? included | ||
| 230 | ? "rounded-xl bg-subtle" | ||
| 231 | : "rounded-xl" | ||
| 232 | : cn( | ||
| 233 | "rounded-[18px] border", | ||
| 234 | included ? "border-border" : "border-border-subtle", | ||
| 235 | ), | ||
| 236 | )} | ||
| 237 | accessibilityRole="checkbox" | ||
| 238 | accessibilityLabel={file.path} | ||
| 239 | accessibilityState={{ checked: included }} | ||
| 240 | onPress={() => { | ||
| 241 | setExcludedFiles((current) => { | ||
| 242 | const next = new Set(current); | ||
| 243 | if (next.has(file.path)) { | ||
| 244 | next.delete(file.path); | ||
| 245 | } else { | ||
| 246 | next.add(file.path); | ||
| 247 | } | ||
| 248 | return next; | ||
| 249 | }); | ||
| 250 | }} | ||
| 251 | > | ||
| 252 | {Platform.OS !== "android" ? ( | ||
| 253 | <View | ||
| 254 | className={`absolute inset-0 rounded-[18px] ${included ? "bg-card" : "bg-subtle"}`} | ||
| 255 | /> | ||
| 256 | ) : null} | ||
| 257 | <View className="flex-row items-start justify-between gap-3"> | ||
| 258 | {Platform.OS === "android" ? ( | ||
| 259 | <View | ||
| 260 | className={cn( | ||
| 261 | "mt-0.5 size-5 items-center justify-center rounded-sm", | ||
| 262 | included ? "bg-primary" : "border border-input-border", | ||
| 263 | )} | ||
| 264 | > | ||
| 265 | {included ? ( | ||
| 266 | <SymbolView | ||
| 267 | name="checkmark" | ||
| 268 | size={16} | ||
| 269 | tintColorClassName="accent-primary-foreground" | ||
| 270 | type="monochrome" | ||
| 271 | /> | ||
| 272 | ) : null} | ||
| 273 | </View> | ||
| 274 | ) : null} | ||
| 275 | <View className="flex-1 gap-1"> | ||
| 276 | <Text | ||
| 277 | selectable | ||
| 278 | className={`text-sm font-t3-bold ${included ? "text-foreground" : "text-foreground-muted"}`} | ||
| 279 | > | ||
| 280 | {file.path} | ||
| 281 | </Text> | ||
| 282 | {!included ? ( | ||
| 283 | <Text className="text-foreground-muted text-2xs leading-normal"> | ||
| 284 | Excluded from this commit | ||
| 285 | </Text> | ||
| 286 | ) : null} | ||
| 287 | </View> | ||
| 288 | <View className="items-end gap-1"> | ||
| 289 | <Text className="text-xs font-t3-bold text-emerald-500"> | ||
| 290 | +{file.insertions} | ||
| 291 | </Text> | ||
| 292 | <Text className="text-xs font-t3-bold text-rose-500"> | ||
| 293 | -{file.deletions} | ||
| 294 | </Text> | ||
| 295 | </View> | ||
| 296 | </View> | ||
| 297 | </Pressable> | ||
| 298 | ); | ||
| 299 | })} | ||
| 300 | </View> | ||
| 301 | )} | ||
| 302 | </View> | ||
| 210 | 303 | ||
| 211 | <View className="flex-row gap-3"> | 304 | <View |
| 212 | <View className="flex-1"> | 305 | className={Platform.OS === "android" ? "gap-3 rounded-[20px] bg-card p-4" : "gap-2"} |
| 213 | <SheetActionButton | 306 | > |
| 214 | icon="arrow.branch" | 307 | <Text |
| 215 | label="Commit on new branch" | 308 | className={ |
| 216 | disabled={noneSelected || busy} | 309 | Platform.OS === "android" |
| 217 | onPress={() => void runCommitAction(true)} | 310 | ? "text-foreground text-base font-t3-medium" |
| 311 | : "text-foreground text-sm font-t3-bold" | ||
| 312 | } | ||
| 313 | > | ||
| 314 | Commit message | ||
| 315 | </Text> | ||
| 316 | <TextInput | ||
| 317 | multiline | ||
| 318 | accessibilityLabel="Commit message" | ||
| 319 | value={dialogCommitMessage} | ||
| 320 | onChangeText={setDialogCommitMessage} | ||
| 321 | placeholder="Leave empty to auto-generate" | ||
| 322 | textAlignVertical="top" | ||
| 323 | className={cn( | ||
| 324 | "min-h-[128px] px-4 py-3.5", | ||
| 325 | Platform.OS === "android" ? "rounded-xl bg-sheet-solid" : "rounded-[20px]", | ||
| 326 | )} | ||
| 218 | /> | 327 | /> |
| 219 | </View> | 328 | </View> |
| 220 | <View className="flex-1"> | 329 | |
| 221 | <SheetActionButton | 330 | <View className={Platform.OS === "android" ? "gap-2" : "flex-row gap-3"}> |
| 222 | icon="checkmark.circle" | 331 | <View className={Platform.OS === "android" ? undefined : "flex-1"}> |
| 223 | label="Commit" | 332 | <SheetActionButton |
| 224 | tone="primary" | 333 | icon="arrow.branch" |
| 225 | disabled={noneSelected || busy} | 334 | label="Commit on new branch" |
| 226 | onPress={() => void runCommitAction(false)} | 335 | disabled={noneSelected || busy} |
| 227 | /> | 336 | onPress={() => void runCommitAction(true)} |
| 337 | /> | ||
| 338 | </View> | ||
| 339 | <View className={Platform.OS === "android" ? undefined : "flex-1"}> | ||
| 340 | <SheetActionButton | ||
| 341 | icon="checkmark.circle" | ||
| 342 | label="Commit" | ||
| 343 | tone="primary" | ||
| 344 | disabled={noneSelected || busy} | ||
| 345 | onPress={() => void runCommitAction(false)} | ||
| 346 | /> | ||
| 347 | </View> | ||
| 228 | </View> | 348 | </View> |
| 229 | </View> | 349 | </ScrollView> |
| 230 | </ScrollView> | 350 | </MaterialScreenContent> |
| 231 | </View> | 351 | </View> |
| 232 | ); | 352 | ); |
| 233 | } | 353 | } |
apps/mobile/src/features/threads/git/GitConfirmSheet.tsx+86-27| ... | @@ -4,11 +4,13 @@ import * as Arr from "effect/Array"; | ... | @@ -4,11 +4,13 @@ import * as Arr from "effect/Array"; |
| 4 | import * as Result from "effect/Result"; | 4 | import * as Result from "effect/Result"; |
| 5 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | 5 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 6 | import { useCallback, useMemo } from "react"; | 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 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 9 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 10 | 10 | ||
| 11 | import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; | 11 | import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; |
| 12 | import { MaterialScreenContent } from "../../../components/MaterialScreenContent"; | ||
| 13 | import { NativeStackScreenOptions } from "../../../native/StackHeader"; | ||
| 12 | import { AppText as Text } from "../../../components/AppText"; | 14 | import { AppText as Text } from "../../../components/AppText"; |
| 13 | import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; | 15 | import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; |
| 14 | import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; | 16 | import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; |
| ... | @@ -27,6 +29,7 @@ type GitConfirmSheetProps = StaticScreenProps<{ | ... | @@ -27,6 +29,7 @@ type GitConfirmSheetProps = StaticScreenProps<{ |
| 27 | export function GitConfirmSheet(props: GitConfirmSheetProps) { | 29 | export function GitConfirmSheet(props: GitConfirmSheetProps) { |
| 28 | const navigation = useNavigation(); | 30 | const navigation = useNavigation(); |
| 29 | const insets = useSafeAreaInsets(); | 31 | const insets = useSafeAreaInsets(); |
| 32 | const { height: windowHeight } = useWindowDimensions(); | ||
| 30 | const gitState = useSelectedThreadGitState(); | 33 | const gitState = useSelectedThreadGitState(); |
| 31 | const gitActions = useSelectedThreadGitActions(); | 34 | const gitActions = useSelectedThreadGitActions(); |
| 32 | 35 | ||
| ... | @@ -102,38 +105,94 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { | ... | @@ -102,38 +105,94 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { |
| 102 | ]); | 105 | ]); |
| 103 | 106 | ||
| 104 | return ( | 107 | return ( |
| 105 | <View collapsable={false} className="flex-1 bg-sheet"> | 108 | <View |
| 109 | collapsable={false} | ||
| 110 | className={Platform.OS === "android" ? "bg-sheet" : "flex-1 bg-sheet"} | ||
| 111 | style={Platform.OS === "android" ? { maxHeight: windowHeight * 0.92 } : undefined} | ||
| 112 | > | ||
| 106 | {Platform.OS === "android" ? ( | 113 | {Platform.OS === "android" ? ( |
| 107 | <AndroidSheetHeader title="Confirm action" onBack={() => navigation.goBack()} /> | 114 | <NativeStackScreenOptions |
| 115 | options={{ | ||
| 116 | sheetCornerRadius: 28, | ||
| 117 | sheetAllowedDetents: "fitToContents", | ||
| 118 | }} | ||
| 119 | /> | ||
| 120 | ) : null} | ||
| 121 | {Platform.OS === "android" ? ( | ||
| 122 | <AndroidSheetHeader | ||
| 123 | title="Confirm action" | ||
| 124 | onBack={() => navigation.goBack()} | ||
| 125 | hideBottomBorder | ||
| 126 | /> | ||
| 108 | ) : ( | 127 | ) : ( |
| 109 | <View className="min-h-4 pt-2" /> | 128 | <View className="min-h-4 pt-2" /> |
| 110 | )} | 129 | )} |
| 111 | 130 | ||
| 112 | <View className="items-center gap-1 px-5 pb-3 pt-4"> | 131 | <MaterialScreenContent fitToContents> |
| 113 | <Text className="text-xs font-t3-bold tracking-[1px] uppercase text-foreground-muted"> | 132 | <ScrollView |
| 114 | Confirm | 133 | className={Platform.OS === "android" ? "shrink grow-0" : "flex-1"} |
| 115 | </Text> | 134 | showsVerticalScrollIndicator={false} |
| 116 | <Text className="text-center text-3xl font-t3-bold"> | 135 | contentContainerClassName={Platform.OS === "android" ? "gap-2 p-2" : undefined} |
| 117 | {copy?.title ?? "Run action on default branch?"} | 136 | contentContainerStyle={ |
| 118 | </Text> | 137 | Platform.OS === "android" |
| 119 | <Text className="text-center text-foreground-secondary text-sm font-medium leading-normal"> | 138 | ? { paddingBottom: Math.max(insets.bottom, 18) + 8 } |
| 120 | {copy?.description ?? "Choose how to continue."} | 139 | : undefined |
| 121 | </Text> | 140 | } |
| 122 | </View> | 141 | > |
| 142 | <View | ||
| 143 | className={ | ||
| 144 | Platform.OS === "android" | ||
| 145 | ? "gap-2 rounded-[20px] bg-card p-3" | ||
| 146 | : "items-center gap-1 px-5 pb-3 pt-4" | ||
| 147 | } | ||
| 148 | > | ||
| 149 | {Platform.OS !== "android" ? ( | ||
| 150 | <Text className="text-xs font-t3-bold tracking-[1px] uppercase text-foreground-muted"> | ||
| 151 | Confirm | ||
| 152 | </Text> | ||
| 153 | ) : null} | ||
| 154 | <Text | ||
| 155 | className={ | ||
| 156 | Platform.OS === "android" | ||
| 157 | ? "text-xl font-t3-medium" | ||
| 158 | : "text-center text-3xl font-t3-bold" | ||
| 159 | } | ||
| 160 | > | ||
| 161 | {copy?.title ?? "Run action on default branch?"} | ||
| 162 | </Text> | ||
| 163 | <Text | ||
| 164 | className={ | ||
| 165 | Platform.OS === "android" | ||
| 166 | ? "text-foreground-secondary text-base leading-normal" | ||
| 167 | : "text-center text-foreground-secondary text-sm font-medium leading-normal" | ||
| 168 | } | ||
| 169 | > | ||
| 170 | {copy?.description ?? "Choose how to continue."} | ||
| 171 | </Text> | ||
| 172 | </View> | ||
| 123 | 173 | ||
| 124 | <View className="gap-3 px-5 pt-2" style={{ paddingBottom: Math.max(insets.bottom, 18) + 8 }}> | 174 | <View |
| 125 | <SheetActionButton | 175 | className={Platform.OS === "android" ? "gap-2" : "gap-3 px-5 pt-2"} |
| 126 | icon="arrow.right.circle" | 176 | style={ |
| 127 | label={copy?.continueLabel ?? "Continue"} | 177 | Platform.OS === "android" |
| 128 | onPress={() => void continuePendingAction()} | 178 | ? undefined |
| 129 | /> | 179 | : { paddingBottom: Math.max(insets.bottom, 18) + 8 } |
| 130 | <SheetActionButton | 180 | } |
| 131 | icon="arrow.branch" | 181 | > |
| 132 | label="Feature branch & continue" | 182 | <SheetActionButton |
| 133 | tone="primary" | 183 | icon="arrow.right.circle" |
| 134 | onPress={() => void movePendingActionToFeatureBranch()} | 184 | label={copy?.continueLabel ?? "Continue"} |
| 135 | /> | 185 | onPress={() => void continuePendingAction()} |
| 136 | </View> | 186 | /> |
| 187 | <SheetActionButton | ||
| 188 | icon="arrow.branch" | ||
| 189 | label="Feature branch & continue" | ||
| 190 | tone="primary" | ||
| 191 | onPress={() => void movePendingActionToFeatureBranch()} | ||
| 192 | /> | ||
| 193 | </View> | ||
| 194 | </ScrollView> | ||
| 195 | </MaterialScreenContent> | ||
| 137 | </View> | 196 | </View> |
| 138 | ); | 197 | ); |
| 139 | } | 198 | } |
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+84-35| ... | @@ -22,10 +22,17 @@ import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "re | ... | @@ -22,10 +22,17 @@ import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "re |
| 22 | import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; | 22 | import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; |
| 23 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 23 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 24 | import { useUniwindTheme } from "../../../lib/useUniwindTheme"; | 24 | import { useUniwindTheme } from "../../../lib/useUniwindTheme"; |
| 25 | 25 | import { | |
| 26 | import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; | 26 | AndroidHeaderIconButton, |
| 27 | AndroidSheetHeader, | ||
| 28 | } from "../../../components/AndroidScreenHeader"; | ||
| 29 | import { MaterialScreenContent } from "../../../components/MaterialScreenContent"; | ||
| 30 | import { useAdaptiveWorkspaceLayout } from "../../layout/AdaptiveWorkspaceLayout"; | ||
| 27 | import { AppText as Text } from "../../../components/AppText"; | 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 | import { tryOpenExternalUrl } from "../../../lib/openExternalUrl"; | 36 | import { tryOpenExternalUrl } from "../../../lib/openExternalUrl"; |
| 30 | import { useEnvironmentQuery } from "../../../state/query"; | 37 | import { useEnvironmentQuery } from "../../../state/query"; |
| 31 | import { useThreadSelection } from "../../../state/use-thread-selection"; | 38 | import { useThreadSelection } from "../../../state/use-thread-selection"; |
| ... | @@ -47,6 +54,7 @@ type GitOverviewSheetProps = StaticScreenProps<{ | ... | @@ -47,6 +54,7 @@ type GitOverviewSheetProps = StaticScreenProps<{ |
| 47 | }; | 54 | }; |
| 48 | 55 | ||
| 49 | export function GitOverviewSheet(props: GitOverviewSheetProps) { | 56 | export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 57 | const { layout } = useAdaptiveWorkspaceLayout(); | ||
| 50 | const navigation = useNavigation(); | 58 | const navigation = useNavigation(); |
| 51 | const insets = useSafeAreaInsets(); | 59 | const insets = useSafeAreaInsets(); |
| 52 | const presentation = props.presentation ?? "sheet"; | 60 | const presentation = props.presentation ?? "sheet"; |
| ... | @@ -224,14 +232,14 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { | ... | @@ -224,14 +232,14 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 224 | 232 | ||
| 225 | const content = ( | 233 | const content = ( |
| 226 | <ScrollView | 234 | <ScrollView |
| 227 | className="flex-1 bg-screen" | 235 | className={Platform.OS === "android" ? "flex-1 bg-sheet-solid" : "flex-1 bg-screen"} |
| 228 | contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} | 236 | contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} |
| 229 | showsVerticalScrollIndicator={false} | 237 | showsVerticalScrollIndicator={false} |
| 230 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | 238 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} |
| 231 | contentContainerStyle={{ | 239 | contentContainerStyle={{ |
| 232 | paddingHorizontal: isInspector ? 12 : 20, | 240 | paddingHorizontal: Platform.OS === "android" ? 8 : isInspector ? 12 : 20, |
| 233 | paddingTop: 8, | 241 | paddingTop: 8, |
| 234 | gap: 14, | 242 | gap: Platform.OS === "android" ? 8 : 14, |
| 235 | }} | 243 | }} |
| 236 | refreshControl={ | 244 | refreshControl={ |
| 237 | <RefreshControl refreshing={isPullRefreshing} onRefresh={() => void handlePullRefresh()} /> | 245 | <RefreshControl refreshing={isPullRefreshing} onRefresh={() => void handlePullRefresh()} /> |
| ... | @@ -239,14 +247,18 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { | ... | @@ -239,14 +247,18 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 239 | > | 247 | > |
| 240 | <View | 248 | <View |
| 241 | className={ | 249 | className={ |
| 242 | isInspector | 250 | Platform.OS === "android" |
| 243 | ? "overflow-hidden rounded-2xl border border-border bg-card px-3 py-1" | 251 | ? "overflow-hidden rounded-[20px] bg-card" |
| 244 | : "overflow-hidden rounded-[22px] border border-border bg-card px-4 py-1" | 252 | : isInspector |
| 253 | ? "overflow-hidden rounded-2xl border border-border bg-card px-3 py-1" | ||
| 254 | : "overflow-hidden rounded-[22px] border border-border bg-card px-4 py-1" | ||
| 245 | } | 255 | } |
| 246 | > | 256 | > |
| 247 | {sheetMenuItems.map(({ item, disabledReason }, index) => ( | 257 | {sheetMenuItems.map(({ item, disabledReason }, index) => ( |
| 248 | <View key={`${item.id}-${item.label}`}> | 258 | <View key={`${item.id}-${item.label}`}> |
| 249 | {index > 0 ? <View className="ml-12 h-px bg-border" /> : null} | 259 | {index > 0 && Platform.OS !== "android" ? ( |
| 260 | <View className="ml-12 h-px bg-border" /> | ||
| 261 | ) : null} | ||
| 250 | <SheetListRow | 262 | <SheetListRow |
| 251 | icon={menuItemIconName(item.icon)} | 263 | icon={menuItemIconName(item.icon)} |
| 252 | title={item.label} | 264 | title={item.label} |
| ... | @@ -258,7 +270,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { | ... | @@ -258,7 +270,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 258 | ))} | 270 | ))} |
| 259 | {behindCount > 0 ? ( | 271 | {behindCount > 0 ? ( |
| 260 | <> | 272 | <> |
| 261 | <View className="ml-12 h-px bg-border" /> | 273 | {Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null} |
| 262 | <SheetListRow | 274 | <SheetListRow |
| 263 | icon="arrow.down.circle" | 275 | icon="arrow.down.circle" |
| 264 | title="Pull latest" | 276 | title="Pull latest" |
| ... | @@ -268,7 +280,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { | ... | @@ -268,7 +280,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 268 | /> | 280 | /> |
| 269 | </> | 281 | </> |
| 270 | ) : null} | 282 | ) : null} |
| 271 | <View className="ml-12 h-px bg-border" /> | 283 | {Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null} |
| 272 | <SheetListRow | 284 | <SheetListRow |
| 273 | icon="text.bubble" | 285 | icon="text.bubble" |
| 274 | title="Review changes" | 286 | title="Review changes" |
| ... | @@ -283,7 +295,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { | ... | @@ -283,7 +295,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 283 | ); | 295 | ); |
| 284 | }} | 296 | }} |
| 285 | /> | 297 | /> |
| 286 | <View className="ml-12 h-px bg-border" /> | 298 | {Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null} |
| 287 | <SheetListRow | 299 | <SheetListRow |
| 288 | icon="point.topleft.down.curvedto.point.bottomright.up" | 300 | icon="point.topleft.down.curvedto.point.bottomright.up" |
| 289 | title="Branches & worktrees" | 301 | title="Branches & worktrees" |
| ... | @@ -306,7 +318,11 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { | ... | @@ -306,7 +318,11 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 306 | {linkedPrChains.map((chain) => ( | 318 | {linkedPrChains.map((chain) => ( |
| 307 | <View | 319 | <View |
| 308 | key={threadPullRequestKeyOf(chain.layers[0]!)} | 320 | key={threadPullRequestKeyOf(chain.layers[0]!)} |
| 309 | className="overflow-hidden rounded-2xl border border-border bg-card px-3 py-1" | 321 | className={ |
| 322 | Platform.OS === "android" | ||
| 323 | ? "overflow-hidden rounded-[20px] bg-card" | ||
| 324 | : "overflow-hidden rounded-2xl border border-border bg-card px-3 py-1" | ||
| 325 | } | ||
| 310 | > | 326 | > |
| 311 | {chain.layers.length > 1 ? ( | 327 | {chain.layers.length > 1 ? ( |
| 312 | <View className="flex-row items-center gap-2 px-1 pt-2 pb-1"> | 328 | <View className="flex-row items-center gap-2 px-1 pt-2 pb-1"> |
| ... | @@ -323,7 +339,9 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { | ... | @@ -323,7 +339,9 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 323 | ) : null} | 339 | ) : null} |
| 324 | {chain.layers.map((link, index) => ( | 340 | {chain.layers.map((link, index) => ( |
| 325 | <View key={threadPullRequestKeyOf(link)}> | 341 | <View key={threadPullRequestKeyOf(link)}> |
| 326 | {index > 0 ? <View className="ml-12 h-px bg-border" /> : null} | 342 | {index > 0 && Platform.OS !== "android" ? ( |
| 343 | <View className="ml-12 h-px bg-border" /> | ||
| 344 | ) : null} | ||
| 327 | <SheetListRow | 345 | <SheetListRow |
| 328 | icon="arrow.triangle.pull" | 346 | icon="arrow.triangle.pull" |
| 329 | title={`#${link.number} ${link.snapshot?.title ?? "Pull request"}`} | 347 | title={`#${link.number} ${link.snapshot?.title ?? "Pull request"}`} |
| ... | @@ -416,8 +434,19 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { | ... | @@ -416,8 +434,19 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 416 | return ( | 434 | return ( |
| 417 | <View | 435 | <View |
| 418 | collapsable={false} | 436 | collapsable={false} |
| 419 | className={isInspector ? "flex-1 border-l border-border bg-sheet" : "flex-1 bg-sheet"} | 437 | className={ |
| 438 | Platform.OS === "android" | ||
| 439 | ? "flex-1 bg-header" | ||
| 440 | : isInspector | ||
| 441 | ? "flex-1 border-l border-border bg-sheet" | ||
| 442 | : "flex-1 bg-sheet" | ||
| 443 | } | ||
| 420 | > | 444 | > |
| 445 | {!isInspector ? ( | ||
| 446 | <NativeStackScreenOptions | ||
| 447 | options={{ sheetCornerRadius: Platform.OS === "android" ? 28 : undefined }} | ||
| 448 | /> | ||
| 449 | ) : null} | ||
| 421 | {isInspector ? ( | 450 | {isInspector ? ( |
| 422 | <View | 451 | <View |
| 423 | style={{ | 452 | style={{ |
| ... | @@ -428,24 +457,41 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { | ... | @@ -428,24 +457,41 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 428 | ) : null} | 457 | ) : null} |
| 429 | 458 | ||
| 430 | {isInspector ? ( | 459 | {isInspector ? ( |
| 431 | <View className="gap-1 border-b border-border px-4 pb-4 pt-3"> | 460 | <View |
| 432 | <Pressable | 461 | className={ |
| 433 | className={ | 462 | Platform.OS === "android" |
| 434 | busy | 463 | ? "gap-1 bg-header px-4 pb-4 pt-3" |
| 435 | ? "absolute right-3 top-4 z-[1] h-9 w-9 items-center justify-center rounded-full bg-subtle opacity-[0.45]" | 464 | : "gap-1 border-b border-border px-4 pb-4 pt-3" |
| 436 | : "absolute right-3 top-4 z-[1] h-9 w-9 items-center justify-center rounded-full bg-subtle" | 465 | } |
| 437 | } | 466 | > |
| 438 | disabled={busy} | 467 | {Platform.OS === "android" ? ( |
| 439 | onPress={() => void gitActions.refreshSelectedThreadGitStatus()} | 468 | <View className="absolute right-3 top-4 z-[1]"> |
| 440 | > | 469 | <AndroidHeaderIconButton |
| 441 | <SymbolView | 470 | accessibilityLabel="Refresh repository status" |
| 442 | name="arrow.clockwise" | 471 | disabled={busy} |
| 443 | size={16} | 472 | icon="arrow.clockwise" |
| 444 | tintColorClassName={"accent-icon"} | 473 | onPress={() => void gitActions.refreshSelectedThreadGitStatus()} |
| 445 | type="monochrome" | 474 | /> |
| 446 | weight="medium" | 475 | </View> |
| 447 | /> | 476 | ) : ( |
| 448 | </Pressable> | 477 | <Pressable |
| 478 | className={ | ||
| 479 | busy | ||
| 480 | ? "absolute right-3 top-4 z-[1] h-9 w-9 items-center justify-center rounded-full bg-subtle opacity-[0.45]" | ||
| 481 | : "absolute right-3 top-4 z-[1] h-9 w-9 items-center justify-center rounded-full bg-subtle" | ||
| 482 | } | ||
| 483 | disabled={busy} | ||
| 484 | onPress={() => void gitActions.refreshSelectedThreadGitStatus()} | ||
| 485 | > | ||
| 486 | <SymbolView | ||
| 487 | name="arrow.clockwise" | ||
| 488 | size={16} | ||
| 489 | tintColorClassName="accent-icon" | ||
| 490 | type="monochrome" | ||
| 491 | weight="medium" | ||
| 492 | /> | ||
| 493 | </Pressable> | ||
| 494 | )} | ||
| 449 | <Text className="text-xs font-t3-bold tracking-[1px] uppercase text-foreground-muted"> | 495 | <Text className="text-xs font-t3-bold tracking-[1px] uppercase text-foreground-muted"> |
| 450 | Repository | 496 | Repository |
| 451 | </Text> | 497 | </Text> |
| ... | @@ -457,6 +503,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { | ... | @@ -457,6 +503,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 457 | ) : ( | 503 | ) : ( |
| 458 | <AndroidSheetHeader | 504 | <AndroidSheetHeader |
| 459 | title={currentBranchLabel} | 505 | title={currentBranchLabel} |
| 506 | hideBottomBorder={Platform.OS === "android"} | ||
| 460 | subtitle={currentStatusSummary} | 507 | subtitle={currentStatusSummary} |
| 461 | onBack={() => navigation.goBack()} | 508 | onBack={() => navigation.goBack()} |
| 462 | actions={[ | 509 | actions={[ |
| ... | @@ -470,7 +517,9 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { | ... | @@ -470,7 +517,9 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 470 | /> | 517 | /> |
| 471 | )} | 518 | )} |
| 472 | 519 | ||
| 473 | {content} | 520 | <MaterialScreenContent insetHorizontal={layout.usesSplitView}> |
| 521 | {content} | ||
| 522 | </MaterialScreenContent> | ||
| 474 | </View> | 523 | </View> |
| 475 | ); | 524 | ); |
| 476 | } | 525 | } |
apps/mobile/src/features/threads/git/gitSheetComponents.tsx+58-19| ... | @@ -1,7 +1,8 @@ | ... | @@ -1,7 +1,8 @@ |
| 1 | import { SymbolView } from "../../../components/AppSymbol"; | 1 | import { SymbolView } from "../../../components/AppSymbol"; |
| 2 | import type { ComponentProps } from "react"; | 2 | import type { ComponentProps } from "react"; |
| 3 | import { Pressable, View } from "react-native"; | 3 | import { Platform, Pressable, View } from "react-native"; |
| 4 | import { AppText as Text } from "../../../components/AppText"; | 4 | import { AppText as Text } from "../../../components/AppText"; |
| 5 | import { MaterialButton } from "../../../components/MaterialButton"; | ||
| 5 | import { cn } from "../../../lib/cn"; | 6 | import { cn } from "../../../lib/cn"; |
| 6 | 7 | ||
| 7 | /* ─── Shared sheet components ──────────────────────────────────────── */ | 8 | /* ─── Shared sheet components ──────────────────────────────────────── */ |
| ... | @@ -13,6 +14,18 @@ export function SheetActionButton(props: { | ... | @@ -13,6 +14,18 @@ export function SheetActionButton(props: { |
| 13 | readonly tone?: "primary" | "secondary" | "danger"; | 14 | readonly tone?: "primary" | "secondary" | "danger"; |
| 14 | readonly onPress: () => void; | 15 | readonly onPress: () => void; |
| 15 | }) { | 16 | }) { |
| 17 | if (Platform.OS === "android") | ||
| 18 | return ( | ||
| 19 | <View className="flex-1"> | ||
| 20 | <MaterialButton | ||
| 21 | label={props.label} | ||
| 22 | tone={props.tone} | ||
| 23 | disabled={props.disabled} | ||
| 24 | onPress={props.onPress} | ||
| 25 | fullWidth | ||
| 26 | /> | ||
| 27 | </View> | ||
| 28 | ); | ||
| 16 | const tone = props.tone ?? "secondary"; | 29 | const tone = props.tone ?? "secondary"; |
| 17 | const textColorClassName = | 30 | const textColorClassName = |
| 18 | tone === "primary" | 31 | tone === "primary" |
| ... | @@ -24,13 +37,13 @@ export function SheetActionButton(props: { | ... | @@ -24,13 +37,13 @@ export function SheetActionButton(props: { |
| 24 | return ( | 37 | return ( |
| 25 | <Pressable | 38 | <Pressable |
| 26 | className={cn( | 39 | className={cn( |
| 27 | "min-h-[48px] flex-1 flex-row items-center justify-center gap-2 rounded-[18px] px-4 py-3 disabled:opacity-[0.45]", | 40 | "min-h-[48px] flex-row items-center justify-center gap-2 px-4 py-3 disabled:opacity-[0.45]", |
| 28 | tone === "primary" | 41 | "flex-1 rounded-[18px]", |
| 29 | ? "bg-primary" | 42 | tone === "primary" ? "bg-primary" : tone === "danger" ? "bg-danger" : "bg-secondary", |
| 30 | : tone === "danger" | 43 | tone !== "primary" && |
| 31 | ? "border border-danger-border bg-danger" | 44 | (tone === "danger" ? "border border-danger-border" : "border border-secondary-border"), |
| 32 | : "border border-secondary-border bg-secondary", | ||
| 33 | )} | 45 | )} |
| 46 | accessibilityRole="button" | ||
| 34 | disabled={props.disabled} | 47 | disabled={props.disabled} |
| 35 | onPress={props.onPress} | 48 | onPress={props.onPress} |
| 36 | > | 49 | > |
| ... | @@ -58,7 +71,13 @@ export function SheetActionButton(props: { | ... | @@ -58,7 +71,13 @@ export function SheetActionButton(props: { |
| 58 | 71 | ||
| 59 | export function MetaCard(props: { readonly label: string; readonly value: string }) { | 72 | export function MetaCard(props: { readonly label: string; readonly value: string }) { |
| 60 | return ( | 73 | return ( |
| 61 | <View className="rounded-[18px] border border-border bg-card px-4 py-3"> | 74 | <View |
| 75 | className={ | ||
| 76 | Platform.OS === "android" | ||
| 77 | ? "rounded-[20px] bg-card px-4 py-3" | ||
| 78 | : "rounded-[18px] border border-border bg-card px-4 py-3" | ||
| 79 | } | ||
| 80 | > | ||
| 62 | <Text className="text-foreground-muted text-2xs font-t3-bold tracking-[0.9px] uppercase"> | 81 | <Text className="text-foreground-muted text-2xs font-t3-bold tracking-[0.9px] uppercase"> |
| 63 | {props.label} | 82 | {props.label} |
| 64 | </Text> | 83 | </Text> |
| ... | @@ -78,30 +97,50 @@ export function SheetListRow(props: { | ... | @@ -78,30 +97,50 @@ export function SheetListRow(props: { |
| 78 | }) { | 97 | }) { |
| 79 | return ( | 98 | return ( |
| 80 | <Pressable | 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 | disabled={props.disabled} | 105 | disabled={props.disabled} |
| 83 | onPress={props.onPress} | 106 | onPress={props.onPress} |
| 84 | > | 107 | > |
| 85 | <View className="bg-subtle h-9 w-9 items-center justify-center rounded-full"> | 108 | <View |
| 109 | className={ | ||
| 110 | Platform.OS === "android" | ||
| 111 | ? "size-6 items-center justify-center" | ||
| 112 | : "bg-subtle h-9 w-9 items-center justify-center rounded-full" | ||
| 113 | } | ||
| 114 | > | ||
| 86 | <SymbolView | 115 | <SymbolView |
| 87 | name={props.icon} | 116 | name={props.icon} |
| 88 | size={16} | 117 | size={Platform.OS === "android" ? 24 : 16} |
| 89 | tintColorClassName={"accent-icon"} | 118 | tintColorClassName="accent-icon" |
| 90 | type="monochrome" | 119 | type="monochrome" |
| 91 | /> | 120 | /> |
| 92 | </View> | 121 | </View> |
| 93 | <View className="flex-1 gap-0.5"> | 122 | <View className="flex-1 gap-0.5"> |
| 94 | <Text className="text-foreground text-base font-t3-bold">{props.title}</Text> | 123 | <Text |
| 124 | className={ | ||
| 125 | Platform.OS === "android" | ||
| 126 | ? "text-foreground text-base font-t3-medium" | ||
| 127 | : "text-foreground text-base font-t3-bold" | ||
| 128 | } | ||
| 129 | > | ||
| 130 | {props.title} | ||
| 131 | </Text> | ||
| 95 | {props.subtitle ? ( | 132 | {props.subtitle ? ( |
| 96 | <Text className="text-foreground-muted text-xs leading-snug">{props.subtitle}</Text> | 133 | <Text className="text-foreground-muted text-xs leading-snug">{props.subtitle}</Text> |
| 97 | ) : null} | 134 | ) : null} |
| 98 | </View> | 135 | </View> |
| 99 | <SymbolView | 136 | {Platform.OS !== "android" ? ( |
| 100 | name="chevron.right" | 137 | <SymbolView |
| 101 | size={13} | 138 | name="chevron.right" |
| 102 | tintColorClassName={"accent-icon-subtle"} | 139 | size={13} |
| 103 | type="monochrome" | 140 | tintColorClassName="accent-icon-subtle" |
| 104 | /> | 141 | type="monochrome" |
| 142 | /> | ||
| 143 | ) : null} | ||
| 105 | </Pressable> | 144 | </Pressable> |
| 106 | ); | 145 | ); |
| 107 | } | 146 | } |
apps/mobile/src/features/threads/thread-list-items.tsx+43-36| ... | @@ -183,7 +183,7 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: | ... | @@ -183,7 +183,7 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: |
| 183 | <SymbolView | 183 | <SymbolView |
| 184 | name="plus" | 184 | name="plus" |
| 185 | size={compact ? 20 : 16} | 185 | size={compact ? 20 : 16} |
| 186 | tintColorClassName={"accent-icon-muted"} | 186 | tintColorClassName="accent-icon-muted" |
| 187 | type="monochrome" | 187 | type="monochrome" |
| 188 | weight="medium" | 188 | weight="medium" |
| 189 | /> | 189 | /> |
| ... | @@ -232,7 +232,7 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: | ... | @@ -232,7 +232,7 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: |
| 232 | <SymbolView | 232 | <SymbolView |
| 233 | name={icon} | 233 | name={icon} |
| 234 | size={10} | 234 | size={10} |
| 235 | tintColorClassName={"accent-icon-subtle"} | 235 | tintColorClassName="accent-icon-subtle" |
| 236 | type="monochrome" | 236 | type="monochrome" |
| 237 | weight="semibold" | 237 | weight="semibold" |
| 238 | /> | 238 | /> |
| ... | @@ -379,7 +379,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { | ... | @@ -379,7 +379,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { |
| 379 | <SymbolView | 379 | <SymbolView |
| 380 | name="chevron.right" | 380 | name="chevron.right" |
| 381 | size={13} | 381 | size={13} |
| 382 | tintColorClassName={"accent-icon-subtle"} | 382 | tintColorClassName="accent-icon-subtle" |
| 383 | type="monochrome" | 383 | type="monochrome" |
| 384 | /> | 384 | /> |
| 385 | </View> | 385 | </View> |
| ... | @@ -469,10 +469,10 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -469,10 +469,10 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 469 | >["simultaneousWithExternalGesture"]; | 469 | >["simultaneousWithExternalGesture"]; |
| 470 | }) { | 470 | }) { |
| 471 | const { width: windowWidth } = useWindowDimensions(); | 471 | const { width: windowWidth } = useWindowDimensions(); |
| 472 | const { themeAppearance: colorScheme, materialYouStyleLayoutActive } = useAppearancePreferences(); | 472 | const { themeAppearance: colorScheme } = useAppearancePreferences(); |
| 473 | const compact = props.variant === "compact"; | 473 | const compact = props.variant === "compact"; |
| 474 | const selected = props.selected === true; | 474 | const selected = props.selected === true; |
| 475 | const visuallySelected = selected && (!compact || materialYouStyleLayoutActive); | 475 | const visuallySelected = selected && (!compact || Platform.OS === "android"); |
| 476 | const theme = useUniwindTheme(); | 476 | const theme = useUniwindTheme(); |
| 477 | const screenColor = theme["--color-screen"]; | 477 | const screenColor = theme["--color-screen"]; |
| 478 | const drawerColor = theme["--color-drawer"]; | 478 | const drawerColor = theme["--color-drawer"]; |
| ... | @@ -507,22 +507,22 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -507,22 +507,22 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 507 | ); | 507 | ); |
| 508 | 508 | ||
| 509 | const backgroundColor = compact ? screenColor : drawerColor; | 509 | const backgroundColor = compact ? screenColor : drawerColor; |
| 510 | const effectiveSelectedBackground = materialYouStyleLayoutActive | 510 | const effectiveSelectedBackground = |
| 511 | ? materialSelectedBackgroundColor | 511 | Platform.OS === "android" ? materialSelectedBackgroundColor : selectedBackgroundColor; |
| 512 | : selectedBackgroundColor; | 512 | const effectiveSelectedForeground = |
| 513 | const effectiveSelectedForeground = materialYouStyleLayoutActive | 513 | Platform.OS === "android" ? materialSelectedForegroundColor : selectedForegroundColor; |
| 514 | ? materialSelectedForegroundColor | ||
| 515 | : selectedForegroundColor; | ||
| 516 | const effectiveStatus = | 514 | const effectiveStatus = |
| 517 | visuallySelected && status | 515 | visuallySelected && status |
| 518 | ? { | 516 | ? { |
| 519 | ...status, | 517 | ...status, |
| 520 | pillClassName: materialYouStyleLayoutActive | 518 | pillClassName: |
| 521 | ? "bg-thread-selected-foreground/20" | 519 | Platform.OS === "android" |
| 522 | : "bg-user-bubble-foreground/20", | 520 | ? "bg-thread-selected-foreground/20" |
| 523 | textClassName: materialYouStyleLayoutActive | 521 | : "bg-user-bubble-foreground/20", |
| 524 | ? "text-thread-selected-foreground" | 522 | textClassName: |
| 525 | : "text-user-bubble-foreground", | 523 | Platform.OS === "android" |
| 524 | ? "text-thread-selected-foreground" | ||
| 525 | : "text-user-bubble-foreground", | ||
| 526 | } | 526 | } |
| 527 | : status; | 527 | : status; |
| 528 | 528 | ||
| ... | @@ -596,7 +596,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -596,7 +596,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 596 | compact | 596 | compact |
| 597 | ? "accent-icon-subtle" | 597 | ? "accent-icon-subtle" |
| 598 | : selected | 598 | : selected |
| 599 | ? materialYouStyleLayoutActive | 599 | ? Platform.OS === "android" |
| 600 | ? "accent-thread-selected-foreground-muted" | 600 | ? "accent-thread-selected-foreground-muted" |
| 601 | : "accent-user-bubble-foreground-muted" | 601 | : "accent-user-bubble-foreground-muted" |
| 602 | : "accent-foreground-muted" | 602 | : "accent-foreground-muted" |
| ... | @@ -608,7 +608,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -608,7 +608,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 608 | "shrink", | 608 | "shrink", |
| 609 | compact ? "text-sm" : "text-xs", | 609 | compact ? "text-sm" : "text-xs", |
| 610 | visuallySelected | 610 | visuallySelected |
| 611 | ? materialYouStyleLayoutActive | 611 | ? Platform.OS === "android" |
| 612 | ? "text-thread-selected-foreground-muted" | 612 | ? "text-thread-selected-foreground-muted" |
| 613 | : "text-user-bubble-foreground-muted" | 613 | : "text-user-bubble-foreground-muted" |
| 614 | : "text-foreground-muted", | 614 | : "text-foreground-muted", |
| ... | @@ -647,7 +647,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -647,7 +647,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 647 | <Text | 647 | <Text |
| 648 | className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${ | 648 | className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${ |
| 649 | visuallySelected | 649 | visuallySelected |
| 650 | ? materialYouStyleLayoutActive | 650 | ? Platform.OS === "android" |
| 651 | ? "text-thread-selected-foreground" | 651 | ? "text-thread-selected-foreground" |
| 652 | : "text-user-bubble-foreground" | 652 | : "text-user-bubble-foreground" |
| 653 | : pr.textClassName | 653 | : pr.textClassName |
| ... | @@ -666,7 +666,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -666,7 +666,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 666 | key={`${thread.environmentId}:${thread.id}`} | 666 | key={`${thread.environmentId}:${thread.id}`} |
| 667 | interactionClassName={ | 667 | interactionClassName={ |
| 668 | visuallySelected | 668 | visuallySelected |
| 669 | ? materialYouStyleLayoutActive | 669 | ? Platform.OS === "android" |
| 670 | ? "bg-thread-selected-foreground" | 670 | ? "bg-thread-selected-foreground" |
| 671 | : "bg-user-bubble-foreground" | 671 | : "bg-user-bubble-foreground" |
| 672 | : "bg-primary" | 672 | : "bg-primary" |
| ... | @@ -676,10 +676,10 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -676,10 +676,10 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 676 | accessibilityRole="button" | 676 | accessibilityRole="button" |
| 677 | className="bg-screen" | 677 | className="bg-screen" |
| 678 | style={ | 678 | style={ |
| 679 | materialYouStyleLayoutActive | 679 | Platform.OS === "android" |
| 680 | ? { | 680 | ? { |
| 681 | backgroundColor: visuallySelected ? effectiveSelectedBackground : backgroundColor, | 681 | backgroundColor: visuallySelected ? effectiveSelectedBackground : backgroundColor, |
| 682 | borderRadius: SIDEBAR_ROW_RADIUS, | 682 | borderRadius: 20, |
| 683 | } | 683 | } |
| 684 | : undefined | 684 | : undefined |
| 685 | } | 685 | } |
| ... | @@ -689,13 +689,18 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -689,13 +689,18 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 689 | }} | 689 | }} |
| 690 | > | 690 | > |
| 691 | <View className="pr-[18px] pt-[10px]" style={{ paddingLeft: THREAD_LIST_COMPACT_INSET }}> | 691 | <View className="pr-[18px] pt-[10px]" style={{ paddingLeft: THREAD_LIST_COMPACT_INSET }}> |
| 692 | <View className={cn("gap-[3px] pb-[10px]", !props.isLast && "border-b border-separator")}> | 692 | <View |
| 693 | className={cn( | ||
| 694 | "gap-[3px] pb-[10px]", | ||
| 695 | Platform.OS !== "android" && !props.isLast && "border-b border-separator", | ||
| 696 | )} | ||
| 697 | > | ||
| 693 | <View className="flex-row items-center justify-between gap-2"> | 698 | <View className="flex-row items-center justify-between gap-2"> |
| 694 | <Text | 699 | <Text |
| 695 | className={cn( | 700 | className={cn( |
| 696 | "flex-1 text-lg font-t3-bold", | 701 | "flex-1 text-lg font-t3-bold", |
| 697 | visuallySelected | 702 | visuallySelected |
| 698 | ? materialYouStyleLayoutActive | 703 | ? Platform.OS === "android" |
| 699 | ? "text-thread-selected-foreground" | 704 | ? "text-thread-selected-foreground" |
| 700 | : "text-user-bubble-foreground" | 705 | : "text-user-bubble-foreground" |
| 701 | : "text-foreground", | 706 | : "text-foreground", |
| ... | @@ -706,14 +711,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -706,14 +711,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 706 | </Text> | 711 | </Text> |
| 707 | <View className="flex-row items-center gap-2"> | 712 | <View className="flex-row items-center gap-2"> |
| 708 | {props.hasQueuedMessages ? ( | 713 | {props.hasQueuedMessages ? ( |
| 709 | <QueuedMessageIcon selected={visuallySelected && !materialYouStyleLayoutActive} /> | 714 | <QueuedMessageIcon selected={visuallySelected && Platform.OS !== "android"} /> |
| 710 | ) : null} | 715 | ) : null} |
| 711 | {statusPill} | 716 | {statusPill} |
| 712 | <Text | 717 | <Text |
| 713 | className={cn( | 718 | className={cn( |
| 714 | "text-base tabular-nums", | 719 | "text-base tabular-nums", |
| 715 | visuallySelected | 720 | visuallySelected |
| 716 | ? materialYouStyleLayoutActive | 721 | ? Platform.OS === "android" |
| 717 | ? "text-thread-selected-foreground-muted" | 722 | ? "text-thread-selected-foreground-muted" |
| 718 | : "text-user-bubble-foreground-muted" | 723 | : "text-user-bubble-foreground-muted" |
| 719 | : "text-foreground-tertiary", | 724 | : "text-foreground-tertiary", |
| ... | @@ -724,7 +729,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -724,7 +729,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 724 | <SymbolView | 729 | <SymbolView |
| 725 | name="chevron.right" | 730 | name="chevron.right" |
| 726 | size={13} | 731 | size={13} |
| 727 | tintColorClassName={"accent-icon-subtle"} | 732 | tintColorClassName="accent-icon-subtle" |
| 728 | type="monochrome" | 733 | type="monochrome" |
| 729 | /> | 734 | /> |
| 730 | </View> | 735 | </View> |
| ... | @@ -746,7 +751,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -746,7 +751,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 746 | key={`${thread.environmentId}:${thread.id}`} | 751 | key={`${thread.environmentId}:${thread.id}`} |
| 747 | interactionClassName={ | 752 | interactionClassName={ |
| 748 | visuallySelected | 753 | visuallySelected |
| 749 | ? materialYouStyleLayoutActive | 754 | ? Platform.OS === "android" |
| 750 | ? "bg-thread-selected-foreground" | 755 | ? "bg-thread-selected-foreground" |
| 751 | : "bg-user-bubble-foreground" | 756 | : "bg-user-bubble-foreground" |
| 752 | : "bg-primary" | 757 | : "bg-primary" |
| ... | @@ -761,7 +766,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -761,7 +766,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 761 | }} | 766 | }} |
| 762 | style={{ | 767 | style={{ |
| 763 | backgroundColor: visuallySelected ? effectiveSelectedBackground : backgroundColor, | 768 | backgroundColor: visuallySelected ? effectiveSelectedBackground : backgroundColor, |
| 764 | borderRadius: SIDEBAR_ROW_RADIUS, | 769 | borderRadius: Platform.OS === "android" ? 20 : SIDEBAR_ROW_RADIUS, |
| 765 | minHeight: 64, | 770 | minHeight: 64, |
| 766 | justifyContent: "center", | 771 | justifyContent: "center", |
| 767 | paddingHorizontal: 12, | 772 | paddingHorizontal: 12, |
| ... | @@ -774,7 +779,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -774,7 +779,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 774 | className={cn( | 779 | className={cn( |
| 775 | "flex-1 text-base font-t3-medium", | 780 | "flex-1 text-base font-t3-medium", |
| 776 | visuallySelected | 781 | visuallySelected |
| 777 | ? materialYouStyleLayoutActive | 782 | ? Platform.OS === "android" |
| 778 | ? "text-thread-selected-foreground" | 783 | ? "text-thread-selected-foreground" |
| 779 | : "text-user-bubble-foreground" | 784 | : "text-user-bubble-foreground" |
| 780 | : "text-foreground", | 785 | : "text-foreground", |
| ... | @@ -785,14 +790,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -785,14 +790,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 785 | </Text> | 790 | </Text> |
| 786 | <View className="flex-row items-center gap-2"> | 791 | <View className="flex-row items-center gap-2"> |
| 787 | {props.hasQueuedMessages ? ( | 792 | {props.hasQueuedMessages ? ( |
| 788 | <QueuedMessageIcon selected={visuallySelected && !materialYouStyleLayoutActive} /> | 793 | <QueuedMessageIcon selected={visuallySelected && Platform.OS !== "android"} /> |
| 789 | ) : null} | 794 | ) : null} |
| 790 | {statusPill} | 795 | {statusPill} |
| 791 | <Text | 796 | <Text |
| 792 | className={cn( | 797 | className={cn( |
| 793 | "text-xs tabular-nums", | 798 | "text-xs tabular-nums", |
| 794 | visuallySelected | 799 | visuallySelected |
| 795 | ? materialYouStyleLayoutActive | 800 | ? Platform.OS === "android" |
| 796 | ? "text-thread-selected-foreground-muted" | 801 | ? "text-thread-selected-foreground-muted" |
| 797 | : "text-user-bubble-foreground-muted" | 802 | : "text-user-bubble-foreground-muted" |
| 798 | : "text-foreground-muted", | 803 | : "text-foreground-muted", |
| ... | @@ -820,9 +825,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -820,9 +825,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 820 | threadKey={`${thread.environmentId}:${thread.id}`} | 825 | threadKey={`${thread.environmentId}:${thread.id}`} |
| 821 | backgroundColor={backgroundColor} | 826 | backgroundColor={backgroundColor} |
| 822 | containerStyle={ | 827 | containerStyle={ |
| 823 | compact && !materialYouStyleLayoutActive | 828 | Platform.OS === "android" |
| 824 | ? undefined | 829 | ? { borderRadius: 20, overflow: "hidden", marginHorizontal: 8, marginVertical: 2 } |
| 825 | : { borderRadius: SIDEBAR_ROW_RADIUS, overflow: "hidden" } | 830 | : compact |
| 831 | ? undefined | ||
| 832 | : { borderRadius: SIDEBAR_ROW_RADIUS, overflow: "hidden" } | ||
| 826 | } | 833 | } |
| 827 | enableTrackpadSwipe | 834 | enableTrackpadSwipe |
| 828 | fullSwipeWidth={props.fullSwipeWidth ?? windowWidth - 32} | 835 | fullSwipeWidth={props.fullSwipeWidth ?? windowWidth - 32} |
apps/mobile/src/features/threads/thread-list-v2-items.tsx+42-36| ... | @@ -1,6 +1,5 @@ | ... | @@ -1,6 +1,5 @@ |
| 1 | import { RowPressable } from "../../components/RowPressable"; | 1 | import { RowPressable } from "../../components/RowPressable"; |
| 2 | import { CustomSnoozeSheet } from "./CustomSnoozeSheet"; | 2 | import { CustomSnoozeSheet } from "./CustomSnoozeSheet"; |
| 3 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 4 | import { appAtomRegistry } from "../../state/atom-registry"; | 3 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 5 | import { threadArrangementOpenAtom } from "../../state/thread-order"; | 4 | import { threadArrangementOpenAtom } from "../../state/thread-order"; |
| 6 | import type { ThreadMoveDestination } from "./threadOrder"; | 5 | import type { ThreadMoveDestination } from "./threadOrder"; |
| ... | @@ -182,7 +181,7 @@ export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledS | ... | @@ -182,7 +181,7 @@ export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledS |
| 182 | <SymbolView | 181 | <SymbolView |
| 183 | name="chevron.down" | 182 | name="chevron.down" |
| 184 | size={10} | 183 | size={10} |
| 185 | tintColorClassName={"accent-foreground-muted"} | 184 | tintColorClassName="accent-foreground-muted" |
| 186 | type="monochrome" | 185 | type="monochrome" |
| 187 | style={{ transform: [{ rotate: props.expanded ? "180deg" : "0deg" }] }} | 186 | style={{ transform: [{ rotate: props.expanded ? "180deg" : "0deg" }] }} |
| 188 | /> | 187 | /> |
| ... | @@ -441,12 +440,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -441,12 +440,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 441 | 440 | ||
| 442 | const pr = useThreadPr(thread); | 441 | const pr = useThreadPr(thread); |
| 443 | 442 | ||
| 444 | const { materialYouStyleLayoutActive } = useAppearancePreferences(); | ||
| 445 | const theme = useUniwindTheme(); | 443 | const theme = useUniwindTheme(); |
| 446 | const screenColor = theme["--color-screen"]; | 444 | const screenColor = theme["--color-screen"]; |
| 447 | const drawerColor = theme["--color-drawer"]; | 445 | const drawerColor = theme["--color-drawer"]; |
| 448 | const selectedBackgroundColor = | 446 | const selectedBackgroundColor = |
| 449 | theme[materialYouStyleLayoutActive ? "--color-thread-selected" : "--color-user-bubble"]; | 447 | theme[Platform.OS === "android" ? "--color-thread-selected" : "--color-user-bubble"]; |
| 450 | const sidebarPane = props.pane === "sidebar"; | 448 | const sidebarPane = props.pane === "sidebar"; |
| 451 | const selected = props.selected === true; | 449 | const selected = props.selected === true; |
| 452 | // The provider badge's border blends into the row's own surface, which | 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,7 +454,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 456 | const providerIconSurfaceColor = sidebarPane | 454 | const providerIconSurfaceColor = sidebarPane |
| 457 | ? selected | 455 | ? selected |
| 458 | ? selectedBackgroundColor | 456 | ? selectedBackgroundColor |
| 459 | : drawerColor | 457 | : Platform.OS === "android" |
| 458 | ? screenColor | ||
| 459 | : drawerColor | ||
| 460 | : screenColor; | 460 | : screenColor; |
| 461 | 461 | ||
| 462 | const status = resolveThreadListV2Status(thread); | 462 | const status = resolveThreadListV2Status(thread); |
| ... | @@ -750,7 +750,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -750,7 +750,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 750 | className={cn( | 750 | className={cn( |
| 751 | "flex-1 text-sm font-t3-medium", | 751 | "flex-1 text-sm font-t3-medium", |
| 752 | selected | 752 | selected |
| 753 | ? materialYouStyleLayoutActive | 753 | ? Platform.OS === "android" |
| 754 | ? "text-thread-selected-foreground-muted" | 754 | ? "text-thread-selected-foreground-muted" |
| 755 | : "text-user-bubble-foreground-muted" | 755 | : "text-user-bubble-foreground-muted" |
| 756 | : "text-foreground-muted", | 756 | : "text-foreground-muted", |
| ... | @@ -764,7 +764,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -764,7 +764,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 764 | <SymbolView | 764 | <SymbolView |
| 765 | name="pin" | 765 | name="pin" |
| 766 | size={11} | 766 | size={11} |
| 767 | tintColorClassName={"accent-foreground-muted"} | 767 | tintColorClassName="accent-foreground-muted" |
| 768 | type="monochrome" | 768 | type="monochrome" |
| 769 | /> | 769 | /> |
| 770 | ) : null} | 770 | ) : null} |
| ... | @@ -772,7 +772,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -772,7 +772,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 772 | className={cn( | 772 | className={cn( |
| 773 | "text-xs tabular-nums", | 773 | "text-xs tabular-nums", |
| 774 | selected | 774 | selected |
| 775 | ? materialYouStyleLayoutActive | 775 | ? Platform.OS === "android" |
| 776 | ? "text-thread-selected-foreground" | 776 | ? "text-thread-selected-foreground" |
| 777 | : "text-user-bubble-foreground" | 777 | : "text-user-bubble-foreground" |
| 778 | : (statusLabel?.className ?? "text-foreground-tertiary"), | 778 | : (statusLabel?.className ?? "text-foreground-tertiary"), |
| ... | @@ -785,7 +785,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -785,7 +785,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 785 | className={cn( | 785 | className={cn( |
| 786 | "mt-1 text-base font-t3-medium", | 786 | "mt-1 text-base font-t3-medium", |
| 787 | selected | 787 | selected |
| 788 | ? materialYouStyleLayoutActive | 788 | ? Platform.OS === "android" |
| 789 | ? "text-thread-selected-foreground" | 789 | ? "text-thread-selected-foreground" |
| 790 | : "text-user-bubble-foreground" | 790 | : "text-user-bubble-foreground" |
| 791 | : "text-foreground", | 791 | : "text-foreground", |
| ... | @@ -809,7 +809,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -809,7 +809,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 809 | className={cn( | 809 | className={cn( |
| 810 | "flex-1 text-xs", | 810 | "flex-1 text-xs", |
| 811 | selected | 811 | selected |
| 812 | ? materialYouStyleLayoutActive | 812 | ? Platform.OS === "android" |
| 813 | ? "text-thread-selected-foreground-muted" | 813 | ? "text-thread-selected-foreground-muted" |
| 814 | : "text-user-bubble-foreground-muted" | 814 | : "text-user-bubble-foreground-muted" |
| 815 | : "text-danger-foreground", | 815 | : "text-danger-foreground", |
| ... | @@ -830,7 +830,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -830,7 +830,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 830 | className={cn( | 830 | className={cn( |
| 831 | "shrink text-xs", | 831 | "shrink text-xs", |
| 832 | selected | 832 | selected |
| 833 | ? materialYouStyleLayoutActive | 833 | ? Platform.OS === "android" |
| 834 | ? "text-thread-selected-foreground-muted" | 834 | ? "text-thread-selected-foreground-muted" |
| 835 | : "text-user-bubble-foreground-muted" | 835 | : "text-user-bubble-foreground-muted" |
| 836 | : "text-foreground-muted", | 836 | : "text-foreground-muted", |
| ... | @@ -842,7 +842,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -842,7 +842,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 842 | className={cn( | 842 | className={cn( |
| 843 | "text-xs", | 843 | "text-xs", |
| 844 | selected | 844 | selected |
| 845 | ? materialYouStyleLayoutActive | 845 | ? Platform.OS === "android" |
| 846 | ? "text-thread-selected-foreground-muted" | 846 | ? "text-thread-selected-foreground-muted" |
| 847 | : "text-user-bubble-foreground-muted" | 847 | : "text-user-bubble-foreground-muted" |
| 848 | : "text-foreground-muted", | 848 | : "text-foreground-muted", |
| ... | @@ -858,7 +858,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -858,7 +858,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 858 | className={cn( | 858 | className={cn( |
| 859 | "text-xs", | 859 | "text-xs", |
| 860 | selected | 860 | selected |
| 861 | ? materialYouStyleLayoutActive | 861 | ? Platform.OS === "android" |
| 862 | ? "text-thread-selected-foreground-muted" | 862 | ? "text-thread-selected-foreground-muted" |
| 863 | : "text-user-bubble-foreground-muted" | 863 | : "text-user-bubble-foreground-muted" |
| 864 | : "text-foreground-tertiary", | 864 | : "text-foreground-tertiary", |
| ... | @@ -874,7 +874,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -874,7 +874,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 874 | size={11} | 874 | size={11} |
| 875 | tintColorClassName={ | 875 | tintColorClassName={ |
| 876 | selected | 876 | selected |
| 877 | ? materialYouStyleLayoutActive | 877 | ? Platform.OS === "android" |
| 878 | ? "accent-thread-selected-foreground-muted" | 878 | ? "accent-thread-selected-foreground-muted" |
| 879 | : "accent-user-bubble-foreground-muted" | 879 | : "accent-user-bubble-foreground-muted" |
| 880 | : "accent-foreground-tertiary" | 880 | : "accent-foreground-tertiary" |
| ... | @@ -893,7 +893,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -893,7 +893,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 893 | size={12} | 893 | size={12} |
| 894 | tintColorClassName={ | 894 | tintColorClassName={ |
| 895 | selected | 895 | selected |
| 896 | ? materialYouStyleLayoutActive | 896 | ? Platform.OS === "android" |
| 897 | ? "accent-thread-selected-foreground" | 897 | ? "accent-thread-selected-foreground" |
| 898 | : "accent-user-bubble-foreground" | 898 | : "accent-user-bubble-foreground" |
| 899 | : pr.state === null || pr.isDraft | 899 | : pr.state === null || pr.isDraft |
| ... | @@ -911,7 +911,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -911,7 +911,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 911 | className={cn( | 911 | className={cn( |
| 912 | "text-xs", | 912 | "text-xs", |
| 913 | selected | 913 | selected |
| 914 | ? materialYouStyleLayoutActive | 914 | ? Platform.OS === "android" |
| 915 | ? "text-thread-selected-foreground" | 915 | ? "text-thread-selected-foreground" |
| 916 | : "text-user-bubble-foreground" | 916 | : "text-user-bubble-foreground" |
| 917 | : pr.textClassName, | 917 | : pr.textClassName, |
| ... | @@ -941,13 +941,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -941,13 +941,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 941 | <RowPressable | 941 | <RowPressable |
| 942 | key={`${thread.environmentId}:${thread.id}`} | 942 | key={`${thread.environmentId}:${thread.id}`} |
| 943 | interactionClassName={ | 943 | interactionClassName={ |
| 944 | selected && (sidebarPane || materialYouStyleLayoutActive) | 944 | selected && (sidebarPane || Platform.OS === "android") |
| 945 | ? materialYouStyleLayoutActive | 945 | ? Platform.OS === "android" |
| 946 | ? "bg-thread-selected-foreground" | 946 | ? "bg-thread-selected-foreground" |
| 947 | : "bg-user-bubble-foreground" | 947 | : "bg-user-bubble-foreground" |
| 948 | : "bg-primary" | 948 | : "bg-primary" |
| 949 | } | 949 | } |
| 950 | className={sidebarPane || materialYouStyleLayoutActive ? undefined : "bg-screen"} | 950 | className={sidebarPane || Platform.OS === "android" ? undefined : "bg-screen"} |
| 951 | accessibilityHint={swipeAccessibilityHint} | 951 | accessibilityHint={swipeAccessibilityHint} |
| 952 | accessibilityLabel={ | 952 | accessibilityLabel={ |
| 953 | props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title | 953 | props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title |
| ... | @@ -959,14 +959,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -959,14 +959,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 959 | onSelectThread(thread); | 959 | onSelectThread(thread); |
| 960 | }} | 960 | }} |
| 961 | style={ | 961 | style={ |
| 962 | sidebarPane || materialYouStyleLayoutActive | 962 | sidebarPane || Platform.OS === "android" |
| 963 | ? { | 963 | ? { |
| 964 | backgroundColor: selected | 964 | backgroundColor: selected |
| 965 | ? selectedBackgroundColor | 965 | ? selectedBackgroundColor |
| 966 | : sidebarPane | 966 | : sidebarPane && Platform.OS !== "android" |
| 967 | ? drawerColor | 967 | ? drawerColor |
| 968 | : screenColor, | 968 | : screenColor, |
| 969 | borderRadius: SIDEBAR_V2_ROW_RADIUS, | 969 | borderRadius: Platform.OS === "android" ? 20 : SIDEBAR_V2_ROW_RADIUS, |
| 970 | ...(sidebarPane ? { paddingHorizontal: 12, paddingVertical: 10 } : null), | 970 | ...(sidebarPane ? { paddingHorizontal: 12, paddingVertical: 10 } : null), |
| 971 | } | 971 | } |
| 972 | : undefined | 972 | : undefined |
| ... | @@ -980,8 +980,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -980,8 +980,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 980 | separates rows. The opaque screen background stays so swipe | 980 | separates rows. The opaque screen background stays so swipe |
| 981 | actions reveal behind the row. */ | 981 | actions reveal behind the row. */ |
| 982 | <View> | 982 | <View> |
| 983 | <View className="px-5 py-2.5">{cardContent}</View> | 983 | <View className={Platform.OS === "android" ? "px-3 py-2.5" : "px-5 py-2.5"}> |
| 984 | {props.showTrailingDivider !== false ? ( | 984 | {cardContent} |
| 985 | </View> | ||
| 986 | {Platform.OS !== "android" && props.showTrailingDivider !== false ? ( | ||
| 985 | <View className="ml-5 h-px bg-border-subtle" /> | 987 | <View className="ml-5 h-px bg-border-subtle" /> |
| 986 | ) : null} | 988 | ) : null} |
| 987 | </View> | 989 | </View> |
| ... | @@ -991,8 +993,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -991,8 +993,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 991 | <RowPressable | 993 | <RowPressable |
| 992 | key={`${thread.environmentId}:${thread.id}`} | 994 | key={`${thread.environmentId}:${thread.id}`} |
| 993 | interactionClassName={ | 995 | interactionClassName={ |
| 994 | selected && (sidebarPane || materialYouStyleLayoutActive) | 996 | selected && (sidebarPane || Platform.OS === "android") |
| 995 | ? materialYouStyleLayoutActive | 997 | ? Platform.OS === "android" |
| 996 | ? "bg-thread-selected-foreground" | 998 | ? "bg-thread-selected-foreground" |
| 997 | : "bg-user-bubble-foreground" | 999 | : "bg-user-bubble-foreground" |
| 998 | : "bg-primary" | 1000 | : "bg-primary" |
| ... | @@ -1003,20 +1005,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -1003,20 +1005,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 1003 | } | 1005 | } |
| 1004 | accessibilityRole="button" | 1006 | accessibilityRole="button" |
| 1005 | accessibilityState={{ selected }} | 1007 | accessibilityState={{ selected }} |
| 1006 | className={sidebarPane || materialYouStyleLayoutActive ? undefined : "bg-screen"} | 1008 | className={sidebarPane || Platform.OS === "android" ? undefined : "bg-screen"} |
| 1007 | onPress={() => { | 1009 | onPress={() => { |
| 1008 | close(); | 1010 | close(); |
| 1009 | onSelectThread(thread); | 1011 | onSelectThread(thread); |
| 1010 | }} | 1012 | }} |
| 1011 | style={ | 1013 | style={ |
| 1012 | sidebarPane || materialYouStyleLayoutActive | 1014 | sidebarPane || Platform.OS === "android" |
| 1013 | ? { | 1015 | ? { |
| 1014 | backgroundColor: selected | 1016 | backgroundColor: selected |
| 1015 | ? selectedBackgroundColor | 1017 | ? selectedBackgroundColor |
| 1016 | : sidebarPane | 1018 | : sidebarPane && Platform.OS !== "android" |
| 1017 | ? drawerColor | 1019 | ? drawerColor |
| 1018 | : screenColor, | 1020 | : screenColor, |
| 1019 | borderRadius: SIDEBAR_V2_ROW_RADIUS, | 1021 | borderRadius: Platform.OS === "android" ? 20 : SIDEBAR_V2_ROW_RADIUS, |
| 1020 | } | 1022 | } |
| 1021 | : undefined | 1023 | : undefined |
| 1022 | } | 1024 | } |
| ... | @@ -1044,7 +1046,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -1044,7 +1046,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 1044 | className={cn( | 1046 | className={cn( |
| 1045 | "text-base", | 1047 | "text-base", |
| 1046 | selected | 1048 | selected |
| 1047 | ? materialYouStyleLayoutActive | 1049 | ? Platform.OS === "android" |
| 1048 | ? "text-thread-selected-foreground" | 1050 | ? "text-thread-selected-foreground" |
| 1049 | : "text-user-bubble-foreground" | 1051 | : "text-user-bubble-foreground" |
| 1050 | : "text-foreground-muted", | 1052 | : "text-foreground-muted", |
| ... | @@ -1066,7 +1068,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -1066,7 +1068,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 1066 | className={cn( | 1068 | className={cn( |
| 1067 | "text-sm tabular-nums", | 1069 | "text-sm tabular-nums", |
| 1068 | selected | 1070 | selected |
| 1069 | ? materialYouStyleLayoutActive | 1071 | ? Platform.OS === "android" |
| 1070 | ? "text-thread-selected-foreground-muted" | 1072 | ? "text-thread-selected-foreground-muted" |
| 1071 | : "text-user-bubble-foreground-muted" | 1073 | : "text-user-bubble-foreground-muted" |
| 1072 | : snoozedRow | 1074 | : snoozedRow |
| ... | @@ -1084,16 +1086,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -1084,16 +1086,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 1084 | ); | 1086 | ); |
| 1085 | 1087 | ||
| 1086 | return ( | 1088 | return ( |
| 1087 | <> | 1089 | <View collapsable={false}> |
| 1088 | {customSnoozeOpen && ( | 1090 | {customSnoozeOpen && ( |
| 1089 | <CustomSnoozeSheet onClose={() => setCustomSnoozeOpen(false)} onSnooze={handleSnooze} /> | 1091 | <CustomSnoozeSheet onClose={() => setCustomSnoozeOpen(false)} onSnooze={handleSnooze} /> |
| 1090 | )} | 1092 | )} |
| 1091 | <ThreadSwipeable | 1093 | <ThreadSwipeable |
| 1092 | threadKey={`${thread.environmentId}:${thread.id}`} | 1094 | threadKey={`${thread.environmentId}:${thread.id}`} |
| 1093 | backgroundColor={sidebarPane ? drawerColor : screenColor} | 1095 | backgroundColor={sidebarPane && Platform.OS !== "android" ? drawerColor : screenColor} |
| 1094 | compactActions={variant === "slim"} | 1096 | compactActions={variant === "slim"} |
| 1095 | containerStyle={ | 1097 | containerStyle={ |
| 1096 | sidebarPane ? { borderRadius: SIDEBAR_V2_ROW_RADIUS, overflow: "hidden" } : 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 | enableTrackpadSwipe | 1104 | enableTrackpadSwipe |
| 1099 | // Full swipe commits the advertised lifecycle action (Settle / | 1105 | // Full swipe commits the advertised lifecycle action (Settle / |
| ... | @@ -1141,6 +1147,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -1141,6 +1147,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 1141 | </ControlPillMenu> | 1147 | </ControlPillMenu> |
| 1142 | )} | 1148 | )} |
| 1143 | </ThreadSwipeable> | 1149 | </ThreadSwipeable> |
| 1144 | </> | 1150 | </View> |
| 1145 | ); | 1151 | ); |
| 1146 | }); | 1152 | }); |
apps/mobile/src/features/threads/thread-search-match.tsx+4-5| ... | @@ -1,8 +1,8 @@ | ... | @@ -1,8 +1,8 @@ |
| 1 | import { Platform } from "react-native"; | ||
| 1 | import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; | 2 | import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; |
| 2 | 3 | ||
| 3 | import { AppText as Text } from "../../components/AppText"; | 4 | import { AppText as Text } from "../../components/AppText"; |
| 4 | import { cn } from "../../lib/cn"; | 5 | import { cn } from "../../lib/cn"; |
| 5 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 6 | 6 | ||
| 7 | function foldAsciiCase(value: string): string { | 7 | function foldAsciiCase(value: string): string { |
| 8 | return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); | 8 | return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); |
| ... | @@ -51,14 +51,13 @@ export function ThreadSearchMatchExcerpt(props: { | ... | @@ -51,14 +51,13 @@ export function ThreadSearchMatchExcerpt(props: { |
| 51 | readonly compact?: boolean; | 51 | readonly compact?: boolean; |
| 52 | }) { | 52 | }) { |
| 53 | const isUser = props.match.source === "user"; | 53 | const isUser = props.match.source === "user"; |
| 54 | const { materialYouStyleLayoutActive } = useAppearancePreferences(); | ||
| 55 | const parts = splitHighlightParts(props.match.snippet, props.query); | 54 | const parts = splitHighlightParts(props.match.snippet, props.query); |
| 56 | return ( | 55 | return ( |
| 57 | <Text | 56 | <Text |
| 58 | className={cn( | 57 | className={cn( |
| 59 | props.compact ? "text-sm" : "text-xs", | 58 | props.compact ? "text-sm" : "text-xs", |
| 60 | props.selected | 59 | props.selected |
| 61 | ? materialYouStyleLayoutActive | 60 | ? Platform.OS === "android" |
| 62 | ? "text-thread-selected-foreground-muted" | 61 | ? "text-thread-selected-foreground-muted" |
| 63 | : "text-user-bubble-foreground-muted" | 62 | : "text-user-bubble-foreground-muted" |
| 64 | : "text-foreground-muted", | 63 | : "text-foreground-muted", |
| ... | @@ -69,7 +68,7 @@ export function ThreadSearchMatchExcerpt(props: { | ... | @@ -69,7 +68,7 @@ export function ThreadSearchMatchExcerpt(props: { |
| 69 | className={cn( | 68 | className={cn( |
| 70 | props.compact ? "text-sm font-t3-medium" : "text-xs font-t3-medium", | 69 | props.compact ? "text-sm font-t3-medium" : "text-xs font-t3-medium", |
| 71 | props.selected | 70 | props.selected |
| 72 | ? materialYouStyleLayoutActive | 71 | ? Platform.OS === "android" |
| 73 | ? "text-thread-selected-foreground" | 72 | ? "text-thread-selected-foreground" |
| 74 | : "text-user-bubble-foreground" | 73 | : "text-user-bubble-foreground" |
| 75 | : isUser | 74 | : isUser |
| ... | @@ -85,7 +84,7 @@ export function ThreadSearchMatchExcerpt(props: { | ... | @@ -85,7 +84,7 @@ export function ThreadSearchMatchExcerpt(props: { |
| 85 | props.compact ? "text-sm" : "text-xs", | 84 | props.compact ? "text-sm" : "text-xs", |
| 86 | part.highlighted && "font-t3-bold", | 85 | part.highlighted && "font-t3-bold", |
| 87 | props.selected | 86 | props.selected |
| 88 | ? materialYouStyleLayoutActive | 87 | ? Platform.OS === "android" |
| 89 | ? "text-thread-selected-foreground" | 88 | ? "text-thread-selected-foreground" |
| 90 | : "text-user-bubble-foreground" | 89 | : "text-user-bubble-foreground" |
| 91 | : part.highlighted | 90 | : part.highlighted |
apps/mobile/src/features/usage/UsageLimitsPooled.tsx+4-12| ... | @@ -12,15 +12,14 @@ import { | ... | @@ -12,15 +12,14 @@ import { |
| 12 | type LimitPoolWindow, | 12 | type LimitPoolWindow, |
| 13 | } from "@t3tools/shared/usageLimits"; | 13 | } from "@t3tools/shared/usageLimits"; |
| 14 | import { useId, useState } from "react"; | 14 | import { useId, useState } from "react"; |
| 15 | import { Platform, Pressable, ScrollView, View } from "react-native"; | 15 | import { Pressable, ScrollView, View } from "react-native"; |
| 16 | import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg"; | 16 | import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg"; |
| 17 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 17 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 18 | 18 | ||
| 19 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | ||
| 20 | import { SymbolView } from "../../components/AppSymbol"; | 19 | import { SymbolView } from "../../components/AppSymbol"; |
| 21 | import { AppText as Text } from "../../components/AppText"; | 20 | import { AppText as Text } from "../../components/AppText"; |
| 22 | import { ProviderIcon } from "../../components/ProviderIcon"; | 21 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 23 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 22 | import { SettingsScreen } from "../settings/components/SettingsScreen"; |
| 24 | import { environmentPresentations } from "../../state/presentation"; | 23 | import { environmentPresentations } from "../../state/presentation"; |
| 25 | import { ResetCredits } from "./UsageLimitsSection"; | 24 | import { ResetCredits } from "./UsageLimitsSection"; |
| 26 | import { useProviderColors } from "./usageProviders"; | 25 | import { useProviderColors } from "./usageProviders"; |
| ... | @@ -278,7 +277,6 @@ type AccountScreenProps = StaticScreenProps<{ | ... | @@ -278,7 +277,6 @@ type AccountScreenProps = StaticScreenProps<{ |
| 278 | 277 | ||
| 279 | /** Resolve the account again so live quota and credit updates reach the open detail screen. */ | 278 | /** Resolve the account again so live quota and credit updates reach the open detail screen. */ |
| 280 | export function UsageLimitAccountScreen({ route }: AccountScreenProps) { | 279 | export function UsageLimitAccountScreen({ route }: AccountScreenProps) { |
| 281 | const navigation = useNavigation(); | ||
| 282 | const insets = useSafeAreaInsets(); | 280 | const insets = useSafeAreaInsets(); |
| 283 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); | 281 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); |
| 284 | const { accountKey, windowId, windowKind, environmentIds, now } = route.params; | 282 | const { accountKey, windowId, windowKind, environmentIds, now } = route.params; |
| ... | @@ -297,13 +295,7 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) { | ... | @@ -297,13 +295,7 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) { |
| 297 | const reset = pool?.resets.find((candidate) => candidate.member.account.key === accountKey); | 295 | const reset = pool?.resets.find((candidate) => candidate.member.account.key === accountKey); |
| 298 | const [revealed, setRevealed] = useState(false); | 296 | const [revealed, setRevealed] = useState(false); |
| 299 | return ( | 297 | return ( |
| 300 | <View collapsable={false} className="flex-1 bg-sheet"> | 298 | <SettingsScreen title="Account"> |
| 301 | {Platform.OS === "android" ? ( | ||
| 302 | <> | ||
| 303 | <NativeStackScreenOptions options={{ headerShown: false }} /> | ||
| 304 | <AndroidScreenHeader title="Account" onBack={() => navigation.goBack()} /> | ||
| 305 | </> | ||
| 306 | ) : null} | ||
| 307 | <ScrollView | 299 | <ScrollView |
| 308 | contentInsetAdjustmentBehavior="automatic" | 300 | contentInsetAdjustmentBehavior="automatic" |
| 309 | contentContainerClassName="gap-5 p-5" | 301 | contentContainerClassName="gap-5 p-5" |
| ... | @@ -389,6 +381,6 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) { | ... | @@ -389,6 +381,6 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) { |
| 389 | </> | 381 | </> |
| 390 | )} | 382 | )} |
| 391 | </ScrollView> | 383 | </ScrollView> |
| 392 | </View> | 384 | </SettingsScreen> |
| 393 | ); | 385 | ); |
| 394 | } | 386 | } |
apps/mobile/src/features/usage/UsageRouteScreen.tsx+8-18| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | ||
| 1 | import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; | 2 | import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; |
| 2 | import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native"; | 3 | import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native"; |
| 3 | import { | 4 | import { |
| ... | @@ -18,15 +19,14 @@ import { | ... | @@ -18,15 +19,14 @@ import { |
| 18 | makeWindow, | 19 | makeWindow, |
| 19 | } from "@t3tools/shared/usageFormat"; | 20 | } from "@t3tools/shared/usageFormat"; |
| 20 | import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; | 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 | import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated"; | 23 | import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated"; |
| 23 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 24 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 24 | 25 | ||
| 25 | import { SegmentedControl } from "../../components/SegmentedControl"; | 26 | import { SegmentedControl } from "../../components/SegmentedControl"; |
| 26 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | ||
| 27 | import { AppText as Text } from "../../components/AppText"; | 27 | import { AppText as Text } from "../../components/AppText"; |
| 28 | import { cn } from "../../lib/cn"; | 28 | import { cn } from "../../lib/cn"; |
| 29 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 29 | import { SettingsScreen } from "../settings/components/SettingsScreen"; |
| 30 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; | 30 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; |
| 31 | import { SettingsSection } from "../settings/components/SettingsSection"; | 31 | import { SettingsSection } from "../settings/components/SettingsSection"; |
| 32 | import { UsageDailyChart } from "./UsageDailyChart"; | 32 | import { UsageDailyChart } from "./UsageDailyChart"; |
| ... | @@ -236,17 +236,7 @@ export function UsageRouteScreen() { | ... | @@ -236,17 +236,7 @@ export function UsageRouteScreen() { |
| 236 | }, [navigation, environmentFilter]); | 236 | }, [navigation, environmentFilter]); |
| 237 | 237 | ||
| 238 | return ( | 238 | return ( |
| 239 | <View collapsable={false} className="flex-1 bg-sheet"> | 239 | <SettingsScreen title="Usage" trailing={environmentFilter}> |
| 240 | {Platform.OS === "android" ? ( | ||
| 241 | <> | ||
| 242 | <NativeStackScreenOptions options={{ headerShown: false }} /> | ||
| 243 | <AndroidScreenHeader | ||
| 244 | title="Usage" | ||
| 245 | onBack={() => navigation.goBack()} | ||
| 246 | trailing={environmentFilter} | ||
| 247 | /> | ||
| 248 | </> | ||
| 249 | ) : null} | ||
| 250 | <ScrollView | 240 | <ScrollView |
| 251 | contentInsetAdjustmentBehavior="automatic" | 241 | contentInsetAdjustmentBehavior="automatic" |
| 252 | showsVerticalScrollIndicator={false} | 242 | showsVerticalScrollIndicator={false} |
| ... | @@ -277,20 +267,20 @@ export function UsageRouteScreen() { | ... | @@ -277,20 +267,20 @@ export function UsageRouteScreen() { |
| 277 | <> | 267 | <> |
| 278 | {/* Period and metric together: neither applies to Limits, and | 268 | {/* Period and metric together: neither applies to Limits, and |
| 279 | both change every number below, so they share one bar. */} | 269 | both change every number below, so they share one bar. */} |
| 280 | <View className="flex-row items-center gap-3"> | 270 | <View className={cn("gap-3", Platform.OS !== "android" && "flex-row items-center")}> |
| 281 | <SegmentedControl | 271 | <SegmentedControl |
| 282 | options={WINDOW_OPTIONS} | 272 | options={WINDOW_OPTIONS} |
| 283 | selected={windowDays} | 273 | selected={windowDays} |
| 284 | onSelect={selectWindow} | 274 | onSelect={selectWindow} |
| 285 | size="compact" | 275 | size="compact" |
| 286 | className="flex-1" | 276 | className={Platform.OS === "android" ? "w-full" : "flex-1"} |
| 287 | /> | 277 | /> |
| 288 | <SegmentedControl | 278 | <SegmentedControl |
| 289 | options={METRIC_OPTIONS} | 279 | options={METRIC_OPTIONS} |
| 290 | selected={metric} | 280 | selected={metric} |
| 291 | onSelect={setMetric} | 281 | onSelect={setMetric} |
| 292 | size="compact" | 282 | size="compact" |
| 293 | className="w-36" | 283 | className={Platform.OS === "android" ? "w-full" : "w-36"} |
| 294 | /> | 284 | /> |
| 295 | </View> | 285 | </View> |
| 296 | {merged.duplicateSources.length > 0 ? ( | 286 | {merged.duplicateSources.length > 0 ? ( |
| ... | @@ -330,7 +320,7 @@ export function UsageRouteScreen() { | ... | @@ -330,7 +320,7 @@ export function UsageRouteScreen() { |
| 330 | )} | 320 | )} |
| 331 | </Animated.View> | 321 | </Animated.View> |
| 332 | </ScrollView> | 322 | </ScrollView> |
| 333 | </View> | 323 | </SettingsScreen> |
| 334 | ); | 324 | ); |
| 335 | } | 325 | } |
| 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,8 +31,20 @@ const palette: MaterialYouPalette = { |
| 31 | }; | 31 | }; |
| 32 | 32 | ||
| 33 | describe("Material You system colors", () => { | 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 | it("overrides the selected theme without mutating its base variables", () => { | 46 | it("overrides the selected theme without mutating its base variables", () => { |
| 35 | const base = getMobileThemeRuntimeVariables("t3-code", "dark"); | 47 | const base = getMobileThemeRuntimeVariables("t3-code", "dark", "android"); |
| 36 | const snapshot = { ...base }; | 48 | const snapshot = { ...base }; |
| 37 | 49 | ||
| 38 | const variables = materialYouPaletteToMobileThemeVariables(palette, "dark", base); | 50 | const variables = materialYouPaletteToMobileThemeVariables(palette, "dark", base); |
| ... | @@ -47,7 +59,7 @@ describe("Material You system colors", () => { | ... | @@ -47,7 +59,7 @@ describe("Material You system colors", () => { |
| 47 | }); | 59 | }); |
| 48 | 60 | ||
| 49 | it("uses the Messages-style RCS tones for sent messages", () => { | 61 | it("uses the Messages-style RCS tones for sent messages", () => { |
| 50 | const base = getMobileThemeRuntimeVariables("t3-code", "dark"); | 62 | const base = getMobileThemeRuntimeVariables("t3-code", "dark", "android"); |
| 51 | const dark = materialYouPaletteToMobileThemeVariables( | 63 | const dark = materialYouPaletteToMobileThemeVariables( |
| 52 | { ...palette, inversePrimary: "#A31D8DFF" }, | 64 | { ...palette, inversePrimary: "#A31D8DFF" }, |
| 53 | "dark", | 65 | "dark", |
| ... | @@ -56,7 +68,7 @@ describe("Material You system colors", () => { | ... | @@ -56,7 +68,7 @@ describe("Material You system colors", () => { |
| 56 | const light = materialYouPaletteToMobileThemeVariables( | 68 | const light = materialYouPaletteToMobileThemeVariables( |
| 57 | { ...palette, inversePrimary: "#A31D8DFF" }, | 69 | { ...palette, inversePrimary: "#A31D8DFF" }, |
| 58 | "light", | 70 | "light", |
| 59 | getMobileThemeRuntimeVariables("t3-code", "light"), | 71 | getMobileThemeRuntimeVariables("t3-code", "light", "android"), |
| 60 | ); | 72 | ); |
| 61 | 73 | ||
| 62 | expect(dark["--color-user-bubble"]).toBe("#850073FF"); | 74 | expect(dark["--color-user-bubble"]).toBe("#850073FF"); |
apps/mobile/src/lib/mobileThemeVariables.test.ts+38-5| ... | @@ -1,25 +1,58 @@ | ... | @@ -1,25 +1,58 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support"; | 3 | import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support"; |
| 4 | import { getMobileThemeVariables } from "./mobileTheme"; | 4 | import { getMobileThemeVariables, MOBILE_THEME_IDS, themeColorWithAlpha } from "./mobileTheme"; |
| 5 | import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; | 5 | import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; |
| 6 | 6 | ||
| 7 | describe("mobile theme runtime variables", () => { | 7 | describe("mobile theme runtime variables", () => { |
| 8 | it("derives the standard runtime palette from global.css", () => { | 8 | it("derives the standard runtime palette from global.css", () => { |
| 9 | expect(getMobileThemeRuntimeVariables("t3-code", "light")).toEqual( | 9 | expect(getMobileThemeRuntimeVariables("t3-code", "light", "ios")).toEqual( |
| 10 | readDefaultMobileThemeVariables("light"), | 10 | readDefaultMobileThemeVariables("light"), |
| 11 | ); | 11 | ); |
| 12 | expect(getMobileThemeRuntimeVariables("t3-code", "dark")).toEqual( | 12 | expect(getMobileThemeRuntimeVariables("t3-code", "dark", "ios")).toEqual( |
| 13 | readDefaultMobileThemeVariables("dark"), | 13 | readDefaultMobileThemeVariables("dark"), |
| 14 | ); | 14 | ); |
| 15 | }); | 15 | }); |
| 16 | 16 | ||
| 17 | it("uses the same shared palette source as generated custom themes", () => { | 17 | it("uses the same shared palette source as generated custom themes", () => { |
| 18 | expect(getMobileThemeRuntimeVariables("ocean", "light")).toEqual( | 18 | expect(getMobileThemeRuntimeVariables("ocean", "light", "ios")).toEqual( |
| 19 | getMobileThemeVariables("ocean", "light"), | 19 | getMobileThemeVariables("ocean", "light"), |
| 20 | ); | 20 | ); |
| 21 | expect(getMobileThemeRuntimeVariables("iris", "dark")).toEqual( | 21 | expect(getMobileThemeRuntimeVariables("iris", "dark", "ios")).toEqual( |
| 22 | getMobileThemeVariables("iris", "dark"), | 22 | getMobileThemeVariables("iris", "dark"), |
| 23 | ); | 23 | ); |
| 24 | }); | 24 | }); |
| 25 | |||
| 26 | it.each(MOBILE_THEME_IDS)( | ||
| 27 | "keeps %s colors on Android with an opaque Material frame", | ||
| 28 | (themeId) => { | ||
| 29 | for (const appearance of ["light", "dark"] as const) { | ||
| 30 | const ios = getMobileThemeRuntimeVariables(themeId, appearance, "ios"); | ||
| 31 | const android = getMobileThemeRuntimeVariables(themeId, appearance, "android"); | ||
| 32 | expect(android).toEqual({ | ||
| 33 | ...ios, | ||
| 34 | "--color-header": themeColorWithAlpha( | ||
| 35 | ios[ | ||
| 36 | themeId === "t3-code" || themeId === "material-you" | ||
| 37 | ? "--color-card" | ||
| 38 | : "--color-drawer" | ||
| 39 | ], | ||
| 40 | 1, | ||
| 41 | ), | ||
| 42 | }); | ||
| 43 | expect(android["--color-header"]).toMatch(/^rgba\(\d+, \d+, \d+, 1\)$/); | ||
| 44 | } | ||
| 45 | }, | ||
| 46 | ); | ||
| 47 | |||
| 48 | it.each(["t3-code", "material-you"] as const)( | ||
| 49 | "keeps the %s default dark frame distinct from the rounded settings body", | ||
| 50 | (themeId) => { | ||
| 51 | const variables = getMobileThemeRuntimeVariables(themeId, "dark", "android"); | ||
| 52 | expect(variables["--color-header"]).toBe("rgba(23, 23, 23, 1)"); | ||
| 53 | expect(variables["--color-header"]).not.toBe( | ||
| 54 | themeColorWithAlpha(variables["--color-sheet-solid"], 1), | ||
| 55 | ); | ||
| 56 | }, | ||
| 57 | ); | ||
| 25 | }); | 58 | }); |
apps/mobile/src/lib/mobileThemeVariables.ts+16-2| ... | @@ -1,8 +1,8 @@ | ... | @@ -1,8 +1,8 @@ |
| 1 | import defaultThemeVariables from "../../generated-uniwind-default-theme-variables.json"; | 1 | import defaultThemeVariables from "../../generated-uniwind-default-theme-variables.json"; |
| 2 | |||
| 3 | import { | 2 | import { |
| 4 | DEFAULT_MOBILE_THEME_ID, | 3 | DEFAULT_MOBILE_THEME_ID, |
| 5 | getMobileThemeVariables, | 4 | getMobileThemeVariables, |
| 5 | themeColorWithAlpha, | ||
| 6 | type MobileThemeAppearance, | 6 | type MobileThemeAppearance, |
| 7 | type MobileThemeId, | 7 | type MobileThemeId, |
| 8 | type MobileThemeVariables, | 8 | type MobileThemeVariables, |
| ... | @@ -20,8 +20,22 @@ const defaults = defaultThemeVariables as Readonly< | ... | @@ -20,8 +20,22 @@ const defaults = defaultThemeVariables as Readonly< |
| 20 | export function getMobileThemeRuntimeVariables( | 20 | export function getMobileThemeRuntimeVariables( |
| 21 | themeId: MobileThemeId, | 21 | themeId: MobileThemeId, |
| 22 | appearance: MobileThemeAppearance, | 22 | appearance: MobileThemeAppearance, |
| 23 | platform: string, | ||
| 23 | ): MobileThemeVariables { | 24 | ): MobileThemeVariables { |
| 24 | return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you" | 25 | const usesDefaultPalette = themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you"; |
| 26 | const variables = usesDefaultPalette | ||
| 25 | ? defaults[appearance] | 27 | ? defaults[appearance] |
| 26 | : getMobileThemeVariables(themeId, appearance); | 28 | : getMobileThemeVariables(themeId, appearance); |
| 29 | if (platform !== "android") return variables; | ||
| 30 | |||
| 31 | // Rounded panes share one opaque frame. The default dark drawer matches the | ||
| 32 | // settings body, so use its card tone to keep the rounded edge visible. | ||
| 33 | // System colors replace this with their own surfaceContainerHigh afterwards. | ||
| 34 | return { | ||
| 35 | ...variables, | ||
| 36 | "--color-header": themeColorWithAlpha( | ||
| 37 | variables[usesDefaultPalette ? "--color-card" : "--color-drawer"], | ||
| 38 | 1, | ||
| 39 | ), | ||
| 40 | }; | ||
| 27 | } | 41 | } |
apps/mobile/src/lib/storage.test.ts+9-15| ... | @@ -204,21 +204,15 @@ describe("mobile connection storage", () => { | ... | @@ -204,21 +204,15 @@ describe("mobile connection storage", () => { |
| 204 | await expect(loadPreferences()).resolves.toEqual({ ...themes, lightThemeId: "t3-chat" }); | 204 | await expect(loadPreferences()).resolves.toEqual({ ...themes, lightThemeId: "t3-chat" }); |
| 205 | }); | 205 | }); |
| 206 | 206 | ||
| 207 | it("persists the Material You layout independently of the selected theme", async () => { | 207 | it.each([true, false])("drops the removed Android layout preference (%s)", async (enabled) => { |
| 208 | await savePreferencesPatch({ | 208 | mocks.setPreferencesJson( |
| 209 | lightThemeId: "material-you", | 209 | JSON.stringify({ |
| 210 | materialYouStyleLayoutEnabled: true, | 210 | materialYouStyleLayoutEnabled: enabled, |
| 211 | }); | 211 | lightThemeId: "t3-chat", |
| 212 | await savePreferencesPatch({ lightThemeId: "t3-chat" }); | 212 | }), |
| 213 | await expect(loadPreferences()).resolves.toEqual({ | 213 | 10, |
| 214 | lightThemeId: "t3-chat", | 214 | ); |
| 215 | materialYouStyleLayoutEnabled: true, | 215 | await expect(loadPreferences()).resolves.toEqual({ lightThemeId: "t3-chat" }); |
| 216 | }); | ||
| 217 | await savePreferencesPatch({ materialYouStyleLayoutEnabled: false }); | ||
| 218 | await expect(loadPreferences()).resolves.toEqual({ | ||
| 219 | lightThemeId: "t3-chat", | ||
| 220 | materialYouStyleLayoutEnabled: false, | ||
| 221 | }); | ||
| 222 | }); | 216 | }); |
| 223 | 217 | ||
| 224 | it("drops the removed theme transition preference", async () => { | 218 | it("drops the removed theme transition preference", async () => { |
apps/mobile/src/native/SelectableMarkdownText.android.tsx+17-1| ... | @@ -2,8 +2,11 @@ import { | ... | @@ -2,8 +2,11 @@ import { |
| 2 | SelectableMarkdownText as T3SelectableMarkdownText, | 2 | SelectableMarkdownText as T3SelectableMarkdownText, |
| 3 | type SelectableMarkdownTextProps, | 3 | type SelectableMarkdownTextProps, |
| 4 | } from "@t3tools/mobile-markdown-text/renderer"; | 4 | } from "@t3tools/mobile-markdown-text/renderer"; |
| 5 | import { useMemo } from "react"; | ||
| 5 | 6 | ||
| 6 | import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"; | 7 | import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"; |
| 8 | import { themeColorWithAlpha } from "../lib/mobileTheme"; | ||
| 9 | import { useUniwindTheme } from "../lib/useUniwindTheme"; | ||
| 7 | 10 | ||
| 8 | type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">; | 11 | type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">; |
| 9 | 12 | ||
| ... | @@ -20,5 +23,18 @@ export function hasNativeSelectableMarkdownText(): boolean { | ... | @@ -20,5 +23,18 @@ export function hasNativeSelectableMarkdownText(): boolean { |
| 20 | } | 23 | } |
| 21 | 24 | ||
| 22 | export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) { | 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,7 +14,7 @@ import { |
| 14 | type Ref, | 14 | type Ref, |
| 15 | } from "react"; | 15 | } from "react"; |
| 16 | import type { NativeSyntheticEvent, ViewProps } from "react-native"; | 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 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; | 19 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; |
| 20 | import { | 20 | import { |
| ... | @@ -259,7 +259,7 @@ export function ComposerEditor({ | ... | @@ -259,7 +259,7 @@ export function ComposerEditor({ |
| 259 | ); | 259 | ); |
| 260 | const { systemColorsActive } = useAppearancePreferences(); | 260 | const { systemColorsActive } = useAppearancePreferences(); |
| 261 | const themeJson = JSON.stringify({ | 261 | const themeJson = JSON.stringify({ |
| 262 | selection: systemColorsActive ? theme["--color-primary"] : null, | 262 | selection: Platform.OS === "android" || systemColorsActive ? theme["--color-primary"] : null, |
| 263 | text: theme["--color-foreground"], | 263 | text: theme["--color-foreground"], |
| 264 | placeholder: theme["--color-placeholder"], | 264 | placeholder: theme["--color-placeholder"], |
| 265 | chipBackground: theme["--color-subtle"], | 265 | chipBackground: theme["--color-subtle"], |
apps/mobile/src/persistence/mobile-preferences.ts-6| ... | @@ -8,7 +8,6 @@ import * as Semaphore from "effect/Semaphore"; | ... | @@ -8,7 +8,6 @@ import * as Semaphore from "effect/Semaphore"; |
| 8 | import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; | 8 | import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; |
| 9 | import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior"; | 9 | import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior"; |
| 10 | import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme"; | 10 | import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme"; |
| 11 | |||
| 12 | import * as MobileDatabase from "./mobile-database"; | 11 | import * as MobileDatabase from "./mobile-database"; |
| 13 | import * as MobileSecureStorage from "./mobile-secure-storage"; | 12 | import * as MobileSecureStorage from "./mobile-secure-storage"; |
| 14 | import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage"; | 13 | import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage"; |
| ... | @@ -22,7 +21,6 @@ export interface Preferences { | ... | @@ -22,7 +21,6 @@ export interface Preferences { |
| 22 | readonly lightThemeId?: MobileThemeId; | 21 | readonly lightThemeId?: MobileThemeId; |
| 23 | readonly darkThemeId?: MobileThemeId; | 22 | readonly darkThemeId?: MobileThemeId; |
| 24 | readonly themeMode?: MobileThemeMode; | 23 | readonly themeMode?: MobileThemeMode; |
| 25 | readonly materialYouStyleLayoutEnabled?: boolean; | ||
| 26 | readonly baseFontSize?: number; | 24 | readonly baseFontSize?: number; |
| 27 | readonly terminalFontSize?: number | null; | 25 | readonly terminalFontSize?: number | null; |
| 28 | readonly markdownFontSize?: number; | 26 | readonly markdownFontSize?: number; |
| ... | @@ -94,7 +92,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { | ... | @@ -94,7 +92,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 94 | lightThemeId?: MobileThemeId; | 92 | lightThemeId?: MobileThemeId; |
| 95 | darkThemeId?: MobileThemeId; | 93 | darkThemeId?: MobileThemeId; |
| 96 | themeMode?: MobileThemeMode; | 94 | themeMode?: MobileThemeMode; |
| 97 | materialYouStyleLayoutEnabled?: boolean; | ||
| 98 | baseFontSize?: number; | 95 | baseFontSize?: number; |
| 99 | terminalFontSize?: number | null; | 96 | terminalFontSize?: number | null; |
| 100 | markdownFontSize?: number; | 97 | markdownFontSize?: number; |
| ... | @@ -139,9 +136,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { | ... | @@ -139,9 +136,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 139 | ) { | 136 | ) { |
| 140 | preferences.themeMode = parsed.themeMode; | 137 | preferences.themeMode = parsed.themeMode; |
| 141 | } | 138 | } |
| 142 | if (typeof parsed.materialYouStyleLayoutEnabled === "boolean") { | ||
| 143 | preferences.materialYouStyleLayoutEnabled = parsed.materialYouStyleLayoutEnabled; | ||
| 144 | } | ||
| 145 | if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize; | 139 | if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize; |
| 146 | if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) { | 140 | if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) { |
| 147 | preferences.terminalFontSize = parsed.terminalFontSize; | 141 | preferences.terminalFontSize = parsed.terminalFontSize; |
docs/user/appearance.md+2-1| ... | @@ -10,7 +10,8 @@ themes or defaults. | ... | @@ -10,7 +10,8 @@ themes or defaults. |
| 10 | On Android 12 or newer, choose the **Material You** theme in Appearance to use colors from | 10 | On Android 12 or newer, choose the **Material You** theme in Appearance to use colors from |
| 11 | your wallpaper. Selecting another theme replaces those colors. Like other themes, Material You | 11 | your wallpaper. Selecting another theme replaces those colors. Like other themes, Material You |
| 12 | can be selected separately for light and dark appearances. | 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 | of the selected theme. | 15 | of the selected theme. |
| 15 | 16 | ||
| 16 | ## Motion | 17 | ## Motion |
vite.config.ts+2| ... | @@ -184,6 +184,7 @@ export default defineConfig({ | ... | @@ -184,6 +184,7 @@ export default defineConfig({ |
| 184 | "apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx", | 184 | "apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx", |
| 185 | "apps/mobile/src/features/files/FileMarkdownPreview.tsx", | 185 | "apps/mobile/src/features/files/FileMarkdownPreview.tsx", |
| 186 | "apps/mobile/src/features/files/SourceFileSurface.tsx", | 186 | "apps/mobile/src/features/files/SourceFileSurface.tsx", |
| 187 | "apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx", | ||
| 187 | "apps/mobile/src/features/files/AttachmentFileScreen.tsx", | 188 | "apps/mobile/src/features/files/AttachmentFileScreen.tsx", |
| 188 | "apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx", | 189 | "apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx", |
| 189 | "apps/mobile/src/features/files/thread-file-navigator-pane.tsx", | 190 | "apps/mobile/src/features/files/thread-file-navigator-pane.tsx", |
| ... | @@ -204,6 +205,7 @@ export default defineConfig({ | ... | @@ -204,6 +205,7 @@ export default defineConfig({ |
| 204 | "apps/mobile/src/lib/useMobileNavigationTheme.ts", | 205 | "apps/mobile/src/lib/useMobileNavigationTheme.ts", |
| 205 | "apps/mobile/src/native/T3ComposerEditor.ios.tsx", | 206 | "apps/mobile/src/native/T3ComposerEditor.ios.tsx", |
| 206 | "apps/mobile/src/native/T3ComposerEditor.native.tsx", | 207 | "apps/mobile/src/native/T3ComposerEditor.native.tsx", |
| 208 | "apps/mobile/src/native/SelectableMarkdownText.android.tsx", | ||
| 207 | ], | 209 | ], |
| 208 | rules: { | 210 | rules: { |
| 209 | "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }], | 211 | "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }], |