From 512aa58949dcb40d1b88a9888e0c652263986543 Mon Sep 17 00:00:00 2001 From: Richard Toth Date: Sat, 24 Oct 2026 09:30:36 +0200 Subject: [PATCH] feat: add persistent theme selection and light mode court styling --- docs/architecture.md | 9 ++- src/app/app.component.ts | 5 +- src/app/i18n/translations.ts | 8 +++ src/app/services/theme.service.spec.ts | 91 ++++++++++++++++++++++++++ src/app/services/theme.service.ts | 44 +++++++++++++ src/app/tab1/tab1.page.html | 6 +- src/app/tab1/tab1.page.scss | 42 ++++++++++-- src/app/tab3/tab3.page.html | 17 +++++ src/app/tab3/tab3.page.ts | 7 ++ src/global.scss | 4 +- src/theme/variables.scss | 1 - 11 files changed, 217 insertions(+), 17 deletions(-) create mode 100644 src/app/services/theme.service.spec.ts create mode 100644 src/app/services/theme.service.ts diff --git a/docs/architecture.md b/docs/architecture.md index ef89713..555e3fe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 7991818..4f76e89 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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(); } diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index ab4b6bd..a52e544 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -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', diff --git a/src/app/services/theme.service.spec.ts b/src/app/services/theme.service.spec.ts new file mode 100644 index 0000000..3cae3f4 --- /dev/null +++ b/src/app/services/theme.service.spec.ts @@ -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 + ); + }); +}); diff --git a/src/app/services/theme.service.ts b/src/app/services/theme.service.ts new file mode 100644 index 0000000..e0320c9 --- /dev/null +++ b/src/app/services/theme.service.ts @@ -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(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); + } +} diff --git a/src/app/tab1/tab1.page.html b/src/app/tab1/tab1.page.html index 1805dd4..7c28649 100644 --- a/src/app/tab1/tab1.page.html +++ b/src/app/tab1/tab1.page.html @@ -12,7 +12,7 @@ - + {{ 'court' | translate }} @@ -35,7 +35,7 @@ @for (item of [].constructor(settings()?.numberOfCourts); track $index) { - + {{ $index + 1 }}. @@ -81,7 +81,7 @@ } - + @if ( (umpireByCourt().get($index + 1) && diff --git a/src/app/tab1/tab1.page.scss b/src/app/tab1/tab1.page.scss index 8afc064..2f312aa 100644 --- a/src/app/tab1/tab1.page.scss +++ b/src/app/tab1/tab1.page.scss @@ -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); - } -} \ No newline at end of file + &.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); + } +} diff --git a/src/app/tab3/tab3.page.html b/src/app/tab3/tab3.page.html index c6c28ac..9b48208 100644 --- a/src/app/tab3/tab3.page.html +++ b/src/app/tab3/tab3.page.html @@ -53,6 +53,23 @@ (ionInput)="updateNumberOfCourts($event)"> + + + + {{ 'themeLight' | translate }} + + + {{ 'themeDark' | translate }} + + + {{ 'themeSystem' | translate }} + + + } diff --git a/src/app/tab3/tab3.page.ts b/src/app/tab3/tab3.page.ts index 425be56..ce922b4 100644 --- a/src/app/tab3/tab3.page.ts +++ b/src/app/tab3/tab3.page.ts @@ -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 { @@ -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); } diff --git a/src/global.scss b/src/global.scss index 2cb92ec..c825b81 100644 --- a/src/global.scss +++ b/src/global.scss @@ -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 { diff --git a/src/theme/variables.scss b/src/theme/variables.scss index 55035c7..f8ea4bd 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -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'; \ No newline at end of file