feat(watch-connectivity): add WearOS support alongside Apple Watch

This commit is contained in:
2026-08-08 12:00:02 +02:00
parent 2211b8ebe4
commit 831ec6b836
52 changed files with 2278 additions and 62 deletions
@@ -0,0 +1,60 @@
package nl.guidoit.swimbuddy
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
class ExerciseSessionService : Service() {
private val CHANNEL_ID = "swim_session_channel"
private val NOTIFICATION_ID = 1
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = buildNotification()
startForeground(NOTIFICATION_ID, notification)
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
"Swim Session",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Tracks your swimming workout"
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
private fun buildNotification(): Notification {
val intent = Intent(this, SwimBuddyWearActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("SwimBuddy")
.setContentText("Tracking your swim session")
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
}
@@ -0,0 +1,44 @@
package nl.guidoit.swimbuddy
import android.content.Context
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
object HapticFeedback {
enum class Type {
START,
CLICK,
NOTIFICATION,
SUCCESS
}
private fun getVibrator(context: Context): Vibrator {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val manager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
manager.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
}
fun play(context: Context, type: Type) {
val vibrator = getVibrator(context)
if (!vibrator.hasVibrator()) return
val effect = when (type) {
Type.START -> VibrationEffect.createOneShot(200, VibrationEffect.DEFAULT_AMPLITUDE)
Type.CLICK -> VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE)
Type.NOTIFICATION -> VibrationEffect.createWaveform(
longArrayOf(0, 100, 50, 100), -1
)
Type.SUCCESS -> VibrationEffect.createWaveform(
longArrayOf(0, 100, 50, 100, 50, 200), -1
)
}
vibrator.vibrate(effect)
}
}
@@ -0,0 +1,90 @@
package nl.guidoit.swimbuddy
import android.content.Context
import androidx.health.services.client.HealthServices
import androidx.health.services.client.ExerciseClient
import androidx.health.services.client.data.ExerciseConfig
import androidx.health.services.client.data.ExerciseType
import androidx.health.services.client.data.DataType
import androidx.health.services.client.data.ExerciseUpdate
import androidx.health.services.client.data.ExerciseState
import androidx.health.services.client.data.DataPointAvailability
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.tasks.await
class HealthServicesManager(context: Context) {
private val healthClient = HealthServices.getClient(context)
private val exerciseClient: ExerciseClient = healthClient.exerciseClient
suspend fun hasSwimmingCapability(): Boolean {
return try {
val capabilities = exerciseClient.getCapabilitiesAsync().await()
ExerciseType.SWIMMING_POOL in capabilities.supportedExerciseTypes
} catch (e: Exception) {
false
}
}
suspend fun startSwimmingWorkout(poolLengthMeters: Float) {
val capabilities = exerciseClient.getCapabilitiesAsync().await()
val poolCapabilities = capabilities.getExerciseTypeCapabilities(ExerciseType.SWIMMING_POOL)
val config = ExerciseConfig.builder(ExerciseType.SWIMMING_POOL)
.setDataTypes(poolCapabilities.supportedDataTypes)
.build()
exerciseClient.startExerciseAsync(config).await()
}
suspend fun pauseWorkout() {
exerciseClient.pauseExerciseAsync().await()
}
suspend fun resumeWorkout() {
exerciseClient.resumeExerciseAsync().await()
}
suspend fun endWorkout() {
exerciseClient.endExerciseAsync().await()
}
fun heartRateFlow(): Flow<Double> = callbackFlow {
val callback = object : androidx.health.services.client.ExerciseUpdateCallback {
override fun onNewUpdate(update: ExerciseUpdate) {
update.getData(DataType.HEART_RATE_BPM)?.let { data ->
trySend(data.toDouble())
}
}
override fun onAvailabilityChanged(dataType: DataType, availability: DataPointAvailability) {
// Not used
}
}
exerciseClient.setUpdateCallback(callback)
awaitClose { exerciseClient.clearUpdateCallbackAsync() }
}
fun caloriesFlow(): Flow<Double> = callbackFlow {
val callback = object : androidx.health.services.client.ExerciseUpdateCallback {
override fun onNewUpdate(update: ExerciseUpdate) {
update.getData(DataType.CALORIES)?.let { data ->
trySend(data.toDouble())
}
}
override fun onAvailabilityChanged(dataType: DataType, availability: DataPointAvailability) {
// Not used
}
}
exerciseClient.setUpdateCallback(callback)
awaitClose { exerciseClient.clearUpdateCallbackAsync() }
}
fun isActive(): Boolean {
return exerciseClient.currentExerciseInfo.value.exerciseState == ExerciseState.ACTIVE
}
}
@@ -0,0 +1,69 @@
package nl.guidoit.swimbuddy
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import kotlin.math.sqrt
class LapDetector(context: Context, private val onLapDetected: () -> Unit) : SensorEventListener {
private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
private val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
private var wasStationary = false
private var stationarySince = 0L
private var lastLapDetectionTime = 0L
companion object {
private const val STATIONARY_THRESHOLD = 1.1
private const val MIN_PAUSE_DURATION = 0.3
private const val MAX_PAUSE_DURATION = 3.0
private const val MIN_LAP_INTERVAL = 8.0
private const val SENSOR_DELAY_MS = 100L
}
fun start() {
accelerometer?.let {
sensorManager.registerListener(this, it, (SENSOR_DELAY_MS * 1000).toInt())
}
}
fun stop() {
sensorManager.unregisterListener(this)
}
override fun onSensorChanged(event: SensorEvent) {
val x = event.values[0]
val y = event.values[1]
val z = event.values[2]
val magnitude = sqrt(x * x + y * y + z * z)
val isStationary = magnitude < STATIONARY_THRESHOLD
val now = System.currentTimeMillis()
if (isStationary) {
if (!wasStationary) {
wasStationary = true
stationarySince = now
}
} else {
if (wasStationary) {
val pauseDuration = (now - stationarySince) / 1000.0
val timeSinceLastLap = (now - lastLapDetectionTime) / 1000.0
if (pauseDuration in MIN_PAUSE_DURATION..MAX_PAUSE_DURATION &&
timeSinceLastLap >= MIN_LAP_INTERVAL
) {
lastLapDetectionTime = now
onLapDetected()
}
}
wasStationary = false
stationarySince = 0L
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
}
@@ -0,0 +1,65 @@
package nl.guidoit.swimbuddy
import android.content.Intent
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.WearableListenerService
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import nl.guidoit.swimbuddy.data.StoredWorkout
import nl.guidoit.swimbuddy.data.StoredSet
class PhoneDataLayerService : WearableListenerService() {
override fun onMessageReceived(messageEvent: MessageEvent) {
if (messageEvent.path != MESSAGE_PATH) return
val json = String(messageEvent.data, Charsets.UTF_8)
val gson = Gson()
try {
val map = gson.fromJson<Map<String, Any>>(
json,
object : TypeToken<Map<String, Any>>() {}.type
)
when (map["type"]) {
"workoutPlan" -> {
val workout = parseWorkoutPlan(map)
// Broadcast to activity
val intent = Intent(ACTION_WORKOUT_RECEIVED).apply {
putExtra(EXTRA_WORKOUT_JSON, gson.toJson(workout))
setPackage(packageName)
}
sendBroadcast(intent)
}
}
} catch (e: Exception) {
// ignore malformed messages
}
}
private fun parseWorkoutPlan(map: Map<String, Any>): StoredWorkout {
val sets = (map["sets"] as? List<Map<String, Any>>)?.map { setMap ->
StoredSet(
stroke = setMap["stroke"] as? String ?: "Freestyle",
distancePerRep = (setMap["distancePerRep"] as? Number)?.toInt() ?: 200,
repCount = (setMap["repCount"] as? Number)?.toInt() ?: 1,
restType = setMap["restType"] as? String ?: "fixed_rest",
restDuration = (setMap["restDuration"] as? Number)?.toFloat() ?: 30f
)
} ?: emptyList()
return StoredWorkout(
id = map["id"] as? String ?: System.currentTimeMillis().toString(),
name = map["name"] as? String,
poolLength = (map["poolLength"] as? Number)?.toFloat() ?: 25f,
sets = sets
)
}
companion object {
const val MESSAGE_PATH = "/swimbuddy"
const val ACTION_WORKOUT_RECEIVED = "nl.guidoit.swimbuddy.WORKOUT_RECEIVED"
const val EXTRA_WORKOUT_JSON = "workout_json"
}
}
@@ -0,0 +1,61 @@
package nl.guidoit.swimbuddy
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import nl.guidoit.swimbuddy.presentation.SwimBuddyApp
class SwimBuddyWearActivity : ComponentActivity() {
private val viewModel: WorkoutViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val savedWorkouts by viewModel.savedWorkouts.collectAsState()
val completedWorkouts by viewModel.completedWorkouts.collectAsState()
val selectedWorkout by viewModel.selectedWorkout.collectAsState()
val isWorkoutActive by viewModel.isWorkoutActive.collectAsState()
val currentSetIndex by viewModel.currentSetIndex.collectAsState()
val currentRep by viewModel.currentRep.collectAsState()
val elapsedSeconds by viewModel.elapsedSeconds.collectAsState()
val heartRate by viewModel.heartRate.collectAsState()
val calories by viewModel.calories.collectAsState()
val lapDetected by viewModel.lapDetected.collectAsState()
val isResting by viewModel.isResting.collectAsState()
val restTimeRemaining by viewModel.restTimeRemaining.collectAsState()
val workoutSummary by viewModel.workoutSummary.collectAsState()
SwimBuddyApp(
savedWorkouts = savedWorkouts,
completedWorkouts = completedWorkouts,
selectedWorkout = selectedWorkout,
isWorkoutActive = isWorkoutActive,
currentSetIndex = currentSetIndex,
currentRep = currentRep,
elapsedSeconds = elapsedSeconds,
heartRate = heartRate,
calories = calories,
lapDetected = lapDetected,
isResting = isResting,
restTimeRemaining = restTimeRemaining,
workoutSummary = workoutSummary,
totalDistanceSwum = viewModel.totalDistanceSwum(),
currentSetDistanceSwum = viewModel.currentSetDistanceSwum(),
currentSetTotalDistance = viewModel.currentSetTotalDistance(),
onSelectWorkout = { viewModel.selectWorkout(it) },
onStartWorkout = { viewModel.startWorkout() },
onCompleteLap = { viewModel.completeLap() },
onEndWorkout = { viewModel.endWorkout() },
onBack = { viewModel.selectWorkout(null) },
onDeleteSaved = { viewModel.deleteSavedWorkout(it) },
onDeleteCompleted = { viewModel.deleteCompletedWorkout(it) },
onDismissSummary = { viewModel.dismissSummary() }
)
}
}
}
@@ -0,0 +1,92 @@
package nl.guidoit.swimbuddy
import android.content.Context
import android.content.SharedPreferences
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import nl.guidoit.swimbuddy.data.StoredWorkout
import nl.guidoit.swimbuddy.data.WorkoutSummary
class WorkoutStorage(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("swimbuddy_workouts", Context.MODE_PRIVATE)
private val gson = Gson()
companion object {
private const val KEY_SAVED = "savedWorkouts"
private const val KEY_COMPLETED = "completedWorkouts"
private const val KEY_PENDING_SYNC = "pendingSync"
private const val MAX_COMPLETED = 10
}
fun loadSavedWorkouts(): List<StoredWorkout> {
val json = prefs.getString(KEY_SAVED, null) ?: return emptyList()
return try {
val type = object : TypeToken<List<StoredWorkout>>() {}.type
gson.fromJson(json, type)
} catch (e: Exception) {
emptyList()
}
}
fun saveWorkouts(workouts: List<StoredWorkout>) {
prefs.edit().putString(KEY_SAVED, gson.toJson(workouts)).apply()
}
fun loadCompletedWorkouts(): List<StoredWorkout> {
val json = prefs.getString(KEY_COMPLETED, null) ?: return emptyList()
return try {
val type = object : TypeToken<List<StoredWorkout>>() {}.type
gson.fromJson(json, type)
} catch (e: Exception) {
emptyList()
}
}
fun saveCompletedWorkouts(workouts: List<StoredWorkout>) {
val limited = workouts.take(MAX_COMPLETED)
prefs.edit().putString(KEY_COMPLETED, gson.toJson(limited)).apply()
}
fun addCompletedWorkout(workout: StoredWorkout) {
val existing = loadCompletedWorkouts().toMutableList()
existing.add(0, workout)
saveCompletedWorkouts(existing)
}
fun deleteSavedWorkout(id: String) {
val workouts = loadSavedWorkouts().toMutableList()
workouts.removeAll { it.id == id }
saveWorkouts(workouts)
}
fun deleteCompletedWorkout(id: String) {
val workouts = loadCompletedWorkouts().toMutableList()
workouts.removeAll { it.id == id }
saveCompletedWorkouts(workouts)
}
fun savePendingSync(summary: WorkoutSummary) {
val existing = loadPendingSyncs().toMutableList()
existing.removeAll { it.workoutId == summary.workoutId }
existing.add(summary)
prefs.edit().putString(KEY_PENDING_SYNC, gson.toJson(existing)).apply()
}
fun loadPendingSyncs(): List<WorkoutSummary> {
val json = prefs.getString(KEY_PENDING_SYNC, null) ?: return emptyList()
return try {
val type = object : TypeToken<List<WorkoutSummary>>() {}.type
gson.fromJson(json, type)
} catch (e: Exception) {
emptyList()
}
}
fun removePendingSync(workoutId: String) {
val pending = loadPendingSyncs().toMutableList()
pending.removeAll { it.workoutId == workoutId }
prefs.edit().putString(KEY_PENDING_SYNC, gson.toJson(pending)).apply()
}
}
@@ -0,0 +1,324 @@
package nl.guidoit.swimbuddy
import android.app.Application
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.android.gms.wearable.Wearable
import com.google.gson.Gson
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import nl.guidoit.swimbuddy.data.CompletedLap
import nl.guidoit.swimbuddy.data.StoredWorkout
import nl.guidoit.swimbuddy.data.WorkoutSummary
class WorkoutViewModel(application: Application) : AndroidViewModel(application) {
private val healthManager = HealthServicesManager(application)
private val storage = WorkoutStorage(application)
private val gson = Gson()
private val appContext = application
private val _savedWorkouts = MutableStateFlow<List<StoredWorkout>>(emptyList())
val savedWorkouts: StateFlow<List<StoredWorkout>> = _savedWorkouts.asStateFlow()
private val _completedWorkouts = MutableStateFlow<List<StoredWorkout>>(emptyList())
val completedWorkouts: StateFlow<List<StoredWorkout>> = _completedWorkouts.asStateFlow()
private val _selectedWorkout = MutableStateFlow<StoredWorkout?>(null)
val selectedWorkout: StateFlow<StoredWorkout?> = _selectedWorkout.asStateFlow()
private val _isWorkoutActive = MutableStateFlow(false)
val isWorkoutActive: StateFlow<Boolean> = _isWorkoutActive.asStateFlow()
private val _currentSetIndex = MutableStateFlow(0)
val currentSetIndex: StateFlow<Int> = _currentSetIndex.asStateFlow()
private val _currentRep = MutableStateFlow(0)
val currentRep: StateFlow<Int> = _currentRep.asStateFlow()
private val _elapsedSeconds = MutableStateFlow(0f)
val elapsedSeconds: StateFlow<Float> = _elapsedSeconds.asStateFlow()
private val _heartRate = MutableStateFlow(0)
val heartRate: StateFlow<Int> = _heartRate.asStateFlow()
private val _calories = MutableStateFlow(0f)
val calories: StateFlow<Float> = _calories.asStateFlow()
private val _lapDetected = MutableStateFlow(false)
val lapDetected: StateFlow<Boolean> = _lapDetected.asStateFlow()
private val _completedLaps = MutableStateFlow<List<CompletedLap>>(emptyList())
val completedLaps: StateFlow<List<CompletedLap>> = _completedLaps.asStateFlow()
private val _isResting = MutableStateFlow(false)
val isResting: StateFlow<Boolean> = _isResting.asStateFlow()
private val _restTimeRemaining = MutableStateFlow(0f)
val restTimeRemaining: StateFlow<Float> = _restTimeRemaining.asStateFlow()
private val _workoutSummary = MutableStateFlow<WorkoutSummary?>(null)
val workoutSummary: StateFlow<WorkoutSummary?> = _workoutSummary.asStateFlow()
private var startTime = 0L
private var lapStartTime = 0L
private var lapDetector: LapDetector? = null
private val workoutReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == PhoneDataLayerService.ACTION_WORKOUT_RECEIVED) {
val workoutJson = intent.getStringExtra(PhoneDataLayerService.EXTRA_WORKOUT_JSON)
workoutJson?.let {
val workout = gson.fromJson(it, StoredWorkout::class.java)
addSavedWorkout(workout)
}
}
}
}
init {
_savedWorkouts.value = storage.loadSavedWorkouts()
_completedWorkouts.value = storage.loadCompletedWorkouts()
val filter = IntentFilter(PhoneDataLayerService.ACTION_WORKOUT_RECEIVED)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
application.registerReceiver(workoutReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
application.registerReceiver(workoutReceiver, filter)
}
retryPendingSyncs()
}
override fun onCleared() {
super.onCleared()
lapDetector?.stop()
try {
getApplication<Application>().unregisterReceiver(workoutReceiver)
} catch (_: Exception) {}
}
fun addSavedWorkout(workout: StoredWorkout) {
_savedWorkouts.value = _savedWorkouts.value + workout
storage.saveWorkouts(_savedWorkouts.value)
}
fun deleteSavedWorkout(workout: StoredWorkout) {
_savedWorkouts.value = _savedWorkouts.value.filter { it.id != workout.id }
storage.saveWorkouts(_savedWorkouts.value)
}
fun deleteCompletedWorkout(workout: StoredWorkout) {
_completedWorkouts.value = _completedWorkouts.value.filter { it.id != workout.id }
storage.saveCompletedWorkouts(_completedWorkouts.value)
}
fun selectWorkout(workout: StoredWorkout?) {
_selectedWorkout.value = workout
}
fun dismissSummary() {
_workoutSummary.value = null
}
fun startWorkout() {
val workout = _selectedWorkout.value ?: return
HapticFeedback.play(appContext, HapticFeedback.Type.START)
viewModelScope.launch {
healthManager.startSwimmingWorkout(workout.poolLength)
_isWorkoutActive.value = true
startTime = System.currentTimeMillis()
lapStartTime = startTime
_currentSetIndex.value = 0
_currentRep.value = 0
_completedLaps.value = emptyList()
startElapsedTimeTracking()
startHeartRateTracking()
startCaloriesTracking()
startLapDetection()
}
}
private fun startLapDetection() {
lapDetector = LapDetector(appContext) {
viewModelScope.launch {
completeLap()
_lapDetected.value = true
kotlinx.coroutines.delay(1500)
_lapDetected.value = false
}
}
lapDetector?.start()
}
private fun startElapsedTimeTracking() {
viewModelScope.launch {
while (_isWorkoutActive.value) {
_elapsedSeconds.value = (System.currentTimeMillis() - startTime) / 1000f
kotlinx.coroutines.delay(1000)
}
}
}
private fun startHeartRateTracking() {
viewModelScope.launch {
healthManager.heartRateFlow().collect { hr ->
_heartRate.value = hr.toInt()
}
}
}
private fun startCaloriesTracking() {
viewModelScope.launch {
healthManager.caloriesFlow().collect { cal ->
_calories.value = cal
}
}
}
fun completeLap() {
val workout = _selectedWorkout.value ?: return
val currentSet = workout.sets.getOrNull(_currentSetIndex.value) ?: return
val now = System.currentTimeMillis()
val lapDuration = (now - lapStartTime) / 1000f
lapStartTime = now
HapticFeedback.play(appContext, HapticFeedback.Type.CLICK)
val lap = CompletedLap(
setPosition = _currentSetIndex.value + 1,
repNumber = _currentRep.value + 1,
distance = currentSet.distancePerRep,
duration = lapDuration,
heartRate = _heartRate.value
)
_completedLaps.value = _completedLaps.value + lap
if (_currentRep.value < currentSet.repCount - 1) {
_currentRep.value++
} else {
if (_currentSetIndex.value < workout.sets.size - 1) {
_currentSetIndex.value++
_currentRep.value = 0
if (currentSet.restType != "none" && currentSet.restDuration > 0) {
startRestPeriod(currentSet.restDuration)
} else {
HapticFeedback.play(appContext, HapticFeedback.Type.START)
}
} else {
endWorkout()
}
}
}
private fun startRestPeriod(duration: Float) {
_isResting.value = true
_restTimeRemaining.value = duration
HapticFeedback.play(appContext, HapticFeedback.Type.NOTIFICATION)
viewModelScope.launch {
while (_restTimeRemaining.value > 0 && _isResting.value) {
kotlinx.coroutines.delay(1000)
_restTimeRemaining.value -= 1
}
_isResting.value = false
HapticFeedback.play(appContext, HapticFeedback.Type.START)
}
}
fun endWorkout() {
lapDetector?.stop()
viewModelScope.launch {
healthManager.endWorkout()
_isWorkoutActive.value = false
HapticFeedback.play(appContext, HapticFeedback.Type.SUCCESS)
val workout = _selectedWorkout.value
val totalDistance = _completedLaps.value.sumOf { it.distance }
val totalDuration = _elapsedSeconds.value
val summary = WorkoutSummary(
workoutId = workout?.id ?: "",
duration = totalDuration,
totalDistance = totalDistance,
calories = _calories.value,
laps = _completedLaps.value
)
_workoutSummary.value = summary
val completedWorkout = workout?.copy(
completedAt = java.text.SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss",
java.util.Locale.US
).format(java.util.Date())
)
if (completedWorkout != null) {
_completedWorkouts.value = listOf(completedWorkout) + _completedWorkouts.value
storage.saveCompletedWorkouts(_completedWorkouts.value)
}
storage.savePendingSync(summary)
sendCompletedWorkoutToPhone(summary)
}
}
private fun sendCompletedWorkoutToPhone(summary: WorkoutSummary) {
viewModelScope.launch {
try {
val nodeClient = Wearable.getNodeClient(appContext)
val nodes = nodeClient.connectedNodes.await()
val nearbyNode = nodes.firstOrNull { it.isNearby }
if (nearbyNode != null) {
val json = gson.toJson(summary)
Wearable.getMessageClient(appContext)
.sendMessage(nearbyNode.id, "/swimbuddy", json.toByteArray())
.await()
storage.removePendingSync(summary.workoutId)
}
} catch (e: Exception) {
// Will retry on next app start
}
}
}
private fun retryPendingSyncs() {
viewModelScope.launch {
val pending = storage.loadPendingSyncs()
for (summary in pending) {
sendCompletedWorkoutToPhone(summary)
}
}
}
fun totalDistanceSwum(): Int = _completedLaps.value.sumOf { it.distance }
fun currentSetDistanceSwum(): Int {
val currentSetIndex = _currentSetIndex.value
return _completedLaps.value
.filter { it.setPosition == currentSetIndex + 1 }
.sumOf { it.distance }
}
fun currentSetTotalDistance(): Int {
val workout = _selectedWorkout.value ?: return 0
val set = workout.sets.getOrNull(_currentSetIndex.value) ?: return 0
return set.distancePerRep * set.repCount
}
}
@@ -0,0 +1,33 @@
package nl.guidoit.swimbuddy.data
data class StoredWorkout(
val id: String,
val name: String?,
val poolLength: Float,
val sets: List<StoredSet>,
val completedAt: String? = null
)
data class StoredSet(
val stroke: String,
val distancePerRep: Int,
val repCount: Int,
val restType: String,
val restDuration: Float
)
data class CompletedLap(
val setPosition: Int,
val repNumber: Int,
val distance: Int,
val duration: Float,
val heartRate: Int?
)
data class WorkoutSummary(
val workoutId: String,
val duration: Float,
val totalDistance: Int,
val calories: Float,
val laps: List<CompletedLap>
)
@@ -0,0 +1,164 @@
package nl.guidoit.swimbuddy.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.wear.compose.material.Button
import androidx.wear.compose.material.ButtonDefaults
import androidx.wear.compose.material.MaterialTheme
import androidx.wear.compose.material.Text
import nl.guidoit.swimbuddy.R
import nl.guidoit.swimbuddy.data.StoredWorkout
@Composable
fun ActiveWorkoutScreen(
workout: StoredWorkout?,
currentSetIndex: Int,
currentRep: Int,
elapsedSeconds: Float,
heartRate: Int,
calories: Float,
lapDetected: Boolean,
totalDistanceSwum: Int,
currentSetDistanceSwum: Int,
currentSetTotalDistance: Int,
onCompleteLap: () -> Unit,
onEndWorkout: () -> Unit
) {
val currentSet = workout?.sets?.getOrNull(currentSetIndex)
val mins = (elapsedSeconds / 60).toInt()
val secs = (elapsedSeconds % 60).toInt()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.set_format, currentSetIndex + 1, workout?.sets?.size ?: 0),
style = MaterialTheme.typography.caption2,
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "·",
style = MaterialTheme.typography.caption2,
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(R.string.rep_format, currentRep + 1, currentSet?.repCount ?: 0),
style = MaterialTheme.typography.caption2,
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
)
}
Text(
text = currentSet?.stroke ?: "",
style = MaterialTheme.typography.headline3,
color = Color.Cyan
)
Text(
text = "${currentSetDistanceSwum}/${currentSetTotalDistance}m",
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
color = Color.Cyan
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = "\uD83C\uDFCA",
fontSize = 14.sp
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(R.string.total, totalDistanceSwum),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
)
}
Text(
text = String.format("%d:%02d", mins, secs),
style = MaterialTheme.typography.title3,
fontFamily = MaterialTheme.typography.title3.fontFamily
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "\uD83D\uDD25",
fontSize = 12.sp
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = "${calories.toInt()}",
style = MaterialTheme.typography.caption
)
}
if (heartRate > 0) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "\u2764\uFE0F",
fontSize = 12.sp
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = "$heartRate",
style = MaterialTheme.typography.caption
)
}
}
}
if (lapDetected) {
Text(
text = stringResource(R.string.lap_detected),
style = MaterialTheme.typography.caption,
color = Color.Green
)
}
Button(
onClick = onCompleteLap,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
backgroundColor = Color.Cyan
)
) {
Text(
text = stringResource(R.string.complete_lap),
color = Color.Black
)
}
Text(
text = stringResource(R.string.end),
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
color = Color.Red,
style = MaterialTheme.typography.caption3,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
@@ -0,0 +1,83 @@
package nl.guidoit.swimbuddy.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.wear.compose.material.MaterialTheme
import androidx.wear.compose.material.Text
import nl.guidoit.swimbuddy.R
import nl.guidoit.swimbuddy.data.StoredSet
@Composable
fun RestScreen(
restTimeRemaining: Float,
totalDistanceSwum: Int,
calories: Float,
nextSet: StoredSet?
) {
val mins = (restTimeRemaining / 60).toInt()
val secs = (restTimeRemaining % 60).toInt()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly
) {
Text(
text = stringResource(R.string.rest),
style = MaterialTheme.typography.headline3,
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
)
Text(
text = String.format("%d:%02d", mins, secs),
fontSize = 44.sp,
fontWeight = FontWeight.Bold,
color = Color.Cyan
)
if (nextSet != null) {
Text(
text = stringResource(R.string.next_format, nextSet.stroke, nextSet.distancePerRep),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "\uD83C\uDFCA",
fontSize = 12.sp
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = "${totalDistanceSwum}m",
style = MaterialTheme.typography.caption
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "\uD83D\uDD25",
fontSize = 12.sp
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = "${calories.toInt()}kcal",
style = MaterialTheme.typography.caption
)
}
}
}
}
@@ -0,0 +1,82 @@
package nl.guidoit.swimbuddy.presentation
import androidx.compose.runtime.Composable
import nl.guidoit.swimbuddy.data.StoredWorkout
import nl.guidoit.swimbuddy.data.WorkoutSummary
@Composable
fun SwimBuddyApp(
savedWorkouts: List<StoredWorkout>,
completedWorkouts: List<StoredWorkout>,
selectedWorkout: StoredWorkout?,
isWorkoutActive: Boolean,
currentSetIndex: Int,
currentRep: Int,
elapsedSeconds: Float,
heartRate: Int,
calories: Float,
lapDetected: Boolean,
isResting: Boolean,
restTimeRemaining: Float,
workoutSummary: WorkoutSummary?,
totalDistanceSwum: Int,
currentSetDistanceSwum: Int,
currentSetTotalDistance: Int,
onSelectWorkout: (StoredWorkout) -> Unit,
onStartWorkout: () -> Unit,
onCompleteLap: () -> Unit,
onEndWorkout: () -> Unit,
onBack: () -> Unit,
onDeleteSaved: (StoredWorkout) -> Unit,
onDeleteCompleted: (StoredWorkout) -> Unit,
onDismissSummary: () -> Unit
) {
when {
workoutSummary != null -> {
WorkoutSummaryScreen(
summary = workoutSummary,
onDismiss = onDismissSummary
)
}
isWorkoutActive && isResting -> {
RestScreen(
restTimeRemaining = restTimeRemaining,
totalDistanceSwum = totalDistanceSwum,
calories = calories,
nextSet = selectedWorkout?.sets?.getOrNull(currentSetIndex)
)
}
isWorkoutActive -> {
ActiveWorkoutScreen(
workout = selectedWorkout,
currentSetIndex = currentSetIndex,
currentRep = currentRep,
elapsedSeconds = elapsedSeconds,
heartRate = heartRate,
calories = calories,
lapDetected = lapDetected,
totalDistanceSwum = totalDistanceSwum,
currentSetDistanceSwum = currentSetDistanceSwum,
currentSetTotalDistance = currentSetTotalDistance,
onCompleteLap = onCompleteLap,
onEndWorkout = onEndWorkout
)
}
selectedWorkout != null -> {
WorkoutDetailScreen(
workout = selectedWorkout,
onStart = onStartWorkout,
onBack = onBack
)
}
else -> {
WorkoutListScreen(
savedWorkouts = savedWorkouts,
completedWorkouts = completedWorkouts,
onSelectWorkout = onSelectWorkout,
onDeleteSaved = onDeleteSaved,
onDeleteCompleted = onDeleteCompleted
)
}
}
}
@@ -0,0 +1,102 @@
package nl.guidoit.swimbuddy.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.wear.compose.material.Button
import androidx.wear.compose.material.ButtonDefaults
import androidx.wear.compose.material.MaterialTheme
import androidx.wear.compose.material.Text
import androidx.wear.compose.material.ListColumn
import nl.guidoit.swimbuddy.R
import nl.guidoit.swimbuddy.data.StoredWorkout
@Composable
fun WorkoutDetailScreen(
workout: StoredWorkout,
onStart: () -> Unit,
onBack: () -> Unit
) {
ListColumn(
modifier = Modifier.padding(16.dp)
) {
item {
Text(
text = workout.name ?: stringResource(R.string.workout),
style = MaterialTheme.typography.title3
)
}
item {
Text(
text = stringResource(R.string.pool, workout.poolLength.toInt()),
style = MaterialTheme.typography.caption3,
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
)
}
item {
Text(
text = stringResource(R.string.total, workout.sets.sumOf { it.distancePerRep * it.repCount }),
style = MaterialTheme.typography.caption3,
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
)
}
item {
Spacer(modifier = Modifier.height(4.dp))
}
item {
Text(
text = stringResource(R.string.sets),
style = MaterialTheme.typography.title3
)
}
items(workout.sets.size) { index ->
val set = workout.sets[index]
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "${index + 1}. ${set.stroke}",
style = MaterialTheme.typography.body1
)
Text(
text = "${set.repCount}x${set.distancePerRep}m",
style = MaterialTheme.typography.caption3,
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
)
}
}
item {
Spacer(modifier = Modifier.height(8.dp))
}
item {
Button(
onClick = onStart,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
backgroundColor = Color.Cyan
)
) {
Text(
text = stringResource(R.string.start),
color = Color.Black
)
}
}
item {
Text(
text = stringResource(R.string.back),
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f),
style = MaterialTheme.typography.caption3,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
}
@@ -0,0 +1,117 @@
package nl.guidoit.swimbuddy.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.wear.compose.material.MaterialTheme
import androidx.wear.compose.material.Text
import androidx.wear.compose.material.ListColumn
import androidx.wear.compose.material.Card
import androidx.wear.compose.material.PositionedCircularButton
import androidx.wear.compose.material.ButtonDefaults
import androidx.wear.compose.material.Icon
import androidx.wear.compose.material.icons.Icons
import androidx.wear.compose.material.icons.filled.Check
import nl.guidoit.swimbuddy.R
import nl.guidoit.swimbuddy.data.StoredWorkout
@Composable
fun WorkoutListScreen(
savedWorkouts: List<StoredWorkout>,
completedWorkouts: List<StoredWorkout>,
onSelectWorkout: (StoredWorkout) -> Unit,
onDeleteSaved: (StoredWorkout) -> Unit,
onDeleteCompleted: (StoredWorkout) -> Unit
) {
if (savedWorkouts.isEmpty() && completedWorkouts.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(R.string.no_workouts),
style = MaterialTheme.typography.body1,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.create_on_phone),
style = MaterialTheme.typography.caption3,
textAlign = TextAlign.Center
)
}
}
} else {
ListColumn {
if (savedWorkouts.isNotEmpty()) {
item {
Text(
text = stringResource(R.string.saved_workouts),
style = MaterialTheme.typography.title3
)
}
items(savedWorkouts.size) { index ->
val workout = savedWorkouts[index]
Card(onClick = { onSelectWorkout(workout) }) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = workout.name ?: stringResource(R.string.workout),
style = MaterialTheme.typography.body1
)
Text(
text = "${workout.sets.sumOf { it.distancePerRep * it.repCount }}m · ${workout.sets.size} sets",
style = MaterialTheme.typography.caption3
)
}
}
}
}
}
if (completedWorkouts.isNotEmpty()) {
item {
Text(
text = stringResource(R.string.recent),
style = MaterialTheme.typography.title3
)
}
items(completedWorkouts.size.coerceAtMost(3)) { index ->
val workout = completedWorkouts[index]
Card(onClick = { }) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = workout.name ?: stringResource(R.string.workout),
style = MaterialTheme.typography.body1
)
Text(
text = workout.completedAt ?: "",
style = MaterialTheme.typography.caption3
)
}
Icon(
imageVector = Icons.Filled.Check,
contentDescription = stringResource(R.string.completed),
tint = MaterialTheme.colors.primary
)
}
}
}
}
}
}
}
@@ -0,0 +1,124 @@
package nl.guidoit.swimbuddy.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.wear.compose.material.Button
import androidx.wear.compose.material.ButtonDefaults
import androidx.wear.compose.material.MaterialTheme
import androidx.wear.compose.material.Text
import nl.guidoit.swimbuddy.R
import nl.guidoit.swimbuddy.data.WorkoutSummary
@Composable
fun WorkoutSummaryScreen(
summary: WorkoutSummary,
onDismiss: () -> Unit
) {
val mins = (summary.duration / 60).toInt()
val secs = (summary.duration % 60).toInt()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly
) {
Text(
text = "\u2705",
fontSize = 32.sp
)
Text(
text = stringResource(R.string.workout_complete),
style = MaterialTheme.typography.headline3,
color = Color.Green
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(R.string.total_time),
style = MaterialTheme.typography.caption3,
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
)
Text(
text = String.format("%d:%02d", mins, secs),
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(R.string.total_distance_label),
style = MaterialTheme.typography.caption3,
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
)
Text(
text = "${summary.totalDistance}m",
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(R.string.calories_label),
style = MaterialTheme.typography.caption3,
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
)
Text(
text = "${summary.calories.toInt()}kcal",
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(R.string.laps_label),
style = MaterialTheme.typography.caption3,
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
)
Text(
text = "${summary.laps.size}",
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold
)
}
}
}
Button(
onClick = onDismiss,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
backgroundColor = Color.Cyan
)
) {
Text(
text = stringResource(R.string.ok),
color = Color.Black
)
}
}
}