1import {
2 createContext,
3 startTransition,
4 use,
5 useCallback,
6 useEffect,
7 useState,
8 useLayoutEffect,
9 useMemo,
10 useRef,
11 type ReactNode,
12} from "react";
13import { AppState, Appearance, Platform, useColorScheme } from "react-native";
14
15import { useAtomSet, useAtomValue } from "@effect/atom-react";
16import { AsyncResult } from "effect/reactivity";
17
18import { ScopedTheme, ScopedVariables, Uniwind } from "uniwind";
19
20import {
21 resolveAppearance,
22 resolveAppearancePreferences,
23 type ResolvedAppearance,
24} from "../../../lib/appearancePreferences";
25import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences";
26import type { Preferences } from "../../../persistence/mobile-preferences";
27import { isSystemColorsAvailable, readSystemColorPalettes } from "../../../lib/materialYouPalette";
28import { materialYouPaletteToMobileThemeVariables } from "../../../lib/materialYouTheme";
29import { getMobileThemeRuntimeVariables } from "../../../lib/mobileThemeVariables";
30import type { MobileThemeVariables } from "../../../lib/mobileTheme";
31import {
32 createMobileThemePairPatch,
33 createMobileThemeSelectionPatch,
34 normalizeMobileThemeMode,
35 resolveMobileThemeIds,
36 type MobileThemeAppearance,
37 type MobileThemeId,
38 type MobileThemeIds,
39 type MobileThemeMode,
40} from "../../../lib/mobileTheme";
41import {
42 createMobileThemeRuntimeOperations,
43 getMobileUniwindThemeName,
44 type MobileThemeRuntimeState,
45} from "../../../lib/mobileThemeRuntime";
46
47interface AppearancePreferencesContextValue {
48 /** Effective values with base-size derivation applied. Use this for rendering. */
49 readonly appearance: ResolvedAppearance;
50 readonly themeId: MobileThemeId;
51 readonly themeIds: MobileThemeIds;
52 readonly themeMode: MobileThemeMode;
53 readonly themeAppearance: MobileThemeAppearance;
54 readonly systemColorsAvailable: boolean;
55 readonly systemColorsActive: boolean;
56 readonly themeVariables: MobileThemeVariables;
57 readonly themeVariablesByAppearance: Readonly<
58 Record<MobileThemeAppearance, MobileThemeVariables>
59 >;
60 readonly systemColorPalettes: ReturnType<typeof readSystemColorPalettes>;
61 readonly isReady: boolean;
62 readonly setThemeIdForAppearance: (
63 appearance: MobileThemeAppearance,
64 value: MobileThemeId,
65 ) => void;
66 readonly setThemeIdForBothAppearances: (value: MobileThemeId) => void;
67 readonly setThemeMode: (value: MobileThemeMode) => void;
68 readonly setBaseFontSize: (value: number) => void;
69 /** Pass null to clear the override and follow the base font size. */
70 readonly setTerminalFontSize: (value: number | null) => void;
71 /** Pass null to clear the override and follow the base font size. */
72 readonly setCodeFontSize: (value: number | null) => void;
73 readonly setCodeWordBreak: (value: boolean) => void;
74}
75
76const AppearancePreferencesContext = createContext<AppearancePreferencesContextValue | null>(null);
77
78export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) {
79 const preferencesResult = useAtomValue(mobilePreferencesAtom);
80 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
81 const systemColorScheme = useColorScheme() === "dark" ? "dark" : "light";
82 const storedPreferences = AsyncResult.isSuccess(preferencesResult)
83 ? preferencesResult.value
84 : null;
85 const preferences = useMemo(
86 () => resolveAppearancePreferences(storedPreferences),
87 [storedPreferences],
88 );
89 const themeMode = normalizeMobileThemeMode(storedPreferences?.themeMode);
90 const themeAppearance = themeMode === "system" ? systemColorScheme : themeMode;
91 const resolvedThemeIds = resolveMobileThemeIds(storedPreferences ?? {});
92 const themeIds = useMemo<MobileThemeIds>(
93 () => ({ light: resolvedThemeIds.light, dark: resolvedThemeIds.dark }),
94 [resolvedThemeIds.dark, resolvedThemeIds.light],
95 );
96 const themeId = themeIds[themeAppearance];
97 const systemColorsActive = themeId === "material-you" && isSystemColorsAvailable;
98 const [systemColorPalettes, setSystemColorPalettes] = useState(readSystemColorPalettes);
99 useEffect(() => {
100 if (!isSystemColorsAvailable) return;
101 const refresh = () => {
102 const next = readSystemColorPalettes();
103 setSystemColorPalettes((previous) =>
104 JSON.stringify(previous) === JSON.stringify(next) ? previous : next,
105 );
106 };
107 const subscription = AppState.addEventListener("change", (state) => {
108 if (state === "active") refresh();
109 });
110 const focusSubscription = AppState.addEventListener("focus", refresh);
111 return () => {
112 subscription.remove();
113 focusSubscription.remove();
114 };
115 }, []);
116 const themeVariablesByAppearance = useMemo(() => {
117 const resolve = (appearance: MobileThemeAppearance) => {
118 const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance, Platform.OS);
119 return themeIds[appearance] === "material-you" && systemColorPalettes
120 ? materialYouPaletteToMobileThemeVariables(
121 systemColorPalettes[appearance],
122 appearance,
123 base,
124 )
125 : base;
126 };
127 return { light: resolve("light"), dark: resolve("dark") };
128 }, [themeIds, systemColorPalettes]);
129 const themeVariables = themeVariablesByAppearance[themeAppearance];
130 const activeThemeName = getMobileUniwindThemeName(themeId, themeAppearance);
131 const { baseFontSize, codeFontSize, codeWordBreak, terminalFontSize } = preferences;
132 const appearance = useMemo(
133 () => resolveAppearance({ baseFontSize, codeFontSize, codeWordBreak, terminalFontSize }),
134 [baseFontSize, codeFontSize, codeWordBreak, terminalFontSize],
135 );
136 // Preference patches are optimistic. Keep controls interactive while a save is
137 // in flight so rapid theme choices can supersede one another immediately.
138 const isReady = AsyncResult.isSuccess(preferencesResult);
139 const runtimeState = useMemo<MobileThemeRuntimeState>(
140 () => ({
141 baseFontSize,
142 themeAppearance,
143 themeMode,
144 }),
145 [baseFontSize, themeAppearance, themeMode],
146 );
147 const appliedRuntimeStateRef = useRef<MobileThemeRuntimeState | null>(null);
148 const selectedThemeIdsRef = useRef(themeIds);
149
150 const applyThemeRuntime = useCallback((next: MobileThemeRuntimeState) => {
151 const operations = createMobileThemeRuntimeOperations(appliedRuntimeStateRef.current, next);
152 for (const operation of operations) {
153 if (operation.kind === "update-text-variables") {
154 Uniwind.updateCSSVariables(operation.themeName, operation.variables);
155 continue;
156 }
157 if (operation.kind === "set-appearance-mode") {
158 Appearance.setColorScheme(
159 operation.themeMode === "system" ? "unspecified" : operation.appearance,
160 );
161 }
162 }
163 appliedRuntimeStateRef.current = next;
164 }, []);
165
166 const syncThemeRuntime = useCallback(
167 (next: MobileThemeRuntimeState) => applyThemeRuntime(next),
168 [applyThemeRuntime],
169 );
170
171 const updatePreferences = useCallback(
172 (patch: Partial<Preferences>) => {
173 startTransition(() => savePreferences(patch));
174 },
175 [savePreferences],
176 );
177
178 const updateThemePreferences = useCallback(
179 (patch: Partial<Preferences>) => {
180 // Theme selection owns the visible root ScopedTheme value. Keep its
181 // optimistic atom update urgent so the first frame after a press is the
182 // complete new palette rather than a deferred transition render.
183 savePreferences(patch);
184 },
185 [savePreferences],
186 );
187
188 useLayoutEffect(() => {
189 selectedThemeIdsRef.current = themeIds;
190 syncThemeRuntime(runtimeState);
191 }, [runtimeState, syncThemeRuntime, themeIds]);
192
193 const setThemeIdForAppearance = useCallback(
194 (appearance: MobileThemeAppearance, value: MobileThemeId) => {
195 const patch = createMobileThemeSelectionPatch(
196 selectedThemeIdsRef.current,
197 themeAppearance,
198 appearance,
199 value,
200 );
201 selectedThemeIdsRef.current = resolveMobileThemeIds(patch);
202 updateThemePreferences(patch);
203 },
204 [themeAppearance, updateThemePreferences],
205 );
206
207 const setThemeIdForBothAppearances = useCallback(
208 (value: MobileThemeId) => {
209 const patch = createMobileThemePairPatch(value);
210 selectedThemeIdsRef.current = resolveMobileThemeIds(patch);
211 updateThemePreferences(patch);
212 },
213 [updateThemePreferences],
214 );
215
216 const setThemeMode = useCallback(
217 (value: MobileThemeMode) => {
218 const current = appliedRuntimeStateRef.current ?? runtimeState;
219
220 // Clear a forced native appearance before publishing System. The
221 // resulting useColorScheme notification still sees the previous forced
222 // preference, so React batches the actual system palette into the one
223 // urgent preference commit below.
224 if (value === "system") {
225 Appearance.setColorScheme("unspecified");
226 }
227 const nextAppearance =
228 value === "system" ? (Appearance.getColorScheme() === "dark" ? "dark" : "light") : value;
229 const next = {
230 ...current,
231 themeAppearance: nextAppearance,
232 themeMode: value,
233 };
234
235 updateThemePreferences({ themeMode: value });
236 if (value === "system") {
237 appliedRuntimeStateRef.current = next;
238 } else {
239 syncThemeRuntime(next);
240 }
241 },
242 [runtimeState, syncThemeRuntime, updateThemePreferences],
243 );
244
245 const setBaseFontSize = useCallback(
246 (value: number) => {
247 const current = appliedRuntimeStateRef.current ?? runtimeState;
248 syncThemeRuntime({ ...current, baseFontSize: value });
249 updatePreferences({ baseFontSize: value });
250 },
251 [runtimeState, syncThemeRuntime, updatePreferences],
252 );
253
254 const setTerminalFontSize = useCallback(
255 (value: number | null) => {
256 updatePreferences({ terminalFontSize: value });
257 },
258 [updatePreferences],
259 );
260
261 const setCodeFontSize = useCallback(
262 (value: number | null) => {
263 updatePreferences({ codeFontSize: value });
264 },
265 [updatePreferences],
266 );
267
268 const setCodeWordBreak = useCallback(
269 (value: boolean) => {
270 updatePreferences({ codeWordBreak: value });
271 },
272 [updatePreferences],
273 );
274
275 const value = useMemo(
276 (): AppearancePreferencesContextValue => ({
277 appearance,
278 themeId,
279 themeIds,
280 themeMode,
281 themeAppearance,
282 systemColorsAvailable: isSystemColorsAvailable,
283 systemColorsActive,
284 themeVariables,
285 themeVariablesByAppearance,
286 systemColorPalettes,
287 isReady,
288 setThemeIdForAppearance,
289 setThemeIdForBothAppearances,
290 setThemeMode,
291 setBaseFontSize,
292 setTerminalFontSize,
293 setCodeFontSize,
294 setCodeWordBreak,
295 }),
296 [
297 appearance,
298 themeId,
299 themeIds,
300 themeMode,
301 themeAppearance,
302 systemColorsActive,
303 themeVariables,
304 themeVariablesByAppearance,
305 systemColorPalettes,
306 isReady,
307 setThemeIdForAppearance,
308 setThemeIdForBothAppearances,
309 setThemeMode,
310 setBaseFontSize,
311 setTerminalFontSize,
312 setCodeFontSize,
313 setCodeWordBreak,
314 ],
315 );
316
317 return (
318 <AppearancePreferencesContext.Provider value={value}>
319 <ScopedTheme theme={activeThemeName}>
320 <ScopedVariables variables={themeVariables}>{props.children}</ScopedVariables>
321 </ScopedTheme>
322 </AppearancePreferencesContext.Provider>
323 );
324}
325
326export function useAppearancePreferences(): AppearancePreferencesContextValue {
327 const context = use(AppearancePreferencesContext);
328 if (!context) {
329 throw new Error("useAppearancePreferences must be used within AppearancePreferencesProvider");
330 }
331 return context;
332}