Compare commits
2 Commits
a5f2a1d6de
...
8b63812734
| Author | SHA1 | Date | |
|---|---|---|---|
|
8b63812734
|
|||
|
d75e2354f6
|
@@ -11,6 +11,12 @@
|
||||
<!-- AndroidTV support -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.TTS_SERVICE" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
|
||||
@@ -6,14 +6,20 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.webkit.JavascriptInterface
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
|
||||
class AndroidBridge(private val context: Context) {
|
||||
|
||||
private var ttsForVoices: TextToSpeech? = null
|
||||
|
||||
@JavascriptInterface
|
||||
fun startLocationService() {
|
||||
fun startLocationService(ttsConfigJson: String) {
|
||||
if (context is Activity) {
|
||||
// Request notification permission on Android 13+ (required for foreground service notification)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
@@ -42,7 +48,9 @@ class AndroidBridge(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
val intent = Intent(context, LocationForegroundService::class.java)
|
||||
val intent = Intent(context, LocationForegroundService::class.java).apply {
|
||||
putExtra("ttsConfig", ttsConfigJson)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
@@ -50,6 +58,12 @@ class AndroidBridge(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Backwards-compatible overload for calls without TTS config */
|
||||
@JavascriptInterface
|
||||
fun startLocationService() {
|
||||
startLocationService("{}")
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun stopLocationService() {
|
||||
val intent = Intent(context, LocationForegroundService::class.java)
|
||||
@@ -65,4 +79,62 @@ class AndroidBridge(private val context: Context) {
|
||||
fun isTracking(): Boolean {
|
||||
return LocationForegroundService.tracking
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun pauseTracking() {
|
||||
LocationForegroundService.instance?.doPause()
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun resumeTracking() {
|
||||
LocationForegroundService.instance?.doResume()
|
||||
}
|
||||
|
||||
/** Returns true if at least one TTS engine is installed on the device. */
|
||||
@JavascriptInterface
|
||||
fun hasTtsEngine(): Boolean {
|
||||
val dummy = TextToSpeech(context, null)
|
||||
val hasEngine = dummy.engines.isNotEmpty()
|
||||
dummy.shutdown()
|
||||
return hasEngine
|
||||
}
|
||||
|
||||
/** Opens the Android TTS install intent (prompts user to install a TTS engine). */
|
||||
@JavascriptInterface
|
||||
fun installTtsEngine() {
|
||||
val intent = Intent(TextToSpeech.Engine.ACTION_INSTALL_TTS_DATA)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns available TTS voices as a JSON array.
|
||||
* Each entry: { "id": "...", "name": "...", "language": "en-US" }
|
||||
* This initializes a temporary TTS engine; the result is returned asynchronously
|
||||
* via a callback, but since @JavascriptInterface is synchronous we block briefly.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun getAvailableTtsVoices(): String {
|
||||
val result = JSONArray()
|
||||
try {
|
||||
val latch = java.util.concurrent.CountDownLatch(1)
|
||||
var engine: TextToSpeech? = null
|
||||
engine = TextToSpeech(context) { status ->
|
||||
if (status == TextToSpeech.SUCCESS) {
|
||||
engine?.voices?.forEach { voice ->
|
||||
val obj = JSONObject().apply {
|
||||
put("id", voice.name)
|
||||
put("name", voice.name)
|
||||
put("language", voice.locale.toLanguageTag())
|
||||
}
|
||||
result.put(obj)
|
||||
}
|
||||
}
|
||||
latch.countDown()
|
||||
}
|
||||
latch.await(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
engine.shutdown()
|
||||
} catch (_: Exception) {}
|
||||
return result.toString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,24 +10,77 @@ import android.content.Intent
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Collections
|
||||
import java.util.Locale
|
||||
import kotlin.math.*
|
||||
|
||||
class LocationForegroundService : Service() {
|
||||
private const val TAG = "BockenTTS"
|
||||
|
||||
class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
|
||||
|
||||
private var locationManager: LocationManager? = null
|
||||
private var locationListener: LocationListener? = null
|
||||
private var notificationManager: NotificationManager? = null
|
||||
private var pendingIntent: PendingIntent? = null
|
||||
private var startTimeMs: Long = 0L
|
||||
private var pausedAccumulatedMs: Long = 0L // total time spent paused
|
||||
private var pausedSinceMs: Long = 0L // timestamp when last paused (0 = not paused)
|
||||
private var lastLat: Double = Double.NaN
|
||||
private var lastLng: Double = Double.NaN
|
||||
private var lastTimestamp: Long = 0L
|
||||
private var currentPaceMinKm: Double = 0.0
|
||||
|
||||
// TTS
|
||||
private var tts: TextToSpeech? = null
|
||||
private var ttsReady = false
|
||||
private var ttsConfig: TtsConfig? = null
|
||||
private var ttsTimeHandler: Handler? = null
|
||||
private var ttsTimeRunnable: Runnable? = null
|
||||
private var lastAnnouncementDistanceKm: Double = 0.0
|
||||
private var lastAnnouncementTimeMs: Long = 0L
|
||||
private var splitDistanceAtLastAnnouncement: Double = 0.0
|
||||
private var splitTimeAtLastAnnouncement: Long = 0L
|
||||
|
||||
data class TtsConfig(
|
||||
val enabled: Boolean = false,
|
||||
val triggerType: String = "distance", // "distance" or "time"
|
||||
val triggerValue: Double = 1.0, // km or minutes
|
||||
val metrics: List<String> = listOf("totalTime", "totalDistance", "avgPace"),
|
||||
val language: String = "en",
|
||||
val voiceId: String? = null
|
||||
) {
|
||||
companion object {
|
||||
fun fromJson(json: String): TtsConfig {
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
val metricsArr = obj.optJSONArray("metrics")
|
||||
val metrics = if (metricsArr != null) {
|
||||
(0 until metricsArr.length()).map { metricsArr.getString(it) }
|
||||
} else {
|
||||
listOf("totalTime", "totalDistance", "avgPace")
|
||||
}
|
||||
TtsConfig(
|
||||
enabled = obj.optBoolean("enabled", false),
|
||||
triggerType = obj.optString("triggerType", "distance"),
|
||||
triggerValue = obj.optDouble("triggerValue", 1.0),
|
||||
metrics = metrics,
|
||||
language = obj.optString("language", "en"),
|
||||
voiceId = obj.optString("voiceId", null)
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
TtsConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "gps_tracking"
|
||||
const val NOTIFICATION_ID = 1001
|
||||
@@ -35,8 +88,12 @@ class LocationForegroundService : Service() {
|
||||
const val MIN_DISTANCE_M = 0f
|
||||
|
||||
private val pointBuffer = Collections.synchronizedList(mutableListOf<JSONObject>())
|
||||
var instance: LocationForegroundService? = null
|
||||
private set
|
||||
var tracking = false
|
||||
private set
|
||||
var paused = false
|
||||
private set
|
||||
var totalDistanceKm: Double = 0.0
|
||||
private set
|
||||
|
||||
@@ -71,12 +128,21 @@ class LocationForegroundService : Service() {
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startTimeMs = System.currentTimeMillis()
|
||||
pausedAccumulatedMs = 0L
|
||||
pausedSinceMs = 0L
|
||||
paused = false
|
||||
totalDistanceKm = 0.0
|
||||
lastLat = Double.NaN
|
||||
lastLng = Double.NaN
|
||||
lastTimestamp = 0L
|
||||
currentPaceMinKm = 0.0
|
||||
|
||||
// Parse TTS config from intent
|
||||
val configJson = intent?.getStringExtra("ttsConfig") ?: "{}"
|
||||
Log.d(TAG, "TTS config JSON: $configJson")
|
||||
ttsConfig = TtsConfig.fromJson(configJson)
|
||||
Log.d(TAG, "TTS enabled=${ttsConfig?.enabled}, trigger=${ttsConfig?.triggerType}/${ttsConfig?.triggerValue}, metrics=${ttsConfig?.metrics}")
|
||||
|
||||
val notifIntent = Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
}
|
||||
@@ -95,10 +161,200 @@ class LocationForegroundService : Service() {
|
||||
|
||||
startLocationUpdates()
|
||||
tracking = true
|
||||
instance = this
|
||||
|
||||
// Initialize TTS *after* startForeground — using applicationContext for reliable engine binding
|
||||
if (ttsConfig?.enabled == true) {
|
||||
Log.d(TAG, "Initializing TTS engine (post-startForeground)...")
|
||||
lastAnnouncementDistanceKm = 0.0
|
||||
lastAnnouncementTimeMs = startTimeMs
|
||||
splitDistanceAtLastAnnouncement = 0.0
|
||||
splitTimeAtLastAnnouncement = startTimeMs
|
||||
|
||||
// Log available TTS engines
|
||||
val dummyTts = TextToSpeech(applicationContext, null)
|
||||
val engines = dummyTts.engines
|
||||
Log.d(TAG, "Available TTS engines: ${engines.map { "${it.label} (${it.name})" }}")
|
||||
dummyTts.shutdown()
|
||||
|
||||
// Try with explicit engine if available
|
||||
if (engines.isNotEmpty()) {
|
||||
val engineName = engines[0].name
|
||||
Log.d(TAG, "Trying TTS with explicit engine: $engineName")
|
||||
tts = TextToSpeech(applicationContext, this, engineName)
|
||||
} else {
|
||||
Log.e(TAG, "No TTS engines found on device!")
|
||||
tts = TextToSpeech(applicationContext, this)
|
||||
}
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
// --- TTS ---
|
||||
|
||||
override fun onInit(status: Int) {
|
||||
Log.d(TAG, "TTS onInit status=$status (SUCCESS=${TextToSpeech.SUCCESS})")
|
||||
if (status == TextToSpeech.SUCCESS) {
|
||||
val config = ttsConfig ?: return
|
||||
val locale = Locale.forLanguageTag(config.language)
|
||||
val langResult = tts?.setLanguage(locale)
|
||||
Log.d(TAG, "TTS setLanguage($locale) result=$langResult")
|
||||
|
||||
// Set specific voice if requested
|
||||
if (!config.voiceId.isNullOrEmpty()) {
|
||||
tts?.voices?.find { it.name == config.voiceId }?.let { voice ->
|
||||
tts?.voice = voice
|
||||
}
|
||||
}
|
||||
|
||||
ttsReady = true
|
||||
Log.d(TAG, "TTS ready! triggerType=${config.triggerType}, triggerValue=${config.triggerValue}")
|
||||
|
||||
// Set up time-based trigger if configured
|
||||
if (config.triggerType == "time") {
|
||||
startTimeTrigger(config.triggerValue)
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "TTS init FAILED with status=$status")
|
||||
}
|
||||
}
|
||||
|
||||
private fun startTimeTrigger(intervalMinutes: Double) {
|
||||
val intervalMs = (intervalMinutes * 60 * 1000).toLong()
|
||||
Log.d(TAG, "Starting time trigger: every ${intervalMs}ms (${intervalMinutes} min)")
|
||||
ttsTimeHandler = Handler(Looper.getMainLooper())
|
||||
ttsTimeRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
Log.d(TAG, "Time trigger fired!")
|
||||
announceMetrics()
|
||||
ttsTimeHandler?.postDelayed(this, intervalMs)
|
||||
}
|
||||
}
|
||||
ttsTimeHandler?.postDelayed(ttsTimeRunnable!!, intervalMs)
|
||||
}
|
||||
|
||||
// --- Pause / Resume ---
|
||||
|
||||
fun doPause() {
|
||||
if (paused) return
|
||||
paused = true
|
||||
pausedSinceMs = System.currentTimeMillis()
|
||||
Log.d(TAG, "Tracking paused")
|
||||
|
||||
// Pause TTS time trigger
|
||||
ttsTimeRunnable?.let { ttsTimeHandler?.removeCallbacks(it) }
|
||||
|
||||
// Update notification to show paused state
|
||||
val notification = buildNotification(formatElapsed(), "%.2f km".format(totalDistanceKm), "PAUSED")
|
||||
notificationManager?.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
fun doResume() {
|
||||
if (!paused) return
|
||||
// Accumulate paused duration
|
||||
pausedAccumulatedMs += System.currentTimeMillis() - pausedSinceMs
|
||||
pausedSinceMs = 0L
|
||||
paused = false
|
||||
Log.d(TAG, "Tracking resumed (total paused: ${pausedAccumulatedMs / 1000}s)")
|
||||
|
||||
// Reset last position so we don't accumulate drift during pause
|
||||
lastLat = Double.NaN
|
||||
lastLng = Double.NaN
|
||||
lastTimestamp = 0L
|
||||
|
||||
// Resume TTS time trigger
|
||||
val config = ttsConfig
|
||||
if (ttsReady && config != null && config.triggerType == "time") {
|
||||
val intervalMs = (config.triggerValue * 60 * 1000).toLong()
|
||||
ttsTimeRunnable?.let { ttsTimeHandler?.postDelayed(it, intervalMs) }
|
||||
}
|
||||
|
||||
updateNotification()
|
||||
}
|
||||
|
||||
private fun checkDistanceTrigger() {
|
||||
val config = ttsConfig ?: return
|
||||
if (!ttsReady || config.triggerType != "distance") return
|
||||
|
||||
val sinceLast = totalDistanceKm - lastAnnouncementDistanceKm
|
||||
if (sinceLast >= config.triggerValue) {
|
||||
announceMetrics()
|
||||
lastAnnouncementDistanceKm = totalDistanceKm
|
||||
}
|
||||
}
|
||||
|
||||
private fun announceMetrics() {
|
||||
if (!ttsReady) return
|
||||
val config = ttsConfig ?: return
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val activeSecs = activeElapsedSecs()
|
||||
val parts = mutableListOf<String>()
|
||||
|
||||
for (metric in config.metrics) {
|
||||
when (metric) {
|
||||
"totalTime" -> {
|
||||
val h = activeSecs / 3600
|
||||
val m = (activeSecs % 3600) / 60
|
||||
val s = activeSecs % 60
|
||||
val timeStr = if (h > 0) {
|
||||
"$h hours $m minutes"
|
||||
} else {
|
||||
"$m minutes $s seconds"
|
||||
}
|
||||
parts.add("Time: $timeStr")
|
||||
}
|
||||
"totalDistance" -> {
|
||||
val distStr = "%.2f".format(totalDistanceKm)
|
||||
parts.add("Distance: $distStr kilometers")
|
||||
}
|
||||
"avgPace" -> {
|
||||
val elapsedMin = activeSecs / 60.0
|
||||
if (totalDistanceKm > 0.01) {
|
||||
val avgPace = elapsedMin / totalDistanceKm
|
||||
val mins = avgPace.toInt()
|
||||
val secs = ((avgPace - mins) * 60).toInt()
|
||||
parts.add("Average pace: $mins minutes $secs seconds per kilometer")
|
||||
}
|
||||
}
|
||||
"splitPace" -> {
|
||||
val splitDist = totalDistanceKm - splitDistanceAtLastAnnouncement
|
||||
val splitTimeMin = (now - splitTimeAtLastAnnouncement) / 60000.0
|
||||
if (splitDist > 0.01) {
|
||||
val splitPace = splitTimeMin / splitDist
|
||||
val mins = splitPace.toInt()
|
||||
val secs = ((splitPace - mins) * 60).toInt()
|
||||
parts.add("Split pace: $mins minutes $secs seconds per kilometer")
|
||||
}
|
||||
}
|
||||
"currentPace" -> {
|
||||
if (currentPaceMinKm > 0 && currentPaceMinKm <= 60) {
|
||||
val mins = currentPaceMinKm.toInt()
|
||||
val secs = ((currentPaceMinKm - mins) * 60).toInt()
|
||||
parts.add("Current pace: $mins minutes $secs seconds per kilometer")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update split tracking
|
||||
splitDistanceAtLastAnnouncement = totalDistanceKm
|
||||
splitTimeAtLastAnnouncement = now
|
||||
lastAnnouncementTimeMs = now
|
||||
|
||||
if (parts.isNotEmpty()) {
|
||||
val text = parts.joinToString(". ")
|
||||
Log.d(TAG, "Announcing: $text")
|
||||
val result = tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "workout_announcement")
|
||||
Log.d(TAG, "TTS speak() result=$result (SUCCESS=${TextToSpeech.SUCCESS})")
|
||||
} else {
|
||||
Log.d(TAG, "announceMetrics: no parts to announce")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Notification / Location (unchanged) ---
|
||||
|
||||
private fun formatPace(paceMinKm: Double): String {
|
||||
if (paceMinKm <= 0 || paceMinKm > 60) return ""
|
||||
val mins = paceMinKm.toInt()
|
||||
@@ -130,8 +386,15 @@ class LocationForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns active (non-paused) elapsed time in seconds. */
|
||||
private fun activeElapsedSecs(): Long {
|
||||
val now = System.currentTimeMillis()
|
||||
val totalPaused = pausedAccumulatedMs + if (pausedSinceMs > 0) (now - pausedSinceMs) else 0L
|
||||
return (now - startTimeMs - totalPaused) / 1000
|
||||
}
|
||||
|
||||
private fun formatElapsed(): String {
|
||||
val secs = (System.currentTimeMillis() - startTimeMs) / 1000
|
||||
val secs = activeElapsedSecs()
|
||||
val h = secs / 3600
|
||||
val m = (secs % 3600) / 60
|
||||
val s = secs % 60
|
||||
@@ -158,9 +421,22 @@ class LocationForegroundService : Service() {
|
||||
locationListener = LocationListener { location ->
|
||||
val lat = location.latitude
|
||||
val lng = location.longitude
|
||||
val now = location.time
|
||||
|
||||
// Always buffer GPS points (for track drawing) even when paused
|
||||
val point = JSONObject().apply {
|
||||
put("lat", lat)
|
||||
put("lng", lng)
|
||||
if (location.hasAltitude()) put("altitude", location.altitude)
|
||||
if (location.hasSpeed()) put("speed", location.speed.toDouble())
|
||||
put("timestamp", location.time)
|
||||
}
|
||||
pointBuffer.add(point)
|
||||
|
||||
// Skip distance/pace accumulation and TTS triggers when paused
|
||||
if (paused) return@LocationListener
|
||||
|
||||
// Accumulate distance and compute pace
|
||||
val now = location.time
|
||||
if (!lastLat.isNaN()) {
|
||||
val segmentKm = haversineKm(lastLat, lastLng, lat, lng)
|
||||
totalDistanceKm += segmentKm
|
||||
@@ -173,16 +449,10 @@ class LocationForegroundService : Service() {
|
||||
lastLng = lng
|
||||
lastTimestamp = now
|
||||
|
||||
val point = JSONObject().apply {
|
||||
put("lat", lat)
|
||||
put("lng", lng)
|
||||
if (location.hasAltitude()) put("altitude", location.altitude)
|
||||
if (location.hasSpeed()) put("speed", location.speed.toDouble())
|
||||
put("timestamp", location.time)
|
||||
}
|
||||
pointBuffer.add(point)
|
||||
|
||||
updateNotification()
|
||||
|
||||
// Check distance-based TTS trigger
|
||||
checkDistanceTrigger()
|
||||
}
|
||||
|
||||
locationManager?.requestLocationUpdates(
|
||||
@@ -195,9 +465,21 @@ class LocationForegroundService : Service() {
|
||||
|
||||
override fun onDestroy() {
|
||||
tracking = false
|
||||
paused = false
|
||||
instance = null
|
||||
locationListener?.let { locationManager?.removeUpdates(it) }
|
||||
locationListener = null
|
||||
locationManager = null
|
||||
|
||||
// Clean up TTS
|
||||
ttsTimeRunnable?.let { ttsTimeHandler?.removeCallbacks(it) }
|
||||
ttsTimeHandler = null
|
||||
ttsTimeRunnable = null
|
||||
tts?.stop()
|
||||
tts?.shutdown()
|
||||
tts = null
|
||||
ttsReady = false
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,25 @@ export interface GpsPoint {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface VoiceGuidanceConfig {
|
||||
enabled: boolean;
|
||||
triggerType: 'distance' | 'time';
|
||||
triggerValue: number;
|
||||
metrics: string[];
|
||||
language: string;
|
||||
voiceId?: string;
|
||||
}
|
||||
|
||||
interface AndroidBridge {
|
||||
startLocationService(): void;
|
||||
startLocationService(ttsConfigJson: string): void;
|
||||
stopLocationService(): void;
|
||||
getPoints(): string;
|
||||
isTracking(): boolean;
|
||||
getAvailableTtsVoices(): string;
|
||||
hasTtsEngine(): boolean;
|
||||
installTtsEngine(): void;
|
||||
pauseTracking(): void;
|
||||
resumeTracking(): void;
|
||||
}
|
||||
|
||||
function checkTauri(): boolean {
|
||||
@@ -98,7 +112,7 @@ export function createGpsTracker() {
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
async function start(voiceGuidance?: VoiceGuidanceConfig) {
|
||||
_debugMsg = 'starting...';
|
||||
if (!checkTauri() || isTracking) {
|
||||
_debugMsg = `bail: tauri=${checkTauri()} tracking=${isTracking}`;
|
||||
@@ -130,7 +144,8 @@ export function createGpsTracker() {
|
||||
const bridge = getAndroidBridge();
|
||||
if (bridge) {
|
||||
_debugMsg = 'starting native GPS service...';
|
||||
bridge.startLocationService();
|
||||
const ttsConfig = JSON.stringify(voiceGuidance ?? {});
|
||||
bridge.startLocationService(ttsConfig);
|
||||
// Poll the native side for collected points
|
||||
_pollTimer = setInterval(pollPoints, POLL_INTERVAL_MS);
|
||||
_debugMsg = 'native GPS service started, polling...';
|
||||
@@ -175,6 +190,53 @@ export function createGpsTracker() {
|
||||
track = [];
|
||||
}
|
||||
|
||||
function getAvailableTtsVoices(): Array<{ id: string; name: string; language: string }> {
|
||||
const bridge = getAndroidBridge();
|
||||
if (!bridge) return [];
|
||||
try {
|
||||
return JSON.parse(bridge.getAvailableTtsVoices());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function hasTtsEngine(): boolean {
|
||||
const bridge = getAndroidBridge();
|
||||
if (!bridge) return false;
|
||||
return bridge.hasTtsEngine();
|
||||
}
|
||||
|
||||
function installTtsEngine(): void {
|
||||
const bridge = getAndroidBridge();
|
||||
bridge?.installTtsEngine();
|
||||
}
|
||||
|
||||
function pauseTracking(): void {
|
||||
const bridge = getAndroidBridge();
|
||||
bridge?.pauseTracking();
|
||||
}
|
||||
|
||||
function resumeTracking(): void {
|
||||
const bridge = getAndroidBridge();
|
||||
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; },
|
||||
@@ -186,7 +248,13 @@ export function createGpsTracker() {
|
||||
get debug() { return _debugMsg; },
|
||||
start,
|
||||
stop,
|
||||
reset
|
||||
reset,
|
||||
getAvailableTtsVoices,
|
||||
hasTtsEngine,
|
||||
installTtsEngine,
|
||||
pauseTracking,
|
||||
resumeTracking,
|
||||
ensurePermissions
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface IActiveWorkout {
|
||||
userId: string;
|
||||
version: number;
|
||||
name: string;
|
||||
mode: 'manual' | 'gps';
|
||||
activityType: 'running' | 'walking' | 'cycling' | 'hiking' | null;
|
||||
templateId: string | null;
|
||||
exercises: IActiveWorkoutExercise[];
|
||||
paused: boolean;
|
||||
@@ -62,6 +64,16 @@ const ActiveWorkoutSchema = new mongoose.Schema(
|
||||
trim: true,
|
||||
maxlength: 100
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
enum: ['manual', 'gps'],
|
||||
default: 'manual'
|
||||
},
|
||||
activityType: {
|
||||
type: String,
|
||||
enum: ['running', 'walking', 'cycling', 'hiking'],
|
||||
default: null
|
||||
},
|
||||
templateId: {
|
||||
type: String,
|
||||
default: null
|
||||
|
||||
@@ -41,12 +41,16 @@ export interface IWorkoutSession {
|
||||
templateId?: string; // Reference to WorkoutTemplate if based on template
|
||||
templateName?: string; // Snapshot of template name for history
|
||||
name: string;
|
||||
mode?: 'manual' | 'gps';
|
||||
activityType?: 'running' | 'walking' | 'cycling' | 'hiking';
|
||||
exercises: ICompletedExercise[];
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
duration?: number; // Duration in minutes
|
||||
totalVolume?: number; // Total weight × reps across all exercises
|
||||
totalDistance?: number; // Total distance across all cardio exercises
|
||||
gpsTrack?: IGpsPoint[]; // Top-level GPS track for GPS-only workouts
|
||||
gpsPreview?: number[][]; // Downsampled [[lat,lng], ...] for card preview
|
||||
prs?: IPr[];
|
||||
notes?: string;
|
||||
createdBy: string; // username/nickname of the person who performed the workout
|
||||
@@ -155,15 +159,18 @@ const WorkoutSessionSchema = new mongoose.Schema(
|
||||
trim: true,
|
||||
maxlength: 100
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
enum: ['manual', 'gps'],
|
||||
default: 'manual'
|
||||
},
|
||||
activityType: {
|
||||
type: String,
|
||||
enum: ['running', 'walking', 'cycling', 'hiking']
|
||||
},
|
||||
exercises: {
|
||||
type: [CompletedExerciseSchema],
|
||||
required: true,
|
||||
validate: {
|
||||
validator: function(exercises: ICompletedExercise[]) {
|
||||
return exercises.length > 0;
|
||||
},
|
||||
message: 'A workout session must have at least one exercise'
|
||||
}
|
||||
default: []
|
||||
},
|
||||
startTime: {
|
||||
type: Date,
|
||||
@@ -185,6 +192,14 @@ const WorkoutSessionSchema = new mongoose.Schema(
|
||||
type: Number,
|
||||
min: 0
|
||||
},
|
||||
gpsTrack: {
|
||||
type: [GpsPointSchema],
|
||||
default: undefined
|
||||
},
|
||||
gpsPreview: {
|
||||
type: [[Number]],
|
||||
default: undefined
|
||||
},
|
||||
prs: [{
|
||||
exerciseId: { type: String, required: true },
|
||||
type: { type: String, required: true },
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { IPr } from '$models/WorkoutSession';
|
||||
import { WorkoutTemplate } from '$models/WorkoutTemplate';
|
||||
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
||||
import { detectCardioPrs } from '$lib/data/cardioPrRanges';
|
||||
import { simplifyTrack } from '$lib/server/simplifyTrack';
|
||||
|
||||
function estimatedOneRepMax(weight: number, reps: number): number {
|
||||
if (reps <= 0 || weight <= 0) return 0;
|
||||
@@ -27,7 +28,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const offset = parseInt(url.searchParams.get('offset') || '0');
|
||||
|
||||
const sessions = await WorkoutSession.find({ createdBy: session.user.nickname })
|
||||
.select('-exercises.gpsTrack')
|
||||
.select('-exercises.gpsTrack -gpsTrack')
|
||||
.sort({ startTime: -1 })
|
||||
.limit(limit)
|
||||
.skip(offset);
|
||||
@@ -52,10 +53,10 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
await dbConnect();
|
||||
|
||||
const data = await request.json();
|
||||
const { templateId, name, exercises, startTime, endTime, notes } = data;
|
||||
const { templateId, name, mode, activityType, exercises, startTime, endTime, notes, gpsTrack, totalDistance: gpsDistance } = data;
|
||||
|
||||
if (!name || !exercises || !Array.isArray(exercises) || exercises.length === 0) {
|
||||
return json({ error: 'Name and at least one exercise are required' }, { status: 400 });
|
||||
if (!name || (!exercises?.length && !gpsTrack?.length)) {
|
||||
return json({ error: 'Name and at least one exercise or GPS track required' }, { status: 400 });
|
||||
}
|
||||
|
||||
let templateName;
|
||||
@@ -68,8 +69,8 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
|
||||
// Compute totalVolume and totalDistance
|
||||
let totalVolume = 0;
|
||||
let totalDistance = 0;
|
||||
for (const ex of exercises) {
|
||||
let totalDistance = gpsDistance ?? 0;
|
||||
for (const ex of (exercises ?? [])) {
|
||||
const exercise = getExerciseById(ex.exerciseId);
|
||||
const metrics = getExerciseMetrics(exercise);
|
||||
const isCardio = metrics.includes('distance');
|
||||
@@ -86,7 +87,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
|
||||
// Detect PRs by comparing against previous best for each exercise
|
||||
const prs: IPr[] = [];
|
||||
for (const ex of exercises) {
|
||||
for (const ex of (exercises ?? [])) {
|
||||
const exercise = getExerciseById(ex.exerciseId);
|
||||
const metrics = getExerciseMetrics(exercise);
|
||||
const isCardio = metrics.includes('distance');
|
||||
@@ -143,16 +144,31 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate GPS preview for top-level GPS track
|
||||
const gpsPreview = gpsTrack?.length >= 2 ? simplifyTrack(gpsTrack) : undefined;
|
||||
|
||||
// Generate gpsPreview for exercise-level GPS tracks
|
||||
const processedExercises = (exercises ?? []).map((ex: any) => {
|
||||
if (ex.gpsTrack?.length >= 2 && !ex.gpsPreview) {
|
||||
return { ...ex, gpsPreview: simplifyTrack(ex.gpsTrack) };
|
||||
}
|
||||
return ex;
|
||||
});
|
||||
|
||||
const workoutSession = new WorkoutSession({
|
||||
templateId,
|
||||
templateName,
|
||||
name,
|
||||
exercises,
|
||||
mode: mode ?? (gpsTrack?.length ? 'gps' : 'manual'),
|
||||
activityType: activityType ?? undefined,
|
||||
exercises: processedExercises,
|
||||
startTime: startTime ? new Date(startTime) : new Date(),
|
||||
endTime: endTime ? new Date(endTime) : undefined,
|
||||
duration: endTime && startTime ? Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / (1000 * 60)) : undefined,
|
||||
totalVolume: totalVolume > 0 ? totalVolume : undefined,
|
||||
totalDistance: totalDistance > 0 ? totalDistance : undefined,
|
||||
gpsTrack: gpsTrack?.length ? gpsTrack : undefined,
|
||||
gpsPreview,
|
||||
prs: prs.length > 0 ? prs : undefined,
|
||||
notes,
|
||||
createdBy: session.user.nickname
|
||||
|
||||
@@ -34,7 +34,7 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
try {
|
||||
await dbConnect();
|
||||
const data = await request.json();
|
||||
const { name, templateId, exercises, paused, elapsed, savedAt, expectedVersion, restStartedAt, restTotal, restExerciseIdx, restSetIdx } = data;
|
||||
const { name, mode, activityType, templateId, exercises, paused, elapsed, savedAt, expectedVersion, restStartedAt, restTotal, restExerciseIdx, restSetIdx } = data;
|
||||
|
||||
if (!name) {
|
||||
return json({ error: 'Name is required' }, { status: 400 });
|
||||
@@ -58,6 +58,8 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
{
|
||||
$set: {
|
||||
name,
|
||||
mode: mode ?? 'manual',
|
||||
activityType: activityType ?? null,
|
||||
templateId: templateId ?? null,
|
||||
exercises: exercises ?? [],
|
||||
paused: paused ?? false,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { Plus, Trash2, Play, Pencil, X, Save, CalendarClock, ChevronUp, ChevronDown, ArrowRight } from 'lucide-svelte';
|
||||
import { Plus, Trash2, Play, Pencil, X, Save, CalendarClock, ChevronUp, ChevronDown, ArrowRight, MapPin, Dumbbell } from 'lucide-svelte';
|
||||
import { getWorkout } from '$lib/js/workout.svelte';
|
||||
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
@@ -48,8 +48,10 @@
|
||||
/** @type {any} */
|
||||
let nextTemplate = $derived(nextTemplateId ? templates.find((t) => t._id === nextTemplateId) : null);
|
||||
let hasSchedule = $derived(scheduleOrder.length > 0);
|
||||
let isApp = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
isApp = '__TAURI__' in window;
|
||||
workout.restore();
|
||||
|
||||
// If there's an active workout, redirect to the active page
|
||||
@@ -93,6 +95,12 @@
|
||||
goto(`/fitness/${sl.workout}/${sl.active}`);
|
||||
}
|
||||
|
||||
async function startGps() {
|
||||
workout.startGpsWorkout('running');
|
||||
await sync.onWorkoutStart();
|
||||
goto(`/fitness/${sl.workout}/${sl.active}`);
|
||||
}
|
||||
|
||||
async function startNextScheduled() {
|
||||
if (!nextTemplate) return;
|
||||
await startFromTemplate(nextTemplate);
|
||||
@@ -333,9 +341,18 @@
|
||||
{/if}
|
||||
|
||||
<section class="quick-start">
|
||||
<button class="start-empty-btn" onclick={startEmpty}>
|
||||
{t('start_empty_workout', lang)}
|
||||
</button>
|
||||
<div class="quick-start-row">
|
||||
{#if isApp}
|
||||
<button class="start-choice-btn" onclick={startGps}>
|
||||
<MapPin size={18} />
|
||||
<span>GPS Workout</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="start-choice-btn" onclick={startEmpty}>
|
||||
{#if isApp}<Dumbbell size={18} />{/if}
|
||||
<span>{t('start_empty_workout', lang)}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="templates-section">
|
||||
@@ -638,19 +655,27 @@
|
||||
.quick-start {
|
||||
text-align: center;
|
||||
}
|
||||
.start-empty-btn {
|
||||
width: 100%;
|
||||
padding: 0.9rem;
|
||||
.quick-start-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.start-choice-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 1rem 0.5rem;
|
||||
background: var(--color-primary);
|
||||
color: var(--primary-contrast);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.start-empty-btn:hover {
|
||||
.start-choice-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.templates-header {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame, MapPin } from 'lucide-svelte';
|
||||
import { Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame, MapPin, Volume2, X } from 'lucide-svelte';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
|
||||
const lang = $derived(detectFitnessLang($page.url.pathname));
|
||||
@@ -41,6 +41,65 @@
|
||||
|
||||
let useGps = $state(gps.isTracking);
|
||||
|
||||
// Voice guidance config
|
||||
let vgEnabled = $state(false);
|
||||
let vgTriggerType = $state('distance');
|
||||
let vgTriggerValue = $state(1);
|
||||
let vgMetrics = $state(['totalTime', 'totalDistance', 'avgPace']);
|
||||
let vgLanguage = $state('en');
|
||||
let vgShowPanel = $state(false);
|
||||
|
||||
// GPS workout mode state — if we're restoring a GPS workout that was already tracking, it's started
|
||||
let gpsStarted = $state(gps.isTracking && workout.mode === 'gps' && !workout.paused);
|
||||
let gpsStarting = $state(false);
|
||||
|
||||
// Activity type for GPS workouts
|
||||
/** @type {import('$lib/js/workout.svelte').GpsActivityType} */
|
||||
let selectedActivity = $state(workout.activityType ?? 'running');
|
||||
let showActivityPicker = $state(false);
|
||||
let showAudioPanel = $state(false);
|
||||
|
||||
const GPS_ACTIVITIES = [
|
||||
{ id: 'running', label: 'Running', icon: '🏃' },
|
||||
{ id: 'walking', label: 'Walking', icon: '🚶' },
|
||||
{ id: 'cycling', label: 'Cycling', icon: '🚴' },
|
||||
{ id: 'hiking', label: 'Hiking', icon: '🥾' },
|
||||
];
|
||||
|
||||
function selectActivity(/** @type {string} */ id) {
|
||||
selectedActivity = /** @type {import('$lib/js/workout.svelte').GpsActivityType} */ (id);
|
||||
const labels = { running: 'Running', walking: 'Walking', cycling: 'Cycling', hiking: 'Hiking' };
|
||||
workout.name = labels[selectedActivity] ?? 'GPS Workout';
|
||||
showActivityPicker = false;
|
||||
}
|
||||
|
||||
const availableMetrics = [
|
||||
{ id: 'totalTime', label: 'Total Time' },
|
||||
{ id: 'totalDistance', label: 'Total Distance' },
|
||||
{ id: 'avgPace', label: 'Average Pace' },
|
||||
{ id: 'splitPace', label: 'Split Pace' },
|
||||
{ id: 'currentPace', label: 'Current Pace' },
|
||||
];
|
||||
|
||||
function getVoiceGuidanceConfig() {
|
||||
if (!vgEnabled) return undefined;
|
||||
return {
|
||||
enabled: true,
|
||||
triggerType: vgTriggerType,
|
||||
triggerValue: vgTriggerValue,
|
||||
metrics: vgMetrics,
|
||||
language: vgLanguage
|
||||
};
|
||||
}
|
||||
|
||||
function toggleMetric(id) {
|
||||
if (vgMetrics.includes(id)) {
|
||||
vgMetrics = vgMetrics.filter(m => m !== id);
|
||||
} else {
|
||||
vgMetrics = [...vgMetrics, id];
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {any} */
|
||||
let liveMap = null;
|
||||
/** @type {any} */
|
||||
@@ -90,6 +149,22 @@
|
||||
liveMarker.setLatLng(pts[pts.length - 1]);
|
||||
liveMap.setView(pts[pts.length - 1], 16);
|
||||
prevTrackLen = gps.track.length;
|
||||
} else {
|
||||
// No track yet — show fallback until GPS kicks in
|
||||
liveMap.setView([51.5, 10], 16);
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
if (liveMap) {
|
||||
const ll = [pos.coords.latitude, pos.coords.longitude];
|
||||
liveMap.setView(ll, 16);
|
||||
liveMarker.setLatLng(ll);
|
||||
}
|
||||
},
|
||||
() => {},
|
||||
{ enableHighAccuracy: true, timeout: 10000 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +177,7 @@
|
||||
if (gps.isTracking) {
|
||||
useGps = true;
|
||||
} else {
|
||||
useGps = await gps.start();
|
||||
useGps = await gps.start(getVoiceGuidanceConfig());
|
||||
}
|
||||
} else {
|
||||
await gps.stop();
|
||||
@@ -119,14 +194,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Sync workout pause state to native GPS/TTS service
|
||||
$effect(() => {
|
||||
if (!gps.isTracking) return;
|
||||
if (workout.paused) {
|
||||
gps.pauseTracking();
|
||||
} else {
|
||||
gps.resumeTracking();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const len = gps.track.length;
|
||||
if (len > prevTrackLen && liveMap && gps.latestPoint) {
|
||||
// Add all new points since last update (native polling delivers batches)
|
||||
for (let i = prevTrackLen; i < len; i++) {
|
||||
const p = gps.track[i];
|
||||
livePolyline.addLatLng([p.lat, p.lng]);
|
||||
if (gpsStarted) {
|
||||
// Only draw the trail once the workout has actually started
|
||||
for (let i = prevTrackLen; i < len; i++) {
|
||||
const p = gps.track[i];
|
||||
livePolyline.addLatLng([p.lat, p.lng]);
|
||||
}
|
||||
}
|
||||
// Always update the position marker
|
||||
const pt = [gps.latestPoint.lat, gps.latestPoint.lng];
|
||||
liveMarker.setLatLng(pt);
|
||||
const zoom = liveMap.getZoom() || 16;
|
||||
@@ -143,9 +231,19 @@
|
||||
});
|
||||
}
|
||||
|
||||
let _prestartGps = false;
|
||||
|
||||
onMount(() => {
|
||||
if (!workout.active && !completionData) {
|
||||
goto(`/fitness/${sl.workout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// For GPS workouts in pre-start: start GPS immediately so the map
|
||||
// shows the user's position while they configure activity/audio.
|
||||
if (workout.mode === 'gps' && !gpsStarted && !gps.isTracking) {
|
||||
_prestartGps = true;
|
||||
gps.start();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -174,33 +272,86 @@
|
||||
// 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();
|
||||
useGps = await gps.start(getVoiceGuidanceConfig());
|
||||
}
|
||||
}
|
||||
|
||||
async function startGpsWorkout() {
|
||||
if (gpsStarting) return;
|
||||
gpsStarting = true;
|
||||
try {
|
||||
if (_prestartGps && gps.isTracking) {
|
||||
// GPS was running for pre-start preview — stop and restart
|
||||
// so the native service resets time/distance to zero
|
||||
await gps.stop();
|
||||
gps.reset();
|
||||
}
|
||||
const started = await gps.start(getVoiceGuidanceConfig());
|
||||
if (started) {
|
||||
gpsStarted = true;
|
||||
useGps = true;
|
||||
workout.resumeTimer();
|
||||
}
|
||||
} finally {
|
||||
gpsStarting = false;
|
||||
_prestartGps = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Map GPS activity types to exercise IDs */
|
||||
const ACTIVITY_EXERCISE_MAP = /** @type {Record<string, string>} */ ({
|
||||
running: 'running',
|
||||
walking: 'walking',
|
||||
cycling: 'cycling-outdoor',
|
||||
hiking: 'hiking',
|
||||
});
|
||||
|
||||
async function finishWorkout() {
|
||||
// Stop GPS tracking and collect track data
|
||||
const gpsTrack = gps.isTracking ? await gps.stop() : [];
|
||||
const wasGpsMode = workout.mode === 'gps';
|
||||
const actType = workout.activityType;
|
||||
|
||||
const sessionData = workout.finish();
|
||||
if (sessionData.exercises.length === 0) {
|
||||
|
||||
if (wasGpsMode && gpsTrack.length >= 2) {
|
||||
// GPS workout: create a cardio exercise entry with the track attached,
|
||||
// just like a manually-added workout with GPX upload
|
||||
const filteredDistance = trackDistance(gpsTrack);
|
||||
const durationMin = (gpsTrack[gpsTrack.length - 1].timestamp - gpsTrack[0].timestamp) / 60000;
|
||||
const exerciseId = ACTIVITY_EXERCISE_MAP[actType ?? 'running'] ?? 'running';
|
||||
const exerciseName = getExerciseById(exerciseId)?.name ?? exerciseId;
|
||||
|
||||
sessionData.exercises = [{
|
||||
exerciseId,
|
||||
name: exerciseName,
|
||||
sets: [{
|
||||
distance: filteredDistance,
|
||||
duration: Math.round(durationMin * 100) / 100,
|
||||
completed: true,
|
||||
}],
|
||||
gpsTrack,
|
||||
totalDistance: filteredDistance,
|
||||
}];
|
||||
} else if (wasGpsMode && gpsTrack.length === 0) {
|
||||
// GPS workout with no track data — nothing to save
|
||||
gps.reset();
|
||||
await sync.onWorkoutEnd();
|
||||
await goto(`/fitness/${sl.workout}`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Manual workout: attach GPS to cardio exercises
|
||||
const workoutStart = new Date(sessionData.startTime).getTime();
|
||||
const filteredTrack = gpsTrack.filter((/** @type {any} */ p) => p.timestamp >= workoutStart);
|
||||
const filteredDistance = trackDistance(filteredTrack);
|
||||
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,7 +392,7 @@
|
||||
const durationMin = Math.round((endTime.getTime() - startTime.getTime()) / 60000);
|
||||
|
||||
let totalTonnage = 0;
|
||||
let totalDistance = 0;
|
||||
let totalDistance = local.totalDistance ?? 0;
|
||||
/** @type {any[]} */
|
||||
const prs = [];
|
||||
|
||||
@@ -705,6 +856,155 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if workout.active && workout.mode === 'gps'}
|
||||
<div class="gps-workout">
|
||||
<div class="gps-workout-map" use:mountMap></div>
|
||||
|
||||
<!-- Overlay: sits on top of the map at the bottom -->
|
||||
<div class="gps-overlay">
|
||||
{#if gpsStarted}
|
||||
<div class="gps-workout-stats">
|
||||
<div class="gps-stat">
|
||||
<span class="gps-stat-value">{gps.distance.toFixed(2)}</span>
|
||||
<span class="gps-stat-unit">km</span>
|
||||
</div>
|
||||
<div class="gps-stat">
|
||||
<span class="gps-stat-value">{formatElapsed(workout.elapsedSeconds)}</span>
|
||||
<span class="gps-stat-unit">time</span>
|
||||
</div>
|
||||
{#if gps.currentPace > 0}
|
||||
<div class="gps-stat">
|
||||
<span class="gps-stat-value">{Math.floor(gps.currentPace)}:{Math.round((gps.currentPace % 1) * 60).toString().padStart(2, '0')}</span>
|
||||
<span class="gps-stat-unit">/km</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if vgEnabled}
|
||||
<div class="vg-active-badge">
|
||||
<Volume2 size={12} />
|
||||
<span>Voice: every {vgTriggerValue} {vgTriggerType === 'distance' ? 'km' : 'min'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="gps-overlay-actions">
|
||||
<button class="gps-overlay-pause" onclick={() => workout.paused ? workout.resumeTimer() : workout.pauseTimer()} aria-label={workout.paused ? 'Resume' : 'Pause'}>
|
||||
{#if workout.paused}<Play size={22} />{:else}<Pause size={22} />{/if}
|
||||
</button>
|
||||
{#if workout.paused}
|
||||
<button class="gps-overlay-cancel" onclick={async () => { if (gps.isTracking) await gps.stop(); gps.reset(); workout.cancel(); await sync.onWorkoutEnd(); await goto(`/fitness/${sl.workout}`); }}>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
{/if}
|
||||
<button class="gps-overlay-finish" onclick={finishWorkout}>Finish</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="gps-options-grid">
|
||||
<button class="gps-option-tile" onclick={() => { showActivityPicker = !showActivityPicker; showAudioPanel = false; }} type="button">
|
||||
<span class="gps-option-icon">{GPS_ACTIVITIES.find(a => a.id === selectedActivity)?.icon ?? '🏃'}</span>
|
||||
<span class="gps-option-label">Activity</span>
|
||||
<span class="gps-option-value">{GPS_ACTIVITIES.find(a => a.id === selectedActivity)?.label ?? 'Running'}</span>
|
||||
</button>
|
||||
<button class="gps-option-tile" onclick={() => { showAudioPanel = !showAudioPanel; showActivityPicker = false; }} type="button">
|
||||
<Volume2 size={20} />
|
||||
<span class="gps-option-label">Audio Stats</span>
|
||||
<span class="gps-option-value">{vgEnabled ? `Every ${vgTriggerValue} ${vgTriggerType === 'distance' ? 'km' : 'min'}` : 'Off'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showActivityPicker}
|
||||
<div class="gps-activity-picker">
|
||||
{#each GPS_ACTIVITIES as act (act.id)}
|
||||
<button
|
||||
class="gps-activity-choice"
|
||||
class:active={selectedActivity === act.id}
|
||||
onclick={() => selectActivity(act.id)}
|
||||
type="button"
|
||||
>
|
||||
<span class="gps-activity-icon">{act.icon}</span>
|
||||
<span>{act.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showAudioPanel}
|
||||
<div class="vg-panel">
|
||||
{#if !gps.hasTtsEngine()}
|
||||
<div class="vg-no-engine">
|
||||
<span>No text-to-speech engine installed.</span>
|
||||
<button class="vg-install-btn" onclick={() => gps.installTtsEngine()} type="button">
|
||||
Install TTS Engine
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<label class="vg-row">
|
||||
<input type="checkbox" bind:checked={vgEnabled} />
|
||||
<span>Enable voice announcements</span>
|
||||
</label>
|
||||
|
||||
{#if vgEnabled}
|
||||
<div class="vg-group">
|
||||
<span class="vg-label">Announce every</span>
|
||||
<div class="vg-trigger-row">
|
||||
<input
|
||||
class="vg-number"
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.5"
|
||||
bind:value={vgTriggerValue}
|
||||
/>
|
||||
<select class="vg-select" bind:value={vgTriggerType}>
|
||||
<option value="distance">km</option>
|
||||
<option value="time">min</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vg-group">
|
||||
<span class="vg-label">Metrics</span>
|
||||
<div class="vg-metrics">
|
||||
{#each availableMetrics as m (m.id)}
|
||||
<button
|
||||
class="vg-metric-chip"
|
||||
class:selected={vgMetrics.includes(m.id)}
|
||||
onclick={() => toggleMetric(m.id)}
|
||||
type="button"
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vg-group">
|
||||
<span class="vg-label">Language</span>
|
||||
<select class="vg-select" bind:value={vgLanguage}>
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button class="gps-start-btn" onclick={startGpsWorkout} disabled={gpsStarting}>
|
||||
{#if gpsStarting}
|
||||
<span class="gps-spinner"></span> Initializing GPS…
|
||||
{:else}
|
||||
Start
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button class="gps-cancel-link" onclick={async () => { if (gps.isTracking) await gps.stop(); gps.reset(); workout.cancel(); await sync.onWorkoutEnd(); await goto(`/fitness/${sl.workout}`); }} type="button">
|
||||
<X size={14} />
|
||||
{t('cancel_workout', lang)}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if workout.active}
|
||||
<div class="active-workout">
|
||||
<input
|
||||
@@ -729,6 +1029,76 @@
|
||||
<span class="gps-spinner"></span> {t('initializing_gps', lang) ?? 'Initializing GPS…'}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !useGps}
|
||||
<button class="vg-toggle-row" onclick={() => vgShowPanel = !vgShowPanel} type="button">
|
||||
<Volume2 size={14} />
|
||||
<span class="gps-toggle-track" class:checked={vgEnabled}></span>
|
||||
<span>Voice Guidance</span>
|
||||
</button>
|
||||
|
||||
{#if vgShowPanel}
|
||||
<div class="vg-panel">
|
||||
{#if !gps.hasTtsEngine()}
|
||||
<div class="vg-no-engine">
|
||||
<span>No text-to-speech engine installed.</span>
|
||||
<button class="vg-install-btn" onclick={() => gps.installTtsEngine()} type="button">
|
||||
Install TTS Engine
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<label class="vg-row">
|
||||
<input type="checkbox" bind:checked={vgEnabled} />
|
||||
<span>Enable voice announcements</span>
|
||||
</label>
|
||||
|
||||
{#if vgEnabled}
|
||||
<div class="vg-group">
|
||||
<span class="vg-label">Announce every</span>
|
||||
<div class="vg-trigger-row">
|
||||
<input
|
||||
class="vg-number"
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.5"
|
||||
bind:value={vgTriggerValue}
|
||||
/>
|
||||
<select class="vg-select" bind:value={vgTriggerType}>
|
||||
<option value="distance">km</option>
|
||||
<option value="time">min</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vg-group">
|
||||
<span class="vg-label">Metrics</span>
|
||||
<div class="vg-metrics">
|
||||
{#each availableMetrics as m (m.id)}
|
||||
<button
|
||||
class="vg-metric-chip"
|
||||
class:selected={vgMetrics.includes(m.id)}
|
||||
onclick={() => toggleMetric(m.id)}
|
||||
type="button"
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vg-group">
|
||||
<span class="vg-label">Language</span>
|
||||
<select class="vg-select" bind:value={vgLanguage}>
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if useGps}
|
||||
<div class="gps-bar active">
|
||||
<span class="gps-distance">{gps.distance.toFixed(2)} km</span>
|
||||
@@ -737,6 +1107,12 @@
|
||||
{/if}
|
||||
<span class="gps-label">{gps.track.length} pts</span>
|
||||
</div>
|
||||
{#if vgEnabled}
|
||||
<div class="vg-active-badge">
|
||||
<Volume2 size={12} />
|
||||
<span>Voice: every {vgTriggerValue} {vgTriggerType === 'distance' ? 'km' : 'min'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="live-map" use:mountMap></div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1315,4 +1691,374 @@
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Voice Guidance */
|
||||
.vg-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;
|
||||
}
|
||||
.vg-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
padding: 0.5rem 0 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.vg-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.vg-row input[type="checkbox"] {
|
||||
accent-color: var(--nord14);
|
||||
}
|
||||
.vg-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.vg-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.vg-trigger-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.vg-number {
|
||||
width: 70px;
|
||||
padding: 0.3rem 0.4rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.vg-select {
|
||||
padding: 0.3rem 0.4rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.vg-metrics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.vg-metric-chip {
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.vg-metric-chip.selected {
|
||||
background: var(--nord14);
|
||||
color: var(--nord0);
|
||||
border-color: var(--nord14);
|
||||
}
|
||||
.vg-no-engine {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.vg-install-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--nord14);
|
||||
background: transparent;
|
||||
color: var(--nord14);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.vg-active-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--nord14);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.gps-overlay .vg-active-badge {
|
||||
color: var(--nord7);
|
||||
}
|
||||
.gps-overlay .vg-panel {
|
||||
background: rgba(255,255,255,0.1);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem;
|
||||
border-top: none;
|
||||
color: #fff;
|
||||
}
|
||||
.gps-overlay .vg-label {
|
||||
color: rgba(255,255,255,0.6);
|
||||
}
|
||||
.gps-overlay .vg-row {
|
||||
color: #fff;
|
||||
}
|
||||
.gps-overlay .vg-number,
|
||||
.gps-overlay .vg-select {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-color: rgba(255,255,255,0.2);
|
||||
color: #fff;
|
||||
}
|
||||
.gps-overlay .vg-metric-chip {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-color: rgba(255,255,255,0.2);
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
.gps-overlay .vg-metric-chip.selected {
|
||||
background: var(--nord14);
|
||||
color: var(--nord0);
|
||||
border-color: var(--nord14);
|
||||
}
|
||||
|
||||
/* GPS Workout Mode — full-bleed map with overlay */
|
||||
.gps-workout {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
.gps-workout-map {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
/* Dark gradient at top so status bar text stays readable */
|
||||
.gps-workout::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(env(safe-area-inset-top, 0px) + 3rem + 24px);
|
||||
background: linear-gradient(to bottom, rgba(0,0,0,0.45), transparent);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
:global(.gps-workout-map .leaflet-control-container) {
|
||||
/* push leaflet's own controls above our overlay */
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
.gps-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.7) 60%, transparent);
|
||||
color: #fff;
|
||||
pointer-events: none;
|
||||
}
|
||||
.gps-overlay > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.gps-workout-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.gps-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.gps-stat-value {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||
}
|
||||
.gps-stat-unit {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgba(255,255,255,0.75);
|
||||
}
|
||||
.gps-options-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.gps-option-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.65rem 0.5rem;
|
||||
background: rgba(255,255,255,0.12);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: #fff;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
.gps-option-tile:hover {
|
||||
border-color: rgba(255,255,255,0.5);
|
||||
}
|
||||
.gps-option-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.gps-option-label {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255,255,255,0.6);
|
||||
}
|
||||
.gps-option-value {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.gps-activity-picker {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.gps-activity-choice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: rgba(255,255,255,0.1);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.gps-activity-choice.active {
|
||||
border-color: var(--nord8);
|
||||
background: rgba(136,192,208,0.25);
|
||||
color: var(--nord8);
|
||||
}
|
||||
.gps-activity-choice:hover:not(.active) {
|
||||
border-color: rgba(255,255,255,0.4);
|
||||
}
|
||||
.gps-activity-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.gps-start-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: var(--color-primary);
|
||||
color: var(--primary-contrast);
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
font-weight: 800;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.gps-start-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.gps-cancel-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255,255,255,0.5);
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
.gps-cancel-link:hover {
|
||||
color: var(--nord11);
|
||||
}
|
||||
.gps-overlay-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.gps-overlay-pause {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
background: rgba(255,255,255,0.15);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255,255,255,0.25);
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gps-overlay-pause:hover {
|
||||
background: rgba(255,255,255,0.25);
|
||||
}
|
||||
.gps-overlay-cancel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
background: rgba(191,97,106,0.25);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--nord11);
|
||||
border-radius: 50%;
|
||||
color: var(--nord11);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gps-overlay-cancel:hover {
|
||||
background: rgba(191,97,106,0.4);
|
||||
}
|
||||
.gps-overlay-finish {
|
||||
flex: 1;
|
||||
padding: 0.85rem;
|
||||
background: var(--nord11);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user