fintess: WIP: interval setup and TTS
All checks were successful
CI / update (push) Successful in 2m17s
All checks were successful
CI / update (push) Successful in 2m17s
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<IntervalStep> = 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<String> = listOf("totalTime", "totalDistance", "avgPace"),
|
||||
val language: String = "en",
|
||||
val voiceId: String? = null
|
||||
val voiceId: String? = null,
|
||||
val intervals: List<IntervalStep> = 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<JSONObject>
|
||||
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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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<IntervalState | null>(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,
|
||||
|
||||
@@ -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, Volume2, X } from 'lucide-svelte';
|
||||
import { Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame, MapPin, Volume2, X, Timer, Plus, GripVertical } from 'lucide-svelte';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
|
||||
const lang = $derived(detectFitnessLang($page.url.pathname));
|
||||
@@ -72,6 +72,98 @@
|
||||
let selectedActivity = $state(workout.activityType ?? 'running');
|
||||
let showActivityPicker = $state(false);
|
||||
let showAudioPanel = $state(false);
|
||||
let showIntervalPanel = $state(false);
|
||||
|
||||
// Interval templates
|
||||
/** @type {any[]} */
|
||||
let intervalTemplates = $state([]);
|
||||
let selectedIntervalId = $state(/** @type {string | null} */ (null));
|
||||
let showIntervalEditor = $state(false);
|
||||
let editingIntervalId = $state(/** @type {string | null} */ (null));
|
||||
let intervalEditorName = $state('');
|
||||
/** @type {Array<{label: string, durationType: 'distance' | 'time', durationValue: number, customLabel: boolean}>} */
|
||||
let intervalEditorSteps = $state([]);
|
||||
let intervalSaving = $state(false);
|
||||
|
||||
const PRESET_LABELS = ['Easy', 'Moderate', 'Hard', 'Sprint', 'Recovery', 'Hill Sprints', 'Tempo', 'Warm Up', 'Cool Down'];
|
||||
|
||||
const selectedInterval = $derived(intervalTemplates.find((/** @type {any} */ t) => t._id === selectedIntervalId) ?? null);
|
||||
|
||||
async function fetchIntervalTemplates() {
|
||||
try {
|
||||
const res = await fetch('/api/fitness/intervals');
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
intervalTemplates = d.templates ?? [];
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function openNewInterval() {
|
||||
editingIntervalId = null;
|
||||
intervalEditorName = '';
|
||||
intervalEditorSteps = [{ label: 'Sprint', durationType: 'distance', durationValue: 400, customLabel: false }];
|
||||
showIntervalEditor = true;
|
||||
}
|
||||
|
||||
function openEditInterval(/** @type {any} */ tmpl) {
|
||||
editingIntervalId = tmpl._id;
|
||||
intervalEditorName = tmpl.name;
|
||||
intervalEditorSteps = tmpl.steps.map((/** @type {any} */ s) => ({
|
||||
label: s.label,
|
||||
durationType: s.durationType,
|
||||
durationValue: s.durationValue,
|
||||
customLabel: !PRESET_LABELS.includes(s.label)
|
||||
}));
|
||||
showIntervalEditor = true;
|
||||
}
|
||||
|
||||
function addIntervalStep() {
|
||||
intervalEditorSteps = [...intervalEditorSteps, { label: 'Recovery', durationType: 'time', durationValue: 60, customLabel: false }];
|
||||
}
|
||||
|
||||
function removeIntervalStep(/** @type {number} */ idx) {
|
||||
intervalEditorSteps = intervalEditorSteps.filter((_, i) => i !== idx);
|
||||
}
|
||||
|
||||
function moveIntervalStep(/** @type {number} */ idx, /** @type {number} */ dir) {
|
||||
const target = idx + dir;
|
||||
if (target < 0 || target >= intervalEditorSteps.length) return;
|
||||
const arr = [...intervalEditorSteps];
|
||||
[arr[idx], arr[target]] = [arr[target], arr[idx]];
|
||||
intervalEditorSteps = arr;
|
||||
}
|
||||
|
||||
async function saveInterval() {
|
||||
if (intervalSaving || !intervalEditorName.trim() || intervalEditorSteps.length === 0) return;
|
||||
intervalSaving = true;
|
||||
try {
|
||||
const body = {
|
||||
name: intervalEditorName.trim(),
|
||||
steps: intervalEditorSteps.map(s => ({
|
||||
label: s.label,
|
||||
durationType: s.durationType,
|
||||
durationValue: s.durationValue
|
||||
}))
|
||||
};
|
||||
const url = editingIntervalId ? `/api/fitness/intervals/${editingIntervalId}` : '/api/fitness/intervals';
|
||||
const method = editingIntervalId ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (res.ok) {
|
||||
showIntervalEditor = false;
|
||||
await fetchIntervalTemplates();
|
||||
}
|
||||
} finally {
|
||||
intervalSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteInterval(/** @type {string} */ id) {
|
||||
if (!confirm(t('delete_interval_confirm', lang))) return;
|
||||
await fetch(`/api/fitness/intervals/${id}`, { method: 'DELETE' });
|
||||
if (selectedIntervalId === id) selectedIntervalId = null;
|
||||
await fetchIntervalTemplates();
|
||||
}
|
||||
|
||||
const GPS_ACTIVITIES = [
|
||||
{ id: 'running', label: 'Running', icon: '🏃' },
|
||||
@@ -96,13 +188,15 @@
|
||||
];
|
||||
|
||||
function getVoiceGuidanceConfig() {
|
||||
if (!vgEnabled) return undefined;
|
||||
const hasIntervals = selectedInterval?.steps?.length > 0;
|
||||
if (!vgEnabled && !hasIntervals) return undefined;
|
||||
return {
|
||||
enabled: true,
|
||||
triggerType: vgTriggerType,
|
||||
triggerValue: vgTriggerValue,
|
||||
metrics: vgMetrics,
|
||||
language: vgLanguage
|
||||
metrics: vgEnabled ? vgMetrics : [],
|
||||
language: vgLanguage,
|
||||
...(hasIntervals ? { intervals: selectedInterval.steps } : {})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -277,6 +371,15 @@
|
||||
} catch {}
|
||||
vgLoaded = true;
|
||||
|
||||
// Restore selected interval from localStorage
|
||||
try {
|
||||
const savedId = localStorage.getItem('selected_interval_id');
|
||||
if (savedId) selectedIntervalId = savedId;
|
||||
} catch {}
|
||||
|
||||
// Fetch interval templates
|
||||
fetchIntervalTemplates();
|
||||
|
||||
// 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) {
|
||||
@@ -285,6 +388,16 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Persist selected interval ID
|
||||
$effect(() => {
|
||||
if (!vgLoaded) return;
|
||||
if (selectedIntervalId) {
|
||||
localStorage.setItem('selected_interval_id', selectedIntervalId);
|
||||
} else {
|
||||
localStorage.removeItem('selected_interval_id');
|
||||
}
|
||||
});
|
||||
|
||||
/** @param {string[]} exerciseIds */
|
||||
async function fetchPreviousData(exerciseIds) {
|
||||
const promises = exerciseIds.map(async (id) => {
|
||||
@@ -926,6 +1039,25 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if gps.intervalState}
|
||||
<div class="interval-active-overlay">
|
||||
{#if gps.intervalState.complete}
|
||||
<div class="interval-complete-badge">
|
||||
<Check size={14} />
|
||||
<span>{t('intervals_complete', lang)}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="interval-current-label">{gps.intervalState.currentLabel}</div>
|
||||
<div class="interval-progress-row">
|
||||
<span class="interval-step-count">{gps.intervalState.currentIndex + 1} / {gps.intervalState.totalSteps}</span>
|
||||
<div class="interval-progress-bar">
|
||||
<div class="interval-progress-fill" style="width: {Math.round(gps.intervalState.progress * 100)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</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}
|
||||
@@ -939,16 +1071,21 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="gps-options-grid">
|
||||
<button class="gps-option-tile" onclick={() => { showActivityPicker = !showActivityPicker; showAudioPanel = false; }} type="button">
|
||||
<button class="gps-option-tile" onclick={() => { showActivityPicker = !showActivityPicker; showAudioPanel = false; showIntervalPanel = 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">
|
||||
<button class="gps-option-tile" onclick={() => { showAudioPanel = !showAudioPanel; showActivityPicker = false; showIntervalPanel = 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>
|
||||
<button class="gps-option-tile" onclick={() => { showIntervalPanel = !showIntervalPanel; showActivityPicker = false; showAudioPanel = false; }} type="button">
|
||||
<Timer size={20} />
|
||||
<span class="gps-option-label">{t('intervals', lang)}</span>
|
||||
<span class="gps-option-value">{selectedInterval?.name ?? t('no_intervals', lang)}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showActivityPicker}
|
||||
@@ -1018,6 +1155,33 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showIntervalPanel}
|
||||
<div class="interval-panel">
|
||||
{#if intervalTemplates.length > 0}
|
||||
<div class="interval-list">
|
||||
{#each intervalTemplates as tmpl (tmpl._id)}
|
||||
<div class="interval-card" class:selected={selectedIntervalId === tmpl._id}>
|
||||
<button class="interval-card-main" type="button" onclick={() => { selectedIntervalId = selectedIntervalId === tmpl._id ? null : tmpl._id; }}>
|
||||
<span class="interval-card-name">{tmpl.name}</span>
|
||||
<span class="interval-card-info">{tmpl.steps.length} {t('steps_count', lang)}</span>
|
||||
</button>
|
||||
<div class="interval-card-actions">
|
||||
<button class="interval-card-edit" type="button" onclick={() => openEditInterval(tmpl)}>{t('edit', lang)}</button>
|
||||
<button class="interval-card-delete" type="button" onclick={() => deleteInterval(tmpl._id)}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="interval-empty">{t('no_intervals', lang)}</p>
|
||||
{/if}
|
||||
<button class="interval-new-btn" type="button" onclick={openNewInterval}>
|
||||
<Plus size={16} />
|
||||
{t('new_interval', lang)}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button class="gps-start-btn" onclick={startGpsWorkout} disabled={gpsStarting}>
|
||||
{#if gpsStarting}
|
||||
<span class="gps-spinner"></span> Initializing GPS…
|
||||
@@ -1032,6 +1196,106 @@
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showIntervalEditor}
|
||||
<div class="interval-editor-overlay">
|
||||
<div class="interval-editor">
|
||||
<div class="interval-editor-header">
|
||||
<h2>{editingIntervalId ? t('edit_interval', lang) : t('new_interval', lang)}</h2>
|
||||
<button class="interval-editor-close" type="button" onclick={() => showIntervalEditor = false}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
class="interval-editor-name"
|
||||
type="text"
|
||||
placeholder={t('interval_name_placeholder', lang)}
|
||||
bind:value={intervalEditorName}
|
||||
/>
|
||||
|
||||
<div class="interval-editor-steps">
|
||||
{#each intervalEditorSteps as step, idx (idx)}
|
||||
<div class="interval-step-card">
|
||||
<div class="interval-step-header">
|
||||
<span class="interval-step-num">{idx + 1}</span>
|
||||
<div class="interval-step-move">
|
||||
<button type="button" onclick={() => moveIntervalStep(idx, -1)} disabled={idx === 0}><ChevronUp size={14} /></button>
|
||||
<button type="button" onclick={() => moveIntervalStep(idx, 1)} disabled={idx === intervalEditorSteps.length - 1}><ChevronDown size={14} /></button>
|
||||
</div>
|
||||
<button class="interval-step-remove" type="button" onclick={() => removeIntervalStep(idx)} disabled={intervalEditorSteps.length <= 1}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="interval-step-labels">
|
||||
{#each PRESET_LABELS as preset}
|
||||
<button
|
||||
class="interval-label-chip"
|
||||
class:selected={!step.customLabel && step.label === preset}
|
||||
type="button"
|
||||
onclick={() => { intervalEditorSteps[idx].label = preset; intervalEditorSteps[idx].customLabel = false; }}
|
||||
>{preset}</button>
|
||||
{/each}
|
||||
<button
|
||||
class="interval-label-chip"
|
||||
class:selected={step.customLabel}
|
||||
type="button"
|
||||
onclick={() => { intervalEditorSteps[idx].customLabel = true; }}
|
||||
>{t('custom', lang)}</button>
|
||||
</div>
|
||||
|
||||
{#if step.customLabel}
|
||||
<input
|
||||
class="interval-step-custom-input"
|
||||
type="text"
|
||||
placeholder={t('step_label', lang)}
|
||||
bind:value={intervalEditorSteps[idx].label}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="interval-step-duration">
|
||||
<input
|
||||
class="interval-step-value"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={intervalEditorSteps[idx].durationValue}
|
||||
/>
|
||||
<div class="interval-step-type-toggle">
|
||||
<button
|
||||
class="interval-type-btn"
|
||||
class:active={step.durationType === 'distance'}
|
||||
type="button"
|
||||
onclick={() => { intervalEditorSteps[idx].durationType = 'distance'; }}
|
||||
>{t('meters', lang)}</button>
|
||||
<button
|
||||
class="interval-type-btn"
|
||||
class:active={step.durationType === 'time'}
|
||||
type="button"
|
||||
onclick={() => { intervalEditorSteps[idx].durationType = 'time'; }}
|
||||
>{t('seconds', lang)}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button class="interval-add-step-btn" type="button" onclick={addIntervalStep}>
|
||||
<Plus size={16} />
|
||||
{t('add_step', lang)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="interval-save-btn"
|
||||
type="button"
|
||||
onclick={saveInterval}
|
||||
disabled={intervalSaving || !intervalEditorName.trim() || intervalEditorSteps.length === 0}
|
||||
>
|
||||
{intervalSaving ? t('saving', lang) : t('save_interval', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if workout.active}
|
||||
@@ -1957,7 +2221,7 @@
|
||||
}
|
||||
.gps-options-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.gps-option-tile {
|
||||
@@ -2112,4 +2376,367 @@
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Interval Panel (pre-start selection) */
|
||||
.interval-panel {
|
||||
background: rgba(46, 52, 64, 0.82);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem;
|
||||
color: #fff;
|
||||
}
|
||||
.interval-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.interval-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.interval-card.selected {
|
||||
border-color: var(--nord8);
|
||||
background: rgba(136,192,208,0.12);
|
||||
}
|
||||
.interval-card-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.interval-card-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.interval-card-info {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.interval-card-actions {
|
||||
display: flex;
|
||||
gap: 0.2rem;
|
||||
padding-right: 0.4rem;
|
||||
}
|
||||
.interval-card-edit, .interval-card-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255,255,255,0.5);
|
||||
font: inherit;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
padding: 0.3rem;
|
||||
}
|
||||
.interval-card-edit:hover, .interval-card-delete:hover {
|
||||
color: #fff;
|
||||
}
|
||||
.interval-empty {
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.5;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.interval-new-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px dashed rgba(255,255,255,0.2);
|
||||
border-radius: 8px;
|
||||
color: var(--nord8);
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.interval-new-btn:hover {
|
||||
background: rgba(255,255,255,0.12);
|
||||
}
|
||||
|
||||
/* Interval Editor (full-screen overlay on map) */
|
||||
.interval-editor-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgba(46, 52, 64, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.interval-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
padding-top: calc(1rem + env(safe-area-inset-top, 0px));
|
||||
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||
color: #fff;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.interval-editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.interval-editor-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.interval-editor-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255,255,255,0.6);
|
||||
cursor: pointer;
|
||||
padding: 0.3rem;
|
||||
}
|
||||
.interval-editor-name {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.interval-editor-name::placeholder {
|
||||
color: rgba(255,255,255,0.35);
|
||||
}
|
||||
.interval-editor-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.interval-step-card {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.interval-step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.interval-step-num {
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
background: rgba(255,255,255,0.12);
|
||||
border-radius: 50%;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.interval-step-move {
|
||||
display: flex;
|
||||
gap: 0.1rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.interval-step-move button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255,255,255,0.4);
|
||||
cursor: pointer;
|
||||
padding: 0.15rem;
|
||||
}
|
||||
.interval-step-move button:hover:not(:disabled) {
|
||||
color: #fff;
|
||||
}
|
||||
.interval-step-move button:disabled {
|
||||
opacity: 0.2;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.interval-step-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255,255,255,0.3);
|
||||
cursor: pointer;
|
||||
padding: 0.15rem;
|
||||
}
|
||||
.interval-step-remove:hover:not(:disabled) {
|
||||
color: var(--nord11);
|
||||
}
|
||||
.interval-step-remove:disabled {
|
||||
opacity: 0.2;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.interval-step-labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.interval-label-chip {
|
||||
padding: 0.25rem 0.55rem;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 20px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.interval-label-chip.selected {
|
||||
background: var(--nord8);
|
||||
border-color: var(--nord8);
|
||||
color: var(--nord0);
|
||||
font-weight: 600;
|
||||
}
|
||||
.interval-step-custom-input {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.interval-step-duration {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.interval-step-value {
|
||||
width: 5rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
.interval-step-type-toggle {
|
||||
display: flex;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.interval-type-btn {
|
||||
padding: 0.4rem 0.65rem;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: none;
|
||||
color: rgba(255,255,255,0.5);
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.interval-type-btn.active {
|
||||
background: var(--nord8);
|
||||
color: var(--nord0);
|
||||
font-weight: 600;
|
||||
}
|
||||
.interval-add-step-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
width: 100%;
|
||||
padding: 0.55rem;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px dashed rgba(255,255,255,0.2);
|
||||
border-radius: 8px;
|
||||
color: var(--nord8);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.interval-save-btn {
|
||||
width: 100%;
|
||||
padding: 0.8rem;
|
||||
background: var(--nord14);
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
color: var(--nord0);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.interval-save-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Active Interval Overlay */
|
||||
.interval-active-overlay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: rgba(136,192,208,0.12);
|
||||
border: 1px solid rgba(136,192,208,0.25);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.interval-current-label {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
color: var(--nord8);
|
||||
text-align: center;
|
||||
}
|
||||
.interval-progress-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.interval-step-count {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.interval-progress-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: rgba(255,255,255,0.12);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.interval-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--nord8);
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.interval-complete-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem;
|
||||
color: var(--nord14);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user