android: add Tauri v2 shell with GPS tracking for cardio workouts

Wraps the web app in a Tauri Android shell that provides native GPS
via the geolocation plugin. Includes foreground service for background
tracking, live map display, GPS data storage in workout sessions,
and route visualization in workout history.
This commit is contained in:
2026-03-20 11:18:53 +01:00
parent 08bd016404
commit 748537dc74
51 changed files with 5695 additions and 8 deletions

3
.gitignore vendored
View File

@@ -10,3 +10,6 @@ node_modules
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
src-tauri/gen/
src-tauri/target/
src-tauri/*.keystore

View File

@@ -19,7 +19,8 @@
"test:e2e:docker:up": "docker compose -f docker-compose.test.yml up -d",
"test:e2e:docker:down": "docker compose -f docker-compose.test.yml down -v",
"test:e2e:docker": "docker compose -f docker-compose.test.yml up -d && playwright test; docker compose -f docker-compose.test.yml down -v",
"test:e2e:docker:run": "docker run --rm --network host -v $(pwd):/app -w /app -e CI=true mcr.microsoft.com/playwright:v1.56.1-noble /bin/bash -c 'npm install -g pnpm@9.0.0 && pnpm install --frozen-lockfile && pnpm run build && pnpm exec playwright test --project=chromium'"
"test:e2e:docker:run": "docker run --rm --network host -v $(pwd):/app -w /app -e CI=true mcr.microsoft.com/playwright:v1.56.1-noble /bin/bash -c 'npm install -g pnpm@9.0.0 && pnpm install --frozen-lockfile && pnpm run build && pnpm exec playwright test --project=chromium'",
"tauri": "tauri"
},
"packageManager": "pnpm@9.0.0",
"devDependencies": {
@@ -27,6 +28,7 @@
"@sveltejs/adapter-auto": "^6.1.0",
"@sveltejs/kit": "^2.37.0",
"@sveltejs/vite-plugin-svelte": "^6.1.3",
"@tauri-apps/cli": "^2.10.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.2.9",
"@types/leaflet": "^1.9.21",
@@ -46,6 +48,7 @@
"dependencies": {
"@auth/sveltekit": "^1.11.1",
"@sveltejs/adapter-node": "^5.0.0",
"@tauri-apps/plugin-geolocation": "^2.3.2",
"chart.js": "^4.5.0",
"file-type": "^19.0.0",
"ioredis": "^5.9.0",

136
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@sveltejs/adapter-node':
specifier: ^5.0.0
version: 5.3.1(@sveltejs/kit@2.37.0(@sveltejs/vite-plugin-svelte@6.1.3(svelte@5.38.6)(vite@7.1.3(@types/node@22.18.0)(terser@5.46.0)))(svelte@5.38.6)(vite@7.1.3(@types/node@22.18.0)(terser@5.46.0)))
'@tauri-apps/plugin-geolocation':
specifier: ^2.3.2
version: 2.3.2
chart.js:
specifier: ^4.5.0
version: 4.5.0
@@ -51,6 +54,9 @@ importers:
'@sveltejs/vite-plugin-svelte':
specifier: ^6.1.3
version: 6.1.3(svelte@5.38.6)(vite@7.1.3(@types/node@22.18.0)(terser@5.46.0))
'@tauri-apps/cli':
specifier: ^2.10.1
version: 2.10.1
'@testing-library/jest-dom':
specifier: ^6.9.1
version: 6.9.1
@@ -852,6 +858,83 @@ packages:
svelte: ^5.0.0
vite: ^6.3.0 || ^7.0.0
'@tauri-apps/api@2.10.1':
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
'@tauri-apps/cli-darwin-arm64@2.10.1':
resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tauri-apps/cli-darwin-x64@2.10.1':
resolution: {integrity: sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tauri-apps/cli-linux-arm-gnueabihf@2.10.1':
resolution: {integrity: sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tauri-apps/cli-linux-arm64-gnu@2.10.1':
resolution: {integrity: sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tauri-apps/cli-linux-arm64-musl@2.10.1':
resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tauri-apps/cli-linux-riscv64-gnu@2.10.1':
resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
'@tauri-apps/cli-linux-x64-gnu@2.10.1':
resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tauri-apps/cli-linux-x64-musl@2.10.1':
resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tauri-apps/cli-win32-arm64-msvc@2.10.1':
resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tauri-apps/cli-win32-ia32-msvc@2.10.1':
resolution: {integrity: sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@tauri-apps/cli-win32-x64-msvc@2.10.1':
resolution: {integrity: sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tauri-apps/cli@2.10.1':
resolution: {integrity: sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==}
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-geolocation@2.3.2':
resolution: {integrity: sha512-ONCwav1monafjeO8/JdXtRlbhZ3xgAdBYxo/62qgw99u9+y6xGsohy3avZgFZsBHA0JVNe1uJnMi+vfT5ZSFUA==}
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@@ -2328,6 +2411,59 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@tauri-apps/api@2.10.1': {}
'@tauri-apps/cli-darwin-arm64@2.10.1':
optional: true
'@tauri-apps/cli-darwin-x64@2.10.1':
optional: true
'@tauri-apps/cli-linux-arm-gnueabihf@2.10.1':
optional: true
'@tauri-apps/cli-linux-arm64-gnu@2.10.1':
optional: true
'@tauri-apps/cli-linux-arm64-musl@2.10.1':
optional: true
'@tauri-apps/cli-linux-riscv64-gnu@2.10.1':
optional: true
'@tauri-apps/cli-linux-x64-gnu@2.10.1':
optional: true
'@tauri-apps/cli-linux-x64-musl@2.10.1':
optional: true
'@tauri-apps/cli-win32-arm64-msvc@2.10.1':
optional: true
'@tauri-apps/cli-win32-ia32-msvc@2.10.1':
optional: true
'@tauri-apps/cli-win32-x64-msvc@2.10.1':
optional: true
'@tauri-apps/cli@2.10.1':
optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 2.10.1
'@tauri-apps/cli-darwin-x64': 2.10.1
'@tauri-apps/cli-linux-arm-gnueabihf': 2.10.1
'@tauri-apps/cli-linux-arm64-gnu': 2.10.1
'@tauri-apps/cli-linux-arm64-musl': 2.10.1
'@tauri-apps/cli-linux-riscv64-gnu': 2.10.1
'@tauri-apps/cli-linux-x64-gnu': 2.10.1
'@tauri-apps/cli-linux-x64-musl': 2.10.1
'@tauri-apps/cli-win32-arm64-msvc': 2.10.1
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
'@tauri-apps/plugin-geolocation@2.3.2':
dependencies:
'@tauri-apps/api': 2.10.1
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.27.1

78
scripts/android-build-deploy.sh Executable file
View File

@@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -euo pipefail
# Android SDK environment
export ANDROID_HOME=/opt/android-sdk
export NDK_HOME=/opt/android-sdk/ndk/27.0.12077973
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
APK_DIR="src-tauri/gen/android/app/build/outputs/apk/universal/release"
APK_UNSIGNED="$APK_DIR/app-universal-release-unsigned.apk"
APK_SIGNED="$APK_DIR/app-universal-release-signed.apk"
KEYSTORE="src-tauri/debug.keystore"
PACKAGE="org.bocken.fitness"
usage() {
echo "Usage: $0 [build|deploy|run]"
echo " build - Build and sign the APK"
echo " deploy - Build + install on connected device"
echo " run - Build + install + launch on device"
exit 1
}
ensure_keystore() {
if [ ! -f "$KEYSTORE" ]; then
echo ":: Generating debug keystore..."
keytool -genkey -v -keystore "$KEYSTORE" \
-alias debug -keyalg RSA -keysize 2048 -validity 10000 \
-storepass android -keypass android \
-dname "CN=Debug,O=Bocken,C=DE"
fi
}
build() {
echo ":: Building Android APK..."
npx tauri android build --apk
ensure_keystore
echo ":: Signing APK..."
# zipalign
"$ANDROID_HOME/build-tools/35.0.0/zipalign" -f -v 4 \
"$APK_UNSIGNED" "$APK_SIGNED" > /dev/null
# sign with apksigner
"$ANDROID_HOME/build-tools/35.0.0/apksigner" sign \
--ks "$KEYSTORE" --ks-pass pass:android --key-pass pass:android \
"$APK_SIGNED"
echo ":: Signed APK at: $APK_SIGNED"
}
deploy() {
if ! adb devices | grep -q "device$"; then
echo "!! No device connected. Connect your phone and enable USB debugging."
exit 1
fi
build
echo ":: Installing APK on device..."
adb install -r "$APK_SIGNED"
echo ":: Installed successfully."
}
run() {
deploy
echo ":: Launching app..."
adb shell am start -n "$PACKAGE/.MainActivity"
echo ":: App launched."
}
case "${1:-}" in
build) build ;;
deploy) deploy ;;
run) run ;;
*) usage ;;
esac

4964
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "bocken-fitness"
version = "0.1.0"
edition = "2021"
[lib]
name = "bocken_fitness_lib"
crate-type = ["lib", "cdylib", "staticlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-geolocation = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,14 @@
{
"identifier": "bocken-remote",
"windows": ["main"],
"remote": {
"urls": ["https://bocken.org/*", "http://192.168.1.4:5173/*"]
},
"permissions": [
"geolocation:allow-check-permissions",
"geolocation:allow-request-permissions",
"geolocation:allow-get-current-position",
"geolocation:allow-watch-position",
"geolocation:allow-clear-watch"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

7
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,7 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_geolocation::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

5
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
bocken_fitness_lib::run();
}

31
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,31 @@
{
"productName": "Bocken Fitness",
"identifier": "org.bocken.fitness",
"version": "0.1.0",
"build": {
"devUrl": "http://192.168.1.4:5173",
"frontendDist": "http://192.168.1.4:5173"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "Bocken Fitness",
"url": "/fitness",
"fullscreen": false,
"useHttpsScheme": false
}
]
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width" />
<meta name="viewport" content="width=device-width, viewport-fit=cover" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#5E81AC" />
<link rel="apple-touch-icon" href="/favicon-192.png" />

View File

@@ -41,14 +41,14 @@ footer {
═══════════════════════════════════════════ */
nav {
position: sticky;
top: 12px;
top: calc(12px + env(safe-area-inset-top, 0px));
z-index: 100;
display: flex;
align-items: center;
height: var(--header-h);
gap: 0.4rem;
padding: 0 0.8rem;
margin: 12px auto 0;
margin: calc(12px + env(safe-area-inset-top, 0px)) auto 0;
width: fit-content;
max-width: calc(100% - 1.5rem);
border-radius: 100px;

168
src/lib/js/gps.svelte.ts Normal file
View File

@@ -0,0 +1,168 @@
/**
* GPS tracking utility for Tauri Android shell.
* Uses @tauri-apps/plugin-geolocation when running inside Tauri,
* falls back to a no-op tracker in the browser.
*/
export interface GpsPoint {
lat: number;
lng: number;
altitude?: number;
speed?: number;
timestamp: number;
}
function checkTauri(): boolean {
return typeof window !== 'undefined' && '__TAURI__' in window;
}
/** Haversine distance in km between two points */
function haversine(a: GpsPoint, b: GpsPoint): number {
const R = 6371;
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
const sinLat = Math.sin(dLat / 2);
const sinLng = Math.sin(dLng / 2);
const h =
sinLat * sinLat +
Math.cos((a.lat * Math.PI) / 180) *
Math.cos((b.lat * Math.PI) / 180) *
sinLng * sinLng;
return 2 * R * Math.asin(Math.sqrt(h));
}
/** Compute total distance from a track */
export function trackDistance(track: GpsPoint[]): number {
let total = 0;
for (let i = 1; i < track.length; i++) {
total += haversine(track[i - 1], track[i]);
}
return total;
}
export function createGpsTracker() {
let track = $state<GpsPoint[]>([]);
let isTracking = $state(false);
let _watchId: number | null = null;
const distance = $derived(trackDistance(track));
const currentSpeed = $derived(
track.length > 0 ? (track[track.length - 1].speed ?? 0) : 0
);
// Pace from track points: use last two points for instantaneous pace
const currentPace = $derived.by(() => {
if (track.length < 2) return 0;
const a = track[track.length - 2];
const b = track[track.length - 1];
const d = haversine(a, b);
const dt = (b.timestamp - a.timestamp) / 60000; // minutes
if (d < 0.001) return 0; // too close, skip
return dt / d; // min/km
});
const latestPoint = $derived(
track.length > 0 ? track[track.length - 1] : null
);
let _debugMsg = $state('');
async function start() {
_debugMsg = 'starting...';
if (!checkTauri() || isTracking) {
_debugMsg = `bail: tauri=${checkTauri()} tracking=${isTracking}`;
return false;
}
try {
_debugMsg = 'importing plugin...';
const geo = await import('@tauri-apps/plugin-geolocation');
_debugMsg = 'checking perms...';
let perms = await geo.checkPermissions();
_debugMsg = `perms: ${JSON.stringify(perms)}`;
if (perms.location !== 'granted') {
_debugMsg = 'requesting perms...';
perms = await geo.requestPermissions(['location']);
_debugMsg = `after req: ${JSON.stringify(perms)}`;
}
if (perms.location !== 'granted') {
_debugMsg = `denied: ${JSON.stringify(perms)}`;
return false;
}
track = [];
isTracking = true;
_debugMsg = 'calling watchPosition...';
_watchId = await geo.watchPosition(
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 0 },
(pos, err) => {
if (err) {
_debugMsg = `watch err: ${JSON.stringify(err)}`;
return;
}
if (!pos) return;
track = [...track, {
lat: pos.coords.latitude,
lng: pos.coords.longitude,
altitude: pos.coords.altitude ?? undefined,
speed: pos.coords.speed ?? undefined,
timestamp: pos.timestamp
}];
_debugMsg = `pts:${track.length} lat:${pos.coords.latitude.toFixed(4)} lng:${pos.coords.longitude.toFixed(4)}`;
}
);
_debugMsg = `watching (id=${_watchId})`;
return true;
} catch (e) {
_debugMsg = `error: ${(e as Error)?.message ?? e}`;
isTracking = false;
return false;
}
}
async function stop(): Promise<GpsPoint[]> {
if (!isTracking) return [];
try {
if (_watchId !== null) {
const geo = await import('@tauri-apps/plugin-geolocation');
await geo.clearWatch(_watchId);
_watchId = null;
}
} catch {}
isTracking = false;
const result = [...track];
return result;
}
function reset() {
track = [];
}
return {
get track() { return track; },
get isTracking() { return isTracking; },
get distance() { return distance; },
get currentSpeed() { return currentSpeed; },
get currentPace() { return currentPace; },
get latestPoint() { return latestPoint; },
get available() { return checkTauri(); },
get debug() { return _debugMsg; },
start,
stop,
reset
};
}
let _instance: ReturnType<typeof createGpsTracker> | null = null;
export function getGpsTracker() {
if (!_instance) {
_instance = createGpsTracker();
}
return _instance;
}

View File

@@ -372,7 +372,7 @@ onMount(() => {
.page-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem 1rem;
padding: 0 1rem 2rem 1rem;
}
.page-container:has(.has-mystery-image) {
max-width: calc(1400px + 25vw + 3rem);

View File

@@ -1,13 +1,14 @@
<script>
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { Plus, Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame } from 'lucide-svelte';
import { Plus, Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame, MapPin } from 'lucide-svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname));
const sl = $derived(fitnessSlugs(lang));
import { getWorkout } from '$lib/js/workout.svelte';
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
import { getGpsTracker, trackDistance } from '$lib/js/gps.svelte';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { estimateWorkoutKcal } from '$lib/data/kcalEstimate';
import { estimateCardioKcal } from '$lib/data/cardioKcalEstimate';
@@ -19,6 +20,7 @@
const workout = getWorkout();
const sync = getWorkoutSync();
const gps = getGpsTracker();
let nameInput = $state(workout.name);
let nameEditing = $state(false);
$effect(() => { if (!nameEditing) nameInput = workout.name; });
@@ -34,6 +36,109 @@
let templateDiffs = $state([]);
let templateUpdateStatus = $state('idle'); // 'idle' | 'updating' | 'done'
let useGps = $state(gps.isTracking);
/** @type {any} */
let liveMap = null;
/** @type {any} */
let livePolyline = null;
/** @type {any} */
let liveMarker = null;
/** @type {any} */
let leafletLib = null;
let prevTrackLen = 0;
/** Svelte use:action — called when the map div enters the DOM */
function mountMap(/** @type {HTMLElement} */ node) {
initMap(node);
return {
destroy() {
if (liveMap) {
liveMap.remove();
liveMap = null;
livePolyline = null;
liveMarker = null;
leafletLib = null;
prevTrackLen = 0;
}
}
};
}
/** @param {HTMLElement} node */
async function initMap(node) {
leafletLib = await import('leaflet');
if (!node.isConnected) return;
liveMap = leafletLib.map(node, {
attributionControl: false,
zoomControl: false
});
leafletLib.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19
}).addTo(liveMap);
livePolyline = leafletLib.polyline([], { color: '#88c0d0', weight: 3 }).addTo(liveMap);
liveMarker = leafletLib.circleMarker([0, 0], {
radius: 6, fillColor: '#a3be8c', fillOpacity: 1, color: '#fff', weight: 2
}).addTo(liveMap);
if (gps.track.length > 0) {
const pts = gps.track.map((/** @type {any} */ p) => [p.lat, p.lng]);
livePolyline.setLatLngs(pts);
liveMarker.setLatLng(pts[pts.length - 1]);
liveMap.setView(pts[pts.length - 1], 16);
prevTrackLen = gps.track.length;
}
}
let gpsToggling = false;
async function toggleGps() {
if (gpsToggling) return;
gpsToggling = true;
try {
if (!useGps) {
if (gps.isTracking) {
useGps = true;
} else {
useGps = await gps.start();
}
} else {
await gps.stop();
useGps = false;
if (liveMap) {
liveMap.remove();
liveMap = null;
livePolyline = null;
liveMarker = null;
}
}
} finally {
gpsToggling = false;
}
}
$effect(() => {
const len = gps.track.length;
if (len > prevTrackLen && liveMap && gps.latestPoint) {
for (let i = prevTrackLen; i < len; i++) {
const p = gps.track[i];
livePolyline.addLatLng([p.lat, p.lng]);
}
const pt = [gps.latestPoint.lat, gps.latestPoint.lng];
liveMarker.setLatLng(pt);
const zoom = liveMap.getZoom() || 16;
liveMap.setView(pt, zoom);
prevTrackLen = len;
}
});
/** Check if any exercise in the workout is cardio */
function hasCardioExercise() {
return workout.exercises.some((/** @type {any} */ e) => {
const exercise = getExerciseById(e.exerciseId);
return exercise?.bodyPart === 'cardio';
});
}
onMount(() => {
if (!workout.active && !completionData) {
goto(`/fitness/${sl.workout}`);
@@ -58,19 +163,45 @@
}
/** @param {string} exerciseId */
function addExerciseFromPicker(exerciseId) {
async function addExerciseFromPicker(exerciseId) {
workout.addExercise(exerciseId);
fetchPreviousData([exerciseId]);
// 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();
}
}
async function finishWorkout() {
// Stop GPS tracking and collect track data
const gpsTrack = gps.isTracking ? await gps.stop() : [];
const sessionData = workout.finish();
if (sessionData.exercises.length === 0) {
gps.reset();
await sync.onWorkoutEnd();
await goto(`/fitness/${sl.workout}`);
return;
}
// 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;
}
}
}
gps.reset();
try {
const res = await fetch('/api/fitness/sessions', {
method: 'POST',
@@ -378,7 +509,10 @@
});
</script>
<svelte:head><title>{workout.name || (lang === 'en' ? 'Workout' : 'Training')} - Fitness</title></svelte:head>
<svelte:head>
<title>{workout.name || (lang === 'en' ? 'Workout' : 'Training')} - Fitness</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</svelte:head>
{#if completionData}
<div class="completion">
@@ -533,6 +667,31 @@
placeholder={t('workout_name_placeholder', lang)}
/>
{#if gps.available && hasCardioExercise()}
<div class="gps-section">
<button class="gps-toggle-row" onclick={toggleGps} type="button">
<MapPin size={14} />
<span class="gps-toggle-track" class:checked={useGps}></span>
<span>GPS Tracking</span>
</button>
{#if gpsToggling}
<div class="gps-initializing">
<span class="gps-spinner"></span> {t('initializing_gps', lang) ?? 'Initializing GPS…'}
</div>
{/if}
{#if useGps}
<div class="gps-bar active">
<span class="gps-distance">{gps.distance.toFixed(2)} km</span>
{#if gps.currentPace > 0}
<span class="gps-pace">{Math.floor(gps.currentPace)}:{Math.round((gps.currentPace % 1) * 60).toString().padStart(2, '0')} /km</span>
{/if}
<span class="gps-label">{gps.track.length} pts</span>
</div>
<div class="live-map" use:mountMap></div>
{/if}
</div>
{/if}
{#each workout.exercises as ex, exIdx (exIdx)}
<div class="exercise-block">
<div class="exercise-header">
@@ -994,4 +1153,103 @@
.cancel-btn:hover {
background: rgba(191, 97, 106, 0.1);
}
/* GPS section */
.gps-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
background: var(--color-surface);
border-radius: 8px;
box-shadow: var(--shadow-sm);
padding: 0.75rem;
}
.gps-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;
}
.gps-toggle-track {
width: 44px;
height: 24px;
background: var(--nord3);
border-radius: 24px;
position: relative;
transition: background 0.3s ease;
flex-shrink: 0;
}
.gps-toggle-track::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
top: 2px;
left: 2px;
background: white;
transition: transform 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.gps-toggle-track.checked {
background: var(--nord14);
}
.gps-toggle-track.checked::before {
transform: translateX(20px);
}
.gps-bar {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.gps-bar.active {
color: var(--nord14);
}
.gps-distance {
font-weight: 700;
font-size: 1.1rem;
font-variant-numeric: tabular-nums;
}
.gps-pace {
font-variant-numeric: tabular-nums;
}
.gps-label {
margin-left: auto;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.7;
}
.live-map {
height: 200px;
border-radius: 8px;
overflow: hidden;
}
.gps-initializing {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--color-text-secondary);
padding: 0.25rem 0;
}
.gps-spinner {
width: 14px;
height: 14px;
border: 2px solid var(--color-border);
border-top-color: var(--nord8);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>