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
+216 -16

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/pilot` | `src/app/tab1/` | Waiting queues, court assignments, drag and drop, and rotation actions |
| `/tabs/umpires` | `src/app/tab2/` | Court-official roster management | | `/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. 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. 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 ### 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. 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 { platform } from '@tauri-apps/plugin-os';
import { relaunch } from '@tauri-apps/plugin-process'; import { relaunch } from '@tauri-apps/plugin-process';
import { TranslationService } from './i18n/translation.service'; import { TranslationService } from './i18n/translation.service';
import { ThemeService } from './services/theme.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -23,6 +22,8 @@ export class AppComponent {
readonly loadingController: LoadingController = inject(LoadingController); readonly loadingController: LoadingController = inject(LoadingController);
readonly translationService: TranslationService = inject(TranslationService); readonly translationService: TranslationService = inject(TranslationService);
readonly themeService = inject(ThemeService);
constructor() { constructor() {
this.checkForUpdates(); this.checkForUpdates();
} }
+8
View File
@@ -5,6 +5,10 @@ export const translations = {
en: { en: {
courts: 'Courts', courts: 'Courts',
numberOfCourts: 'Number of Courts', numberOfCourts: 'Number of Courts',
theme: 'Theme',
themeLight: 'Light',
themeDark: 'Dark',
themeSystem: 'Device-preferred',
showAlerts: 'Show alerts on quick buttons', showAlerts: 'Show alerts on quick buttons',
serviceJudge: 'Service Judge', serviceJudge: 'Service Judge',
language: 'Language', language: 'Language',
@@ -54,6 +58,10 @@ export const translations = {
hu: { hu: {
courts: 'Pályák', courts: 'Pályák',
numberOfCourts: 'Pályák száma', 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', showAlerts: 'Figyelmeztetések megjelenítése a gyors gomboknál',
serviceJudge: 'Adogatásbíró', serviceJudge: 'Adogatásbíró',
language: 'Nyelv', 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-header>
<ion-grid [fixed]="'fixed'" class="bottom-lists"> <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-col [size]="1" class="ion-display-none ion-display-xl-block">
<ion-item [lines]="'none'">{{ 'court' | translate }}</ion-item> <ion-item [lines]="'none'">{{ 'court' | translate }}</ion-item>
</ion-col> </ion-col>
@@ -35,7 +35,7 @@
</ion-row> </ion-row>
@for (item of [].constructor(settings()?.numberOfCourts); track $index) { @for (item of [].constructor(settings()?.numberOfCourts); track $index) {
<ion-row> <ion-row>
<ion-col [size]="1"> <ion-col [size]="1" class="court-number">
<ion-list> <ion-list>
<ion-item [lines]="'none'"> {{ $index + 1 }}. </ion-item> <ion-item [lines]="'none'"> {{ $index + 1 }}. </ion-item>
</ion-list> </ion-list>
@@ -81,7 +81,7 @@
</ion-list> </ion-list>
</ion-col> </ion-col>
} }
<ion-col size="2" sizeLg="1"> <ion-col size="2" sizeLg="1" class="court-actions">
<ion-list> <ion-list>
<ion-item lines="none"> <ion-item lines="none">
@if ( (umpireByCourt().get($index + 1) && @if ( (umpireByCourt().get($index + 1) &&
+34 -6
View File
@@ -1,12 +1,40 @@
.bottom-lists ion-list { .bottom-lists ion-list {
min-height: 64px; min-height: 64px;
} }
.custom-ion-item { .custom-ion-item {
cursor: move; cursor: move;
padding: 10px 15px; padding: 10px 15px;
&.primary { &.primary {
background-color: var(--ion-color-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)"> (ionInput)="updateNumberOfCourts($event)">
</ion-input> </ion-input>
</ion-item> </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> </ion-list>
} }
+7
View File
@@ -23,6 +23,7 @@ import { WaitingServiceJudgesService } from '../services/waiting-service-judges.
import { TranslationService } from '../i18n/translation.service'; import { TranslationService } from '../i18n/translation.service';
import { Language } from '../i18n/translations'; import { Language } from '../i18n/translations';
import { TranslatePipe } from '../i18n/translation.pipe'; import { TranslatePipe } from '../i18n/translation.pipe';
import { ThemePreference, ThemeService } from '../services/theme.service';
@Component({ @Component({
selector: 'app-tab3', selector: 'app-tab3',
@@ -53,6 +54,8 @@ export class Tab3Page {
readonly waitingServiceJudgeService = inject(WaitingServiceJudgesService); readonly waitingServiceJudgeService = inject(WaitingServiceJudgesService);
readonly translationService = inject(TranslationService); readonly translationService = inject(TranslationService);
readonly themeService = inject(ThemeService);
readonly settings = this.settingsService.settings; readonly settings = this.settingsService.settings;
async updateNumberOfCourts(event: CustomEvent): Promise<void> { 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 { updateLanguage(event: CustomEvent): void {
this.translationService.setLanguage(event.detail.value as Language); this.translationService.setLanguage(event.detail.value as Language);
} }
+1 -3
View File
@@ -32,9 +32,7 @@
* https://ionicframework.com/docs/theming/dark-mode * 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.class.css"; */
@import '@ionic/angular/css/palettes/dark.system.css';
.wide.sc-ion-alert-md-h { .wide.sc-ion-alert-md-h {
.sc-ion-alert-md { .sc-ion-alert-md {
-1
View File
@@ -1,3 +1,2 @@
// For information on how to create your own theme, please refer to: // For information on how to create your own theme, please refer to:
// https://ionicframework.com/docs/theming/ // https://ionicframework.com/docs/theming/
@import '@ionic/angular/css/palettes/dark.always.css';