Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion frontend/src/operator/webui/src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
<div class="container">
<div class="container" [class.dark-theme]="themeService.isDarkMode">
<mat-toolbar class="cloud-android-toolbar">
<button mat-icon-button id="device-pane-toggle" (click)="snav.toggle()"><mat-icon>menu</mat-icon></button>
<h1 class="cloud-android-title">Cloud Android</h1>
<button
mat-icon-button
id="dark-mode-toggle"
(click)="themeService.toggleDarkMode()"
[title]="themeService.isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'"
[attr.aria-label]="themeService.isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'"
>
<mat-icon>{{ themeService.isDarkMode ? 'light_mode' : 'dark_mode' }}</mat-icon>
</button>
</mat-toolbar>

<mat-sidenav-container class="drawer-container">
Expand Down
52 changes: 45 additions & 7 deletions frontend/src/operator/webui/src/app/app.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,21 +56,26 @@

.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 {
position: absolute;
bottom: 0;
right: 0;
margin: 0px 5px 5px 0px;
color: var(--text-version);
}
9 changes: 7 additions & 2 deletions frontend/src/operator/webui/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@

button {
display: inline-block;
color: #073042;
color: var(--button-color, #073042);
}

.group-name {
Expand Down
87 changes: 87 additions & 0 deletions frontend/src/operator/webui/src/app/theme.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
});
70 changes: 70 additions & 0 deletions frontend/src/operator/webui/src/app/theme.service.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -72,7 +75,7 @@ ktd-grid {
} // hidden

.loading-message {
color: white;
color: var(--text-primary, white);
font-size: 15px;
font-weight: 400;
padding: 10px;
Expand Down
Loading