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.
3
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
17
src-tauri/Cargo.toml
Normal 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
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
14
src-tauri/capabilities/remote.json
Normal 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
|
After Width: | Height: | Size: 6.9 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 952 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
7
src-tauri/src/lib.rs
Normal 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
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||