feat: redesign GPS workout UI with Runkeeper-style map overlay

- 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
This commit is contained in:
2026-03-25 19:54:18 +01:00
parent 1a2ec40e7a
commit 47e85587dc
9 changed files with 705 additions and 46 deletions
+18 -1
View File
@@ -221,6 +221,22 @@ export function createGpsTracker() {
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; },
@@ -237,7 +253,8 @@ export function createGpsTracker() {
hasTtsEngine,
installTtsEngine,
pauseTracking,
resumeTracking
resumeTracking,
ensurePermissions
};
}
+46
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,
+9 -1
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,