1import { memo, useId } from "react";
2import { Pressable, View } from "react-native";
3import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg";
4import { ScopedTheme, ScopedVariables } from "uniwind";
5
6import { mixThemePreviewBase, THEME_PREVIEW_RENDER_SPECS } from "@t3tools/shared/themePreview";
7
8import { SymbolView } from "../../../../components/AppSymbol";
9import { AppText as Text } from "../../../../components/AppText";
10import {
11 getMobileThemePreviewColors,
12 MOBILE_THEME_OPTIONS,
13 type MobileThemeAppearance,
14 type MobileThemeId,
15 type MobileThemeIds,
16 type MobileThemeMode,
17} from "../../../../lib/mobileTheme";
18import { getMobileUniwindThemeName } from "../../../../lib/mobileThemeRuntime";
19import { cn } from "../../../../lib/cn";
20import { useAppearancePreferences } from "../AppearancePreferencesProvider";
21
22const APPEARANCE_MODES: ReadonlyArray<{
23 readonly id: MobileThemeMode;
24 readonly label: string;
25}> = [
26 { id: "system", label: "System" },
27 { id: "light", label: "Light" },
28 { id: "dark", label: "Dark" },
29];
30
31const previewPercentage = (value: number) => `${value * 100}%`;
32
33const PreviewOrb = memo(function PreviewOrb(props: {
34 readonly appearance: MobileThemeAppearance;
35 readonly compact?: boolean;
36 readonly themeId: MobileThemeId;
37}) {
38 const idPrefix = useId().replaceAll(":", "");
39 const accentGradientId = `${idPrefix}-accent-glow`;
40 const actionGradientId = `${idPrefix}-action-glow`;
41 const { systemColorPalettes } = useAppearancePreferences();
42 const palette = systemColorPalettes?.[props.appearance];
43 const colors =
44 props.themeId === "material-you" && palette
45 ? { canvas: palette.surface, accent: palette.primary, messageAction: palette.tertiary }
46 : getMobileThemePreviewColors(props.themeId, props.appearance);
47 const spec = THEME_PREVIEW_RENDER_SPECS[props.appearance];
48 const accentRadius = Math.hypot(
49 Math.max(spec.accent.center[0], 1 - spec.accent.center[0]),
50 Math.max(spec.accent.center[1], 1 - spec.accent.center[1]),
51 );
52 const actionRadius = Math.hypot(
53 Math.max(spec.action.center[0], 1 - spec.action.center[0]),
54 Math.max(spec.action.center[1], 1 - spec.action.center[1]),
55 );
56 return (
57 <View
58 className={`${props.compact ? "size-14" : "size-16"} overflow-hidden rounded-full border`}
59 style={{
60 borderColor:
61 props.appearance === "dark" ? "rgba(255, 255, 255, 0.14)" : "rgba(0, 0, 0, 0.1)",
62 }}
63 >
64 <Svg accessibilityElementsHidden height="100%" viewBox="0 0 64 64" width="100%">
65 <Defs>
66 <RadialGradient
67 cx={previewPercentage(spec.accent.center[0])}
68 cy={previewPercentage(spec.accent.center[1])}
69 fx={previewPercentage(spec.accent.center[0])}
70 fy={previewPercentage(spec.accent.center[1])}
71 id={accentGradientId}
72 r={previewPercentage(accentRadius)}
73 >
74 <Stop offset="0%" stopColor={colors.accent} stopOpacity={1} />
75 <Stop
76 offset={previewPercentage(spec.accent.middleOffset)}
77 stopColor={colors.accent}
78 stopOpacity={spec.accent.middleOpacity}
79 />
80 <Stop
81 offset={previewPercentage(spec.accent.endOffset)}
82 stopColor={colors.accent}
83 stopOpacity={0}
84 />
85 <Stop offset="100%" stopColor={colors.accent} stopOpacity={0} />
86 </RadialGradient>
87 <RadialGradient
88 cx={previewPercentage(spec.action.center[0])}
89 cy={previewPercentage(spec.action.center[1])}
90 fx={previewPercentage(spec.action.center[0])}
91 fy={previewPercentage(spec.action.center[1])}
92 id={actionGradientId}
93 r={previewPercentage(actionRadius)}
94 >
95 <Stop
96 offset="0%"
97 stopColor={colors.messageAction}
98 stopOpacity={spec.action.startOpacity}
99 />
100 <Stop
101 offset={previewPercentage(spec.action.endOffset)}
102 stopColor={colors.messageAction}
103 stopOpacity={0}
104 />
105 <Stop offset="100%" stopColor={colors.messageAction} stopOpacity={0} />
106 </RadialGradient>
107 </Defs>
108 <Circle cx="32" cy="32" fill={mixThemePreviewBase(colors, props.appearance)} r="32" />
109 <Circle cx="32" cy="32" fill={`url(#${actionGradientId})`} r="32" />
110 <Circle cx="32" cy="32" fill={`url(#${accentGradientId})`} r="32" />
111 </Svg>
112 </View>
113 );
114});
115
116function ThemeCard(props: {
117 readonly disabled: boolean;
118 readonly darkSelected: boolean;
119 readonly label: string;
120 readonly lightSelected: boolean;
121 readonly onSelectBoth: () => void;
122 readonly onSelect: (appearance: MobileThemeAppearance) => void;
123 readonly themeId: MobileThemeId;
124}) {
125 const choice = (appearance: MobileThemeAppearance, selected: boolean) => (
126 <Pressable
127 accessibilityHint={`Sets the ${appearance} appearance only`}
128 accessibilityLabel={`${props.label} ${appearance} theme`}
129 accessibilityRole="button"
130 accessibilityState={{ disabled: props.disabled, selected }}
131 className={cn(
132 "size-[66px] items-center justify-center rounded-full border-[3px] active:scale-[0.94]",
133 selected ? "border-primary" : "border-transparent",
134 )}
135 disabled={props.disabled}
136 onPress={() => props.onSelect(appearance)}
137 >
138 <PreviewOrb appearance={appearance} compact themeId={props.themeId} />
139 {selected ? (
140 <View className="absolute -bottom-0.5 -right-0.5 size-5 items-center justify-center rounded-full border border-border bg-card">
141 <SymbolView
142 name={appearance === "light" ? "sun.max" : "moon"}
143 size={12}
144 tintColorClassName="accent-icon"
145 type="monochrome"
146 weight="medium"
147 />
148 </View>
149 ) : null}
150 </Pressable>
151 );
152
153 return (
154 <View className="min-w-36 flex-1 basis-[47%] gap-3 rounded-[24px] border border-border bg-grouped-card px-2 py-4">
155 <Pressable
156 accessibilityHint="Sets both light and dark appearances"
157 accessibilityLabel={`${props.label} theme`}
158 accessibilityRole="button"
159 accessibilityState={{
160 disabled: props.disabled,
161 selected: props.lightSelected && props.darkSelected,
162 }}
163 className="absolute inset-0 rounded-[24px] active:bg-subtle"
164 disabled={props.disabled}
165 onPress={props.onSelectBoth}
166 />
167 <View className="flex-row items-center justify-center gap-2 py-1" pointerEvents="box-none">
168 {choice("light", props.lightSelected)}
169 {choice("dark", props.darkSelected)}
170 </View>
171 <Text
172 className="min-w-0 flex-1 px-1 text-lg font-t3-medium"
173 numberOfLines={1}
174 pointerEvents="none"
175 >
176 {props.label}
177 </Text>
178 </View>
179 );
180}
181
182function PreviewPane(props: { readonly compact?: boolean }) {
183 return (
184 <View className="flex-1 overflow-hidden bg-screen">
185 <View
186 className={cn("bg-card", props.compact ? "h-[18px] gap-0.5 px-1" : "h-[18px] gap-1 px-1.5")}
187 >
188 <View className="mt-2 flex-row items-center gap-1">
189 <View className="size-1.5 rounded-full bg-primary" />
190 <View className="h-1 flex-1 rounded-full bg-foreground-muted" />
191 </View>
192 </View>
193 <View
194 className={
195 props.compact ? "flex-1 justify-between px-1 py-2" : "flex-1 justify-between px-1.5 py-2"
196 }
197 >
198 <View className="gap-1">
199 <View className="h-1.5 w-[72%] rounded-full bg-subtle-strong" />
200 <View className="h-1.5 w-[46%] rounded-full bg-subtle-strong" />
201 </View>
202 <View className="items-end gap-1 pb-2">
203 <View className="h-3 w-[78%] rounded-full bg-user-bubble" />
204 <View className="h-1 w-[38%] rounded-full bg-foreground-muted" />
205 </View>
206 </View>
207 </View>
208 );
209}
210
211function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: MobileThemeIds }) {
212 const { themeVariablesByAppearance } = useAppearancePreferences();
213 if (props.mode === "system") {
214 return (
215 <View className="h-24 w-14 self-center rounded-[16px] border-[1.5px] border-border bg-drawer p-[3px]">
216 <View className="flex-1 flex-row overflow-hidden rounded-[11px]">
217 <ScopedTheme theme={getMobileUniwindThemeName(props.themeIds.light, "light")}>
218 <ScopedVariables variables={themeVariablesByAppearance.light}>
219 <PreviewPane compact />
220 </ScopedVariables>
221 </ScopedTheme>
222 <ScopedTheme theme={getMobileUniwindThemeName(props.themeIds.dark, "dark")}>
223 <ScopedVariables variables={themeVariablesByAppearance.dark}>
224 <PreviewPane compact />
225 </ScopedVariables>
226 </ScopedTheme>
227 </View>
228 <View className="absolute bottom-[6px] left-1/2 h-1 w-4 -translate-x-1/2 rounded-full bg-foreground-muted" />
229 </View>
230 );
231 }
232
233 return (
234 <ScopedTheme theme={getMobileUniwindThemeName(props.themeIds[props.mode], props.mode)}>
235 <View className="h-24 w-14 self-center rounded-[16px] border-[1.5px] border-border bg-drawer p-[3px]">
236 <View className="flex-1 flex-row overflow-hidden rounded-[11px]">
237 <ScopedVariables variables={themeVariablesByAppearance[props.mode]}>
238 <PreviewPane />
239 </ScopedVariables>
240 </View>
241 <View className="absolute bottom-[6px] left-1/2 h-1 w-4 -translate-x-1/2 rounded-full bg-foreground-muted" />
242 </View>
243 </ScopedTheme>
244 );
245}
246
247function ModeCard(props: {
248 readonly disabled: boolean;
249 readonly label: string;
250 readonly mode: MobileThemeMode;
251 readonly onPress: () => void;
252 readonly selected: boolean;
253 readonly themeIds: MobileThemeIds;
254}) {
255 return (
256 <Pressable
257 accessibilityLabel={`${props.label} appearance`}
258 accessibilityRole="radio"
259 accessibilityState={{ checked: props.selected, disabled: props.disabled }}
260 className={cn(
261 "min-w-0 flex-1 gap-2 rounded-[24px] p-2 active:scale-[0.97]",
262 props.selected
263 ? "border-2 border-primary bg-subtle"
264 : "border border-border bg-grouped-card",
265 )}
266 disabled={props.disabled}
267 onPress={props.onPress}
268 >
269 <ModePreview mode={props.mode} themeIds={props.themeIds} />
270 <Text
271 className={
272 props.selected
273 ? "text-center text-base font-t3-bold text-foreground"
274 : "text-center text-base text-foreground-muted"
275 }
276 >
277 {props.label}
278 </Text>
279 </Pressable>
280 );
281}
282
283function SectionLabel({ children }: { readonly children: string }) {
284 return <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{children}</Text>;
285}
286
287export function ThemeAppearanceSection() {
288 const {
289 isReady,
290 setThemeIdForAppearance,
291 setThemeIdForBothAppearances,
292 setThemeMode,
293 themeIds,
294 themeMode,
295 systemColorsAvailable,
296 } = useAppearancePreferences();
297
298 return (
299 <View className="gap-6">
300 <View className="gap-2">
301 <SectionLabel>Color scheme</SectionLabel>
302 <View accessibilityRole="radiogroup" className="flex-row gap-2">
303 {APPEARANCE_MODES.map((mode) => (
304 <ModeCard
305 disabled={!isReady}
306 key={mode.id}
307 label={mode.label}
308 mode={mode.id}
309 onPress={() => setThemeMode(mode.id)}
310 selected={mode.id === themeMode}
311 themeIds={themeIds}
312 />
313 ))}
314 </View>
315 </View>
316
317 <View className="gap-3">
318 <SectionLabel>Themes</SectionLabel>
319 <View className="flex-row flex-wrap gap-3">
320 {MOBILE_THEME_OPTIONS.filter(
321 (theme) => theme.id !== "material-you" || systemColorsAvailable,
322 ).map((theme) => (
323 <ThemeCard
324 disabled={!isReady}
325 key={theme.id}
326 label={theme.label}
327 darkSelected={theme.id === themeIds.dark}
328 lightSelected={theme.id === themeIds.light}
329 onSelect={(appearance) => setThemeIdForAppearance(appearance, theme.id)}
330 onSelectBoth={() => setThemeIdForBothAppearances(theme.id)}
331 themeId={theme.id}
332 />
333 ))}
334 </View>
335 </View>
336 </View>
337 );
338}