diff --git a/frontend/css/styles.css b/frontend/css/styles.css index b3e620a..64a0ca6 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -945,6 +945,38 @@ body { z-index: 1; } +/* Floating recording bar */ +.rec-floating-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + padding-top: calc(8px + var(--safe-area-top)); + background: rgba(187, 134, 252, 0.15); + border-bottom: 1px solid var(--primary); + cursor: pointer; + order: 0; +} + +.rec-bar-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #f44336; + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +#rec-bar-text { + font-size: 13px; + color: var(--primary); + font-weight: 500; +} + /* Recording screen */ .rec-screen { display: flex; diff --git a/frontend/js/gps.js b/frontend/js/gps.js index 0f38988..739319a 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -4,6 +4,23 @@ const GPS = {}; // --------------------------------------------------------------------------- +// Configurable thresholds (can be changed in settings) +GPS.config = { + minSpeedMs: 0.42, // 1.5 km/h — below this = stationary + minAccuracyM: 20, // skip fixes less accurate than this + minDistanceM: 1, // skip points closer than this +}; + +// Load saved config +try { + const saved = localStorage.getItem("baldav_gps_config"); + if (saved) Object.assign(GPS.config, JSON.parse(saved)); +} catch {} + +GPS.saveConfig = function() { + localStorage.setItem("baldav_gps_config", JSON.stringify(GPS.config)); +}; + // Haversine distance (meters) // --------------------------------------------------------------------------- GPS.distance = function(lat1, lon1, lat2, lon2) { @@ -114,6 +131,197 @@ GPS.recording = { activityType: "bike", }; +// Recording states: waiting_gps → ready → recording (auto on first movement) +// Pause: speed < 1 m/s for 5+ seconds → stop counting distance/time +// Start native GPS service on Android (foreground service survives background) +GPS._startNativeService = async function() { + try { + // Send config along with start command: start:minSpeed:maxAcc:minDist + const cmd = `start:${GPS.config.minSpeedMs}:${GPS.config.minAccuracyM}:${GPS.config.minDistanceM}`; + await window.__TAURI__.core.invoke("gps_service_command", { cmd }); + console.log("[GPS] Native service started with config:", cmd); + GPS.recording.nativeService = true; + } catch (e) { + console.log("[GPS] No native service (desktop?), using WebView GPS"); + GPS.recording.nativeService = false; + } +}; + +GPS._stopNativeService = async function() { + try { + await window.__TAURI__.core.invoke("gps_service_command", { cmd: "stop" }); + } catch {} +}; + +// Read points from native service file +GPS._readNativePoints = async function() { + try { + const raw = await window.__TAURI__.core.invoke("gps_service_read_points"); + if (!raw) return []; + return raw.trim().split("\n").filter(Boolean).map(line => { + const d = JSON.parse(line); + return { + lat: d.lat, lon: d.lon, altitude: d.alt, + speed_ms: d.speed, accuracy: d.acc, + ts: new Date(d.ts).toISOString(), + }; + }); + } catch { return []; } +}; + +// Core point processing — used by both WebView GPS and native service polling +GPS._processIncomingPoint = async function(p) { + if (!GPS.recording.active) return; + + // State: waiting_gps → ready (got first accurate fix) + if (GPS.recording.state === "waiting_gps") { + GPS.recording.state = "ready"; + if (GPS.recording.onStateChange) GPS.recording.onStateChange("ready", p); + } + + // Manual pause — show position but don't process + if (GPS.recording.manualPaused) { + if (GPS.recording.onPoint) GPS.recording.onPoint(p); + return; + } + + // Determine if moving + const speed = p.speed_ms != null ? p.speed_ms : 0; + let isMoving = speed > GPS.config.minSpeedMs; + if (!isMoving && GPS.recording.lastMovingPoint) { + const d = GPS.distance(GPS.recording.lastMovingPoint.lat, GPS.recording.lastMovingPoint.lon, p.lat, p.lon); + const dt = (new Date(p.ts) - new Date(GPS.recording.lastMovingPoint.ts)) / 1000; + if (d > GPS.config.minDistanceM * 3 && dt < 5 && dt > 0) isMoving = true; + } + + // State: ready → recording (first movement detected) + if (GPS.recording.state === "ready" && isMoving) { + GPS.recording.state = "recording"; + GPS.recording.startTime = new Date(); + const now = GPS.recording.startTime; + GPS.recording.sessionId = await DB.createGpsSession( + now.toISOString().slice(0, 10), + now.toTimeString().slice(0, 5), + GPS.recording.activityType + ); + GPS._persistState(); + if (GPS.recording.onStateChange) GPS.recording.onStateChange("recording", p); + } + + // If not yet recording, just update position display + if (GPS.recording.state !== "recording") { + if (GPS.recording.onPoint) GPS.recording.onPoint(p); + return; + } + + // Auto-pause detection + if (!isMoving) { + if (!GPS.recording.pauseStart) { + GPS.recording.pauseStart = new Date(p.ts); + } + if (GPS.recording.onPoint) GPS.recording.onPoint(p); + return; + } + + // Resumed from auto-pause + if (GPS.recording.pauseStart) { + const pauseMs = new Date(p.ts) - GPS.recording.pauseStart; + GPS.recording.totalPauseMs += pauseMs; + GPS.recording.pauseStart = null; + if (GPS.recording.onStateChange) GPS.recording.onStateChange("resumed", p); + } + + // Save moving point + GPS.recording.lastMovingPoint = p; + GPS.recording.points.push(p); + if (GPS.recording.sessionId) { + await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms); + } + if (GPS.recording.onPoint) GPS.recording.onPoint(p); +}; + +// Persist recording state to localStorage for crash recovery +GPS._persistState = function() { + try { + localStorage.setItem("baldav_gps_active", JSON.stringify({ + sessionId: GPS.recording.sessionId, + activityType: GPS.recording.activityType, + weightKg: GPS.recording.weightKg, + startTime: GPS.recording.startTime?.toISOString(), + totalPauseMs: GPS.recording.totalPauseMs, + manualPaused: GPS.recording.manualPaused, + pauses: GPS.recording.pauses || [], + pointCount: GPS.recording.points.length, + })); + } catch {} +}; + +GPS._clearPersistedState = function() { + localStorage.removeItem("baldav_gps_active"); +}; + +// Manual pause/resume +GPS.pauseRecording = function() { + if (!GPS.recording.active || GPS.recording.manualPaused) return; + GPS.recording.manualPaused = true; + const last = GPS.recording.lastMovingPoint || GPS.recording.points[GPS.recording.points.length - 1]; + GPS.recording._currentPause = { + startTs: Date.now(), + startLat: last?.lat, startLon: last?.lon, + }; + GPS._persistState(); + if (GPS.recording.onStateChange) GPS.recording.onStateChange("manual_paused"); +}; + +GPS.resumeRecording = function() { + if (!GPS.recording.active || !GPS.recording.manualPaused) return; + GPS.recording.manualPaused = false; + const last = GPS.recording.lastMovingPoint || GPS.recording.points[GPS.recording.points.length - 1]; + if (GPS.recording._currentPause) { + GPS.recording._currentPause.endTs = Date.now(); + GPS.recording._currentPause.endLat = last?.lat; + GPS.recording._currentPause.endLon = last?.lon; + const dur = GPS.recording._currentPause.endTs - GPS.recording._currentPause.startTs; + GPS.recording.totalPauseMs += dur; + if (!GPS.recording.pauses) GPS.recording.pauses = []; + GPS.recording.pauses.push(GPS.recording._currentPause); + GPS.recording._currentPause = null; + } + GPS._persistState(); + if (GPS.recording.onStateChange) GPS.recording.onStateChange("manual_resumed"); +}; + +// Recover orphaned recording session on app startup +GPS.recoverSession = async function() { + const saved = localStorage.getItem("baldav_gps_active"); + if (!saved) return; + try { + const s = JSON.parse(saved); + if (!s.sessionId) { GPS._clearPersistedState(); return; } + // Import any remaining native points + const nativePoints = await GPS._readNativePoints(); + if (nativePoints.length > (s.pointCount || 0)) { + for (let i = s.pointCount || 0; i < nativePoints.length; i++) { + const p = nativePoints[i]; + await DB.addGpsPoint(s.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms); + } + } + // Finalize the session + const pts = await DB.getGpsPoints(s.sessionId); + if (pts.length > 0) { + const stats = GPS.computeStats(pts, s.weightKg || 80); + await DB.finishGpsSession(s.sessionId, new Date().toTimeString().slice(0, 5), + stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss); + console.log("[GPS] Recovered session", s.sessionId, "with", pts.length, "points"); + } + GPS._clearPersistedState(); + await GPS._stopNativeService(); + } catch (e) { + console.error("[GPS] Recovery error:", e); + GPS._clearPersistedState(); + } +}; + GPS.startRecording = async function(activityType, weightKg) { if (GPS.recording.active) return; if (!navigator.geolocation) throw new Error("Geolocation not available"); @@ -121,34 +329,46 @@ GPS.startRecording = async function(activityType, weightKg) { GPS.recording.active = true; GPS.recording.activityType = activityType || "bike"; GPS.recording.points = []; - GPS.recording.startTime = new Date(); + GPS.recording.startTime = null; GPS.recording.weightKg = weightKg ?? 80; + GPS.recording.state = "waiting_gps"; + GPS.recording.sessionId = null; + GPS.recording.pauseStart = null; + GPS.recording.totalPauseMs = 0; + GPS.recording.lastMovingPoint = null; + GPS.recording.manualPaused = false; + GPS.recording.pauses = []; + GPS.recording._currentPause = null; + GPS.recording.nativeService = false; + GPS.recording._pollTimer = null; + GPS.recording._lastNativeCount = 0; - const now = GPS.recording.startTime; - GPS.recording.sessionId = await DB.createGpsSession( - now.toISOString().slice(0, 10), - now.toTimeString().slice(0, 5), - GPS.recording.activityType - ); + // Start native foreground service (Android) + await GPS._startNativeService(); + + // Poll native service output file + if (GPS.recording.nativeService) { + GPS.recording._pollTimer = setInterval(async () => { + if (!GPS.recording.active) { clearInterval(GPS.recording._pollTimer); return; } + const nativePoints = await GPS._readNativePoints(); + for (let i = GPS.recording._lastNativeCount; i < nativePoints.length; i++) { + await GPS._processIncomingPoint(nativePoints[i]); + } + GPS.recording._lastNativeCount = nativePoints.length; + }, 2000); + } GPS.recording.watchId = navigator.geolocation.watchPosition( async (pos) => { - // Skip inaccurate fixes (>100m accuracy = unreliable) - if (pos.coords.accuracy > 100) return; - - const p = { + if (pos.coords.accuracy > GPS.config.minAccuracyM) return; + await GPS._processIncomingPoint({ lat: pos.coords.latitude, lon: pos.coords.longitude, altitude: pos.coords.altitude, speed_ms: pos.coords.speed, accuracy: pos.coords.accuracy, ts: new Date(pos.timestamp).toISOString(), - }; - GPS.recording.points.push(p); - await DB.addGpsPoint( - GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms - ); - if (GPS.recording.onPoint) GPS.recording.onPoint(p); + }); }, (err) => { console.error("GPS error:", err.message); @@ -162,11 +382,31 @@ GPS.stopRecording = async function() { if (!GPS.recording.active) return null; GPS.recording.active = false; + if (GPS.recording._pollTimer) clearInterval(GPS.recording._pollTimer); + if (GPS.recording.watchId != null) { navigator.geolocation.clearWatch(GPS.recording.watchId); GPS.recording.watchId = null; } + // Stop native service + if (GPS.recording.nativeService) { + // Import any remaining points from native service + const nativePoints = await GPS._readNativePoints(); + if (nativePoints.length > GPS.recording._lastNativeCount && GPS.recording.sessionId) { + for (let i = GPS.recording._lastNativeCount; i < nativePoints.length; i++) { + const p = nativePoints[i]; + GPS.recording.points.push(p); + await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms); + } + } + await GPS._stopNativeService(); + } + + if (!GPS.recording.sessionId) { + return null; + } + const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg); const endTime = new Date().toTimeString().slice(0, 5); @@ -177,9 +417,13 @@ GPS.stopRecording = async function() { ); const sid = GPS.recording.sessionId; + const pauses = GPS.recording.pauses || []; GPS.recording.sessionId = null; GPS.recording.points = []; - return { sessionId: sid, stats }; + GPS.recording.pauses = []; + GPS.recording.manualPaused = false; + GPS._clearPersistedState(); + return { sessionId: sid, stats, pauses }; }; // --------------------------------------------------------------------------- diff --git a/frontend/js/main.js b/frontend/js/main.js index 57227d2..54c79e7 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -27,6 +27,9 @@ async function init() { // Wait for DB migrations if (DB._migrationsReady) await DB._migrationsReady; + // Recover any orphaned GPS recording session + await GPS.recoverSession(); + // Import seed products if needed try { app.innerHTML = `