feat(watch-connectivity): add WearOS support alongside Apple Watch
This commit is contained in:
@@ -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>
|
||||
)
|
||||
+164
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+102
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+117
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -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>
|
||||
Reference in New Issue
Block a user