feat(wearos): add pause/resume workout, water lock, and swipeable workout pages

This commit is contained in:
2026-08-24 10:24:24 +02:00
parent 31b2a115ec
commit a94ebf3653
18 changed files with 436 additions and 33 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ android {
dependencies {
// Wear OS Compose
implementation("androidx.wear.compose:compose-material:1.3.1")
implementation("androidx.wear.compose:compose-foundation:1.3.1")
implementation("androidx.wear.compose:compose-foundation:1.5.0")
implementation("androidx.wear.compose:compose-navigation:1.3.1")
// Compose
@@ -20,6 +20,7 @@ class SwimBuddyWearActivity : ComponentActivity() {
val completedWorkouts by viewModel.completedWorkouts.collectAsState()
val selectedWorkout by viewModel.selectedWorkout.collectAsState()
val isWorkoutActive by viewModel.isWorkoutActive.collectAsState()
val isPaused by viewModel.isPaused.collectAsState()
val currentSetIndex by viewModel.currentSetIndex.collectAsState()
val currentRep by viewModel.currentRep.collectAsState()
val elapsedSeconds by viewModel.elapsedSeconds.collectAsState()
@@ -29,12 +30,14 @@ class SwimBuddyWearActivity : ComponentActivity() {
val isResting by viewModel.isResting.collectAsState()
val restTimeRemaining by viewModel.restTimeRemaining.collectAsState()
val workoutSummary by viewModel.workoutSummary.collectAsState()
val isWaterLockActive by viewModel.isWaterLockActive.collectAsState()
SwimBuddyApp(
savedWorkouts = savedWorkouts,
completedWorkouts = completedWorkouts,
selectedWorkout = selectedWorkout,
isWorkoutActive = isWorkoutActive,
isPaused = isPaused,
currentSetIndex = currentSetIndex,
currentRep = currentRep,
elapsedSeconds = elapsedSeconds,
@@ -47,9 +50,13 @@ class SwimBuddyWearActivity : ComponentActivity() {
totalDistanceSwum = viewModel.totalDistanceSwum(),
currentSetDistanceSwum = viewModel.currentSetDistanceSwum(),
currentSetTotalDistance = viewModel.currentSetTotalDistance(),
isWaterLockActive = isWaterLockActive,
onSelectWorkout = { viewModel.selectWorkout(it) },
onStartWorkout = { viewModel.startWorkout() },
onCompleteLap = { viewModel.completeLap() },
onPauseResume = {
if (isPaused) viewModel.resumeWorkout() else viewModel.pauseWorkout()
},
onEndWorkout = { viewModel.endWorkout() },
onBack = { viewModel.selectWorkout(null) },
onDeleteSaved = { viewModel.deleteSavedWorkout(it) },
@@ -0,0 +1,19 @@
package nl.guidoit.swimbuddy
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class WaterLockHelper {
private val _isActive = MutableStateFlow(false)
val isActive: StateFlow<Boolean> = _isActive.asStateFlow()
fun onWorkoutStarted() {
_isActive.value = true
}
fun onWorkoutEnded() {
_isActive.value = false
}
}
@@ -22,6 +22,7 @@ class WorkoutViewModel(application: Application) : AndroidViewModel(application)
private val healthManager = HealthServicesManager(application)
private val storage = WorkoutStorage(application)
private val waterLockHelper = WaterLockHelper()
private val gson = Gson()
private val appContext = application
@@ -67,8 +68,14 @@ class WorkoutViewModel(application: Application) : AndroidViewModel(application)
private val _workoutSummary = MutableStateFlow<WorkoutSummary?>(null)
val workoutSummary: StateFlow<WorkoutSummary?> = _workoutSummary.asStateFlow()
private val _isPaused = MutableStateFlow(false)
val isPaused: StateFlow<Boolean> = _isPaused.asStateFlow()
val isWaterLockActive: StateFlow<Boolean> = waterLockHelper.isActive
private var startTime = 0L
private var lapStartTime = 0L
private var pausedElapsedSeconds = 0f
private var lapDetector: LapDetector? = null
private var currentStrokeCount = 0L
@@ -137,8 +144,11 @@ class WorkoutViewModel(application: Application) : AndroidViewModel(application)
viewModelScope.launch {
healthManager.startSwimmingWorkout(workout.poolLength)
_isWorkoutActive.value = true
_isPaused.value = false
waterLockHelper.onWorkoutStarted()
startTime = System.currentTimeMillis()
lapStartTime = startTime
pausedElapsedSeconds = 0f
_currentSetIndex.value = 0
_currentRep.value = 0
_completedLaps.value = emptyList()
@@ -152,6 +162,27 @@ class WorkoutViewModel(application: Application) : AndroidViewModel(application)
}
}
fun pauseWorkout() {
if (!_isWorkoutActive.value || _isPaused.value) return
viewModelScope.launch {
healthManager.pauseWorkout()
_isPaused.value = true
pausedElapsedSeconds = _elapsedSeconds.value
HapticFeedback.play(appContext, HapticFeedback.Type.NOTIFICATION)
}
}
fun resumeWorkout() {
if (!_isWorkoutActive.value || !_isPaused.value) return
viewModelScope.launch {
healthManager.resumeWorkout()
_isPaused.value = false
startTime = System.currentTimeMillis() - (pausedElapsedSeconds * 1000).toLong()
lapStartTime = System.currentTimeMillis()
HapticFeedback.play(appContext, HapticFeedback.Type.START)
}
}
private fun startLapDetection() {
lapDetector = LapDetector(appContext) {
viewModelScope.launch {
@@ -167,7 +198,9 @@ class WorkoutViewModel(application: Application) : AndroidViewModel(application)
private fun startElapsedTimeTracking() {
viewModelScope.launch {
while (_isWorkoutActive.value) {
if (!_isPaused.value) {
_elapsedSeconds.value = (System.currentTimeMillis() - startTime) / 1000f
}
kotlinx.coroutines.delay(1000)
}
}
@@ -259,6 +292,8 @@ class WorkoutViewModel(application: Application) : AndroidViewModel(application)
viewModelScope.launch {
healthManager.endWorkout()
_isWorkoutActive.value = false
_isPaused.value = false
waterLockHelper.onWorkoutEnded()
_currentSetIndex.value = 0
_currentRep.value = 0
_elapsedSeconds.value = 0f
@@ -151,14 +151,17 @@ fun ActiveWorkoutScreen(
)
}
Button(
onClick = onEndWorkout,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
backgroundColor = Color.Red
)
) {
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
text = stringResource(R.string.end_workout),
color = Color.White
)
}
}
}
@@ -0,0 +1,79 @@
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.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
@Composable
fun ControlsScreen(
isPaused: Boolean,
isWaterLockActive: Boolean,
onPauseResume: () -> Unit,
onEndWorkout: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly
) {
if (isWaterLockActive) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = "\uD83D\uDCA7",
fontSize = 12.sp
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(R.string.water_lock),
style = MaterialTheme.typography.caption,
color = Color.Cyan
)
}
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = onPauseResume,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
backgroundColor = Color(0xFFFF9800)
)
) {
Text(
text = if (isPaused) stringResource(R.string.resume) else stringResource(R.string.pause),
color = Color.Black
)
}
Button(
onClick = onEndWorkout,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
backgroundColor = Color.Red
)
) {
Text(
text = stringResource(R.string.end_workout),
color = Color.White
)
}
Spacer(modifier = Modifier.weight(1f))
}
}
@@ -1,6 +1,11 @@
package nl.guidoit.swimbuddy.presentation
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
import androidx.wear.compose.foundation.pager.HorizontalPager
import androidx.wear.compose.foundation.pager.rememberPagerState
import nl.guidoit.swimbuddy.data.StoredWorkout
import nl.guidoit.swimbuddy.data.WorkoutSummary
@@ -10,6 +15,7 @@ fun SwimBuddyApp(
completedWorkouts: List<StoredWorkout>,
selectedWorkout: StoredWorkout?,
isWorkoutActive: Boolean,
isPaused: Boolean,
currentSetIndex: Int,
currentRep: Int,
elapsedSeconds: Float,
@@ -22,9 +28,11 @@ fun SwimBuddyApp(
totalDistanceSwum: Int,
currentSetDistanceSwum: Int,
currentSetTotalDistance: Int,
isWaterLockActive: Boolean,
onSelectWorkout: (StoredWorkout) -> Unit,
onStartWorkout: () -> Unit,
onCompleteLap: () -> Unit,
onPauseResume: () -> Unit,
onEndWorkout: () -> Unit,
onBack: () -> Unit,
onDeleteSaved: (StoredWorkout) -> Unit,
@@ -38,16 +46,8 @@ fun SwimBuddyApp(
onDismiss = onDismissSummary
)
}
isWorkoutActive && isResting -> {
RestScreen(
restTimeRemaining = restTimeRemaining,
totalDistanceSwum = totalDistanceSwum,
calories = calories,
nextSet = selectedWorkout?.sets?.getOrNull(currentSetIndex)
)
}
isWorkoutActive -> {
ActiveWorkoutScreen(
ActiveWorkoutPager(
workout = selectedWorkout,
currentSetIndex = currentSetIndex,
currentRep = currentRep,
@@ -55,10 +55,15 @@ fun SwimBuddyApp(
heartRate = heartRate,
calories = calories,
lapDetected = lapDetected,
isResting = isResting,
restTimeRemaining = restTimeRemaining,
totalDistanceSwum = totalDistanceSwum,
currentSetDistanceSwum = currentSetDistanceSwum,
currentSetTotalDistance = currentSetTotalDistance,
isPaused = isPaused,
isWaterLockActive = isWaterLockActive,
onCompleteLap = onCompleteLap,
onPauseResume = onPauseResume,
onEndWorkout = onEndWorkout
)
}
@@ -80,3 +85,76 @@ fun SwimBuddyApp(
}
}
}
@OptIn(ExperimentalWearFoundationApi::class)
@Composable
private fun ActiveWorkoutPager(
workout: StoredWorkout?,
currentSetIndex: Int,
currentRep: Int,
elapsedSeconds: Float,
heartRate: Int,
calories: Float,
lapDetected: Boolean,
isResting: Boolean,
restTimeRemaining: Float,
totalDistanceSwum: Int,
currentSetDistanceSwum: Int,
currentSetTotalDistance: Int,
isPaused: Boolean,
isWaterLockActive: Boolean,
onCompleteLap: () -> Unit,
onPauseResume: () -> Unit,
onEndWorkout: () -> Unit
) {
val pagerState = rememberPagerState(pageCount = { 3 })
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
when (page) {
0 -> {
if (isResting) {
RestScreen(
restTimeRemaining = restTimeRemaining,
totalDistanceSwum = totalDistanceSwum,
calories = calories,
nextSet = workout?.sets?.getOrNull(currentSetIndex)
)
} else {
ActiveWorkoutScreen(
workout = workout,
currentSetIndex = currentSetIndex,
currentRep = currentRep,
elapsedSeconds = elapsedSeconds,
heartRate = heartRate,
calories = calories,
lapDetected = lapDetected,
totalDistanceSwum = totalDistanceSwum,
currentSetDistanceSwum = currentSetDistanceSwum,
currentSetTotalDistance = currentSetTotalDistance,
onCompleteLap = onCompleteLap,
onEndWorkout = onEndWorkout
)
}
}
1 -> {
WorkoutOverviewScreen(
workout = workout,
currentSetIndex = currentSetIndex,
isResting = isResting,
restTimeRemaining = restTimeRemaining
)
}
2 -> {
ControlsScreen(
isPaused = isPaused,
isWaterLockActive = isWaterLockActive,
onPauseResume = onPauseResume,
onEndWorkout = onEndWorkout
)
}
}
}
}
@@ -7,6 +7,7 @@ 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.compose.ui.unit.sp
import androidx.wear.compose.material.Button
import androidx.wear.compose.material.ButtonDefaults
import androidx.wear.compose.material.MaterialTheme
@@ -55,6 +56,7 @@ fun WorkoutDetailScreen(
}
items(workout.sets.size) { index ->
val set = workout.sets[index]
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
@@ -69,6 +71,24 @@ fun WorkoutDetailScreen(
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
)
}
if (set.restType != "none" && set.restDuration > 0) {
Row(
modifier = Modifier.padding(start = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "\u23F0",
fontSize = 10.sp
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = stringResource(R.string.rest_duration, set.restDuration.toInt()),
style = MaterialTheme.typography.caption2,
color = Color(0xFFFF9800)
)
}
}
}
}
item {
Spacer(modifier = Modifier.height(8.dp))
@@ -0,0 +1,108 @@
package nl.guidoit.swimbuddy.presentation
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.wear.compose.material.ListColumn
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 WorkoutOverviewScreen(
workout: StoredWorkout?,
currentSetIndex: Int,
isResting: Boolean,
restTimeRemaining: Float
) {
if (workout == null) return
val restMins = (restTimeRemaining / 60).toInt()
val restSecs = (restTimeRemaining % 60).toInt()
ListColumn(
modifier = Modifier.padding(horizontal = 16.dp)
) {
if (isResting) {
item {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "\u23F8",
fontSize = 12.sp
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(R.string.rest) + " " + String.format("%d:%02d", restMins, restSecs),
style = MaterialTheme.typography.caption,
color = Color(0xFFFF9800)
)
}
}
}
items(workout.sets.size) { index ->
val set = workout.sets[index]
val statusColor = when {
index < currentSetIndex -> Color.Green
index == currentSetIndex -> Color.Cyan
else -> Color.Gray.copy(alpha = 0.4f)
}
val textColor = if (index <= currentSetIndex) {
MaterialTheme.colors.onPrimary
} else {
MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
}
Column {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Canvas(modifier = Modifier.size(8.dp)) {
drawCircle(color = statusColor)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "${index + 1}. ${set.stroke}",
style = MaterialTheme.typography.caption,
color = textColor,
modifier = Modifier.weight(1f)
)
Text(
text = "${set.repCount}x${set.distancePerRep}m",
style = MaterialTheme.typography.caption2,
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
)
}
if (set.restType != "none" && set.restDuration > 0) {
Row(
modifier = Modifier.padding(start = 22.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "\u23F0",
fontSize = 10.sp
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = stringResource(R.string.rest_duration, set.restDuration.toInt()),
style = MaterialTheme.typography.caption2,
color = Color(0xFFFF9800)
)
}
}
}
}
}
}
@@ -27,4 +27,10 @@
<string name="laps_label">Omgange</string>
<string name="tap_to_dismiss">Tryk for at lukke</string>
<string name="ok">OK</string>
<string name="water_lock">Water Lock</string>
<string name="pause">Pause</string>
<string name="resume">Genoptag</string>
<string name="end_workout">Afslut træning</string>
<string name="workout_overview">Oversigt</string>
<string name="rest_duration">%ds pause</string>
</resources>
@@ -27,4 +27,10 @@
<string name="laps_label">Runden</string>
<string name="tap_to_dismiss">Tippen zum Schließen</string>
<string name="ok">OK</string>
<string name="water_lock">Water Lock</string>
<string name="pause">Pause</string>
<string name="resume">Fortsetzen</string>
<string name="end_workout">Training beenden</string>
<string name="workout_overview">Übersicht</string>
<string name="rest_duration">%ds Pause</string>
</resources>
@@ -27,4 +27,10 @@
<string name="laps_label">Vueltas</string>
<string name="tap_to_dismiss">Toca para cerrar</string>
<string name="ok">OK</string>
<string name="water_lock">Water Lock</string>
<string name="pause">Pausa</string>
<string name="resume">Reanudar</string>
<string name="end_workout">Finalizar entrenamiento</string>
<string name="workout_overview">Resumen</string>
<string name="rest_duration">%ds descanso</string>
</resources>
@@ -27,4 +27,10 @@
<string name="laps_label">Tours</string>
<string name="tap_to_dismiss">Appuyez pour fermer</string>
<string name="ok">OK</string>
<string name="water_lock">Water Lock</string>
<string name="pause">Pause</string>
<string name="resume">Reprendre</string>
<string name="end_workout">Terminer l\'entraînement</string>
<string name="workout_overview">Aperçu</string>
<string name="rest_duration">%ds repos</string>
</resources>
@@ -27,4 +27,10 @@
<string name="laps_label">Giri</string>
<string name="tap_to_dismiss">Tocca per chiudere</string>
<string name="ok">OK</string>
<string name="water_lock">Water Lock</string>
<string name="pause">Pausa</string>
<string name="resume">Riprendi</string>
<string name="end_workout">Termina allenamento</string>
<string name="workout_overview">Panoramica</string>
<string name="rest_duration">%ds pausa</string>
</resources>
@@ -27,4 +27,10 @@
<string name="laps_label">Rondes</string>
<string name="tap_to_dismiss">Tik om te sluiten</string>
<string name="ok">OK</string>
<string name="water_lock">Water Lock</string>
<string name="pause">Pauze</string>
<string name="resume">Hervat</string>
<string name="end_workout">Training beëindigen</string>
<string name="workout_overview">Overzicht</string>
<string name="rest_duration">%ds rust</string>
</resources>
@@ -27,4 +27,10 @@
<string name="laps_label">Okrążenia</string>
<string name="tap_to_dismiss">Stuknij aby zamknąć</string>
<string name="ok">OK</string>
<string name="water_lock">Water Lock</string>
<string name="pause">Pauza</string>
<string name="resume">Wznów</string>
<string name="end_workout">Zakończ trening</string>
<string name="workout_overview">Przegląd</string>
<string name="rest_duration">%ds przerwy</string>
</resources>
@@ -27,4 +27,10 @@
<string name="laps_label">Voltas</string>
<string name="tap_to_dismiss">Toque para fechar</string>
<string name="ok">OK</string>
<string name="water_lock">Water Lock</string>
<string name="pause">Pausar</string>
<string name="resume">Retomar</string>
<string name="end_workout">Terminar treino</string>
<string name="workout_overview">Resumo</string>
<string name="rest_duration">%ds descanso</string>
</resources>
@@ -27,4 +27,10 @@
<string name="laps_label">Laps</string>
<string name="tap_to_dismiss">Tap to dismiss</string>
<string name="ok">OK</string>
<string name="water_lock">Water Lock</string>
<string name="pause">Pause</string>
<string name="resume">Resume</string>
<string name="end_workout">End Workout</string>
<string name="workout_overview">Overview</string>
<string name="rest_duration">%ds rest</string>
</resources>