mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 12:43:09 -05:00
Compare commits
7 Commits
claude/fix
...
ai/custom-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2f6ecf3f4 | ||
|
|
0f6d1be4c0 | ||
|
|
aae8335fcf | ||
|
|
b2267b8b0d | ||
|
|
13fcc408fa | ||
|
|
dd6f27607e | ||
|
|
95badf1608 |
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 83 KiB |
@@ -30,86 +30,169 @@ import {
|
||||
import type { InstalledTheme } from '@desktop-client/style/customThemes';
|
||||
|
||||
const INSTALL_NEW_VALUE = '__install_new__';
|
||||
const INSTALL_CUSTOM_LIGHT = '__install_custom_light__';
|
||||
const INSTALL_CUSTOM_DARK = '__install_custom_dark__';
|
||||
|
||||
export function ThemeSettings() {
|
||||
const { t } = useTranslation();
|
||||
const sidebar = useSidebar();
|
||||
const [theme, switchTheme] = useTheme();
|
||||
const [darkTheme, switchDarkTheme] = usePreferredDarkTheme();
|
||||
const [showInstaller, setShowInstaller] = useState(false);
|
||||
const [showInstaller, setShowInstaller] = useState<
|
||||
'single' | 'light' | 'dark' | null
|
||||
>(null);
|
||||
|
||||
const customThemesEnabled = useFeatureFlag('customThemes');
|
||||
|
||||
// Global prefs for custom themes
|
||||
const [installedThemeJson, setInstalledThemeJson] = useGlobalPref(
|
||||
'installedCustomTheme',
|
||||
const [installedLightThemeJson, setInstalledLightThemeJson] = useGlobalPref(
|
||||
'installedCustomLightTheme',
|
||||
);
|
||||
const [installedDarkThemeJson, setInstalledDarkThemeJson] = useGlobalPref(
|
||||
'installedCustomDarkTheme',
|
||||
);
|
||||
|
||||
const installedTheme = parseInstalledTheme(installedThemeJson);
|
||||
const installedCustomLightTheme = parseInstalledTheme(
|
||||
installedLightThemeJson,
|
||||
);
|
||||
const installedCustomDarkTheme = parseInstalledTheme(installedDarkThemeJson);
|
||||
|
||||
// Build the options list
|
||||
// Build the options list for the single (non-auto) theme selector
|
||||
const buildOptions = useCallback(() => {
|
||||
const options: Array<readonly [string, string] | typeof Menu.line> = [
|
||||
...themeOptions,
|
||||
];
|
||||
|
||||
// Add custom theme options only if feature flag is enabled
|
||||
if (customThemesEnabled) {
|
||||
// Add installed custom theme if it exists
|
||||
if (installedTheme) {
|
||||
if (theme !== 'auto' && installedCustomLightTheme) {
|
||||
options.push([
|
||||
`custom:${installedTheme.id}`,
|
||||
installedTheme.name,
|
||||
`custom:${installedCustomLightTheme.id}`,
|
||||
installedCustomLightTheme.name,
|
||||
] as const);
|
||||
}
|
||||
|
||||
// Add separator and "Custom theme" option
|
||||
options.push(Menu.line);
|
||||
options.push([INSTALL_NEW_VALUE, t('Custom theme')] as const);
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [installedTheme, customThemesEnabled, t]);
|
||||
}, [installedCustomLightTheme, customThemesEnabled, theme, t]);
|
||||
|
||||
// Determine current value for the select
|
||||
// Build options for the auto-mode light theme selector
|
||||
const buildLightOptions = useCallback(() => {
|
||||
const options: Array<readonly [string, string] | typeof Menu.line> = [
|
||||
['light', t('Light')],
|
||||
];
|
||||
if (customThemesEnabled) {
|
||||
if (installedCustomLightTheme) {
|
||||
options.push([
|
||||
`custom-light:${installedCustomLightTheme.id}`,
|
||||
installedCustomLightTheme.name,
|
||||
] as const);
|
||||
}
|
||||
options.push(Menu.line);
|
||||
options.push([INSTALL_CUSTOM_LIGHT, t('Custom theme')] as const);
|
||||
}
|
||||
return options;
|
||||
}, [installedCustomLightTheme, customThemesEnabled, t]);
|
||||
|
||||
// Build options for the auto-mode dark theme selector
|
||||
const buildDarkOptions = useCallback(() => {
|
||||
const options: Array<readonly [string, string] | typeof Menu.line> = [
|
||||
...darkThemeOptions,
|
||||
];
|
||||
if (customThemesEnabled) {
|
||||
if (installedCustomDarkTheme) {
|
||||
options.push([
|
||||
`custom-dark:${installedCustomDarkTheme.id}`,
|
||||
installedCustomDarkTheme.name,
|
||||
] as const);
|
||||
}
|
||||
options.push(Menu.line);
|
||||
options.push([INSTALL_CUSTOM_DARK, t('Custom theme')] as const);
|
||||
}
|
||||
return options;
|
||||
}, [installedCustomDarkTheme, customThemesEnabled, t]);
|
||||
|
||||
// Determine current value for the single theme select
|
||||
const getCurrentValue = useCallback(() => {
|
||||
if (customThemesEnabled && installedTheme) {
|
||||
return `custom:${installedTheme.id}`;
|
||||
if (customThemesEnabled && installedCustomLightTheme && theme !== 'auto') {
|
||||
return `custom:${installedCustomLightTheme.id}`;
|
||||
}
|
||||
return theme;
|
||||
}, [customThemesEnabled, installedTheme, theme]);
|
||||
}, [customThemesEnabled, installedCustomLightTheme, theme]);
|
||||
|
||||
// Handle theme selection
|
||||
// Handle theme selection (non-auto mode)
|
||||
const handleThemeChange = useCallback(
|
||||
(value: string) => {
|
||||
if (value === INSTALL_NEW_VALUE) {
|
||||
setShowInstaller(true);
|
||||
setShowInstaller('single');
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.startsWith('custom:')) {
|
||||
// Custom theme is already installed and active, no action needed
|
||||
// (since there's only one theme, selecting it means it's already active)
|
||||
} else {
|
||||
// Built-in theme selected - clear the installed custom theme
|
||||
setInstalledThemeJson(serializeInstalledTheme(null));
|
||||
if (!value.startsWith('custom:')) {
|
||||
setInstalledLightThemeJson(serializeInstalledTheme(null));
|
||||
setInstalledDarkThemeJson(serializeInstalledTheme(null));
|
||||
switchTheme(value as Theme);
|
||||
}
|
||||
},
|
||||
[setInstalledThemeJson, switchTheme],
|
||||
[setInstalledLightThemeJson, setInstalledDarkThemeJson, switchTheme],
|
||||
);
|
||||
|
||||
// Handle light theme selection (auto mode)
|
||||
const handleLightThemeChange = useCallback(
|
||||
(value: string) => {
|
||||
if (value === INSTALL_CUSTOM_LIGHT) {
|
||||
setShowInstaller('light');
|
||||
return;
|
||||
}
|
||||
if (value === 'light') {
|
||||
setInstalledLightThemeJson(serializeInstalledTheme(null));
|
||||
}
|
||||
},
|
||||
[setInstalledLightThemeJson],
|
||||
);
|
||||
|
||||
// Handle dark theme selection (auto mode)
|
||||
const handleDarkThemeChange = useCallback(
|
||||
(value: string) => {
|
||||
if (value === INSTALL_CUSTOM_DARK) {
|
||||
setShowInstaller('dark');
|
||||
return;
|
||||
}
|
||||
if (!value.startsWith('custom-dark:')) {
|
||||
setInstalledDarkThemeJson(serializeInstalledTheme(null));
|
||||
switchDarkTheme(value as DarkTheme);
|
||||
}
|
||||
},
|
||||
[setInstalledDarkThemeJson, switchDarkTheme],
|
||||
);
|
||||
|
||||
// Handle theme installation
|
||||
const handleInstall = useCallback(
|
||||
(newTheme: InstalledTheme) => {
|
||||
setInstalledThemeJson(serializeInstalledTheme(newTheme));
|
||||
if (showInstaller === 'light') {
|
||||
setInstalledLightThemeJson(serializeInstalledTheme(newTheme));
|
||||
} else if (showInstaller === 'dark') {
|
||||
setInstalledDarkThemeJson(serializeInstalledTheme(newTheme));
|
||||
} else {
|
||||
setInstalledLightThemeJson(serializeInstalledTheme(newTheme));
|
||||
if (theme === 'auto') {
|
||||
switchTheme('light');
|
||||
}
|
||||
}
|
||||
},
|
||||
[setInstalledThemeJson],
|
||||
[
|
||||
showInstaller,
|
||||
theme,
|
||||
setInstalledLightThemeJson,
|
||||
setInstalledDarkThemeJson,
|
||||
switchTheme,
|
||||
],
|
||||
);
|
||||
|
||||
// Handle installer close
|
||||
const handleInstallerClose = useCallback(() => {
|
||||
setShowInstaller(false);
|
||||
setShowInstaller(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -146,24 +229,49 @@ export function ThemeSettings() {
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: themeStyle.buttonNormalBackgroundHover,
|
||||
},
|
||||
maxWidth: '100%',
|
||||
})}
|
||||
/>
|
||||
</Column>
|
||||
{theme === 'auto' && !installedTheme && (
|
||||
<Column title={t('Dark theme')}>
|
||||
<Select<DarkTheme>
|
||||
onChange={value => {
|
||||
switchDarkTheme(value);
|
||||
}}
|
||||
value={darkTheme}
|
||||
options={darkThemeOptions}
|
||||
className={css({
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: themeStyle.buttonNormalBackgroundHover,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Column>
|
||||
{theme === 'auto' && (
|
||||
<>
|
||||
<Column title={t('Light theme')}>
|
||||
<Select<string>
|
||||
onChange={handleLightThemeChange}
|
||||
value={
|
||||
customThemesEnabled && installedCustomLightTheme
|
||||
? `custom-light:${installedCustomLightTheme.id}`
|
||||
: 'light'
|
||||
}
|
||||
options={buildLightOptions()}
|
||||
className={css({
|
||||
'&[data-hovered]': {
|
||||
backgroundColor:
|
||||
themeStyle.buttonNormalBackgroundHover,
|
||||
},
|
||||
maxWidth: '100%',
|
||||
})}
|
||||
/>
|
||||
</Column>
|
||||
<Column title={t('Dark theme')}>
|
||||
<Select<string>
|
||||
onChange={handleDarkThemeChange}
|
||||
value={
|
||||
customThemesEnabled && installedCustomDarkTheme
|
||||
? `custom-dark:${installedCustomDarkTheme.id}`
|
||||
: darkTheme
|
||||
}
|
||||
options={buildDarkOptions()}
|
||||
className={css({
|
||||
'&[data-hovered]': {
|
||||
backgroundColor:
|
||||
themeStyle.buttonNormalBackgroundHover,
|
||||
},
|
||||
maxWidth: '100%',
|
||||
})}
|
||||
/>
|
||||
</Column>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
@@ -172,7 +280,11 @@ export function ThemeSettings() {
|
||||
<ThemeInstaller
|
||||
onInstall={handleInstall}
|
||||
onClose={handleInstallerClose}
|
||||
installedTheme={installedTheme}
|
||||
installedTheme={
|
||||
showInstaller === 'dark'
|
||||
? installedCustomDarkTheme
|
||||
: installedCustomLightTheme
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -118,7 +118,9 @@ export function Column({
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontWeight: 500 }}>{title}</Text>
|
||||
<View style={{ alignItems: 'flex-start', gap: '1em' }}>{children}</View>
|
||||
<View style={{ alignItems: 'flex-start', gap: '1em', width: '100%' }}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,30 +103,67 @@ export function ThemeStyle() {
|
||||
/**
|
||||
* CustomThemeStyle injects CSS from the installed custom theme (if any).
|
||||
* This is rendered after ThemeStyle to allow custom themes to override base theme variables.
|
||||
*
|
||||
* When `theme === 'auto'`, separate custom themes can be set for light and dark modes,
|
||||
* injected via @media (prefers-color-scheme) rules. Otherwise, a single custom theme applies.
|
||||
*/
|
||||
export function CustomThemeStyle() {
|
||||
const customThemesEnabled = useFeatureFlag('customThemes');
|
||||
const [installedThemeJson] = useGlobalPref('installedCustomTheme');
|
||||
const [activeTheme] = useTheme();
|
||||
const [installedCustomLightThemeJson] = useGlobalPref(
|
||||
'installedCustomLightTheme',
|
||||
);
|
||||
const [installedCustomDarkThemeJson] = useGlobalPref(
|
||||
'installedCustomDarkTheme',
|
||||
);
|
||||
|
||||
// Parse installed theme (single theme, not array)
|
||||
const installedTheme = parseInstalledTheme(installedThemeJson);
|
||||
|
||||
// Get CSS content from the theme (cssContent is required)
|
||||
const { cssContent } = installedTheme ?? {};
|
||||
|
||||
// Memoize validated CSS to avoid re-validation on every render
|
||||
const validatedCss = useMemo(() => {
|
||||
if (!customThemesEnabled || !cssContent) {
|
||||
return null;
|
||||
if (!customThemesEnabled) return null;
|
||||
|
||||
if (activeTheme === 'auto') {
|
||||
const lightTheme = parseInstalledTheme(installedCustomLightThemeJson);
|
||||
const darkTheme = parseInstalledTheme(installedCustomDarkThemeJson);
|
||||
|
||||
let css = '';
|
||||
|
||||
if (lightTheme?.cssContent) {
|
||||
try {
|
||||
const validated = validateThemeCss(lightTheme.cssContent);
|
||||
css += `@media (prefers-color-scheme: light) { ${validated} }\n`;
|
||||
} catch (error) {
|
||||
console.error('Invalid custom light theme CSS', { error });
|
||||
}
|
||||
}
|
||||
|
||||
if (darkTheme?.cssContent) {
|
||||
try {
|
||||
const validated = validateThemeCss(darkTheme.cssContent);
|
||||
css += `@media (prefers-color-scheme: dark) { ${validated} }\n`;
|
||||
} catch (error) {
|
||||
console.error('Invalid custom dark theme CSS', { error });
|
||||
}
|
||||
}
|
||||
|
||||
return css || null;
|
||||
}
|
||||
|
||||
const installedTheme = parseInstalledTheme(installedCustomLightThemeJson);
|
||||
const { cssContent } = installedTheme ?? {};
|
||||
|
||||
if (!cssContent) return null;
|
||||
|
||||
try {
|
||||
return validateThemeCss(cssContent);
|
||||
} catch (error) {
|
||||
console.error('Invalid custom theme CSS', { error, cssContent });
|
||||
return null;
|
||||
}
|
||||
}, [customThemesEnabled, cssContent]);
|
||||
}, [
|
||||
customThemesEnabled,
|
||||
activeTheme,
|
||||
installedCustomLightThemeJson,
|
||||
installedCustomDarkThemeJson,
|
||||
]);
|
||||
|
||||
if (!validatedCss) {
|
||||
return null;
|
||||
|
||||
@@ -99,10 +99,16 @@ async function saveGlobalPrefs(prefs: GlobalPrefs) {
|
||||
prefs.preferredDarkTheme,
|
||||
);
|
||||
}
|
||||
if (prefs.installedCustomTheme !== undefined) {
|
||||
if (prefs.installedCustomLightTheme !== undefined) {
|
||||
await asyncStorage.setItem(
|
||||
'installed-custom-theme',
|
||||
prefs.installedCustomTheme,
|
||||
prefs.installedCustomLightTheme,
|
||||
);
|
||||
}
|
||||
if (prefs.installedCustomDarkTheme !== undefined) {
|
||||
await asyncStorage.setItem(
|
||||
'installed-custom-dark-theme',
|
||||
prefs.installedCustomDarkTheme,
|
||||
);
|
||||
}
|
||||
if (prefs.serverSelfSignedCert !== undefined) {
|
||||
@@ -133,7 +139,8 @@ async function loadGlobalPrefs(): Promise<GlobalPrefs> {
|
||||
language,
|
||||
theme,
|
||||
'preferred-dark-theme': preferredDarkTheme,
|
||||
'installed-custom-theme': installedCustomTheme,
|
||||
'installed-custom-theme': installedCustomLightTheme,
|
||||
'installed-custom-dark-theme': installedCustomDarkTheme,
|
||||
'server-self-signed-cert': serverSelfSignedCert,
|
||||
syncServerConfig,
|
||||
notifyWhenUpdateIsAvailable,
|
||||
@@ -147,6 +154,7 @@ async function loadGlobalPrefs(): Promise<GlobalPrefs> {
|
||||
'theme',
|
||||
'preferred-dark-theme',
|
||||
'installed-custom-theme',
|
||||
'installed-custom-dark-theme',
|
||||
'server-self-signed-cert',
|
||||
'syncServerConfig',
|
||||
'notifyWhenUpdateIsAvailable',
|
||||
@@ -170,7 +178,8 @@ async function loadGlobalPrefs(): Promise<GlobalPrefs> {
|
||||
preferredDarkTheme === 'dark' || preferredDarkTheme === 'midnight'
|
||||
? preferredDarkTheme
|
||||
: 'dark',
|
||||
installedCustomTheme: installedCustomTheme || undefined,
|
||||
installedCustomLightTheme: installedCustomLightTheme || undefined,
|
||||
installedCustomDarkTheme: installedCustomDarkTheme || undefined,
|
||||
serverSelfSignedCert: serverSelfSignedCert || undefined,
|
||||
syncServerConfig: syncServerConfig || undefined,
|
||||
notifyWhenUpdateIsAvailable:
|
||||
|
||||
@@ -118,7 +118,8 @@ export type GlobalPrefs = Partial<{
|
||||
colors: Record<string, string>;
|
||||
}
|
||||
>; // Complete plugin theme metadata
|
||||
installedCustomTheme?: string; // JSON string of installed custom theme
|
||||
installedCustomLightTheme?: string; // JSON of InstalledTheme for light custom theme (also used as single custom theme in non-auto mode)
|
||||
installedCustomDarkTheme?: string; // JSON of InstalledTheme for auto-mode dark custom theme
|
||||
documentDir: string; // Electron only
|
||||
serverSelfSignedCert: string; // Electron only
|
||||
syncServerConfig?: {
|
||||
@@ -147,7 +148,8 @@ export type GlobalPrefsJson = Partial<{
|
||||
language?: GlobalPrefs['language'];
|
||||
theme?: GlobalPrefs['theme'];
|
||||
'preferred-dark-theme'?: GlobalPrefs['preferredDarkTheme'];
|
||||
'installed-custom-theme'?: GlobalPrefs['installedCustomTheme'];
|
||||
'installed-custom-theme'?: GlobalPrefs['installedCustomLightTheme'];
|
||||
'installed-custom-dark-theme'?: GlobalPrefs['installedCustomDarkTheme'];
|
||||
plugins?: string; // "true" or "false"
|
||||
'plugin-theme'?: string; // JSON string of complete plugin theme (current selected plugin theme)
|
||||
'server-self-signed-cert'?: GlobalPrefs['serverSelfSignedCert'];
|
||||
|
||||
6
upcoming-release-notes/7145.md
Normal file
6
upcoming-release-notes/7145.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancement
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Custom Themes: separate light and dark theme options when selecting "system default" theme.
|
||||
Reference in New Issue
Block a user