| 1 | import { memo, useId } from "react"; |
| 2 | import { Pressable, View } from "react-native"; |
| 3 | import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; |
| 4 | import { ScopedTheme, ScopedVariables } from "uniwind"; |
| 5 | |
| 6 | import { mixThemePreviewBase, THEME_PREVIEW_RENDER_SPECS } from "@t3tools/shared/themePreview"; |
| 7 | |
| 8 | import { SymbolView } from "../../../../components/AppSymbol"; |
| 9 | import { AppText as Text } from "../../../../components/AppText"; |
| 10 | import { |
| 11 | getMobileThemePreviewColors, |
| 12 | MOBILE_THEME_OPTIONS, |
| 13 | type MobileThemeAppearance, |
| 14 | type MobileThemeId, |
| 15 | type MobileThemeIds, |
| 16 | type MobileThemeMode, |
| 17 | } from "../../../../lib/mobileTheme"; |
| 18 | import { getMobileUniwindThemeName } from "../../../../lib/mobileThemeRuntime"; |
| 19 | import { cn } from "../../../../lib/cn"; |
| 20 | import { useAppearancePreferences } from "../AppearancePreferencesProvider"; |
| 21 | |
| 22 | const 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 | |
| 31 | const previewPercentage = (value: number) => `${value * 100}%`; |
| 32 | |
| 33 | const 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 | |
| 116 | function 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 | |
| 182 | function 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 | |
| 211 | function 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 | |
| 247 | function 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 | |
| 283 | function SectionLabel({ children }: { readonly children: string }) { |
| 284 | return <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{children}</Text>; |
| 285 | } |
| 286 | |
| 287 | export 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 | } |