Compare commits

2 Commits

Author SHA1 Message Date
8b63812734 feat: redesign GPS workout UI with Runkeeper-style map overlay
All checks were successful
CI / update (push) Successful in 2m32s
- Full-screen fixed map with controls overlaid at the bottom
- Activity type selector (running/walking/cycling/hiking) with proper
  exercise mapping for history display
- GPS starts immediately on entering workout screen for faster lock
- GPS track attached to cardio exercise (like GPX upload) so history
  shows distance, pace, splits, and map
- Add activityType field to workout state, session model, and sync
- Cancel button appears when workout is paused
- GPS Workout button only shown in Tauri app
2026-03-25 19:54:30 +01:00
d75e2354f6 feat: add TTS voice guidance during GPS-tracked workouts
Voice announcements run entirely in the Android foreground service
(works with screen locked). Configurable via web UI before starting
GPS: time-based or distance-based intervals, selectable metrics
(total time, distance, avg/split/current pace), language (en/de).

Also syncs workout pause/resume state to the native service — pausing
the workout timer now freezes the Android-side elapsed time, distance
accumulation, and TTS triggers.

Includes TTS engine detection with install prompt if none found, and
Android 11+ package visibility query for TTS service discovery.
2026-03-25 13:13:04 +01:00
12 changed files with 1363 additions and 65 deletions

View File

@@ -11,6 +11,12 @@
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<queries>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
</queries>
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"

View File

@@ -6,14 +6,20 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.speech.tts.TextToSpeech
import android.webkit.JavascriptInterface
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import org.json.JSONArray
import org.json.JSONObject
import java.util.Locale
class AndroidBridge(private val context: Context) {
private var ttsForVoices: TextToSpeech? = null
@JavascriptInterface
fun startLocationService() {
fun startLocationService(ttsConfigJson: String) {
if (context is Activity) {
// Request notification permission on Android 13+ (required for foreground service notification)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -42,7 +48,9 @@ class AndroidBridge(private val context: Context) {
}
}
val intent = Intent(context, LocationForegroundService::class.java)
val intent = Intent(context, LocationForegroundService::class.java).apply {
putExtra("ttsConfig", ttsConfigJson)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
@@ -50,6 +58,12 @@ class AndroidBridge(private val context: Context) {
}
}
/** Backwards-compatible overload for calls without TTS config */
@JavascriptInterface
fun startLocationService() {
startLocationService("{}")
}
@JavascriptInterface
fun stopLocationService() {
val intent = Intent(context, LocationForegroundService::class.java)
@@ -65,4 +79,62 @@ class AndroidBridge(private val context: Context) {
fun isTracking(): Boolean {
return LocationForegroundService.tracking
}
@JavascriptInterface
fun pauseTracking() {
LocationForegroundService.instance?.doPause()
}
@JavascriptInterface
fun resumeTracking() {
LocationForegroundService.instance?.doResume()
}
/** Returns true if at least one TTS engine is installed on the device. */
@JavascriptInterface
fun hasTtsEngine(): Boolean {
val dummy = TextToSpeech(context, null)
val hasEngine = dummy.engines.isNotEmpty()
dummy.shutdown()
return hasEngine
}
/** Opens the Android TTS install intent (prompts user to install a TTS engine). */
@JavascriptInterface
fun installTtsEngine() {
val intent = Intent(TextToSpeech.Engine.ACTION_INSTALL_TTS_DATA)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
/**
* Returns available TTS voices as a JSON array.
* Each entry: { "id": "...", "name": "...", "language": "en-US" }
* This initializes a temporary TTS engine; the result is returned asynchronously
* via a callback, but since @JavascriptInterface is synchronous we block briefly.
*/
@JavascriptInterface
fun getAvailableTtsVoices(): String {
val result = JSONArray()
try {
val latch = java.util.concurrent.CountDownLatch(1)
var engine: TextToSpeech? = null
engine = TextToSpeech(context) { status ->
if (status == TextToSpeech.SUCCESS) {
engine?.voices?.forEach { voice ->
val obj = JSONObject().apply {
put("id", voice.name)
put("name", voice.name)
put("language", voice.locale.toLanguageTag())
}
result.put(obj)
}
}
latch.countDown()
}
latch.await(3, java.util.concurrent.TimeUnit.SECONDS)
engine.shutdown()
} catch (_: Exception) {}
return result.toString()
}
}

View File

@@ -10,24 +10,77 @@ import android.content.Intent
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.speech.tts.TextToSpeech
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.util.Collections
import java.util.Locale
import kotlin.math.*
class LocationForegroundService : Service() {
private const val TAG = "BockenTTS"
class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
private var locationManager: LocationManager? = null
private var locationListener: LocationListener? = null
private var notificationManager: NotificationManager? = null
private var pendingIntent: PendingIntent? = null
private var startTimeMs: Long = 0L
private var pausedAccumulatedMs: Long = 0L // total time spent paused
private var pausedSinceMs: Long = 0L // timestamp when last paused (0 = not paused)
private var lastLat: Double = Double.NaN
private var lastLng: Double = Double.NaN
private var lastTimestamp: Long = 0L
private var currentPaceMinKm: Double = 0.0
// TTS
private var tts: TextToSpeech? = null
private var ttsReady = false
private var ttsConfig: TtsConfig? = null
private var ttsTimeHandler: Handler? = null
private var ttsTimeRunnable: Runnable? = null
private var lastAnnouncementDistanceKm: Double = 0.0
private var lastAnnouncementTimeMs: Long = 0L
private var splitDistanceAtLastAnnouncement: Double = 0.0
private var splitTimeAtLastAnnouncement: Long = 0L
data class TtsConfig(
val enabled: Boolean = false,
val triggerType: String = "distance", // "distance" or "time"
val triggerValue: Double = 1.0, // km or minutes
val metrics: List<String> = listOf("totalTime", "totalDistance", "avgPace"),
val language: String = "en",
val voiceId: String? = null
) {
companion object {
fun fromJson(json: String): TtsConfig {
return try {
val obj = JSONObject(json)
val metricsArr = obj.optJSONArray("metrics")
val metrics = if (metricsArr != null) {
(0 until metricsArr.length()).map { metricsArr.getString(it) }
} else {
listOf("totalTime", "totalDistance", "avgPace")
}
TtsConfig(
enabled = obj.optBoolean("enabled", false),
triggerType = obj.optString("triggerType", "distance"),
triggerValue = obj.optDouble("triggerValue", 1.0),
metrics = metrics,
language = obj.optString("language", "en"),
voiceId = obj.optString("voiceId", null)
)
} catch (_: Exception) {
TtsConfig()
}
}
}
}
companion object {
const val CHANNEL_ID = "gps_tracking"
const val NOTIFICATION_ID = 1001
@@ -35,8 +88,12 @@ class LocationForegroundService : Service() {
const val MIN_DISTANCE_M = 0f
private val pointBuffer = Collections.synchronizedList(mutableListOf<JSONObject>())
var instance: LocationForegroundService? = null
private set
var tracking = false
private set
var paused = false
private set
var totalDistanceKm: Double = 0.0
private set
@@ -71,12 +128,21 @@ class LocationForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startTimeMs = System.currentTimeMillis()
pausedAccumulatedMs = 0L
pausedSinceMs = 0L
paused = false
totalDistanceKm = 0.0
lastLat = Double.NaN
lastLng = Double.NaN
lastTimestamp = 0L
currentPaceMinKm = 0.0
// Parse TTS config from intent
val configJson = intent?.getStringExtra("ttsConfig") ?: "{}"
Log.d(TAG, "TTS config JSON: $configJson")
ttsConfig = TtsConfig.fromJson(configJson)
Log.d(TAG, "TTS enabled=${ttsConfig?.enabled}, trigger=${ttsConfig?.triggerType}/${ttsConfig?.triggerValue}, metrics=${ttsConfig?.metrics}")
val notifIntent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
@@ -95,10 +161,200 @@ class LocationForegroundService : Service() {
startLocationUpdates()
tracking = true
instance = this
// Initialize TTS *after* startForeground — using applicationContext for reliable engine binding
if (ttsConfig?.enabled == true) {
Log.d(TAG, "Initializing TTS engine (post-startForeground)...")
lastAnnouncementDistanceKm = 0.0
lastAnnouncementTimeMs = startTimeMs
splitDistanceAtLastAnnouncement = 0.0
splitTimeAtLastAnnouncement = startTimeMs
// Log available TTS engines
val dummyTts = TextToSpeech(applicationContext, null)
val engines = dummyTts.engines
Log.d(TAG, "Available TTS engines: ${engines.map { "${it.label} (${it.name})" }}")
dummyTts.shutdown()
// Try with explicit engine if available
if (engines.isNotEmpty()) {
val engineName = engines[0].name
Log.d(TAG, "Trying TTS with explicit engine: $engineName")
tts = TextToSpeech(applicationContext, this, engineName)
} else {
Log.e(TAG, "No TTS engines found on device!")
tts = TextToSpeech(applicationContext, this)
}
}
return START_STICKY
}
// --- TTS ---
override fun onInit(status: Int) {
Log.d(TAG, "TTS onInit status=$status (SUCCESS=${TextToSpeech.SUCCESS})")
if (status == TextToSpeech.SUCCESS) {
val config = ttsConfig ?: return
val locale = Locale.forLanguageTag(config.language)
val langResult = tts?.setLanguage(locale)
Log.d(TAG, "TTS setLanguage($locale) result=$langResult")
// Set specific voice if requested
if (!config.voiceId.isNullOrEmpty()) {
tts?.voices?.find { it.name == config.voiceId }?.let { voice ->
tts?.voice = voice
}
}
ttsReady = true
Log.d(TAG, "TTS ready! triggerType=${config.triggerType}, triggerValue=${config.triggerValue}")
// Set up time-based trigger if configured
if (config.triggerType == "time") {
startTimeTrigger(config.triggerValue)
}
} else {
Log.e(TAG, "TTS init FAILED with status=$status")
}
}
private fun startTimeTrigger(intervalMinutes: Double) {
val intervalMs = (intervalMinutes * 60 * 1000).toLong()
Log.d(TAG, "Starting time trigger: every ${intervalMs}ms (${intervalMinutes} min)")
ttsTimeHandler = Handler(Looper.getMainLooper())
ttsTimeRunnable = object : Runnable {
override fun run() {
Log.d(TAG, "Time trigger fired!")
announceMetrics()
ttsTimeHandler?.postDelayed(this, intervalMs)
}
}
ttsTimeHandler?.postDelayed(ttsTimeRunnable!!, intervalMs)
}
// --- Pause / Resume ---
fun doPause() {
if (paused) return
paused = true
pausedSinceMs = System.currentTimeMillis()
Log.d(TAG, "Tracking paused")
// Pause TTS time trigger
ttsTimeRunnable?.let { ttsTimeHandler?.removeCallbacks(it) }
// Update notification to show paused state
val notification = buildNotification(formatElapsed(), "%.2f km".format(totalDistanceKm), "PAUSED")
notificationManager?.notify(NOTIFICATION_ID, notification)
}
fun doResume() {
if (!paused) return
// Accumulate paused duration
pausedAccumulatedMs += System.currentTimeMillis() - pausedSinceMs
pausedSinceMs = 0L
paused = false
Log.d(TAG, "Tracking resumed (total paused: ${pausedAccumulatedMs / 1000}s)")
// Reset last position so we don't accumulate drift during pause
lastLat = Double.NaN
lastLng = Double.NaN
lastTimestamp = 0L
// Resume TTS time trigger
val config = ttsConfig
if (ttsReady && config != null && config.triggerType == "time") {
val intervalMs = (config.triggerValue * 60 * 1000).toLong()
ttsTimeRunnable?.let { ttsTimeHandler?.postDelayed(it, intervalMs) }
}
updateNotification()
}
private fun checkDistanceTrigger() {
val config = ttsConfig ?: return
if (!ttsReady || config.triggerType != "distance") return
val sinceLast = totalDistanceKm - lastAnnouncementDistanceKm
if (sinceLast >= config.triggerValue) {
announceMetrics()
lastAnnouncementDistanceKm = totalDistanceKm
}
}
private fun announceMetrics() {
if (!ttsReady) return
val config = ttsConfig ?: return
val now = System.currentTimeMillis()
val activeSecs = activeElapsedSecs()
val parts = mutableListOf<String>()
for (metric in config.metrics) {
when (metric) {
"totalTime" -> {
val h = activeSecs / 3600
val m = (activeSecs % 3600) / 60
val s = activeSecs % 60
val timeStr = if (h > 0) {
"$h hours $m minutes"
} else {
"$m minutes $s seconds"
}
parts.add("Time: $timeStr")
}
"totalDistance" -> {
val distStr = "%.2f".format(totalDistanceKm)
parts.add("Distance: $distStr kilometers")
}
"avgPace" -> {
val elapsedMin = activeSecs / 60.0
if (totalDistanceKm > 0.01) {
val avgPace = elapsedMin / totalDistanceKm
val mins = avgPace.toInt()
val secs = ((avgPace - mins) * 60).toInt()
parts.add("Average pace: $mins minutes $secs seconds per kilometer")
}
}
"splitPace" -> {
val splitDist = totalDistanceKm - splitDistanceAtLastAnnouncement
val splitTimeMin = (now - splitTimeAtLastAnnouncement) / 60000.0
if (splitDist > 0.01) {
val splitPace = splitTimeMin / splitDist
val mins = splitPace.toInt()
val secs = ((splitPace - mins) * 60).toInt()
parts.add("Split pace: $mins minutes $secs seconds per kilometer")
}
}
"currentPace" -> {
if (currentPaceMinKm > 0 && currentPaceMinKm <= 60) {
val mins = currentPaceMinKm.toInt()
val secs = ((currentPaceMinKm - mins) * 60).toInt()
parts.add("Current pace: $mins minutes $secs seconds per kilometer")
}
}
}
}
// Update split tracking
splitDistanceAtLastAnnouncement = totalDistanceKm
splitTimeAtLastAnnouncement = now
lastAnnouncementTimeMs = now
if (parts.isNotEmpty()) {
val text = parts.joinToString(". ")
Log.d(TAG, "Announcing: $text")
val result = tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "workout_announcement")
Log.d(TAG, "TTS speak() result=$result (SUCCESS=${TextToSpeech.SUCCESS})")
} else {
Log.d(TAG, "announceMetrics: no parts to announce")
}
}
// --- Notification / Location (unchanged) ---
private fun formatPace(paceMinKm: Double): String {
if (paceMinKm <= 0 || paceMinKm > 60) return ""
val mins = paceMinKm.toInt()
@@ -130,8 +386,15 @@ class LocationForegroundService : Service() {
}
}
/** Returns active (non-paused) elapsed time in seconds. */
private fun activeElapsedSecs(): Long {
val now = System.currentTimeMillis()
val totalPaused = pausedAccumulatedMs + if (pausedSinceMs > 0) (now - pausedSinceMs) else 0L
return (now - startTimeMs - totalPaused) / 1000
}
private fun formatElapsed(): String {
val secs = (System.currentTimeMillis() - startTimeMs) / 1000
val secs = activeElapsedSecs()
val h = secs / 3600
val m = (secs % 3600) / 60
val s = secs % 60
@@ -158,9 +421,22 @@ class LocationForegroundService : Service() {
locationListener = LocationListener { location ->
val lat = location.latitude
val lng = location.longitude
val now = location.time
// Always buffer GPS points (for track drawing) even when paused
val point = JSONObject().apply {
put("lat", lat)
put("lng", lng)
if (location.hasAltitude()) put("altitude", location.altitude)
if (location.hasSpeed()) put("speed", location.speed.toDouble())
put("timestamp", location.time)
}
pointBuffer.add(point)
// Skip distance/pace accumulation and TTS triggers when paused
if (paused) return@LocationListener
// Accumulate distance and compute pace
val now = location.time
if (!lastLat.isNaN()) {
val segmentKm = haversineKm(lastLat, lastLng, lat, lng)
totalDistanceKm += segmentKm
@@ -173,16 +449,10 @@ class LocationForegroundService : Service() {
lastLng = lng
lastTimestamp = now
val point = JSONObject().apply {
put("lat", lat)
put("lng", lng)
if (location.hasAltitude()) put("altitude", location.altitude)
if (location.hasSpeed()) put("speed", location.speed.toDouble())
put("timestamp", location.time)
}
pointBuffer.add(point)
updateNotification()
// Check distance-based TTS trigger
checkDistanceTrigger()
}
locationManager?.requestLocationUpdates(
@@ -195,9 +465,21 @@ class LocationForegroundService : Service() {
override fun onDestroy() {
tracking = false
paused = false
instance = null
locationListener?.let { locationManager?.removeUpdates(it) }
locationListener = null
locationManager = null
// Clean up TTS
ttsTimeRunnable?.let { ttsTimeHandler?.removeCallbacks(it) }
ttsTimeHandler = null
ttsTimeRunnable = null
tts?.stop()
tts?.shutdown()
tts = null
ttsReady = false
super.onDestroy()
}

View File

@@ -13,11 +13,25 @@ export interface GpsPoint {
timestamp: number;
}
export interface VoiceGuidanceConfig {
enabled: boolean;
triggerType: 'distance' | 'time';
triggerValue: number;
metrics: string[];
language: string;
voiceId?: string;
}
interface AndroidBridge {
startLocationService(): void;
startLocationService(ttsConfigJson: string): void;
stopLocationService(): void;
getPoints(): string;
isTracking(): boolean;
getAvailableTtsVoices(): string;
hasTtsEngine(): boolean;
installTtsEngine(): void;
pauseTracking(): void;
resumeTracking(): void;
}
function checkTauri(): boolean {
@@ -98,7 +112,7 @@ export function createGpsTracker() {
}
}
async function start() {
async function start(voiceGuidance?: VoiceGuidanceConfig) {
_debugMsg = 'starting...';
if (!checkTauri() || isTracking) {
_debugMsg = `bail: tauri=${checkTauri()} tracking=${isTracking}`;
@@ -130,7 +144,8 @@ export function createGpsTracker() {
const bridge = getAndroidBridge();
if (bridge) {
_debugMsg = 'starting native GPS service...';
bridge.startLocationService();
const ttsConfig = JSON.stringify(voiceGuidance ?? {});
bridge.startLocationService(ttsConfig);
// Poll the native side for collected points
_pollTimer = setInterval(pollPoints, POLL_INTERVAL_MS);
_debugMsg = 'native GPS service started, polling...';
@@ -175,6 +190,53 @@ export function createGpsTracker() {
track = [];
}
function getAvailableTtsVoices(): Array<{ id: string; name: string; language: string }> {
const bridge = getAndroidBridge();
if (!bridge) return [];
try {
return JSON.parse(bridge.getAvailableTtsVoices());
} catch {
return [];
}
}
function hasTtsEngine(): boolean {
const bridge = getAndroidBridge();
if (!bridge) return false;
return bridge.hasTtsEngine();
}
function installTtsEngine(): void {
const bridge = getAndroidBridge();
bridge?.installTtsEngine();
}
function pauseTracking(): void {
const bridge = getAndroidBridge();
bridge?.pauseTracking();
}
function resumeTracking(): void {
const bridge = getAndroidBridge();
bridge?.resumeTracking();
}
/** Request location permissions without starting the tracking service.
* Returns true if permissions were granted. */
async function ensurePermissions(): Promise<boolean> {
if (!checkTauri()) return false;
try {
const geo = await import('@tauri-apps/plugin-geolocation');
let perms = await geo.checkPermissions();
if (perms.location !== 'granted') {
perms = await geo.requestPermissions(['location']);
}
return perms.location === 'granted';
} catch {
return false;
}
}
return {
get track() { return track; },
get isTracking() { return isTracking; },
@@ -186,7 +248,13 @@ export function createGpsTracker() {
get debug() { return _debugMsg; },
start,
stop,
reset
reset,
getAvailableTtsVoices,
hasTtsEngine,
installTtsEngine,
pauseTracking,
resumeTracking,
ensurePermissions
};
}

View File

@@ -33,9 +33,14 @@ export interface TemplateData {
const STORAGE_KEY = 'fitness-active-workout';
export type WorkoutMode = 'manual' | 'gps';
export type GpsActivityType = 'running' | 'walking' | 'cycling' | 'hiking';
export interface StoredState {
active: boolean;
paused: boolean;
mode: WorkoutMode;
activityType: GpsActivityType | null;
name: string;
templateId: string | null;
exercises: WorkoutExercise[];
@@ -49,6 +54,8 @@ export interface StoredState {
export interface RemoteState {
name: string;
mode: WorkoutMode;
activityType: GpsActivityType | null;
templateId: string | null;
exercises: WorkoutExercise[];
paused: boolean;
@@ -89,6 +96,8 @@ function clearStorage() {
export function createWorkout() {
let active = $state(false);
let paused = $state(false);
let mode = $state<WorkoutMode>('manual');
let activityType = $state<GpsActivityType | null>(null);
let name = $state('');
let templateId: string | null = $state(null);
let exercises = $state<WorkoutExercise[]>([]);
@@ -115,6 +124,8 @@ export function createWorkout() {
saveToStorage({
active,
paused,
mode,
activityType,
name,
templateId,
exercises: JSON.parse(JSON.stringify(exercises)),
@@ -182,6 +193,8 @@ export function createWorkout() {
active = true;
paused = stored.paused;
mode = stored.mode ?? 'manual';
activityType = stored.activityType ?? null;
name = stored.name;
templateId = stored.templateId;
exercises = stored.exercises;
@@ -220,6 +233,7 @@ export function createWorkout() {
function startFromTemplate(template: TemplateData) {
name = template.name;
templateId = template._id;
mode = 'manual';
exercises = template.exercises.map((e) => ({
exerciseId: e.exerciseId,
sets: e.sets.length > 0
@@ -246,6 +260,7 @@ export function createWorkout() {
function startEmpty() {
name = 'Quick Workout';
templateId = null;
mode = 'manual';
exercises = [];
startTime = new Date();
_pausedElapsed = 0;
@@ -256,6 +271,26 @@ export function createWorkout() {
_persist();
}
function startGpsWorkout(activity: GpsActivityType = 'running') {
const labels: Record<GpsActivityType, string> = {
running: 'Running',
walking: 'Walking',
cycling: 'Cycling',
hiking: 'Hiking'
};
name = labels[activity];
templateId = null;
mode = 'gps';
activityType = activity;
exercises = [];
startTime = null;
_pausedElapsed = 0;
_elapsed = 0;
paused = true;
active = true;
_persist();
}
function pauseTimer() {
if (!active || paused) return;
_computeElapsed();
@@ -374,6 +409,8 @@ export function createWorkout() {
templateId,
templateName: templateId ? name : undefined,
name,
mode,
activityType,
exercises: exercises
.filter((e) => e.sets.some((s) => s.completed))
.map((e) => ({
@@ -409,6 +446,8 @@ export function createWorkout() {
function _reset() {
active = false;
paused = false;
mode = 'manual';
activityType = null;
name = '';
templateId = null;
exercises = [];
@@ -427,6 +466,8 @@ export function createWorkout() {
/** Apply state from another device (merge strategy: incoming wins) */
function applyRemoteState(remote: RemoteState) {
name = remote.name;
mode = remote.mode ?? 'manual';
activityType = remote.activityType ?? null;
templateId = remote.templateId;
exercises = remote.exercises;
@@ -470,6 +511,8 @@ export function createWorkout() {
saveToStorage({
active: true,
paused,
mode,
activityType,
name,
templateId,
exercises: JSON.parse(JSON.stringify(exercises)),
@@ -496,6 +539,8 @@ export function createWorkout() {
return {
get active() { return active; },
get paused() { return paused; },
get mode() { return mode; },
get activityType() { return activityType; },
get name() { return name; },
set name(v: string) { name = v; _persist(); },
get templateId() { return templateId; },
@@ -511,6 +556,7 @@ export function createWorkout() {
restore,
startFromTemplate,
startEmpty,
startGpsWorkout,
pauseTimer,
resumeTimer,
addExercise,

View File

@@ -7,13 +7,15 @@
*/
import { getWorkout } from '$lib/js/workout.svelte';
import type { WorkoutExercise } from '$lib/js/workout.svelte';
import type { WorkoutExercise, WorkoutMode, GpsActivityType } from '$lib/js/workout.svelte';
type SyncStatus = 'idle' | 'synced' | 'syncing' | 'offline' | 'conflict';
interface ServerWorkout {
version: number;
name: string;
mode: WorkoutMode;
activityType: GpsActivityType | null;
templateId: string | null;
exercises: WorkoutExercise[];
paused: boolean;
@@ -42,6 +44,8 @@ export function createWorkoutSync() {
return {
version: serverVersion,
name: workout.name,
mode: workout.mode,
activityType: workout.activityType,
templateId: workout.templateId,
exercises: JSON.parse(JSON.stringify(workout.exercises)),
paused: workout.paused,
@@ -107,6 +111,8 @@ export function createWorkoutSync() {
// but we keep the higher value for completed sets
workout.applyRemoteState({
name: doc.name,
mode: doc.mode ?? 'manual',
activityType: doc.activityType ?? null,
templateId: doc.templateId,
exercises: doc.exercises,
paused: doc.paused,
@@ -225,6 +231,8 @@ export function createWorkoutSync() {
serverVersion = serverDoc.version;
workout.restoreFromRemote({
name: serverDoc.name,
mode: serverDoc.mode ?? 'manual',
activityType: serverDoc.activityType ?? null,
templateId: serverDoc.templateId,
exercises: serverDoc.exercises,
paused: serverDoc.paused,

View File

@@ -18,6 +18,8 @@ export interface IActiveWorkout {
userId: string;
version: number;
name: string;
mode: 'manual' | 'gps';
activityType: 'running' | 'walking' | 'cycling' | 'hiking' | null;
templateId: string | null;
exercises: IActiveWorkoutExercise[];
paused: boolean;
@@ -62,6 +64,16 @@ const ActiveWorkoutSchema = new mongoose.Schema(
trim: true,
maxlength: 100
},
mode: {
type: String,
enum: ['manual', 'gps'],
default: 'manual'
},
activityType: {
type: String,
enum: ['running', 'walking', 'cycling', 'hiking'],
default: null
},
templateId: {
type: String,
default: null

View File

@@ -41,12 +41,16 @@ export interface IWorkoutSession {
templateId?: string; // Reference to WorkoutTemplate if based on template
templateName?: string; // Snapshot of template name for history
name: string;
mode?: 'manual' | 'gps';
activityType?: 'running' | 'walking' | 'cycling' | 'hiking';
exercises: ICompletedExercise[];
startTime: Date;
endTime?: Date;
duration?: number; // Duration in minutes
totalVolume?: number; // Total weight × reps across all exercises
totalDistance?: number; // Total distance across all cardio exercises
gpsTrack?: IGpsPoint[]; // Top-level GPS track for GPS-only workouts
gpsPreview?: number[][]; // Downsampled [[lat,lng], ...] for card preview
prs?: IPr[];
notes?: string;
createdBy: string; // username/nickname of the person who performed the workout
@@ -155,15 +159,18 @@ const WorkoutSessionSchema = new mongoose.Schema(
trim: true,
maxlength: 100
},
mode: {
type: String,
enum: ['manual', 'gps'],
default: 'manual'
},
activityType: {
type: String,
enum: ['running', 'walking', 'cycling', 'hiking']
},
exercises: {
type: [CompletedExerciseSchema],
required: true,
validate: {
validator: function(exercises: ICompletedExercise[]) {
return exercises.length > 0;
},
message: 'A workout session must have at least one exercise'
}
default: []
},
startTime: {
type: Date,
@@ -185,6 +192,14 @@ const WorkoutSessionSchema = new mongoose.Schema(
type: Number,
min: 0
},
gpsTrack: {
type: [GpsPointSchema],
default: undefined
},
gpsPreview: {
type: [[Number]],
default: undefined
},
prs: [{
exerciseId: { type: String, required: true },
type: { type: String, required: true },

View File

@@ -6,6 +6,7 @@ import type { IPr } from '$models/WorkoutSession';
import { WorkoutTemplate } from '$models/WorkoutTemplate';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { detectCardioPrs } from '$lib/data/cardioPrRanges';
import { simplifyTrack } from '$lib/server/simplifyTrack';
function estimatedOneRepMax(weight: number, reps: number): number {
if (reps <= 0 || weight <= 0) return 0;
@@ -27,7 +28,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
const offset = parseInt(url.searchParams.get('offset') || '0');
const sessions = await WorkoutSession.find({ createdBy: session.user.nickname })
.select('-exercises.gpsTrack')
.select('-exercises.gpsTrack -gpsTrack')
.sort({ startTime: -1 })
.limit(limit)
.skip(offset);
@@ -52,10 +53,10 @@ export const POST: RequestHandler = async ({ request, locals }) => {
await dbConnect();
const data = await request.json();
const { templateId, name, exercises, startTime, endTime, notes } = data;
const { templateId, name, mode, activityType, exercises, startTime, endTime, notes, gpsTrack, totalDistance: gpsDistance } = data;
if (!name || !exercises || !Array.isArray(exercises) || exercises.length === 0) {
return json({ error: 'Name and at least one exercise are required' }, { status: 400 });
if (!name || (!exercises?.length && !gpsTrack?.length)) {
return json({ error: 'Name and at least one exercise or GPS track required' }, { status: 400 });
}
let templateName;
@@ -68,8 +69,8 @@ export const POST: RequestHandler = async ({ request, locals }) => {
// Compute totalVolume and totalDistance
let totalVolume = 0;
let totalDistance = 0;
for (const ex of exercises) {
let totalDistance = gpsDistance ?? 0;
for (const ex of (exercises ?? [])) {
const exercise = getExerciseById(ex.exerciseId);
const metrics = getExerciseMetrics(exercise);
const isCardio = metrics.includes('distance');
@@ -86,7 +87,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
// Detect PRs by comparing against previous best for each exercise
const prs: IPr[] = [];
for (const ex of exercises) {
for (const ex of (exercises ?? [])) {
const exercise = getExerciseById(ex.exerciseId);
const metrics = getExerciseMetrics(exercise);
const isCardio = metrics.includes('distance');
@@ -143,16 +144,31 @@ export const POST: RequestHandler = async ({ request, locals }) => {
}
}
// Generate GPS preview for top-level GPS track
const gpsPreview = gpsTrack?.length >= 2 ? simplifyTrack(gpsTrack) : undefined;
// Generate gpsPreview for exercise-level GPS tracks
const processedExercises = (exercises ?? []).map((ex: any) => {
if (ex.gpsTrack?.length >= 2 && !ex.gpsPreview) {
return { ...ex, gpsPreview: simplifyTrack(ex.gpsTrack) };
}
return ex;
});
const workoutSession = new WorkoutSession({
templateId,
templateName,
name,
exercises,
mode: mode ?? (gpsTrack?.length ? 'gps' : 'manual'),
activityType: activityType ?? undefined,
exercises: processedExercises,
startTime: startTime ? new Date(startTime) : new Date(),
endTime: endTime ? new Date(endTime) : undefined,
duration: endTime && startTime ? Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / (1000 * 60)) : undefined,
totalVolume: totalVolume > 0 ? totalVolume : undefined,
totalDistance: totalDistance > 0 ? totalDistance : undefined,
gpsTrack: gpsTrack?.length ? gpsTrack : undefined,
gpsPreview,
prs: prs.length > 0 ? prs : undefined,
notes,
createdBy: session.user.nickname

View File

@@ -34,7 +34,7 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
try {
await dbConnect();
const data = await request.json();
const { name, templateId, exercises, paused, elapsed, savedAt, expectedVersion, restStartedAt, restTotal, restExerciseIdx, restSetIdx } = data;
const { name, mode, activityType, templateId, exercises, paused, elapsed, savedAt, expectedVersion, restStartedAt, restTotal, restExerciseIdx, restSetIdx } = data;
if (!name) {
return json({ error: 'Name is required' }, { status: 400 });
@@ -58,6 +58,8 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
{
$set: {
name,
mode: mode ?? 'manual',
activityType: activityType ?? null,
templateId: templateId ?? null,
exercises: exercises ?? [],
paused: paused ?? false,

View File

@@ -2,7 +2,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { Plus, Trash2, Play, Pencil, X, Save, CalendarClock, ChevronUp, ChevronDown, ArrowRight } from 'lucide-svelte';
import { Plus, Trash2, Play, Pencil, X, Save, CalendarClock, ChevronUp, ChevronDown, ArrowRight, MapPin, Dumbbell } from 'lucide-svelte';
import { getWorkout } from '$lib/js/workout.svelte';
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
@@ -48,8 +48,10 @@
/** @type {any} */
let nextTemplate = $derived(nextTemplateId ? templates.find((t) => t._id === nextTemplateId) : null);
let hasSchedule = $derived(scheduleOrder.length > 0);
let isApp = $state(false);
onMount(() => {
isApp = '__TAURI__' in window;
workout.restore();
// If there's an active workout, redirect to the active page
@@ -93,6 +95,12 @@
goto(`/fitness/${sl.workout}/${sl.active}`);
}
async function startGps() {
workout.startGpsWorkout('running');
await sync.onWorkoutStart();
goto(`/fitness/${sl.workout}/${sl.active}`);
}
async function startNextScheduled() {
if (!nextTemplate) return;
await startFromTemplate(nextTemplate);
@@ -333,9 +341,18 @@
{/if}
<section class="quick-start">
<button class="start-empty-btn" onclick={startEmpty}>
{t('start_empty_workout', lang)}
</button>
<div class="quick-start-row">
{#if isApp}
<button class="start-choice-btn" onclick={startGps}>
<MapPin size={18} />
<span>GPS Workout</span>
</button>
{/if}
<button class="start-choice-btn" onclick={startEmpty}>
{#if isApp}<Dumbbell size={18} />{/if}
<span>{t('start_empty_workout', lang)}</span>
</button>
</div>
</section>
<section class="templates-section">
@@ -638,19 +655,27 @@
.quick-start {
text-align: center;
}
.start-empty-btn {
width: 100%;
padding: 0.9rem;
.quick-start-row {
display: flex;
gap: 0.5rem;
}
.start-choice-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
padding: 1rem 0.5rem;
background: var(--color-primary);
color: var(--primary-contrast);
border: none;
border-radius: 10px;
font-weight: 700;
font-size: 0.9rem;
font-size: 0.85rem;
cursor: pointer;
letter-spacing: 0.03em;
}
.start-empty-btn:hover {
.start-choice-btn:hover {
opacity: 0.9;
}
.templates-header {

View File

@@ -1,7 +1,7 @@
<script>
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame, MapPin } from 'lucide-svelte';
import { Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame, MapPin, Volume2, X } from 'lucide-svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname));
@@ -41,6 +41,65 @@
let useGps = $state(gps.isTracking);
// Voice guidance config
let vgEnabled = $state(false);
let vgTriggerType = $state('distance');
let vgTriggerValue = $state(1);
let vgMetrics = $state(['totalTime', 'totalDistance', 'avgPace']);
let vgLanguage = $state('en');
let vgShowPanel = $state(false);
// GPS workout mode state — if we're restoring a GPS workout that was already tracking, it's started
let gpsStarted = $state(gps.isTracking && workout.mode === 'gps' && !workout.paused);
let gpsStarting = $state(false);
// Activity type for GPS workouts
/** @type {import('$lib/js/workout.svelte').GpsActivityType} */
let selectedActivity = $state(workout.activityType ?? 'running');
let showActivityPicker = $state(false);
let showAudioPanel = $state(false);
const GPS_ACTIVITIES = [
{ id: 'running', label: 'Running', icon: '🏃' },
{ id: 'walking', label: 'Walking', icon: '🚶' },
{ id: 'cycling', label: 'Cycling', icon: '🚴' },
{ id: 'hiking', label: 'Hiking', icon: '🥾' },
];
function selectActivity(/** @type {string} */ id) {
selectedActivity = /** @type {import('$lib/js/workout.svelte').GpsActivityType} */ (id);
const labels = { running: 'Running', walking: 'Walking', cycling: 'Cycling', hiking: 'Hiking' };
workout.name = labels[selectedActivity] ?? 'GPS Workout';
showActivityPicker = false;
}
const availableMetrics = [
{ id: 'totalTime', label: 'Total Time' },
{ id: 'totalDistance', label: 'Total Distance' },
{ id: 'avgPace', label: 'Average Pace' },
{ id: 'splitPace', label: 'Split Pace' },
{ id: 'currentPace', label: 'Current Pace' },
];
function getVoiceGuidanceConfig() {
if (!vgEnabled) return undefined;
return {
enabled: true,
triggerType: vgTriggerType,
triggerValue: vgTriggerValue,
metrics: vgMetrics,
language: vgLanguage
};
}
function toggleMetric(id) {
if (vgMetrics.includes(id)) {
vgMetrics = vgMetrics.filter(m => m !== id);
} else {
vgMetrics = [...vgMetrics, id];
}
}
/** @type {any} */
let liveMap = null;
/** @type {any} */
@@ -90,6 +149,22 @@
liveMarker.setLatLng(pts[pts.length - 1]);
liveMap.setView(pts[pts.length - 1], 16);
prevTrackLen = gps.track.length;
} else {
// No track yet — show fallback until GPS kicks in
liveMap.setView([51.5, 10], 16);
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
(pos) => {
if (liveMap) {
const ll = [pos.coords.latitude, pos.coords.longitude];
liveMap.setView(ll, 16);
liveMarker.setLatLng(ll);
}
},
() => {},
{ enableHighAccuracy: true, timeout: 10000 }
);
}
}
}
@@ -102,7 +177,7 @@
if (gps.isTracking) {
useGps = true;
} else {
useGps = await gps.start();
useGps = await gps.start(getVoiceGuidanceConfig());
}
} else {
await gps.stop();
@@ -119,14 +194,27 @@
}
}
// Sync workout pause state to native GPS/TTS service
$effect(() => {
if (!gps.isTracking) return;
if (workout.paused) {
gps.pauseTracking();
} else {
gps.resumeTracking();
}
});
$effect(() => {
const len = gps.track.length;
if (len > prevTrackLen && liveMap && gps.latestPoint) {
// Add all new points since last update (native polling delivers batches)
for (let i = prevTrackLen; i < len; i++) {
const p = gps.track[i];
livePolyline.addLatLng([p.lat, p.lng]);
if (gpsStarted) {
// Only draw the trail once the workout has actually started
for (let i = prevTrackLen; i < len; i++) {
const p = gps.track[i];
livePolyline.addLatLng([p.lat, p.lng]);
}
}
// Always update the position marker
const pt = [gps.latestPoint.lat, gps.latestPoint.lng];
liveMarker.setLatLng(pt);
const zoom = liveMap.getZoom() || 16;
@@ -143,9 +231,19 @@
});
}
let _prestartGps = false;
onMount(() => {
if (!workout.active && !completionData) {
goto(`/fitness/${sl.workout}`);
return;
}
// For GPS workouts in pre-start: start GPS immediately so the map
// shows the user's position while they configure activity/audio.
if (workout.mode === 'gps' && !gpsStarted && !gps.isTracking) {
_prestartGps = true;
gps.start();
}
});
@@ -174,33 +272,86 @@
// Auto-start GPS when adding a cardio exercise
const exercise = getExerciseById(exerciseId);
if (exercise?.bodyPart === 'cardio' && gps.available && !useGps && !gps.isTracking) {
useGps = await gps.start();
useGps = await gps.start(getVoiceGuidanceConfig());
}
}
async function startGpsWorkout() {
if (gpsStarting) return;
gpsStarting = true;
try {
if (_prestartGps && gps.isTracking) {
// GPS was running for pre-start preview — stop and restart
// so the native service resets time/distance to zero
await gps.stop();
gps.reset();
}
const started = await gps.start(getVoiceGuidanceConfig());
if (started) {
gpsStarted = true;
useGps = true;
workout.resumeTimer();
}
} finally {
gpsStarting = false;
_prestartGps = false;
}
}
/** Map GPS activity types to exercise IDs */
const ACTIVITY_EXERCISE_MAP = /** @type {Record<string, string>} */ ({
running: 'running',
walking: 'walking',
cycling: 'cycling-outdoor',
hiking: 'hiking',
});
async function finishWorkout() {
// Stop GPS tracking and collect track data
const gpsTrack = gps.isTracking ? await gps.stop() : [];
const wasGpsMode = workout.mode === 'gps';
const actType = workout.activityType;
const sessionData = workout.finish();
if (sessionData.exercises.length === 0) {
if (wasGpsMode && gpsTrack.length >= 2) {
// GPS workout: create a cardio exercise entry with the track attached,
// just like a manually-added workout with GPX upload
const filteredDistance = trackDistance(gpsTrack);
const durationMin = (gpsTrack[gpsTrack.length - 1].timestamp - gpsTrack[0].timestamp) / 60000;
const exerciseId = ACTIVITY_EXERCISE_MAP[actType ?? 'running'] ?? 'running';
const exerciseName = getExerciseById(exerciseId)?.name ?? exerciseId;
sessionData.exercises = [{
exerciseId,
name: exerciseName,
sets: [{
distance: filteredDistance,
duration: Math.round(durationMin * 100) / 100,
completed: true,
}],
gpsTrack,
totalDistance: filteredDistance,
}];
} else if (wasGpsMode && gpsTrack.length === 0) {
// GPS workout with no track data — nothing to save
gps.reset();
await sync.onWorkoutEnd();
await goto(`/fitness/${sl.workout}`);
return;
}
} else {
// Manual workout: attach GPS to cardio exercises
const workoutStart = new Date(sessionData.startTime).getTime();
const filteredTrack = gpsTrack.filter((/** @type {any} */ p) => p.timestamp >= workoutStart);
const filteredDistance = trackDistance(filteredTrack);
// Only save GPS points recorded while the workout timer was running
const workoutStart = new Date(sessionData.startTime).getTime();
const filteredTrack = gpsTrack.filter((/** @type {any} */ p) => p.timestamp >= workoutStart);
const filteredDistance = trackDistance(filteredTrack);
if (filteredTrack.length > 0) {
for (const ex of sessionData.exercises) {
const exercise = getExerciseById(ex.exerciseId);
if (exercise?.bodyPart === 'cardio') {
ex.gpsTrack = filteredTrack;
ex.totalDistance = filteredDistance;
if (filteredTrack.length > 0) {
for (const ex of sessionData.exercises) {
const exercise = getExerciseById(ex.exerciseId);
if (exercise?.bodyPart === 'cardio') {
ex.gpsTrack = filteredTrack;
ex.totalDistance = filteredDistance;
}
}
}
}
@@ -241,7 +392,7 @@
const durationMin = Math.round((endTime.getTime() - startTime.getTime()) / 60000);
let totalTonnage = 0;
let totalDistance = 0;
let totalDistance = local.totalDistance ?? 0;
/** @type {any[]} */
const prs = [];
@@ -705,6 +856,155 @@
</button>
</div>
{:else if workout.active && workout.mode === 'gps'}
<div class="gps-workout">
<div class="gps-workout-map" use:mountMap></div>
<!-- Overlay: sits on top of the map at the bottom -->
<div class="gps-overlay">
{#if gpsStarted}
<div class="gps-workout-stats">
<div class="gps-stat">
<span class="gps-stat-value">{gps.distance.toFixed(2)}</span>
<span class="gps-stat-unit">km</span>
</div>
<div class="gps-stat">
<span class="gps-stat-value">{formatElapsed(workout.elapsedSeconds)}</span>
<span class="gps-stat-unit">time</span>
</div>
{#if gps.currentPace > 0}
<div class="gps-stat">
<span class="gps-stat-value">{Math.floor(gps.currentPace)}:{Math.round((gps.currentPace % 1) * 60).toString().padStart(2, '0')}</span>
<span class="gps-stat-unit">/km</span>
</div>
{/if}
</div>
{#if vgEnabled}
<div class="vg-active-badge">
<Volume2 size={12} />
<span>Voice: every {vgTriggerValue} {vgTriggerType === 'distance' ? 'km' : 'min'}</span>
</div>
{/if}
<div class="gps-overlay-actions">
<button class="gps-overlay-pause" onclick={() => workout.paused ? workout.resumeTimer() : workout.pauseTimer()} aria-label={workout.paused ? 'Resume' : 'Pause'}>
{#if workout.paused}<Play size={22} />{:else}<Pause size={22} />{/if}
</button>
{#if workout.paused}
<button class="gps-overlay-cancel" onclick={async () => { if (gps.isTracking) await gps.stop(); gps.reset(); workout.cancel(); await sync.onWorkoutEnd(); await goto(`/fitness/${sl.workout}`); }}>
<Trash2 size={18} />
</button>
{/if}
<button class="gps-overlay-finish" onclick={finishWorkout}>Finish</button>
</div>
{:else}
<div class="gps-options-grid">
<button class="gps-option-tile" onclick={() => { showActivityPicker = !showActivityPicker; showAudioPanel = false; }} type="button">
<span class="gps-option-icon">{GPS_ACTIVITIES.find(a => a.id === selectedActivity)?.icon ?? '🏃'}</span>
<span class="gps-option-label">Activity</span>
<span class="gps-option-value">{GPS_ACTIVITIES.find(a => a.id === selectedActivity)?.label ?? 'Running'}</span>
</button>
<button class="gps-option-tile" onclick={() => { showAudioPanel = !showAudioPanel; showActivityPicker = false; }} type="button">
<Volume2 size={20} />
<span class="gps-option-label">Audio Stats</span>
<span class="gps-option-value">{vgEnabled ? `Every ${vgTriggerValue} ${vgTriggerType === 'distance' ? 'km' : 'min'}` : 'Off'}</span>
</button>
</div>
{#if showActivityPicker}
<div class="gps-activity-picker">
{#each GPS_ACTIVITIES as act (act.id)}
<button
class="gps-activity-choice"
class:active={selectedActivity === act.id}
onclick={() => selectActivity(act.id)}
type="button"
>
<span class="gps-activity-icon">{act.icon}</span>
<span>{act.label}</span>
</button>
{/each}
</div>
{/if}
{#if showAudioPanel}
<div class="vg-panel">
{#if !gps.hasTtsEngine()}
<div class="vg-no-engine">
<span>No text-to-speech engine installed.</span>
<button class="vg-install-btn" onclick={() => gps.installTtsEngine()} type="button">
Install TTS Engine
</button>
</div>
{:else}
<label class="vg-row">
<input type="checkbox" bind:checked={vgEnabled} />
<span>Enable voice announcements</span>
</label>
{#if vgEnabled}
<div class="vg-group">
<span class="vg-label">Announce every</span>
<div class="vg-trigger-row">
<input
class="vg-number"
type="number"
min="0.1"
step="0.5"
bind:value={vgTriggerValue}
/>
<select class="vg-select" bind:value={vgTriggerType}>
<option value="distance">km</option>
<option value="time">min</option>
</select>
</div>
</div>
<div class="vg-group">
<span class="vg-label">Metrics</span>
<div class="vg-metrics">
{#each availableMetrics as m (m.id)}
<button
class="vg-metric-chip"
class:selected={vgMetrics.includes(m.id)}
onclick={() => toggleMetric(m.id)}
type="button"
>
{m.label}
</button>
{/each}
</div>
</div>
<div class="vg-group">
<span class="vg-label">Language</span>
<select class="vg-select" bind:value={vgLanguage}>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
</div>
{/if}
{/if}
</div>
{/if}
<button class="gps-start-btn" onclick={startGpsWorkout} disabled={gpsStarting}>
{#if gpsStarting}
<span class="gps-spinner"></span> Initializing GPS…
{:else}
Start
{/if}
</button>
<button class="gps-cancel-link" onclick={async () => { if (gps.isTracking) await gps.stop(); gps.reset(); workout.cancel(); await sync.onWorkoutEnd(); await goto(`/fitness/${sl.workout}`); }} type="button">
<X size={14} />
{t('cancel_workout', lang)}
</button>
{/if}
</div>
</div>
{:else if workout.active}
<div class="active-workout">
<input
@@ -729,6 +1029,76 @@
<span class="gps-spinner"></span> {t('initializing_gps', lang) ?? 'Initializing GPS…'}
</div>
{/if}
{#if !useGps}
<button class="vg-toggle-row" onclick={() => vgShowPanel = !vgShowPanel} type="button">
<Volume2 size={14} />
<span class="gps-toggle-track" class:checked={vgEnabled}></span>
<span>Voice Guidance</span>
</button>
{#if vgShowPanel}
<div class="vg-panel">
{#if !gps.hasTtsEngine()}
<div class="vg-no-engine">
<span>No text-to-speech engine installed.</span>
<button class="vg-install-btn" onclick={() => gps.installTtsEngine()} type="button">
Install TTS Engine
</button>
</div>
{:else}
<label class="vg-row">
<input type="checkbox" bind:checked={vgEnabled} />
<span>Enable voice announcements</span>
</label>
{#if vgEnabled}
<div class="vg-group">
<span class="vg-label">Announce every</span>
<div class="vg-trigger-row">
<input
class="vg-number"
type="number"
min="0.1"
step="0.5"
bind:value={vgTriggerValue}
/>
<select class="vg-select" bind:value={vgTriggerType}>
<option value="distance">km</option>
<option value="time">min</option>
</select>
</div>
</div>
<div class="vg-group">
<span class="vg-label">Metrics</span>
<div class="vg-metrics">
{#each availableMetrics as m (m.id)}
<button
class="vg-metric-chip"
class:selected={vgMetrics.includes(m.id)}
onclick={() => toggleMetric(m.id)}
type="button"
>
{m.label}
</button>
{/each}
</div>
</div>
<div class="vg-group">
<span class="vg-label">Language</span>
<select class="vg-select" bind:value={vgLanguage}>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
</div>
{/if}
{/if}
</div>
{/if}
{/if}
{#if useGps}
<div class="gps-bar active">
<span class="gps-distance">{gps.distance.toFixed(2)} km</span>
@@ -737,6 +1107,12 @@
{/if}
<span class="gps-label">{gps.track.length} pts</span>
</div>
{#if vgEnabled}
<div class="vg-active-badge">
<Volume2 size={12} />
<span>Voice: every {vgTriggerValue} {vgTriggerType === 'distance' ? 'km' : 'min'}</span>
</div>
{/if}
<div class="live-map" use:mountMap></div>
{/if}
</div>
@@ -1315,4 +1691,374 @@
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Voice Guidance */
.vg-toggle-row {
display: flex;
align-items: center;
gap: 0.6rem;
color: var(--color-text-primary);
cursor: pointer;
background: none;
border: none;
padding: 0;
font: inherit;
font-size: 0.9rem;
}
.vg-panel {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.5rem 0 0;
border-top: 1px solid var(--color-border);
}
.vg-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.vg-row input[type="checkbox"] {
accent-color: var(--nord14);
}
.vg-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.vg-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
}
.vg-trigger-row {
display: flex;
gap: 0.4rem;
}
.vg-number {
width: 70px;
padding: 0.3rem 0.4rem;
border-radius: 6px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-primary);
font-size: 0.9rem;
}
.vg-select {
padding: 0.3rem 0.4rem;
border-radius: 6px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-primary);
font-size: 0.9rem;
}
.vg-metrics {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.vg-metric-chip {
padding: 0.25rem 0.6rem;
border-radius: 20px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-secondary);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s ease;
}
.vg-metric-chip.selected {
background: var(--nord14);
color: var(--nord0);
border-color: var(--nord14);
}
.vg-no-engine {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.vg-install-btn {
padding: 0.4rem 0.8rem;
border-radius: 6px;
border: 1px solid var(--nord14);
background: transparent;
color: var(--nord14);
font-size: 0.85rem;
cursor: pointer;
align-self: flex-start;
}
.vg-active-badge {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
color: var(--nord14);
opacity: 0.8;
}
.gps-overlay .vg-active-badge {
color: var(--nord7);
}
.gps-overlay .vg-panel {
background: rgba(255,255,255,0.1);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 10px;
padding: 0.6rem;
border-top: none;
color: #fff;
}
.gps-overlay .vg-label {
color: rgba(255,255,255,0.6);
}
.gps-overlay .vg-row {
color: #fff;
}
.gps-overlay .vg-number,
.gps-overlay .vg-select {
background: rgba(255,255,255,0.1);
border-color: rgba(255,255,255,0.2);
color: #fff;
}
.gps-overlay .vg-metric-chip {
background: rgba(255,255,255,0.1);
border-color: rgba(255,255,255,0.2);
color: rgba(255,255,255,0.7);
}
.gps-overlay .vg-metric-chip.selected {
background: var(--nord14);
color: var(--nord0);
border-color: var(--nord14);
}
/* GPS Workout Mode — full-bleed map with overlay */
.gps-workout {
position: fixed;
inset: 0;
z-index: 50;
}
.gps-workout-map {
position: absolute;
inset: 0;
z-index: 0;
}
/* Dark gradient at top so status bar text stays readable */
.gps-workout::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: calc(env(safe-area-inset-top, 0px) + 3rem + 24px);
background: linear-gradient(to bottom, rgba(0,0,0,0.45), transparent);
z-index: 1;
pointer-events: none;
}
:global(.gps-workout-map .leaflet-control-container) {
/* push leaflet's own controls above our overlay */
position: relative;
z-index: 5;
}
.gps-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 10;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
background: linear-gradient(to top, rgba(0,0,0,0.7) 60%, transparent);
color: #fff;
pointer-events: none;
}
.gps-overlay > * {
pointer-events: auto;
}
.gps-workout-stats {
display: flex;
justify-content: space-around;
padding: 0.5rem 0;
}
.gps-stat {
display: flex;
flex-direction: column;
align-items: center;
}
.gps-stat-value {
font-size: 1.8rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #fff;
text-shadow: 0 1px 4px rgba(0,0,0,0.5);
}
.gps-stat-unit {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(255,255,255,0.75);
}
.gps-options-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.gps-option-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.65rem 0.5rem;
background: rgba(255,255,255,0.12);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 10px;
cursor: pointer;
font: inherit;
color: #fff;
transition: border-color 0.15s ease;
}
.gps-option-tile:hover {
border-color: rgba(255,255,255,0.5);
}
.gps-option-icon {
font-size: 1.25rem;
}
.gps-option-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255,255,255,0.6);
}
.gps-option-value {
font-size: 0.85rem;
font-weight: 700;
}
.gps-activity-picker {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.4rem;
}
.gps-activity-choice {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.75rem;
background: rgba(255,255,255,0.1);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 8px;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
font-weight: 600;
color: #fff;
transition: all 0.15s ease;
}
.gps-activity-choice.active {
border-color: var(--nord8);
background: rgba(136,192,208,0.25);
color: var(--nord8);
}
.gps-activity-choice:hover:not(.active) {
border-color: rgba(255,255,255,0.4);
}
.gps-activity-icon {
font-size: 1.1rem;
}
.gps-start-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
width: 100%;
padding: 1rem;
background: var(--color-primary);
color: var(--primary-contrast);
border: none;
border-radius: 50px;
font-weight: 800;
font-size: 1.2rem;
cursor: pointer;
letter-spacing: 0.04em;
}
.gps-start-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.gps-cancel-link {
display: flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
background: none;
border: none;
color: rgba(255,255,255,0.5);
font: inherit;
font-size: 0.8rem;
cursor: pointer;
padding: 0.25rem;
}
.gps-cancel-link:hover {
color: var(--nord11);
}
.gps-overlay-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.gps-overlay-pause {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
background: rgba(255,255,255,0.15);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.25);
border-radius: 50%;
color: #fff;
cursor: pointer;
flex-shrink: 0;
}
.gps-overlay-pause:hover {
background: rgba(255,255,255,0.25);
}
.gps-overlay-cancel {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
background: rgba(191,97,106,0.25);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--nord11);
border-radius: 50%;
color: var(--nord11);
cursor: pointer;
flex-shrink: 0;
}
.gps-overlay-cancel:hover {
background: rgba(191,97,106,0.4);
}
.gps-overlay-finish {
flex: 1;
padding: 0.85rem;
background: var(--nord11);
color: #fff;
border: none;
border-radius: 50px;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
}
</style>