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,49 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.type.watch" />
<uses-permission android:name="android.permission.BODY_SENSORS" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".SwimBuddyWearActivity"
android:exported="true"
android:taskAffinity=""
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".ExerciseSessionService"
android:foregroundServiceType="health"
android:exported="false" />
<service
android:name=".PhoneDataLayerService"
android:exported="true">
<intent-filter>
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
<data
android:scheme="wear"
android:host="*"
android:pathPrefix="/swimbuddy" />
</intent-filter>
</service>
</application>
</manifest>
@@ -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
)
}
}
}
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">SwimBuddy</string>
<string name="no_workouts">Ingen træninger endnu</string>
<string name="create_on_phone">Opret en træning på din telefon</string>
<string name="saved_workouts">Gemte træninger</string>
<string name="recent">Seneste</string>
<string name="completed">Afsluttet</string>
<string name="sets">Sæt</string>
<string name="start">Start</string>
<string name="back">Tilbage</string>
<string name="complete_lap">Afslut omgang</string>
<string name="end">Slut</string>
<string name="rest">Hvile</string>
<string name="lap_detected">Omgang registreret!</string>
<string name="workout">Træning</string>
<string name="pool">%dm pool</string>
<string name="total">%dm total</string>
<string name="kcal">%dkcal</string>
<string name="set_format">Sæt %1$d/%2$d</string>
<string name="rep_format">Gentagelse %1$d/%2$d</string>
<string name="next_format">Næste: %1$s %2$dm</string>
<string name="workout_complete">Træning afsluttet!</string>
<string name="total_time">Samlet tid</string>
<string name="total_distance_label">Distance</string>
<string name="calories_label">Kalorier</string>
<string name="laps_label">Omgange</string>
<string name="tap_to_dismiss">Tryk for at lukke</string>
<string name="ok">OK</string>
</resources>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">SwimBuddy</string>
<string name="no_workouts">Noch keine Trainings</string>
<string name="create_on_phone">Erstelle ein Training auf deinem Telefon</string>
<string name="saved_workouts">Gespeicherte Trainings</string>
<string name="recent">Zuletzt</string>
<string name="completed">Abgeschlossen</string>
<string name="sets">Sätze</string>
<string name="start">Starten</string>
<string name="back">Zurück</string>
<string name="complete_lap">Runde abschließen</string>
<string name="end">Ende</string>
<string name="rest">Pause</string>
<string name="lap_detected">Runde erkannt!</string>
<string name="workout">Training</string>
<string name="pool">%dm Becken</string>
<string name="total">%dm gesamt</string>
<string name="kcal">%dkcal</string>
<string name="set_format">Satz %1$d/%2$d</string>
<string name="rep_format">Wiederholung %1$d/%2$d</string>
<string name="next_format">Nächste: %1$s %2$dm</string>
<string name="workout_complete">Training abgeschlossen!</string>
<string name="total_time">Gesamtzeit</string>
<string name="total_distance_label">Distanz</string>
<string name="calories_label">Kalorien</string>
<string name="laps_label">Runden</string>
<string name="tap_to_dismiss">Tippen zum Schließen</string>
<string name="ok">OK</string>
</resources>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">SwimBuddy</string>
<string name="no_workouts">Ningún entrenamiento</string>
<string name="create_on_phone">Crea un entrenamiento en tu teléfono</string>
<string name="saved_workouts">Entrenamientos guardados</string>
<string name="recent">Recientes</string>
<string name="completed">Completado</string>
<string name="sets">Series</string>
<string name="start">Iniciar</string>
<string name="back">Volver</string>
<string name="complete_lap">Completar vuelta</string>
<string name="end">Fin</string>
<string name="rest">Descanso</string>
<string name="lap_detected">¡Vuelta detectada!</string>
<string name="workout">Entrenamiento</string>
<string name="pool">%dm piscina</string>
<string name="total">%dm total</string>
<string name="kcal">%dkcal</string>
<string name="set_format">Serie %1$d/%2$d</string>
<string name="rep_format">Rep %1$d/%2$d</string>
<string name="next_format">Siguiente: %1$s %2$dm</string>
<string name="workout_complete">¡Entrenamiento completado!</string>
<string name="total_time">Tiempo total</string>
<string name="total_distance_label">Distancia</string>
<string name="calories_label">Calorías</string>
<string name="laps_label">Vueltas</string>
<string name="tap_to_dismiss">Toca para cerrar</string>
<string name="ok">OK</string>
</resources>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">SwimBuddy</string>
<string name="no_workouts">Aucun entraînement</string>
<string name="create_on_phone">Créez un entraînement sur votre téléphone</string>
<string name="saved_workouts">Entraînements enregistrés</string>
<string name="recent">Récent</string>
<string name="completed">Terminé</string>
<string name="sets">Séries</string>
<string name="start">Démarrer</string>
<string name="back">Retour</string>
<string name="complete_lap">Terminer le tour</string>
<string name="end">Fin</string>
<string name="rest">Repos</string>
<string name="lap_detected">Tour détecté !</string>
<string name="workout">Entraînement</string>
<string name="pool">%dm bassin</string>
<string name="total">%dm total</string>
<string name="kcal">%dkcal</string>
<string name="set_format">Série %1$d/%2$d</string>
<string name="rep_format">Répétition %1$d/%2$d</string>
<string name="next_format">Prochain : %1$s %2$dm</string>
<string name="workout_complete">Entraînement terminé !</string>
<string name="total_time">Durée totale</string>
<string name="total_distance_label">Distance</string>
<string name="calories_label">Calories</string>
<string name="laps_label">Tours</string>
<string name="tap_to_dismiss">Appuyez pour fermer</string>
<string name="ok">OK</string>
</resources>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">SwimBuddy</string>
<string name="no_workouts">Nessun allenamento</string>
<string name="create_on_phone">Crea un allenamento sul tuo telefono</string>
<string name="saved_workouts">Allenamenti salvati</string>
<string name="recent">Recenti</string>
<string name="completed">Completato</string>
<string name="sets">Serie</string>
<string name="start">Inizia</string>
<string name="back">Indietro</string>
<string name="complete_lap">Completa il giro</string>
<string name="end">Fine</string>
<string name="rest">Riposo</string>
<string name="lap_detected">Giro rilevato!</string>
<string name="workout">Allenamento</string>
<string name="pool">%dm vasca</string>
<string name="total">%dm totale</string>
<string name="kcal">%dkcal</string>
<string name="set_format">Serie %1$d/%2$d</string>
<string name="rep_format">Rep %1$d/%2$d</string>
<string name="next_format">Prossimo: %1$s %2$dm</string>
<string name="workout_complete">Allenamento completato!</string>
<string name="total_time">Tempo totale</string>
<string name="total_distance_label">Distanza</string>
<string name="calories_label">Calorie</string>
<string name="laps_label">Giri</string>
<string name="tap_to_dismiss">Tocca per chiudere</string>
<string name="ok">OK</string>
</resources>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">SwimBuddy</string>
<string name="no_workouts">Nog geen trainingen</string>
<string name="create_on_phone">Maak een training aan op je telefoon</string>
<string name="saved_workouts">Opgeslagen trainingen</string>
<string name="recent">Recent</string>
<string name="completed">Voltooid</string>
<string name="sets">Sets</string>
<string name="start">Start</string>
<string name="back">Terug</string>
<string name="complete_lap">Ronde voltooien</string>
<string name="end">Einde</string>
<string name="rest">Rust</string>
<string name="lap_detected">Ronde gedetecteerd!</string>
<string name="workout">Training</string>
<string name="pool">%dm bad</string>
<string name="total">%dm totaal</string>
<string name="kcal">%dkcal</string>
<string name="set_format">Set %1$d/%2$d</string>
<string name="rep_format">Herhaling %1$d/%2$d</string>
<string name="next_format">Volgende: %1$s %2$dm</string>
<string name="workout_complete">Training voltooid!</string>
<string name="total_time">Totale tijd</string>
<string name="total_distance_label">Afstand</string>
<string name="calories_label">Calorieën</string>
<string name="laps_label">Rondes</string>
<string name="tap_to_dismiss">Tik om te sluiten</string>
<string name="ok">OK</string>
</resources>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">SwimBuddy</string>
<string name="no_workouts">Brak treningów</string>
<string name="create_on_phone">Utwórz trening na telefonie</string>
<string name="saved_workouts">Zapisane treningi</string>
<string name="recent">Ostatnie</string>
<string name="completed">Ukończono</string>
<string name="sets">Serie</string>
<string name="start">Start</string>
<string name="back">Wstecz</string>
<string name="complete_lap">Zakończ okrążenie</string>
<string name="end">Koniec</string>
<string name="rest">Odpoczynek</string>
<string name="lap_detected">Wykryto okrążenie!</string>
<string name="workout">Trening</string>
<string name="pool">%dm basen</string>
<string name="total">%dm łącznie</string>
<string name="kcal">%dkcal</string>
<string name="set_format">Serie %1$d/%2$d</string>
<string name="rep_format">Powtórzenie %1$d/%2$d</string>
<string name="next_format">Następne: %1$s %2$dm</string>
<string name="workout_complete">Trening zakończony!</string>
<string name="total_time">Czas łączny</string>
<string name="total_distance_label">Dystans</string>
<string name="calories_label">Kalorie</string>
<string name="laps_label">Okrążenia</string>
<string name="tap_to_dismiss">Stuknij aby zamknąć</string>
<string name="ok">OK</string>
</resources>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">SwimBuddy</string>
<string name="no_workouts">Nenhum treino</string>
<string name="create_on_phone">Crie um treino no seu telefone</string>
<string name="saved_workouts">Treinos salvos</string>
<string name="recent">Recentes</string>
<string name="completed">Concluído</string>
<string name="sets">Séries</string>
<string name="start">Iniciar</string>
<string name="back">Voltar</string>
<string name="complete_lap">Completar volta</string>
<string name="end">Fim</string>
<string name="rest">Descanso</string>
<string name="lap_detected">Volta detectada!</string>
<string name="workout">Treino</string>
<string name="pool">%dm piscina</string>
<string name="total">%dm total</string>
<string name="kcal">%dkcal</string>
<string name="set_format">Série %1$d/%2$d</string>
<string name="rep_format">Repetição %1$d/%2$d</string>
<string name="next_format">Próximo: %1$s %2$dm</string>
<string name="workout_complete">Treino concluído!</string>
<string name="total_time">Tempo total</string>
<string name="total_distance_label">Distância</string>
<string name="calories_label">Calorias</string>
<string name="laps_label">Voltas</string>
<string name="tap_to_dismiss">Toque para fechar</string>
<string name="ok">OK</string>
</resources>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">SwimBuddy</string>
<string name="no_workouts">No workouts yet</string>
<string name="create_on_phone">Create a workout on your phone to get started</string>
<string name="saved_workouts">Saved Workouts</string>
<string name="recent">Recent</string>
<string name="completed">Completed</string>
<string name="sets">Sets</string>
<string name="start">Start</string>
<string name="back">Back</string>
<string name="complete_lap">Complete Lap</string>
<string name="end">End</string>
<string name="rest">Rest</string>
<string name="lap_detected">Lap detected!</string>
<string name="workout">Workout</string>
<string name="pool">%dm pool</string>
<string name="total">%dm total</string>
<string name="kcal">%dkcal</string>
<string name="set_format">Set %1$d/%2$d</string>
<string name="rep_format">Rep %1$d/%2$d</string>
<string name="next_format">Next: %1$s %2$dm</string>
<string name="workout_complete">Workout complete!</string>
<string name="total_time">Total time</string>
<string name="total_distance_label">Distance</string>
<string name="calories_label">Calories</string>
<string name="laps_label">Laps</string>
<string name="tap_to_dismiss">Tap to dismiss</string>
<string name="ok">OK</string>
</resources>