diff --git a/angular.json b/angular.json index 56a3a5b..8cc757d 100644 --- a/angular.json +++ b/angular.json @@ -35,6 +35,9 @@ }, { "input": "src/global.scss" + }, + { + "input": "src/settings.scss" } ], "scripts": [], @@ -123,7 +126,9 @@ ], "tsConfig": "tsconfig.spec.json", "karmaConfig": "karma.conf.js", - "styles": [], + "styles": [ + "src/settings.scss" + ], "scripts": [], "assets": [ { diff --git a/src/app/home/course/course.page.html b/src/app/home/course/course.page.html index d2fb4d4..0279c38 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 358942f..2624fb2 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'; +import {SettingsMenuComponent} from '../../shared/settings-menu.component'; import {DomSanitizer} from '@angular/platform-browser'; import {PlayHistory} from '../../play-tracker.service'; import {addIcons} from "ionicons"; @@ -59,6 +61,7 @@ interface SeekBarPointerHandlers { IonHeader, IonToolbar, IonBackButton, + IonButtons, IonTitle, IonContent, IonGrid, @@ -82,6 +85,7 @@ interface SeekBarPointerHandlers { DecimalPipe, DatePipe, PomodoroTimerComponent, + SettingsMenuComponent, ] }) export class CoursePage implements OnInit, AfterViewInit, OnDestroy { @@ -192,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 d5f13e1..cc4fd62 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -4,6 +4,7 @@ MDCU Recorded Lecture + Sign out @@ -126,6 +127,7 @@

{{ result.title }}

} } @else if (response$ | async; as response) { + @if (response.last_played) { @@ -161,7 +163,7 @@

{{ response.last_played.video.course.name }}

aria-label="Open your activity for the past year">
- Your activity · last 7 days + Your activity · {{ activityRange.label.toLowerCase() }} @if (recentTotals.actual) { {{ formatDuration(recentTotals.video) }} video · {{ formatDuration(recentTotals.actual) }} actual @@ -170,19 +172,35 @@

{{ response.last_played.video.course.name }}

}
-
- @for (row of recentRows; track row.date) { - {{ row.label }} - @for (cell of row.cells; track $index) { - @if (cell) { - - } @else { - + @if (activityWeeks.length) { +
+ @for (week of activityWeeks; track $index) { + {{ week.monthLabel }} + @for (day of week.days; track $index) { + @if (day) { + + } @else { + + } } } - } -
+
+ } @else { +
+ @for (row of recentRows; track row.date) { + {{ row.label }} + @for (cell of row.cells; track $index) { + @if (cell) { + + } @else { + + } + } + } +
+ }
Tap to see the whole year
diff --git a/src/app/home/home.page.scss b/src/app/home/home.page.scss index 882a955..d89cd56 100644 --- a/src/app/home/home.page.scss +++ b/src/app/home/home.page.scss @@ -8,6 +8,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; } @@ -297,3 +307,27 @@ p { .recent-heatmap { @include hour-grid; } + +// A longer period chosen in the settings: a day a square, a column a week with Sunday on top, +// under the month that starts in it. Columns shrink to fit a year on the card. +.period-heatmap { + display: grid; + grid-template-rows: auto repeat(7, auto); + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1rem); + gap: 2px; + justify-content: center; + + .month { + font-size: 0.6rem; + line-height: 1.2; + color: var(--ion-color-medium); + white-space: nowrap; + } + + .cell { + aspect-ratio: 1; + border-radius: 2px; + @include heat-levels; + } +} diff --git a/src/app/home/home.page.spec.ts b/src/app/home/home.page.spec.ts index 58ae344..8352a68 100644 --- a/src/app/home/home.page.spec.ts +++ b/src/app/home/home.page.spec.ts @@ -2,16 +2,19 @@ import {TestBed, fakeAsync, tick} from '@angular/core/testing'; import {Router} from '@angular/router'; import {AlertController, ModalController} from '@ionic/angular'; import {Analytics} from '@angular/fire/analytics'; -import {of, throwError} from 'rxjs'; +import {BehaviorSubject, of, throwError} from 'rxjs'; import {HomePage, SearchResults} from './home.page'; -import {ManService} from '../man.service'; +import {ManService, PlayStats} from '../man.service'; import {AuthService} from '../auth.service'; import {ConsentService} from '../consent.service'; +import {SettingsService} from '../settings.service'; +import {ActivityRange} from '../settings/settings.model'; describe('HomePage', () => { let component: HomePage; let manService: jasmine.SpyObj>; let router: jasmine.SpyObj>; + let activityRange$: BehaviorSubject; const videoList = { years: { @@ -26,6 +29,7 @@ describe('HomePage', () => { manService.getVideoList.and.returnValue(of(videoList)); manService.getPlayStats.and.returnValue(of(null)); router = jasmine.createSpyObj('Router', ['navigate']); + activityRange$ = new BehaviorSubject('7d'); TestBed.configureTestingModule({ providers: [ @@ -37,6 +41,7 @@ describe('HomePage', () => { {provide: ConsentService, useValue: {current: 'unset'}}, {provide: ModalController, useValue: {}}, {provide: AlertController, useValue: {}}, + {provide: SettingsService, useValue: {activityRange$}}, ], }); component = TestBed.inject(HomePage); @@ -134,4 +139,41 @@ describe('HomePage', () => { expect(component.isSearching).toBe(false); expect(manService.searchVideos).not.toHaveBeenCalled(); })); + describe('activity heatmap', () => { + const stats: PlayStats = { + from: '2025-10-11', + to: '2026-10-10', + updated_at: '2026-10-10T12:00:00+07:00', + total: {sessions: 2, video_seconds: 5400, actual_seconds: 3600}, + days: [ + {date: '2026-09-01', sessions: 1, video_seconds: 1800, actual_seconds: 1200}, + {date: '2026-10-09', sessions: 1, video_seconds: 3600, actual_seconds: 2400}, + ], + recent_hours: [{date: '2026-10-09', hour: 9, sessions: 1, video_seconds: 3600, actual_seconds: 2400}], + }; + + beforeEach(() => manService.getPlayStats.and.returnValue(of(stats))); + + it('shows the last 7 days by the hour until a longer period is chosen', () => { + component.ionViewWillEnter(); + + expect(component.recentRows.length).toBe(7); + expect(component.activityWeeks).toEqual([]); + expect(component.recentTotals).toEqual({video: 3600, actual: 2400}); + }); + + it('shows the period chosen in the settings a day a square', () => { + component.ionViewWillEnter(); + activityRange$.next('1m'); + + expect(component.activityRange.label).toBe('Last month'); + expect(component.recentRows).toEqual([]); + // 11 September to 10 October; the first of September falls outside it + expect(component.activityWeeks.flatMap(week => week.days).filter(day => day).length).toBe(30); + expect(component.recentTotals).toEqual({video: 3600, actual: 2400}); + + activityRange$.next('1y'); + expect(component.recentTotals).toEqual({video: 5400, actual: 3600}); + }); + }); }); diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index 8d558d0..e2f4fa3 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -2,6 +2,11 @@ import {Component, inject, OnInit} from '@angular/core'; import {Observable, of, Subject, timer} from 'rxjs'; import {CourseListResponse, DocumentSearchHit, Lecture, ManService, PlayStats, 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 {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {SettingsService} from '../settings.service'; +import {ACTIVITY_RANGES, DEFAULT_ACTIVITY_RANGE} from '../settings/settings-presets'; import {AuthService} from '../auth.service'; import {colorByFolderName, formatDuration, snippetToHtml} from '../../helpers'; import {addIcons} from "ionicons"; @@ -38,7 +43,7 @@ import {Analytics, logEvent} from '@angular/fire/analytics'; import {ConsentService} from '../consent.service'; import {confirmAiDisclaimer, ModalDocumentComponent} from './course/modal-document.component'; import {ModalPlayStatsComponent} from './modal-play-stats.component'; -import {buildRecentGrid, formatDay, RecentRow, serverNow} from './play-stats'; +import {buildRecentGrid, buildYearGrid, formatDay, rangeStart, RecentRow, serverNow, sumFrom, WeekColumn} from './play-stats'; // `title` searches videos by title, lecturer or date; `content` searches the AI-generated transcript documents. export type SearchMode = 'title' | 'content'; @@ -64,6 +69,7 @@ export type SearchResults = styleUrls: ['home.page.scss'], imports: [ IonHeader, IonToolbar, IonTitle, IonButtons, IonButton, IonIcon, + SettingsMenuComponent, PomodoroTimerComponent, IonContent, IonGrid, IonRow, IonCol, IonCard, RouterLink, NgStyle, IonCardHeader, IonCardTitle, AsyncPipe, DatePipe, IonCardContent, IonItem, IonLabel, IonText, IonSpinner, IonSearchbar, IonList, IonSegment, IonSegmentButton, @@ -77,12 +83,17 @@ export class HomePage implements OnInit { private consentService = inject(ConsentService); private modalCtrl = inject(ModalController); private alertController = inject(AlertController); + private settings = inject(SettingsService); response$: Observable; playStats: PlayStats | null = null; recentRows: RecentRow[] = []; - /** Totals of the 7-day grid, in seconds */ + /** The days of a longer period, a column a week; empty while the last 7 days are shown by the hour */ + activityWeeks: WeekColumn[] = []; + /** Totals of the grid shown, in seconds */ recentTotals = {video: 0, actual: 0}; + /** The period the grid covers, chosen in the settings */ + activityRange = ACTIVITY_RANGES.find(range => range.value === DEFAULT_ACTIVITY_RANGE); searchQuery = ''; searchMode: SearchMode = 'title'; // The mode switch is only shown while the search box is focused or holds a query @@ -97,6 +108,10 @@ export class HomePage implements OnInit { constructor() { addIcons({documentTextOutline, filmOutline, logOutOutline, playOutline, searchOutline}); + this.settings.activityRange$.pipe(takeUntilDestroyed()).subscribe(value => { + this.activityRange = ACTIVITY_RANGES.find(range => range.value === value); + this.showActivity(); + }); } logout() { @@ -109,15 +124,31 @@ export class HomePage implements OnInit { ionViewWillEnter() { this.manService.getPlayStats().pipe(catchError(() => of(null))).subscribe(stats => { this.playStats = stats; - this.recentRows = stats ? buildRecentGrid(stats, undefined, serverNow(stats)) : []; - const cells = this.recentRows.flatMap(row => row.cells).filter(c => c); - this.recentTotals = { - video: cells.reduce((sum, c) => sum + c.video_seconds, 0), - actual: cells.reduce((sum, c) => sum + c.actual_seconds, 0), - }; + this.showActivity(); }); } + /** The last 7 days by the hour, or a longer period by the day */ + private showActivity() { + const stats = this.playStats; + const months = this.activityRange.months; + if (stats && months !== null) { + const from = rangeStart(stats, months); + const total = sumFrom(stats, from); + this.recentRows = []; + this.activityWeeks = buildYearGrid(stats, undefined, from); + this.recentTotals = {video: total.video_seconds, actual: total.actual_seconds}; + return; + } + this.activityWeeks = []; + this.recentRows = stats ? buildRecentGrid(stats, undefined, serverNow(stats)) : []; + const cells = this.recentRows.flatMap(row => row.cells).filter(c => c); + this.recentTotals = { + video: cells.reduce((sum, c) => sum + c.video_seconds, 0), + actual: cells.reduce((sum, c) => sum + c.actual_seconds, 0), + }; + } + ngOnInit() { this.response$ = this.manService.getVideoList(); 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'}} + + + { + 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/pomodoro.service.spec.ts b/src/app/pomodoro.service.spec.ts index 5c2b697..c44fe88 100644 --- a/src/app/pomodoro.service.spec.ts +++ b/src/app/pomodoro.service.spec.ts @@ -5,6 +5,8 @@ import { DEFAULT_DURATIONS, 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; @@ -26,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', () => { @@ -69,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, @@ -105,11 +105,41 @@ 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 Flick only', () => { + 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); + settings.setVisible('pomodoro', true); + 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/pomodoro.service.ts b/src/app/pomodoro.service.ts index 3b2ca5c..3487960 100644 --- a/src/app/pomodoro.service.ts +++ b/src/app/pomodoro.service.ts @@ -1,8 +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 { @@ -13,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; @@ -64,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 { @@ -84,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_'; @@ -91,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'); @@ -100,19 +99,31 @@ export class PomodoroService { private completedSessions = new BehaviorSubject(0); private studySessionsInCycle = 0; - /** Notification stream */ - private phaseNotificationSubject = new Subject(); - phaseNotification$: Observable = this.phaseNotificationSubject.asObservable(); + /** 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); + + /** 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( @@ -122,6 +133,23 @@ export class PomodoroService { constructor() { this.loadPreferences(this.currentUserId); this.restoreTimerState(this.currentUserId); + this.registerVisibilityHandlers(); + + 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(); + } + }); + // This timer acts on the choice, so the settings sheet can offer it. + this.sessionModes.markSupported(); } /** Request browser system notification permissions */ @@ -142,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 */ @@ -153,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}; @@ -171,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()); @@ -180,9 +224,8 @@ export class PomodoroService { } } - this.timerState.next('running'); - this.startTicking(); - this.saveTimerState(); + this.pausedByVisibility = false; + this.startRunning(); } /** Pause the timer */ @@ -190,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(); } @@ -200,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 */ @@ -225,11 +288,31 @@ export class PomodoroService { /** Get the sessions before long break count */ getSessionsBeforeLongBreak(): number { - return this.sessionsBeforeLongBreak; + return this.sessionsBeforeLongBreak.value; } // ────────────────────────── 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]; } @@ -254,19 +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(() => { - const remaining = this.timeRemaining.value - 1; + if (Date.now() >= this.nextDayAt) { + this.rollOverDay(); + } + + 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(); } } @@ -280,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 { @@ -297,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') { @@ -323,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 + } + } + + private closeAudioContext(): void { + if (this.audioContext) { + try { + void this.audioContext.close(); + } catch { + // Ignore + } + this.audioContext = null; + } + } - const now = this.audioContext.currentTime; + /** 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 = ctx.createOscillator(); + const osc2 = ctx.createOscillator(); + const gain = ctx.createGain(); - // Two-tone chime (high-low sequence) - const osc1 = this.audioContext.createOscillator(); - const osc2 = this.audioContext.createOscillator(); - const gain = this.audioContext.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(ctx.destination); - osc1.connect(gain); - osc2.connect(gain); - gain.connect(this.audioContext.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 } @@ -382,7 +536,7 @@ export class PomodoroService { } } if (typeof parsed.sessionsBeforeLongBreak === 'number' && parsed.sessionsBeforeLongBreak > 0) { - this.sessionsBeforeLongBreak = parsed.sessionsBeforeLongBreak; + this.sessionsBeforeLongBreak.next(parsed.sessionsBeforeLongBreak); } } } catch { @@ -395,7 +549,7 @@ export class PomodoroService { this.STORAGE_KEY_PREFIX + this.currentUserId, JSON.stringify({ durations: this.durations, - sessionsBeforeLongBreak: this.sessionsBeforeLongBreak, + sessionsBeforeLongBreak: this.sessionsBeforeLongBreak.value, }), ); } @@ -411,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 { @@ -430,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); @@ -442,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/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.spec.ts b/src/app/settings.service.spec.ts new file mode 100644 index 0000000..07e4e63 --- /dev/null +++ b/src/app/settings.service.spec.ts @@ -0,0 +1,72 @@ +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. + */ +/** An element the selector matches: a tag name, with any classes after it (`ion-card.play-stats-card`). */ +function elementFor(selector: string): HTMLElement { + const [tag, ...classes] = selector.split('.'); + const element = document.createElement(tag); + element.classList.add(...classes); + + return element; +} + +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(elementFor(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('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('activity')).toBeTrue(); + 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(elementFor(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); + }); + } finally { + elements.forEach(element => element.remove()); + } + }); +}); diff --git a/src/app/settings.service.ts b/src/app/settings.service.ts new file mode 100644 index 0000000..68ac8fd --- /dev/null +++ b/src/app/settings.service.ts @@ -0,0 +1,235 @@ +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, previewRules} from './settings/fonts'; +import {ACTIVITY_RANGES, 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 {ActivityRange, 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'; +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 + * 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; + readonly activityRanges = ACTIVITY_RANGES; + + private sessionModes = inject(PomodoroSessionModeService); + + 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'); + readonly activityRange$: Observable = this.settings$.pipe( + map(settings => settings.activityRange), + distinctUntilChanged(), + ); + readonly activityAvailable$: Observable = this.available$('activity'); + + 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())); + subscription.add(inject(RestoreDefaultsService).restore$.subscribe(() => this.reset())); + 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}); + } + + setActivityRange(activityRange: ActivityRange): void { + this.update({activityRange}); + } + + 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()); + if (!document.getElementById(PREVIEW_STYLE_ID)) { + const style = document.createElement('style'); + style.id = PREVIEW_STYLE_ID; + style.textContent = previewRules(); + document.head.appendChild(style); + } + } + + /** + * 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}); + this.sessionModes.setMode(DEFAULT_SESSION_MODE); + } + + 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..b3b2d0f --- /dev/null +++ b/src/app/settings/fonts.ts @@ -0,0 +1,84 @@ +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'; +} + +/** 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 + * 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..348b8f4 --- /dev/null +++ b/src/app/settings/sanitize.ts @@ -0,0 +1,28 @@ +import {FONT_OPTIONS} from './fonts'; +import {ACTIVITY_RANGES, 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(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 + : fallback.fontId, + pomodoroResetHour: Number.isInteger(hour) && hour >= 0 && hour < 24 ? hour : fallback.pomodoroResetHour, + pomodoroDayKey: typeof value.pomodoroDayKey === 'string' ? value.pomodoroDayKey : '', + activityRange: ACTIVITY_RANGES.find(range => range.value === value.activityRange)?.value ?? fallback.activityRange, + }; +} diff --git a/src/app/settings/settings-presets.ts b/src/app/settings/settings-presets.ts new file mode 100644 index 0000000..ee2c768 --- /dev/null +++ b/src/app/settings/settings-presets.ts @@ -0,0 +1,93 @@ +import {ActivityRange, ActivityRangeOption, AppSettings, ResetHourOption, SessionModeOption, 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 for study sessions.', + 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: 'activity', + label: 'Activity heatmap', + description: 'Your last 7 days of viewing, on the home page.', + icon: 'grid-outline', + selectors: ['ion-card.play-stats-card'], + }, + { + key: 'appearance', + label: 'Theme button', + description: 'The appearance control in the toolbar.', + icon: 'color-palette-outline', + selectors: ['app-theme-menu', 'app-theme-dropdown'], + }, +]; + +/** 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; + +function formatHour(hour: number): string { + const suffix = hour < 12 ? 'AM' : 'PM'; + const display = hour % 12 === 0 ? 12 : hour % 12; + + 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: 'Flick only', + hint: 'The timer pauses when you leave this tab, only time spent on Flick 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), +})); + +/** The periods the activity heatmap on the home page can cover, longest first as in the activity window. */ +export const ACTIVITY_RANGES: ActivityRangeOption[] = [ + {value: '1y', label: 'Last year', months: 12}, + {value: '6m', label: 'Last 6 months', months: 6}, + {value: '1m', label: 'Last month', months: 1}, + {value: '7d', label: 'Last 7 days', months: null}, +]; + +/** What the home page showed before the period could be chosen. */ +export const DEFAULT_ACTIVITY_RANGE: ActivityRange = '7d'; + +export function defaultSettings(): AppSettings { + return { + hidden: [...DEFAULT_HIDDEN], + seen: [], + fontId: DEFAULT_FONT_ID, + pomodoroResetHour: DEFAULT_RESET_HOUR, + pomodoroDayKey: '', + activityRange: DEFAULT_ACTIVITY_RANGE, + }; +} diff --git a/src/app/settings/settings.model.ts b/src/app/settings/settings.model.ts new file mode 100644 index 0000000..599b250 --- /dev/null +++ b/src/app/settings/settings.model.ts @@ -0,0 +1,67 @@ +import {SessionMode} from '../pomodoro-session-mode.service'; + +/** 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; +} + +export interface SessionModeOption { + value: SessionMode; + label: string; + hint: string; +} + +/** How much of the reader's viewing the activity heatmap on the home page covers. */ +export type ActivityRange = '1y' | '6m' | '1m' | '7d'; + +export interface ActivityRangeOption { + value: ActivityRange; + label: string; + /** Months back from today, as the activity window counts them; null for the last 7 days, which are shown by the hour. */ + months: number | null; +} + +/** Everything the settings sheet controls, as kept on this device. */ +export interface AppSettings { + /** + * 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. */ + seen: string[]; + fontId: string; + pomodoroResetHour: number; + /** The study day the Pomodoro session count currently belongs to. */ + pomodoroDayKey: string; + activityRange: ActivityRange; +} + +/** 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..118b36c --- /dev/null +++ b/src/app/settings/settings.spec.ts @@ -0,0 +1,75 @@ +import {sanitizeSettings} from './sanitize'; +import {DEFAULT_ACTIVITY_RANGE, defaultSettings, DEFAULT_HIDDEN, 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('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'); + }); + + 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('keeps only a heatmap period this build offers', () => { + expect(sanitizeSettings({activityRange: '6m'}).activityRange).toBe('6m'); + expect(sanitizeSettings({activityRange: '2y'}).activityRange).toBe(DEFAULT_ACTIVITY_RANGE); + expect(sanitizeSettings(null).activityRange).toBe('7d'); + }); + + 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()); + }); +}); 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 571496e..b4859b3 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'; -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 df0ad47..e4f768a 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'; -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/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..4ddef87 --- /dev/null +++ b/src/app/shared/settings-menu.component.ts @@ -0,0 +1,31 @@ +import {Component, inject} from '@angular/core'; +import {IonButton, IonIcon, ModalController} from '@ionic/angular'; +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, + // 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(); + } +} diff --git a/src/app/shared/settings-sheet.component.html b/src/app/shared/settings-sheet.component.html new file mode 100644 index 0000000..adffe53 --- /dev/null +++ b/src/app/shared/settings-sheet.component.html @@ -0,0 +1,117 @@ + + + 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, on this device only.

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

{{ sample }}

+ } +
+
+ + @if (pomodoroAvailable$ | async) { +
+

Pomodoro

+

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

+ + + + @for (option of settingsService.resetHours; track option.value) { + {{ option.label }} + } + + +

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) }}

+
+ } +
+
+ } + + @if (activityAvailable$ | async) { +
+

Activity heatmap

+

How much of your viewing the home page shows, on this device only.

+ + + + @for (option of settingsService.activityRanges; track option.value) { + {{ option.label }} + } + + +

{{ settings.activityRange === '7d' ? 'Each square is an hour.' : 'Each square is a day, with a column for each week.' }}

+
+
+ } + + Restore default settings + } +
diff --git a/src/app/shared/settings-sheet.component.scss b/src/app/shared/settings-sheet.component.scss new file mode 100644 index 0000000..5638741 --- /dev/null +++ b/src/app/shared/settings-sheet.component.scss @@ -0,0 +1,105 @@ +.settings-section { + 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 { + margin: 0 0 0.15rem; + font-size: 1rem; + font-weight: 600; +} + +.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 { + 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'] { + margin-inline-end: 0.75rem; + font-size: 1.25rem; + opacity: 0.7; +} + +.widget-toggle { + width: 100%; +} + +.widget-label { + display: block; + font-size: 0.95rem; + font-weight: 600; +} + +.widget-hint { + 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 { + padding-top: 0.15rem; + font-size: 1.05rem; + 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; + 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..2b7000d --- /dev/null +++ b/src/app/shared/settings-sheet.component.ts @@ -0,0 +1,135 @@ +import {Component, inject} from '@angular/core'; +import {AsyncPipe} from '@angular/common'; +import { + IonButton, + IonButtons, + IonContent, + IonHeader, + IonIcon, + IonItem, + IonLabel, + IonList, + IonSegment, + IonSegmentButton, + IonSelect, + IonSelectOption, + IonTitle, + IonToggle, + IonToolbar, + ModalController, +} from '@ionic/angular'; +import {addIcons} from 'ionicons'; +import { + calendarOutline, + close, + colorPaletteOutline, + gridOutline, + timerOutline, +} 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 {ActivityRange, 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 { + 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, + IonSegment, + IonSegmentButton, + IonSelect, + IonSelectOption, + AsyncPipe, + ], +}) +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$; + protected readonly availableWidgets$ = this.settingsService.availableWidgets$; + protected readonly pomodoroAvailable$ = this.settingsService.pomodoroAvailable$; + protected readonly activityAvailable$ = this.settingsService.activityAvailable$; + 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'}; + + constructor() { + addIcons({close, timerOutline, calendarOutline, gridOutline, 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)); + } + + onFontChange(event: Event): void { + this.settingsService.setFont(detailValue(event)); + } + + onResetHourChange(event: Event): void { + this.settingsService.setPomodoroResetHour(detailValue(event)); + } + + onActivityRangeChange(event: Event): void { + this.settingsService.setActivityRange(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(); + } +} 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 diff --git a/src/index.html b/src/index.html index bbcc773..737d937 100644 --- a/src/index.html +++ b/src/index.html @@ -18,6 +18,42 @@ + + + diff --git a/src/settings.scss b/src/settings.scss new file mode 100644 index 0000000..b33c648 --- /dev/null +++ b/src/settings.scss @@ -0,0 +1,35 @@ +/* + * 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~='activity'] ion-card.play-stats-card, +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)); +}