diff --git a/frontend/src/operator/webui/src/app/app.component.html b/frontend/src/operator/webui/src/app/app.component.html index ea094dbb6a2..138c6651911 100644 --- a/frontend/src/operator/webui/src/app/app.component.html +++ b/frontend/src/operator/webui/src/app/app.component.html @@ -1,7 +1,16 @@ -
+

Cloud Android

+
diff --git a/frontend/src/operator/webui/src/app/app.component.scss b/frontend/src/operator/webui/src/app/app.component.scss index 08792390bf2..61105d7b9d8 100644 --- a/frontend/src/operator/webui/src/app/app.component.scss +++ b/frontend/src/operator/webui/src/app/app.component.scss @@ -6,15 +6,48 @@ bottom: 0; left: 0; right: 0; - background: #2a2a2a; - color: #eee; + + // Light Mode Variables (Default) + --bg-primary: #f5f5f5; + --bg-drawer: #e8e8e8; + --bg-device-pane: #ffffff; + --bg-toolbar: #073042; + --text-primary: #073042; + --text-toolbar: #eee; + --text-version: #555555; + --button-color: #073042; + --card-header-bg: #073042; + --card-header-text: #eee; + --card-bg: #ffffff; + + background: var(--bg-primary); + color: var(--text-primary); + transition: + background-color 0.3s ease, + color 0.3s ease; + + &.dark-theme { + // Dark Mode Variables + --bg-primary: #121212; + --bg-drawer: #1e1e1e; + --bg-device-pane: #2d2d2d; + --bg-toolbar: #1f2937; + --text-primary: #e8eaed; + --text-toolbar: #e8eaed; + --text-version: #9aa0a6; + --button-color: #8ab4f8; + --card-header-bg: #2d3748; + --card-header-text: #e8eaed; + --card-bg: #141414; + } } .cloud-android-toolbar { - background: #073042; - color: #eee; + background: var(--bg-toolbar); + color: var(--text-toolbar); box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.5); text-align: center; + transition: background-color 0.3s ease; } .cloud-android-title { @@ -23,16 +56,20 @@ .drawer-container { flex: 1; - background: #cfcfcf; + background: var(--bg-drawer); + transition: background-color 0.3s ease; } .device-pane { margin: 0 auto; width: 275px; - background: rgba(245, 245, 245, 0.8); - color: #073042; + background: var(--bg-device-pane); + color: var(--text-primary); filter: drop-shadow(4px 4px 10px rgba(0, 0, 0, 0.25)); border-radius: 0px 16px 16px 0px; + transition: + background-color 0.3s ease, + color 0.3s ease; } .version-info { @@ -40,4 +77,5 @@ bottom: 0; right: 0; margin: 0px 5px 5px 0px; + color: var(--text-version); } diff --git a/frontend/src/operator/webui/src/app/app.component.ts b/frontend/src/operator/webui/src/app/app.component.ts index a7b54153be5..3169dde9ae8 100644 --- a/frontend/src/operator/webui/src/app/app.component.ts +++ b/frontend/src/operator/webui/src/app/app.component.ts @@ -1,7 +1,8 @@ import {Component, HostListener, Injectable, ChangeDetectionStrategy} from '@angular/core'; import {DisplaysService} from './displays.service'; -import {BUILD_VERSION} from '../environments/version' +import {ThemeService} from './theme.service'; +import {BUILD_VERSION} from '../environments/version'; @Injectable() @Component({ @@ -13,7 +14,11 @@ import {BUILD_VERSION} from '../environments/version' }) export class AppComponent { readonly version = BUILD_VERSION; - constructor(private displaysService: DisplaysService) {} + + constructor( + private displaysService: DisplaysService, + public themeService: ThemeService, + ) {} @HostListener('window:message', ['$event']) onWindowMessage(e: MessageEvent) { diff --git a/frontend/src/operator/webui/src/app/device-pane/device-pane.component.scss b/frontend/src/operator/webui/src/app/device-pane/device-pane.component.scss index 98493da6065..f588402ad95 100644 --- a/frontend/src/operator/webui/src/app/device-pane/device-pane.component.scss +++ b/frontend/src/operator/webui/src/app/device-pane/device-pane.component.scss @@ -45,7 +45,7 @@ button { display: inline-block; - color: #073042; + color: var(--button-color, #073042); } .group-name { diff --git a/frontend/src/operator/webui/src/app/theme.service.spec.ts b/frontend/src/operator/webui/src/app/theme.service.spec.ts new file mode 100644 index 00000000000..fdc23cc95e8 --- /dev/null +++ b/frontend/src/operator/webui/src/app/theme.service.spec.ts @@ -0,0 +1,87 @@ +import {TestBed} from '@angular/core/testing'; + +import {ThemeService} from './theme.service'; + +describe('ThemeService', () => { + beforeEach(() => { + window.localStorage.removeItem('theme'); + TestBed.configureTestingModule({ + providers: [ThemeService], + }); + }); + + afterEach(() => { + window.localStorage.removeItem('theme'); + }); + + it('should be created', () => { + const service = TestBed.inject(ThemeService); + expect(service).toBeTruthy(); + }); + + it('should toggle dark mode state and save to localStorage', () => { + const service = TestBed.inject(ThemeService); + const initialState = service.isDarkMode; + service.toggleDarkMode(); + expect(service.isDarkMode).toBe(!initialState); + expect(window.localStorage.getItem('theme')).toBe( + service.isDarkMode ? 'dark' : 'light', + ); + }); + + it('should respect saved theme from localStorage on initialization', () => { + window.localStorage.setItem('theme', 'dark'); + const service = TestBed.inject(ThemeService); + expect(service.isDarkMode).toBe(true); + }); + + describe('system theme preference', () => { + let queryListener: ((e: MediaQueryListEvent) => void) | undefined; + let removeEventListenerSpy: jasmine.Spy; + + beforeEach(() => { + queryListener = undefined; + removeEventListenerSpy = jasmine.createSpy('removeEventListener'); + + spyOn(window, 'matchMedia').and.callFake((query: string) => { + return { + matches: false, + addEventListener: ( + event: string, + listener: (e: MediaQueryListEvent) => void, + ) => (queryListener = listener), + removeEventListener: removeEventListenerSpy, + } as unknown as MediaQueryList; + }); + }); + + it('should use system theme when no theme is saved', () => { + const service = TestBed.inject(ThemeService); + expect(service.isDarkMode).toBe(false); + expect(queryListener).toBeDefined(); + + queryListener!({matches: true} as MediaQueryListEvent); + expect(service.isDarkMode).toBe(true); + + queryListener!({matches: false} as MediaQueryListEvent); + expect(service.isDarkMode).toBe(false); + }); + + it('should ignore system theme when a saved theme is present', () => { + window.localStorage.setItem('theme', 'dark'); + const service = TestBed.inject(ThemeService); + expect(service.isDarkMode).toBe(true); + expect(queryListener).toBeUndefined(); + }); + + it('should remove listener on ngOnDestroy', () => { + const service = TestBed.inject(ThemeService); + service.ngOnDestroy(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'change', + queryListener, + ); + }); + }); +}); diff --git a/frontend/src/operator/webui/src/app/theme.service.ts b/frontend/src/operator/webui/src/app/theme.service.ts new file mode 100644 index 00000000000..341155c66ad --- /dev/null +++ b/frontend/src/operator/webui/src/app/theme.service.ts @@ -0,0 +1,70 @@ +import {Injectable, OnDestroy, NgZone} from '@angular/core'; + +function getSavedTheme(): string | null { + try { + return window.localStorage.getItem('theme'); + } catch { + return null; + } +} + +function setSavedTheme(theme: string): void { + try { + window.localStorage.setItem('theme', theme); + } catch { + // Ignore. + } +} + +/** + * Manages light and dark theme state, system preference detection + * (prefers-color-scheme), and persisting user theme preference in localStorage. + */ +@Injectable({ + providedIn: 'root', +}) +export class ThemeService implements OnDestroy { + isDarkMode = false; + private query?: MediaQueryList; + private queryListener?: (e: MediaQueryListEvent) => void; + + constructor(private ngZone: NgZone) { + const savedTheme = getSavedTheme(); + if (savedTheme !== null) { + this.isDarkMode = savedTheme === 'dark'; + } else { + this.listenToPrefersColorSchemeChanges(); + } + } + + toggleDarkMode(): void { + this.isDarkMode = !this.isDarkMode; + setSavedTheme(this.isDarkMode ? 'dark' : 'light'); + this.stopListeningToPrefersColorSchemeChanges(); + } + + private listenToPrefersColorSchemeChanges(): void { + if (window.matchMedia) { + this.query = window.matchMedia('(prefers-color-scheme: dark)'); + this.queryListener = (e: MediaQueryListEvent) => { + if (getSavedTheme() === null) { + this.ngZone.run(() => (this.isDarkMode = e.matches)); + } + }; + this.query.addEventListener('change', this.queryListener); + this.isDarkMode = this.query.matches; + } + } + + private stopListeningToPrefersColorSchemeChanges(): void { + if (this.queryListener) { + this.query?.removeEventListener('change', this.queryListener); + this.query = undefined; + this.queryListener = undefined; + } + } + + ngOnDestroy(): void { + this.stopListeningToPrefersColorSchemeChanges(); + } +} diff --git a/frontend/src/operator/webui/src/app/view-pane/view-pane.component.scss b/frontend/src/operator/webui/src/app/view-pane/view-pane.component.scss index 25ff9b38029..5d522ba7529 100644 --- a/frontend/src/operator/webui/src/app/view-pane/view-pane.component.scss +++ b/frontend/src/operator/webui/src/app/view-pane/view-pane.component.scss @@ -15,21 +15,24 @@ ktd-grid { flex-direction: column; height: 98%; width: 98%; - background-color: #000; + background-color: var(--card-bg, #000); border-radius: 10px 10px 10px 10px; .viewer-header { display: flex; align-items: center; - background-color: #073042; + background-color: var(--card-header-bg, #073042); border-style: solid; - border-color: #073042; + border-color: var(--card-header-bg, #073042); border-radius: 10px 10px 0px 0px; font-size: 20px; font-weight: 400; - color: #eee; + color: var(--card-header-text, #eee); height: 40px; padding-left: 20px; + transition: + background-color 0.3s ease, + border-color 0.3s ease; .grid-item-remove-handle { position: absolute; @@ -72,7 +75,7 @@ ktd-grid { } // hidden .loading-message { - color: white; + color: var(--text-primary, white); font-size: 15px; font-weight: 400; padding: 10px;