diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index bbfb44f..2a726af 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -9,7 +9,9 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { - + implementation project(':capacitor-community-text-to-speech') + implementation project(':capacitor-app') + implementation project(':capacitor-local-notifications') } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b06ddbf..3b1ea86 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -33,9 +33,22 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths"> + + + + + + + + diff --git a/android/app/src/main/java/app/mindlist/foss/MainActivity.java b/android/app/src/main/java/app/mindlist/foss/MainActivity.java index 2459a02..1283213 100644 --- a/android/app/src/main/java/app/mindlist/foss/MainActivity.java +++ b/android/app/src/main/java/app/mindlist/foss/MainActivity.java @@ -1,5 +1,13 @@ package app.mindlist.foss; +import android.os.Bundle; + import com.getcapacitor.BridgeActivity; -public class MainActivity extends BridgeActivity {} +public class MainActivity extends BridgeActivity { + @Override + public void onCreate(Bundle savedInstanceState) { + registerPlugin(TimerPlugin.class); + super.onCreate(savedInstanceState); + } +} diff --git a/android/app/src/main/java/app/mindlist/foss/TimerPlugin.java b/android/app/src/main/java/app/mindlist/foss/TimerPlugin.java new file mode 100644 index 0000000..2fbcdaf --- /dev/null +++ b/android/app/src/main/java/app/mindlist/foss/TimerPlugin.java @@ -0,0 +1,62 @@ +package app.mindlist.foss; + +import android.content.Context; +import android.content.Intent; +import android.os.Build; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +/** JS bridge for TimerService (#1). All methods are safe no-ops on web. */ +@CapacitorPlugin(name = "TimerService") +public class TimerPlugin extends Plugin { + + @PluginMethod + public void start(PluginCall call) { + String title = call.getString("title", "Mindlist focus"); + String text = call.getString("text", ""); + Context ctx = getContext(); + Intent i = new Intent(ctx, TimerService.class); + i.putExtra(TimerService.EXTRA_TITLE, title); + i.putExtra(TimerService.EXTRA_TEXT, text); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ctx.startForegroundService(i); + } else { + ctx.startService(i); + } + call.resolve(new JSObject()); + } + + @PluginMethod + public void update(PluginCall call) { + String title = call.getString("title", "Mindlist focus"); + String text = call.getString("text", ""); + Context ctx = getContext(); + Intent i = new Intent(ctx, TimerService.class); + i.setAction(TimerService.ACTION_UPDATE); + i.putExtra(TimerService.EXTRA_TITLE, title); + i.putExtra(TimerService.EXTRA_TEXT, text); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ctx.startForegroundService(i); + } else { + ctx.startService(i); + } + call.resolve(new JSObject()); + } + + @PluginMethod + public void stop(PluginCall call) { + Context ctx = getContext(); + Intent i = new Intent(ctx, TimerService.class); + i.setAction(TimerService.ACTION_STOP); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ctx.startForegroundService(i); + } else { + ctx.startService(i); + } + call.resolve(new JSObject()); + } +} diff --git a/android/app/src/main/java/app/mindlist/foss/TimerService.java b/android/app/src/main/java/app/mindlist/foss/TimerService.java new file mode 100644 index 0000000..efa46e9 --- /dev/null +++ b/android/app/src/main/java/app/mindlist/foss/TimerService.java @@ -0,0 +1,103 @@ +package app.mindlist.foss; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.content.pm.ServiceInfo; +import android.os.Build; +import android.os.IBinder; + +import androidx.core.app.NotificationCompat; + +/** + * Ongoing foreground service that keeps the focus timer alive while the + * app is minimized or the phone is locked (#1). Shows a persistent + * notification with the current step + remaining time. + */ +public class TimerService extends Service { + public static final String CHANNEL_ID = "mindlist_timer"; + public static final int NOTIF_ID = 1001; + public static final String EXTRA_TITLE = "title"; + public static final String EXTRA_TEXT = "text"; + public static final String ACTION_UPDATE = "app.mindlist.foss.UPDATE_TIMER"; + public static final String ACTION_STOP = "app.mindlist.foss.STOP_TIMER"; + + private String title = "Mindlist focus"; + private String text = ""; + + @Override + public void onCreate() { + super.onCreate(); + createChannel(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent != null) { + String action = intent.getAction(); + if (ACTION_STOP.equals(action)) { + stopForeground(STOP_FOREGROUND_REMOVE); + stopSelf(); + return START_NOT_STICKY; + } + if (intent.hasExtra(EXTRA_TITLE)) title = intent.getStringExtra(EXTRA_TITLE); + if (intent.hasExtra(EXTRA_TEXT)) text = intent.getStringExtra(EXTRA_TEXT); + } + Notification notif = buildNotification(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIF_ID, notif); + } else { + startForeground(NOTIF_ID, notif); + } + // NOT_STICKY: scheduled alarms (not the service) are the source of + // truth โ€” a kill-restarted zombie with stale text is worse than none. + // Known gap: reboot drops pending alarms (no BOOT receiver yet). + return START_NOT_STICKY; + } + + private void createChannel() { + NotificationManager nm = getSystemService(NotificationManager.class); + if (nm == null) return; + NotificationChannel ch = new NotificationChannel( + CHANNEL_ID, "Focus timer", NotificationManager.IMPORTANCE_LOW); + ch.setDescription("Ongoing focus timer while steps run"); + ch.setSound(null, null); + nm.createNotificationChannel(ch); + } + + private Notification buildNotification() { + Intent open = new Intent(this, MainActivity.class); + open.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); + PendingIntent pi = PendingIntent.getActivity( + this, 0, open, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + + Intent stop = new Intent(this, TimerService.class); + stop.setAction(ACTION_STOP); + PendingIntent stopPi = PendingIntent.getService( + this, 1, stop, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + + int icon = getResources().getIdentifier("ic_stat_timer", "drawable", getPackageName()); + if (icon == 0) icon = android.R.drawable.ic_dialog_info; + + return new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(title) + .setContentText(text) + .setStyle(new NotificationCompat.BigTextStyle().bigText(text)) + .setSmallIcon(icon) + .setContentIntent(pi) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopPi) + .setOngoing(true) + .setOnlyAlertOnce(true) + .build(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } +} diff --git a/android/app/src/main/res/drawable/ic_stat_timer.xml b/android/app/src/main/res/drawable/ic_stat_timer.xml new file mode 100644 index 0000000..e0e7249 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_stat_timer.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 9a5fa87..d8f75e6 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -1,3 +1,12 @@ // DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN include ':capacitor-android' project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') + +include ':capacitor-community-text-to-speech' +project(':capacitor-community-text-to-speech').projectDir = new File('../node_modules/@capacitor-community/text-to-speech/android') + +include ':capacitor-app' +project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') + +include ':capacitor-local-notifications' +project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android') diff --git a/package-lock.json b/package-lock.json index 37500dc..ec12a0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,12 @@ "name": "app", "version": "0.0.0", "dependencies": { + "@capacitor-community/text-to-speech": "^8.0.2", "@capacitor/android": "^8.5.2", + "@capacitor/app": "^8.1.1", "@capacitor/cli": "^8.5.2", "@capacitor/core": "^8.5.2", + "@capacitor/local-notifications": "^8.3.1", "@tailwindcss/vite": "^4.3.3", "dexie": "^4.4.6", "dexie-react-hooks": "^4.4.0", @@ -1499,6 +1502,15 @@ "node": ">=6.9.0" } }, + "node_modules/@capacitor-community/text-to-speech": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor-community/text-to-speech/-/text-to-speech-8.0.2.tgz", + "integrity": "sha512-SbpBMzcmzdTiN8jQDme/yUYHEJISZA+IiaRn5zT14k3UKpjaA7eYBnSm9h76rJHRV/y6DVZVhT1J0SaK5JTKtA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/android": { "version": "8.5.2", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.5.2.tgz", @@ -1508,6 +1520,15 @@ "@capacitor/core": "^8.5.0" } }, + "node_modules/@capacitor/app": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.1.1.tgz", + "integrity": "sha512-xM2ZTX5jK60tFtmjsmJv+Cdj1Qsb6NcavkqmdEnCPQBeya9oKhamZJxYOnQNj1ZvcJM7sWfG6BEtSLx42pisbA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/cli": { "version": "8.5.2", "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.2.tgz", @@ -1585,6 +1606,15 @@ "tslib": "^2.1.0" } }, + "node_modules/@capacitor/local-notifications": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.3.1.tgz", + "integrity": "sha512-F7zu/+p/mIR958YP4axOkeI5hBKRaXDyYjbNPrAil2wWfd7OxScPSTvP9t85NpQ0eULVRu0/sntoxOg6ck477A==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@ionic/cli-framework-output": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", diff --git a/package.json b/package.json index 01fc929..51cb360 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,12 @@ "preview": "vite preview" }, "dependencies": { + "@capacitor-community/text-to-speech": "^8.0.2", "@capacitor/android": "^8.5.2", + "@capacitor/app": "^8.1.1", "@capacitor/cli": "^8.5.2", "@capacitor/core": "^8.5.2", + "@capacitor/local-notifications": "^8.3.1", "@tailwindcss/vite": "^4.3.3", "dexie": "^4.4.6", "dexie-react-hooks": "^4.4.0", diff --git a/src/App.tsx b/src/App.tsx index feb0179..20fca34 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,8 +21,20 @@ import { VolumeX, Wand2, } from 'lucide-react'; +import { App as CapApp } from '@capacitor/app'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { beep, heuristicPlan, speak } from './lib/fallback'; +import { beep, speak, stopSpeak } from './lib/audio'; +import { buildAlarms, catchUp, WARN_BEFORE_MS, type Alarm } from './lib/catchup'; +import { heuristicPlan } from './lib/fallback'; +import { fgStart, fgStop, fgUpdate } from './lib/fgservice'; +import { + cancelAlarmsFor, + cancelAllAlarms, + ensureExactAlarms, + ensureNotifPermission, + isNativePlatform, + scheduleAlarms, +} from './lib/notify'; import { callOpenRouter, getKey, @@ -352,6 +364,55 @@ function FocusMode({ task, steps, voice, onClose }: { task: Task; steps: Step[]; const [syncedFor, setSyncedFor] = useState(null); const cur = ordered[Math.min(idx, Math.max(0, ordered.length - 1))]; const announced = useRef(''); + const lastBeeped = useRef(-1); + const [alarmsOn, setAlarmsOn] = useState(true); + + // (Re)arm background machinery for the chain starting at `index`: + // notification alarms + foreground service. Reads live snapshot. + const scheduleFrom = useCallback( + async (index: number, snap: Step[]) => { + const step = snap[index]; + if (!step) return; + const now = Date.now(); + const firstEnds = now + Math.max(1, step.secondsLeft) * 1000; + const alarms: Alarm[] = []; + if (step.secondsLeft * 1000 > WARN_BEFORE_MS + 2000) { + alarms.push({ atMs: firstEnds - WARN_BEFORE_MS, kind: 'warn', stepId: step.id, order: step.order }); + } + alarms.push({ atMs: firstEnds, kind: 'end', stepId: step.id, order: step.order }); + const nextOrder = snap[index + 1]?.order ?? Number.MAX_SAFE_INTEGER; + alarms.push(...buildAlarms(snap, nextOrder, firstEnds)); + const granted = await ensureNotifPermission(); + setAlarmsOn(granted); + if (!granted) return; + await ensureExactAlarms(); // opens "Alarms & reminders" once if needed; chain still schedules (inexact fallback) + await scheduleAlarms(alarms, (a) => { + const s = snap.find((x) => x.id === a.stepId); + const label = s ? `${s.emoji} ${s.text}` : 'Next step'; + const next = snap.find( + (x) => x.order === a.order + 1 && x.status !== 'done' && x.status !== 'skipped', + ); + if (a.kind === 'warn') return { title: 'Mindlist ยท 10 seconds left', body: label }; + return next + ? { title: 'Mindlist ยท step done', body: `Next up: ${next.emoji} ${next.text}` } + : { title: 'Mindlist ยท all done ๐ŸŽ‰', body: `${task.title} โ€” well done!` }; + }); + }, + [task.title], + ); + + const armStep = useCallback( + async (step: Step, index: number, total: number, snap?: Step[]) => { + const endsAt = Date.now() + Math.max(1, step.secondsLeft) * 1000; + await db.steps + .update(step.id, { status: step.status === 'todo' ? 'active' : step.status, endsAt }) + .catch(() => {}); + const live = snap ?? ordered; + await scheduleFrom(index, live); + await fgStart('Mindlist focus', `Step ${index + 1}/${total} ยท ${step.text}`); + }, + [ordered, scheduleFrom], + ); // Sync to first unfinished when task changes (fixes stale idx + resume-at-0) useEffect(() => { @@ -379,32 +440,137 @@ function FocusMode({ task, steps, voice, onClose }: { task: Task; steps: Step[]; if (!cur || !running) return; if (announced.current !== cur.id) { announced.current = cur.id; - speak(`${cur.text}. ${Math.max(1, Math.round(cur.secondsLeft / 60))} minutes.`, voice && getVoiceOn()); - if (cur.status === 'todo') db.steps.update(cur.id, { status: 'active' }).catch(() => {}); + lastBeeped.current = -1; + void speak(`${cur.text}. ${Math.max(1, Math.round(cur.secondsLeft / 60))} minutes.`); + armStep(cur, idx, ordered.length).catch(() => {}); } if (cur.secondsLeft <= 0) { - beep(); - db.steps.update(cur.id, { status: 'done' }).catch(() => {}); + beep(2); + void stopSpeak(); + db.steps.update(cur.id, { status: 'done', secondsLeft: 0, endsAt: undefined }).catch(() => {}); + void cancelAlarmsFor([cur.id]); const next = ordered[idx + 1]; if (next) { // Reset next if revisiting a finished step so it doesn't instantly skip if (next.status === 'done' && next.secondsLeft <= 0) db.steps.update(next.id, { status: 'todo', secondsLeft: next.minutes * 60 }).catch(() => {}); - speak(`Done. Next: ${next.text}`, voice && getVoiceOn()); + void speak(`Done. Next: ${next.text}`); setIdx(idx + 1); } else { - speak('All steps finished. Well done.', voice && getVoiceOn()); + void speak('All steps finished. Well done.'); db.tasks.update(task.id, { status: 'done', completedAt: Date.now() }).catch(() => {}); + void cancelAllAlarms(); + void fgStop(); setRunning(false); onClose(); } return; } + // Foreground warning beeps for the final countdown (#2). + if (cur.secondsLeft <= 10 && cur.secondsLeft >= 1 && lastBeeped.current !== cur.secondsLeft) { + lastBeeped.current = cur.secondsLeft; + beep(1, cur.secondsLeft <= 3 ? 1320 : 990); + } + // Single source of truth is endsAt (set by armStep): the tick derives + // remaining time from the wall clock, so +1min/catch-up can never be + // clobbered by a stale decrement racing an async DB write. const t = setTimeout(() => { - db.steps.update(cur.id, { secondsLeft: Math.max(0, cur.secondsLeft - 1) }).catch(() => {}); + void (async () => { + try { + const fresh = await db.steps.get(cur.id); + const left = + fresh?.endsAt != null + ? Math.max(0, Math.round((fresh.endsAt - Date.now()) / 1000)) + : Math.max(0, cur.secondsLeft - 1); + await db.steps.update(cur.id, { secondsLeft: left }); + } catch { + /* noop */ + } + })(); }, 1000); return () => clearTimeout(t); - }, [cur, running, idx, ordered, task.id, voice, onClose]); + }, [cur, running, idx, ordered, task.id, voice, onClose, armStep]); + + // Background catch-up (#1): replay elapsed wall-clock time on return. + const catchUpNow = useCallback(async () => { + const live = await db.steps.where('taskId').equals(task.id).sortBy('order'); + const res = catchUp(live, Date.now()); + if (res.updates.length === 0) return; + for (const u of res.updates) { + await db.steps.update(u.id, { status: u.status, secondsLeft: u.secondsLeft, endsAt: undefined }).catch(() => {}); + } + // Finished steps must not alarm late (inexact/Doze-delayed alarms). + if (res.finishedIds.length > 0) await cancelAlarmsFor(res.finishedIds); + const missed = res.finishedIds.length; + if (res.allDone) { + await db.tasks.update(task.id, { status: 'done', completedAt: Date.now() }).catch(() => {}); + await cancelAllAlarms(); + await fgStop(); + void speak('All steps finished while you were away. Well done.'); + setRunning(false); + onClose(); + return; + } + if (res.activeId) { + const upd = res.updates.find((u) => u.id === res.activeId); + const left = upd?.secondsLeft ?? 60; + await db.steps.update(res.activeId, { endsAt: Date.now() + left * 1000 }).catch(() => {}); + const fresh = await db.steps.where('taskId').equals(task.id).sortBy('order'); + const ai = fresh.findIndex((s) => s.id === res.activeId); + announced.current = res.activeId; // suppress intro re-speak; summary below instead + setIdx(Math.max(0, ai)); + const now = fresh[Math.max(0, ai)]; + if (now) { + void speak( + missed > 0 + ? `Welcome back. ${missed} ${missed === 1 ? 'step' : 'steps'} finished while you were away. Now: ${now.text}.` + : `Welcome back. ${now.text}.`, + ); + await scheduleFrom(Math.max(0, ai), fresh); + await fgUpdate(`Step ${Math.max(0, ai) + 1}/${fresh.length} ยท ${now.text}`); + } + } + }, [task.id, onClose, scheduleFrom]); + + useEffect(() => { + if (!running) return; + const onVisible = () => { + if (document.visibilityState === 'visible') void catchUpNow(); + }; + document.addEventListener('visibilitychange', onVisible); + let sub: { remove: () => void } | null = null; + CapApp.addListener('appStateChange', ({ isActive }) => { + if (isActive) void catchUpNow(); + }) + .then((s) => { + sub = s; + }) + .catch(() => {}); + return () => { + document.removeEventListener('visibilitychange', onVisible); + try { + sub?.remove(); + } catch { + /* noop */ + } + }; + }, [running, catchUpNow]); + + // Leaving focus freezes the timer: clear wall-clock state + alarms + service. + useEffect(() => { + return () => { + void cancelAllAlarms(); + void fgStop(); + void stopSpeak(); + db.steps + .where('taskId') + .equals(task.id) + .filter((s) => s.status === 'active') + .modify({ endsAt: undefined } as Partial) + .catch(() => {}); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); if (!cur) return null; const total = cur.minutes * 60 || 1; @@ -414,6 +580,7 @@ function FocusMode({ task, steps, voice, onClose }: { task: Task; steps: Step[]; const goBack = () => { const prev = Math.max(0, idx - 1); const target = ordered[prev]; + void cancelAlarmsFor([cur.id]); // Reset target so going back never lands on 0s (fixes instant auto-skip) if (target && target.secondsLeft <= 0) db.steps.update(target.id, { status: 'todo', secondsLeft: Math.max(60, target.minutes * 60) }).catch(() => {}); @@ -422,14 +589,38 @@ function FocusMode({ task, steps, voice, onClose }: { task: Task; steps: Step[]; setIdx(prev); }; const completeStep = () => { - db.steps.update(cur.id, { status: 'done', secondsLeft: 0 }).catch(() => {}); + db.steps.update(cur.id, { status: 'done', secondsLeft: 0, endsAt: undefined }).catch(() => {}); + void cancelAlarmsFor([cur.id]); if (idx + 1 < ordered.length) setIdx(idx + 1); - else { db.tasks.update(task.id, { status: 'done', completedAt: Date.now() }).catch(() => {}); onClose(); } + else { + db.tasks.update(task.id, { status: 'done', completedAt: Date.now() }).catch(() => {}); + void cancelAllAlarms(); + void fgStop(); + onClose(); + } }; const skipStep = () => { - db.steps.update(cur.id, { status: 'skipped', secondsLeft: 0 }).catch(() => {}); + db.steps.update(cur.id, { status: 'skipped', secondsLeft: 0, endsAt: undefined }).catch(() => {}); + void cancelAlarmsFor([cur.id]); if (idx + 1 < ordered.length) setIdx(idx + 1); - else { db.tasks.update(task.id, { status: 'done', completedAt: Date.now() }).catch(() => {}); onClose(); } + else { + db.tasks.update(task.id, { status: 'done', completedAt: Date.now() }).catch(() => {}); + void cancelAllAlarms(); + void fgStop(); + onClose(); + } + }; + const togglePause = () => { + if (running) { + setRunning(false); + db.steps.update(cur.id, { endsAt: undefined }).catch(() => {}); + void cancelAlarmsFor(ordered.map((s) => s.id)); + void fgStop(); + void stopSpeak(); + } else { + setRunning(true); + announced.current = ''; // force re-arm (intro re-speaks โ€” good resume feedback) + } }; return ( @@ -460,18 +651,38 @@ function FocusMode({ task, steps, voice, onClose }: { task: Task; steps: Step[];
-
-

One step at a time. Future steps hidden to protect focus.

+ {!alarmsOn && isNativePlatform() && ( + + )} ); diff --git a/src/app.test.ts b/src/app.test.ts index 713811e..95f8745 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { buildAlarms, catchUp, type CatchStep } from './lib/catchup'; import { heuristicPlan } from './lib/fallback'; import { parsePlanExported } from './lib/openrouter'; import { buildSystemPrompt } from './lib/prompts'; @@ -46,3 +47,92 @@ describe('parsePlan', () => { expect(p?.totalMin).toBe(20 * 60); }); }); + +const T0 = 1_000_000_000_000; +const mk = (over: Partial & { id: string; order: number }): CatchStep => ({ + status: 'todo', + secondsLeft: 60, + minutes: 1, + ...over, +}); + +describe('catchUp', () => { + it('no-op when nothing elapsed', () => { + const steps = [mk({ id: 'a', order: 0, status: 'active', endsAt: T0 + 60_000 })]; + const r = catchUp(steps, T0 + 5_000); + expect(r.updates).toHaveLength(1); + expect(r.updates[0]).toMatchObject({ id: 'a', status: 'active' }); + expect(r.updates[0].secondsLeft).toBeGreaterThan(50); + expect(r.finishedIds).toEqual([]); + expect(r.activeId).toBe('a'); + }); + it('finishes one step and activates next with live remaining', () => { + const steps = [ + mk({ id: 'a', order: 0, status: 'active', endsAt: T0 + 60_000 }), + { ...mk({ id: 'b', order: 1, minutes: 2 }), secondsLeft: 60 }, + ]; + const r = catchUp(steps, T0 + 90_000); + expect(r.finishedIds).toEqual(['a']); + expect(r.activeId).toBe('b'); + expect(r.updates.find((u) => u.id === 'b')?.secondsLeft).toBe(30); + }); + it('cascades across multiple missed steps and reports allDone', () => { + const steps = [ + mk({ id: 'a', order: 0, status: 'active', endsAt: T0 + 60_000 }), + mk({ id: 'b', order: 1, minutes: 1 }), + mk({ id: 'c', order: 2, minutes: 1 }), + ]; + const r = catchUp(steps, T0 + 300_000); + expect(r.finishedIds).toEqual(['a', 'b', 'c']); + expect(r.allDone).toBe(true); + expect(r.activeId).toBeNull(); + }); + it('paused step (no endsAt) stops the chain', () => { + const steps = [mk({ id: 'a', order: 0, status: 'todo' })]; + const r = catchUp(steps, T0 + 999_999_999); + expect(r.updates).toEqual([]); + expect(r.activeId).toBe('a'); + }); + it('skips done/skipped steps', () => { + const steps = [ + mk({ id: 'a', order: 0, status: 'done', secondsLeft: 0 }), + mk({ id: 'b', order: 1, status: 'skipped', secondsLeft: 0 }), + mk({ id: 'c', order: 2, status: 'active', endsAt: T0 + 60_000 }), + ]; + const r = catchUp(steps, T0 + 120_000); + expect(r.finishedIds).toEqual(['c']); + }); +}); + +describe('buildAlarms', () => { + it('emits warn+end per step with chained times', () => { + const steps = [ + { ...mk({ id: 'a', order: 0, minutes: 2 }), secondsLeft: 120 }, + { ...mk({ id: 'b', order: 1, minutes: 1 }), secondsLeft: 60 }, + ]; + const alarms = buildAlarms(steps, 0, T0); + expect(alarms.map((a) => [a.kind, a.atMs])).toEqual([ + ['warn', T0 + 110_000], + ['end', T0 + 120_000], + ['warn', T0 + 170_000], + ['end', T0 + 180_000], + ]); + }); + it('prefers live secondsLeft over minutes for partial steps', () => { + const steps = [{ ...mk({ id: 'a', order: 0, minutes: 5 }), secondsLeft: 30 }]; + const alarms = buildAlarms(steps, 0, T0); + expect(alarms.map((a) => [a.kind, a.atMs])).toEqual([ + ['warn', T0 + 20_000], + ['end', T0 + 30_000], + ]); + }); + it('skips warn for very short steps, skips done steps', () => { + const steps = [ + mk({ id: 'a', order: 0, minutes: 1, status: 'done' }), + { id: 'b', order: 1, minutes: 1, status: 'todo' as const, secondsLeft: 8, endsAt: undefined }, + ]; + const short = [{ ...steps[1], minutes: 0.1 }]; + const alarms = buildAlarms([steps[0], short[0]], 0, T0); + expect(alarms.map((a) => a.kind)).toEqual(['end']); + }); +}); diff --git a/src/lib/audio.ts b/src/lib/audio.ts new file mode 100644 index 0000000..ecc0567 --- /dev/null +++ b/src/lib/audio.ts @@ -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 { + 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 { + if (!getVoiceOn()) return; + if (isNative()) { + const ok = await nativeSpeak(text); + if (ok) return; + } + webSpeak(text); +} + +export async function stopSpeak(): Promise { + 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 */ + } +} diff --git a/src/lib/catchup.ts b/src/lib/catchup.ts new file mode 100644 index 0000000..8ad9194 --- /dev/null +++ b/src/lib/catchup.ts @@ -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[], + 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; +} diff --git a/src/lib/fgservice.ts b/src/lib/fgservice.ts new file mode 100644 index 0000000..66df877 --- /dev/null +++ b/src/lib/fgservice.ts @@ -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; + update(opts: { title: string; text: string }): Promise; + stop(): Promise; +} + +let cached: TimerServicePlugin | null = null; +function native(): TimerServicePlugin | null { + try { + if (!Capacitor.isNativePlatform()) return null; + } catch { + return null; + } + cached ??= registerPlugin('TimerService'); + return cached; +} + +export async function fgStart(title: string, text: string): Promise { + 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 { + try { + await native()?.update({ title, text }); + } catch { + /* noop */ + } +} + +export async function fgStop(): Promise { + try { + await native()?.stop(); + } catch { + /* noop */ + } +} diff --git a/src/lib/notify.ts b/src/lib/notify.ts new file mode 100644 index 0000000..ea145be --- /dev/null +++ b/src/lib/notify.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 */ + } +} diff --git a/src/lib/store.ts b/src/lib/store.ts index 66cabb1..945e421 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -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 {