feat: add persistent theme selection and light mode court styling

This commit is contained in:
tothbt committed 2026-10-24 09:30:36 +02:00
1 parent 0757e0df76
commit 512aa58949
11 files changed
+217 -17

No files matched your search

+8 -1
View File
@@ -25,7 +25,7 @@ The root route lazy-loads the tab routes. The three primary routes are:
|---|---|---|
| `/tabs/pilot` | `src/app/tab1/` | Waiting queues, court assignments, drag and drop, and rotation actions |
| `/tabs/umpires` | `src/app/tab2/` | Court-official roster management |
| `/tabs/settings` | `src/app/tab3/` | Court count, service-judge mode, alerts, and language |
| `/tabs/settings` | `src/app/tab3/` | Court count, theme, service-judge mode, alerts, and language |
Pages are standalone Angular components and directly import the Ionic standalone components they use. Services are root-provided and injected with Angular's `inject()` function.
@@ -78,6 +78,13 @@ erDiagram
Settings use a singleton row with ID `1`. Defaults are five courts, service judges enabled, and confirmation alerts enabled.
Theme preference is stored separately in `localStorage` under `theme`, using
`light`, `dark`, or `system`. Device-preferred (`system`) is the default when no
valid preference exists. `ThemeService` restores the selection when the root
component starts and toggles Ionic's `ion-palette-dark` class. Device theme changes
apply while the app is open when device-preferred is selected. This does not
change the Dexie schema or existing settings records.
### Schema-change policy
Once data may exist in released clients, do not modify schema version 1 in place. Add a new `db.version(n)` declaration and define upgrade behavior when existing records need transformation.
+3 -2
View File
@@ -10,8 +10,7 @@ import { isTauri } from '@tauri-apps/api/core';
import { platform } from '@tauri-apps/plugin-os';
import { relaunch } from '@tauri-apps/plugin-process';
import { TranslationService } from './i18n/translation.service';
import { ThemeService } from './services/theme.service';
@Component({
selector: 'app-root',
@@ -23,6 +22,8 @@ export class AppComponent {
readonly loadingController: LoadingController = inject(LoadingController);
readonly translationService: TranslationService = inject(TranslationService);
readonly themeService = inject(ThemeService);
constructor() {
this.checkForUpdates();
}
+8
View File
@@ -5,6 +5,10 @@ export const translations = {
en: {
courts: 'Courts',
numberOfCourts: 'Number of Courts',
theme: 'Theme',
themeLight: 'Light',
themeDark: 'Dark',
themeSystem: 'Device-preferred',
showAlerts: 'Show alerts on quick buttons',
serviceJudge: 'Service Judge',
language: 'Language',
@@ -54,6 +58,10 @@ export const translations = {
hu: {
courts: 'Pályák',
numberOfCourts: 'Pályák száma',
theme: 'Téma',
themeLight: 'Világos',
themeDark: 'Sötét',
themeSystem: 'Eszközbeállítás szerint',
showAlerts: 'Figyelmeztetések megjelenítése a gyors gomboknál',
serviceJudge: 'Adogatásbíró',
language: 'Nyelv',
+91
View File
@@ -0,0 +1,91 @@
import { TestBed } from '@angular/core/testing';
import { ThemePreference, ThemeService } from './theme.service';
describe('ThemeService', () => {
let deviceTheme: MediaQueryList;
let deviceThemeChanged: () => void;
let storedTheme: string | null;
beforeEach(() => {
storedTheme = localStorage.getItem('theme');
localStorage.removeItem('theme');
deviceTheme = {
matches: false,
addEventListener: jasmine
.createSpy('addEventListener')
.and.callFake((_type: string, listener: () => void) => {
deviceThemeChanged = listener;
}),
removeEventListener: jasmine.createSpy('removeEventListener')
} as unknown as MediaQueryList;
spyOn(window, 'matchMedia').and.returnValue(deviceTheme);
TestBed.configureTestingModule({});
});
afterEach(() => {
TestBed.resetTestingModule();
if (storedTheme === null) {
localStorage.removeItem('theme');
} else {
localStorage.setItem('theme', storedTheme);
}
document.documentElement.classList.remove('ion-palette-dark');
});
function expectDark(dark: boolean): void {
expect(
document.documentElement.classList.contains('ion-palette-dark')
).toBe(dark);
}
it('defaults to the device preference and follows changes', () => {
const service = TestBed.inject(ThemeService);
expect(service.theme()).toBe('system');
expectDark(false);
expect(localStorage.getItem('theme')).toBeNull();
Object.defineProperty(deviceTheme, 'matches', { value: true });
deviceThemeChanged();
expectDark(true);
});
for (const theme of ['light', 'dark', 'system'] as ThemePreference[]) {
it(`persists and restores ${theme} on startup`, () => {
Object.defineProperty(deviceTheme, 'matches', { value: true });
TestBed.inject(ThemeService).setTheme(theme);
expect(localStorage.getItem('theme')).toBe(theme);
expectDark(theme !== 'light');
TestBed.resetTestingModule();
document.documentElement.classList.remove('ion-palette-dark');
const restarted = TestBed.inject(ThemeService);
expect(restarted.theme()).toBe(theme);
expectDark(theme !== 'light');
});
}
it('keeps an explicit selection when the device theme changes', () => {
const service = TestBed.inject(ThemeService);
service.setTheme('dark');
deviceThemeChanged();
expectDark(true);
Object.defineProperty(deviceTheme, 'matches', { value: true });
service.setTheme('light');
deviceThemeChanged();
expectDark(false);
service.setTheme('system');
expectDark(true);
});
it('falls back to the device preference for an invalid stored value', () => {
localStorage.setItem('theme', 'invalid');
expect(TestBed.inject(ThemeService).theme()).toBe('system');
});
it('removes its device listener on destruction', () => {
TestBed.inject(ThemeService);
TestBed.resetTestingModule();
expect(deviceTheme.removeEventListener).toHaveBeenCalledWith(
'change',
deviceThemeChanged
);
});
});
+44
View File
@@ -0,0 +1,44 @@
import { DOCUMENT } from '@angular/common';
import { DestroyRef, Injectable, inject, signal } from '@angular/core';
export type ThemePreference = 'light' | 'dark' | 'system';
@Injectable({ providedIn: 'root' })
export class ThemeService {
private readonly document = inject(DOCUMENT);
private readonly destroyRef = inject(DestroyRef);
private readonly storageKey = 'theme';
private readonly deviceTheme = window.matchMedia(
'(prefers-color-scheme: dark)'
);
private readonly preference = signal<ThemePreference>(this.getInitialTheme());
readonly theme = this.preference.asReadonly();
constructor() {
this.applyTheme();
const onDeviceThemeChange = (): void => this.applyTheme();
this.deviceTheme.addEventListener('change', onDeviceThemeChange);
this.destroyRef.onDestroy(() => {
this.deviceTheme.removeEventListener('change', onDeviceThemeChange);
});
}
setTheme(theme: ThemePreference): void {
this.preference.set(theme);
localStorage.setItem(this.storageKey, theme);
this.applyTheme();
}
private getInitialTheme(): ThemePreference {
const stored = localStorage.getItem(this.storageKey);
return stored === 'light' || stored === 'dark' ? stored : 'system';
}
private applyTheme(): void {
const theme = this.preference();
const dark =
theme === 'dark' || (theme === 'system' && this.deviceTheme.matches);
this.document.documentElement.classList.toggle('ion-palette-dark', dark);
}
}
+3 -3
View File
@@ -12,7 +12,7 @@
</ion-header>
<ion-grid [fixed]="'fixed'" class="bottom-lists">
<ion-row>
<ion-row class="court-headers">
<ion-col [size]="1" class="ion-display-none ion-display-xl-block">
<ion-item [lines]="'none'">{{ 'court' | translate }}</ion-item>
</ion-col>
@@ -35,7 +35,7 @@
</ion-row>
@for (item of [].constructor(settings()?.numberOfCourts); track $index) {
<ion-row>
<ion-col [size]="1">
<ion-col [size]="1" class="court-number">
<ion-list>
<ion-item [lines]="'none'"> {{ $index + 1 }}. </ion-item>
</ion-list>
@@ -81,7 +81,7 @@
</ion-list>
</ion-col>
}
<ion-col size="2" sizeLg="1">
<ion-col size="2" sizeLg="1" class="court-actions">
<ion-list>
<ion-item lines="none">
@if ( (umpireByCourt().get($index + 1) &&
+35 -7
View File
@@ -1,12 +1,40 @@
.bottom-lists ion-list {
min-height: 64px;
min-height: 64px;
}
.custom-ion-item {
cursor: move;
padding: 10px 15px;
cursor: move;
padding: 10px 15px;
&.primary {
background-color: var(--ion-color-primary);
}
}
&.primary {
background-color: var(--ion-color-primary);
}
}
:host-context(html:not(.ion-palette-dark)) ion-list[cdkDropList] {
background-color: var(--ion-color-light);
}
:host-context(html:not(.ion-palette-dark)) {
ion-list[cdkDropList].cdk-drop-list-dragging,
.custom-ion-item.primary {
background-color: var(--ion-color-step-150, #d9d9d9);
}
.court-actions ion-list {
background-color: var(--ion-color-light);
}
.court-actions ion-item {
--background: var(--ion-color-light);
}
.court-headers ion-item,
.court-number ion-item {
--background: var(--ion-color-step-150, #d9d9d9);
}
.court-number ion-list {
background-color: var(--ion-color-step-150, #d9d9d9);
}
}
+17
View File
@@ -53,6 +53,23 @@
(ionInput)="updateNumberOfCourts($event)">
</ion-input>
</ion-item>
<ion-item>
<ion-select
[label]="'theme' | translate"
[value]="themeService.theme()"
(ionChange)="updateTheme($event)"
interface="popover">
<ion-select-option value="light">
{{ 'themeLight' | translate }}
</ion-select-option>
<ion-select-option value="dark">
{{ 'themeDark' | translate }}
</ion-select-option>
<ion-select-option value="system">
{{ 'themeSystem' | translate }}
</ion-select-option>
</ion-select>
</ion-item>
</ion-list>
}
+7
View File
@@ -23,6 +23,7 @@ import { WaitingServiceJudgesService } from '../services/waiting-service-judges.
import { TranslationService } from '../i18n/translation.service';
import { Language } from '../i18n/translations';
import { TranslatePipe } from '../i18n/translation.pipe';
import { ThemePreference, ThemeService } from '../services/theme.service';
@Component({
selector: 'app-tab3',
@@ -53,6 +54,8 @@ export class Tab3Page {
readonly waitingServiceJudgeService = inject(WaitingServiceJudgesService);
readonly translationService = inject(TranslationService);
readonly themeService = inject(ThemeService);
readonly settings = this.settingsService.settings;
async updateNumberOfCourts(event: CustomEvent): Promise<void> {
@@ -101,6 +104,10 @@ export class Tab3Page {
});
}
updateTheme(event: CustomEvent<{ value: ThemePreference }>): void {
this.themeService.setTheme(event.detail.value);
}
updateLanguage(event: CustomEvent): void {
this.translationService.setLanguage(event.detail.value as Language);
}
+1 -3
View File
@@ -32,9 +32,7 @@
* https://ionicframework.com/docs/theming/dark-mode
*/
/* @import "@ionic/angular/css/palettes/dark.always.css"; */
/* @import "@ionic/angular/css/palettes/dark.class.css"; */
@import '@ionic/angular/css/palettes/dark.system.css';
@import '@ionic/angular/css/palettes/dark.class.css';
.wide.sc-ion-alert-md-h {
.sc-ion-alert-md {
-1
View File
@@ -1,3 +1,2 @@
// For information on how to create your own theme, please refer to:
// https://ionicframework.com/docs/theming/
@import '@ionic/angular/css/palettes/dark.always.css';