This commit is contained in:
1 parent
fe9e75a4e7
commit
bbfcbe74af
16 files changed
+920
-17
No files matched your search
@@ -0,0 +1,92 @@
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { getVoiceOn } from './store';
|
||||
|
||||
// Unified audio layer: native TTS on Android (Web Speech is unreliable
|
||||
// inside the WebView), Web Speech fallback on desktop web.
|
||||
// All entry points are safe no-ops when muted or unavailable.
|
||||
|
||||
const isNative = () => {
|
||||
try {
|
||||
return Capacitor.isNativePlatform();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
async function nativeSpeak(text: string): Promise<boolean> {
|
||||
try {
|
||||
const mod = await import('@capacitor-community/text-to-speech');
|
||||
const TTS = mod.TextToSpeech;
|
||||
await TTS.speak({ text, lang: 'en-US', rate: 1.0, queueStrategy: mod.QueueStrategy.Flush });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function webSpeak(text: string): boolean {
|
||||
try {
|
||||
if (!('speechSynthesis' in window)) return false;
|
||||
window.speechSynthesis.cancel();
|
||||
const u = new SpeechSynthesisUtterance(text);
|
||||
u.rate = 1.02;
|
||||
window.speechSynthesis.speak(u);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function speak(text: string): Promise<void> {
|
||||
if (!getVoiceOn()) return;
|
||||
if (isNative()) {
|
||||
const ok = await nativeSpeak(text);
|
||||
if (ok) return;
|
||||
}
|
||||
webSpeak(text);
|
||||
}
|
||||
|
||||
export async function stopSpeak(): Promise<void> {
|
||||
try {
|
||||
if (isNative()) {
|
||||
const mod = await import('@capacitor-community/text-to-speech');
|
||||
await mod.TextToSpeech.stop();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to web */
|
||||
}
|
||||
try {
|
||||
if ('speechSynthesis' in window) window.speechSynthesis.cancel();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
// Short countdown beeps for the last seconds of a step (foreground only;
|
||||
// background warnings go through scheduled notifications instead).
|
||||
let beepCtx: AudioContext | null = null;
|
||||
export function beep(times = 1, freq = 880): void {
|
||||
if (!getVoiceOn()) return;
|
||||
try {
|
||||
const Ctx =
|
||||
window.AudioContext ||
|
||||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
beepCtx ??= new Ctx();
|
||||
const ctx = beepCtx;
|
||||
if (ctx.state === 'suspended') void ctx.resume();
|
||||
for (let i = 0; i < times; i++) {
|
||||
const o = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
o.connect(g);
|
||||
g.connect(ctx.destination);
|
||||
o.frequency.value = freq;
|
||||
g.gain.value = 0.08;
|
||||
const t0 = ctx.currentTime + i * 0.35;
|
||||
o.start(t0);
|
||||
o.stop(t0 + 0.22);
|
||||
}
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Pure background-catch-up logic (no DB, no plugins — fully unit-tested).
|
||||
// The live timer records `endsAt` (epoch ms) when a step starts. When the
|
||||
// app returns from background, catchUp() replays the chain so missed steps
|
||||
// are marked done instead of silently freezing the timer.
|
||||
|
||||
export interface CatchStep {
|
||||
id: string;
|
||||
order: number;
|
||||
status: 'todo' | 'active' | 'done' | 'skipped';
|
||||
secondsLeft: number;
|
||||
minutes: number;
|
||||
endsAt?: number;
|
||||
}
|
||||
|
||||
export interface StepUpdate {
|
||||
id: string;
|
||||
status: 'todo' | 'active' | 'done';
|
||||
secondsLeft: number;
|
||||
}
|
||||
|
||||
export interface CatchResult {
|
||||
updates: StepUpdate[];
|
||||
finishedIds: string[];
|
||||
activeId: string | null;
|
||||
allDone: boolean;
|
||||
}
|
||||
|
||||
export function catchUp(steps: CatchStep[], nowMs: number): CatchResult {
|
||||
const ordered = [...steps].sort((a, b) => a.order - b.order);
|
||||
const updates: StepUpdate[] = [];
|
||||
const finishedIds: string[] = [];
|
||||
let activeId: string | null = null;
|
||||
// Walk the chain of unfinished steps, consuming elapsed wall-clock time.
|
||||
let cursor = -1;
|
||||
for (const s of ordered) {
|
||||
if (s.status === 'done' || s.status === 'skipped') continue;
|
||||
if (cursor === -1) {
|
||||
// First unfinished step: needs a recorded endsAt to infer anything.
|
||||
if (s.endsAt == null) {
|
||||
activeId = s.id;
|
||||
break; // paused — nothing to catch up
|
||||
}
|
||||
cursor = s.endsAt;
|
||||
} else {
|
||||
// Prefer live secondsLeft (accounts for +1 min / partial consumption);
|
||||
// fall back to full minutes for untouched steps.
|
||||
cursor += Math.max(1000, (s.secondsLeft || s.minutes * 60) * 1000);
|
||||
}
|
||||
if (nowMs >= cursor) {
|
||||
updates.push({ id: s.id, status: 'done', secondsLeft: 0 });
|
||||
finishedIds.push(s.id);
|
||||
} else {
|
||||
const left = Math.max(1, Math.ceil((cursor - nowMs) / 1000));
|
||||
updates.push({ id: s.id, status: 'active', secondsLeft: left });
|
||||
activeId = s.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const remaining = ordered.filter(
|
||||
(s) => s.status !== 'done' && s.status !== 'skipped' && !finishedIds.includes(s.id),
|
||||
);
|
||||
const allDone = finishedIds.length > 0 && activeId === null && remaining.length === 0;
|
||||
return { updates, finishedIds, activeId, allDone };
|
||||
}
|
||||
|
||||
// Alarm schedule for a step chain starting at `fromOrder`, assuming the
|
||||
// step at fromOrder starts at `startEpochMs`. Pure + testable; the caller
|
||||
// maps these to LocalNotifications (or drops them on web).
|
||||
export interface Alarm {
|
||||
atMs: number;
|
||||
kind: 'warn' | 'end';
|
||||
stepId: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export const WARN_BEFORE_MS = 10000;
|
||||
|
||||
export function buildAlarms(
|
||||
steps: Pick<CatchStep, 'id' | 'order' | 'minutes' | 'secondsLeft' | 'status'>[],
|
||||
fromOrder: number,
|
||||
startEpochMs: number,
|
||||
): Alarm[] {
|
||||
const ordered = [...steps]
|
||||
.filter((s) => s.order >= fromOrder && s.status !== 'done' && s.status !== 'skipped')
|
||||
.sort((a, b) => a.order - b.order);
|
||||
const alarms: Alarm[] = [];
|
||||
let cursor = startEpochMs;
|
||||
for (const s of ordered) {
|
||||
const durMs = Math.max(1000, (s.secondsLeft || s.minutes * 60) * 1000);
|
||||
const endsAt = cursor + durMs;
|
||||
if (durMs > WARN_BEFORE_MS + 2000) {
|
||||
alarms.push({ atMs: endsAt - WARN_BEFORE_MS, kind: 'warn', stepId: s.id, order: s.order });
|
||||
}
|
||||
alarms.push({ atMs: endsAt, kind: 'end', stepId: s.id, order: s.order });
|
||||
cursor = endsAt;
|
||||
}
|
||||
return alarms;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||
|
||||
// Bridge to the native TimerService foreground service (#1).
|
||||
// Web: safe no-ops (Wake Lock + timestamp catch-up cover desktop).
|
||||
|
||||
interface TimerServicePlugin {
|
||||
start(opts: { title: string; text: string }): Promise<void>;
|
||||
update(opts: { title: string; text: string }): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
let cached: TimerServicePlugin | null = null;
|
||||
function native(): TimerServicePlugin | null {
|
||||
try {
|
||||
if (!Capacitor.isNativePlatform()) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
cached ??= registerPlugin<TimerServicePlugin>('TimerService');
|
||||
return cached;
|
||||
}
|
||||
|
||||
export async function fgStart(title: string, text: string): Promise<void> {
|
||||
try {
|
||||
await native()?.start({ title, text });
|
||||
} catch {
|
||||
/* service unavailable — alarms + catch-up still cover background */
|
||||
}
|
||||
}
|
||||
|
||||
export async function fgUpdate(text: string, title = 'Mindlist focus'): Promise<void> {
|
||||
try {
|
||||
await native()?.update({ title, text });
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
export async function fgStop(): Promise<void> {
|
||||
try {
|
||||
await native()?.stop();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import type { Alarm } from './catchup';
|
||||
|
||||
// Local-notification alarms for step boundaries. They fire even when the
|
||||
// WebView is suspended (locked phone), which is exactly the gap in #1.
|
||||
// Web fallback: silent no-ops (in-foreground beeps cover desktop).
|
||||
|
||||
const isNative = () => {
|
||||
try {
|
||||
return Capacitor.isNativePlatform();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const isNativePlatform = isNative;
|
||||
|
||||
async function ln() {
|
||||
const mod = await import('@capacitor/local-notifications');
|
||||
return mod.LocalNotifications;
|
||||
}
|
||||
|
||||
// Deterministic notification IDs so rescheduling never duplicates.
|
||||
export function alarmId(stepId: string, kind: 'warn' | 'end'): number {
|
||||
let h = kind === 'warn' ? 7 : 13;
|
||||
const s = `${stepId}:${kind}`;
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
return Math.abs(h) % 2000000000;
|
||||
}
|
||||
|
||||
export async function ensureNotifPermission(): Promise<boolean> {
|
||||
if (!isNative()) return false;
|
||||
try {
|
||||
const L = await ln();
|
||||
const cur = await L.checkPermissions();
|
||||
if (cur.display === 'granted') return true;
|
||||
const req = await L.requestPermissions();
|
||||
return req.display === 'granted';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Exact alarms need the system "Alarms & reminders" grant (API 31+).
|
||||
// Returns true when exact scheduling is available. Opens the settings
|
||||
// screen once when missing (plugin behavior); caller continues with
|
||||
// inexact fallback + timestamp catch-up for that chain.
|
||||
export async function ensureExactAlarms(): Promise<boolean> {
|
||||
if (!isNative()) return false;
|
||||
try {
|
||||
const L = await ln();
|
||||
const st = await L.checkExactNotificationSetting();
|
||||
if (st.exact_alarm === 'granted') return true;
|
||||
// Ask at most once per install — the system screen yanks the user out
|
||||
// of focus, so never nag on every step.
|
||||
if (localStorage.getItem('mindlist.exactAsked') === 'yes') return false;
|
||||
localStorage.setItem('mindlist.exactAsked', 'yes');
|
||||
await L.changeExactNotificationSetting();
|
||||
const again = await L.checkExactNotificationSetting();
|
||||
return again.exact_alarm === 'granted';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function scheduleAlarms(
|
||||
alarms: Alarm[],
|
||||
textFor: (a: Alarm) => { title: string; body: string },
|
||||
): Promise<void> {
|
||||
if (!isNative() || alarms.length === 0) return;
|
||||
try {
|
||||
const L = await ln();
|
||||
await L.schedule({
|
||||
notifications: alarms.map((a) => {
|
||||
const { title, body } = textFor(a);
|
||||
return {
|
||||
id: alarmId(a.stepId, a.kind),
|
||||
title,
|
||||
body,
|
||||
schedule: { at: new Date(a.atMs), allowWhileIdle: true },
|
||||
sound: undefined, // default notification sound
|
||||
smallIcon: 'ic_stat_timer',
|
||||
};
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
/* notifications unavailable — timer catch-up still covers resume */
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelAlarmsFor(stepIds: string[]): Promise<void> {
|
||||
if (!isNative() || stepIds.length === 0) return;
|
||||
try {
|
||||
const L = await ln();
|
||||
const ids = stepIds.flatMap((id) => [alarmId(id, 'warn' as const), alarmId(id, 'end' as const)]);
|
||||
await L.cancel({ notifications: ids.map((id) => ({ id })) });
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelAllAlarms(): Promise<void> {
|
||||
if (!isNative()) return;
|
||||
try {
|
||||
const L = await ln();
|
||||
const pending = await L.getPending();
|
||||
if (pending.notifications.length > 0) {
|
||||
await L.cancel({
|
||||
notifications: pending.notifications.map((n) => ({ id: n.id })),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ export interface Step {
|
||||
minutes: number;
|
||||
secondsLeft: number;
|
||||
status: 'todo' | 'active' | 'done' | 'skipped';
|
||||
/** Wall-clock epoch ms when the running step ends. Set on start/resume,
|
||||
* cleared on pause — drives background catch-up (#1). Unindexed. */
|
||||
endsAt?: number;
|
||||
}
|
||||
|
||||
class DB extends Dexie {
|
||||
|
||||
Reference in new issue
Block a user