3 Commits

Author SHA1 Message Date
Alexander f0ad5b67a5 fix(layout): refresh load() data on tab/app resume
CI / update (push) Successful in 3m51s
Tauri WebView sessions (and long-lived browser tabs) persist
hydrated load() data indefinitely, so server-side changes never
surface until the user manually navigates across a depends()
boundary. Wire visibilitychange + focus to invalidateAll(),
throttled to once per 5 min to keep expensive loaders cheap.
2026-04-21 19:45:13 +02:00
Alexander a056618696 fix(fitness): request ACTIVITY_RECOGNITION for cadence
Android step detector silently returns no events on API 29+
when ACTIVITY_RECOGNITION is ungranted, so cadence was always
absent from recorded tracks. Declare the permission, request
it at GPS start, guard sensor registration and retry it from
MainActivity.onRequestPermissionsResult when the user grants
mid-session, and toast a hint if they deny.
2026-04-21 19:21:25 +02:00
Alexander cf5ac96fc3 feat(fitness): download GPX from history detail
Export each cardio exercise's stored GPS track from the history
detail page. Cadence is emitted per-point via Garmin's
TrackPointExtension v1 so Strava/Garmin Connect preserve it.
Filename: YYYY-MM-DD-<workout> <mins>min <Activity>.gpx.
2026-04-21 18:53:19 +02:00
15 changed files with 282 additions and 9 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.43.1", "version": "1.44.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -144,7 +144,7 @@ dependencies = [
[[package]] [[package]]
name = "bocken" name = "bocken"
version = "0.4.0" version = "0.5.1"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "bocken" name = "bocken"
version = "0.5.0" version = "0.5.1"
edition = "2021" edition = "2021"
[lib] [lib]
@@ -9,6 +9,8 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<!-- Step detector sensor (cadence during GPS workouts); runtime-requested on API 29+ -->
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<!-- AndroidTV support --> <!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" /> <uses-feature android:name="android.software.leanback" android:required="false" />
@@ -20,6 +20,12 @@ import java.util.Locale
class AndroidBridge(private val context: Context) { class AndroidBridge(private val context: Context) {
companion object {
const val REQ_BACKGROUND_LOCATION = 1002
const val REQ_NOTIFICATIONS = 1003
const val REQ_ACTIVITY_RECOGNITION = 1004
}
@JavascriptInterface @JavascriptInterface
fun startLocationService(ttsConfigJson: String, startPaused: Boolean) { fun startLocationService(ttsConfigJson: String, startPaused: Boolean) {
if (context is Activity) { if (context is Activity) {
@@ -31,7 +37,7 @@ class AndroidBridge(private val context: Context) {
ActivityCompat.requestPermissions( ActivityCompat.requestPermissions(
context, context,
arrayOf(Manifest.permission.POST_NOTIFICATIONS), arrayOf(Manifest.permission.POST_NOTIFICATIONS),
1003 REQ_NOTIFICATIONS
) )
} }
} }
@@ -44,7 +50,20 @@ class AndroidBridge(private val context: Context) {
ActivityCompat.requestPermissions( ActivityCompat.requestPermissions(
context, context,
arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION), arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
1002 REQ_BACKGROUND_LOCATION
)
}
}
// Request activity recognition on Android 10+ (required for step detector / cadence)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION)
!= PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
context,
arrayOf(Manifest.permission.ACTIVITY_RECOGNITION),
REQ_ACTIVITY_RECOGNITION
) )
} }
} }
@@ -104,6 +123,15 @@ class AndroidBridge(private val context: Context) {
return LocationForegroundService.getIntervalState() return LocationForegroundService.getIntervalState()
} }
/** True if cadence (step detector) is usable — permission granted or not required (pre-Q). */
@JavascriptInterface
fun hasActivityRecognitionPermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return true
return ContextCompat.checkSelfPermission(
context, Manifest.permission.ACTIVITY_RECOGNITION
) == PackageManager.PERMISSION_GRANTED
}
/** /**
* Force-vibrate bypassing silent/DND by using USAGE_ACCESSIBILITY attributes. * Force-vibrate bypassing silent/DND by using USAGE_ACCESSIBILITY attributes.
* Why: default web Vibration API uses USAGE_TOUCH which Android silences. * Why: default web Vibration API uses USAGE_TOUCH which Android silences.
@@ -4,9 +4,11 @@ import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.Manifest
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.hardware.Sensor import android.hardware.Sensor
import android.hardware.SensorEvent import android.hardware.SensorEvent
import android.hardware.SensorEventListener import android.hardware.SensorEventListener
@@ -24,6 +26,7 @@ import android.os.Looper
import android.speech.tts.TextToSpeech import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener import android.speech.tts.UtteranceProgressListener
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.util.Collections import java.util.Collections
@@ -696,8 +699,22 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener, Sensor
notificationManager?.notify(NOTIFICATION_ID, notification) notificationManager?.notify(NOTIFICATION_ID, notification)
} }
private fun hasActivityRecognitionPermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return true
return ContextCompat.checkSelfPermission(
this, Manifest.permission.ACTIVITY_RECOGNITION
) == PackageManager.PERMISSION_GRANTED
}
private fun startStepDetector() { private fun startStepDetector() {
sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager if (!hasActivityRecognitionPermission()) {
Log.d(TAG, "Step detector skipped — ACTIVITY_RECOGNITION not granted")
return
}
if (stepDetector != null) return // already registered
if (sensorManager == null) {
sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
}
stepDetector = sensorManager?.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR) stepDetector = sensorManager?.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR)
if (stepDetector != null) { if (stepDetector != null) {
sensorManager?.registerListener(this, stepDetector, SensorManager.SENSOR_DELAY_FASTEST) sensorManager?.registerListener(this, stepDetector, SensorManager.SENSOR_DELAY_FASTEST)
@@ -707,6 +724,12 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener, Sensor
} }
} }
/** Called from MainActivity when ACTIVITY_RECOGNITION is granted mid-session. */
fun onActivityRecognitionGranted() {
Log.d(TAG, "ACTIVITY_RECOGNITION granted — retrying step detector registration")
startStepDetector()
}
@Suppress("MissingPermission") @Suppress("MissingPermission")
private fun startLocationUpdates() { private fun startLocationUpdates() {
locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
@@ -1,5 +1,6 @@
package org.bocken.app package org.bocken.app
import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import android.webkit.WebView import android.webkit.WebView
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@@ -13,4 +14,17 @@ class MainActivity : TauriActivity() {
override fun onWebViewCreate(webView: WebView) { override fun onWebViewCreate(webView: WebView) {
webView.addJavascriptInterface(AndroidBridge(this), "AndroidBridge") webView.addJavascriptInterface(AndroidBridge(this), "AndroidBridge")
} }
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == AndroidBridge.REQ_ACTIVITY_RECOGNITION &&
grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
LocationForegroundService.instance?.onActivityRecognitionGranted()
}
}
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"productName": "Bocken", "productName": "Bocken",
"identifier": "org.bocken.app", "identifier": "org.bocken.app",
"version": "0.5.0", "version": "0.5.1",
"build": { "build": {
"devUrl": "http://192.168.1.4:5173", "devUrl": "http://192.168.1.4:5173",
"frontendDist": "https://bocken.org" "frontendDist": "https://bocken.org"
+5
View File
@@ -104,12 +104,17 @@ const translations: Translations = {
pace: { en: 'PACE', de: 'TEMPO' }, pace: { en: 'PACE', de: 'TEMPO' },
upload_gpx: { en: 'Upload GPX', de: 'GPX hochladen' }, upload_gpx: { en: 'Upload GPX', de: 'GPX hochladen' },
uploading: { en: 'Uploading...', de: 'Hochladen...' }, uploading: { en: 'Uploading...', de: 'Hochladen...' },
download_gpx: { en: 'Download GPX', de: 'GPX herunterladen' },
elevation: { en: 'Elevation', de: 'Höhenprofil' }, elevation: { en: 'Elevation', de: 'Höhenprofil' },
elevation_unit: { en: 'm', de: 'm' }, elevation_unit: { en: 'm', de: 'm' },
elevation_gain: { en: 'Gain', de: 'Anstieg' }, elevation_gain: { en: 'Gain', de: 'Anstieg' },
elevation_loss: { en: 'Loss', de: 'Abstieg' }, elevation_loss: { en: 'Loss', de: 'Abstieg' },
cadence: { en: 'Cadence', de: 'Kadenz' }, cadence: { en: 'Cadence', de: 'Kadenz' },
cadence_unit: { en: 'spm', de: 'spm' }, cadence_unit: { en: 'spm', de: 'spm' },
cadence_permission_missing: {
en: 'Cadence disabled — grant Activity Recognition in system settings',
de: 'Kadenz deaktiviert — Aktivitätserkennung in den Einstellungen erlauben'
},
personal_records: { en: 'Personal Records', de: 'Persönliche Rekorde' }, personal_records: { en: 'Personal Records', de: 'Persönliche Rekorde' },
delete_session_confirm: { en: 'Delete this workout session?', de: 'Dieses Training löschen?' }, delete_session_confirm: { en: 'Delete this workout session?', de: 'Dieses Training löschen?' },
remove_gps_confirm: { en: 'Remove GPS track from this exercise?', de: 'GPS-Track von dieser Übung entfernen?' }, remove_gps_confirm: { en: 'Remove GPS track from this exercise?', de: 'GPS-Track von dieser Übung entfernen?' },
+9
View File
@@ -79,6 +79,7 @@ interface AndroidBridge {
pauseTracking(): void; pauseTracking(): void;
resumeTracking(): void; resumeTracking(): void;
getIntervalState(): string; getIntervalState(): string;
hasActivityRecognitionPermission?: () => boolean;
} }
function checkTauri(): boolean { function checkTauri(): boolean {
@@ -294,6 +295,13 @@ export function createGpsTracker() {
} }
} }
function cadenceAvailable(): boolean {
const bridge = getAndroidBridge();
// No bridge (e.g. browser) or older build lacking the check: assume ok, don't nag.
if (!bridge || typeof bridge.hasActivityRecognitionPermission !== 'function') return true;
try { return bridge.hasActivityRecognitionPermission(); } catch { return true; }
}
return { return {
get track() { return track; }, get track() { return track; },
get isTracking() { return isTracking; }, get isTracking() { return isTracking; },
@@ -304,6 +312,7 @@ export function createGpsTracker() {
get available() { return checkTauri(); }, get available() { return checkTauri(); },
get debug() { return _debugMsg; }, get debug() { return _debugMsg; },
get intervalState() { return _intervalState; }, get intervalState() { return _intervalState; },
cadenceAvailable,
start, start,
stop, stop,
reset, reset,
+68
View File
@@ -0,0 +1,68 @@
import type { IGpsPoint } from '$models/WorkoutSession';
function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/** Generate a GPX 1.1 document from an array of GPS points.
* Cadence is emitted via Garmin's TrackPointExtension v1 (<gpxtpx:cad>). */
export function generateGpx(track: IGpsPoint[], name: string): string {
const hasCadence = track.some(p => typeof p.cadence === 'number');
const nsExt = hasCadence
? ' xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"'
: '';
const pts = track.map(p => {
const ele = typeof p.altitude === 'number' ? ` <ele>${p.altitude}</ele>\n` : '';
const time = ` <time>${new Date(p.timestamp).toISOString()}</time>\n`;
const cad = typeof p.cadence === 'number'
? ` <extensions><gpxtpx:TrackPointExtension><gpxtpx:cad>${Math.round(p.cadence)}</gpxtpx:cad></gpxtpx:TrackPointExtension></extensions>\n`
: '';
return ` <trkpt lat="${p.lat}" lon="${p.lng}">\n${ele}${time}${cad} </trkpt>`;
}).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Bocken Homepage" xmlns="http://www.topografix.com/GPX/1/1"${nsExt}>
<trk>
<name>${escapeXml(name)}</name>
<trkseg>
${pts}
</trkseg>
</trk>
</gpx>
`;
}
/** Sanitize a string for use as a filename. Preserves spaces; strips path/control chars. */
export function safeFilename(s: string): string {
return s.replace(/[\\/\x00-\x1f:*?"<>|]+/g, '').replace(/\s+/g, ' ').trim().slice(0, 120);
}
const ACTIVITY_LABEL: Record<string, string> = {
running: 'Run',
walking: 'Walk',
cycling: 'Cycle',
hiking: 'Hike'
};
/** Build a GPX filename: "YYYY-MM-DD-<workout> <duration>min <Activity>.gpx". */
export function buildGpxFilename(opts: {
startTime: Date | string | number;
workoutName: string;
durationMin: number;
activityType?: string;
fallbackActivity?: string;
}): string {
const date = new Date(opts.startTime).toISOString().slice(0, 10);
const name = safeFilename(opts.workoutName) || 'Workout';
const mins = Math.max(0, Math.round(opts.durationMin));
const activity = opts.activityType && ACTIVITY_LABEL[opts.activityType]
? ACTIVITY_LABEL[opts.activityType]
: safeFilename(opts.fallbackActivity ?? '') || 'Cardio';
return `${date}-${name} ${mins}min ${activity}.gpx`;
}
+22 -1
View File
@@ -1,10 +1,31 @@
<script> <script>
import '../app.css'; import '../app.css';
import { onNavigate } from '$app/navigation'; import { onNavigate, invalidateAll } from '$app/navigation';
import { onMount } from 'svelte';
import Toast from '$lib/components/Toast.svelte'; import Toast from '$lib/components/Toast.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
let { children } = $props(); let { children } = $props();
/** Refresh server data on resume — Tauri WebView and backgrounded browser tabs
* don't re-run SvelteKit load() otherwise. Throttled: at most once per 5 min. */
const REFRESH_MIN_GAP_MS = 5 * 60 * 1000;
let lastRefreshAt = Date.now();
onMount(() => {
const refresh = () => {
if (document.hidden) return;
const now = Date.now();
if (now - lastRefreshAt < REFRESH_MIN_GAP_MS) return;
lastRefreshAt = now;
invalidateAll();
};
document.addEventListener('visibilitychange', refresh);
window.addEventListener('focus', refresh);
return () => {
document.removeEventListener('visibilitychange', refresh);
window.removeEventListener('focus', refresh);
};
});
onNavigate((navigation) => { onNavigate((navigation) => {
if (!(/** @type {any} */ (document)).startViewTransition) return; if (!(/** @type {any} */ (document)).startViewTransition) return;
@@ -5,6 +5,7 @@ import { WorkoutSession } from '$models/WorkoutSession';
import type { IGpsPoint } from '$models/WorkoutSession'; import type { IGpsPoint } from '$models/WorkoutSession';
import { simplifyTrack } from '$lib/server/simplifyTrack'; import { simplifyTrack } from '$lib/server/simplifyTrack';
import { computeSessionKcal } from '$lib/server/computeSessionKcal'; import { computeSessionKcal } from '$lib/server/computeSessionKcal';
import { generateGpx, buildGpxFilename } from '$lib/server/gpxExport';
import mongoose from 'mongoose'; import mongoose from 'mongoose';
/** Haversine distance in km between two points */ /** Haversine distance in km between two points */
@@ -56,6 +57,62 @@ function parseGpx(xml: string): IGpsPoint[] {
return points; return points;
} }
// GET /api/fitness/sessions/[id]/gpx?exerciseIdx=N — download GPX export of the track
export const GET: RequestHandler = async ({ params, url, locals }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!mongoose.Types.ObjectId.isValid(params.id)) {
return json({ error: 'Invalid session ID' }, { status: 400 });
}
const exerciseIdx = parseInt(url.searchParams.get('exerciseIdx') ?? '', 10);
if (isNaN(exerciseIdx) || exerciseIdx < 0) {
return json({ error: 'Invalid exercise index' }, { status: 400 });
}
await dbConnect();
const workoutSession = await WorkoutSession.findOne({
_id: params.id,
createdBy: session.user.nickname
}).lean();
if (!workoutSession) {
return json({ error: 'Session not found' }, { status: 404 });
}
const ex = workoutSession.exercises[exerciseIdx];
if (!ex) {
return json({ error: 'Exercise index out of range' }, { status: 400 });
}
if (!ex.gpsTrack || ex.gpsTrack.length === 0) {
return json({ error: 'No GPS track on this exercise' }, { status: 404 });
}
const trackName = `${workoutSession.name}${ex.name}`;
const trackMs = ex.gpsTrack[ex.gpsTrack.length - 1].timestamp - ex.gpsTrack[0].timestamp;
const durationMin = trackMs > 0 ? trackMs / 60000 : (workoutSession.duration ?? 0);
const filename = buildGpxFilename({
startTime: workoutSession.startTime,
workoutName: workoutSession.name,
durationMin,
activityType: workoutSession.activityType,
fallbackActivity: ex.name
});
const xml = generateGpx(ex.gpsTrack, trackName);
return new Response(xml, {
status: 200,
headers: {
'Content-Type': 'application/gpx+xml; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`
}
});
};
// POST /api/fitness/sessions/[id]/gpx — upload GPX file for an exercise // POST /api/fitness/sessions/[id]/gpx — upload GPX file for an exercise
export const POST: RequestHandler = async ({ params, request, locals }) => { export const POST: RequestHandler = async ({ params, request, locals }) => {
const session = await locals.auth(); const session = await locals.auth();
@@ -1,7 +1,7 @@
<script> <script>
import { goto, invalidateAll } from '$app/navigation'; import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { Clock, Weight, Trophy, Trash2, Pencil, Plus, Upload, Route, X, RefreshCw, Gauge, Flame, Info, Mountain } from '@lucide/svelte'; import { Clock, Weight, Trophy, Trash2, Pencil, Plus, Upload, Download, Route, X, RefreshCw, Gauge, Flame, Info, Mountain } from '@lucide/svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n'; import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import { confirm } from '$lib/js/confirmDialog.svelte'; import { confirm } from '$lib/js/confirmDialog.svelte';
import { toast } from '$lib/js/toast.svelte'; import { toast } from '$lib/js/toast.svelte';
@@ -543,6 +543,11 @@
input.click(); input.click();
} }
/** @param {number} exIdx */
function downloadGpx(exIdx) {
window.location.href = `/api/fitness/sessions/${session._id}/gpx?exerciseIdx=${exIdx}`;
}
/** @param {number} exIdx */ /** @param {number} exIdx */
async function removeGpx(exIdx) { async function removeGpx(exIdx) {
if (!await confirm(t('remove_gps_confirm', lang))) return; if (!await confirm(t('remove_gps_confirm', lang))) return;
@@ -836,6 +841,10 @@
</div> </div>
{/if} {/if}
{/if} {/if}
<button class="gpx-download-btn" onclick={() => downloadGpx(exIdx)}>
<Download size={14} />
{t('download_gpx', lang)}
</button>
</div> </div>
{:else if isCardio(ex.exerciseId)} {:else if isCardio(ex.exerciseId)}
<button class="gpx-upload-btn" onclick={() => uploadGpx(exIdx)} disabled={uploading === exIdx}> <button class="gpx-upload-btn" onclick={() => uploadGpx(exIdx)} disabled={uploading === exIdx}>
@@ -1345,6 +1354,24 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.gpx-download-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.75rem;
padding: 0.4rem 0.75rem;
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-secondary);
font-size: 0.8rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.gpx-download-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* GPS charts */ /* GPS charts */
.chart-section { .chart-section {
@@ -4,6 +4,7 @@
import { Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame, MapPin, Volume2, X, Timer, Plus, GripVertical, Repeat } from '@lucide/svelte'; import { Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame, MapPin, Volume2, X, Timer, Plus, GripVertical, Repeat } from '@lucide/svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n'; import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import { confirm } from '$lib/js/confirmDialog.svelte'; import { confirm } from '$lib/js/confirmDialog.svelte';
import { toast } from '$lib/js/toast.svelte';
const lang = $derived(detectFitnessLang($page.url.pathname)); const lang = $derived(detectFitnessLang($page.url.pathname));
const sl = $derived(fitnessSlugs(lang)); const sl = $derived(fitnessSlugs(lang));
@@ -359,6 +360,20 @@
} }
} }
let _cadenceWarned = false;
/** After the user resolves the permission dialog, check if cadence will work.
* We wait a few seconds to let the system dialog settle before toasting. */
function maybeWarnCadence() {
if (_cadenceWarned) return;
setTimeout(() => {
if (_cadenceWarned) return;
if (!gps.cadenceAvailable()) {
_cadenceWarned = true;
toast.info(t('cadence_permission_missing', lang));
}
}, 4000);
}
let gpsToggling = $state(false); let gpsToggling = $state(false);
async function toggleGps() { async function toggleGps() {
if (gpsToggling) return; if (gpsToggling) return;
@@ -369,6 +384,7 @@
useGps = true; useGps = true;
} else { } else {
useGps = await gps.start(getVoiceGuidanceConfig()); useGps = await gps.start(getVoiceGuidanceConfig());
if (useGps) maybeWarnCadence();
} }
} else { } else {
await gps.stop(); await gps.stop();
@@ -471,6 +487,7 @@
if (workout.mode === 'gps' && !gpsStarted && !gps.isTracking) { if (workout.mode === 'gps' && !gpsStarted && !gps.isTracking) {
_prestartGps = true; _prestartGps = true;
gps.start(undefined, true); gps.start(undefined, true);
maybeWarnCadence();
} }
}); });
@@ -510,6 +527,7 @@
const exercise = getExerciseById(exerciseId); const exercise = getExerciseById(exerciseId);
if (exercise?.bodyPart === 'cardio' && gps.available && !useGps && !gps.isTracking) { if (exercise?.bodyPart === 'cardio' && gps.available && !useGps && !gps.isTracking) {
useGps = await gps.start(getVoiceGuidanceConfig()); useGps = await gps.start(getVoiceGuidanceConfig());
if (useGps) maybeWarnCadence();
} }
} }
@@ -529,6 +547,7 @@
gpsStarted = true; gpsStarted = true;
useGps = true; useGps = true;
workout.resumeTimer(); workout.resumeTimer();
maybeWarnCadence();
} }
} finally { } finally {
gpsStarting = false; gpsStarting = false;