Theme colour setting: seven accents, persisted, applied app-wide
This commit is contained in:
1 parent
b2cb6d454b
commit
fc947db6de
9 files changed
+213
-10
No files matched your search
@@ -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')}>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 免费且没有广告。如果它对你有帮助,欢迎支持开发。',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user