* Add appearance settings for theme, fonts, and syntax highlighting New Appearance section in Settings to configure the app theme, UI and mono font family + size, and a syntax-highlighting theme chosen independently of light/dark. Font family fields default to empty and show the active system stack as the placeholder; a live split-diff preview reflects the choices as they change. The theme picker moves here from General. Preferences are client-only (AsyncStorage) and applied at runtime by patching every registered Unistyles theme via UnistylesRuntime.updateTheme, so existing StyleSheet consumers repaint with no per-component preference reads and no useUnistyles. No daemon/protocol change. * Expand syntax themes and move the preview under the chooser Replace the small fixed syntax-theme set with eight popular themes — GitHub, Catppuccin, Dracula, Tokyo Night, One, Nord, Gruvbox, Solarized — built from a compact per-theme role palette. The app theme and syntax theme stay separate; the only coupling is the light/dark axis: a theme with both variants uses its light palette on a light app and its dark palette on a dark app, while the code frame (gutter, line numbers, background) keeps following the app theme. Default is now GitHub (GitHub keeps its existing hand-tuned maps for an exact match). Move the live preview directly under the syntax-theme chooser, drop the "preview.ts" filename header, and use a clearer code snippet for it. * Update settings e2e for the moved theme picker and Appearance section The theme picker moved from General to the new Appearance section, so the General-content assertion no longer finds "Theme" — point it at "Default send" (which stays in General) and add Appearance to the e2e section map plus an expectAppearanceContent check, and exercise the Appearance section in the sidebar-navigation test. * Render the appearance preview as a unified diff Switch the live preview from side-by-side columns to a single unified diff: unchanged context lines plus "-"/"+" rows for the change, matching how diffs read elsewhere in the app. Same snippet, mono font, syntax colors, and live code-font draft behavior. * Fix code font size change shrinking all UI text applyAppearance scaled the live (already-patched) theme ramp instead of the authored FONT_SIZE ramp, so it was not idempotent: every appearance change re-scaled the current sizes, compounding the whole-UI font scale. With any non-default UI size, changing the code size — or any setting — shrank (or grew) all app text, and it persisted into the theme. Build the ramp from the canonical FONT_SIZE ramp each apply (code size still set absolutely), so applies are idempotent and a code-size change never touches the UI ramp. * Apply the interface font across the app on web react-native-web stamps a default font onto every text element, so the interface font only reached the few components that set a font explicitly. Inject one rule that points all text at a CSS variable (updated live as the setting changes), with high specificity (1,2,0) so it deterministically beats RN-web's base font and Unistyles' generated classes — no stylesheet-order fragility. Code, diff, and monospace surfaces carry a `data-pmono` marker (and their subtrees are excluded), so they keep their code font. Web-only: React Native has no global font cascade, so native still applies the UI font only where components opt in. Inline `code` within chat prose is a known minor gap (its render paths aren't tagged yet). * Keep preview code in the code font * Apply code font settings to terminals * Fix settings host picker import after rebase * Format terminal webview bundle
457 lines
16 KiB
TypeScript
457 lines
16 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { ScrollView, Text, View } from "react-native";
|
|
import Animated, {
|
|
cancelAnimation,
|
|
Easing,
|
|
useAnimatedStyle,
|
|
useSharedValue,
|
|
withRepeat,
|
|
withTiming,
|
|
} from "react-native-reanimated";
|
|
import MaskedView from "@react-native-masked-view/masked-view";
|
|
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
|
import * as Clipboard from "expo-clipboard";
|
|
import { openExternalUrl } from "@/utils/open-external-url";
|
|
import { BookOpen, Copy, RotateCw, TriangleAlert } from "lucide-react-native";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
|
import { Button } from "@/components/ui/button";
|
|
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
|
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
|
import { isNative, isWeb } from "@/constants/platform";
|
|
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
|
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
|
|
|
interface StartupSplashScreenProps {
|
|
bootstrapState?: {
|
|
splashError: string | null;
|
|
retry: () => void;
|
|
};
|
|
}
|
|
|
|
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
|
|
const DOCS_URL = "https://paseo.sh/docs";
|
|
|
|
const LOGO_SIZE = 96;
|
|
const SHIMMER_PEAK_WIDTH = 120;
|
|
const SHIMMER_DURATION_MS = 1800;
|
|
|
|
function openGithubIssue(): void {
|
|
void openExternalUrl(GITHUB_ISSUE_URL);
|
|
}
|
|
|
|
function openDocs(): void {
|
|
void openExternalUrl(DOCS_URL);
|
|
}
|
|
|
|
const WEB_SPLASH_SHIMMER_KEYFRAME_ID = "paseo-splash-shimmer-keyframes";
|
|
const WEB_SPLASH_SHIMMER_ANIMATION_NAME = "paseo-splash-shimmer";
|
|
|
|
const WEB_SPLASH_SHIMMER_KEYFRAME_CSS = `
|
|
@keyframes ${WEB_SPLASH_SHIMMER_ANIMATION_NAME} {
|
|
0% {
|
|
background-position: -${LOGO_SIZE + SHIMMER_PEAK_WIDTH}px 0;
|
|
}
|
|
100% {
|
|
background-position: ${LOGO_SIZE + SHIMMER_PEAK_WIDTH}px 0;
|
|
}
|
|
}
|
|
`;
|
|
|
|
let webSplashShimmerRegistered = false;
|
|
|
|
function ensureWebSplashShimmerKeyframes() {
|
|
if (isNative) {
|
|
return;
|
|
}
|
|
if (webSplashShimmerRegistered) {
|
|
return;
|
|
}
|
|
const existing = document.getElementById(WEB_SPLASH_SHIMMER_KEYFRAME_ID);
|
|
if (existing) {
|
|
webSplashShimmerRegistered = true;
|
|
return;
|
|
}
|
|
const styleElement = document.createElement("style");
|
|
styleElement.id = WEB_SPLASH_SHIMMER_KEYFRAME_ID;
|
|
styleElement.textContent = WEB_SPLASH_SHIMMER_KEYFRAME_CSS;
|
|
document.head.appendChild(styleElement);
|
|
webSplashShimmerRegistered = true;
|
|
}
|
|
|
|
function LogoShimmer() {
|
|
const { theme } = useUnistyles();
|
|
|
|
if (isWeb) {
|
|
return <WebLogoShimmer color={theme.colors.foreground} />;
|
|
}
|
|
|
|
return <NativeLogoShimmer color={theme.colors.foreground} />;
|
|
}
|
|
|
|
function WebLogoShimmer({ color }: { color: string }) {
|
|
useEffect(() => {
|
|
ensureWebSplashShimmerKeyframes();
|
|
}, []);
|
|
|
|
const shimmerStyle = useMemo(
|
|
() => ({
|
|
width: LOGO_SIZE,
|
|
height: LOGO_SIZE,
|
|
WebkitMaskImage: `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' width='${LOGO_SIZE}' height='${LOGO_SIZE}' viewBox='0 0 700 700'><path fill='black' d='M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z'/></svg>`)}")`,
|
|
WebkitMaskSize: "contain",
|
|
WebkitMaskRepeat: "no-repeat",
|
|
WebkitMaskPosition: "center",
|
|
background: `linear-gradient(90deg, ${color} 0%, ${color}88 40%, ${color}FF 50%, ${color}88 60%, ${color} 100%)`,
|
|
backgroundSize: `${LOGO_SIZE + SHIMMER_PEAK_WIDTH * 2}px ${LOGO_SIZE}px`,
|
|
animationName: WEB_SPLASH_SHIMMER_ANIMATION_NAME,
|
|
animationDuration: `${SHIMMER_DURATION_MS}ms`,
|
|
animationTimingFunction: "linear",
|
|
animationIterationCount: "infinite",
|
|
}),
|
|
[color],
|
|
);
|
|
|
|
return <View style={shimmerStyle as never} />;
|
|
}
|
|
|
|
function NativeLogoShimmer({ color }: { color: string }) {
|
|
const shimmerTranslateX = useSharedValue(-SHIMMER_PEAK_WIDTH);
|
|
|
|
useEffect(() => {
|
|
shimmerTranslateX.value = -SHIMMER_PEAK_WIDTH;
|
|
shimmerTranslateX.value = withRepeat(
|
|
withTiming(LOGO_SIZE + SHIMMER_PEAK_WIDTH, {
|
|
duration: SHIMMER_DURATION_MS,
|
|
easing: Easing.linear,
|
|
}),
|
|
-1,
|
|
false,
|
|
);
|
|
return () => {
|
|
cancelAnimation(shimmerTranslateX);
|
|
};
|
|
}, [shimmerTranslateX]);
|
|
|
|
const peakStyle = useAnimatedStyle(() => ({
|
|
transform: [{ translateX: shimmerTranslateX.value }],
|
|
}));
|
|
|
|
const trackStyle = useMemo(
|
|
() => [styles.nativeShimmerTrack, { width: LOGO_SIZE, height: LOGO_SIZE }],
|
|
[],
|
|
);
|
|
|
|
const peakCombinedStyle = useMemo(
|
|
() => [styles.nativeShimmerPeak, peakStyle, { width: SHIMMER_PEAK_WIDTH, height: LOGO_SIZE }],
|
|
[peakStyle],
|
|
);
|
|
|
|
const maskElement = useMemo(
|
|
() => (
|
|
<View style={styles.shimmerMask}>
|
|
<PaseoLogo size={LOGO_SIZE} color="#000000" />
|
|
</View>
|
|
),
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<MaskedView style={trackStyle} maskElement={maskElement}>
|
|
<View style={trackStyle}>
|
|
<View style={styles.nativeShimmerBase}>
|
|
<PaseoLogo size={LOGO_SIZE} color={color} />
|
|
</View>
|
|
<Animated.View style={peakCombinedStyle}>
|
|
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
|
<Defs>
|
|
<SvgLinearGradient id="splashShimmer" x1="0" y1="0" x2="1" y2="0">
|
|
<Stop offset="0" stopColor="#FFFFFF" stopOpacity="0" />
|
|
<Stop offset="0.5" stopColor="#FFFFFF" stopOpacity="0.4" />
|
|
<Stop offset="1" stopColor="#FFFFFF" stopOpacity="0" />
|
|
</SvgLinearGradient>
|
|
</Defs>
|
|
<Rect x="0" y="0" width="100%" height="100%" fill="url(#splashShimmer)" />
|
|
</Svg>
|
|
</Animated.View>
|
|
</View>
|
|
</MaskedView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
container: {
|
|
position: "relative",
|
|
flex: 1,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
backgroundColor: theme.colors.surface0,
|
|
paddingHorizontal: theme.spacing[8],
|
|
paddingVertical: theme.spacing[8],
|
|
},
|
|
errorScreen: {
|
|
position: "relative",
|
|
flex: 1,
|
|
backgroundColor: theme.colors.surface0,
|
|
},
|
|
errorScrollView: {
|
|
flex: 1,
|
|
...(isWeb
|
|
? {
|
|
overflowX: "auto",
|
|
overflowY: "auto",
|
|
WebkitAppRegion: "no-drag",
|
|
}
|
|
: null),
|
|
},
|
|
errorScrollContent: {
|
|
flexGrow: 1,
|
|
alignItems: "center",
|
|
justifyContent: "flex-start",
|
|
paddingHorizontal: theme.spacing[8],
|
|
paddingVertical: theme.spacing[8],
|
|
paddingTop: theme.spacing[16],
|
|
},
|
|
errorContent: {
|
|
alignItems: "stretch",
|
|
maxWidth: 720,
|
|
width: "100%",
|
|
gap: theme.spacing[6],
|
|
},
|
|
errorHeader: {
|
|
alignItems: "flex-start",
|
|
},
|
|
title: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize["3xl"],
|
|
fontWeight: theme.fontWeight.semibold,
|
|
textAlign: "left",
|
|
},
|
|
errorDescription: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.base,
|
|
lineHeight: 22,
|
|
},
|
|
errorMessage: {
|
|
color: theme.colors.destructive,
|
|
fontSize: theme.fontSize.code,
|
|
lineHeight: 20,
|
|
fontFamily: theme.fontFamily.mono,
|
|
},
|
|
logsMeta: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
logsContainer: {
|
|
height: 200,
|
|
borderRadius: theme.borderRadius.xl,
|
|
backgroundColor: theme.colors.surface1,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.border,
|
|
overflow: "hidden",
|
|
},
|
|
logsScroll: {
|
|
flexGrow: 0,
|
|
},
|
|
logsContent: {
|
|
padding: theme.spacing[4],
|
|
},
|
|
logsText: {
|
|
fontFamily: theme.fontFamily.mono,
|
|
fontSize: theme.fontSize.code,
|
|
color: theme.colors.foreground,
|
|
lineHeight: 18,
|
|
...(isWeb
|
|
? {
|
|
whiteSpace: "pre",
|
|
overflowWrap: "normal",
|
|
}
|
|
: null),
|
|
},
|
|
actionRow: {
|
|
flexDirection: "row",
|
|
gap: theme.spacing[3],
|
|
flexWrap: "wrap",
|
|
},
|
|
shimmerMask: {
|
|
width: LOGO_SIZE,
|
|
height: LOGO_SIZE,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
nativeShimmerTrack: {
|
|
overflow: "hidden",
|
|
},
|
|
nativeShimmerBase: {
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
},
|
|
nativeShimmerPeak: {
|
|
position: "absolute",
|
|
top: 0,
|
|
bottom: 0,
|
|
},
|
|
}));
|
|
|
|
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
|
|
const { theme } = useUnistyles();
|
|
const webScrollbarStyle = useWebScrollbarStyle();
|
|
const errorScrollViewStyle = useMemo(
|
|
() => [styles.errorScrollView, webScrollbarStyle],
|
|
[webScrollbarStyle],
|
|
);
|
|
const logsScrollStyle = useMemo(
|
|
() => [styles.logsScroll, webScrollbarStyle],
|
|
[webScrollbarStyle],
|
|
);
|
|
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
|
|
const [logsError, setLogsError] = useState<string | null>(null);
|
|
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
|
|
|
const isError = bootstrapState !== undefined && bootstrapState.splashError !== null;
|
|
|
|
useEffect(() => {
|
|
if (!isError) {
|
|
setDaemonLogs(null);
|
|
setLogsError(null);
|
|
setIsLoadingLogs(false);
|
|
return;
|
|
}
|
|
|
|
let isCancelled = false;
|
|
setIsLoadingLogs(true);
|
|
setLogsError(null);
|
|
|
|
void getDesktopDaemonLogs()
|
|
.then((logs) => {
|
|
if (isCancelled) {
|
|
return;
|
|
}
|
|
setDaemonLogs(logs);
|
|
return;
|
|
})
|
|
.catch((error) => {
|
|
if (isCancelled) {
|
|
return;
|
|
}
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
setDaemonLogs(null);
|
|
setLogsError(`Unable to load daemon logs: ${message}`);
|
|
})
|
|
.finally(() => {
|
|
if (!isCancelled) {
|
|
setIsLoadingLogs(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
isCancelled = true;
|
|
};
|
|
}, [isError]);
|
|
|
|
const logsText = useMemo(() => {
|
|
if (isLoadingLogs) {
|
|
return "Loading daemon logs...";
|
|
}
|
|
if (daemonLogs?.contents) {
|
|
return daemonLogs.contents;
|
|
}
|
|
if (logsError) {
|
|
return logsError;
|
|
}
|
|
return "No daemon logs available.";
|
|
}, [daemonLogs?.contents, isLoadingLogs, logsError]);
|
|
|
|
const handleCopyLogs = useCallback(() => {
|
|
const payload = daemonLogs?.logPath
|
|
? `${daemonLogs.logPath}\n\n${daemonLogs.contents}`
|
|
: logsText;
|
|
void Clipboard.setStringAsync(payload);
|
|
}, [daemonLogs?.logPath, daemonLogs?.contents, logsText]);
|
|
|
|
const copyIcon = useMemo(
|
|
() => <Copy size={16} color={theme.colors.foreground} />,
|
|
[theme.colors.foreground],
|
|
);
|
|
const warningIcon = useMemo(
|
|
() => <TriangleAlert size={16} color={theme.colors.foreground} />,
|
|
[theme.colors.foreground],
|
|
);
|
|
const bookIcon = useMemo(
|
|
() => <BookOpen size={16} color={theme.colors.foreground} />,
|
|
[theme.colors.foreground],
|
|
);
|
|
const retryIcon = useMemo(
|
|
() => <RotateCw size={16} color={theme.colors.palette.white} />,
|
|
[theme.colors.palette.white],
|
|
);
|
|
|
|
if (!isError) {
|
|
return (
|
|
<View testID="startup-splash" style={styles.container}>
|
|
<TitlebarDragRegion />
|
|
<LogoShimmer />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={styles.errorScreen}>
|
|
<TitlebarDragRegion />
|
|
<ScrollView
|
|
style={errorScrollViewStyle}
|
|
contentContainerStyle={styles.errorScrollContent}
|
|
showsVerticalScrollIndicator
|
|
>
|
|
<View style={styles.errorContent}>
|
|
<View style={styles.errorHeader}>
|
|
<PaseoLogo size={64} />
|
|
<Text style={styles.title}>Something went wrong</Text>
|
|
</View>
|
|
|
|
<Text style={styles.errorDescription}>
|
|
The local server failed to start. If this keeps happening, please report the issue on
|
|
GitHub and include the logs below.
|
|
</Text>
|
|
|
|
<Text dataSet={CODE_SURFACE_DATASET} style={styles.errorMessage}>
|
|
{bootstrapState.splashError}
|
|
</Text>
|
|
|
|
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
|
|
|
|
<View style={styles.logsContainer}>
|
|
<ScrollView
|
|
style={logsScrollStyle}
|
|
contentContainerStyle={styles.logsContent}
|
|
showsVerticalScrollIndicator
|
|
>
|
|
<Text dataSet={CODE_SURFACE_DATASET} selectable style={styles.logsText}>
|
|
{logsText}
|
|
</Text>
|
|
</ScrollView>
|
|
</View>
|
|
|
|
<View style={styles.actionRow}>
|
|
<Button variant="secondary" leftIcon={copyIcon} onPress={handleCopyLogs}>
|
|
Copy logs
|
|
</Button>
|
|
<Button variant="outline" leftIcon={warningIcon} onPress={openGithubIssue}>
|
|
Open GitHub issue
|
|
</Button>
|
|
<Button variant="outline" leftIcon={bookIcon} onPress={openDocs}>
|
|
Docs
|
|
</Button>
|
|
<Button variant="default" leftIcon={retryIcon} onPress={bootstrapState.retry}>
|
|
Retry
|
|
</Button>
|
|
</View>
|
|
</View>
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|