Select the types of activity you want to include in your feed.
feat: add components, theme/settings helpers, example app
Extracted from https://github.com/thehale/HabitSync-for-Todoist and modified slightly to provide more flexibility for other apps I'm upgrading as well.
···11-export function multiply(a: number, b: number): number {
22- return a * b;
33-}
11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+export { default as Button } from './components/Button';
88+export { default as Card } from './components/Card';
99+export { default as Dialog } from './components/Dialog';
1010+export { default as Divider } from './components/Divider';
1111+export { default as Row } from './components/Row';
1212+export { default as Text } from './components/Text';
1313+export { default as TextInput } from './components/TextInput';
1414+1515+export * from './theme';
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+import Card, { type CardProps } from './Card';
88+import { Modal, Pressable, StyleSheet, View } from 'react-native';
99+1010+import { useMaterialTheme } from '../theme/material/useMaterialTheme';
1111+1212+export interface DialogProps extends CardProps {
1313+ visible: boolean;
1414+ onDismiss: () => void;
1515+}
1616+1717+export default function Dialog(props: DialogProps) {
1818+ const { theme } = useMaterialTheme();
1919+ return (
2020+ <Modal
2121+ visible={props.visible}
2222+ animationType="fade"
2323+ transparent={true}
2424+ onRequestClose={props.onDismiss}
2525+ >
2626+ <Pressable
2727+ style={[styles.centered, { backgroundColor: theme.colors.backdrop }]}
2828+ onPress={props.onDismiss}
2929+ >
3030+ <View style={[styles.centered, styles.container]}>
3131+ <Card {...props} />
3232+ </View>
3333+ </Pressable>
3434+ </Modal>
3535+ );
3636+}
3737+3838+Dialog.Actions = Card.Actions;
3939+4040+const styles = StyleSheet.create({
4141+ centered: {
4242+ flex: 1,
4343+ justifyContent: 'center',
4444+ alignItems: 'center',
4545+ },
4646+ background: {
4747+ backgroundColor: 'rgba(0, 0, 0, 0.25)',
4848+ },
4949+ container: {
5050+ width: '80%',
5151+ height: 'auto',
5252+ },
5353+});
+25
src/components/Divider.tsx
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+import { StyleSheet, View } from 'react-native';
88+99+import { useMaterialTheme } from '../theme/material/useMaterialTheme';
1010+1111+export default function Divider() {
1212+ const { theme } = useMaterialTheme();
1313+ return (
1414+ <View
1515+ style={[styles.container, { backgroundColor: theme.colors.outline }]}
1616+ />
1717+ );
1818+}
1919+2020+const styles = StyleSheet.create({
2121+ container: {
2222+ width: '100%',
2323+ height: 1,
2424+ },
2525+});
+35
src/components/Row.tsx
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+import React from 'react';
88+import { StyleSheet, View } from 'react-native';
99+import type { StyleProp, ViewStyle } from 'react-native';
1010+1111+interface RowProps {
1212+ children: React.ReactNode;
1313+ style?: StyleProp<ViewStyle>;
1414+ itemStyle?: StyleProp<ViewStyle>;
1515+}
1616+1717+export default function Row(props: RowProps) {
1818+ return (
1919+ <View style={[styles.container, props.style]}>
2020+ {React.Children.map(props.children, (child, index) => (
2121+ <View key={index} style={props.itemStyle}>
2222+ {child}
2323+ </View>
2424+ ))}
2525+ </View>
2626+ );
2727+}
2828+2929+const styles = StyleSheet.create({
3030+ container: {
3131+ flexDirection: 'row',
3232+ alignItems: 'center',
3333+ justifyContent: 'space-between',
3434+ },
3535+});
+36
src/components/Text.tsx
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+import { Text as NativeText } from 'react-native';
88+import type { StyleProp, TextStyle } from 'react-native';
99+1010+import type { MaterialTheme } from '../theme/material/types';
1111+import { useMaterialTheme } from '../theme/material/useMaterialTheme';
1212+1313+type TextVariant = keyof MaterialTheme['fonts'];
1414+type TextSize = keyof MaterialTheme['fonts'][TextVariant];
1515+1616+export interface TextProps {
1717+ variant?: TextVariant | `${TextVariant} ${TextSize}`;
1818+ children: string | React.ReactNode;
1919+ style?: StyleProp<TextStyle>;
2020+}
2121+2222+export default function Text(props: TextProps) {
2323+ const { theme } = useMaterialTheme();
2424+ const colors: TextStyle = { color: theme.colors.onSurface };
2525+ const [variant, size] = (props.variant ?? 'body').split(' ') as [
2626+ TextVariant,
2727+ TextSize | undefined,
2828+ ];
2929+ return (
3030+ <NativeText
3131+ style={[theme.fonts[variant][size ?? 'medium'], colors, props.style]}
3232+ >
3333+ {props.children}
3434+ </NativeText>
3535+ );
3636+}
+64
src/components/TextInput.tsx
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+import { TextInput as NativeTextInput, StyleSheet, View } from 'react-native';
88+99+import Color from 'color';
1010+import { useMaterialTheme } from '../theme/material/useMaterialTheme';
1111+1212+interface TextInputProps {
1313+ value: string;
1414+ onChangeText: (text: string) => void;
1515+ placeholder?: string;
1616+ before?: React.ReactNode;
1717+ after?: React.ReactNode;
1818+}
1919+2020+export default function TextInput(props: TextInputProps) {
2121+ const { theme } = useMaterialTheme();
2222+ return (
2323+ <View
2424+ style={[
2525+ styles.container,
2626+ {
2727+ backgroundColor: theme.colors.secondaryContainer,
2828+ borderColor: theme.colors.primary,
2929+ },
3030+ ]}
3131+ >
3232+ {props.before}
3333+ <NativeTextInput
3434+ style={[styles.input, { color: theme.colors.onSecondaryContainer }]}
3535+ placeholder={`${props.placeholder}...`}
3636+ placeholderTextColor={Color(theme.colors.onSecondaryContainer)
3737+ .fade(0.5)
3838+ .string()}
3939+ value={props.value}
4040+ onChangeText={props.onChangeText}
4141+ autoCapitalize="none"
4242+ autoCorrect={false}
4343+ underlineColorAndroid={'transparent'}
4444+ />
4545+ {props.after}
4646+ </View>
4747+ );
4848+}
4949+5050+const styles = StyleSheet.create({
5151+ container: {
5252+ flexDirection: 'row',
5353+ justifyContent: 'space-between',
5454+ alignItems: 'center',
5555+ borderRadius: 8,
5656+ gap: 8,
5757+ paddingHorizontal: 8,
5858+ borderBottomWidth: 1,
5959+ },
6060+ input: {
6161+ borderRadius: 8,
6262+ flex: 1,
6363+ },
6464+});
+41
src/settings/SettingsStore.ts
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+import { NamedSubscriberStore } from './_utils/NamedSubscriberStore';
88+import type { Settings } from './types';
99+1010+export class SettingsStore<S extends Settings> extends NamedSubscriberStore<{
1111+ [K in keyof S]: S[K]['default'];
1212+}> {
1313+ settings: S;
1414+ constructor(settings: S) {
1515+ super(toDefaults(settings));
1616+ this.settings = settings;
1717+ }
1818+1919+ reset() {
2020+ this.update(toDefaults(this.settings));
2121+ }
2222+2323+ protected isValidUpdate<K extends keyof S>(
2424+ key: K,
2525+ value: S[K]['default']
2626+ ): boolean {
2727+ return this.settings[key]!.validate(value);
2828+ }
2929+ protected onUpdate<K extends keyof S>(key: K, value: S[K]['default']): void {
3030+ this.settings[key]!.update(value);
3131+ }
3232+}
3333+3434+function toDefaults<S extends Settings>(
3535+ settings: S
3636+): { [K in keyof S]: S[K]['default'] } {
3737+ // @ts-expect-error Object.fromEntries is type-lossy
3838+ return Object.fromEntries(
3939+ Object.entries(settings).map(([key, value]) => [key, value.default])
4040+ );
4141+}
+43
src/settings/createSettings.ts
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+import { useCallback, useSyncExternalStore } from 'react';
88+99+import type { Settings } from './types';
1010+import { SettingsStore } from './SettingsStore';
1111+1212+export function createSettings<S extends Settings>(definitions: S) {
1313+ const store = new SettingsStore(definitions);
1414+1515+ async function initSettings() {
1616+ const entries = await Promise.all(
1717+ Object.entries(definitions).map(async ([key, setting]) => [
1818+ key,
1919+ await setting.read(),
2020+ ])
2121+ );
2222+ store.update(Object.fromEntries(entries));
2323+ }
2424+2525+ function useSettings(callerName: string = 'settings') {
2626+ const subscribe = useCallback<Parameters<typeof useSyncExternalStore>[0]>(
2727+ (listener) => store.subscribe(listener, callerName),
2828+ [callerName]
2929+ );
3030+ const getSnapshot = useCallback(() => store.getSnapshot(), []);
3131+3232+ const snapshot = useSyncExternalStore(subscribe, getSnapshot);
3333+ const update = useCallback(
3434+ (updates: Partial<typeof snapshot>) => store.update(updates),
3535+ []
3636+ );
3737+ const resetToDefaults = useCallback(() => store.reset(), []);
3838+3939+ return [snapshot, update, resetToDefaults] as const;
4040+ }
4141+4242+ return { initSettings, useSettings, settingsStore: store };
4343+}
+10
src/settings/index.ts
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+export type { Setting, Settings } from './types';
88+99+export { SettingsStore } from './SettingsStore';
1010+export { createSettings } from './createSettings';
+14
src/settings/types.ts
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+export interface Setting<Value> {
88+ default: Value;
99+ validate: (value: Value) => boolean;
1010+ update: (value: Value) => Promise<void>;
1111+ read: () => Promise<Value>;
1212+}
1313+1414+export type Settings = Record<string, Setting<any>>;
+52
src/stores/KeyValueStore.ts
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+import { debounce, type DebouncedFunc } from 'lodash';
88+99+export interface ExternalKeyValueStore {
1010+ get: (key: string) => Promise<string | null>;
1111+ set: (key: string, value: string) => Promise<void>;
1212+}
1313+1414+export default class KeyValueStore {
1515+ private external: ExternalKeyValueStore;
1616+ private setters = new Map<
1717+ string,
1818+ DebouncedFunc<(value: string) => Promise<void>>
1919+ >();
2020+2121+ constructor(external?: ExternalKeyValueStore) {
2222+ this.external = external ?? new InMemoryKeyValueStore();
2323+ }
2424+2525+ async put(key: string, value: string): Promise<void> {
2626+ if (!this.setters.has(key)) {
2727+ const setter = async (newVal: string) =>
2828+ await this.external.set(key, newVal);
2929+ this.setters.set(key, debounce(setter, 1000, { trailing: true }));
3030+ return setter(value);
3131+ } else {
3232+ return this.setters.get(key)!(value);
3333+ }
3434+ }
3535+3636+ async read(key: string, defaultValue: string = ''): Promise<string> {
3737+ const value = await this.external.get(key);
3838+ return value ?? defaultValue;
3939+ }
4040+}
4141+4242+class InMemoryKeyValueStore implements ExternalKeyValueStore {
4343+ private store = new Map<string, string>();
4444+4545+ async get(key: string): Promise<string | null> {
4646+ return this.store.get(key) ?? null;
4747+ }
4848+4949+ async set(key: string, value: string): Promise<void> {
5050+ this.store.set(key, value);
5151+ }
5252+}
+77
src/theme/createTheme.ts
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+import { createSettings } from '../settings/createSettings';
88+import type { Settings, Setting } from '../settings/types';
99+import KeyValueStore from '../stores/KeyValueStore';
1010+import { useColorScheme as useSystemColorScheme } from 'react-native';
1111+import type { Theme, ThemeColors, ThemeDefinition } from './types';
1212+1313+type ColorScheme = 'light' | 'dark' | 'system';
1414+const COLOR_SCHEMES = ['light', 'dark', 'system'] as const;
1515+const DEFAULT_COLOR_SCHEME = 'system';
1616+const DEFAULT_SYSTEM_COLOR_SCHEME = 'light';
1717+1818+export function createTheme<
1919+ C extends ThemeColors,
2020+ T extends ThemeDefinition<C> = ThemeDefinition<C>,
2121+>(defaultTheme: T) {
2222+ const themeStore = new KeyValueStore();
2323+ const keys = { theme: 'theme', scheme: 'scheme' };
2424+2525+ const stringifiedDefaultTheme = JSON.stringify(defaultTheme);
2626+2727+ const definitions = {
2828+ theme: {
2929+ default: defaultTheme,
3030+ validate: (_value: T) => true, // If it passes TypeScript, it's valid
3131+ update: async (value: T) =>
3232+ await themeStore.put(keys.theme, JSON.stringify(value)),
3333+ read: async () =>
3434+ JSON.parse(
3535+ await themeStore.read(keys.theme, stringifiedDefaultTheme)
3636+ ) as T,
3737+ } satisfies Setting<T>,
3838+ scheme: {
3939+ default: DEFAULT_COLOR_SCHEME as ColorScheme,
4040+ validate: (value: ColorScheme) => COLOR_SCHEMES.includes(value),
4141+ update: async (value: ColorScheme) =>
4242+ await themeStore.put(keys.scheme, value),
4343+ read: async () =>
4444+ (await themeStore.read(
4545+ keys.scheme,
4646+ DEFAULT_COLOR_SCHEME
4747+ )) as ColorScheme,
4848+ } satisfies Setting<ColorScheme>,
4949+ } satisfies Settings;
5050+5151+ const { useSettings, settingsStore } = createSettings(definitions);
5252+5353+ function initTheme(theme: T, scheme: ColorScheme = DEFAULT_COLOR_SCHEME) {
5454+ settingsStore.update({ theme, scheme });
5555+ }
5656+5757+ function useTheme() {
5858+ const [settings, updateSettings, resetSettings] = useSettings('useTheme');
5959+ const systemColorScheme = useSystemColorScheme();
6060+6161+ const currentScheme =
6262+ settings.scheme === 'system'
6363+ ? (systemColorScheme ?? DEFAULT_SYSTEM_COLOR_SCHEME)
6464+ : settings.scheme;
6565+ const colors =
6666+ currentScheme === 'dark' ? settings.theme.dark : settings.theme.light;
6767+6868+ return {
6969+ theme: { fonts: settings.theme.fonts, colors } as Theme<C>,
7070+ setTheme: (theme: T) => updateSettings({ theme }),
7171+ setScheme: (scheme: ColorScheme) => updateSettings({ scheme }),
7272+ resetTheme: resetSettings,
7373+ };
7474+ }
7575+7676+ return { initTheme, useTheme };
7777+}
+11
src/theme/index.ts
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+export type { Theme, ThemeColors, ThemeDefinition } from './types';
88+export { createTheme } from './createTheme';
99+1010+export * from './fonts';
1111+export * from './material';
+21
src/theme/types.ts
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+import type { ThemeFonts } from './fonts/types';
88+99+export interface ThemeColors extends Record<string, string | ThemeColors> {}
1010+1111+export type Theme<C extends ThemeColors> = {
1212+ fonts: ThemeFonts;
1313+ colors: C;
1414+};
1515+1616+export type ThemeDefinition<C extends ThemeColors> = {
1717+ name: string;
1818+ fonts: ThemeFonts;
1919+ dark: C;
2020+ light: C;
2121+};
+10
src/utils/typing.ts
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+/**
88+ * `Optional<Type, Union>` makes only the attributes in `Union` optional, as opposed to `Partial<Type>` which makes _all_ attributes of `Type` optional.
99+ */
1010+export type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>; // Source: https://stackoverflow.com/a/61108377/14765128
+183
src/settings/__tests__/SettingStore.test.ts
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+import { SettingsStore } from '../SettingsStore';
88+import type { Setting, Settings } from '../types';
99+1010+describe('settings', () => {
1111+ it('initialize to their defined default values', () => {
1212+ const store = new SettingsStore(createTestDefinitions());
1313+1414+ expect(store.getSnapshot()).toEqual({
1515+ dessert: 'cookies',
1616+ price: 10,
1717+ open: true,
1818+ });
1919+ });
2020+2121+ it('can update one at a time', async () => {
2222+ const store = new SettingsStore(createTestDefinitions());
2323+2424+ store.update({ dessert: 'pie' });
2525+2626+ expect(store.getSnapshot()).toEqual({
2727+ dessert: 'pie',
2828+ price: 10,
2929+ open: true,
3030+ });
3131+ });
3232+3333+ it('can update multiple at once', async () => {
3434+ const store = new SettingsStore(createTestDefinitions());
3535+3636+ store.update({ dessert: 'cake', price: 20, open: false });
3737+3838+ expect(store.getSnapshot()).toEqual({
3939+ dessert: 'cake',
4040+ price: 20,
4141+ open: false,
4242+ });
4343+ });
4444+4545+ it('rejects invalid updates', async () => {
4646+ const store = new SettingsStore(createTestDefinitions());
4747+4848+ store.update({
4949+ dessert: 'brussel sprouts',
5050+ price: -5,
5151+ open: 'yes' as unknown as boolean,
5252+ });
5353+5454+ expect(store.getSnapshot()).toEqual({
5555+ dessert: 'cookies',
5656+ price: 10,
5757+ open: true,
5858+ });
5959+ });
6060+6161+ it('rejects updates to unknown settings', async () => {
6262+ const store = new SettingsStore(createTestDefinitions());
6363+6464+ // @ts-expect-error Testing invalid key
6565+ store.update({ unknown: 'value' });
6666+6767+ expect(store.getSnapshot()).toEqual({
6868+ dessert: 'cookies',
6969+ price: 10,
7070+ open: true,
7171+ });
7272+ });
7373+7474+ it('can reset to default values', async () => {
7575+ const store = new SettingsStore(createTestDefinitions());
7676+7777+ store.update({ dessert: 'pie', price: 15, open: false });
7878+ expect(store.getSnapshot()).toEqual({
7979+ dessert: 'pie',
8080+ price: 15,
8181+ open: false,
8282+ });
8383+8484+ store.reset();
8585+ expect(store.getSnapshot()).toEqual({
8686+ dessert: 'cookies',
8787+ price: 10,
8888+ open: true,
8989+ });
9090+ });
9191+});
9292+9393+describe('subscribe', () => {
9494+ it('supports listening for updates', async () => {
9595+ const store = new SettingsStore(createTestDefinitions());
9696+ const listener = jest.fn();
9797+9898+ const unsubscribe = store.subscribe(listener, 'test-listener');
9999+ store.update({ dessert: 'cake' });
100100+101101+ expect(listener).toHaveBeenCalledTimes(1);
102102+ expect(listener).toHaveBeenCalledWith();
103103+104104+ unsubscribe();
105105+ store.update({ price: 20 });
106106+107107+ expect(listener).toHaveBeenCalledTimes(1); // No additional calls after unsubscribe
108108+ });
109109+110110+ it('does not notify listeners on invalid updates', () => {
111111+ const store = new SettingsStore(createTestDefinitions());
112112+ const listener = jest.fn();
113113+114114+ store.subscribe(listener, 'test-listener');
115115+ store.update({ dessert: 'brussel sprouts' });
116116+117117+ expect(listener).not.toHaveBeenCalled();
118118+ });
119119+120120+ it('does not notify listeners when no values change', () => {
121121+ const store = new SettingsStore(createTestDefinitions());
122122+ const listener = jest.fn();
123123+124124+ store.subscribe(listener, 'test-listener');
125125+ store.update({ dessert: 'cookies' }); // same as default
126126+127127+ expect(listener).not.toHaveBeenCalled();
128128+ });
129129+});
130130+131131+describe('snapshots', () => {
132132+ test('stay stable across reads', () => {
133133+ const store = new SettingsStore(createTestDefinitions());
134134+135135+ const snapshot1 = store.getSnapshot();
136136+ const snapshot2 = store.getSnapshot();
137137+138138+ expect(snapshot1).toBe(snapshot2);
139139+ });
140140+141141+ test('update after state change', () => {
142142+ const store = new SettingsStore(createTestDefinitions());
143143+144144+ const snapshot1 = store.getSnapshot();
145145+ store.update({ dessert: 'pie' });
146146+ const snapshot2 = store.getSnapshot();
147147+148148+ expect(snapshot1).not.toBe(snapshot2);
149149+ expect(snapshot2).toEqual({ dessert: 'pie', price: 10, open: true });
150150+ });
151151+});
152152+153153+function createTestDefinitions() {
154154+ let dessert = 'cookies';
155155+ let price = 10;
156156+ let open = true;
157157+ return {
158158+ dessert: {
159159+ default: 'cookies',
160160+ validate: (value: string) => ['cookies', 'cake', 'pie'].includes(value),
161161+ update: async (value: string) => {
162162+ dessert = value;
163163+ },
164164+ read: async () => dessert,
165165+ } satisfies Setting<string>,
166166+ price: {
167167+ default: 10,
168168+ validate: (value: number) => value >= 0,
169169+ update: async (value: number) => {
170170+ price = value;
171171+ },
172172+ read: async () => price,
173173+ } satisfies Setting<number>,
174174+ open: {
175175+ default: true as boolean,
176176+ validate: (value: boolean) => typeof value === 'boolean',
177177+ update: async (value: boolean) => {
178178+ open = value;
179179+ },
180180+ read: async () => open,
181181+ } satisfies Setting<boolean>,
182182+ } satisfies Settings;
183183+}
+70
src/settings/_utils/NamedSubscriberStore.ts
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+import structuredClone from '@ungap/structured-clone';
88+99+export class NamedSubscriberStore<State extends Record<string, any>> {
1010+ state: State;
1111+ validKeys: Set<keyof State>;
1212+ listeners = new Map<string, Array<Function>>();
1313+ snapshot: State | null = null;
1414+1515+ constructor(defaultState: State) {
1616+ this.state = defaultState;
1717+ this.validKeys = new Set(Object.keys(defaultState));
1818+ }
1919+2020+ subscribe(callback: Function, name: string = 'unknown') {
2121+ const namedListeners = this.listeners.get(name) ?? [];
2222+ namedListeners.push(callback);
2323+ this.listeners.set(name, namedListeners);
2424+2525+ const unsubscribe = () => {
2626+ const listeners = this.listeners.get(name) ?? [];
2727+ this.listeners.set(
2828+ name,
2929+ listeners.filter((l) => l !== callback)
3030+ );
3131+ };
3232+ return unsubscribe;
3333+ }
3434+3535+ update(updates: Partial<State>) {
3636+ let changed = false;
3737+ for (const key in updates) {
3838+ if (
3939+ this.validKeys.has(key) &&
4040+ updates[key] !== undefined &&
4141+ updates[key] !== this.state[key] &&
4242+ this.isValidUpdate(key, updates[key])
4343+ ) {
4444+ this.state[key] = updates[key];
4545+ this.onUpdate(key, updates[key]);
4646+ changed = true;
4747+ }
4848+ }
4949+ if (changed) {
5050+ this.snapshot = null; // Invalidate snapshot to ensure fresh data on next getSnapshot
5151+ this.listeners.forEach((batch) => batch.forEach((notify) => notify()));
5252+ }
5353+ }
5454+5555+ /** Assesses if the key can be assigned to the value */
5656+ protected isValidUpdate<K extends keyof State>(
5757+ _key: K,
5858+ _value: State[K]
5959+ ): boolean {
6060+ return true;
6161+ }
6262+6363+ /** Allows child classes to trigger additional effects after successful updates (e.g. sync external stores) */
6464+ protected onUpdate<K extends keyof State>(_key: K, _value: State[K]) {}
6565+6666+ getSnapshot(): State {
6767+ if (this.snapshot === null) this.snapshot = structuredClone(this.state);
6868+ return this.snapshot;
6969+ }
7070+}
+62
src/stores/__tests__/KeyValueStore.test.ts
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+import KeyValueStore, { type ExternalKeyValueStore } from '../KeyValueStore';
88+99+describe('default behavior', () => {
1010+ it('stores and retrieves values', async () => {
1111+ const store = new KeyValueStore();
1212+1313+ await store.put('key', 'value');
1414+ await store.put('dessert', 'cookies');
1515+1616+ expect(await store.read('key')).toBe('value');
1717+ expect(await store.read('dessert')).toBe('cookies');
1818+ });
1919+2020+ it('returns default value if key not found', async () => {
2121+ const store = new KeyValueStore();
2222+2323+ expect(await store.read('cookies', 'default')).toBe('default');
2424+ });
2525+2626+ it('returns empty string if key not found and no default value provided', async () => {
2727+ const store = new KeyValueStore();
2828+2929+ expect(await store.read('cookies')).toBe('');
3030+ });
3131+});
3232+3333+describe('with custom external store', () => {
3434+ class TestExternalStore implements ExternalKeyValueStore {
3535+ async get(key: string): Promise<string | null> {
3636+ return key === 'exists' ? 'stored value' : null;
3737+ }
3838+ async set(_key: string, _value: string): Promise<void> {
3939+ /* no-op */
4040+ }
4141+ }
4242+4343+ it('uses external store to read values', async () => {
4444+ const store = new KeyValueStore(new TestExternalStore());
4545+4646+ store.put('exists', 'new value'); // should be ignored by external store
4747+4848+ expect(await store.read('exists')).toBe('stored value');
4949+ });
5050+5151+ it('returns a default value when external store returns null', async () => {
5252+ const store = new KeyValueStore(new TestExternalStore());
5353+5454+ expect(await store.read('nonexistent', 'default')).toBe('default');
5555+ });
5656+5757+ it('returns an empty string when external store returns null and no default provided', async () => {
5858+ const store = new KeyValueStore(new TestExternalStore());
5959+6060+ expect(await store.read('nonexistent')).toBe('');
6161+ });
6262+});
+101
src/theme/__tests__/useTheme.test.ts
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+import DefaultFonts from '../fonts/DefaultFonts';
88+import type { ThemeColors, ThemeDefinition } from '../types';
99+import { createTheme } from '../createTheme';
1010+import { act, renderHook } from '@testing-library/react-native';
1111+1212+interface BasicColors extends ThemeColors {
1313+ primary: string;
1414+ background: string;
1515+}
1616+1717+const basicTheme: ThemeDefinition<BasicColors> = {
1818+ name: 'basic',
1919+ fonts: DefaultFonts,
2020+ light: {
2121+ primary: 'basic-light-primary',
2222+ background: 'basic-light-background',
2323+ } satisfies BasicColors,
2424+ dark: {
2525+ primary: 'basic-dark-primary',
2626+ background: 'basic-dark-background',
2727+ } satisfies BasicColors,
2828+};
2929+3030+const altTheme: ThemeDefinition<BasicColors> = {
3131+ name: 'basic-alt',
3232+ fonts: DefaultFonts,
3333+ light: {
3434+ primary: 'alt-light-primary',
3535+ background: 'alt-light-background',
3636+ },
3737+ dark: {
3838+ primary: 'alt-dark-primary',
3939+ background: 'alt-dark-background',
4040+ },
4141+};
4242+4343+describe('useTheme', () => {
4444+ it('provides the default theme', () => {
4545+ const { useTheme } = createTheme<BasicColors>(basicTheme);
4646+4747+ const { result } = renderHook(() => useTheme());
4848+ const { theme } = result.current;
4949+5050+ expect(theme.colors.primary).toBe(basicTheme.light.primary);
5151+ expect(theme.colors.background).toBe(basicTheme.light.background);
5252+ });
5353+5454+ it('can initialize a default theme/scheme', () => {
5555+ const { initTheme, useTheme } = createTheme<BasicColors>(altTheme);
5656+ initTheme(altTheme, 'dark');
5757+5858+ const { result } = renderHook(() => useTheme());
5959+ const { theme } = result.current;
6060+6161+ expect(theme.colors.primary).toBe(altTheme.dark.primary);
6262+ expect(theme.colors.background).toBe(altTheme.dark.background);
6363+ });
6464+6565+ it('reacts to theme changes', () => {
6666+ const { useTheme } = createTheme<BasicColors>(basicTheme);
6767+6868+ const { result } = renderHook(() => useTheme());
6969+ const { setTheme } = result.current;
7070+ act(() => setTheme(altTheme));
7171+ const { theme } = result.current;
7272+7373+ expect(theme.colors.primary).toBe(altTheme.light.primary);
7474+ expect(theme.colors.background).toBe(altTheme.light.background);
7575+ });
7676+7777+ it('reacts to color scheme changes', () => {
7878+ const { useTheme } = createTheme<BasicColors>(basicTheme);
7979+8080+ const { result } = renderHook(() => useTheme());
8181+ const { setScheme } = result.current;
8282+ act(() => setScheme('dark'));
8383+ const { theme } = result.current;
8484+8585+ expect(theme.colors.primary).toBe(basicTheme.dark.primary);
8686+ expect(theme.colors.background).toBe(basicTheme.dark.background);
8787+ });
8888+8989+ it('can reset to default theme and scheme', () => {
9090+ const { initTheme, useTheme } = createTheme<BasicColors>(basicTheme);
9191+ initTheme(altTheme, 'dark');
9292+9393+ const { result } = renderHook(() => useTheme());
9494+ const { resetTheme } = result.current;
9595+ act(() => resetTheme());
9696+ const { theme } = result.current;
9797+9898+ expect(theme.colors.primary).toBe(basicTheme.light.primary);
9999+ expect(theme.colors.background).toBe(basicTheme.light.background);
100100+ });
101101+});
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+export type { ThemeFonts } from './types';
88+export { default as DefaultFonts, withFontFamily } from './DefaultFonts';
+45
src/theme/fonts/types.ts
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+// Modified from React Native Paper. Used under the terms of the MIT license.
88+// https://github.com/callstack/react-native-paper/blob/ff0df5454eb13d6e8e2d8f1c87c0bca8bb3635f0/src/types.tsx#
99+1010+export type ThemeFonts = {
1111+ display: ThemeFont;
1212+ headline: ThemeFont;
1313+ title: ThemeFont;
1414+ label: ThemeFont;
1515+ body: ThemeFont;
1616+};
1717+1818+////////////////////////////////////////////////////////////
1919+// Private types below here.
2020+2121+type ThemeFont = {
2222+ small: Font;
2323+ medium: Font;
2424+ large: Font;
2525+};
2626+2727+type Font = {
2828+ fontFamily: string;
2929+ fontWeight:
3030+ | 'normal'
3131+ | 'bold'
3232+ | '100'
3333+ | '200'
3434+ | '300'
3535+ | '400'
3636+ | '500'
3737+ | '600'
3838+ | '700'
3939+ | '800'
4040+ | '900';
4141+ fontStyle: 'normal' | 'italic';
4242+ fontSize: number;
4343+ letterSpacing: number;
4444+ lineHeight: number;
4545+};
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+export type {
88+ MaterialTheme,
99+ MaterialThemeDefinition,
1010+ MaterialThemeColors,
1111+} from './types';
1212+1313+export { initMaterialTheme, useMaterialTheme } from './useMaterialTheme';
1414+export * as MaterialColors from './MaterialColors';
1515+1616+export { default as MaterialBlue } from './MaterialBlue';
1717+export { default as MaterialCyan } from './MaterialCyan';
1818+export { default as MaterialGreen } from './MaterialGreen';
1919+export { default as MaterialOrange } from './MaterialOrange';
2020+export { default as MaterialPink } from './MaterialPink';
2121+export { default as MaterialRed } from './MaterialRed';
2222+export { default as MaterialYellow } from './MaterialYellow';
+66
src/theme/material/types.ts
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+// Modified from React Native Paper. Used under the terms of the MIT license.
88+// https://github.com/callstack/react-native-paper/blob/ff0df5454eb13d6e8e2d8f1c87c0bca8bb3635f0/src/types.tsx#
99+1010+import type { ThemeFonts } from '../fonts/types';
1111+import type { Theme, ThemeDefinition, ThemeColors } from '../types';
1212+1313+export interface MaterialTheme extends Theme<MaterialThemeColors> {
1414+ fonts: ThemeFonts;
1515+ colors: MaterialThemeColors;
1616+}
1717+1818+export interface MaterialThemeDefinition
1919+ extends ThemeDefinition<MaterialThemeColors> {
2020+ fonts: ThemeFonts;
2121+ dark: MaterialThemeColors;
2222+ light: MaterialThemeColors;
2323+}
2424+2525+export interface MaterialThemeColors extends ThemeColors {
2626+ primary: string;
2727+ primaryContainer: string;
2828+ secondary: string;
2929+ secondaryContainer: string;
3030+ tertiary: string;
3131+ tertiaryContainer: string;
3232+ surface: string;
3333+ surfaceVariant: string;
3434+ surfaceDisabled: string;
3535+ background: string;
3636+ error: string;
3737+ errorContainer: string;
3838+ onPrimary: string;
3939+ onPrimaryContainer: string;
4040+ onSecondary: string;
4141+ onSecondaryContainer: string;
4242+ onTertiary: string;
4343+ onTertiaryContainer: string;
4444+ onSurface: string;
4545+ onSurfaceVariant: string;
4646+ onSurfaceDisabled: string;
4747+ onError: string;
4848+ onErrorContainer: string;
4949+ onBackground: string;
5050+ outline: string;
5151+ outlineVariant: string;
5252+ inverseSurface: string;
5353+ inverseOnSurface: string;
5454+ inversePrimary: string;
5555+ shadow: string;
5656+ scrim: string;
5757+ backdrop: string;
5858+ elevation: {
5959+ level0: string;
6060+ level1: string;
6161+ level2: string;
6262+ level3: string;
6363+ level4: string;
6464+ level5: string;
6565+ };
6666+}
+13
src/theme/material/useMaterialTheme.ts
···11+// Copyright (c) 2025 Joseph Hale
22+//
33+// This Source Code Form is subject to the terms of the Mozilla Public
44+// License, v. 2.0. If a copy of the MPL was not distributed with this
55+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66+77+import type { MaterialThemeColors } from './types';
88+import { createTheme } from '../createTheme';
99+import MaterialGreen from './MaterialGreen';
1010+1111+const { initTheme, useTheme } = createTheme<MaterialThemeColors>(MaterialGreen);
1212+1313+export { initTheme as initMaterialTheme, useTheme as useMaterialTheme };