import { Injectable, Inject, signal, computed, effect } from '@angular/core'; import { RedisStateService } from './redis-state.service'; /** * Theme management service using Angular signals. * * Manages theme selection, persistence (localStorage), dark/light classification, * and body class toggling. During hybrid mode, the AngularJS p3xrTheme provider * handles the AngularJS Material-specific parts ($mdThemingProvider, $mdColors, * dynamic CSS injection via jQuery). This service handles the framework-agnostic * parts that both Angular and AngularJS components need. * * After full migration (Phase 6), this service will also manage Angular Material * theming via Sass-compiled CSS class switching. * * Theme architecture: * - Each theme has 3 sub-themes: {Name}, {Name}Layout, {Name}Common * - Themes are classified as dark or light * - Current theme is persisted to localStorage key 'p3xr-theme' * - Body gets class 'p3xr-theme-dark' or 'p3xr-theme-light' * - document.documentElement gets data-color-scheme="dark"/"light" (for scrollbar styling) */ @Injectable({ providedIn: 'root' }) export class ThemeService { private static readonly STORAGE_KEY = 'p3xr-theme'; private static readonly AUTO_THEME = 'auto'; /** Theme classification: which themes are dark, which are light */ private static readonly DARK_THEMES = [ 'p3xrThemeDarkNeu', 'p3xrThemeDark', 'p3xrThemeDarkoBluo', 'p3xrThemeMatrix', ]; private static readonly LIGHT_THEMES = [ 'p3xrThemeLight', 'p3xrThemeEnterprise', 'p3xrThemeRedis', ]; /** All available theme names */ static readonly ALL_THEMES = [...ThemeService.DARK_THEMES, ...ThemeService.LIGHT_THEMES]; /** * Maps AngularJS theme names to Angular Material CSS class suffixes. * AngularJS: 'p3xrThemeDark' → Angular Material: 'p3xr-mat-theme-dark' */ private static readonly THEME_CSS_CLASS_MAP: Record = { 'p3xrThemeDark': 'p3xr-mat-theme-dark', 'p3xrThemeDarkNeu': 'p3xr-mat-theme-dark-neu', 'p3xrThemeDarkoBluo': 'p3xr-mat-theme-darko-bluo', 'p3xrThemeMatrix': 'p3xr-mat-theme-matrix', 'p3xrThemeLight': 'p3xr-mat-theme-light', 'p3xrThemeEnterprise': 'p3xr-mat-theme-enterprise', 'p3xrThemeRedis': 'p3xr-mat-theme-redis', }; /** Default theme based on system preference */ private static readonly DEFAULT_THEME = (typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches) ? 'p3xrThemeDark' : 'p3xrThemeEnterprise'; // --- Signals --- /** Current theme name signal, persisted to localStorage */ readonly currentTheme: ReturnType>; /** Whether the current theme is a dark theme */ readonly isDark = computed(() => ThemeService.DARK_THEMES.includes(this.currentTheme())); /** Layout sub-theme name (e.g. 'p3xrThemeDarkLayout') */ readonly themeLayout = computed(() => this.currentTheme() + 'Layout'); /** Common sub-theme name (e.g. 'p3xrThemeDarkCommon') */ readonly themeCommon = computed(() => this.currentTheme() + 'Common'); /** Whether the current mode is auto (follows system) */ readonly isAuto: ReturnType>; constructor(@Inject(RedisStateService) private state: RedisStateService) { const initial = this.getInitialTheme(); const isAutoMode = initial === ThemeService.AUTO_THEME; this.isAuto = signal(isAutoMode); // If auto, resolve to system preference const resolvedTheme = isAutoMode ? ThemeService.getSystemTheme() : initial; this.currentTheme = signal(resolvedTheme); // Apply body classes and persist to localStorage on theme change effect(() => { const theme = this.currentTheme(); this.applyTheme(theme); }); // Listen for system dark/light mode changes if (typeof window !== 'undefined' && window.matchMedia) { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { if (this.isAuto()) { this.currentTheme.set(e.matches ? 'p3xrThemeDark' : 'p3xrThemeEnterprise'); } }); } } private static getSystemTheme(): string { return (typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches) ? 'p3xrThemeDark' : 'p3xrThemeEnterprise'; } /** * Switch to a different theme. */ setTheme(themeName: string): void { if (themeName === ThemeService.AUTO_THEME) { this.isAuto.set(true); const resolved = ThemeService.getSystemTheme(); this.currentTheme.set(resolved); return; } if (!ThemeService.ALL_THEMES.includes(themeName)) { console.warn(`[ThemeService] Unknown theme: ${themeName}`); return; } this.isAuto.set(false); this.currentTheme.set(themeName); } /** * Get theme display name from internal name. * e.g. 'p3xrThemeDark' → 'Dark' */ getDisplayName(themeName: string): string { return themeName.replace('p3xrTheme', ''); } /** * Generate the internal theme name from a raw display name. * e.g. 'Dark' → 'p3xrThemeDark' */ generateThemeName(rawName: string): string { return 'p3xrTheme' + rawName[0].toUpperCase() + rawName.substring(1); } // --- Private helpers --- /** * Convert short localStorage key to Angular internal name. * e.g. 'dark' → 'p3xrThemeDark', 'enterprise' → 'p3xrThemeEnterprise' */ private fromShortKey(key: string): string { if (key.startsWith('p3xrTheme')) return key; // already Angular format const angularName = 'p3xrTheme' + key.charAt(0).toUpperCase() + key.slice(1); return ThemeService.ALL_THEMES.includes(angularName) ? angularName : key; } /** * Convert Angular internal name to short localStorage key. * e.g. 'p3xrThemeDark' → 'dark', 'p3xrThemeEnterprise' → 'enterprise' */ private toShortKey(themeName: string): string { if (!themeName.startsWith('p3xrTheme')) return themeName; const name = themeName.replace('p3xrTheme', ''); return name.charAt(0).toLowerCase() + name.slice(1); } private getInitialTheme(): string { const stored = this.readStorageItem(ThemeService.STORAGE_KEY); if (!stored) return ThemeService.AUTO_THEME; if (stored === ThemeService.AUTO_THEME) return stored; return this.fromShortKey(stored); } private applyTheme(themeName: string): void { const dark = ThemeService.DARK_THEMES.includes(themeName); this.setStorageItem(ThemeService.STORAGE_KEY, this.isAuto() ? ThemeService.AUTO_THEME : this.toShortKey(themeName)); if (typeof document !== 'undefined') { document.body.classList.remove('p3xr-theme-light', 'p3xr-theme-dark'); document.body.classList.add(dark ? 'p3xr-theme-dark' : 'p3xr-theme-light'); const allMatClasses = Object.values(ThemeService.THEME_CSS_CLASS_MAP); document.body.classList.remove(...allMatClasses); const matClass = ThemeService.THEME_CSS_CLASS_MAP[themeName]; if (matClass) { document.body.classList.add(matClass); } document.documentElement.style.display = 'none'; document.documentElement.setAttribute('data-color-scheme', dark ? 'dark' : 'light'); document.body.clientWidth; document.documentElement.style.display = ''; // Notify Electron shell (iframe parent) about theme change for scrollbar styling try { window.parent?.postMessage({ type: 'p3x-theme-change', dark: dark }, '*'); } catch (e) { /* not in iframe or cross-origin */ } } this.state.theme.set(themeName); } private readStorageItem(name: string): string | null { try { return localStorage.getItem(name); } catch { return null; } } private setStorageItem(name: string, value: string): void { try { localStorage.setItem(name, value); } catch {} } }