diff --git a/src-tauri/gen/android/app/src/main/java/org/bocken/app/AndroidBridge.kt b/src-tauri/gen/android/app/src/main/java/org/bocken/app/AndroidBridge.kt index 97463fc..3f1df67 100644 --- a/src-tauri/gen/android/app/src/main/java/org/bocken/app/AndroidBridge.kt +++ b/src-tauri/gen/android/app/src/main/java/org/bocken/app/AndroidBridge.kt @@ -97,6 +97,11 @@ class AndroidBridge(private val context: Context) { LocationForegroundService.instance?.doResume() } + @JavascriptInterface + fun getIntervalState(): String { + return LocationForegroundService.getIntervalState() + } + /** Returns true if at least one TTS engine is installed on the device. */ @JavascriptInterface fun hasTtsEngine(): Boolean { diff --git a/src-tauri/gen/android/app/src/main/java/org/bocken/app/LocationForegroundService.kt b/src-tauri/gen/android/app/src/main/java/org/bocken/app/LocationForegroundService.kt index b965371..dd9ec7b 100644 --- a/src-tauri/gen/android/app/src/main/java/org/bocken/app/LocationForegroundService.kt +++ b/src-tauri/gen/android/app/src/main/java/org/bocken/app/LocationForegroundService.kt @@ -48,13 +48,27 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener { private var splitDistanceAtLastAnnouncement: Double = 0.0 private var splitTimeAtLastAnnouncement: Long = 0L + // Interval tracking + private var intervalSteps: List = emptyList() + private var currentIntervalIdx: Int = 0 + private var intervalAccumulatedDistanceKm: Double = 0.0 + private var intervalStartTimeMs: Long = 0L + private var intervalsComplete: Boolean = false + + data class IntervalStep( + val label: String, + val durationType: String, // "distance" or "time" + val durationValue: Double // meters (distance) or seconds (time) + ) + 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 = listOf("totalTime", "totalDistance", "avgPace"), val language: String = "en", - val voiceId: String? = null + val voiceId: String? = null, + val intervals: List = emptyList() ) { companion object { fun fromJson(json: String): TtsConfig { @@ -66,13 +80,27 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener { } else { listOf("totalTime", "totalDistance", "avgPace") } + val intervalsArr = obj.optJSONArray("intervals") + val intervals = if (intervalsArr != null) { + (0 until intervalsArr.length()).map { i -> + val step = intervalsArr.getJSONObject(i) + IntervalStep( + label = step.optString("label", ""), + durationType = step.optString("durationType", "time"), + durationValue = step.optDouble("durationValue", 0.0) + ) + } + } else { + emptyList() + } 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) + voiceId = obj.optString("voiceId", null), + intervals = intervals ) } catch (_: Exception) { TtsConfig() @@ -97,6 +125,35 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener { var totalDistanceKm: Double = 0.0 private set + fun getIntervalState(): String { + val svc = instance ?: return "{}" + if (svc.intervalSteps.isEmpty()) return "{}" + val obj = JSONObject() + obj.put("currentIndex", svc.currentIntervalIdx) + obj.put("totalSteps", svc.intervalSteps.size) + obj.put("complete", svc.intervalsComplete) + if (!svc.intervalsComplete && svc.currentIntervalIdx < svc.intervalSteps.size) { + val step = svc.intervalSteps[svc.currentIntervalIdx] + obj.put("currentLabel", step.label) + val progress = when (step.durationType) { + "distance" -> { + val target = step.durationValue / 1000.0 + if (target > 0) (svc.intervalAccumulatedDistanceKm / target).coerceIn(0.0, 1.0) else 0.0 + } + "time" -> { + val target = step.durationValue * 1000.0 + if (target > 0) ((System.currentTimeMillis() - svc.intervalStartTimeMs) / target).coerceIn(0.0, 1.0) else 0.0 + } + else -> 0.0 + } + obj.put("progress", progress) + } else { + obj.put("currentLabel", "") + obj.put("progress", 1.0) + } + return obj.toString() + } + fun drainPoints(): String { val drained: List synchronized(pointBuffer) { @@ -144,6 +201,19 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener { ttsConfig = TtsConfig.fromJson(configJson) Log.d(TAG, "TTS enabled=${ttsConfig?.enabled}, trigger=${ttsConfig?.triggerType}/${ttsConfig?.triggerValue}, metrics=${ttsConfig?.metrics}") + // Initialize interval tracking + intervalSteps = ttsConfig?.intervals ?: emptyList() + currentIntervalIdx = 0 + intervalAccumulatedDistanceKm = 0.0 + intervalStartTimeMs = startTimeMs + intervalsComplete = false + if (intervalSteps.isNotEmpty()) { + Log.d(TAG, "Intervals configured: ${intervalSteps.size} steps") + intervalSteps.forEachIndexed { i, step -> + Log.d(TAG, " Step $i: ${step.label} ${step.durationValue} ${step.durationType}") + } + } + val notifIntent = Intent(this, MainActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) } @@ -216,6 +286,24 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener { ttsReady = true Log.d(TAG, "TTS ready! triggerType=${config.triggerType}, triggerValue=${config.triggerValue}") + // Announce first interval step if intervals are configured + if (intervalSteps.isNotEmpty() && !intervalsComplete) { + val first = intervalSteps[0] + val durationText = if (first.durationType == "distance") { + "${first.durationValue.toInt()} meters" + } else { + val secs = first.durationValue.toInt() + if (secs >= 60) { + val m = secs / 60 + val s = secs % 60 + if (s > 0) "$m minutes $s seconds" else "$m minutes" + } else { + "$secs seconds" + } + } + announceIntervalTransition("${first.label}. $durationText") + } + // Set up time-based trigger if configured if (config.triggerType == "time") { startTimeTrigger(config.triggerValue) @@ -289,6 +377,60 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener { } } + private fun checkIntervalProgress(segmentKm: Double) { + if (intervalsComplete || intervalSteps.isEmpty()) return + if (currentIntervalIdx >= intervalSteps.size) return + + val step = intervalSteps[currentIntervalIdx] + val now = System.currentTimeMillis() + + val complete = when (step.durationType) { + "distance" -> { + intervalAccumulatedDistanceKm += segmentKm + intervalAccumulatedDistanceKm >= step.durationValue / 1000.0 + } + "time" -> { + (now - intervalStartTimeMs) >= step.durationValue * 1000 + } + else -> false + } + + if (complete) { + currentIntervalIdx++ + intervalAccumulatedDistanceKm = 0.0 + intervalStartTimeMs = now + + if (currentIntervalIdx >= intervalSteps.size) { + intervalsComplete = true + Log.d(TAG, "All intervals complete!") + announceIntervalTransition("Intervals complete") + } else { + val next = intervalSteps[currentIntervalIdx] + val durationText = if (next.durationType == "distance") { + "${next.durationValue.toInt()} meters" + } else { + val secs = next.durationValue.toInt() + if (secs >= 60) { + val m = secs / 60 + val s = secs % 60 + if (s > 0) "$m minutes $s seconds" else "$m minutes" + } else { + "$secs seconds" + } + } + Log.d(TAG, "Interval transition: step ${currentIntervalIdx}/${intervalSteps.size} — ${next.label} $durationText") + announceIntervalTransition("${next.label}. $durationText") + } + updateNotification() + } + } + + private fun announceIntervalTransition(text: String) { + if (!ttsReady) return + Log.d(TAG, "Interval announcement: $text") + tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "interval_announcement") + } + private fun announceMetrics() { if (!ttsReady) return val config = ttsConfig ?: return @@ -411,10 +553,18 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener { } private fun updateNotification() { + val paceStr = if (intervalSteps.isNotEmpty() && !intervalsComplete && currentIntervalIdx < intervalSteps.size) { + val step = intervalSteps[currentIntervalIdx] + "${step.label} (${currentIntervalIdx + 1}/${intervalSteps.size})" + } else if (intervalsComplete) { + "Intervals done" + } else { + formatPace(currentPaceMinKm) + } val notification = buildNotification( formatElapsed(), "%.2f km".format(totalDistanceKm), - formatPace(currentPaceMinKm) + paceStr ) notificationManager?.notify(NOTIFICATION_ID, notification) } @@ -449,6 +599,11 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener { val dtMin = (now - lastTimestamp) / 60000.0 currentPaceMinKm = dtMin / segmentKm } + // Check interval progress with this segment's distance + checkIntervalProgress(segmentKm) + } else { + // First point — check time-based intervals even with no distance + checkIntervalProgress(0.0) } lastLat = lat lastLng = lng diff --git a/src/lib/js/fitnessI18n.ts b/src/lib/js/fitnessI18n.ts index b88875f..7a1110a 100644 --- a/src/lib/js/fitnessI18n.ts +++ b/src/lib/js/fitnessI18n.ts @@ -235,6 +235,34 @@ const translations: Translations = { workouts_per_week_goal: { en: 'workouts / week', de: 'Trainings / Woche' }, set_goal: { en: 'Set Goal', de: 'Ziel setzen' }, goal_set: { en: 'Goal set', de: 'Ziel gesetzt' }, + + // Intervals + intervals: { en: 'Intervals', de: 'Intervalle' }, + no_intervals: { en: 'None', de: 'Keine' }, + new_interval: { en: 'New Interval', de: 'Neues Intervall' }, + edit_interval: { en: 'Edit Interval', de: 'Intervall bearbeiten' }, + delete_interval: { en: 'Delete', de: 'Löschen' }, + delete_interval_confirm: { en: 'Delete this interval template?', de: 'Diese Intervallvorlage löschen?' }, + add_step: { en: '+ Add Step', de: '+ Schritt hinzufügen' }, + step_label: { en: 'Label', de: 'Bezeichnung' }, + meters: { en: 'meters', de: 'Meter' }, + seconds: { en: 'seconds', de: 'Sekunden' }, + intervals_complete: { en: 'Intervals complete', de: 'Intervalle abgeschlossen' }, + select_interval: { en: 'Select Interval', de: 'Intervall wählen' }, + custom: { en: 'Custom', de: 'Eigene' }, + steps_count: { en: 'steps', de: 'Schritte' }, + save_interval: { en: 'Save Interval', de: 'Intervall speichern' }, + interval_name_placeholder: { en: 'Interval name', de: 'Intervallname' }, + // Preset labels + label_easy: { en: 'Easy', de: 'Leicht' }, + label_moderate: { en: 'Moderate', de: 'Moderat' }, + label_hard: { en: 'Hard', de: 'Hart' }, + label_sprint: { en: 'Sprint', de: 'Sprint' }, + label_recovery: { en: 'Recovery', de: 'Erholung' }, + label_hill_sprints: { en: 'Hill Sprints', de: 'Bergsprints' }, + label_tempo: { en: 'Tempo', de: 'Tempo' }, + label_warm_up: { en: 'Warm Up', de: 'Aufwärmen' }, + label_cool_down: { en: 'Cool Down', de: 'Abkühlen' }, }; /** Get a translated string */ diff --git a/src/lib/js/gps.svelte.ts b/src/lib/js/gps.svelte.ts index 4745c2b..92c6fbf 100644 --- a/src/lib/js/gps.svelte.ts +++ b/src/lib/js/gps.svelte.ts @@ -13,6 +13,12 @@ export interface GpsPoint { timestamp: number; } +export interface IntervalStep { + label: string; + durationType: 'distance' | 'time'; + durationValue: number; // meters (distance) or seconds (time) +} + export interface VoiceGuidanceConfig { enabled: boolean; triggerType: 'distance' | 'time'; @@ -20,6 +26,15 @@ export interface VoiceGuidanceConfig { metrics: string[]; language: string; voiceId?: string; + intervals?: IntervalStep[]; +} + +export interface IntervalState { + currentIndex: number; + totalSteps: number; + currentLabel: string; + progress: number; // 0.0–1.0 + complete: boolean; } interface AndroidBridge { @@ -32,6 +47,7 @@ interface AndroidBridge { installTtsEngine(): void; pauseTracking(): void; resumeTracking(): void; + getIntervalState(): string; } function checkTauri(): boolean { @@ -95,6 +111,7 @@ export function createGpsTracker() { ); let _debugMsg = $state(''); + let _intervalState = $state(null); function pollPoints() { const bridge = getAndroidBridge(); @@ -110,6 +127,13 @@ export function createGpsTracker() { } catch (e) { _debugMsg = `poll err: ${(e as Error)?.message ?? e}`; } + // Poll interval state + try { + const stateJson = bridge.getIntervalState(); + if (stateJson && stateJson !== '{}') { + _intervalState = JSON.parse(stateJson); + } + } catch { /* no interval active */ } } async function start(voiceGuidance?: VoiceGuidanceConfig, startPaused = false) { @@ -182,12 +206,14 @@ export function createGpsTracker() { } isTracking = false; + _intervalState = null; const result = [...track]; return result; } function reset() { track = []; + _intervalState = null; } function getAvailableTtsVoices(): Array<{ id: string; name: string; language: string }> { @@ -246,6 +272,7 @@ export function createGpsTracker() { get latestPoint() { return latestPoint; }, get available() { return checkTauri(); }, get debug() { return _debugMsg; }, + get intervalState() { return _intervalState; }, start, stop, reset, diff --git a/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte b/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte index 3109b49..b2ef76c 100644 --- a/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte +++ b/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte @@ -1,7 +1,7 @@