diff --git a/package.json b/package.json index f6c6071..58fd941 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.25.1", + "version": "1.25.2", "private": true, "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0268e7c..8d2dc46 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -144,7 +144,7 @@ dependencies = [ [[package]] name = "bocken" -version = "0.2.1" +version = "0.4.0" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 589d73e..0c419b6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bocken" -version = "0.3.0" +version = "0.4.0" edition = "2021" [lib] 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 a718829..d9b817b 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 @@ -7,6 +7,10 @@ import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager import android.location.LocationListener import android.location.LocationManager import android.media.AudioAttributes @@ -24,15 +28,22 @@ import org.json.JSONArray import org.json.JSONObject import java.util.Collections import java.util.Locale +import java.util.concurrent.ConcurrentLinkedQueue import kotlin.math.* private const val TAG = "BockenTTS" -class LocationForegroundService : Service(), TextToSpeech.OnInitListener { +class LocationForegroundService : Service(), TextToSpeech.OnInitListener, SensorEventListener { private var locationManager: LocationManager? = null private var locationListener: LocationListener? = null private var notificationManager: NotificationManager? = null + + // Step detector for cadence + private var sensorManager: SensorManager? = null + private var stepDetector: Sensor? = null + private val stepTimestamps = ConcurrentLinkedQueue() + private val CADENCE_WINDOW_MS = 15_000L // 15 second rolling window private var pendingIntent: PendingIntent? = null private var startTimeMs: Long = 0L private var pausedAccumulatedMs: Long = 0L // total time spent paused @@ -191,6 +202,36 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener { override fun onBind(intent: Intent?): IBinder? = null + // --- Step detector sensor callbacks --- + + override fun onSensorChanged(event: SensorEvent?) { + if (event?.sensor?.type == Sensor.TYPE_STEP_DETECTOR) { + if (!paused) { + stepTimestamps.add(System.currentTimeMillis()) + } + } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} + + /** + * Compute cadence (steps per minute) from recent step detector events. + * Returns null if no steps detected in the rolling window. + */ + private fun computeCadence(): Double? { + val now = System.currentTimeMillis() + val cutoff = now - CADENCE_WINDOW_MS + // Prune old timestamps + while (stepTimestamps.peek()?.let { it < cutoff } == true) { + stepTimestamps.poll() + } + val count = stepTimestamps.size + if (count < 2) return null + val windowMs = now - (stepTimestamps.peek() ?: now) + if (windowMs < 2000) return null // need at least 2s of data + return count.toDouble() / (windowMs / 60000.0) + } + override fun onCreate() { super.onCreate() createNotificationChannel() @@ -249,6 +290,7 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener { } startLocationUpdates() + startStepDetector() tracking = true instance = this @@ -654,6 +696,17 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener { notificationManager?.notify(NOTIFICATION_ID, notification) } + private fun startStepDetector() { + sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager + stepDetector = sensorManager?.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR) + if (stepDetector != null) { + sensorManager?.registerListener(this, stepDetector, SensorManager.SENSOR_DELAY_FASTEST) + Log.d(TAG, "Step detector sensor registered") + } else { + Log.d(TAG, "Step detector sensor not available on this device") + } + } + @Suppress("MissingPermission") private fun startLocationUpdates() { locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager @@ -664,11 +717,13 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener { val now = location.time // Always buffer GPS points (for track drawing) even when paused + val cadence = computeCadence() 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()) + if (cadence != null) put("cadence", cadence) put("timestamp", location.time) } pointBuffer.add(point) @@ -764,6 +819,10 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener { locationListener?.let { locationManager?.removeUpdates(it) } locationListener = null locationManager = null + sensorManager?.unregisterListener(this) + sensorManager = null + stepDetector = null + stepTimestamps.clear() abandonAudioFocus() // Speak finish summary using the handed-off TTS instance (already initialized) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3f2cd8c..8eeaeea 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "productName": "Bocken", "identifier": "org.bocken.app", - "version": "0.3.0", + "version": "0.4.0", "build": { "devUrl": "http://192.168.1.4:5173", "frontendDist": "https://bocken.org" diff --git a/src/lib/js/fitnessI18n.ts b/src/lib/js/fitnessI18n.ts index 6bfe606..bcdfc9d 100644 --- a/src/lib/js/fitnessI18n.ts +++ b/src/lib/js/fitnessI18n.ts @@ -108,6 +108,8 @@ const translations: Translations = { elevation_unit: { en: 'm', de: 'm' }, elevation_gain: { en: 'Gain', de: 'Anstieg' }, elevation_loss: { en: 'Loss', de: 'Abstieg' }, + cadence: { en: 'Cadence', de: 'Kadenz' }, + cadence_unit: { en: 'spm', de: 'spm' }, personal_records: { en: 'Personal Records', de: 'Persönliche Rekorde' }, delete_session_confirm: { en: 'Delete this workout session?', de: 'Dieses Training löschen?' }, remove_gps_confirm: { en: 'Remove GPS track from this exercise?', de: 'GPS-Track von dieser Übung entfernen?' }, diff --git a/src/lib/js/gps.svelte.ts b/src/lib/js/gps.svelte.ts index b7761c1..0e84fc6 100644 --- a/src/lib/js/gps.svelte.ts +++ b/src/lib/js/gps.svelte.ts @@ -10,6 +10,7 @@ export interface GpsPoint { lng: number; altitude?: number; speed?: number; + cadence?: number; // steps per minute, from step detector sensor timestamp: number; } diff --git a/src/models/WorkoutSession.ts b/src/models/WorkoutSession.ts index 6291e5d..3f9b0ac 100644 --- a/src/models/WorkoutSession.ts +++ b/src/models/WorkoutSession.ts @@ -15,6 +15,7 @@ export interface IGpsPoint { lng: number; altitude?: number; speed?: number; + cadence?: number; // steps per minute, from step detector sensor timestamp: number; } @@ -108,6 +109,7 @@ const GpsPointSchema = new mongoose.Schema({ lng: { type: Number, required: true }, altitude: Number, speed: Number, + cadence: Number, timestamp: { type: Number, required: true } }, { _id: false }); diff --git a/src/routes/fitness/[history=fitnessHistory]/[id]/+page.svelte b/src/routes/fitness/[history=fitnessHistory]/[id]/+page.svelte index decef02..ac7a7b5 100644 --- a/src/routes/fitness/[history=fitnessHistory]/[id]/+page.svelte +++ b/src/routes/fitness/[history=fitnessHistory]/[id]/+page.svelte @@ -473,6 +473,47 @@ }; } + /** + * Compute cadence samples over distance from GPS track. + * Returns array of { dist (km), cadence (spm) } — only for points with cadence data. + * @param {any[]} track + */ + function computeCadenceSamples(track) { + /** @type {Array<{dist: number, cadence: number}>} */ + const samples = []; + let cumDist = 0; + for (let i = 0; i < track.length; i++) { + if (track[i].cadence == null) continue; + if (i > 0) cumDist += haversine(track[i - 1], track[i]); + samples.push({ dist: cumDist, cadence: Math.round(track[i].cadence) }); + } + return samples; + } + + /** + * Build Chart.js data for cadence over distance + * @param {Array<{dist: number, cadence: number}>} samples + */ + function buildCadenceChartData(samples) { + const step = Math.max(1, Math.floor(samples.length / 50)); + const filtered = samples.filter((_, i) => i % step === 0 || i === samples.length - 1); + const color = dark ? '#B48EAD' : '#5E81AC'; + const fill = dark ? 'rgba(180, 142, 173, 0.12)' : 'rgba(94, 129, 172, 0.12)'; + return { + labels: filtered.map(s => s.dist.toFixed(2)), + datasets: [{ + label: t('cadence', lang), + data: filtered.map(s => s.cadence), + borderColor: color, + backgroundColor: fill, + borderWidth: 1.5, + pointRadius: 0, + tension: 0.3, + fill: true + }] + }; + } + /** @param {number} exIdx */ async function uploadGpx(exIdx) { const input = document.createElement('input'); @@ -754,6 +795,19 @@ {/if} + {@const cadenceSamples = computeCadenceSamples(ex.gpsTrack)} + {#if cadenceSamples.length > 1} + {@const avgCadence = Math.round(cadenceSamples.reduce((a, s) => a + s.cadence, 0) / cadenceSamples.length)} +
+ +
+ {/if} + {#if splits.length > 1} {@const avgPace = splits.reduce((a, s) => a + s.pace, 0) / splits.length}