import { memo, useId } from "react";import { Pressable, View } from "react-native";import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; import { mixThemePreviewBase, THEME_PREVIEW_RENDER_SPECS } from "@t3tools/shared/themePreview"; import { SymbolView } from "../../../../components/AppSymbol";import { AppText as Text } from "../../../../components/AppText";import { getMobileThemeVariables, getMobileThemePreviewColors, MOBILE_THEME_OPTIONS, type MobileThemeAppearance, type MobileThemeId, type MobileThemeIds, type MobileThemeMode, type MobileThemeVariables,} from "../../../../lib/mobileTheme";import { useThemeColor } from "../../../../lib/useThemeColor";import { useAppearancePreferences } from "../AppearancePreferencesProvider"; const APPEARANCE_MODES: ReadonlyArray<{ readonly id: MobileThemeMode; readonly label: string;}> = [ { id: "system", label: "System" }, { id: "light", label: "Light" }, { id: "dark", label: "Dark" },]; const PreviewOrb = memo(function PreviewOrb(props: { readonly appearance: MobileThemeAppearance; readonly compact?: boolean; readonly themeId: MobileThemeId;}) { const idPrefix = useId().replaceAll(":", ""); const accentGradientId = `${idPrefix}-accent-glow`; const actionGradientId = `${idPrefix}-action-glow`; const colors = getMobileThemePreviewColors(props.themeId, props.appearance); const spec = THEME_PREVIEW_RENDER_SPECS[props.appearance]; const accentRadius = Math.hypot( Math.max(spec.accent.center[0], 1 - spec.accent.center[0]), Math.max(spec.accent.center[1], 1 - spec.accent.center[1]), ); const actionRadius = Math.hypot( Math.max(spec.action.center[0], 1 - spec.action.center[0]), Math.max(spec.action.center[1], 1 - spec.action.center[1]), ); const position = (value: number) => `${value * 100}%`; const radius = (value: number) => `${value * 100}%`; return ( <View className={`${props.compact ? "size-14" : "size-16"} overflow-hidden rounded-full border`} style={{ borderColor: props.appearance === "dark" ? "rgba(255, 255, 255, 0.14)" : "rgba(0, 0, 0, 0.1)", }} > <Svg accessibilityElementsHidden height="100%" viewBox="0 0 64 64" width="100%"> <Defs> <RadialGradient cx={position(spec.accent.center[0])} cy={position(spec.accent.center[1])} fx={position(spec.accent.center[0])} fy={position(spec.accent.center[1])} id={accentGradientId} r={radius(accentRadius)} > <Stop offset="0%" stopColor={colors.accent} stopOpacity={1} /> <Stop offset={position(spec.accent.middleOffset)} stopColor={colors.accent} stopOpacity={spec.accent.middleOpacity} /> <Stop offset={position(spec.accent.endOffset)} stopColor={colors.accent} stopOpacity={0} /> <Stop offset="100%" stopColor={colors.accent} stopOpacity={0} /> </RadialGradient> <RadialGradient cx={position(spec.action.center[0])} cy={position(spec.action.center[1])} fx={position(spec.action.center[0])} fy={position(spec.action.center[1])} id={actionGradientId} r={radius(actionRadius)} > <Stop offset="0%" stopColor={colors.messageAction} stopOpacity={spec.action.startOpacity} /> <Stop offset={position(spec.action.endOffset)} stopColor={colors.messageAction} stopOpacity={0} /> <Stop offset="100%" stopColor={colors.messageAction} stopOpacity={0} /> </RadialGradient> </Defs> <Circle cx="32" cy="32" fill={mixThemePreviewBase(colors, props.appearance)} r="32" /> <Circle cx="32" cy="32" fill={`url(#${actionGradientId})`} r="32" /> <Circle cx="32" cy="32" fill={`url(#${accentGradientId})`} r="32" /> </Svg> </View> );}); function ThemeCard(props: { readonly disabled: boolean; readonly darkSelected: boolean; readonly label: string; readonly lightSelected: boolean; readonly onSelectBoth: () => void; readonly onSelect: (appearance: MobileThemeAppearance) => void; readonly themeId: MobileThemeId;}) { const badgeBackground = useThemeColor("--color-card"); const badgeIcon = useThemeColor("--color-icon"); const choice = (appearance: MobileThemeAppearance, selected: boolean) => ( <Pressable accessibilityLabel={`${props.label} ${appearance} theme`} accessibilityRole="radio" accessibilityState={{ checked: selected, disabled: props.disabled }} className={ selected ? "size-[66px] items-center justify-center rounded-full border-[3px] border-primary" : "size-[66px] items-center justify-center rounded-full border-[3px] border-transparent" } disabled={props.disabled} onPress={() => props.onSelect(appearance)} > <PreviewOrb appearance={appearance} compact themeId={props.themeId} /> {selected ? ( <View className="absolute -bottom-0.5 -right-0.5 size-5 items-center justify-center rounded-full border border-border" style={{ backgroundColor: badgeBackground }} > <SymbolView name={appearance === "light" ? "sun.max" : "moon"} size={12} tintColor={badgeIcon} type="monochrome" weight="medium" /> </View> ) : null} </Pressable> ); return ( <View className="min-w-36 flex-1 basis-[47%] gap-3 rounded-[24px] border border-border bg-card px-2 py-4"> <Pressable accessibilityHint="Sets both light and dark appearances" accessibilityLabel={`${props.label} theme`} accessibilityRole="button" accessibilityState={{ disabled: props.disabled }} className="absolute inset-0 rounded-[24px] active:bg-subtle" disabled={props.disabled} onPress={props.onSelectBoth} /> <View className="flex-row items-center justify-center gap-2 py-1" pointerEvents="box-none"> {choice("light", props.lightSelected)} {choice("dark", props.darkSelected)} </View> <View className="min-h-8 flex-row items-center" pointerEvents="none"> <Text className="min-w-0 flex-1 text-lg font-t3-medium" numberOfLines={1}> {props.label} </Text> </View> </View> );} function PreviewPane(props: { readonly colors: MobileThemeVariables; readonly compact?: boolean }) { return ( <View className="flex-1 overflow-hidden" style={{ backgroundColor: props.colors["--color-screen"] }} > <View className={props.compact ? "h-[18px] gap-0.5 px-1" : "h-[18px] gap-1 px-1.5"} style={{ backgroundColor: props.colors["--color-card"] }} > <View className="mt-2 flex-row items-center gap-1"> <View className="size-1.5 rounded-full" style={{ backgroundColor: props.colors["--color-primary"] }} /> <View className="h-1 flex-1 rounded-full" style={{ backgroundColor: props.colors["--color-foreground-muted"] }} /> </View> </View> <View className={ props.compact ? "flex-1 justify-between px-1 py-2" : "flex-1 justify-between px-1.5 py-2" } > <View className="gap-1"> <View className="h-1.5 w-[72%] rounded-full" style={{ backgroundColor: props.colors["--color-subtle-strong"] }} /> <View className="h-1.5 w-[46%] rounded-full" style={{ backgroundColor: props.colors["--color-subtle-strong"] }} /> </View> <View className="items-end gap-1 pb-2"> <View className="h-3 w-[78%] rounded-full" style={{ backgroundColor: props.colors["--color-user-bubble"] }} /> <View className="h-1 w-[38%] rounded-full" style={{ backgroundColor: props.colors["--color-foreground-muted"] }} /> </View> </View> </View> );} function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: MobileThemeIds }) { const light = getMobileThemeVariables(props.themeIds.light, "light"); const dark = getMobileThemeVariables(props.themeIds.dark, "dark"); const currentBorder = useThemeColor("--color-border"); const currentFrame = useThemeColor("--color-drawer"); const currentIndicator = useThemeColor("--color-foreground-muted"); const frameColor = props.mode === "light" ? light["--color-border"] : props.mode === "dark" ? dark["--color-border"] : currentBorder; const frameBackground = props.mode === "light" ? light["--color-drawer"] : props.mode === "dark" ? dark["--color-drawer"] : currentFrame; const indicatorColor = props.mode === "light" ? light["--color-foreground-muted"] : props.mode === "dark" ? dark["--color-foreground-muted"] : currentIndicator; return ( <View className="h-24 w-14 self-center rounded-[16px] p-[3px]" style={{ backgroundColor: frameBackground, borderColor: frameColor, borderWidth: 1.5 }} > <View className="flex-1 flex-row overflow-hidden rounded-[11px]"> {props.mode === "system" ? ( <> <PreviewPane colors={light} compact /> <PreviewPane colors={dark} compact /> </> ) : ( <PreviewPane colors={props.mode === "light" ? light : dark} /> )} </View> <View className="absolute bottom-[6px] left-1/2 h-1 w-4 -translate-x-1/2 rounded-full" style={{ backgroundColor: indicatorColor }} /> </View> );} function ModeCard(props: { readonly disabled: boolean; readonly label: string; readonly mode: MobileThemeMode; readonly onPress: () => void; readonly selected: boolean; readonly themeIds: MobileThemeIds;}) { return ( <Pressable accessibilityLabel={`${props.label} appearance`} accessibilityRole="radio" accessibilityState={{ checked: props.selected, disabled: props.disabled }} className={ props.selected ? "min-w-0 flex-1 gap-2 rounded-[24px] border-2 border-primary bg-subtle p-2" : "min-w-0 flex-1 gap-2 rounded-[24px] border border-border bg-card p-2" } disabled={props.disabled} onPress={props.onPress} > <ModePreview mode={props.mode} themeIds={props.themeIds} /> <Text className={ props.selected ? "text-center text-base font-t3-bold text-foreground" : "text-center text-base text-foreground-muted" } > {props.label} </Text> </Pressable> );} function SectionLabel({ children }: { readonly children: string }) { return <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{children}</Text>;} export function ThemeAppearanceSection() { const { isReady, setThemeIdForAppearance, setThemeIdForBothAppearances, setThemeMode, themeIds, themeMode, } = useAppearancePreferences(); return ( <View className="gap-6"> <View className="gap-2"> <SectionLabel>Color scheme</SectionLabel> <View accessibilityRole="radiogroup" className="flex-row gap-2"> {APPEARANCE_MODES.map((mode) => ( <ModeCard disabled={!isReady} key={mode.id} label={mode.label} mode={mode.id} onPress={() => setThemeMode(mode.id)} selected={mode.id === themeMode} themeIds={themeIds} /> ))} </View> </View> <View className="gap-3"> <SectionLabel>Themes</SectionLabel> <View className="flex-row flex-wrap gap-3"> {MOBILE_THEME_OPTIONS.map((theme) => ( <ThemeCard disabled={!isReady} key={theme.id} label={theme.label} darkSelected={theme.id === themeIds.dark} lightSelected={theme.id === themeIds.light} onSelect={(appearance) => setThemeIdForAppearance(appearance, theme.id)} onSelectBoth={() => setThemeIdForBothAppearances(theme.id)} themeId={theme.id} /> ))} </View> </View> </View> );}
import { memo, useId } from "react";
import { Pressable, View } from "react-native";
import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg";
import { mixThemePreviewBase, THEME_PREVIEW_RENDER_SPECS } from "@t3tools/shared/themePreview";
import { SymbolView } from "../../../../components/AppSymbol";
import { AppText as Text } from "../../../../components/AppText";
import {
getMobileThemeVariables,
getMobileThemePreviewColors,
MOBILE_THEME_OPTIONS,
type MobileThemeAppearance,
type MobileThemeId,
type MobileThemeIds,
type MobileThemeMode,
type MobileThemeVariables,
} from "../../../../lib/mobileTheme";
import { useThemeColor } from "../../../../lib/useThemeColor";
import { useAppearancePreferences } from "../AppearancePreferencesProvider";
const APPEARANCE_MODES: ReadonlyArray<{
readonly id: MobileThemeMode;
readonly label: string;
}> = [
{ id: "system", label: "System" },
{ id: "light", label: "Light" },
{ id: "dark", label: "Dark" },
];
const PreviewOrb = memo(function PreviewOrb(props: {
readonly appearance: MobileThemeAppearance;
readonly compact?: boolean;
readonly themeId: MobileThemeId;
}) {
const idPrefix = useId().replaceAll(":", "");
const accentGradientId = `${idPrefix}-accent-glow`;
const actionGradientId = `${idPrefix}-action-glow`;
const colors = getMobileThemePreviewColors(props.themeId, props.appearance);
const spec = THEME_PREVIEW_RENDER_SPECS[props.appearance];
const accentRadius = Math.hypot(
Math.max(spec.accent.center[0], 1 - spec.accent.center[0]),
Math.max(spec.accent.center[1], 1 - spec.accent.center[1]),
);
const actionRadius = Math.hypot(
Math.max(spec.action.center[0], 1 - spec.action.center[0]),
Math.max(spec.action.center[1], 1 - spec.action.center[1]),
);
const position = (value: number) => `${value * 100}%`;
const radius = (value: number) => `${value * 100}%`;
return (
<View
className={`${props.compact ? "size-14" : "size-16"} overflow-hidden rounded-full border`}
style={{
borderColor:
props.appearance === "dark" ? "rgba(255, 255, 255, 0.14)" : "rgba(0, 0, 0, 0.1)",
}}
>
<Svg accessibilityElementsHidden height="100%" viewBox="0 0 64 64" width="100%">
<Defs>
<RadialGradient
cx={position(spec.accent.center[0])}
cy={position(spec.accent.center[1])}
fx={position(spec.accent.center[0])}
fy={position(spec.accent.center[1])}
id={accentGradientId}
r={radius(accentRadius)}
>
<Stop offset="0%" stopColor={colors.accent} stopOpacity={1} />
<Stop
offset={position(spec.accent.middleOffset)}
stopColor={colors.accent}
stopOpacity={spec.accent.middleOpacity}
/>
<Stop
offset={position(spec.accent.endOffset)}
stopColor={colors.accent}
stopOpacity={0}
/>
<Stop offset="100%" stopColor={colors.accent} stopOpacity={0} />
</RadialGradient>
<RadialGradient
cx={position(spec.action.center[0])}
cy={position(spec.action.center[1])}
fx={position(spec.action.center[0])}
fy={position(spec.action.center[1])}
id={actionGradientId}
r={radius(actionRadius)}
>
<Stop
offset="0%"
stopColor={colors.messageAction}
stopOpacity={spec.action.startOpacity}
/>
<Stop
offset={position(spec.action.endOffset)}
stopColor={colors.messageAction}
stopOpacity={0}
/>
<Stop offset="100%" stopColor={colors.messageAction} stopOpacity={0} />
</RadialGradient>
</Defs>
<Circle cx="32" cy="32" fill={mixThemePreviewBase(colors, props.appearance)} r="32" />
<Circle cx="32" cy="32" fill={`url(#${actionGradientId})`} r="32" />
<Circle cx="32" cy="32" fill={`url(#${accentGradientId})`} r="32" />
</Svg>
</View>
);
});
function ThemeCard(props: {
readonly disabled: boolean;
readonly darkSelected: boolean;
readonly label: string;
readonly lightSelected: boolean;
readonly onSelectBoth: () => void;
readonly onSelect: (appearance: MobileThemeAppearance) => void;
readonly themeId: MobileThemeId;
}) {
const badgeBackground = useThemeColor("--color-card");
const badgeIcon = useThemeColor("--color-icon");
const choice = (appearance: MobileThemeAppearance, selected: boolean) => (
<Pressable
accessibilityLabel={`${props.label} ${appearance} theme`}
accessibilityRole="radio"
accessibilityState={{ checked: selected, disabled: props.disabled }}
className={
selected
? "size-[66px] items-center justify-center rounded-full border-[3px] border-primary"
: "size-[66px] items-center justify-center rounded-full border-[3px] border-transparent"
}
disabled={props.disabled}
onPress={() => props.onSelect(appearance)}
>
<PreviewOrb appearance={appearance} compact themeId={props.themeId} />
{selected ? (
<View
className="absolute -bottom-0.5 -right-0.5 size-5 items-center justify-center rounded-full border border-border"
style={{ backgroundColor: badgeBackground }}
>
<SymbolView
name={appearance === "light" ? "sun.max" : "moon"}
size={12}
tintColor={badgeIcon}
type="monochrome"
weight="medium"
/>
</View>
) : null}
</Pressable>
);
return (
<View className="min-w-36 flex-1 basis-[47%] gap-3 rounded-[24px] border border-border bg-card px-2 py-4">
<Pressable
accessibilityHint="Sets both light and dark appearances"
accessibilityLabel={`${props.label} theme`}
accessibilityRole="button"
accessibilityState={{ disabled: props.disabled }}
className="absolute inset-0 rounded-[24px] active:bg-subtle"
disabled={props.disabled}
onPress={props.onSelectBoth}
/>
<View className="flex-row items-center justify-center gap-2 py-1" pointerEvents="box-none">
{choice("light", props.lightSelected)}
{choice("dark", props.darkSelected)}
</View>
<View className="min-h-8 flex-row items-center" pointerEvents="none">
<Text className="min-w-0 flex-1 text-lg font-t3-medium" numberOfLines={1}>
{props.label}
</Text>
</View>
</View>
);
}
function PreviewPane(props: { readonly colors: MobileThemeVariables; readonly compact?: boolean }) {
return (
<View
className="flex-1 overflow-hidden"
style={{ backgroundColor: props.colors["--color-screen"] }}
>
<View
className={props.compact ? "h-[18px] gap-0.5 px-1" : "h-[18px] gap-1 px-1.5"}
style={{ backgroundColor: props.colors["--color-card"] }}
>
<View className="mt-2 flex-row items-center gap-1">
<View
className="size-1.5 rounded-full"
style={{ backgroundColor: props.colors["--color-primary"] }}
/>
<View
className="h-1 flex-1 rounded-full"
style={{ backgroundColor: props.colors["--color-foreground-muted"] }}
/>
</View>
</View>
<View
className={
props.compact ? "flex-1 justify-between px-1 py-2" : "flex-1 justify-between px-1.5 py-2"
}
>
<View className="gap-1">
<View
className="h-1.5 w-[72%] rounded-full"
style={{ backgroundColor: props.colors["--color-subtle-strong"] }}
/>
<View
className="h-1.5 w-[46%] rounded-full"
style={{ backgroundColor: props.colors["--color-subtle-strong"] }}
/>
</View>
<View className="items-end gap-1 pb-2">
<View
className="h-3 w-[78%] rounded-full"
style={{ backgroundColor: props.colors["--color-user-bubble"] }}
/>
<View
className="h-1 w-[38%] rounded-full"
style={{ backgroundColor: props.colors["--color-foreground-muted"] }}
/>
</View>
</View>
</View>
);
}
function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: MobileThemeIds }) {
const light = getMobileThemeVariables(props.themeIds.light, "light");
const dark = getMobileThemeVariables(props.themeIds.dark, "dark");
const currentBorder = useThemeColor("--color-border");
const currentFrame = useThemeColor("--color-drawer");
const currentIndicator = useThemeColor("--color-foreground-muted");
const frameColor =
props.mode === "light"
? light["--color-border"]
: props.mode === "dark"
? dark["--color-border"]
: currentBorder;
const frameBackground =
props.mode === "light"
? light["--color-drawer"]
: props.mode === "dark"
? dark["--color-drawer"]
: currentFrame;
const indicatorColor =
props.mode === "light"
? light["--color-foreground-muted"]
: props.mode === "dark"
? dark["--color-foreground-muted"]
: currentIndicator;
return (
<View
className="h-24 w-14 self-center rounded-[16px] p-[3px]"
style={{ backgroundColor: frameBackground, borderColor: frameColor, borderWidth: 1.5 }}
>
<View className="flex-1 flex-row overflow-hidden rounded-[11px]">
{props.mode === "system" ? (
<>
<PreviewPane colors={light} compact />
<PreviewPane colors={dark} compact />
</>
) : (
<PreviewPane colors={props.mode === "light" ? light : dark} />
)}
</View>
<View
className="absolute bottom-[6px] left-1/2 h-1 w-4 -translate-x-1/2 rounded-full"
style={{ backgroundColor: indicatorColor }}
/>
</View>
);
}
function ModeCard(props: {
readonly disabled: boolean;
readonly label: string;
readonly mode: MobileThemeMode;
readonly onPress: () => void;
readonly selected: boolean;
readonly themeIds: MobileThemeIds;
}) {
return (
<Pressable
accessibilityLabel={`${props.label} appearance`}
accessibilityRole="radio"
accessibilityState={{ checked: props.selected, disabled: props.disabled }}
className={
props.selected
? "min-w-0 flex-1 gap-2 rounded-[24px] border-2 border-primary bg-subtle p-2"
: "min-w-0 flex-1 gap-2 rounded-[24px] border border-border bg-card p-2"
}
disabled={props.disabled}
onPress={props.onPress}
>
<ModePreview mode={props.mode} themeIds={props.themeIds} />
<Text
className={
props.selected
? "text-center text-base font-t3-bold text-foreground"
: "text-center text-base text-foreground-muted"
}
>
{props.label}
</Text>
</Pressable>
);
}
function SectionLabel({ children }: { readonly children: string }) {
return <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{children}</Text>;
}
export function ThemeAppearanceSection() {
const {
isReady,
setThemeIdForAppearance,
setThemeIdForBothAppearances,
setThemeMode,
themeIds,
themeMode,
} = useAppearancePreferences();
return (
<View className="gap-6">
<View className="gap-2">
<SectionLabel>Color scheme</SectionLabel>
<View accessibilityRole="radiogroup" className="flex-row gap-2">
{APPEARANCE_MODES.map((mode) => (
<ModeCard
disabled={!isReady}
key={mode.id}
label={mode.label}
mode={mode.id}
onPress={() => setThemeMode(mode.id)}
selected={mode.id === themeMode}
themeIds={themeIds}
/>
))}
</View>
</View>
<View className="gap-3">
<SectionLabel>Themes</SectionLabel>
<View className="flex-row flex-wrap gap-3">
{MOBILE_THEME_OPTIONS.map((theme) => (
<ThemeCard
disabled={!isReady}
key={theme.id}
label={theme.label}
darkSelected={theme.id === themeIds.dark}
lightSelected={theme.id === themeIds.light}
onSelect={(appearance) => setThemeIdForAppearance(appearance, theme.id)}
onSelectBoth={() => setThemeIdForBothAppearances(theme.id)}
themeId={theme.id}
/>
))}
</View>
</View>
</View>
);
}