From 958e9bec7bdf9f8acaf74949990c76257398cca1 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Sat, 5 Sep 2026 19:44:44 +0700 Subject: [PATCH 01/20] feat: add device-local settings with widget discovery and fonts Settings stay on the device that set them: a phone and a desktop are allowed to differ, so the storage key carries no account id and nothing is sent anywhere. Which widgets exist is discovered rather than declared. Each entry in the registry names the elements that would prove its feature is in this build, and only the ones actually found are offered, so the list can name a feature that has not been merged yet and start working on its own once it is. Switched-off widgets are stored as the keys to hide, so a widget added by a later release is visible by default and needs no migration. Fonts are applied through Ionic's own --ion-font-family, and the downloadable faces are fetched only once chosen. --- src/app/settings.service.ts | 212 +++++++++++++++++++++++++++ src/app/settings/day.ts | 25 ++++ src/app/settings/fonts.ts | 62 ++++++++ src/app/settings/sanitize.ts | 27 ++++ src/app/settings/settings-presets.ts | 57 +++++++ src/app/settings/settings.model.ts | 48 ++++++ src/app/settings/settings.spec.ts | 63 ++++++++ 7 files changed, 494 insertions(+) create mode 100644 src/app/settings.service.ts create mode 100644 src/app/settings/day.ts create mode 100644 src/app/settings/fonts.ts create mode 100644 src/app/settings/sanitize.ts create mode 100644 src/app/settings/settings-presets.ts create mode 100644 src/app/settings/settings.model.ts create mode 100644 src/app/settings/settings.spec.ts diff --git a/src/app/settings.service.ts b/src/app/settings.service.ts new file mode 100644 index 0000000..a53a24c --- /dev/null +++ b/src/app/settings.service.ts @@ -0,0 +1,212 @@ +import {DestroyRef, inject, Injectable} from '@angular/core'; +import {NavigationEnd, Router} from '@angular/router'; +import {BehaviorSubject, Observable} from 'rxjs'; +import {distinctUntilChanged, filter, map} from 'rxjs/operators'; +import {findFont, FONT_OPTIONS, fontHref, previewHref} from './settings/fonts'; +import {defaultSettings, RESET_HOURS, WIDGETS} from './settings/settings-presets'; +import {sanitizeSettings} from './settings/sanitize'; +import {AppliedFont, AppSettings, FontOption, WidgetOption} from './settings/settings.model'; + +/** Settings stay on the device that set them, so the key carries no account id. */ +export const SETTINGS_STORAGE_KEY = 'flickSettings'; +/** Mirrors the resolved font so index.html can paint it before Angular boots. */ +export const APPLIED_FONT_KEY = 'flickFontApplied'; +/** Names the switched-off widgets for the rules in settings.scss. */ +export const HIDDEN_ATTRIBUTE = 'data-hidden'; + +const FONT_LINK_PREFIX = 'flick-font-'; +const PREVIEW_LINK_ID = 'flick-font-previews'; + +/** + * The device's own preferences: which widgets to show, the font to read the app in, and when + * a Pomodoro day rolls over. Deliberately never synced — a phone and a desktop are allowed to + * differ, so nothing here is keyed by account or sent anywhere. + */ +@Injectable({ + providedIn: 'root', +}) +export class SettingsService { + readonly widgets = WIDGETS; + readonly fonts = FONT_OPTIONS; + readonly resetHours = RESET_HOURS; + + private readonly settingsSubject = new BehaviorSubject(defaultSettings()); + + readonly settings$: Observable = this.settingsSubject.asObservable(); + readonly availableWidgets$: Observable = this.settings$.pipe( + map(settings => WIDGETS.filter(widget => settings.seen.includes(widget.key))), + ); + readonly font$: Observable = this.settings$.pipe( + map(settings => findFont(settings.fontId)), + distinctUntilChanged(), + ); + readonly pomodoroResetHour$: Observable = this.settings$.pipe( + map(settings => settings.pomodoroResetHour), + distinctUntilChanged(), + ); + readonly pomodoroAvailable$: Observable = this.available$('pomodoro'); + + constructor() { + this.apply(this.readLocal(), false); + + // Nothing has rendered yet, so the first look for widgets waits for the first page. + const timer = setTimeout(() => this.probe()); + const subscription = inject(Router).events + .pipe(filter(event => event instanceof NavigationEnd)) + .subscribe(() => setTimeout(() => this.probe())); + inject(DestroyRef).onDestroy(() => { + clearTimeout(timer); + subscription.unsubscribe(); + }); + } + + get settings(): AppSettings { + return this.settingsSubject.value; + } + + isVisible(key: string): boolean { + return !this.settings.hidden.includes(key); + } + + /** Builds a new stream per call, so hold the result in a field rather than a template. */ + visible$(key: string): Observable { + return this.settings$.pipe( + map(settings => !settings.hidden.includes(key)), + distinctUntilChanged(), + ); + } + + /** Whether this build turned out to contain the widget at all. */ + available$(key: string): Observable { + return this.settings$.pipe( + map(settings => settings.seen.includes(key)), + distinctUntilChanged(), + ); + } + + setVisible(key: string, visible: boolean): void { + const hidden = this.settings.hidden.filter(entry => entry !== key); + if (!visible) { + hidden.push(key); + } + this.update({hidden}); + } + + setFont(fontId: string): void { + this.update({fontId: findFont(fontId).id}); + } + + setPomodoroResetHour(pomodoroResetHour: number): void { + this.update({pomodoroResetHour}); + } + + readPomodoroDayKey(): string { + return this.settings.pomodoroDayKey; + } + + writePomodoroDayKey(pomodoroDayKey: string): void { + this.update({pomodoroDayKey}); + } + + /** Draw the picker in the real faces, in one request small enough to be worth it. */ + loadFontPreviews(): void { + this.ensureLink(PREVIEW_LINK_ID, previewHref()); + } + + /** + * Look for the elements that prove a feature is in this build. A switched-off widget is + * only hidden by CSS, so it stays in the page and stays discoverable — which is what lets + * the user switch it back on. + */ + probe(): void { + const seen = [...this.settings.seen]; + for (const widget of WIDGETS) { + const present = widget.selectors.some(selector => document.querySelector(selector)); + if (present && !seen.includes(widget.key)) { + seen.push(widget.key); + } + } + if (seen.length !== this.settings.seen.length) { + this.update({seen}); + } + } + + reset(): void { + // What the app has discovered about itself is not a preference, so it survives. + this.update({...defaultSettings(), seen: this.settings.seen}); + } + + private update(change: Partial): void { + this.apply({...this.settings, ...change}, true); + } + + private apply(settings: AppSettings, persist: boolean): void { + const root = document.documentElement; + if (settings.hidden.length) { + root.setAttribute(HIDDEN_ATTRIBUTE, settings.hidden.join(' ')); + } else { + root.removeAttribute(HIDDEN_ATTRIBUTE); + } + + this.applyFont(findFont(settings.fontId)); + this.settingsSubject.next(settings); + + if (persist) { + this.writeLocal(settings); + } + } + + private applyFont(font: FontOption): void { + const root = document.documentElement; + if (font.stack) { + root.style.setProperty('--ion-font-family', font.stack); + } else { + root.style.removeProperty('--ion-font-family'); + } + root.style.setProperty('--flick-font-scale', String(font.scale ?? 1)); + + const href = fontHref(font); + if (href) { + this.ensureLink(FONT_LINK_PREFIX + font.id, href); + } + this.writeAppliedFont({id: font.id, stack: font.stack, href, scale: font.scale ?? 1}); + } + + /** A face is fetched once and left in place, so switching back to it is instant. */ + private ensureLink(id: string, href: string): void { + if (document.getElementById(id)) { + return; + } + const link = document.createElement('link'); + link.id = id; + link.rel = 'stylesheet'; + link.href = href; + document.head.appendChild(link); + } + + private readLocal(): AppSettings { + try { + const raw = localStorage.getItem(SETTINGS_STORAGE_KEY); + + return sanitizeSettings(raw ? JSON.parse(raw) : null); + } catch { + return defaultSettings(); + } + } + + private writeLocal(settings: AppSettings): void { + try { + localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); + } catch { + // Storage may be unavailable; the settings still hold for this session. + } + } + + private writeAppliedFont(font: AppliedFont): void { + try { + localStorage.setItem(APPLIED_FONT_KEY, JSON.stringify(font)); + } catch { + // Without the mirror the first paint falls back to the default font. + } + } +} diff --git a/src/app/settings/day.ts b/src/app/settings/day.ts new file mode 100644 index 0000000..382b987 --- /dev/null +++ b/src/app/settings/day.ts @@ -0,0 +1,25 @@ +const HOUR_MS = 3600000; + +/** Local calendar date as YYYY-MM-DD, so a day turns where the reader lives, not at UTC. */ +export function toDateKey(date: Date): string { + return date.getFullYear() + + '-' + String(date.getMonth() + 1).padStart(2, '0') + + '-' + String(date.getDate()).padStart(2, '0'); +} + +/** + * The study day a moment belongs to when the day begins at `resetHour` rather than midnight. + * Winding the clock back by that many hours puts a late night on the day it belongs to. + */ +export function studyDayKey(at: number, resetHour: number): string { + return toDateKey(new Date(at - resetHour * HOUR_MS)); +} + +/** When the study day after the one holding `at` begins. */ +export function nextResetAt(at: number, resetHour: number): number { + const shifted = new Date(at - resetHour * HOUR_MS); + + // Built from calendar fields rather than added milliseconds, so daylight saving cannot + // drift the boundary by an hour. + return new Date(shifted.getFullYear(), shifted.getMonth(), shifted.getDate() + 1, resetHour).getTime(); +} diff --git a/src/app/settings/fonts.ts b/src/app/settings/fonts.ts new file mode 100644 index 0000000..1c93ced --- /dev/null +++ b/src/app/settings/fonts.ts @@ -0,0 +1,62 @@ +import {FontOption} from './settings.model'; + +/** Drawn under every font name, so a face is judged on Thai as much as on Latin. */ +export const FONT_SAMPLE = 'ตัวอย่างข้อความ Sample 123'; + +export const DEFAULT_FONT_ID = 'default'; + +const GOOGLE_CSS_URL = 'https://fonts.googleapis.com/css2'; +/** The regular and semibold the app actually renders. */ +const WEIGHTS = 'wght@400;600'; + +/** + * The font picker, ordered from plainest to most decorative. A font without `google` is + * already on the device and costs nothing; the rest are fetched only once chosen. Arial and + * Times New Roman carry no Thai glyphs at all, hence the Thai-capable family at the end of + * their stacks. + */ +export const FONT_OPTIONS: FontOption[] = [ + {id: DEFAULT_FONT_ID, name: 'Default', stack: ''}, + {id: 'sarabun', name: 'Sarabun', stack: 'Sarabun, sans-serif', google: 'Sarabun'}, + {id: 'prompt', name: 'Prompt', stack: 'Prompt, sans-serif', google: 'Prompt'}, + {id: 'kanit', name: 'Kanit', stack: 'Kanit, sans-serif', google: 'Kanit'}, + {id: 'taviraj', name: 'Taviraj', stack: 'Taviraj, serif', google: 'Taviraj'}, + {id: 'mali', name: 'Mali', stack: 'Mali, cursive', google: 'Mali'}, + {id: 'charm', name: 'Charm', stack: 'Charm, cursive', google: 'Charm'}, + {id: 'arial', name: 'Arial', stack: "Arial, Helvetica, 'Leelawadee UI', Tahoma, sans-serif"}, + {id: 'times', name: 'Times New Roman', stack: "'Times New Roman', Times, Norasi, serif"}, + {id: 'angsana', name: 'Angsana New', stack: "'Angsana New', AngsanaUPC, Norasi, serif", scale: 1.35}, + {id: 'thonburi', name: 'Thonburi', stack: 'Thonburi, Krungthep, sans-serif'}, +]; + +export function findFont(id: string): FontOption { + return FONT_OPTIONS.find(font => font.id === id) ?? FONT_OPTIONS[0]; +} + +/** The stylesheet for one family, at the weights the app renders. */ +export function fontHref(font: FontOption): string { + if (!font.google) { + return ''; + } + + return GOOGLE_CSS_URL + '?family=' + encodeURIComponent(font.google) + ':' + WEIGHTS + '&display=swap'; +} + +/** + * One request covering every downloadable face in the picker, cut down with Google's `text` + * parameter to the handful of characters the picker actually draws. That turns opening the + * sheet from hundreds of kilobytes into a few. + */ +export function previewHref(): string { + const families: string[] = []; + let characters = FONT_SAMPLE; + for (const font of FONT_OPTIONS) { + if (font.google) { + families.push('family=' + encodeURIComponent(font.google)); + } + characters += font.name; + } + const unique = Array.from(new Set(characters)).join(''); + + return GOOGLE_CSS_URL + '?' + families.join('&') + '&text=' + encodeURIComponent(unique) + '&display=swap'; +} diff --git a/src/app/settings/sanitize.ts b/src/app/settings/sanitize.ts new file mode 100644 index 0000000..c9f91bf --- /dev/null +++ b/src/app/settings/sanitize.ts @@ -0,0 +1,27 @@ +import {FONT_OPTIONS} from './fonts'; +import {defaultSettings, WIDGETS} from './settings-presets'; +import {AppSettings} from './settings.model'; + +/** Keep only the keys this build still knows, so a widget dropped later cleans itself up. */ +function sanitizeKeys(raw: unknown): string[] { + const stored = Array.isArray(raw) ? raw : []; + + return WIDGETS.map(widget => widget.key).filter(key => stored.includes(key)); +} + +/** Accept only values this build understands, so stored data can never break the app. */ +export function sanitizeSettings(raw: unknown): AppSettings { + const fallback = defaultSettings(); + const value = (raw ?? {}) as Partial; + const hour = Number(value.pomodoroResetHour); + + return { + hidden: sanitizeKeys(value.hidden), + seen: sanitizeKeys(value.seen), + fontId: FONT_OPTIONS.some(font => font.id === value.fontId) + ? value.fontId as string + : fallback.fontId, + pomodoroResetHour: Number.isInteger(hour) && hour >= 0 && hour < 24 ? hour : fallback.pomodoroResetHour, + pomodoroDayKey: typeof value.pomodoroDayKey === 'string' ? value.pomodoroDayKey : '', + }; +} diff --git a/src/app/settings/settings-presets.ts b/src/app/settings/settings-presets.ts new file mode 100644 index 0000000..c7172fc --- /dev/null +++ b/src/app/settings/settings-presets.ts @@ -0,0 +1,57 @@ +import {AppSettings, ResetHourOption, WidgetOption} from './settings.model'; +import {DEFAULT_FONT_ID} from './fonts'; + +/** + * Everything the sheet can switch off. `selectors` name the elements that prove a feature + * shipped in this build: a widget whose element is nowhere in the page is never offered, so + * this list may name features that have not been merged yet and will start working on their + * own once they are. + */ +export const WIDGETS: WidgetOption[] = [ + { + key: 'pomodoro', + label: 'Pomodoro timer', + description: 'The focus timer on a course page.', + icon: 'timer-outline', + selectors: ['app-pomodoro-timer'], + }, + { + key: 'heatmap', + label: 'Study heatmap', + description: 'The activity calendar on the home page.', + icon: 'calendar-outline', + selectors: ['app-study-heatmap'], + }, + { + key: 'appearance', + label: 'Theme button', + description: 'The appearance control in the toolbar.', + icon: 'color-palette-outline', + selectors: ['app-theme-menu', 'app-theme-dropdown'], + }, +]; + +/** A Pomodoro day starts here rather than at midnight, so a late night counts as one sitting. */ +export const DEFAULT_RESET_HOUR = 4; + +function formatHour(hour: number): string { + const suffix = hour < 12 ? 'AM' : 'PM'; + const display = hour % 12 === 0 ? 12 : hour % 12; + + return display + ':00 ' + suffix; +} + +export const RESET_HOURS: ResetHourOption[] = Array.from({length: 24}, (unused, hour) => ({ + value: hour, + label: formatHour(hour), +})); + +export function defaultSettings(): AppSettings { + return { + hidden: [], + seen: [], + fontId: DEFAULT_FONT_ID, + pomodoroResetHour: DEFAULT_RESET_HOUR, + pomodoroDayKey: '', + }; +} diff --git a/src/app/settings/settings.model.ts b/src/app/settings/settings.model.ts new file mode 100644 index 0000000..ac937bd --- /dev/null +++ b/src/app/settings/settings.model.ts @@ -0,0 +1,48 @@ +/** A screen element the user can switch off. */ +export interface WidgetOption { + key: string; + label: string; + description: string; + icon: string; + /** Any one of these existing in the page proves the feature shipped in this build. */ + selectors: string[]; +} + +/** A choice in the font picker. A font without `google` is already on the device. */ +export interface FontOption { + id: string; + name: string; + /** Empty for the default, which drops the override instead of setting one. */ + stack: string; + google?: string; + /** Corrects families that render much smaller than the rest at the same size. */ + scale?: number; +} + +export interface ResetHourOption { + value: number; + label: string; +} + +/** Everything the settings sheet controls, as kept on this device. */ +export interface AppSettings { + /** + * Widget keys the user switched off. Absent means visible, so a widget added by a later + * release is on by default and needs no migration. + */ + hidden: string[]; + /** Widget keys whose element has been seen at least once on this device. */ + seen: string[]; + fontId: string; + pomodoroResetHour: number; + /** The study day the Pomodoro session count currently belongs to. */ + pomodoroDayKey: string; +} + +/** The resolved font, mirrored so index.html can paint it before Angular boots. */ +export interface AppliedFont { + id: string; + stack: string; + href: string; + scale: number; +} diff --git a/src/app/settings/settings.spec.ts b/src/app/settings/settings.spec.ts new file mode 100644 index 0000000..f6a5225 --- /dev/null +++ b/src/app/settings/settings.spec.ts @@ -0,0 +1,63 @@ +import {sanitizeSettings} from './sanitize'; +import {defaultSettings, DEFAULT_RESET_HOUR} from './settings-presets'; +import {nextResetAt, studyDayKey, toDateKey} from './day'; + +describe('sanitizeSettings', () => { + it('falls back to the defaults for anything unusable', () => { + expect(sanitizeSettings(null)).toEqual(defaultSettings()); + expect(sanitizeSettings('not an object')).toEqual(defaultSettings()); + }); + + it('drops widget keys this build no longer knows', () => { + const settings = sanitizeSettings({hidden: ['heatmap', 'gramophone'], seen: ['gramophone']}); + + expect(settings.hidden).toEqual(['heatmap']); + expect(settings.seen).toEqual([]); + }); + + it('treats a widget missing from `hidden` as visible', () => { + expect(sanitizeSettings({hidden: ['pomodoro']}).hidden).not.toContain('heatmap'); + }); + + it('keeps only a font this build offers', () => { + expect(sanitizeSettings({fontId: 'mali'}).fontId).toBe('mali'); + expect(sanitizeSettings({fontId: 'comic-sans'}).fontId).toBe(defaultSettings().fontId); + }); + + it('rejects a reset hour outside the clock', () => { + expect(sanitizeSettings({pomodoroResetHour: 0}).pomodoroResetHour).toBe(0); + expect(sanitizeSettings({pomodoroResetHour: 23}).pomodoroResetHour).toBe(23); + expect(sanitizeSettings({pomodoroResetHour: 24}).pomodoroResetHour).toBe(DEFAULT_RESET_HOUR); + expect(sanitizeSettings({pomodoroResetHour: -1}).pomodoroResetHour).toBe(DEFAULT_RESET_HOUR); + expect(sanitizeSettings({pomodoroResetHour: 4.5}).pomodoroResetHour).toBe(DEFAULT_RESET_HOUR); + }); +}); + +describe('the study day', () => { + const hour = 4; + + it('puts the small hours on the day before', () => { + const lateNight = new Date(2026, 8, 5, 2, 0).getTime(); + + expect(studyDayKey(lateNight, hour)).toBe('2026-09-04'); + }); + + it('turns over exactly at the chosen hour', () => { + expect(studyDayKey(new Date(2026, 8, 5, 3, 59).getTime(), hour)).toBe('2026-09-04'); + expect(studyDayKey(new Date(2026, 8, 5, 4, 0).getTime(), hour)).toBe('2026-09-05'); + }); + + it('follows local midnight when the hour is zero', () => { + const justAfterMidnight = new Date(2026, 8, 5, 0, 30).getTime(); + + expect(studyDayKey(justAfterMidnight, 0)).toBe('2026-09-05'); + expect(toDateKey(new Date(justAfterMidnight))).toBe('2026-09-05'); + }); + + it('reports the next turn as the coming reset hour', () => { + expect(nextResetAt(new Date(2026, 8, 5, 2, 0).getTime(), hour)) + .toBe(new Date(2026, 8, 5, 4, 0).getTime()); + expect(nextResetAt(new Date(2026, 8, 5, 10, 0).getTime(), hour)) + .toBe(new Date(2026, 8, 6, 4, 0).getTime()); + }); +}); From 79269ce241698d5f43d6de26f36a7964e64d2e65 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Sat, 5 Sep 2026 19:44:51 +0700 Subject: [PATCH 02/20] feat: hide widgets by attribute and paint settings before boot The hiding rules name elements this stylesheet does not own. One that has not shipped yet simply matches nothing, so the file is safe to carry ahead of the features it controls. Hiding with CSS rather than dropping the element also keeps a switched-off widget in the page, which is what lets it go on being discovered and so be switched back on. The same file restates the font family after Ionic's iOS rule, which sets the `font` shorthand from -apple-system-body and would otherwise discard a chosen face on iPhone, and carries the size correction for families that render much smaller than the rest. The boot script applies both before Angular starts, so neither a hidden widget nor the default font flashes on the way in. --- angular.json | 3 +++ src/index.html | 31 +++++++++++++++++++++++++++++++ src/settings.scss | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 src/settings.scss diff --git a/angular.json b/angular.json index 8eb62d8..4e0dd09 100644 --- a/angular.json +++ b/angular.json @@ -35,6 +35,9 @@ }, { "input": "src/global.scss" + }, + { + "input": "src/settings.scss" } ], "scripts": [], diff --git a/src/index.html b/src/index.html index bbcc773..ce1afa0 100644 --- a/src/index.html +++ b/src/index.html @@ -18,6 +18,37 @@ + + + diff --git a/src/settings.scss b/src/settings.scss new file mode 100644 index 0000000..c053520 --- /dev/null +++ b/src/settings.scss @@ -0,0 +1,34 @@ +/* + * Settings that have to reach elements this file does not own. + * + * Each selector below names a widget that may or may not be part of this build. One that has + * not shipped yet simply matches nothing, so these rules are safe to keep ahead of the + * features they control and start working the moment a feature arrives. Hiding with CSS + * rather than dropping the element keeps the widget in the page, which is what lets the + * settings sheet go on finding it while it is switched off — and so lets it be switched + * back on. + */ +html[data-hidden~='pomodoro'] app-pomodoro-timer, +html[data-hidden~='heatmap'] app-study-heatmap, +html[data-hidden~='appearance'] app-theme-menu, +html[data-hidden~='appearance'] app-theme-dropdown { + display: none !important; +} + +/* + * Ionic's iOS rule sets the `font` shorthand from -apple-system-body, which carries a family + * of its own and would quietly drop the chosen font. Restating the family after it keeps both + * the dynamic type size and the font. This file is loaded after global.scss, so it comes + * after Ionic's own typography at the same specificity and wins on source order. + */ +html { + font-family: var(--ion-font-family); +} + +/* + * Angsana New and its relatives render far smaller than Arial at the same size. The scale + * sits on body rather than html so 1rem still resolves to the size iOS chose for itself. + */ +body { + font-size: calc(1rem * var(--flick-font-scale, 1)); +} From 2d57119d160339fa547dbcb8f45d875b66190906 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Sat, 5 Sep 2026 19:45:03 +0700 Subject: [PATCH 03/20] feat: add the settings sheet and its toolbar button Each section appears only once it has something to control, so the sheet grows by itself as features land rather than showing switches that do nothing. The font picker draws every name in its own face. That needs the downloadable ones present, so opening the sheet asks for them in a single request cut down to the handful of characters the picker actually draws; the full family follows only when one is chosen. --- src/app/home/home.page.html | 1 + src/app/home/home.page.ts | 2 + src/app/shared/settings-menu.component.html | 3 + src/app/shared/settings-menu.component.scss | 4 + src/app/shared/settings-menu.component.ts | 28 +++++ src/app/shared/settings-sheet.component.html | 79 ++++++++++++++ src/app/shared/settings-sheet.component.scss | 83 +++++++++++++++ src/app/shared/settings-sheet.component.ts | 102 +++++++++++++++++++ 8 files changed, 302 insertions(+) create mode 100644 src/app/shared/settings-menu.component.html create mode 100644 src/app/shared/settings-menu.component.scss create mode 100644 src/app/shared/settings-menu.component.ts create mode 100644 src/app/shared/settings-sheet.component.html create mode 100644 src/app/shared/settings-sheet.component.scss create mode 100644 src/app/shared/settings-sheet.component.ts diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html index 50d3485..9621083 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -4,6 +4,7 @@ MDCU Recorded Lecture + Sign out diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index d2e6b67..ff310ad 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -30,6 +30,7 @@ import { IonToolbar, } from '@ionic/angular/standalone'; import {AsyncPipe, NgStyle} from '@angular/common'; +import {SettingsMenuComponent} from '../shared/settings-menu.component'; export interface EnrichedSearchResult extends SearchVideoResult { courseName?: string; @@ -45,6 +46,7 @@ export interface EnrichedSearchResult extends SearchVideoResult { IonContent, IonGrid, IonRow, IonCol, IonCard, RouterLink, NgStyle, IonCardHeader, IonCardTitle, AsyncPipe, IonCardContent, IonItem, IonLabel, IonText, IonSpinner, IonSearchbar, IonList, + SettingsMenuComponent, ] }) export class HomePage implements OnInit { diff --git a/src/app/shared/settings-menu.component.html b/src/app/shared/settings-menu.component.html new file mode 100644 index 0000000..501a7bc --- /dev/null +++ b/src/app/shared/settings-menu.component.html @@ -0,0 +1,3 @@ + + + diff --git a/src/app/shared/settings-menu.component.scss b/src/app/shared/settings-menu.component.scss new file mode 100644 index 0000000..25dc02d --- /dev/null +++ b/src/app/shared/settings-menu.component.scss @@ -0,0 +1,4 @@ +ion-button { + --padding-start: 0.35rem; + --padding-end: 0.35rem; +} diff --git a/src/app/shared/settings-menu.component.ts b/src/app/shared/settings-menu.component.ts new file mode 100644 index 0000000..b859108 --- /dev/null +++ b/src/app/shared/settings-menu.component.ts @@ -0,0 +1,28 @@ +import {Component, inject} from '@angular/core'; +import {IonButton, IonIcon, ModalController} from '@ionic/angular/standalone'; +import {addIcons} from 'ionicons'; +import {settingsOutline} from 'ionicons/icons'; +import {SettingsSheetComponent} from './settings-sheet.component'; + +@Component({ + selector: 'app-settings-menu', + templateUrl: './settings-menu.component.html', + styleUrls: ['./settings-menu.component.scss'], + imports: [IonButton, IonIcon], +}) +export class SettingsMenuComponent { + private modalCtrl = inject(ModalController); + + constructor() { + addIcons({settingsOutline}); + } + + async open(): Promise { + const modal = await this.modalCtrl.create({ + component: SettingsSheetComponent, + breakpoints: [0, 0.9], + initialBreakpoint: 0.9, + }); + await modal.present(); + } +} diff --git a/src/app/shared/settings-sheet.component.html b/src/app/shared/settings-sheet.component.html new file mode 100644 index 0000000..f2477ff --- /dev/null +++ b/src/app/shared/settings-sheet.component.html @@ -0,0 +1,79 @@ + + + Settings + + + + + + + + + + @if (settings$ | async; as settings) { + @if (availableWidgets$ | async; as widgets) { + @if (widgets.length) { +
+

Show or hide

+

Kept on this device, so your phone and your computer can differ.

+ + @for (widget of widgets; track widget.key) { + + + +

{{ widget.label }}

+

{{ widget.description }}

+
+ +
+ } +
+
+ } + } + +
+

Font

+

Changes every page. Thai and English are both shown, so you can judge the fit.

+
+ @for (font of settingsService.fonts; track font.id) { + + } +
+
+ + @if (pomodoroAvailable$ | async) { +
+

Pomodoro

+

Where your day starts. The session count goes back to zero at this time.

+ + Day starts at + + @for (option of settingsService.resetHours; track option.value) { + {{ option.label }} + } + + +
+ } + + Start over + } +
diff --git a/src/app/shared/settings-sheet.component.scss b/src/app/shared/settings-sheet.component.scss new file mode 100644 index 0000000..85ddef9 --- /dev/null +++ b/src/app/shared/settings-sheet.component.scss @@ -0,0 +1,83 @@ +.settings-section { + margin-bottom: 1.75rem; +} + +.section-title { + margin: 0 0 0.15rem; + font-size: 1rem; + font-weight: 600; +} + +.section-hint { + margin: 0 0 0.75rem; + font-size: 0.8rem; + opacity: 0.7; +} + +.widget-item { + --padding-start: 0; + --inner-padding-end: 0; +} + +.widget-item ion-icon { + margin-inline-end: 0.75rem; + font-size: 1.25rem; + opacity: 0.7; +} + +.font-list { + display: grid; + gap: 0.5rem; +} + +.font-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + width: 100%; + padding: 0.6rem 0.85rem; + border: 1px solid var(--ion-border-color, var(--ion-color-step-150)); + border-radius: 0.65rem; + background: var(--ion-item-background, var(--ion-background-color)); + color: inherit; + text-align: start; + cursor: pointer; +} + +.font-option.selected { + border-color: var(--ion-color-primary); + box-shadow: inset 0 0 0 1px var(--ion-color-primary); +} + +.font-text { + display: grid; + gap: 0.1rem; + min-width: 0; +} + +.font-name { + font-size: 0.95rem; + font-weight: 600; +} + +.font-sample { + font-size: 0.85rem; + opacity: 0.75; +} + +.font-check { + flex: none; + color: var(--ion-color-primary); +} + +.control-item { + --padding-start: 0; + --inner-padding-end: 0; +} + +.quiet-button { + margin-top: 0.5rem; + font-size: 0.85rem; + opacity: 0.75; +} diff --git a/src/app/shared/settings-sheet.component.ts b/src/app/shared/settings-sheet.component.ts new file mode 100644 index 0000000..56385a4 --- /dev/null +++ b/src/app/shared/settings-sheet.component.ts @@ -0,0 +1,102 @@ +import {Component, inject} from '@angular/core'; +import {AsyncPipe} from '@angular/common'; +import { + IonButton, + IonButtons, + IonContent, + IonHeader, + IonIcon, + IonItem, + IonLabel, + IonList, + IonSelect, + IonSelectOption, + IonTitle, + IonToggle, + IonToolbar, + ModalController, +} from '@ionic/angular/standalone'; +import {addIcons} from 'ionicons'; +import { + calendarOutline, + checkmark, + close, + colorPaletteOutline, + timerOutline, +} from 'ionicons/icons'; +import {SettingsService} from '../settings.service'; +import {FONT_SAMPLE} from '../settings/fonts'; +import {AppSettings, FontOption} from '../settings/settings.model'; + +/** Ionic hands its payload over in `detail`, which the DOM event types do not describe. */ +function detailValue(event: Event): T { + return (event as CustomEvent<{value: T}>).detail.value; +} + +function detailChecked(event: Event): boolean { + return (event as CustomEvent<{checked: boolean}>).detail.checked; +} + +@Component({ + selector: 'app-settings-sheet', + templateUrl: './settings-sheet.component.html', + styleUrls: ['./settings-sheet.component.scss'], + imports: [ + IonHeader, + IonToolbar, + IonTitle, + IonButtons, + IonButton, + IonIcon, + IonContent, + IonList, + IonItem, + IonLabel, + IonToggle, + IonSelect, + IonSelectOption, + AsyncPipe, + ], +}) +export class SettingsSheetComponent { + protected settingsService = inject(SettingsService); + private modalCtrl = inject(ModalController); + + protected readonly settings$ = this.settingsService.settings$; + protected readonly availableWidgets$ = this.settingsService.availableWidgets$; + protected readonly pomodoroAvailable$ = this.settingsService.pomodoroAvailable$; + protected readonly sample = FONT_SAMPLE; + + constructor() { + addIcons({close, checkmark, timerOutline, calendarOutline, colorPaletteOutline}); + + // Opening the sheet is an explicit request to look at the fonts, so draw them properly, + // and take the chance to notice any widget this build turned out to have. + this.settingsService.loadFontPreviews(); + this.settingsService.probe(); + } + + close(): void { + void this.modalCtrl.dismiss(); + } + + isVisible(settings: AppSettings, key: string): boolean { + return !settings.hidden.includes(key); + } + + onWidgetToggle(key: string, event: Event): void { + this.settingsService.setVisible(key, detailChecked(event)); + } + + selectFont(font: FontOption): void { + this.settingsService.setFont(font.id); + } + + onResetHourChange(event: Event): void { + this.settingsService.setPomodoroResetHour(detailValue(event)); + } + + reset(): void { + this.settingsService.reset(); + } +} From 46a4ebeede024b04750918c5dd73ece6b0ec98a8 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Sat, 5 Sep 2026 19:45:03 +0700 Subject: [PATCH 04/20] feat: reset the Pomodoro session count at a chosen hour The count was kept for the life of the install, so a morning began wherever the last one left off. It now belongs to a day that starts at an hour the reader picks, four in the morning by default, late enough that a night's work counts as one sitting. The day is checked once per tick against a precomputed boundary, so nothing new is scheduled, and crossing it mid-session leaves the running phase alone. The day itself is kept with the settings rather than with the timer state, so resetting the timer does not persuade the app that a new day has begun. Switching the widget off now stops the timer. It runs from the root injector whether or not it is on screen, so hiding it alone would have left it counting. --- src/app/pomodoro.service.ts | 47 ++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/app/pomodoro.service.ts b/src/app/pomodoro.service.ts index ecb5445..bf81722 100644 --- a/src/app/pomodoro.service.ts +++ b/src/app/pomodoro.service.ts @@ -1,6 +1,8 @@ -import {Injectable} from '@angular/core'; +import {inject, Injectable} from '@angular/core'; import {BehaviorSubject, Observable, Subject, Subscription, interval} from 'rxjs'; import {map} from 'rxjs/operators'; +import {SettingsService} from './settings.service'; +import {nextResetAt, studyDayKey} from './settings/day'; /** Pomodoro timer phase definitions */ export interface PomodoroPhase { @@ -95,6 +97,11 @@ export class PomodoroService { private completedSessions = new BehaviorSubject(0); private studySessionsInCycle = 0; + /** The session count belongs to a day, and the day turns at an hour the reader picks. */ + private settingsService = inject(SettingsService); + private resetHour = this.settingsService.settings.pomodoroResetHour; + private nextDayAt = nextResetAt(Date.now(), this.resetHour); + /** Notification stream */ private phaseNotificationSubject = new Subject(); phaseNotification$: Observable = this.phaseNotificationSubject.asObservable(); @@ -117,6 +124,20 @@ export class PomodoroService { constructor() { this.loadPreferences(this.currentUserId); this.restoreTimerState(this.currentUserId); + + this.settingsService.pomodoroResetHour$.subscribe(hour => { + this.resetHour = hour; + this.nextDayAt = nextResetAt(Date.now(), hour); + this.rollOverIfNewDay(); + }); + + // The timer keeps running from the root injector whether or not the widget is on + // screen, so switching it off has to stop it rather than merely hide it. + this.settingsService.visible$('pomodoro').subscribe(visible => { + if (!visible) { + this.pause(); + } + }); } /** Request browser system notification permissions */ @@ -222,6 +243,26 @@ export class PomodoroService { // ────────────────────────── Private methods ────────────────────────── + /** Catch up on a day that turned while the app was closed or idle. */ + private rollOverIfNewDay(): void { + const today = studyDayKey(Date.now(), this.resetHour); + if (this.settingsService.readPomodoroDayKey() !== today) { + this.rollOverDay(); + } + } + + /** + * Start the day's session count again. The running phase is left alone: crossing the + * boundary mid-session should not throw away the sitting the reader is in. + */ + private rollOverDay(): void { + this.studySessionsInCycle = 0; + this.completedSessions.next(0); + this.nextDayAt = nextResetAt(Date.now(), this.resetHour); + this.settingsService.writePomodoroDayKey(studyDayKey(Date.now(), this.resetHour)); + this.saveTimerState(); + } + private getStudyPhase(): PomodoroPhase { return POMODORO_PHASES.find(p => p.key === 'study') ?? POMODORO_PHASES[0]; } @@ -247,6 +288,10 @@ export class PomodoroService { private startTicking(): void { this.stopTicking(); this.tickSubscription = interval(1000).subscribe(() => { + if (Date.now() >= this.nextDayAt) { + this.rollOverDay(); + } + const remaining = this.timeRemaining.value - 1; if (remaining <= 0) { From e939c7d171f8e9fbd4146e553d775c023d78cc1c Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Sat, 5 Sep 2026 20:21:25 +0700 Subject: [PATCH 05/20] fix: make the whole row the switch and pick the font from a dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the switch itself answered a tap, so aiming at the row — the obvious target, and the whole width of it — did nothing at all. The label now sits inside the toggle, which is how Ionic 8 expects it and which makes the entire row the control. The switch also sat flush against the card's edge, because the item's end padding had been zeroed. It is left alone now, so the switch keeps its margin. The font was a column of eleven sample buttons, which ran to 647px and pushed everything else off the screen. It is a dropdown now, with a single line underneath drawn in the chosen face so the fit can still be judged. The section is 127px. --- src/app/shared/settings-sheet.component.html | 71 ++++++++++---------- src/app/shared/settings-sheet.component.scss | 69 +++++++------------ src/app/shared/settings-sheet.component.ts | 12 ++-- 3 files changed, 64 insertions(+), 88 deletions(-) diff --git a/src/app/shared/settings-sheet.component.html b/src/app/shared/settings-sheet.component.html index f2477ff..6ad91cd 100644 --- a/src/app/shared/settings-sheet.component.html +++ b/src/app/shared/settings-sheet.component.html @@ -16,18 +16,19 @@

Show or hide

Kept on this device, so your phone and your computer can differ.

- + @for (widget of widgets; track widget.key) { - + - -

{{ widget.label }}

-

{{ widget.description }}

-
- + + (ionChange)="onWidgetToggle(widget.key, $event)"> + {{ widget.label }} + {{ widget.description }} +
}
@@ -37,40 +38,38 @@

{{ widget.label }}

Font

-

Changes every page. Thai and English are both shown, so you can judge the fit.

-
- @for (font of settingsService.fonts; track font.id) { - - } -
+ + + + @if (font$ | async; as font) { +

{{ sample }}

+ }
@if (pomodoroAvailable$ | async) {

Pomodoro

Where your day starts. The session count goes back to zero at this time.

- - Day starts at - - @for (option of settingsService.resetHours; track option.value) { - {{ option.label }} - } - - + + + + @for (option of settingsService.resetHours; track option.value) { + {{ option.label }} + } + + +
} diff --git a/src/app/shared/settings-sheet.component.scss b/src/app/shared/settings-sheet.component.scss index 85ddef9..e161ed0 100644 --- a/src/app/shared/settings-sheet.component.scss +++ b/src/app/shared/settings-sheet.component.scss @@ -9,71 +9,50 @@ } .section-hint { - margin: 0 0 0.75rem; + margin: 0 0 0.6rem; font-size: 0.8rem; opacity: 0.7; } -.widget-item { - --padding-start: 0; - --inner-padding-end: 0; +.settings-card { + padding: 0; + border-radius: 0.75rem; + overflow: hidden; } -.widget-item ion-icon { +.settings-card ion-item { + --background: transparent; +} + +.settings-card ion-icon[slot='start'] { margin-inline-end: 0.75rem; font-size: 1.25rem; opacity: 0.7; } -.font-list { - display: grid; - gap: 0.5rem; -} - -.font-option { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; +/* The switch needs room to sit inside the card rather than on its edge. */ +.widget-toggle { width: 100%; - padding: 0.6rem 0.85rem; - border: 1px solid var(--ion-border-color, var(--ion-color-step-150)); - border-radius: 0.65rem; - background: var(--ion-item-background, var(--ion-background-color)); - color: inherit; - text-align: start; - cursor: pointer; + padding-inline-end: 0.15rem; } -.font-option.selected { - border-color: var(--ion-color-primary); - box-shadow: inset 0 0 0 1px var(--ion-color-primary); -} - -.font-text { - display: grid; - gap: 0.1rem; - min-width: 0; -} - -.font-name { +.widget-label { + display: block; font-size: 0.95rem; font-weight: 600; } -.font-sample { - font-size: 0.85rem; - opacity: 0.75; -} - -.font-check { - flex: none; - color: var(--ion-color-primary); +.widget-hint { + display: block; + margin-top: 0.1rem; + font-size: 0.78rem; + opacity: 0.7; + white-space: normal; } -.control-item { - --padding-start: 0; - --inner-padding-end: 0; +.font-preview { + margin: 0.6rem 0.25rem 0; + font-size: 1.05rem; } .quiet-button { diff --git a/src/app/shared/settings-sheet.component.ts b/src/app/shared/settings-sheet.component.ts index 56385a4..a49edda 100644 --- a/src/app/shared/settings-sheet.component.ts +++ b/src/app/shared/settings-sheet.component.ts @@ -7,7 +7,6 @@ import { IonHeader, IonIcon, IonItem, - IonLabel, IonList, IonSelect, IonSelectOption, @@ -19,14 +18,13 @@ import { import {addIcons} from 'ionicons'; import { calendarOutline, - checkmark, close, colorPaletteOutline, timerOutline, } from 'ionicons/icons'; import {SettingsService} from '../settings.service'; import {FONT_SAMPLE} from '../settings/fonts'; -import {AppSettings, FontOption} from '../settings/settings.model'; +import {AppSettings} from '../settings/settings.model'; /** Ionic hands its payload over in `detail`, which the DOM event types do not describe. */ function detailValue(event: Event): T { @@ -51,7 +49,6 @@ function detailChecked(event: Event): boolean { IonContent, IonList, IonItem, - IonLabel, IonToggle, IonSelect, IonSelectOption, @@ -65,10 +62,11 @@ export class SettingsSheetComponent { protected readonly settings$ = this.settingsService.settings$; protected readonly availableWidgets$ = this.settingsService.availableWidgets$; protected readonly pomodoroAvailable$ = this.settingsService.pomodoroAvailable$; + protected readonly font$ = this.settingsService.font$; protected readonly sample = FONT_SAMPLE; constructor() { - addIcons({close, checkmark, timerOutline, calendarOutline, colorPaletteOutline}); + addIcons({close, timerOutline, calendarOutline, colorPaletteOutline}); // Opening the sheet is an explicit request to look at the fonts, so draw them properly, // and take the chance to notice any widget this build turned out to have. @@ -88,8 +86,8 @@ export class SettingsSheetComponent { this.settingsService.setVisible(key, detailChecked(event)); } - selectFont(font: FontOption): void { - this.settingsService.setFont(font.id); + onFontChange(event: Event): void { + this.settingsService.setFont(detailValue(event)); } onResetHourChange(event: Event): void { From 8c4386de86be11ce722c9cf0d565062e8ed7e311 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Sat, 5 Sep 2026 20:21:25 +0700 Subject: [PATCH 06/20] feat: reach the settings from the course and year toolbars The button lived only on the home page, while the one widget it can switch off lives on a course page. Turning the timer off therefore changed nothing the reader could see, which made the whole sheet look inert. It now sits on the course and year toolbars as well, so a switch takes effect in front of whoever threw it. --- src/app/home/course/course.page.html | 3 +++ src/app/home/course/course.page.ts | 4 ++++ src/app/home/list/list.page.html | 3 +++ src/app/home/list/list.page.ts | 6 ++++-- 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/app/home/course/course.page.html b/src/app/home/course/course.page.html index bac1407..7c0df16 100644 --- a/src/app/home/course/course.page.html +++ b/src/app/home/course/course.page.html @@ -7,6 +7,9 @@ {{ (courseProgress.duration - courseProgress.viewed) / 3600 | number:'1.0-1' }} hours left ({{ (courseProgress.viewed / courseProgress.duration) * 100 | number:'1.0-1' }}%) } + + + diff --git a/src/app/home/course/course.page.ts b/src/app/home/course/course.page.ts index f30584f..2b716c7 100644 --- a/src/app/home/course/course.page.ts +++ b/src/app/home/course/course.page.ts @@ -10,6 +10,7 @@ import { AlertController, IonBackButton, IonButton, + IonButtons, IonCard, IonCardContent, IonCardHeader, @@ -31,6 +32,7 @@ import { IonToolbar, ModalController, } from '@ionic/angular/standalone'; +import {SettingsMenuComponent} from '../../shared/settings-menu.component'; import { DomSanitizer } from '@angular/platform-browser'; import { PlayHistory } from '../../play-tracker.service'; import { addIcons } from "ionicons"; @@ -49,6 +51,7 @@ import { PomodoroTimerComponent } from '../../shared/pomodoro-timer.component'; IonHeader, IonToolbar, IonBackButton, + IonButtons, IonTitle, IonContent, IonGrid, @@ -72,6 +75,7 @@ import { PomodoroTimerComponent } from '../../shared/pomodoro-timer.component'; DecimalPipe, DatePipe, PomodoroTimerComponent, + SettingsMenuComponent, ] }) export class CoursePage implements OnInit, AfterViewInit, OnDestroy { diff --git a/src/app/home/list/list.page.html b/src/app/home/list/list.page.html index c3bbd09..d4c8e77 100644 --- a/src/app/home/list/list.page.html +++ b/src/app/home/list/list.page.html @@ -4,6 +4,9 @@ {{ year || 'Loading'}} + + + Date: Mon, 5 Oct 2026 21:55:13 +0700 Subject: [PATCH 07/20] feat: preview each typeface in the font picker Ionic's popover copies an option's classes onto its row but not its styles, so every row now takes its face from a rule keyed by that class, built from the font list. Default shows Ionic's own font rather than the one currently chosen. Also rename "Start over" to "Restore default settings", and stop the iOS toolbar title running under the toolbar buttons on phones. --- src/app/home/home.page.scss | 12 ++++++++++- src/app/settings.service.ts | 9 +++++++- src/app/settings/fonts.ts | 22 ++++++++++++++++++++ src/app/shared/settings-sheet.component.html | 4 ++-- src/app/shared/settings-sheet.component.ts | 3 ++- 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/app/home/home.page.scss b/src/app/home/home.page.scss index e1ad0f2..d6b9529 100644 --- a/src/app/home/home.page.scss +++ b/src/app/home/home.page.scss @@ -187,4 +187,14 @@ p { font-size: 0.7rem; font-weight: 600; letter-spacing: 0.02em; -} \ No newline at end of file +} + +/* iOS centres the title between fixed gutters, which the toolbar buttons outgrow on a phone. + There the title moves to the start and gives way to them instead of running underneath. */ +@media (max-width: 575.98px) { + ion-title.ios { + position: static; + padding-inline: 16px 0; + text-align: start; + } +} diff --git a/src/app/settings.service.ts b/src/app/settings.service.ts index a53a24c..dfd4bb1 100644 --- a/src/app/settings.service.ts +++ b/src/app/settings.service.ts @@ -2,7 +2,7 @@ import {DestroyRef, inject, Injectable} from '@angular/core'; import {NavigationEnd, Router} from '@angular/router'; import {BehaviorSubject, Observable} from 'rxjs'; import {distinctUntilChanged, filter, map} from 'rxjs/operators'; -import {findFont, FONT_OPTIONS, fontHref, previewHref} from './settings/fonts'; +import {findFont, FONT_OPTIONS, fontHref, previewHref, previewRules} from './settings/fonts'; import {defaultSettings, RESET_HOURS, WIDGETS} from './settings/settings-presets'; import {sanitizeSettings} from './settings/sanitize'; import {AppliedFont, AppSettings, FontOption, WidgetOption} from './settings/settings.model'; @@ -16,6 +16,7 @@ export const HIDDEN_ATTRIBUTE = 'data-hidden'; const FONT_LINK_PREFIX = 'flick-font-'; const PREVIEW_LINK_ID = 'flick-font-previews'; +const PREVIEW_STYLE_ID = 'flick-font-preview-rules'; /** * The device's own preferences: which widgets to show, the font to read the app in, and when @@ -111,6 +112,12 @@ export class SettingsService { /** Draw the picker in the real faces, in one request small enough to be worth it. */ loadFontPreviews(): void { this.ensureLink(PREVIEW_LINK_ID, previewHref()); + if (!document.getElementById(PREVIEW_STYLE_ID)) { + const style = document.createElement('style'); + style.id = PREVIEW_STYLE_ID; + style.textContent = previewRules(); + document.head.appendChild(style); + } } /** diff --git a/src/app/settings/fonts.ts b/src/app/settings/fonts.ts index 1c93ced..b3b2d0f 100644 --- a/src/app/settings/fonts.ts +++ b/src/app/settings/fonts.ts @@ -42,6 +42,28 @@ export function fontHref(font: FontOption): string { return GOOGLE_CSS_URL + '?family=' + encodeURIComponent(font.google) + ':' + WEIGHTS + '&display=swap'; } +/** Marks a picker option so its row in the popover can be drawn in that face. */ +export function fontOptionClass(font: FontOption): string { + return 'flick-font-option-' + font.id; +} + +/** + * Ionic's popover copies each option's classes onto its row but not its styles, so every row + * takes its face from a rule keyed by that class. Default names Ionic's own font rather than + * inheriting, which would otherwise show whichever font is currently chosen. + */ +export function previewRules(): string { + return FONT_OPTIONS.map(font => { + const selector = '.' + fontOptionClass(font); + let rules = selector + ' { --ion-font-family: ' + (font.stack || 'var(--ion-default-font)') + '; }'; + if (font.scale) { + rules += ' ' + selector + ' ion-radio { font-size: ' + font.scale + 'em; }'; + } + + return rules; + }).join('\n'); +} + /** * One request covering every downloadable face in the picker, cut down with Google's `text` * parameter to the handful of characters the picker actually draws. That turns opening the diff --git a/src/app/shared/settings-sheet.component.html b/src/app/shared/settings-sheet.component.html index 6ad91cd..9d77f1d 100644 --- a/src/app/shared/settings-sheet.component.html +++ b/src/app/shared/settings-sheet.component.html @@ -45,7 +45,7 @@

Font

[value]="settings.fontId" (ionChange)="onFontChange($event)"> @for (font of settingsService.fonts; track font.id) { - {{ font.name }} + {{ font.name }} } @@ -73,6 +73,6 @@

Pomodoro

} - Start over + Restore default settings } diff --git a/src/app/shared/settings-sheet.component.ts b/src/app/shared/settings-sheet.component.ts index a49edda..944310a 100644 --- a/src/app/shared/settings-sheet.component.ts +++ b/src/app/shared/settings-sheet.component.ts @@ -23,7 +23,7 @@ import { timerOutline, } from 'ionicons/icons'; import {SettingsService} from '../settings.service'; -import {FONT_SAMPLE} from '../settings/fonts'; +import {FONT_SAMPLE, fontOptionClass} from '../settings/fonts'; import {AppSettings} from '../settings/settings.model'; /** Ionic hands its payload over in `detail`, which the DOM event types do not describe. */ @@ -64,6 +64,7 @@ export class SettingsSheetComponent { protected readonly pomodoroAvailable$ = this.settingsService.pomodoroAvailable$; protected readonly font$ = this.settingsService.font$; protected readonly sample = FONT_SAMPLE; + protected readonly fontOptionClass = fontOptionClass; constructor() { addIcons({close, timerOutline, calendarOutline, colorPaletteOutline}); From e6d66e2ad539cb51cd8737c5b13c3fa186101c92 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Tue, 6 Oct 2026 06:56:12 +0700 Subject: [PATCH 08/20] fix: describe the Pomodoro timer without naming a page The timer can sit on more than one page, so its switch in the settings sheet says what it is rather than where it is. --- src/app/settings/settings-presets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/settings/settings-presets.ts b/src/app/settings/settings-presets.ts index c7172fc..9b7f643 100644 --- a/src/app/settings/settings-presets.ts +++ b/src/app/settings/settings-presets.ts @@ -11,7 +11,7 @@ export const WIDGETS: WidgetOption[] = [ { key: 'pomodoro', label: 'Pomodoro timer', - description: 'The focus timer on a course page.', + description: 'The focus timer for study sessions.', icon: 'timer-outline', selectors: ['app-pomodoro-timer'], }, From e0bd9a50dac5ef871109f9fafb18dca5901faa42 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Tue, 6 Oct 2026 06:56:12 +0700 Subject: [PATCH 09/20] feat: let Restore default settings reach every feature The settings sheet now announces the restore through RestoreDefaultsService, and each feature that keeps preferences puts its own back. The sheet's settings listen like any other, so the button needs no knowledge of which features a build contains. The service is shared byte for byte with the themes branch, where the theme listens too, so the two merge cleanly. --- src/app/restore-defaults.service.ts | 18 ++++++++++++++++++ src/app/settings.service.ts | 2 ++ src/app/shared/settings-sheet.component.ts | 5 ++++- 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 src/app/restore-defaults.service.ts diff --git a/src/app/restore-defaults.service.ts b/src/app/restore-defaults.service.ts new file mode 100644 index 0000000..adf57fc --- /dev/null +++ b/src/app/restore-defaults.service.ts @@ -0,0 +1,18 @@ +import {Injectable} from '@angular/core'; +import {Observable, Subject} from 'rxjs'; + +/** + * The app-wide "Restore default settings". Each feature that keeps preferences listens and puts + * its own back, so the button needs no knowledge of which features this build contains. + */ +@Injectable({ + providedIn: 'root', +}) +export class RestoreDefaultsService { + private readonly restoreSubject = new Subject(); + readonly restore$: Observable = this.restoreSubject.asObservable(); + + restore(): void { + this.restoreSubject.next(); + } +} diff --git a/src/app/settings.service.ts b/src/app/settings.service.ts index dfd4bb1..aeb78e9 100644 --- a/src/app/settings.service.ts +++ b/src/app/settings.service.ts @@ -5,6 +5,7 @@ import {distinctUntilChanged, filter, map} from 'rxjs/operators'; import {findFont, FONT_OPTIONS, fontHref, previewHref, previewRules} from './settings/fonts'; import {defaultSettings, RESET_HOURS, WIDGETS} from './settings/settings-presets'; import {sanitizeSettings} from './settings/sanitize'; +import {RestoreDefaultsService} from './restore-defaults.service'; import {AppliedFont, AppSettings, FontOption, WidgetOption} from './settings/settings.model'; /** Settings stay on the device that set them, so the key carries no account id. */ @@ -55,6 +56,7 @@ export class SettingsService { const subscription = inject(Router).events .pipe(filter(event => event instanceof NavigationEnd)) .subscribe(() => setTimeout(() => this.probe())); + subscription.add(inject(RestoreDefaultsService).restore$.subscribe(() => this.reset())); inject(DestroyRef).onDestroy(() => { clearTimeout(timer); subscription.unsubscribe(); diff --git a/src/app/shared/settings-sheet.component.ts b/src/app/shared/settings-sheet.component.ts index 944310a..901236a 100644 --- a/src/app/shared/settings-sheet.component.ts +++ b/src/app/shared/settings-sheet.component.ts @@ -23,6 +23,7 @@ import { timerOutline, } from 'ionicons/icons'; import {SettingsService} from '../settings.service'; +import {RestoreDefaultsService} from '../restore-defaults.service'; import {FONT_SAMPLE, fontOptionClass} from '../settings/fonts'; import {AppSettings} from '../settings/settings.model'; @@ -57,6 +58,7 @@ function detailChecked(event: Event): boolean { }) export class SettingsSheetComponent { protected settingsService = inject(SettingsService); + private restoreDefaults = inject(RestoreDefaultsService); private modalCtrl = inject(ModalController); protected readonly settings$ = this.settingsService.settings$; @@ -95,7 +97,8 @@ export class SettingsSheetComponent { this.settingsService.setPomodoroResetHour(detailValue(event)); } + /** Puts back this sheet's settings and anything else that listens, such as the theme. */ reset(): void { - this.settingsService.reset(); + this.restoreDefaults.restore(); } } From 9a8b31331adc8a3fa8916fef714388125db6cccb Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Tue, 6 Oct 2026 10:08:49 +0700 Subject: [PATCH 10/20] fix: let the settings sheet scroll to its last control The sheet stops at 90% of the screen, but its content was laid out for the full height, so on a short screen the Restore default settings button could never be scrolled into view. With expandToScroll off the content scrolls within the sheet at any height. --- src/app/shared/settings-menu.component.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/shared/settings-menu.component.ts b/src/app/shared/settings-menu.component.ts index b859108..770d264 100644 --- a/src/app/shared/settings-menu.component.ts +++ b/src/app/shared/settings-menu.component.ts @@ -22,6 +22,9 @@ export class SettingsMenuComponent { component: SettingsSheetComponent, breakpoints: [0, 0.9], initialBreakpoint: 0.9, + // Otherwise the content is laid out for the full height while the sheet stops at + // 0.9, and the last part of the sheet can never be scrolled into view. + expandToScroll: false, }); await modal.present(); } From 07b41b6f275f16d27faede4eadf06026b4dce811 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Tue, 6 Oct 2026 13:06:19 +0700 Subject: [PATCH 11/20] fix: keep the settings sheet's text clear of its edges Titles, hints, rows and the font sample each started at a different distance from the edge, and the rows floated with no card behind them. Everything now starts at one inset: titles and hints line up with the rows on a card drawn from the theme's step colour, the font sample sits inside its card, and the note about the day's reset sits under the "Day starts at" row. The Typeface and reset-hour menus open from the row's right edge towards the sheet instead of hanging over its edge. --- src/app/shared/settings-sheet.component.html | 11 ++++--- src/app/shared/settings-sheet.component.scss | 31 ++++++++++++++++---- src/app/shared/settings-sheet.component.ts | 2 ++ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/app/shared/settings-sheet.component.html b/src/app/shared/settings-sheet.component.html index 9d77f1d..38383e7 100644 --- a/src/app/shared/settings-sheet.component.html +++ b/src/app/shared/settings-sheet.component.html @@ -42,6 +42,7 @@

Font

@for (font of settingsService.fonts; track font.id) { @@ -49,19 +50,20 @@

Font

}
+ @if (font$ | async; as font) { +

{{ sample }}

+ }
- @if (font$ | async; as font) { -

{{ sample }}

- } @if (pomodoroAvailable$ | async) {

Pomodoro

-

Where your day starts. The session count goes back to zero at this time.

+

How the focus timer counts your study, on this device only.

@for (option of settingsService.resetHours; track option.value) { @@ -69,6 +71,7 @@

Pomodoro

}
+

The session count goes back to zero at this time.

} diff --git a/src/app/shared/settings-sheet.component.scss b/src/app/shared/settings-sheet.component.scss index e161ed0..b830f6c 100644 --- a/src/app/shared/settings-sheet.component.scss +++ b/src/app/shared/settings-sheet.component.scss @@ -1,5 +1,11 @@ .settings-section { - margin-bottom: 1.75rem; + margin-bottom: 1.5rem; +} + +/* Titles and hints start where the rows inside the card below them start. */ +.section-title, +.section-hint { + padding-inline: 1rem; } .section-title { @@ -11,17 +17,23 @@ .section-hint { margin: 0 0 0.6rem; font-size: 0.8rem; + line-height: 1.35; opacity: 0.7; } +/* The rows sit on a card of their own, so they keep clear of the sheet's edge in any theme. */ .settings-card { - padding: 0; + margin: 0; + padding: 0.25rem 0; border-radius: 0.75rem; + background: var(--ion-color-step-50, rgba(0, 0, 0, 0.04)); overflow: hidden; } .settings-card ion-item { --background: transparent; + --padding-start: 1rem; + --inner-padding-end: 1rem; } .settings-card ion-icon[slot='start'] { @@ -30,10 +42,8 @@ opacity: 0.7; } -/* The switch needs room to sit inside the card rather than on its edge. */ .widget-toggle { width: 100%; - padding-inline-end: 0.15rem; } .widget-label { @@ -46,13 +56,24 @@ display: block; margin-top: 0.1rem; font-size: 0.78rem; + line-height: 1.35; opacity: 0.7; white-space: normal; } +/* Text on a card that is not a control: the note under the row above it, or the font sample. */ +.card-note { + margin: 0; + padding: 0 1rem 0.6rem; + font-size: 0.78rem; + line-height: 1.35; + opacity: 0.7; +} + .font-preview { - margin: 0.6rem 0.25rem 0; + padding-top: 0.15rem; font-size: 1.05rem; + opacity: 1; } .quiet-button { diff --git a/src/app/shared/settings-sheet.component.ts b/src/app/shared/settings-sheet.component.ts index 901236a..b9d8c78 100644 --- a/src/app/shared/settings-sheet.component.ts +++ b/src/app/shared/settings-sheet.component.ts @@ -67,6 +67,8 @@ export class SettingsSheetComponent { protected readonly font$ = this.settingsService.font$; protected readonly sample = FONT_SAMPLE; protected readonly fontOptionClass = fontOptionClass; + /** Menus open from the row's right edge towards the sheet, rather than out over its edge. */ + protected readonly popoverOptions = {side: 'bottom', alignment: 'end'}; constructor() { addIcons({close, timerOutline, calendarOutline, colorPaletteOutline}); From 20753b09f2363e633bd2d7d936338f9e7944f0e1 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Tue, 6 Oct 2026 13:06:19 +0700 Subject: [PATCH 12/20] feat: choose in Settings whether leaving the tab pauses the Pomodoro Session type (Watching lectures / Studying elsewhere) joins the Pomodoro section, with a line saying what the chosen one does, and Restore default settings puts it back to Watching lectures. The choice lives in PomodoroSessionModeService, shared byte for byte with the Pomodoro branch, which is where the timer that acts on it is. Until a timer that can pause has run on the device, the sheet leaves the control out rather than offer a setting that does nothing. --- src/app/pomodoro-session-mode.service.spec.ts | 42 +++++++++++ src/app/pomodoro-session-mode.service.ts | 69 +++++++++++++++++++ src/app/settings.service.ts | 4 ++ src/app/settings/settings-presets.ts | 16 ++++- src/app/settings/settings.model.ts | 8 +++ src/app/shared/settings-sheet.component.html | 16 +++++ src/app/shared/settings-sheet.component.scss | 22 ++++++ src/app/shared/settings-sheet.component.ts | 23 +++++++ 8 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 src/app/pomodoro-session-mode.service.spec.ts create mode 100644 src/app/pomodoro-session-mode.service.ts diff --git a/src/app/pomodoro-session-mode.service.spec.ts b/src/app/pomodoro-session-mode.service.spec.ts new file mode 100644 index 0000000..ba1cdb3 --- /dev/null +++ b/src/app/pomodoro-session-mode.service.spec.ts @@ -0,0 +1,42 @@ +import {TestBed} from '@angular/core/testing'; +import {DEFAULT_SESSION_MODE, PomodoroSessionModeService} from './pomodoro-session-mode.service'; + +describe('PomodoroSessionModeService', () => { + const keys = ['flickPomodoroSessionMode', 'flickPomodoroSessionModeSupported']; + + function create(): PomodoroSessionModeService { + TestBed.resetTestingModule(); + return TestBed.inject(PomodoroSessionModeService); + } + + beforeEach(() => keys.forEach(key => localStorage.removeItem(key))); + afterEach(() => keys.forEach(key => localStorage.removeItem(key))); + + it('starts in the default mode', () => { + expect(create().mode).toBe(DEFAULT_SESSION_MODE); + }); + + it('keeps the chosen mode across a reload', () => { + create().setMode('elsewhere'); + expect(create().mode).toBe('elsewhere'); + }); + + it('ignores a stored mode this build does not know', () => { + localStorage.setItem('flickPomodoroSessionMode', 'sometimes'); + expect(create().mode).toBe(DEFAULT_SESSION_MODE); + }); + + it('stays unsupported until a timer says it can pause, then remembers that', () => { + const service = create(); + let supported = true; + service.supported$.subscribe(value => supported = value); + expect(supported).toBeFalse(); + + service.markSupported(); + expect(supported).toBeTrue(); + + let afterReload = false; + create().supported$.subscribe(value => afterReload = value); + expect(afterReload).toBeTrue(); + }); +}); diff --git a/src/app/pomodoro-session-mode.service.ts b/src/app/pomodoro-session-mode.service.ts new file mode 100644 index 0000000..0eba6cd --- /dev/null +++ b/src/app/pomodoro-session-mode.service.ts @@ -0,0 +1,69 @@ +import {Injectable} from '@angular/core'; +import {BehaviorSubject, Observable} from 'rxjs'; + +/** What the reader studies from, which decides whether leaving the tab pauses the Pomodoro timer. */ +export type SessionMode = 'lecture' | 'elsewhere'; + +export const SESSION_MODES: SessionMode[] = ['lecture', 'elsewhere']; +export const DEFAULT_SESSION_MODE: SessionMode = 'lecture'; + +const MODE_KEY = 'flickPomodoroSessionMode'; +const SUPPORTED_KEY = 'flickPomodoroSessionModeSupported'; + +function read(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +function write(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + // Storage may be unavailable; the choice still holds for this session. + } +} + +/** + * Whether the Pomodoro timer pauses when the reader leaves the tab. The settings sheet offers + * the choice and the timer acts on it; they meet here, so neither has to know whether the other + * is in the build. Kept on the device, like the rest of the settings. + */ +@Injectable({ + providedIn: 'root', +}) +export class PomodoroSessionModeService { + private readonly modeSubject = new BehaviorSubject( + SESSION_MODES.find(mode => mode === read(MODE_KEY)) ?? DEFAULT_SESSION_MODE); + readonly mode$: Observable = this.modeSubject.asObservable(); + + private readonly supportedSubject = new BehaviorSubject(read(SUPPORTED_KEY) === 'true'); + /** + * Whether a timer that can pause on leaving the tab has run on this device. Until one has, + * the choice would change nothing, so the settings sheet leaves it out. + */ + readonly supported$: Observable = this.supportedSubject.asObservable(); + + get mode(): SessionMode { + return this.modeSubject.value; + } + + setMode(mode: SessionMode): void { + if (mode === this.modeSubject.value) { + return; + } + this.modeSubject.next(mode); + write(MODE_KEY, mode); + } + + /** Called by a timer that acts on the choice. */ + markSupported(): void { + if (this.supportedSubject.value) { + return; + } + this.supportedSubject.next(true); + write(SUPPORTED_KEY, 'true'); + } +} diff --git a/src/app/settings.service.ts b/src/app/settings.service.ts index aeb78e9..4ecec0c 100644 --- a/src/app/settings.service.ts +++ b/src/app/settings.service.ts @@ -6,6 +6,7 @@ import {findFont, FONT_OPTIONS, fontHref, previewHref, previewRules} from './set import {defaultSettings, RESET_HOURS, WIDGETS} from './settings/settings-presets'; import {sanitizeSettings} from './settings/sanitize'; import {RestoreDefaultsService} from './restore-defaults.service'; +import {DEFAULT_SESSION_MODE, PomodoroSessionModeService} from './pomodoro-session-mode.service'; import {AppliedFont, AppSettings, FontOption, WidgetOption} from './settings/settings.model'; /** Settings stay on the device that set them, so the key carries no account id. */ @@ -32,6 +33,8 @@ export class SettingsService { readonly fonts = FONT_OPTIONS; readonly resetHours = RESET_HOURS; + private sessionModes = inject(PomodoroSessionModeService); + private readonly settingsSubject = new BehaviorSubject(defaultSettings()); readonly settings$: Observable = this.settingsSubject.asObservable(); @@ -143,6 +146,7 @@ export class SettingsService { reset(): void { // What the app has discovered about itself is not a preference, so it survives. this.update({...defaultSettings(), seen: this.settings.seen}); + this.sessionModes.setMode(DEFAULT_SESSION_MODE); } private update(change: Partial): void { diff --git a/src/app/settings/settings-presets.ts b/src/app/settings/settings-presets.ts index 9b7f643..e826730 100644 --- a/src/app/settings/settings-presets.ts +++ b/src/app/settings/settings-presets.ts @@ -1,4 +1,4 @@ -import {AppSettings, ResetHourOption, WidgetOption} from './settings.model'; +import {AppSettings, ResetHourOption, SessionModeOption, WidgetOption} from './settings.model'; import {DEFAULT_FONT_ID} from './fonts'; /** @@ -41,6 +41,20 @@ function formatHour(hour: number): string { return display + ':00 ' + suffix; } +/** Whether the Pomodoro timer pauses when the reader leaves the tab, in the words the sheet uses. */ +export const SESSION_MODE_OPTIONS: SessionModeOption[] = [ + { + value: 'lecture', + label: 'Watching lectures', + hint: 'The timer pauses when you leave this tab, so only time spent here counts.', + }, + { + value: 'elsewhere', + label: 'Studying elsewhere', + hint: 'The timer keeps running while you use other tabs and apps.', + }, +]; + export const RESET_HOURS: ResetHourOption[] = Array.from({length: 24}, (unused, hour) => ({ value: hour, label: formatHour(hour), diff --git a/src/app/settings/settings.model.ts b/src/app/settings/settings.model.ts index ac937bd..d2fe38b 100644 --- a/src/app/settings/settings.model.ts +++ b/src/app/settings/settings.model.ts @@ -1,3 +1,5 @@ +import {SessionMode} from '../pomodoro-session-mode.service'; + /** A screen element the user can switch off. */ export interface WidgetOption { key: string; @@ -24,6 +26,12 @@ export interface ResetHourOption { label: string; } +export interface SessionModeOption { + value: SessionMode; + label: string; + hint: string; +} + /** Everything the settings sheet controls, as kept on this device. */ export interface AppSettings { /** diff --git a/src/app/shared/settings-sheet.component.html b/src/app/shared/settings-sheet.component.html index 38383e7..951065e 100644 --- a/src/app/shared/settings-sheet.component.html +++ b/src/app/shared/settings-sheet.component.html @@ -72,6 +72,22 @@

Pomodoro

The session count goes back to zero at this time.

+ + @if (sessionModeSupported$ | async) { +
+ Session type + + @for (option of sessionModeOptions; track option.value) { + + {{ option.label }} + + } + +

{{ sessionModeHint(sessionMode$ | async) }}

+
+ } } diff --git a/src/app/shared/settings-sheet.component.scss b/src/app/shared/settings-sheet.component.scss index b830f6c..5638741 100644 --- a/src/app/shared/settings-sheet.component.scss +++ b/src/app/shared/settings-sheet.component.scss @@ -76,6 +76,28 @@ opacity: 1; } +/* A control that needs the card's full width, with its label above it. */ +.card-block { + margin-top: 0.25rem; + padding: 0.75rem 1rem 0; + border-top: 1px solid var(--ion-color-step-150, rgba(0, 0, 0, 0.1)); +} + +.card-block .card-note { + padding: 0.45rem 0 0.6rem; +} + +.block-label { + display: block; + margin-bottom: 0.5rem; +} + +.card-block ion-segment-button { + min-height: 2.25rem; + font-size: 0.85rem; + text-transform: none; +} + .quiet-button { margin-top: 0.5rem; font-size: 0.85rem; diff --git a/src/app/shared/settings-sheet.component.ts b/src/app/shared/settings-sheet.component.ts index b9d8c78..99ceb80 100644 --- a/src/app/shared/settings-sheet.component.ts +++ b/src/app/shared/settings-sheet.component.ts @@ -7,7 +7,10 @@ import { IonHeader, IonIcon, IonItem, + IonLabel, IonList, + IonSegment, + IonSegmentButton, IonSelect, IonSelectOption, IonTitle, @@ -24,7 +27,9 @@ import { } from 'ionicons/icons'; import {SettingsService} from '../settings.service'; import {RestoreDefaultsService} from '../restore-defaults.service'; +import {PomodoroSessionModeService, SessionMode} from '../pomodoro-session-mode.service'; import {FONT_SAMPLE, fontOptionClass} from '../settings/fonts'; +import {SESSION_MODE_OPTIONS} from '../settings/settings-presets'; import {AppSettings} from '../settings/settings.model'; /** Ionic hands its payload over in `detail`, which the DOM event types do not describe. */ @@ -50,7 +55,10 @@ function detailChecked(event: Event): boolean { IonContent, IonList, IonItem, + IonLabel, IonToggle, + IonSegment, + IonSegmentButton, IonSelect, IonSelectOption, AsyncPipe, @@ -59,6 +67,7 @@ function detailChecked(event: Event): boolean { export class SettingsSheetComponent { protected settingsService = inject(SettingsService); private restoreDefaults = inject(RestoreDefaultsService); + private sessionModes = inject(PomodoroSessionModeService); private modalCtrl = inject(ModalController); protected readonly settings$ = this.settingsService.settings$; @@ -67,6 +76,9 @@ export class SettingsSheetComponent { protected readonly font$ = this.settingsService.font$; protected readonly sample = FONT_SAMPLE; protected readonly fontOptionClass = fontOptionClass; + protected readonly sessionModeOptions = SESSION_MODE_OPTIONS; + protected readonly sessionMode$ = this.sessionModes.mode$; + protected readonly sessionModeSupported$ = this.sessionModes.supported$; /** Menus open from the row's right edge towards the sheet, rather than out over its edge. */ protected readonly popoverOptions = {side: 'bottom', alignment: 'end'}; @@ -99,6 +111,17 @@ export class SettingsSheetComponent { this.settingsService.setPomodoroResetHour(detailValue(event)); } + onSessionModeChange(event: Event): void { + const mode = this.sessionModeOptions.find(option => option.value === detailValue(event)); + if (mode) { + this.sessionModes.setMode(mode.value); + } + } + + sessionModeHint(mode: SessionMode | null): string { + return this.sessionModeOptions.find(option => option.value === mode)?.hint ?? ''; + } + /** Puts back this sheet's settings and anything else that listens, such as the theme. */ reset(): void { this.restoreDefaults.restore(); From 64466675d32f3b0dd89a367ffc39aa9dde4cc92a Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Tue, 6 Oct 2026 13:06:19 +0700 Subject: [PATCH 13/20] test: check each settings switch hides its feature Each widget arrives in its own pull request, and the switches only work while settings.scss still has a rule for every element the sheet offers. The test build now loads that stylesheet, and a test switches each widget off and on and checks its element really hides and comes back. Another checks that switching the Pomodoro off pauses a running timer. --- angular.json | 4 ++- src/app/pomodoro.service.spec.ts | 13 ++++++++ src/app/settings.service.spec.ts | 52 ++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 src/app/settings.service.spec.ts diff --git a/angular.json b/angular.json index 29234f7..8cc757d 100644 --- a/angular.json +++ b/angular.json @@ -126,7 +126,9 @@ ], "tsConfig": "tsconfig.spec.json", "karmaConfig": "karma.conf.js", - "styles": [], + "styles": [ + "src/settings.scss" + ], "scripts": [], "assets": [ { diff --git a/src/app/pomodoro.service.spec.ts b/src/app/pomodoro.service.spec.ts index 5c2b697..83d8b56 100644 --- a/src/app/pomodoro.service.spec.ts +++ b/src/app/pomodoro.service.spec.ts @@ -5,6 +5,7 @@ import { DEFAULT_DURATIONS, DEFAULT_SESSIONS_BEFORE_LONG_BREAK } from './pomodoro.service'; +import {SETTINGS_STORAGE_KEY, SettingsService} from './settings.service'; describe('PomodoroService', () => { let service: PomodoroService; @@ -112,4 +113,16 @@ describe('PomodoroService', () => { discardPeriodicTasks(); })); + + it('pauses a running timer when the Pomodoro is switched off in settings', () => { + const settings = TestBed.inject(SettingsService); + service.start(); + + settings.setVisible('pomodoro', false); + expect(firstValue(service.timerState$)).toBe('paused'); + + service.reset(); + settings.setVisible('pomodoro', true); + localStorage.removeItem(SETTINGS_STORAGE_KEY); + }); }); diff --git a/src/app/settings.service.spec.ts b/src/app/settings.service.spec.ts new file mode 100644 index 0000000..ff1abb0 --- /dev/null +++ b/src/app/settings.service.spec.ts @@ -0,0 +1,52 @@ +import {TestBed} from '@angular/core/testing'; +import {provideRouter} from '@angular/router'; +import {SETTINGS_STORAGE_KEY, SettingsService} from './settings.service'; +import {WIDGETS} from './settings/settings-presets'; + +/* + * The switches in the settings sheet hide each widget through settings.scss, which the test + * build loads as well. Each widget arrives in its own pull request, so this is what shows the + * sheet still reaches every element it offers to hide, whichever order they are merged in. + */ +describe('SettingsService widget switches', () => { + beforeEach(() => { + localStorage.removeItem(SETTINGS_STORAGE_KEY); + TestBed.configureTestingModule({providers: [provideRouter([])]}); + }); + + afterEach(() => { + localStorage.removeItem(SETTINGS_STORAGE_KEY); + document.documentElement.removeAttribute('data-hidden'); + }); + + for (const widget of WIDGETS) { + for (const selector of widget.selectors) { + it(`hides <${selector}> while "${widget.label}" is switched off`, () => { + const service = TestBed.inject(SettingsService); + const element = document.body.appendChild(document.createElement(selector)); + try { + service.setVisible(widget.key, false); + expect(getComputedStyle(element).display).toBe('none'); + + service.setVisible(widget.key, true); + expect(getComputedStyle(element).display).not.toBe('none'); + } finally { + element.remove(); + } + }); + } + } + + it('hides only the widget that was switched off', () => { + const service = TestBed.inject(SettingsService); + const elements = WIDGETS.map(widget => document.body.appendChild(document.createElement(widget.selectors[0]))); + try { + service.setVisible(WIDGETS[0].key, false); + elements.forEach((element, index) => { + expect(getComputedStyle(element).display === 'none').toBe(index === 0); + }); + } finally { + elements.forEach(element => element.remove()); + } + }); +}); From 588649dad2bbde816c6631d36b876cfc14d91386 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Tue, 6 Oct 2026 14:15:34 +0700 Subject: [PATCH 14/20] feat: show Pomodoro timer on homepage with fullscreen toasts and in-page playback - show Pomodoro timer on the homepage with synchronized state across pages - keep video playback in-page (playsinline, preferFullWindow) so notifications overlay fullscreen - add fullscreen toast notifications on phase transitions - split Pomodoro timer and info popover into template, style and component files - configure study sessions before long break - accurately track background tab countdown and pause timer when closed --- src/app/home/course/course.page.ts | 10 + src/app/home/home.page.html | 1 + src/app/home/home.page.scss | 22 +- src/app/home/home.page.ts | 5 +- src/app/home/list/list.page.ts | 8 +- src/app/pomodoro.service.spec.ts | 44 +- src/app/pomodoro.service.ts | 303 +++++++++---- .../pomodoro-info-popover.component.html | 9 + .../pomodoro-info-popover.component.scss | 26 ++ .../shared/pomodoro-info-popover.component.ts | 25 ++ src/app/shared/pomodoro-timer.component.html | 97 +++++ src/app/shared/pomodoro-timer.component.scss | 118 ++++++ .../shared/pomodoro-timer.component.spec.ts | 3 - src/app/shared/pomodoro-timer.component.ts | 397 ++---------------- src/app/shared/pomodoro-toast.ts | 152 +++++++ src/global.scss | 71 +++- 16 files changed, 784 insertions(+), 507 deletions(-) create mode 100644 src/app/shared/pomodoro-info-popover.component.html create mode 100644 src/app/shared/pomodoro-info-popover.component.scss create mode 100644 src/app/shared/pomodoro-info-popover.component.ts create mode 100644 src/app/shared/pomodoro-timer.component.html create mode 100644 src/app/shared/pomodoro-timer.component.scss create mode 100644 src/app/shared/pomodoro-toast.ts diff --git a/src/app/home/course/course.page.ts b/src/app/home/course/course.page.ts index 87501a7..036b7bf 100644 --- a/src/app/home/course/course.page.ts +++ b/src/app/home/course/course.page.ts @@ -196,6 +196,16 @@ export class CoursePage implements OnInit, AfterViewInit, OnDestroy { }, }, techOrder: ['html5', 'youtube'], + // Keep playback in the page instead of handing off to Apple's native fullscreen player. + // That player renders outside the web view, so nothing in the DOM — including the + // Pomodoro phase notification — can be drawn over it. + playsinline: true, + // Only consulted when the browser exposes no Fullscreen API (iPhone, and the in-app + // web views used by the Google and Chrome apps on iOS). Without it video.js hands those + // browsers to the native player; with it they get video.js's own full-window mode, which + // keeps the page — and any overlay on it — visible. Browsers that do have the Fullscreen + // API are unaffected: they fullscreen the player element and overlays are placed inside it. + preferFullWindow: true, }, () => { this.isPlayerReady = true; if (this.pendingVideoToPlay) { diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html index 654c42c..6afa81a 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -65,6 +65,7 @@

{{ result.title }}

} } @else if (response$ | async; as response) { + @if (response.last_played) { diff --git a/src/app/home/home.page.scss b/src/app/home/home.page.scss index d6b9529..d3663eb 100644 --- a/src/app/home/home.page.scss +++ b/src/app/home/home.page.scss @@ -6,6 +6,16 @@ main { } } +/* iOS centres the title between fixed gutters, which the toolbar buttons outgrow on a phone. + There the title moves to the start and gives way to them instead of running underneath. */ +@media (max-width: 575.98px) { + ion-title.ios { + position: static; + padding-inline: 16px 0; + text-align: start; + } +} + ion-card { min-height: 4rem; } @@ -187,14 +197,4 @@ p { font-size: 0.7rem; font-weight: 600; letter-spacing: 0.02em; -} - -/* iOS centres the title between fixed gutters, which the toolbar buttons outgrow on a phone. - There the title moves to the start and gives way to them instead of running underneath. */ -@media (max-width: 575.98px) { - ion-title.ios { - position: static; - padding-inline: 16px 0; - text-align: start; - } -} +} \ No newline at end of file diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index 73daf44..1965376 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -2,6 +2,8 @@ import {Component, inject, OnInit} from '@angular/core'; import {Observable, of, Subject} from 'rxjs'; import {CourseListResponse, Lecture, ManService, SearchVideoResult} from '../man.service'; import {Router, RouterLink} from '@angular/router'; +import {SettingsMenuComponent} from '../shared/settings-menu.component'; +import {PomodoroTimerComponent} from '../shared/pomodoro-timer.component'; import {AuthService} from '../auth.service'; import {colorByFolderName} from '../../helpers'; import {addIcons} from "ionicons"; @@ -30,7 +32,6 @@ import { IonToolbar, } from '@ionic/angular/standalone'; import {AsyncPipe, NgStyle} from '@angular/common'; -import {SettingsMenuComponent} from '../shared/settings-menu.component'; import {Analytics, logEvent} from '@angular/fire/analytics'; import {ConsentService} from '../consent.service'; @@ -45,10 +46,10 @@ export interface EnrichedSearchResult extends SearchVideoResult { styleUrls: ['home.page.scss'], imports: [ IonHeader, IonToolbar, IonTitle, IonButtons, IonButton, IonIcon, + SettingsMenuComponent, PomodoroTimerComponent, IonContent, IonGrid, IonRow, IonCol, IonCard, RouterLink, NgStyle, IonCardHeader, IonCardTitle, AsyncPipe, IonCardContent, IonItem, IonLabel, IonText, IonSpinner, IonSearchbar, IonList, - SettingsMenuComponent, ] }) export class HomePage implements OnInit { diff --git a/src/app/home/list/list.page.ts b/src/app/home/list/list.page.ts index e4370db..a286c52 100644 --- a/src/app/home/list/list.page.ts +++ b/src/app/home/list/list.page.ts @@ -2,6 +2,7 @@ import {Component, inject, OnInit} from '@angular/core'; import {ActivatedRoute, Router, RouterLink} from '@angular/router'; import {EMPTY, Observable, of, Subject} from 'rxjs'; import {debounceTime, distinctUntilChanged, map, switchMap, tap} from 'rxjs/operators'; +import {SettingsMenuComponent} from '../../shared/settings-menu.component'; import {ManService, SearchVideoResult} from '../../man.service'; import {colorByFolderName} from '../../../helpers'; import { @@ -18,7 +19,6 @@ import { IonTitle, IonToolbar, } from '@ionic/angular/standalone'; -import {SettingsMenuComponent} from '../../shared/settings-menu.component'; import {AsyncPipe, NgStyle} from '@angular/common'; import {Analytics, logEvent} from '@angular/fire/analytics'; import {ConsentService} from '../../consent.service'; @@ -33,9 +33,11 @@ export interface EnrichedSearchResult extends SearchVideoResult { templateUrl: './list.page.html', styleUrls: ['./list.page.scss'], imports: [ - IonHeader, IonToolbar, RouterLink, IonBackButton, IonButtons, IonTitle, NgStyle, + IonHeader, IonToolbar, RouterLink, IonBackButton, IonTitle, NgStyle, + IonButtons, IonContent, IonList, IonListHeader, IonItem, IonLabel, AsyncPipe, - IonSearchbar, IonSpinner, SettingsMenuComponent, + IonSearchbar, IonSpinner, + SettingsMenuComponent, ] }) export class ListPage implements OnInit { diff --git a/src/app/pomodoro.service.spec.ts b/src/app/pomodoro.service.spec.ts index 83d8b56..a4bf5be 100644 --- a/src/app/pomodoro.service.spec.ts +++ b/src/app/pomodoro.service.spec.ts @@ -6,6 +6,7 @@ import { DEFAULT_SESSIONS_BEFORE_LONG_BREAK } from './pomodoro.service'; import {SETTINGS_STORAGE_KEY, SettingsService} from './settings.service'; +import {PomodoroSessionModeService} from './pomodoro-session-mode.service'; describe('PomodoroService', () => { let service: PomodoroService; @@ -27,12 +28,14 @@ describe('PomodoroService', () => { beforeEach(() => { localStorage.removeItem('pomodoroPrefs_guest'); localStorage.removeItem('pomodoroTimer_guest'); + localStorage.removeItem('flickPomodoroSessionMode'); service = createService(); }); afterEach(() => { localStorage.removeItem('pomodoroPrefs_guest'); localStorage.removeItem('pomodoroTimer_guest'); + localStorage.removeItem('flickPomodoroSessionMode'); }); it('formats the default study duration as HH:MM:SS', () => { @@ -70,30 +73,26 @@ describe('PomodoroService', () => { expect(localStorage.getItem('pomodoroTimer_guest')).toBeNull(); }); - it('restoreTimerState() subtracts elapsed time for a running timer resumed after a short gap', fakeAsync(() => { + it('restoreTimerState() brings a timer closed mid-phase back paused where it stopped', fakeAsync(() => { localStorage.setItem('pomodoroTimer_guest', JSON.stringify({ timerState: 'running', timeRemaining: 100, currentPhaseKey: 'study', - completedSessions: 2, - studySessionsInCycle: 1, + completedSessions: 0, + studySessionsInCycle: 0, savedAt: Date.now() - 30000 })); service = createService(); - const remaining = firstValue(service.timeRemaining$); - expect(remaining).toBeGreaterThanOrEqual(69); - expect(remaining).toBeLessThanOrEqual(70); + expect(firstValue(service.timerState$)).toBe('paused'); + expect(firstValue(service.timeRemaining$)).toBe(100); expect(firstValue(service.currentPhase$).key).toBe('study'); - expect(firstValue(service.completedSessions$)).toBe(2); discardPeriodicTasks(); })); - it('restoreTimerState() rolls forward through completed phases after a long gap', fakeAsync(() => { - // 700s gap from a 10s-remaining study phase: one full study->break->study - // cycle completes (break is 300s), landing back in study with time to spare. + it('restoreTimerState() does not count the time the page was closed, however long', fakeAsync(() => { localStorage.setItem('pomodoroTimer_guest', JSON.stringify({ timerState: 'running', timeRemaining: 10, @@ -106,14 +105,31 @@ describe('PomodoroService', () => { service = createService(); expect(firstValue(service.currentPhase$).key).toBe('study'); - expect(firstValue(service.completedSessions$)).toBe(1); - const remaining = firstValue(service.timeRemaining$); - expect(remaining).toBeGreaterThanOrEqual(1108); - expect(remaining).toBeLessThanOrEqual(1110); + expect(firstValue(service.completedSessions$)).toBe(0); + expect(firstValue(service.timeRemaining$)).toBe(10); + expect(firstValue(service.timerState$)).toBe('paused'); discardPeriodicTasks(); })); + it('pauses on leaving the tab only in the mode chosen for watching lectures', () => { + const hidden = spyOnProperty(document, 'hidden').and.returnValue(true); + service.start(); + document.dispatchEvent(new Event('visibilitychange')); + expect(firstValue(service.timerState$)).toBe('paused'); + + hidden.and.returnValue(false); + document.dispatchEvent(new Event('visibilitychange')); + expect(firstValue(service.timerState$)).toBe('running'); + + TestBed.inject(PomodoroSessionModeService).setMode('elsewhere'); + hidden.and.returnValue(true); + document.dispatchEvent(new Event('visibilitychange')); + expect(firstValue(service.timerState$)).toBe('running'); + + service.reset(); + }); + it('pauses a running timer when the Pomodoro is switched off in settings', () => { const settings = TestBed.inject(SettingsService); service.start(); diff --git a/src/app/pomodoro.service.ts b/src/app/pomodoro.service.ts index da57467..3487960 100644 --- a/src/app/pomodoro.service.ts +++ b/src/app/pomodoro.service.ts @@ -1,10 +1,12 @@ import {inject, Injectable} from '@angular/core'; -import {BehaviorSubject, interval, Observable, Subject, Subscription} from 'rxjs'; +import {BehaviorSubject, Observable, Subscription, interval} from 'rxjs'; import {map} from 'rxjs/operators'; import {Analytics, logEvent} from '@angular/fire/analytics'; import {ConsentService} from './consent.service'; +import {PomodoroSessionModeService} from './pomodoro-session-mode.service'; import {SettingsService} from './settings.service'; import {nextResetAt, studyDayKey} from './settings/day'; +import {PomodoroToast} from './shared/pomodoro-toast'; /** Pomodoro timer phase definitions */ export interface PomodoroPhase { @@ -15,13 +17,6 @@ export interface PomodoroPhase { endMessage: string; } -/** Notification payload for phase transitions */ -export interface PhaseNotification { - title: string; - body: string; - phase: PomodoroPhase; -} - /** Configurable durations (in minutes) */ export interface PomodoroDurations { studyMinutes: number; @@ -66,6 +61,7 @@ export const DEFAULT_DURATIONS: PomodoroDurations = { /** Default number of study sessions before a long break */ export const DEFAULT_SESSIONS_BEFORE_LONG_BREAK = 4; +export const MAX_SESSIONS_BEFORE_LONG_BREAK = 10; /** Duration input field config — drives the UI form dynamically */ export interface DurationField { @@ -86,6 +82,7 @@ export const DURATION_FIELDS: DurationField[] = [ export class PomodoroService { private analytics = inject(Analytics); private consentService = inject(ConsentService); + private sessionModes = inject(PomodoroSessionModeService); private readonly STORAGE_KEY_PREFIX = 'pomodoroPrefs_'; private readonly TIMER_KEY_PREFIX = 'pomodoroTimer_'; @@ -93,7 +90,7 @@ export class PomodoroService { /** Configurable settings */ private durations: PomodoroDurations = {...DEFAULT_DURATIONS}; - private sessionsBeforeLongBreak = DEFAULT_SESSIONS_BEFORE_LONG_BREAK; + private sessionsBeforeLongBreak = new BehaviorSubject(DEFAULT_SESSIONS_BEFORE_LONG_BREAK); /** Internal state */ private timerState = new BehaviorSubject('idle'); @@ -107,19 +104,26 @@ export class PomodoroService { private resetHour = this.settingsService.settings.pomodoroResetHour; private nextDayAt = nextResetAt(Date.now(), this.resetHour); - /** Notification stream */ - private phaseNotificationSubject = new Subject(); - phaseNotification$: Observable = this.phaseNotificationSubject.asObservable(); + /** One toast for the whole app, so a timer card on each open page doesn't show it twice. */ + private toast: PomodoroToast | null = null; private tickSubscription: Subscription | null = null; private audioContext: AudioContext | null = null; + /** Wall-clock epoch (ms) when the current running phase should end. Null while not running. */ + private phaseEndsAt: number | null = null; + private lastSavedAt = 0; + /** + * True only while the timer is paused because the page was hidden. Distinguishes an automatic + * pause (resume on return) from one the user asked for (stays paused until they say otherwise). + */ + private pausedByVisibility = false; /** Public observables */ timerState$: Observable = this.timerState.asObservable(); timeRemaining$: Observable = this.timeRemaining.asObservable(); currentPhase$: Observable = this.currentPhase.asObservable(); completedSessions$: Observable = this.completedSessions.asObservable(); - sessionsBeforeLongBreak$: Observable = new BehaviorSubject(this.sessionsBeforeLongBreak).asObservable(); + sessionsBeforeLongBreak$: Observable = this.sessionsBeforeLongBreak.asObservable(); /** Formatted time string observable (HH:MM:SS) */ formattedTime$: Observable = this.timeRemaining$.pipe( @@ -129,6 +133,7 @@ export class PomodoroService { constructor() { this.loadPreferences(this.currentUserId); this.restoreTimerState(this.currentUserId); + this.registerVisibilityHandlers(); this.settingsService.pomodoroResetHour$.subscribe(hour => { this.resetHour = hour; @@ -143,6 +148,8 @@ export class PomodoroService { this.pause(); } }); + // This timer acts on the choice, so the settings sheet can offer it. + this.sessionModes.markSupported(); } /** Request browser system notification permissions */ @@ -163,10 +170,13 @@ export class PomodoroService { /** Clear preferences from active state (call on logout) */ clearForUser(): void { + this.stopTicking(); + this.timerState.next('idle'); this.currentUserId = 'guest'; this.clearTimerState('guest'); this.loadPreferences('guest'); this.resetToPhase(this.getStudyPhase()); + this.closeAudioContext(); } /** Get current durations */ @@ -174,6 +184,16 @@ export class PomodoroService { return {...this.durations}; } + /** How many study sessions earn a long break. A change applies to the cycle already under way. */ + setSessionsBeforeLongBreak(count: number): void { + if (!Number.isInteger(count) || count < 1 || count > MAX_SESSIONS_BEFORE_LONG_BREAK + || count === this.sessionsBeforeLongBreak.value) { + return; + } + this.sessionsBeforeLongBreak.next(count); + this.savePreferences(); + } + /** Update duration config and save */ updateDurations(newDurations: Partial): void { this.durations = {...this.durations, ...newDurations}; @@ -192,6 +212,9 @@ export class PomodoroService { } this.requestNotificationPermission(); + // Create (or re-arm) the audio context while we're inside a user gesture, so the + // later automatic phase-change chime is allowed to play under iOS's autoplay policy. + this.ensureAudioContext(); if (this.timerState.value === 'idle') { this.timeRemaining.next(this.getCurrentPhaseDuration()); @@ -201,9 +224,8 @@ export class PomodoroService { } } - this.timerState.next('running'); - this.startTicking(); - this.saveTimerState(); + this.pausedByVisibility = false; + this.startRunning(); } /** Pause the timer */ @@ -211,8 +233,17 @@ export class PomodoroService { if (this.timerState.value !== 'running') { return; } + // A deliberate pause by default; the visibility handler re-flags it when it was the one + // pausing, so returning to the tab only resumes what the tab itself paused. + this.pausedByVisibility = false; + // Settle the countdown from the wall-clock anchor rather than trusting the last tick: + // the tick can be up to a second stale, and more when the tab was throttled. + if (this.phaseEndsAt !== null) { + this.timeRemaining.next(Math.max(0, Math.round((this.phaseEndsAt - Date.now()) / 1000))); + } this.timerState.next('paused'); this.stopTicking(); + this.phaseEndsAt = null; this.saveTimerState(); } @@ -221,18 +252,29 @@ export class PomodoroService { if (this.timerState.value !== 'paused') { return; } + // Only reachable from a user gesture, which is what lets iOS unlock the audio context. + this.ensureAudioContext(); + this.pausedByVisibility = false; + this.startRunning(); + } + + /** Enter the running state and re-anchor the countdown. Shared by every resume path. */ + private startRunning(): void { this.timerState.next('running'); this.startTicking(); + this.saveTimerState(); } /** Reset the timer to idle state */ reset(): void { this.stopTicking(); this.timerState.next('idle'); + this.pausedByVisibility = false; this.studySessionsInCycle = 0; this.completedSessions.next(0); this.resetToPhase(this.getStudyPhase()); this.clearTimerState(this.currentUserId); + this.closeAudioContext(); } /** Skip to the next phase */ @@ -246,7 +288,7 @@ export class PomodoroService { /** Get the sessions before long break count */ getSessionsBeforeLongBreak(): number { - return this.sessionsBeforeLongBreak; + return this.sessionsBeforeLongBreak.value; } // ────────────────────────── Private methods ────────────────────────── @@ -295,23 +337,35 @@ export class PomodoroService { private startTicking(): void { this.stopTicking(); + // Anchor to wall-clock time rather than counting ticks: a plain "decrement once per interval" + // counter drifts whenever the interval is throttled (e.g. iOS suspending timers for a + // backgrounded tab), so the displayed time and the actual phase-end notification lag behind. + this.phaseEndsAt = Date.now() + this.timeRemaining.value * 1000; this.tickSubscription = interval(1000).subscribe(() => { if (Date.now() >= this.nextDayAt) { this.rollOverDay(); } - const remaining = this.timeRemaining.value - 1; + const remaining = Math.round((this.phaseEndsAt! - Date.now()) / 1000); if (remaining <= 0) { this.timeRemaining.next(0); this.playNotificationSound(); - this.advancePhase(); - // Auto-start the next phase - this.startTicking(); + if (this.currentPhase.value.key !== 'study' && document.hidden) { + // Nobody is at the tab as the break ends. Wait for them rather than starting study + // time on their behalf, so a timer left running cannot count study nobody did. + this.stopTicking(); + this.phaseEndsAt = null; + this.timerState.next('paused'); + this.advancePhase(true); + } else { + this.advancePhase(); + // Auto-start the next phase + this.startTicking(); + } } else { this.timeRemaining.next(remaining); - // Persist every 5 seconds to avoid excessive localStorage writes - if (remaining % 5 === 0) { + if (Date.now() - this.lastSavedAt >= 5000) { this.saveTimerState(); } } @@ -325,14 +379,15 @@ export class PomodoroService { } } - private advancePhase(): void { + /** Move to the next phase. While waiting for the user, announce the end of the old phase instead. */ + private advancePhase(waitingForUser = false): void { const previousPhase = this.currentPhase.value; if (previousPhase.key === 'study') { this.studySessionsInCycle++; this.completedSessions.next(this.completedSessions.value + 1); - if (this.studySessionsInCycle >= this.sessionsBeforeLongBreak) { + if (this.studySessionsInCycle >= this.sessionsBeforeLongBreak.value) { this.studySessionsInCycle = 0; this.resetToPhase(this.getLongBreakPhase()); } else { @@ -342,18 +397,23 @@ export class PomodoroService { this.resetToPhase(this.getStudyPhase()); } - const newPhase = this.currentPhase.value; - this.notifyPhase(newPhase, true); + if (waitingForUser) { + this.notifyPhase(previousPhase, false); + } else { + this.notifyPhase(this.currentPhase.value, true); + } this.saveTimerState(); } - /** Send system notification and emit in-app toast event */ + /** Send system notification and show the in-app toast */ private notifyPhase(phase: PomodoroPhase, isStart: boolean): void { const title = `Pomodoro: ${phase.label}`; const body = isStart ? phase.startMessage : phase.endMessage; - // Emit for in-app / in-fullscreen overlay toast - this.phaseNotificationSubject.next({title, body, phase}); + if (typeof document !== 'undefined') { + this.toast ??= new PomodoroToast(); + this.toast.show(title, body); + } // Trigger system desktop browser notification if allowed (works over Fullscreen apps) if (typeof window !== 'undefined' && 'Notification' in window && Notification.permission === 'granted') { @@ -368,38 +428,87 @@ export class PomodoroService { } } - /** Play a notification tone using Web Audio API */ - private playNotificationSound(): void { + /** + * Create the shared audio context if needed, then immediately suspend it. Call this from inside a + * user-gesture handler (start/resume) so iOS treats the context as unlocked — playNotificationSound() + * can then resume() it later from a timer callback, which iOS otherwise blocks. + * + * The context is kept suspended (not closed) between chimes so it doesn't hold an active iOS audio + * session for the lifetime of the page — that active session was observed to interrupt/reload the + * concurrently playing lecture video. + */ + private ensureAudioContext(): void { try { if (!this.audioContext) { this.audioContext = new AudioContext(); } + if (this.audioContext.state === 'running') { + void this.audioContext.suspend(); + } + } catch { + // Web Audio unsupported in this environment + } + } - const now = this.audioContext.currentTime; + private closeAudioContext(): void { + if (this.audioContext) { + try { + void this.audioContext.close(); + } catch { + // Ignore + } + this.audioContext = null; + } + } + + /** Play a notification tone using Web Audio API */ + private playNotificationSound(): void { + // Create on demand if we never got a user gesture (e.g. a running timer restored on page + // refresh). Browsers that require a gesture will simply refuse to resume() below, which is + // handled — but where it's allowed, the chime still plays. + if (!this.audioContext) { + this.ensureAudioContext(); + } + const ctx = this.audioContext; + if (!ctx) { + return; + } + try { + void ctx.resume().then(() => { + const now = ctx.currentTime; - // Two-tone chime (high-low sequence) - const osc1 = this.audioContext.createOscillator(); - const osc2 = this.audioContext.createOscillator(); - const gain = this.audioContext.createGain(); + // Two-tone chime (high-low sequence) + const osc1 = ctx.createOscillator(); + const osc2 = ctx.createOscillator(); + const gain = ctx.createGain(); - osc1.type = 'sine'; - osc2.type = 'sine'; + osc1.type = 'sine'; + osc2.type = 'sine'; - osc1.frequency.setValueAtTime(587.33, now); // D5 - osc2.frequency.setValueAtTime(880.00, now + 0.15); // A5 + osc1.frequency.setValueAtTime(587.33, now); // D5 + osc2.frequency.setValueAtTime(880.00, now + 0.15); // A5 - gain.gain.setValueAtTime(0.2, now); - gain.gain.exponentialRampToValueAtTime(0.001, now + 0.6); + gain.gain.setValueAtTime(0.2, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.6); - osc1.connect(gain); - osc2.connect(gain); - gain.connect(this.audioContext.destination); + osc1.connect(gain); + osc2.connect(gain); + gain.connect(ctx.destination); - osc1.start(now); - osc1.stop(now + 0.15); + osc1.start(now); + osc1.stop(now + 0.15); - osc2.start(now + 0.15); - osc2.stop(now + 0.6); + osc2.start(now + 0.15); + osc2.stop(now + 0.6); + + osc2.onended = () => { + if (ctx.state === 'running') { + void ctx.suspend(); + } + }; + }).catch(() => { + // Autoplay policy refused to resume the context (no user gesture yet) — skip the chime. + }); } catch { // Silently fail if Web Audio is unsupported } @@ -427,7 +536,7 @@ export class PomodoroService { } } if (typeof parsed.sessionsBeforeLongBreak === 'number' && parsed.sessionsBeforeLongBreak > 0) { - this.sessionsBeforeLongBreak = parsed.sessionsBeforeLongBreak; + this.sessionsBeforeLongBreak.next(parsed.sessionsBeforeLongBreak); } } } catch { @@ -440,7 +549,7 @@ export class PomodoroService { this.STORAGE_KEY_PREFIX + this.currentUserId, JSON.stringify({ durations: this.durations, - sessionsBeforeLongBreak: this.sessionsBeforeLongBreak, + sessionsBeforeLongBreak: this.sessionsBeforeLongBreak.value, }), ); } @@ -456,14 +565,59 @@ export class PomodoroService { currentPhaseKey: this.currentPhase.value.key, completedSessions: this.completedSessions.value, studySessionsInCycle: this.studySessionsInCycle, - savedAt: Date.now(), }), ); + this.lastSavedAt = Date.now(); } catch { // Ignore storage errors } } + /** + * In the lecture session mode, pause a running timer whenever the page is hidden, so time spent + * on another tab is not counted as study time, and resume it as soon as the page comes back. In + * the elsewhere mode the timer keeps running, since the studying is happening on another site. + * + * Only a pause this handler caused is resumed automatically: if the user paused deliberately + * before switching away, the timer is still theirs to restart. A tab that was closed rather than + * hidden also stays paused, since pausedByVisibility does not survive the reload. + * + * This also covers the flush that a hidden/torn-down page needs, since pause() persists state — + * a tab closed mid-phase still resumes from where it stopped rather than the last phase change. + */ + private registerVisibilityHandlers(): void { + if (typeof document === 'undefined') { + return; + } + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + if (this.sessionModes.mode === 'lecture' && this.timerState.value === 'running') { + this.pause(); + this.pausedByVisibility = true; + } else if (this.timerState.value !== 'idle') { + this.saveTimerState(); + } + } else { + // A tab left open across the reset hour won't have been ticking if it was idle or + // paused, so re-check the rollover on the way back in. + if (Date.now() >= this.nextDayAt) { + this.rollOverDay(); + } + if (this.pausedByVisibility && this.timerState.value === 'paused') { + this.pausedByVisibility = false; + // Deliberately not touching the audio context here: this is not a user gesture, + // and creating one outside a gesture leaves it locked on iOS. + this.startRunning(); + } + } + }); + window.addEventListener('pagehide', () => { + if (this.timerState.value !== 'idle') { + this.saveTimerState(); + } + }); + } + /** Restore timer state from localStorage on page load */ private restoreTimerState(uid: string): void { try { @@ -475,8 +629,9 @@ export class PomodoroService { const saved = JSON.parse(raw); const savedState: TimerState = saved.timerState; - let phase = POMODORO_PHASES.find(p => p.key === saved.currentPhaseKey) ?? POMODORO_PHASES[0]; + const phase = POMODORO_PHASES.find(p => p.key === saved.currentPhaseKey) ?? POMODORO_PHASES[0]; + // A count from before the last reset is cleared by rollOverIfNewDay() once settings load. this.completedSessions.next(saved.completedSessions ?? 0); this.studySessionsInCycle = saved.studySessionsInCycle ?? 0; this.currentPhase.next(phase); @@ -487,42 +642,10 @@ export class PomodoroService { return; } - // Calculate how many seconds elapsed while page was closed - const elapsedSeconds = savedState === 'running' - ? Math.floor((Date.now() - (saved.savedAt ?? Date.now())) / 1000) - : 0; - - let timeRemaining: number = (saved.timeRemaining ?? 0) - elapsedSeconds; - - // Handle case where one or more phases completed while page was away - while (timeRemaining <= 0 && savedState === 'running') { - // Advance phase silently (no notification for missed phases) - if (phase.key === 'study') { - this.studySessionsInCycle++; - this.completedSessions.next(this.completedSessions.value + 1); - if (this.studySessionsInCycle >= this.sessionsBeforeLongBreak) { - this.studySessionsInCycle = 0; - this.currentPhase.next(this.getLongBreakPhase()); - } else { - this.currentPhase.next(this.getBreakPhase()); - } - } else { - this.currentPhase.next(this.getStudyPhase()); - } - phase = this.currentPhase.value; - const nextPhaseDuration = (this.durations[phase.durationKey] ?? DEFAULT_DURATIONS.studyMinutes) * 60; - timeRemaining += nextPhaseDuration; - } - - this.timeRemaining.next(Math.max(0, timeRemaining)); - - if (savedState === 'running') { - this.timerState.next('running'); - this.startTicking(); - } else { - // Restore paused state - this.timerState.next('paused'); - } + // The timer only advances while the page is open, so time spent closed is not + // counted. Come back paused where the page left off and let the user resume. + this.timeRemaining.next(Math.max(0, saved.timeRemaining ?? 0)); + this.timerState.next('paused'); } catch { // Fallback to fresh state on any error this.resetToPhase(this.getStudyPhase()); diff --git a/src/app/shared/pomodoro-info-popover.component.html b/src/app/shared/pomodoro-info-popover.component.html new file mode 100644 index 0000000..ede3297 --- /dev/null +++ b/src/app/shared/pomodoro-info-popover.component.html @@ -0,0 +1,9 @@ +
+

{{ title }}

+

{{ description }}

+
    + @for (step of steps; track step.text) { +
  1. {{ step.text }}
    {{ step.detail }}
  2. + } +
+
diff --git a/src/app/shared/pomodoro-info-popover.component.scss b/src/app/shared/pomodoro-info-popover.component.scss new file mode 100644 index 0000000..32ca901 --- /dev/null +++ b/src/app/shared/pomodoro-info-popover.component.scss @@ -0,0 +1,26 @@ +.pomodoro-info-popover { + padding: 1rem; + max-width: 300px; +} + +.pomodoro-info-popover h3 { + margin: 0 0 0.5rem 0; + font-weight: 700; + color: var(--ion-text-color); +} + +.pomodoro-info-popover p, +.pomodoro-info-popover li { + font-size: 0.85rem; + color: var(--ion-color-medium); + line-height: 1.4; +} + +.pomodoro-info-popover ol { + padding-left: 1.25rem; + margin: 0.5rem 0 0 0; +} + +.step-detail { + color: var(--ion-color-medium-tint); +} diff --git a/src/app/shared/pomodoro-info-popover.component.ts b/src/app/shared/pomodoro-info-popover.component.ts new file mode 100644 index 0000000..985db9e --- /dev/null +++ b/src/app/shared/pomodoro-info-popover.component.ts @@ -0,0 +1,25 @@ +import {Component, inject} from '@angular/core'; +import {PomodoroService} from '../pomodoro.service'; + +/** + * Info popover explaining the Pomodoro technique. + */ +@Component({ + selector: 'app-pomodoro-info-popover', + templateUrl: './pomodoro-info-popover.component.html', + styleUrls: ['./pomodoro-info-popover.component.scss'], + imports: [], +}) +export class PomodoroInfoPopoverComponent { + private sessions = inject(PomodoroService).getSessionsBeforeLongBreak(); + title = 'Pomodoro Technique'; + description = 'A time management method that uses a timer to break work into intervals:'; + steps = [ + { text: 'Study for the set duration', detail: '(default 25 min)' }, + { text: 'Take a short break', detail: '(default 5 min)' }, + { + text: `Repeat — after ${this.sessions} study session${this.sessions === 1 ? '' : 's'}, take a long break`, + detail: '(default 20 min)', + }, + ]; +} diff --git a/src/app/shared/pomodoro-timer.component.html b/src/app/shared/pomodoro-timer.component.html new file mode 100644 index 0000000..d35bc6e --- /dev/null +++ b/src/app/shared/pomodoro-timer.component.html @@ -0,0 +1,97 @@ + + + + + Pomodoro Timer + + + + + @if (!isCollapsed) { + + +
+
+ {{ (pomodoroService.currentPhase$ | async)?.label ?? 'Timer' }} + + + +
+
{{ pomodoroService.formattedTime$ | async }}
+ + +
+ @if ((pomodoroService.timerState$ | async) === 'idle') { + + + + } + @if ((pomodoroService.timerState$ | async) === 'running') { + + + + } + @if ((pomodoroService.timerState$ | async) === 'paused') { + + + + } + @if ((pomodoroService.timerState$ | async) !== 'idle') { + + + + + + + } +
+ + + @if ((pomodoroService.completedSessions$ | async)! > 0) { +
+ Session {{ pomodoroService.completedSessions$ | async }} completed +
+ } +
+ + +
+ @for (field of durationFields; track field.key) { +
+ {{ field.label }} +
+ + {{ field.suffix }} +
+
+ } +
+ Long Break After +
+ + sessions +
+
+
+
+ } +
diff --git a/src/app/shared/pomodoro-timer.component.scss b/src/app/shared/pomodoro-timer.component.scss new file mode 100644 index 0000000..aed33f5 --- /dev/null +++ b/src/app/shared/pomodoro-timer.component.scss @@ -0,0 +1,118 @@ +:host { + display: block; + width: 100%; +} + +.pomodoro-card { + margin: 0.75rem 0 0 0; + border-radius: 0.75rem; + overflow: hidden; + box-shadow: var(--flick-welcome-card-shadow, 0 4px 16px rgba(0, 0, 0, 0.08)); +} + +.pomodoro-header { + cursor: pointer; + padding: 0.75rem 1rem; +} + +.pomodoro-title { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 1rem; + font-weight: 600; +} + +.collapse-icon { + font-size: 1.2rem; + color: var(--ion-color-medium); +} + +.pomodoro-content { + padding: 0 1rem 1rem 1rem; +} + +.timer-section { + text-align: center; + padding: 0.5rem 0 0.75rem 0; +} + +.timer-label { + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + margin-bottom: 0.25rem; +} + +.phase-label { + font-size: 1.25rem; + font-weight: 700; + color: var(--ion-text-color); +} + +.info-btn { + --padding-start: 0; + --padding-end: 0; + height: auto; +} + +.timer-display { + font-size: 2.25rem; + font-weight: 300; + font-variant-numeric: tabular-nums; + letter-spacing: 0.05em; + color: var(--ion-color-medium); + margin: 0.25rem 0; +} + +.timer-controls { + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; +} + +.session-counter { + font-size: 0.8rem; + color: var(--ion-color-medium); + margin-top: 0.5rem; +} + +.duration-section { + border-top: 1px solid var(--ion-border-color, rgba(0, 0, 0, 0.1)); + padding-top: 0.75rem; +} + +.duration-field { + margin-bottom: 0.75rem; +} + +.duration-label { + display: block; + font-size: 0.85rem; + color: var(--ion-color-medium); + margin-bottom: 0.25rem; +} + +.duration-input-wrapper { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.duration-input { + --padding-start: 0.75rem; + --padding-end: 0.75rem; + border: 1px solid var(--ion-border-color, rgba(0, 0, 0, 0.15)); + border-radius: 0.5rem; + max-width: 180px; +} + +.duration-suffix { + font-size: 0.9rem; + color: var(--ion-color-medium); +} + +/* The phase-notification toast lives outside this component's view (see template note), + so its styles are global — see .pomodoro-toast in global.scss. */ diff --git a/src/app/shared/pomodoro-timer.component.spec.ts b/src/app/shared/pomodoro-timer.component.spec.ts index 78a1376..0ba426c 100644 --- a/src/app/shared/pomodoro-timer.component.spec.ts +++ b/src/app/shared/pomodoro-timer.component.spec.ts @@ -1,6 +1,5 @@ import {TestBed} from '@angular/core/testing'; import {PopoverController} from '@ionic/angular/standalone'; -import {of} from 'rxjs'; import {PomodoroTimerComponent} from './pomodoro-timer.component'; import {PomodoroService, DEFAULT_DURATIONS} from '../pomodoro.service'; @@ -11,7 +10,6 @@ describe('PomodoroTimerComponent', () => { beforeEach(() => { pomodoroService = jasmine.createSpyObj('PomodoroService', ['updateDurations', 'getDurations']); pomodoroService.getDurations.and.returnValue({...DEFAULT_DURATIONS}); - (pomodoroService as unknown as {phaseNotification$: unknown}).phaseNotification$ = of(); TestBed.configureTestingModule({ providers: [ @@ -21,7 +19,6 @@ describe('PomodoroTimerComponent', () => { ], }); component = TestBed.inject(PomodoroTimerComponent); - component.ngOnInit(); }); describe('onDurationChange', () => { diff --git a/src/app/shared/pomodoro-timer.component.ts b/src/app/shared/pomodoro-timer.component.ts index 5119cde..7e8cfdb 100644 --- a/src/app/shared/pomodoro-timer.component.ts +++ b/src/app/shared/pomodoro-timer.component.ts @@ -1,4 +1,4 @@ -import {Component, inject, OnDestroy, OnInit} from '@angular/core'; +import {Component, inject} from '@angular/core'; import { IonButton, IonCard, @@ -10,7 +10,14 @@ import { IonLabel, PopoverController, } from '@ionic/angular/standalone'; -import {PomodoroService, DURATION_FIELDS, DurationField, PomodoroDurations, PhaseNotification} from '../pomodoro.service'; +import { + PomodoroService, + DURATION_FIELDS, + DurationField, + PomodoroDurations, + MAX_SESSIONS_BEFORE_LONG_BREAK, +} from '../pomodoro.service'; +import {PomodoroInfoPopoverComponent} from './pomodoro-info-popover.component'; import {AsyncPipe} from '@angular/common'; import {addIcons} from 'ionicons'; import { @@ -24,320 +31,14 @@ import { notificationsOutline, } from 'ionicons/icons'; import {FormsModule} from '@angular/forms'; -import {Subscription} from 'rxjs'; - -/** - * Info popover explaining the Pomodoro technique. - */ -@Component({ - selector: 'app-pomodoro-info-popover', - template: ` -
-

{{ title }}

-

{{ description }}

-
    - @for (step of steps; track step.text) { -
  1. {{ step.text }}
    {{ step.detail }}
  2. - } -
-
- `, - styles: [` - .pomodoro-info-popover { - padding: 1rem; - max-width: 300px; - } - - .pomodoro-info-popover h3 { - margin: 0 0 0.5rem 0; - font-weight: 700; - color: var(--ion-text-color); - } - - .pomodoro-info-popover p, - .pomodoro-info-popover li { - font-size: 0.85rem; - color: var(--ion-color-medium); - line-height: 1.4; - } - - .pomodoro-info-popover ol { - padding-left: 1.25rem; - margin: 0.5rem 0 0 0; - } - - .step-detail { - color: var(--ion-color-medium-tint); - } - `], - imports: [], -}) -export class PomodoroInfoPopoverComponent { - title = 'Pomodoro Technique'; - description = 'A time management method that uses a timer to break work into intervals:'; - steps = [ - { text: 'Study for the set duration', detail: '(default 25 min)' }, - { text: 'Take a short break', detail: '(default 5 min)' }, - { text: 'Repeat \u2014 after 4 study sessions, take a long break', detail: '(default 20 min)' }, - ]; -} /** - * Collapsible Pomodoro Timer widget with fullscreen notification overlay. + * Collapsible Pomodoro Timer widget. Every copy shows the one timer in PomodoroService. */ @Component({ selector: 'app-pomodoro-timer', - template: ` - - @if (activeNotification) { -
- -
- {{ activeNotification.title }} - {{ activeNotification.body }} -
-
- } - - - - - Pomodoro Timer - - - - - @if (!isCollapsed) { - - -
-
- {{ (pomodoroService.currentPhase$ | async)?.label ?? 'Timer' }} - - - -
-
{{ pomodoroService.formattedTime$ | async }}
- - -
- @if ((pomodoroService.timerState$ | async) === 'idle') { - - - - } - @if ((pomodoroService.timerState$ | async) === 'running') { - - - - } - @if ((pomodoroService.timerState$ | async) === 'paused') { - - - - } - @if ((pomodoroService.timerState$ | async) !== 'idle') { - - - - - - - } -
- - - @if ((pomodoroService.completedSessions$ | async)! > 0) { -
- Session {{ pomodoroService.completedSessions$ | async }} completed -
- } -
- - -
- @for (field of durationFields; track field.key) { -
- {{ field.label }} -
- - {{ field.suffix }} -
-
- } -
-
- } -
- `, - styles: [` - :host { - display: block; - width: 100%; - } - - .pomodoro-card { - margin: 0.75rem 0 0 0; - border-radius: 0.75rem; - overflow: hidden; - box-shadow: var(--flick-welcome-card-shadow, 0 4px 16px rgba(0, 0, 0, 0.08)); - } - - .pomodoro-header { - cursor: pointer; - padding: 0.75rem 1rem; - } - - .pomodoro-title { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 1rem; - font-weight: 600; - } - - .collapse-icon { - font-size: 1.2rem; - color: var(--ion-color-medium); - } - - .pomodoro-content { - padding: 0 1rem 1rem 1rem; - } - - .timer-section { - text-align: center; - padding: 0.5rem 0 0.75rem 0; - } - - .timer-label { - display: flex; - align-items: center; - justify-content: center; - gap: 0.25rem; - margin-bottom: 0.25rem; - } - - .phase-label { - font-size: 1.25rem; - font-weight: 700; - color: var(--ion-text-color); - } - - .info-btn { - --padding-start: 0; - --padding-end: 0; - height: auto; - } - - .timer-display { - font-size: 2.25rem; - font-weight: 300; - font-variant-numeric: tabular-nums; - letter-spacing: 0.05em; - color: var(--ion-color-medium); - margin: 0.25rem 0; - } - - .timer-controls { - display: flex; - align-items: center; - justify-content: center; - gap: 0.25rem; - } - - .session-counter { - font-size: 0.8rem; - color: var(--ion-color-medium); - margin-top: 0.5rem; - } - - .duration-section { - border-top: 1px solid var(--ion-border-color, rgba(0, 0, 0, 0.1)); - padding-top: 0.75rem; - } - - .duration-field { - margin-bottom: 0.75rem; - } - - .duration-label { - display: block; - font-size: 0.85rem; - color: var(--ion-color-medium); - margin-bottom: 0.25rem; - } - - .duration-input-wrapper { - display: flex; - align-items: center; - gap: 0.5rem; - } - - .duration-input { - --padding-start: 0.75rem; - --padding-end: 0.75rem; - border: 1px solid var(--ion-border-color, rgba(0, 0, 0, 0.15)); - border-radius: 0.5rem; - max-width: 180px; - } - - .duration-suffix { - font-size: 0.9rem; - color: var(--ion-color-medium); - } - - /* Fullscreen & Floating Toast Overlay */ - .pomodoro-toast-overlay { - position: fixed; - top: 4.5rem; - left: 50%; - transform: translateX(-50%); - z-index: 999999; - background: var(--ion-color-dark, #222428); - color: var(--ion-color-dark-contrast, #ffffff); - padding: 0.75rem 1.25rem; - border-radius: 2rem; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45); - display: flex; - align-items: center; - gap: 0.75rem; - animation: slideDownFade 0.4s cubic-bezier(0.16, 1, 0.3, 1); - pointer-events: none; - max-width: 90vw; - } - - .toast-icon { - font-size: 1.5rem; - color: var(--ion-color-warning, #ffc409); - } - - .toast-text { - display: flex; - flex-direction: column; - font-size: 0.85rem; - line-height: 1.3; - } - - @keyframes slideDownFade { - from { - opacity: 0; - transform: translate(-50%, -20px); - } - to { - opacity: 1; - transform: translate(-50%, 0); - } - } - `], + templateUrl: './pomodoro-timer.component.html', + styleUrls: ['./pomodoro-timer.component.scss'], imports: [ IonCard, IonCardHeader, @@ -351,75 +52,20 @@ export class PomodoroInfoPopoverComponent { FormsModule, ], }) -export class PomodoroTimerComponent implements OnInit, OnDestroy { +export class PomodoroTimerComponent { pomodoroService = inject(PomodoroService); private popoverCtrl = inject(PopoverController); durationFields: DurationField[] = DURATION_FIELDS; - currentDurations: PomodoroDurations; + currentDurations: PomodoroDurations = this.pomodoroService.getDurations(); + readonly maxSessionsBeforeLongBreak = MAX_SESSIONS_BEFORE_LONG_BREAK; isCollapsed = true; - activeNotification: PhaseNotification | null = null; - private notificationSub: Subscription | null = null; - private toastTimer: ReturnType | null = null; - constructor() { addIcons({play, pause, stop, playSkipForward, chevronDown, chevronUp, informationCircleOutline, notificationsOutline}); } - ngOnInit(): void { - this.currentDurations = this.pomodoroService.getDurations(); - - // Listen for phase notifications and display in-app toast - this.notificationSub = this.pomodoroService.phaseNotification$.subscribe(notif => { - this.showToast(notif); - }); - } - - ngOnDestroy(): void { - this.notificationSub?.unsubscribe(); - if (this.toastTimer) { - clearTimeout(this.toastTimer); - } - } - - private showToast(notif: PhaseNotification): void { - this.activeNotification = notif; - if (this.toastTimer) { - clearTimeout(this.toastTimer); - } - - // Handle Fullscreen Video DOM Overlay (inject directly into active fullscreen element) - const prefixedDocument = document as Document & { - webkitFullscreenElement?: Element | null; - mozFullScreenElement?: Element | null; - }; - const fullscreenEl = typeof document !== 'undefined' - ? (document.fullscreenElement || prefixedDocument.webkitFullscreenElement || prefixedDocument.mozFullScreenElement) - : null; - - if (fullscreenEl) { - let fullscreenToast = fullscreenEl.querySelector('.pomodoro-fullscreen-toast') as HTMLElement; - if (!fullscreenToast) { - fullscreenToast = document.createElement('div'); - fullscreenToast.className = 'pomodoro-fullscreen-toast'; - fullscreenEl.appendChild(fullscreenToast); - } - fullscreenToast.textContent = `${notif.title}: ${notif.body}`; - fullscreenToast.classList.add('show'); - - setTimeout(() => { - fullscreenToast?.classList.remove('show'); - }, 5000); - } - - // Auto hide standard in-app toast after 5 seconds - this.toastTimer = setTimeout(() => { - this.activeNotification = null; - }, 5000); - } - toggleCollapse(): void { this.isCollapsed = !this.isCollapsed; if (!this.isCollapsed) { @@ -427,14 +73,23 @@ export class PomodoroTimerComponent implements OnInit, OnDestroy { } } - onDurationChange(key: keyof PomodoroDurations, event: CustomEvent): void { - const value = parseInt(event.detail.value, 10); + onDurationChange(key: keyof PomodoroDurations, event: CustomEvent<{value?: string | null}>): void { + const value = parseInt(event.detail.value ?? '', 10); if (!isNaN(value) && value > 0 && value <= 120) { this.currentDurations = {...this.currentDurations, [key]: value}; this.pomodoroService.updateDurations({[key]: value}); } } + onSessionsBeforeLongBreakChange(event: CustomEvent<{value?: string | null}>): void { + const value = parseInt(event.detail.value ?? '', 10); + if (!isNaN(value)) { + this.pomodoroService.setSessionsBeforeLongBreak(Math.min(Math.max(value, 1), this.maxSessionsBeforeLongBreak)); + } + // Show the count actually in use when the entry was out of range or blank. + (event.target as HTMLIonInputElement).value = this.pomodoroService.getSessionsBeforeLongBreak(); + } + async openInfoPopover(event: Event): Promise { const popover = await this.popoverCtrl.create({ component: PomodoroInfoPopoverComponent, diff --git a/src/app/shared/pomodoro-toast.ts b/src/app/shared/pomodoro-toast.ts new file mode 100644 index 0000000..d8f8bda --- /dev/null +++ b/src/app/shared/pomodoro-toast.ts @@ -0,0 +1,152 @@ +/** + * The Pomodoro phase-change notification. + * + * Rendered outside the Angular component tree, for two reasons: + * + * 1. `.ion-page` sets `contain: layout`, which makes it the containing block for `position: fixed` + * descendants. A toast rendered inside the page is therefore positioned against the page box + * rather than the viewport, so it drifts by however much the two disagree — which on iOS in-app + * browsers (the Google and Chrome apps) is the height of their dynamic toolbar, dropping the + * toast down over the course list. Attaching to body restores true viewport positioning. + * 2. While an element is fullscreen the browser paints only that element's subtree, so the toast has + * to live inside the fullscreen element for the duration. + * + * Both are handled by attaching to `document.body`, or to the fullscreen element while one is + * active, and following the fullscreen element as it changes. + * + * Deliberately free of Angular and Ionic imports so it can be exercised directly in a browser test. + */ +export class PomodoroToast { + /** How long the toast stays fully visible before it starts fading. */ + static readonly VISIBLE_MS = 5000; + /** Must outlast the opacity transition on `.pomodoro-toast` in global.scss. */ + static readonly FADE_MS = 300; + + private static readonly FULLSCREEN_EVENTS = ['fullscreenchange', 'webkitfullscreenchange', 'mozfullscreenchange']; + private static readonly BELL_SVG = + ''; + + private el: HTMLElement | null = null; + private titleEl: HTMLElement | null = null; + private bodyEl: HTMLElement | null = null; + private hideTimer: ReturnType | null = null; + private removeTimer: ReturnType | null = null; + private destroyed = false; + + constructor(private readonly doc: Document = document) { + for (const event of PomodoroToast.FULLSCREEN_EVENTS) { + this.doc.addEventListener(event, this.onFullscreenChange); + } + } + + /** The live toast element, or null when nothing has been shown yet. Exposed for tests. */ + get element(): HTMLElement | null { + return this.el; + } + + /** Show (or replace the contents of) the toast and start its auto-hide countdown. */ + show(title: string, body: string): void { + if (this.destroyed) { + return; + } + this.clearTimers(); + + if (!this.el) { + this.build(); + } + // Set as text, never markup: these strings are the only variable part of the toast. + this.titleEl!.textContent = title; + this.bodyEl!.textContent = body; + + this.attachToHost(); + + // Restart the entry transition. The browser has to observe the un-shown state before + // `show` goes back on, or a toast replaced while still on screen jumps to the end state + // with no animation. Reading offsetWidth forces that style flush synchronously — waiting + // for animation frames instead would leave the toast invisible in any context where the + // next frame never comes (a throttled or non-compositing tab). + const el = this.el!; + el.classList.remove('show'); + void el.offsetWidth; + el.classList.add('show'); + + this.hideTimer = setTimeout(() => { + el.classList.remove('show'); + this.removeTimer = setTimeout(() => el.remove(), PomodoroToast.FADE_MS); + }, PomodoroToast.VISIBLE_MS); + } + + /** Remove the toast and stop listening. The instance is inert afterwards. */ + destroy(): void { + this.destroyed = true; + this.clearTimers(); + for (const event of PomodoroToast.FULLSCREEN_EVENTS) { + this.doc.removeEventListener(event, this.onFullscreenChange); + } + this.el?.remove(); + this.el = null; + this.titleEl = null; + this.bodyEl = null; + } + + /** + * Where the toast must live to be visible right now: inside the fullscreen element if there is + * one, otherwise on document.body. + */ + private getHost(): HTMLElement { + const doc = this.doc as Document & { webkitFullscreenElement?: Element; mozFullScreenElement?: Element }; + const fullscreenEl = this.doc.fullscreenElement ?? doc.webkitFullscreenElement ?? doc.mozFullScreenElement; + return (fullscreenEl as HTMLElement | null) ?? this.doc.body; + } + + private attachToHost(): void { + const host = this.getHost(); + if (this.el && this.el.parentElement !== host) { + host.appendChild(this.el); + } + } + + /** Follow the fullscreen element in and out of fullscreen, but only while a toast is on screen. */ + private onFullscreenChange = (): void => { + if (this.destroyed || !this.el || !this.el.isConnected) { + return; + } + this.attachToHost(); + }; + + private build(): void { + const el = this.doc.createElement('div'); + el.className = 'pomodoro-toast'; + // Announced by assistive tech without stealing focus from the video. + el.setAttribute('role', 'status'); + el.setAttribute('aria-live', 'polite'); + + const icon = this.doc.createElement('span'); + icon.className = 'pomodoro-toast-icon'; + // Static markup, no interpolation. + icon.innerHTML = PomodoroToast.BELL_SVG; + + const text = this.doc.createElement('div'); + text.className = 'pomodoro-toast-text'; + this.titleEl = this.doc.createElement('strong'); + this.bodyEl = this.doc.createElement('span'); + text.append(this.titleEl, this.bodyEl); + + el.append(icon, text); + this.el = el; + } + + private clearTimers(): void { + if (this.hideTimer) { + clearTimeout(this.hideTimer); + this.hideTimer = null; + } + if (this.removeTimer) { + clearTimeout(this.removeTimer); + this.removeTimer = null; + } + } + +} diff --git a/src/global.scss b/src/global.scss index 3c81298..c7f72bb 100644 --- a/src/global.scss +++ b/src/global.scss @@ -50,31 +50,76 @@ } } -/* Fullscreen Pomodoro Notification Toast (Appended inside video fullscreen element) */ -.pomodoro-fullscreen-toast { - position: absolute; - top: 3rem; +/** + * Pomodoro phase notification. + * + * Attached to document.body, or to the fullscreen element while one is active, by PomodoroToast — + * never inside the page, because .ion-page sets "contain: layout" and so becomes the containing + * block for position:fixed children, which positions the toast against the page box rather than the + * viewport. Styles are global because the element sits outside the component's view encapsulation. + */ +.pomodoro-toast { + position: fixed; + /* Clears the app header (ion-toolbar is 56px) so the toast never straddles it; the safe-area + term keeps it clear of the notch/status bar when the viewport is served edge to edge. */ + top: calc(env(safe-area-inset-top, 0px) + 4.5rem); left: 50%; - transform: translateX(-50%); z-index: 2147483647; - background: rgba(34, 36, 40, 0.95); - color: #ffffff; - padding: 0.75rem 1.5rem; + display: flex; + align-items: center; + gap: 0.75rem; + box-sizing: border-box; + max-width: min(90vw, 26rem); + padding: 0.75rem 1.25rem; border-radius: 2rem; - font-size: 1.1rem; - font-weight: 600; - border: 1px solid rgba(255, 255, 255, 0.25); - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.6); + background: rgba(var(--ion-color-dark-rgb), 0.96); + color: var(--ion-color-dark-contrast); + border: 1px solid rgba(var(--ion-color-dark-contrast-rgb), 0.22); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.55); pointer-events: none; opacity: 0; + /* Both states set translate so the transition interpolates rather than snapping. */ + transform: translate(-50%, -1rem); transition: opacity 0.3s ease, transform 0.3s ease; } -.pomodoro-fullscreen-toast.show { +.pomodoro-toast.show { opacity: 1; transform: translate(-50%, 0); } +.pomodoro-toast-icon { + flex: 0 0 auto; + display: flex; + width: 1.5rem; + height: 1.5rem; + color: var(--ion-color-warning); +} + +.pomodoro-toast-icon svg { + width: 100%; + height: 100%; +} + +.pomodoro-toast-text { + display: flex; + flex-direction: column; + min-width: 0; + font-size: 0.9rem; + line-height: 1.3; +} + +.pomodoro-toast-text strong { + font-weight: 600; +} + +@media (prefers-reduced-motion: reduce) { + .pomodoro-toast { + transition: opacity 0.3s ease; + transform: translate(-50%, 0); + } +} + /* video.js adds .vjs-touch-enabled based on touch *capability* (navigator.maxTouchPoints), which stays true on touchscreen laptops even when the mouse is the input in use. Gate on (hover: none) and (pointer: coarse) too so these rules only kick in when touch is actually the From 49867d53f15d8e80c8ef98d10b0d4bc70e5e578d Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Tue, 6 Oct 2026 14:57:36 +0700 Subject: [PATCH 15/20] refactor(settings): rename lecture session mode to Flick only - change 'Watching lectures' label to 'Flick only' - update hint text to clarify that only time spent on Flick counts --- src/app/pomodoro.service.spec.ts | 2 +- src/app/settings/settings-presets.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/pomodoro.service.spec.ts b/src/app/pomodoro.service.spec.ts index a4bf5be..a938e13 100644 --- a/src/app/pomodoro.service.spec.ts +++ b/src/app/pomodoro.service.spec.ts @@ -112,7 +112,7 @@ describe('PomodoroService', () => { discardPeriodicTasks(); })); - it('pauses on leaving the tab only in the mode chosen for watching lectures', () => { + it('pauses on leaving the tab only in the mode chosen for Flick only', () => { const hidden = spyOnProperty(document, 'hidden').and.returnValue(true); service.start(); document.dispatchEvent(new Event('visibilitychange')); diff --git a/src/app/settings/settings-presets.ts b/src/app/settings/settings-presets.ts index e826730..fddf58f 100644 --- a/src/app/settings/settings-presets.ts +++ b/src/app/settings/settings-presets.ts @@ -45,8 +45,8 @@ function formatHour(hour: number): string { export const SESSION_MODE_OPTIONS: SessionModeOption[] = [ { value: 'lecture', - label: 'Watching lectures', - hint: 'The timer pauses when you leave this tab, so only time spent here counts.', + label: 'Flick only', + hint: 'The timer pauses when you leave this tab, only time spent on Flick counts.', }, { value: 'elsewhere', From 13222210f12bf2740e72863d452195aee678ba5c Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Wed, 7 Oct 2026 13:31:44 +0700 Subject: [PATCH 16/20] feat: sync Pomodoro preferences across devices - save study, break and long-break minutes and sessions before a long break to FlickMan's v1/user_settings - on sign-in take on what another device saved, or share this device's when nothing is saved yet - send a change about 2 seconds after it is made; the reset hour, session mode and a running timer stay per device - wait for the ID token before the first read, so sync is not switched off while sign-in finishes --- src/app/man.service.ts | 19 +- src/app/pomodoro-sync.service.spec.ts | 108 +++++++++++ src/app/pomodoro-sync.service.ts | 50 +++++ src/app/pomodoro.service.spec.ts | 24 +++ src/app/pomodoro.service.ts | 53 +++-- src/app/user-sync.service.spec.ts | 51 +++++ src/app/user-sync.service.ts | 268 ++++++++++++++++++++++++++ src/main.ts | 7 +- 8 files changed, 565 insertions(+), 15 deletions(-) create mode 100644 src/app/pomodoro-sync.service.spec.ts create mode 100644 src/app/pomodoro-sync.service.ts create mode 100644 src/app/user-sync.service.spec.ts create mode 100644 src/app/user-sync.service.ts diff --git a/src/app/man.service.ts b/src/app/man.service.ts index 21f5caa..70ae71c 100644 --- a/src/app/man.service.ts +++ b/src/app/man.service.ts @@ -171,6 +171,21 @@ export class ManService { return this.get('v1/auth_check').pipe(timeout(8000), map(a => a.hasOwnProperty('success'))); } + /** The settings synced across the user's devices (see UserSyncService), or null when they can't be fetched. */ + getUserSettings(): Observable { + return this.get>('v1/user_settings').pipe(map(response => response?.data ?? null)); + } + + /** Merge a change into the synced settings. `keepalive` lets the request finish while the page closes. */ + saveUserSettings(patch: object): Observable> { + return this.post>('v1/user_settings', patch, {keepalive: true}).pipe(map(response => { + if (!response) { + throw new Error('ManService could not send the user settings.'); + } + return response; + })); + } + changeEndpoint() { this.endpoint.push(this.endpoint.shift()); } @@ -186,13 +201,13 @@ export class ManService { return of(null); } - post(path: string, body: object): Observable { + post(path: string, body: object, options?: object): Observable { if (this.httpOptions.headers.get('Authorization').length < 30) { console.error('ManService ID token is not set.'); } else if (!this.getEndpointLocation()) { console.error('ManService endpoint is not set.'); } else { - return this.http.post(this.getEndpointLocation() + path, body, this.httpOptions); + return this.http.post(this.getEndpointLocation() + path, body, {...this.httpOptions, ...options}); } return of(null); } diff --git a/src/app/pomodoro-sync.service.spec.ts b/src/app/pomodoro-sync.service.spec.ts new file mode 100644 index 0000000..c8a5731 --- /dev/null +++ b/src/app/pomodoro-sync.service.spec.ts @@ -0,0 +1,108 @@ +import {fakeAsync, flushMicrotasks, TestBed} from '@angular/core/testing'; +import {BehaviorSubject, Subject} from 'rxjs'; +import {AuthService} from './auth.service'; +import {PomodoroPreferences, PomodoroService} from './pomodoro.service'; +import {PomodoroSyncService} from './pomodoro-sync.service'; +import {RemoteUserSettings, UserSyncService} from './user-sync.service'; + +describe('PomodoroSyncService', () => { + const preferences: PomodoroPreferences = { + durations: {studyMinutes: 25, breakMinutes: 5, longBreakMinutes: 15}, + sessionsBeforeLongBreak: 4, + }; + let user: BehaviorSubject<{uid: string} | null>; + let idToken: BehaviorSubject; + let changes: Subject; + let pomodoro: jasmine.SpyObj>; + let sync: jasmine.SpyObj>; + let remote: RemoteUserSettings | null; + + function signIn(uid = 'user-1'): void { + user.next({uid}); + idToken.next('token-for-' + uid); + } + + beforeEach(() => { + user = new BehaviorSubject<{uid: string} | null>(null); + idToken = new BehaviorSubject(null); + changes = new Subject(); + remote = {}; + pomodoro = jasmine.createSpyObj('PomodoroService', ['applyPreferences', 'getPreferences'], {preferencesChanged$: changes}); + pomodoro.getPreferences.and.returnValue(preferences); + sync = jasmine.createSpyObj('UserSyncService', ['attach', 'detach', 'read', 'queue']); + sync.read.and.callFake(() => Promise.resolve(remote)); + + TestBed.configureTestingModule({ + providers: [ + {provide: AuthService, useValue: {user, idToken}}, + {provide: PomodoroService, useValue: pomodoro}, + {provide: UserSyncService, useValue: sync}, + ], + }); + TestBed.inject(PomodoroSyncService); + }); + + it('waits for the ID token before reading, so the first read cannot fail', () => { + user.next({uid: 'user-1'}); + expect(sync.read).not.toHaveBeenCalled(); + + idToken.next('token'); + + expect(sync.attach).toHaveBeenCalledWith('user-1'); + expect(sync.read).toHaveBeenCalledTimes(1); + }); + + it('takes on the preferences another device saved', fakeAsync(() => { + remote = {pomodoro: {sessionsBeforeLongBreak: 2}}; + + signIn(); + flushMicrotasks(); + + expect(pomodoro.applyPreferences).toHaveBeenCalledWith({sessionsBeforeLongBreak: 2}); + expect(sync.queue).not.toHaveBeenCalled(); + })); + + it('shares this device\'s preferences when none are stored yet', fakeAsync(() => { + remote = {theme: {}}; + + signIn(); + flushMicrotasks(); + + expect(sync.queue).toHaveBeenCalledWith({pomodoro: preferences}, true); + expect(pomodoro.applyPreferences).not.toHaveBeenCalled(); + })); + + it('changes nothing when the settings could not be read', fakeAsync(() => { + remote = null; + + signIn(); + flushMicrotasks(); + + expect(pomodoro.applyPreferences).not.toHaveBeenCalled(); + expect(sync.queue).not.toHaveBeenCalled(); + })); + + it('reads once per sign-in, not on every token refresh', () => { + signIn(); + idToken.next('refreshed-token'); + + expect(sync.read).toHaveBeenCalledTimes(1); + }); + + it('sends a change the reader makes soon', () => { + signIn(); + + changes.next(preferences); + + expect(sync.queue).toHaveBeenCalledWith({pomodoro: preferences}, true); + }); + + it('lets go of the account on sign-out', () => { + signIn(); + + user.next(null); + idToken.next(null); + + expect(sync.detach).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/pomodoro-sync.service.ts b/src/app/pomodoro-sync.service.ts new file mode 100644 index 0000000..f3155e1 --- /dev/null +++ b/src/app/pomodoro-sync.service.ts @@ -0,0 +1,50 @@ +import {inject, Injectable} from '@angular/core'; +import {combineLatest} from 'rxjs'; +import {AuthService} from './auth.service'; +import {PomodoroService} from './pomodoro.service'; +import {UserSyncService} from './user-sync.service'; + +/** + * Keeps the Pomodoro preferences (durations and sessions before a long break) the same on every device + * the reader signs in on. Signing in takes on what another device saved; a change made here is sent a + * couple of seconds later. + */ +@Injectable({ + providedIn: 'root', +}) +export class PomodoroSyncService { + private pomodoro = inject(PomodoroService); + private sync = inject(UserSyncService); + private userId: string | null = null; + + constructor() { + const authService = inject(AuthService); + // ManService only has a token once idToken emits, and a read without one would switch sync off + combineLatest([authService.user, authService.idToken]).subscribe(([user, idToken]) => { + if (user?.uid && idToken) { + if (this.userId !== user.uid) { + this.userId = user.uid; + this.sync.attach(user.uid); + void this.pull(user.uid); + } + } else if (!user && this.userId) { + this.userId = null; + this.sync.detach(); + } + }); + this.pomodoro.preferencesChanged$.subscribe(preferences => this.sync.queue({pomodoro: preferences}, true)); + } + + private async pull(uid: string): Promise { + const remote = await this.sync.read(); + if (this.userId !== uid || !remote) { + return; + } + if (remote.pomodoro) { + this.pomodoro.applyPreferences(remote.pomodoro); + } else { + // The first device to sync shares what it has + this.sync.queue({pomodoro: this.pomodoro.getPreferences()}, true); + } + } +} diff --git a/src/app/pomodoro.service.spec.ts b/src/app/pomodoro.service.spec.ts index a938e13..7396e55 100644 --- a/src/app/pomodoro.service.spec.ts +++ b/src/app/pomodoro.service.spec.ts @@ -1,6 +1,7 @@ import {TestBed, fakeAsync, discardPeriodicTasks} from '@angular/core/testing'; import {Analytics} from '@angular/fire/analytics'; import { + PomodoroPreferences, PomodoroService, DEFAULT_DURATIONS, DEFAULT_SESSIONS_BEFORE_LONG_BREAK @@ -141,4 +142,27 @@ describe('PomodoroService', () => { settings.setVisible('pomodoro', true); localStorage.removeItem(SETTINGS_STORAGE_KEY); }); + + describe('preferences shared across devices', () => { + it('reports each change the reader makes, but not preferences applied from another device', () => { + const changes: PomodoroPreferences[] = []; + service.preferencesChanged$.subscribe(preferences => changes.push(preferences)); + + service.updateDurations({studyMinutes: 50}); + service.setSessionsBeforeLongBreak(3); + service.applyPreferences({sessionsBeforeLongBreak: 2}); + + expect(changes.length).toBe(2); + expect(changes[1]).toEqual({durations: {...DEFAULT_DURATIONS, studyMinutes: 50}, sessionsBeforeLongBreak: 3}); + }); + + it('applies and saves valid preferences from another device, ignoring invalid values', () => { + service.applyPreferences({durations: {studyMinutes: 45, breakMinutes: -1, longBreakMinutes: 'x'}, sessionsBeforeLongBreak: 6}); + + const expected = {durations: {...DEFAULT_DURATIONS, studyMinutes: 45}, sessionsBeforeLongBreak: 6}; + expect(service.getPreferences()).toEqual(expected); + expect(JSON.parse(localStorage.getItem('pomodoroPrefs_guest') ?? '')).toEqual(expected); + expect(firstValue(service.timeRemaining$)).toBe(45 * 60); + }); + }); }); diff --git a/src/app/pomodoro.service.ts b/src/app/pomodoro.service.ts index 3487960..d022c04 100644 --- a/src/app/pomodoro.service.ts +++ b/src/app/pomodoro.service.ts @@ -1,5 +1,5 @@ import {inject, Injectable} from '@angular/core'; -import {BehaviorSubject, Observable, Subscription, interval} from 'rxjs'; +import {BehaviorSubject, Observable, Subject, Subscription, interval} from 'rxjs'; import {map} from 'rxjs/operators'; import {Analytics, logEvent} from '@angular/fire/analytics'; import {ConsentService} from './consent.service'; @@ -24,6 +24,12 @@ export interface PomodoroDurations { longBreakMinutes: number; } +/** The preferences that follow the user across devices (see PomodoroSyncService). */ +export interface PomodoroPreferences { + durations: PomodoroDurations; + sessionsBeforeLongBreak: number; +} + /** Timer operational state */ export type TimerState = 'idle' | 'running' | 'paused'; @@ -91,6 +97,9 @@ export class PomodoroService { /** Configurable settings */ private durations: PomodoroDurations = {...DEFAULT_DURATIONS}; private sessionsBeforeLongBreak = new BehaviorSubject(DEFAULT_SESSIONS_BEFORE_LONG_BREAK); + private readonly preferencesChangedSubject = new Subject(); + /** The preferences after each change the reader makes, but not after applyPreferences(). */ + readonly preferencesChanged$: Observable = this.preferencesChangedSubject.asObservable(); /** Internal state */ private timerState = new BehaviorSubject('idle'); @@ -192,12 +201,14 @@ export class PomodoroService { } this.sessionsBeforeLongBreak.next(count); this.savePreferences(); + this.preferencesChangedSubject.next(this.getPreferences()); } /** Update duration config and save */ updateDurations(newDurations: Partial): void { this.durations = {...this.durations, ...newDurations}; this.savePreferences(); + this.preferencesChangedSubject.next(this.getPreferences()); // If idle, reset timer to reflect new duration if (this.timerState.value === 'idle') { @@ -205,6 +216,19 @@ export class PomodoroService { } } + getPreferences(): PomodoroPreferences { + return {durations: {...this.durations}, sessionsBeforeLongBreak: this.sessionsBeforeLongBreak.value}; + } + + /** Take on preferences saved on another device. Invalid values are ignored, as in local storage. */ + applyPreferences(value: unknown): void { + this.readPreferences(value); + this.savePreferences(); + if (this.timerState.value === 'idle') { + this.timeRemaining.next(this.getCurrentPhaseDuration()); + } + } + /** Start or resume the timer */ start(): void { if (this.timerState.value === 'running') { @@ -527,23 +551,28 @@ export class PomodoroService { try { const raw = localStorage.getItem(this.STORAGE_KEY_PREFIX + uid); if (raw) { - const parsed = JSON.parse(raw); - if (parsed.durations) { - for (const field of DURATION_FIELDS) { - if (typeof parsed.durations[field.key] === 'number' && parsed.durations[field.key] > 0) { - this.durations[field.key] = parsed.durations[field.key]; - } - } - } - if (typeof parsed.sessionsBeforeLongBreak === 'number' && parsed.sessionsBeforeLongBreak > 0) { - this.sessionsBeforeLongBreak.next(parsed.sessionsBeforeLongBreak); - } + this.readPreferences(JSON.parse(raw)); } } catch { // Ignore parse errors } } + private readPreferences(value: unknown): void { + const parsed = (value ?? {}) as {durations?: Partial>, sessionsBeforeLongBreak?: unknown}; + if (parsed.durations) { + for (const field of DURATION_FIELDS) { + const minutes = parsed.durations[field.key]; + if (typeof minutes === 'number' && minutes > 0) { + this.durations[field.key] = minutes; + } + } + } + if (typeof parsed.sessionsBeforeLongBreak === 'number' && parsed.sessionsBeforeLongBreak > 0) { + this.sessionsBeforeLongBreak.next(parsed.sessionsBeforeLongBreak); + } + } + private savePreferences(): void { localStorage.setItem( this.STORAGE_KEY_PREFIX + this.currentUserId, diff --git a/src/app/user-sync.service.spec.ts b/src/app/user-sync.service.spec.ts new file mode 100644 index 0000000..2772876 --- /dev/null +++ b/src/app/user-sync.service.spec.ts @@ -0,0 +1,51 @@ +import {TestBed} from '@angular/core/testing'; +import {BehaviorSubject, of} from 'rxjs'; +import {AuthService} from './auth.service'; +import {ManService} from './man.service'; +import {UserSyncService} from './user-sync.service'; + +describe('UserSyncService', () => { + let idToken: BehaviorSubject; + let manService: jasmine.SpyObj>; + let sync: UserSyncService; + + beforeEach(() => { + localStorage.clear(); + idToken = new BehaviorSubject(null); + manService = jasmine.createSpyObj('ManService', ['getUserSettings', 'saveUserSettings']); + manService.getUserSettings.and.returnValue(of({theme: {}})); + + TestBed.configureTestingModule({ + providers: [ + {provide: AuthService, useValue: {idToken}}, + {provide: ManService, useValue: manService}, + ], + }); + sync = TestBed.inject(UserSyncService); + }); + + afterEach(() => localStorage.clear()); + + it('waits for the ID token before asking FlickMan, so signing in does not switch sync off', async () => { + sync.attach('user-1'); + const reading = sync.read(); + await Promise.resolve(); + expect(manService.getUserSettings).not.toHaveBeenCalled(); + + idToken.next('token'); + + expect(await reading).toEqual({theme: {}}); + expect(manService.getUserSettings).toHaveBeenCalledTimes(1); + }); + + it('drops a read still waiting for the token when the user signs out', async () => { + sync.attach('user-1'); + const reading = sync.read(); + sync.detach(); + + idToken.next('token'); + + expect(await reading).toBeNull(); + expect(manService.getUserSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/user-sync.service.ts b/src/app/user-sync.service.ts new file mode 100644 index 0000000..dd4faaa --- /dev/null +++ b/src/app/user-sync.service.ts @@ -0,0 +1,268 @@ +import {DestroyRef, inject, Injectable} from '@angular/core'; +import {BehaviorSubject, firstValueFrom, Observable} from 'rxjs'; +import {filter} from 'rxjs/operators'; +import {getValue, RemoteConfig} from '@angular/fire/remote-config'; +import {AuthService} from './auth.service'; +import {ManService} from './man.service'; + +export const SYNC_CACHE_KEY_PREFIX = 'flickSync_'; +/** A remote config flag, so sync can be switched off without a release. */ +export const SYNC_ENABLED_KEY = 'userSyncEnabled'; + +/** + * The whole account lives in one document on FlickMan (`v1/user_settings`), fetched at most + * once per this interval however many times the app is opened or reloaded. + */ +export const SYNC_READ_TTL_MS = 5 * 60 * 1000; +/** + * A preference is sent once it has been left alone this long, so dragging a slider or a colour + * picker costs one write rather than one per step. + */ +export const SYNC_URGENT_DELAY_MS = 2000; +/** + * Study time accumulates continuously, so a device sends it at most once per this interval, + * which caps it at 48 writes a day. Leaving or reloading the page does not break the limit: + * what is held back is still on the device, and it goes out the next time the app opens there. + */ +export const SYNC_BACKGROUND_DELAY_MS = 30 * 60 * 1000; +/** When this device last wrote, so the interval holds across reloads. */ +export const SYNC_SENT_AT_KEY = 'flickSyncSentAt'; + +export interface RemoteUserSettings { + theme?: unknown; + pomodoro?: unknown; + study?: {devices?: Record}; +} + +interface CachedSettings { + data: RemoteUserSettings; + at: number; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +/** Deep merge, so a queued patch never discards another part of the document. FlickMan merges the same way. */ +function mergePatch(target: Record, patch: Record): Record { + for (const [key, value] of Object.entries(patch)) { + const existing = target[key]; + target[key] = isRecord(value) && isRecord(existing) + ? mergePatch({...existing}, value) + : value; + } + return target; +} + +/** + * Keeps one document per account in step across devices, on a budget. + * + * There is no live updating: none of this needs to arrive mid-session. Reads come from a cache + * that survives reloads, writes are merged and sent on a delay, and anything unexpected turns + * sync off for the session rather than retrying. + */ +@Injectable({ + providedIn: 'root', +}) +export class UserSyncService { + private authService = inject(AuthService); + private manService = inject(ManService); + + private readonly activeSubject = new BehaviorSubject(false); + /** True once the account document has actually been reached, so the app can say so. */ + readonly active$: Observable = this.activeSubject.asObservable(); + + private userId: string | null = null; + private enabled = true; + private pending: Record = {}; + private urgentPending = false; + private lastSentAt = this.readSentAt(); + private timer: ReturnType | null = null; + private dueAt = 0; + /** Every caller opening the app at once shares the one read in flight. */ + private reading: Promise | null = null; + + constructor() { + const remoteConfig = inject(RemoteConfig, {optional: true}); + if (remoteConfig) { + try { + const flag = getValue(remoteConfig, SYNC_ENABLED_KEY); + // A static source means nothing has been published for the key, which must + // leave sync on: the switch is there to turn it off, never to fail it closed. + this.enabled = flag.getSource() === 'static' || flag.asBoolean(); + } catch { + // Remote config is unavailable, so the switch simply does not apply. + } + } + + const leave = () => this.flush(false); + window.addEventListener('pagehide', leave); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + leave(); + } + }); + inject(DestroyRef).onDestroy(() => { + window.removeEventListener('pagehide', leave); + this.flush(); + }); + } + + attach(uid: string): void { + if (this.userId !== uid) { + this.flush(); + this.userId = uid; + } + } + + detach(): void { + this.flush(); + this.userId = null; + this.activeSubject.next(false); + } + + private disable(): void { + this.enabled = false; + this.activeSubject.next(false); + } + + /** The stored document, from the local cache when it is recent enough to trust. */ + async read(): Promise { + const uid = this.userId; + if (!uid || !this.enabled) { + return null; + } + const cached = this.readCache(uid); + if (cached && Date.now() - cached.at < SYNC_READ_TTL_MS) { + this.activeSubject.next(true); + return cached.data; + } + // Callers read as soon as the user signs in, a moment before ManService has the ID token + await firstValueFrom(this.authService.idToken.pipe(filter(Boolean))); + if (this.userId !== uid) { + return null; + } + this.reading ??= this.fetch(uid, cached).finally(() => { + this.reading = null; + }); + return this.reading; + } + + private async fetch(uid: string, cached: CachedSettings | null): Promise { + try { + const data = await firstValueFrom(this.manService.getUserSettings()) as RemoteUserSettings | null; + if (!data) { + throw new Error('No user settings'); + } + this.writeCache(uid, data); + this.activeSubject.next(true); + return data; + } catch { + this.disable(); + return cached?.data ?? null; + } + } + + /** + * Merge a change into the document. `urgent` sends it in a couple of seconds; everything + * else waits for the background cadence or for the page to go away. + */ + queue(patch: Record, urgent = false): void { + if (!this.userId || !this.enabled) { + return; + } + mergePatch(this.pending, patch); + if (urgent) { + this.urgentPending = true; + this.schedule(SYNC_URGENT_DELAY_MS, true); + } else { + this.schedule(SYNC_BACKGROUND_DELAY_MS); + } + } + + /** + * Send whatever is waiting. Leaving the page (`force` false) sends a preference straight + * away, but holds study time back while the last write is recent, so switching apps over + * and over never adds writes. + */ + flush(force = true): void { + const uid = this.userId; + if (!force && !this.urgentPending && Date.now() - this.lastSentAt < SYNC_BACKGROUND_DELAY_MS) { + return; + } + this.clearTimer(); + const patch = this.pending; + this.pending = {}; + this.urgentPending = false; + if (!uid || !this.enabled || !Object.keys(patch).length) { + return; + } + this.writeSentAt(Date.now()); + this.mergeCache(uid, patch); + this.manService.saveUserSettings(patch).subscribe({error: () => this.disable()}); + } + + /** A `restart` waits for changes to stop; otherwise the earliest send already due holds. */ + private schedule(delay: number, restart = false): void { + const due = Date.now() + delay; + if (this.timer && this.dueAt <= due && !restart) { + return; + } + this.clearTimer(); + this.dueAt = due; + this.timer = setTimeout(() => this.flush(), delay); + } + + private clearTimer(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } + + private readSentAt(): number { + try { + return Number(localStorage.getItem(SYNC_SENT_AT_KEY)) || 0; + } catch { + return 0; + } + } + + private writeSentAt(at: number): void { + this.lastSentAt = at; + try { + localStorage.setItem(SYNC_SENT_AT_KEY, String(at)); + } catch { + // Without it the interval still holds within this page. + } + } + + private cacheKey(uid: string): string { + return SYNC_CACHE_KEY_PREFIX + uid; + } + + private readCache(uid: string): CachedSettings | null { + try { + const raw = localStorage.getItem(this.cacheKey(uid)); + const parsed = raw ? JSON.parse(raw) as CachedSettings : null; + return parsed && isRecord(parsed.data) ? parsed : null; + } catch { + return null; + } + } + + private writeCache(uid: string, data: RemoteUserSettings): void { + try { + localStorage.setItem(this.cacheKey(uid), JSON.stringify({data, at: Date.now()} as CachedSettings)); + } catch { + // Without the cache every load costs one read, which is still affordable. + } + } + + /** Keep the cache in step with what was just sent, so the next read stays local. */ + private mergeCache(uid: string, patch: Record): void { + const cached = this.readCache(uid); + const data = mergePatch({...(cached?.data ?? {})} as Record, patch); + this.writeCache(uid, data as RemoteUserSettings); + } +} diff --git a/src/main.ts b/src/main.ts index 7ffa00f..4305413 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import {enableProdMode, importProvidersFrom} from '@angular/core'; +import {enableProdMode, importProvidersFrom, inject, provideAppInitializer} from '@angular/core'; import {environment} from './environments/environment'; import {RouteReuseStrategy} from '@angular/router'; @@ -20,6 +20,7 @@ import {AppRoutingModule} from './app/app-routing.module'; import {WelcomePageModule} from './app/welcome/welcome.module'; import {ServiceWorkerModule} from '@angular/service-worker'; import {AppComponent} from './app/app.component'; +import {PomodoroSyncService} from './app/pomodoro-sync.service'; import Aura from '@primeuix/themes/aura'; if (environment.production) { @@ -44,6 +45,10 @@ bootstrapApplication(AppComponent, { ScreenTrackingService, UserTrackingService, provideAnimationsAsync(), + // Started with the app, so the Pomodoro preferences follow the reader whichever page opens first + provideAppInitializer(() => { + inject(PomodoroSyncService); + }), providePrimeNG({ theme: { preset: Aura, From 106daf855a0be04b235375bbaa46e38eaf38d0cc Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Thu, 8 Oct 2026 13:12:56 +0700 Subject: [PATCH 17/20] feat: start with the Pomodoro and heatmap switched off - hide both widgets on a device with no saved settings, and again after Restore default settings - apply the same default in the boot script so neither flashes in before Angular starts - keep the theme button on by default; a saved choice still wins --- src/app/pomodoro.service.spec.ts | 1 + src/app/settings.service.spec.ts | 10 ++++++++++ src/app/settings/sanitize.ts | 2 +- src/app/settings/settings-presets.ts | 5 ++++- src/app/settings/settings.model.ts | 4 ++-- src/app/settings/settings.spec.ts | 8 +++++++- src/index.html | 11 ++++++++--- 7 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/app/pomodoro.service.spec.ts b/src/app/pomodoro.service.spec.ts index 7396e55..21d6b65 100644 --- a/src/app/pomodoro.service.spec.ts +++ b/src/app/pomodoro.service.spec.ts @@ -133,6 +133,7 @@ describe('PomodoroService', () => { it('pauses a running timer when the Pomodoro is switched off in settings', () => { const settings = TestBed.inject(SettingsService); + settings.setVisible('pomodoro', true); service.start(); settings.setVisible('pomodoro', false); diff --git a/src/app/settings.service.spec.ts b/src/app/settings.service.spec.ts index ff1abb0..51587aa 100644 --- a/src/app/settings.service.spec.ts +++ b/src/app/settings.service.spec.ts @@ -37,10 +37,20 @@ describe('SettingsService widget switches', () => { } } + it('starts a new device with the Pomodoro and heatmap switched off', () => { + const service = TestBed.inject(SettingsService); + + expect(service.isVisible('pomodoro')).toBeFalse(); + expect(service.isVisible('heatmap')).toBeFalse(); + expect(service.isVisible('appearance')).toBeTrue(); + expect(document.documentElement.getAttribute('data-hidden')).toBe('pomodoro heatmap'); + }); + it('hides only the widget that was switched off', () => { const service = TestBed.inject(SettingsService); const elements = WIDGETS.map(widget => document.body.appendChild(document.createElement(widget.selectors[0]))); try { + WIDGETS.forEach(widget => service.setVisible(widget.key, true)); service.setVisible(WIDGETS[0].key, false); elements.forEach((element, index) => { expect(getComputedStyle(element).display === 'none').toBe(index === 0); diff --git a/src/app/settings/sanitize.ts b/src/app/settings/sanitize.ts index c9f91bf..3b583f7 100644 --- a/src/app/settings/sanitize.ts +++ b/src/app/settings/sanitize.ts @@ -16,7 +16,7 @@ export function sanitizeSettings(raw: unknown): AppSettings { const hour = Number(value.pomodoroResetHour); return { - hidden: sanitizeKeys(value.hidden), + hidden: sanitizeKeys(Array.isArray(value.hidden) ? value.hidden : fallback.hidden), seen: sanitizeKeys(value.seen), fontId: FONT_OPTIONS.some(font => font.id === value.fontId) ? value.fontId as string diff --git a/src/app/settings/settings-presets.ts b/src/app/settings/settings-presets.ts index fddf58f..d1a9ad8 100644 --- a/src/app/settings/settings-presets.ts +++ b/src/app/settings/settings-presets.ts @@ -31,6 +31,9 @@ export const WIDGETS: WidgetOption[] = [ }, ]; +/** Switched off until the reader turns them on. Keep in step with the boot script in index.html. */ +export const DEFAULT_HIDDEN = ['pomodoro', 'heatmap']; + /** A Pomodoro day starts here rather than at midnight, so a late night counts as one sitting. */ export const DEFAULT_RESET_HOUR = 4; @@ -62,7 +65,7 @@ export const RESET_HOURS: ResetHourOption[] = Array.from({length: 24}, (unused, export function defaultSettings(): AppSettings { return { - hidden: [], + hidden: [...DEFAULT_HIDDEN], seen: [], fontId: DEFAULT_FONT_ID, pomodoroResetHour: DEFAULT_RESET_HOUR, diff --git a/src/app/settings/settings.model.ts b/src/app/settings/settings.model.ts index d2fe38b..4971b79 100644 --- a/src/app/settings/settings.model.ts +++ b/src/app/settings/settings.model.ts @@ -35,8 +35,8 @@ export interface SessionModeOption { /** Everything the settings sheet controls, as kept on this device. */ export interface AppSettings { /** - * Widget keys the user switched off. Absent means visible, so a widget added by a later - * release is on by default and needs no migration. + * Widget keys switched off, starting from DEFAULT_HIDDEN. Absent means visible, so a widget + * added by a later release is on by default and needs no migration. */ hidden: string[]; /** Widget keys whose element has been seen at least once on this device. */ diff --git a/src/app/settings/settings.spec.ts b/src/app/settings/settings.spec.ts index f6a5225..e06303f 100644 --- a/src/app/settings/settings.spec.ts +++ b/src/app/settings/settings.spec.ts @@ -1,5 +1,5 @@ import {sanitizeSettings} from './sanitize'; -import {defaultSettings, DEFAULT_RESET_HOUR} from './settings-presets'; +import {defaultSettings, DEFAULT_HIDDEN, DEFAULT_RESET_HOUR} from './settings-presets'; import {nextResetAt, studyDayKey, toDateKey} from './day'; describe('sanitizeSettings', () => { @@ -15,6 +15,12 @@ describe('sanitizeSettings', () => { expect(settings.seen).toEqual([]); }); + it('switches off the Pomodoro and heatmap when nothing was saved', () => { + expect(sanitizeSettings(null).hidden).toEqual(DEFAULT_HIDDEN); + expect(sanitizeSettings({fontId: 'mali'}).hidden).toEqual(DEFAULT_HIDDEN); + expect(sanitizeSettings({hidden: []}).hidden).toEqual([]); + }); + it('treats a widget missing from `hidden` as visible', () => { expect(sanitizeSettings({hidden: ['pomodoro']}).hidden).not.toContain('heatmap'); }); diff --git a/src/index.html b/src/index.html index ce1afa0..737d937 100644 --- a/src/index.html +++ b/src/index.html @@ -23,13 +23,18 @@