This commit is contained in:
1 parent
fe9e75a4e7
commit
bbfcbe74af
16 files changed
+920
-17
No files matched your search
@@ -9,7 +9,9 @@ android {
|
|||||||
|
|
||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation project(':capacitor-community-text-to-speech')
|
||||||
|
implementation project(':capacitor-app')
|
||||||
|
implementation project(':capacitor-local-notifications')
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,22 @@
|
|||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/file_paths"></meta-data>
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<!-- Ongoing focus timer (#1) -->
|
||||||
|
<service
|
||||||
|
android:name=".TimerService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse">
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="Ongoing focus timer keeping step countdown alive while the app is in background." />
|
||||||
|
</service>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
package app.mindlist.foss;
|
package app.mindlist.foss;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
|
||||||
import com.getcapacitor.BridgeActivity;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="512" android:viewportHeight="512">
|
||||||
|
<!-- monochrome silhouette of the v3 logo for status-bar icons -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M 256 124 A 132 132 0 1 0 360 180"
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="36"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M 210 270 l 34 34 l 64 -76"
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="42"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round" />
|
||||||
|
</vector>
|
||||||
@@ -1,3 +1,12 @@
|
|||||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
include ':capacitor-android'
|
include ':capacitor-android'
|
||||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
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')
|
||||||
Generated
+30
@@ -8,9 +8,12 @@
|
|||||||
"name": "app",
|
"name": "app",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@capacitor-community/text-to-speech": "^8.0.2",
|
||||||
"@capacitor/android": "^8.5.2",
|
"@capacitor/android": "^8.5.2",
|
||||||
|
"@capacitor/app": "^8.1.1",
|
||||||
"@capacitor/cli": "^8.5.2",
|
"@capacitor/cli": "^8.5.2",
|
||||||
"@capacitor/core": "^8.5.2",
|
"@capacitor/core": "^8.5.2",
|
||||||
|
"@capacitor/local-notifications": "^8.3.1",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"dexie": "^4.4.6",
|
"dexie": "^4.4.6",
|
||||||
"dexie-react-hooks": "^4.4.0",
|
"dexie-react-hooks": "^4.4.0",
|
||||||
@@ -1499,6 +1502,15 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@capacitor/android": {
|
||||||
"version": "8.5.2",
|
"version": "8.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.5.2.tgz",
|
||||||
@@ -1508,6 +1520,15 @@
|
|||||||
"@capacitor/core": "^8.5.0"
|
"@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": {
|
"node_modules/@capacitor/cli": {
|
||||||
"version": "8.5.2",
|
"version": "8.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.2.tgz",
|
||||||
@@ -1585,6 +1606,15 @@
|
|||||||
"tslib": "^2.1.0"
|
"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": {
|
"node_modules/@ionic/cli-framework-output": {
|
||||||
"version": "2.2.8",
|
"version": "2.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz",
|
||||||
|
|||||||
@@ -11,9 +11,12 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@capacitor-community/text-to-speech": "^8.0.2",
|
||||||
"@capacitor/android": "^8.5.2",
|
"@capacitor/android": "^8.5.2",
|
||||||
|
"@capacitor/app": "^8.1.1",
|
||||||
"@capacitor/cli": "^8.5.2",
|
"@capacitor/cli": "^8.5.2",
|
||||||
"@capacitor/core": "^8.5.2",
|
"@capacitor/core": "^8.5.2",
|
||||||
|
"@capacitor/local-notifications": "^8.3.1",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"dexie": "^4.4.6",
|
"dexie": "^4.4.6",
|
||||||
"dexie-react-hooks": "^4.4.0",
|
"dexie-react-hooks": "^4.4.0",
|
||||||
|
|||||||
+226
-15
@@ -21,8 +21,20 @@ import {
|
|||||||
VolumeX,
|
VolumeX,
|
||||||
Wand2,
|
Wand2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { App as CapApp } from '@capacitor/app';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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 {
|
import {
|
||||||
callOpenRouter,
|
callOpenRouter,
|
||||||
getKey,
|
getKey,
|
||||||
@@ -352,6 +364,55 @@ function FocusMode({ task, steps, voice, onClose }: { task: Task; steps: Step[];
|
|||||||
const [syncedFor, setSyncedFor] = useState<string | null>(null);
|
const [syncedFor, setSyncedFor] = useState<string | null>(null);
|
||||||
const cur = ordered[Math.min(idx, Math.max(0, ordered.length - 1))];
|
const cur = ordered[Math.min(idx, Math.max(0, ordered.length - 1))];
|
||||||
const announced = useRef('');
|
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)
|
// Sync to first unfinished when task changes (fixes stale idx + resume-at-0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -379,32 +440,137 @@ function FocusMode({ task, steps, voice, onClose }: { task: Task; steps: Step[];
|
|||||||
if (!cur || !running) return;
|
if (!cur || !running) return;
|
||||||
if (announced.current !== cur.id) {
|
if (announced.current !== cur.id) {
|
||||||
announced.current = cur.id;
|
announced.current = cur.id;
|
||||||
speak(`${cur.text}. ${Math.max(1, Math.round(cur.secondsLeft / 60))} minutes.`, voice && getVoiceOn());
|
lastBeeped.current = -1;
|
||||||
if (cur.status === 'todo') db.steps.update(cur.id, { status: 'active' }).catch(() => {});
|
void speak(`${cur.text}. ${Math.max(1, Math.round(cur.secondsLeft / 60))} minutes.`);
|
||||||
|
armStep(cur, idx, ordered.length).catch(() => {});
|
||||||
}
|
}
|
||||||
if (cur.secondsLeft <= 0) {
|
if (cur.secondsLeft <= 0) {
|
||||||
beep();
|
beep(2);
|
||||||
db.steps.update(cur.id, { status: 'done' }).catch(() => {});
|
void stopSpeak();
|
||||||
|
db.steps.update(cur.id, { status: 'done', secondsLeft: 0, endsAt: undefined }).catch(() => {});
|
||||||
|
void cancelAlarmsFor([cur.id]);
|
||||||
const next = ordered[idx + 1];
|
const next = ordered[idx + 1];
|
||||||
if (next) {
|
if (next) {
|
||||||
// Reset next if revisiting a finished step so it doesn't instantly skip
|
// Reset next if revisiting a finished step so it doesn't instantly skip
|
||||||
if (next.status === 'done' && next.secondsLeft <= 0)
|
if (next.status === 'done' && next.secondsLeft <= 0)
|
||||||
db.steps.update(next.id, { status: 'todo', secondsLeft: next.minutes * 60 }).catch(() => {});
|
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);
|
setIdx(idx + 1);
|
||||||
} else {
|
} 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(() => {});
|
db.tasks.update(task.id, { status: 'done', completedAt: Date.now() }).catch(() => {});
|
||||||
|
void cancelAllAlarms();
|
||||||
|
void fgStop();
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
return;
|
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(() => {
|
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);
|
}, 1000);
|
||||||
return () => clearTimeout(t);
|
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<Step>)
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!cur) return null;
|
if (!cur) return null;
|
||||||
const total = cur.minutes * 60 || 1;
|
const total = cur.minutes * 60 || 1;
|
||||||
@@ -414,6 +580,7 @@ function FocusMode({ task, steps, voice, onClose }: { task: Task; steps: Step[];
|
|||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
const prev = Math.max(0, idx - 1);
|
const prev = Math.max(0, idx - 1);
|
||||||
const target = ordered[prev];
|
const target = ordered[prev];
|
||||||
|
void cancelAlarmsFor([cur.id]);
|
||||||
// Reset target so going back never lands on 0s (fixes instant auto-skip)
|
// Reset target so going back never lands on 0s (fixes instant auto-skip)
|
||||||
if (target && target.secondsLeft <= 0)
|
if (target && target.secondsLeft <= 0)
|
||||||
db.steps.update(target.id, { status: 'todo', secondsLeft: Math.max(60, target.minutes * 60) }).catch(() => {});
|
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);
|
setIdx(prev);
|
||||||
};
|
};
|
||||||
const completeStep = () => {
|
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);
|
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 = () => {
|
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);
|
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 (
|
return (
|
||||||
@@ -460,18 +651,38 @@ function FocusMode({ task, steps, voice, onClose }: { task: Task; steps: Step[];
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex items-center justify-center gap-3">
|
<div className="mt-3 flex items-center justify-center gap-3">
|
||||||
<button onClick={goBack} aria-label="Go to previous step" className="grid size-12 place-items-center rounded-full bg-slate-100 dark:bg-white/10"><ChevronLeft size={19} /></button>
|
<button onClick={goBack} aria-label="Go to previous step" className="grid size-12 place-items-center rounded-full bg-slate-100 dark:bg-white/10"><ChevronLeft size={19} /></button>
|
||||||
<button onClick={() => setRunning(!running)} aria-label={running ? 'Pause timer' : 'Resume timer'} aria-pressed={running}
|
<button onClick={togglePause} aria-label={running ? 'Pause timer' : 'Resume timer'} aria-pressed={running}
|
||||||
className="grid size-14 place-items-center rounded-full bg-teal-700 text-white">
|
className="grid size-14 place-items-center rounded-full bg-teal-700 text-white">
|
||||||
{running ? <Pause size={22} /> : <Play size={22} />}</button>
|
{running ? <Pause size={22} /> : <Play size={22} />}</button>
|
||||||
<button onClick={completeStep} aria-label="Complete this step" className="grid size-12 place-items-center rounded-full bg-teal-700 text-white"><Check size={19} /></button>
|
<button onClick={completeStep} aria-label="Complete this step" className="grid size-12 place-items-center rounded-full bg-teal-700 text-white"><Check size={19} /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex justify-center gap-2">
|
<div className="mt-3 flex justify-center gap-2">
|
||||||
<button onClick={() => db.steps.update(cur.id, { secondsLeft: cur.secondsLeft + 60, minutes: cur.minutes + 1 }).catch(() => {})}
|
<button onClick={() => {
|
||||||
|
const nl = cur.secondsLeft + 60;
|
||||||
|
const bumped = { ...cur, secondsLeft: nl, minutes: cur.minutes + 1 };
|
||||||
|
db.steps.update(cur.id, { secondsLeft: nl, minutes: cur.minutes + 1 }).catch(() => {});
|
||||||
|
// Re-arm wall-clock state so alarms/catch-up use the extended time.
|
||||||
|
// Pass the bumped snapshot: the closure `ordered` is stale here,
|
||||||
|
// and the tick derives from endsAt so no decrement can clobber it.
|
||||||
|
const snap = ordered.map((s) => (s.id === cur.id ? bumped : s));
|
||||||
|
armStep(bumped, idx, ordered.length, snap).catch(() => {});
|
||||||
|
}}
|
||||||
className="min-h-[44px] rounded-full bg-slate-100 px-4 py-2 text-[13px] font-medium dark:bg-white/10">+1 min</button>
|
className="min-h-[44px] rounded-full bg-slate-100 px-4 py-2 text-[13px] font-medium dark:bg-white/10">+1 min</button>
|
||||||
<button onClick={skipStep} aria-label="Skip this step"
|
<button onClick={skipStep} aria-label="Skip this step"
|
||||||
className="min-h-[44px] rounded-full bg-slate-100 px-4 py-2 text-[13px] font-medium dark:bg-white/10">Skip →</button>
|
className="min-h-[44px] rounded-full bg-slate-100 px-4 py-2 text-[13px] font-medium dark:bg-white/10">Skip →</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-[11px] text-slate-400">One step at a time. Future steps hidden to protect focus.</p>
|
<p className="mt-2 text-[11px] text-slate-400">One step at a time. Future steps hidden to protect focus.</p>
|
||||||
|
{!alarmsOn && isNativePlatform() && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.removeItem('mindlist.exactAsked');
|
||||||
|
void ensureNotifPermission().then((g) => setAlarmsOn(g));
|
||||||
|
}}
|
||||||
|
className="mt-1 min-h-[44px] w-full rounded-xl text-[12px] font-medium text-amber-700 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
🔕 Background alarms off — tap to enable notifications for lock-screen timing.
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildAlarms, catchUp, type CatchStep } from './lib/catchup';
|
||||||
import { heuristicPlan } from './lib/fallback';
|
import { heuristicPlan } from './lib/fallback';
|
||||||
import { parsePlanExported } from './lib/openrouter';
|
import { parsePlanExported } from './lib/openrouter';
|
||||||
import { buildSystemPrompt } from './lib/prompts';
|
import { buildSystemPrompt } from './lib/prompts';
|
||||||
@@ -46,3 +47,92 @@ describe('parsePlan', () => {
|
|||||||
expect(p?.totalMin).toBe(20 * 60);
|
expect(p?.totalMin).toBe(20 * 60);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const T0 = 1_000_000_000_000;
|
||||||
|
const mk = (over: Partial<CatchStep> & { 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
minutes: number;
|
||||||
secondsLeft: number;
|
secondsLeft: number;
|
||||||
status: 'todo' | 'active' | 'done' | 'skipped';
|
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 {
|
class DB extends Dexie {
|
||||||
|
|||||||
Reference in new issue
Block a user