Theme colour setting: seven accents, persisted, applied app-wide

This commit is contained in:
Jude.Chen committed 2026-10-06 22:37:42 +08:00
1 parent b2cb6d454b
commit fc947db6de
9 files changed
+213 -10

No files matched your search

+36 -3
View File
@@ -7,7 +7,7 @@ import { Screen } from '@/components/screen';
import { Section, Row } from '@/components/section';
import { ScreenHeader } from '@/components/screen-header';
import { ThemedText } from '@/components/themed-text';
import { Radius, Spacing } from '@/constants/theme';
import { Accents, Radius, Spacing, type AccentName } from '@/constants/theme';
import {
useAccountActions,
useAccountViews,
@@ -18,7 +18,7 @@ import {
import { useTheme } from '@/hooks/use-theme';
import { useT, type LocaleSetting, type ThemeSetting } from '@/i18n';
import { usePreferences } from '@/providers/preferences';
import { Button, Segmented } from '@/ui/controls';
import { Button, ColorSwatches, Segmented } from '@/ui/controls';
import { haptics } from '@/ui/haptics';
import { enterFade, enterItem, exitFade, layoutShift } from '@/ui/motion';
import { ProviderIcon } from '@/ui/provider-icon';
@@ -28,6 +28,17 @@ const GITHUB_HANDLE = 'nextroad-dev';
const GITHUB_URL = 'https://github.com/nextroad-dev';
const SPONSOR_URL = 'https://afdian.com/a/nextroad';
/** Spoken and shown names of the theme colours (English keys, translated with t()). */
const ACCENT_LABELS: Record<AccentName, string> = {
blue: 'Blue',
indigo: 'Indigo',
violet: 'Violet',
pink: 'Pink',
orange: 'Orange',
green: 'Green',
teal: 'Teal',
};
export default function SettingsScreen() {
const router = useRouter();
const t = useT();
@@ -38,7 +49,14 @@ export default function SettingsScreen() {
const { settings, update } = useAlertSettings();
const permission = useNotificationPermission();
const background = useBackgroundState(settings.backgroundRefresh);
const { localeSetting, themeSetting, setLocaleSetting, setThemeSetting } = usePreferences();
const {
localeSetting,
themeSetting,
accentSetting,
setLocaleSetting,
setThemeSetting,
setAccentSetting,
} = usePreferences();
const confirmClear = () =>
Alert.alert(
@@ -111,6 +129,21 @@ export default function SettingsScreen() {
onChange={(v) => setThemeSetting(v)}
/>
</View>
<View style={{ gap: Spacing.two }}>
<ThemedText type="small" themeColor="textSecondary">
{t('Theme color')}
</ThemedText>
<ColorSwatches<AccentName>
label={t('Theme color')}
options={(Object.keys(Accents) as AccentName[]).map((name) => ({
value: name,
color: Accents[name],
label: t(ACCENT_LABELS[name]),
}))}
value={accentSetting}
onChange={setAccentSetting}
/>
</View>
</Section>
<Section title={t('Accounts')}>
+28 -1
View File
@@ -1,7 +1,34 @@
import { Colors } from '@/constants/theme';
import { Accents, Colors, DEFAULT_ACCENT, isAccentName } from '@/constants/theme';
/** WCAG relative luminance of a #RRGGBB colour. */
function luminance(hex: string): number {
const [r, g, b] = [1, 3, 5].map((i) => {
const c = parseInt(hex.slice(i, i + 2), 16) / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
const contrast = (a: string, b: string) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
describe('theme tokens', () => {
it('defines the same keys for light and dark', () => {
expect(Object.keys(Colors.dark).sort()).toEqual(Object.keys(Colors.light).sort());
});
it('keeps white button text readable on every theme colour', () => {
for (const [name, color] of Object.entries(Accents)) {
expect([name, contrast(color, '#FFFFFF') >= 4.5]).toEqual([name, true]);
}
});
it('defaults to the original blue and validates stored names', () => {
expect(Accents[DEFAULT_ACCENT]).toBe(Colors.light.primary);
expect(isAccentName('teal')).toBe(true);
expect(isAccentName('neon')).toBe(false);
expect(isAccentName('toString')).toBe(false);
});
});
+21
View File
@@ -37,6 +37,27 @@ export const Colors = {
export type ThemeColor = keyof typeof Colors.light & keyof typeof Colors.dark;
/**
* User-selectable theme (accent) colours. Each replaces `primary` in both schemes, so every one
* keeps white button text at 4.5:1 or better (checked in the theme tests). Blue is the default.
*/
export const Accents = {
blue: '#2563EB',
indigo: '#4F46E5',
violet: '#7C3AED',
pink: '#DB2777',
orange: '#C2410C',
green: '#15803D',
teal: '#0F766E',
} as const;
export type AccentName = keyof typeof Accents;
export const DEFAULT_ACCENT: AccentName = 'blue';
export const isAccentName = (v: unknown): v is AccentName =>
typeof v === 'string' && Object.prototype.hasOwnProperty.call(Accents, v);
export const Fonts = Platform.select({
ios: {
sans: 'system-ui',
+6 -3
View File
@@ -3,9 +3,10 @@
* https://docs.expo.dev/guides/color-schemes/
*/
import { useMemo } from 'react';
import { useColorScheme as useSystemScheme } from 'react-native';
import { Colors } from '@/constants/theme';
import { Accents, Colors } from '@/constants/theme';
import { usePreferences } from '@/providers/preferences';
function effectiveScheme(
@@ -21,9 +22,11 @@ function effectiveScheme(
* otherwise follows the OS scheme. Safe in tests (defaults to light).
*/
export function useTheme() {
const { themeSetting } = usePreferences();
const { themeSetting, accentSetting } = usePreferences();
const system = useSystemScheme();
return Colors[effectiveScheme(themeSetting, system)];
const base = Colors[effectiveScheme(themeSetting, system)];
// the chosen theme colour replaces the action hue; everything else follows the scheme
return useMemo(() => ({ ...base, primary: Accents[accentSetting] }), [base, accentSetting]);
}
/** Effective scheme name, for components that branch on light/dark directly. */
+8
View File
@@ -28,6 +28,14 @@ export const zh: Record<string, string> = {
'Open this account to sign in again.': '请打开此账号重新登录。',
'Tap refresh to load usage.': '点击刷新以加载用量。',
Refresh: '刷新',
'Theme color': '主题色',
Blue: '蓝色',
Indigo: '靛蓝',
Violet: '紫色',
Pink: '粉色',
Orange: '橙色',
Green: '绿色',
Teal: '青色',
'Support AI Usage': '支持 AI Usage',
'AI Usage is free and has no ads. If it helps you, consider supporting development.':
'AI Usage 免费且没有广告。如果它对你有帮助,欢迎支持开发。',
+26 -2
View File
@@ -1,24 +1,30 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';
import { AppState } from 'react-native';
import { DEFAULT_ACCENT, isAccentName, type AccentName } from '@/constants/theme';
import { getServices } from '@/data/services';
import { resolveLocale, setLocale, type LocaleSetting, type ThemeSetting } from '@/i18n';
const LOCALE_KEY = 'locale-setting';
const APPEARANCE_KEY = 'appearance-setting';
const ACCENT_KEY = 'accent-setting';
interface Preferences {
localeSetting: LocaleSetting;
themeSetting: ThemeSetting;
accentSetting: AccentName;
setLocaleSetting: (v: LocaleSetting) => void;
setThemeSetting: (v: ThemeSetting) => void;
setAccentSetting: (v: AccentName) => void;
}
const PreferencesContext = createContext<Preferences>({
localeSetting: 'system',
themeSetting: 'system',
accentSetting: DEFAULT_ACCENT,
setLocaleSetting: () => {},
setThemeSetting: () => {},
setAccentSetting: () => {},
});
/**
@@ -29,18 +35,22 @@ const PreferencesContext = createContext<Preferences>({
export function PreferencesProvider({ children }: { children: ReactNode }) {
const [localeSetting, setLocaleState] = useState<LocaleSetting>('system');
const [themeSetting, setThemeState] = useState<ThemeSetting>('system');
const [accentSetting, setAccentState] = useState<AccentName>(DEFAULT_ACCENT);
useEffect(() => {
let cancelled = false;
getServices()
.then(async (s) => {
const [l, a] = await Promise.all([
const [l, a, c] = await Promise.all([
s.repos.settings.getJson<LocaleSetting>(LOCALE_KEY, 'system'),
s.repos.settings.getJson<ThemeSetting>(APPEARANCE_KEY, 'system'),
s.repos.settings.getJson<unknown>(ACCENT_KEY, DEFAULT_ACCENT),
]);
if (cancelled) return;
setLocaleState(l);
setThemeState(a);
// an unknown stored value (e.g. a colour removed in a later version) falls back to the default
setAccentState(isAccentName(c) ? c : DEFAULT_ACCENT);
setLocale(resolveLocale(l));
})
.catch(() => {
@@ -75,9 +85,23 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
.catch(() => {});
}, []);
const setAccentSetting = useCallback((v: AccentName) => {
setAccentState(v);
getServices()
.then((s) => s.repos.settings.setJson(ACCENT_KEY, v))
.catch(() => {});
}, []);
return (
<PreferencesContext.Provider
value={{ localeSetting, themeSetting, setLocaleSetting, setThemeSetting }}
value={{
localeSetting,
themeSetting,
accentSetting,
setLocaleSetting,
setThemeSetting,
setAccentSetting,
}}
>
{children}
</PreferencesContext.Provider>
+21 -1
View File
@@ -4,7 +4,7 @@ import type { Meter, UsageSnapshot } from '@/core/types';
import type { AccountView } from '@/data/summary';
import { setLocale } from '@/i18n';
import { AccountCard } from '@/ui/account-card';
import { Button, Field, Segmented } from '@/ui/controls';
import { Button, ColorSwatches, Field, Segmented } from '@/ui/controls';
import { MeterRow } from '@/ui/meter-row';
import { StatusBadge } from '@/ui/status-badge';
import { defaultColumns } from '@/ui/option-grid';
@@ -236,6 +236,26 @@ describe('controls', () => {
});
});
describe('ColorSwatches', () => {
it('exposes named radios and reports the picked colour', async () => {
const onChange = jest.fn();
await render(
<ColorSwatches
label="Theme color"
options={[
{ value: 'blue', color: '#2563EB', label: 'Blue' },
{ value: 'teal', color: '#0F766E', label: 'Teal' },
]}
value="blue"
onChange={onChange}
/>,
);
expect(screen.getByRole('radio', { name: 'Blue', checked: true })).toBeTruthy();
await fireEvent.press(screen.getByRole('radio', { name: 'Teal', checked: false }));
expect(onChange).toHaveBeenCalledWith('teal');
});
});
describe('option grid', () => {
it('keeps short groups in one row and wraps long ones', () => {
// language / appearance / history range: one row of equal cells
+64
View File
@@ -151,6 +151,53 @@ export function OptionTile({
);
}
/**
* Round colour swatches with radio semantics. The selected one gets a ring in the text colour
* plus a check mark, so the choice never relies on hue alone.
*/
export function ColorSwatches<T extends string>({
options,
value,
onChange,
label,
}: {
options: { value: T; color: string; label: string }[];
value: T;
onChange: (v: T) => void;
label?: string;
}) {
const theme = useTheme();
return (
<View style={styles.swatches} accessibilityRole="radiogroup" accessibilityLabel={label}>
{options.map((o) => {
const on = o.value === value;
return (
<PressableScale
key={o.value}
accessibilityRole="radio"
accessibilityLabel={o.label}
accessibilityState={{ checked: on }}
scaleTo={0.9}
onPress={() => {
if (!on) haptics.select();
onChange(o.value);
}}
style={[styles.swatchRing, { borderColor: on ? theme.text : 'transparent' }]}
>
<View style={[styles.swatch, { backgroundColor: o.color }]}>
{on ? (
<ThemedText type="smallBold" style={{ color: '#FFFFFF' }}>
✓
</ThemedText>
) : null}
</View>
</PressableScale>
);
})}
</View>
);
}
export function Segmented<T extends string | number>({
options,
value,
@@ -218,5 +265,22 @@ const styles = StyleSheet.create({
overflow: 'hidden',
},
segFill: { borderRadius: Radius.control },
swatches: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.two },
// 44pt touch target: 36pt swatch inside a 2pt ring with 2pt breathing room
swatchRing: {
width: 44,
height: 44,
borderRadius: 22,
borderWidth: 2,
alignItems: 'center',
justifyContent: 'center',
},
swatch: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
},
segLabel: { textAlign: 'center' },
});
+3
View File
@@ -2,6 +2,7 @@ import { StyleSheet, Switch, View } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
import { OptionTile } from '@/ui/controls';
import { haptics } from '@/ui/haptics';
import { useOptionGrid } from '@/ui/option-grid';
@@ -17,6 +18,7 @@ export function ToggleRow({
value: boolean;
onChange: (v: boolean) => void;
}) {
const theme = useTheme();
return (
<View style={styles.row}>
<View style={styles.text}>
@@ -30,6 +32,7 @@ export function ToggleRow({
<Switch
accessibilityLabel={label}
value={value}
trackColor={{ true: theme.primary }}
onValueChange={(v) => {
haptics.select();
onChange(v);