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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user