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