From fc947db6deb81b64dfe7388e1886693c55c2eea5 Mon Sep 17 00:00:00 2001 From: "Jude.Chen" Date: Tue, 6 Oct 2026 22:37:42 +0800 Subject: [PATCH] Theme colour setting: seven accents, persisted, applied app-wide --- src/app/(tabs)/settings.tsx | 39 ++++++++++++++-- src/constants/__tests__/theme.test.ts | 29 +++++++++++- src/constants/theme.ts | 21 +++++++++ src/hooks/use-theme.ts | 9 ++-- src/i18n/zh.ts | 8 ++++ src/providers/preferences.tsx | 28 +++++++++++- src/ui/__tests__/components.test.tsx | 22 ++++++++- src/ui/controls.tsx | 64 +++++++++++++++++++++++++++ src/ui/toggles.tsx | 3 ++ 9 files changed, 213 insertions(+), 10 deletions(-) diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx index ba1e174..0c1fa90 100644 --- a/src/app/(tabs)/settings.tsx +++ b/src/app/(tabs)/settings.tsx @@ -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 = { + 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)} /> + + + {t('Theme color')} + + + 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} + /> +
diff --git a/src/constants/__tests__/theme.test.ts b/src/constants/__tests__/theme.test.ts index 80786d7..b35e652 100644 --- a/src/constants/__tests__/theme.test.ts +++ b/src/constants/__tests__/theme.test.ts @@ -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); + }); }); diff --git a/src/constants/theme.ts b/src/constants/theme.ts index e113511..5d2029b 100644 --- a/src/constants/theme.ts +++ b/src/constants/theme.ts @@ -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', diff --git a/src/hooks/use-theme.ts b/src/hooks/use-theme.ts index 34af448..aa0b982 100644 --- a/src/hooks/use-theme.ts +++ b/src/hooks/use-theme.ts @@ -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. */ diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 6689096..da94efb 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -28,6 +28,14 @@ export const zh: Record = { '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 免费且没有广告。如果它对你有帮助,欢迎支持开发。', diff --git a/src/providers/preferences.tsx b/src/providers/preferences.tsx index 15faffc..9341562 100644 --- a/src/providers/preferences.tsx +++ b/src/providers/preferences.tsx @@ -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({ localeSetting: 'system', themeSetting: 'system', + accentSetting: DEFAULT_ACCENT, setLocaleSetting: () => {}, setThemeSetting: () => {}, + setAccentSetting: () => {}, }); /** @@ -29,18 +35,22 @@ const PreferencesContext = createContext({ export function PreferencesProvider({ children }: { children: ReactNode }) { const [localeSetting, setLocaleState] = useState('system'); const [themeSetting, setThemeState] = useState('system'); + const [accentSetting, setAccentState] = useState(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(LOCALE_KEY, 'system'), s.repos.settings.getJson(APPEARANCE_KEY, 'system'), + s.repos.settings.getJson(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 ( {children} diff --git a/src/ui/__tests__/components.test.tsx b/src/ui/__tests__/components.test.tsx index b25ae2b..107d6b7 100644 --- a/src/ui/__tests__/components.test.tsx +++ b/src/ui/__tests__/components.test.tsx @@ -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( + , + ); + 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 diff --git a/src/ui/controls.tsx b/src/ui/controls.tsx index d662c97..87dbd34 100644 --- a/src/ui/controls.tsx +++ b/src/ui/controls.tsx @@ -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({ + options, + value, + onChange, + label, +}: { + options: { value: T; color: string; label: string }[]; + value: T; + onChange: (v: T) => void; + label?: string; +}) { + const theme = useTheme(); + return ( + + {options.map((o) => { + const on = o.value === value; + return ( + { + if (!on) haptics.select(); + onChange(o.value); + }} + style={[styles.swatchRing, { borderColor: on ? theme.text : 'transparent' }]} + > + + {on ? ( + + ✓ + + ) : null} + + + ); + })} + + ); +} + export function Segmented({ 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' }, }); diff --git a/src/ui/toggles.tsx b/src/ui/toggles.tsx index f9617f9..b619a19 100644 --- a/src/ui/toggles.tsx +++ b/src/ui/toggles.tsx @@ -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 ( @@ -30,6 +32,7 @@ export function ToggleRow({ { haptics.select(); onChange(v);