feat: add persistent theme selection and light mode court styling
This commit is contained in:
1 parent
0757e0df76
commit
512aa58949
11 files changed
+210
-10
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) &&
|
||||
|
||||
@@ -10,3 +10,31 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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';
|
||||
Reference in new issue
Block a user