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
+2 -1
View File
@@ -36,7 +36,8 @@
"@bacons/apple-targets", "@bacons/apple-targets",
["expo-localization", { ["expo-localization", {
"supportedLocales": ["en", "nl", "de", "fr", "it", "es", "pl", "pt", "dk"] "supportedLocales": ["en", "nl", "de", "fr", "it", "es", "pl", "pt", "dk"]
}] }],
"./modules/wear-connectivity"
], ],
"extra": { "extra": {
"eas": { "eas": {
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="nl.guidoit.swimbuddy.wear">
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application>
<service
android:name=".WearMessageListenerService"
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,145 @@
package nl.guidoit.swimbuddy.wear
import com.google.android.gms.wearable.MessageClient
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.NodeClient
import com.google.android.gms.wearable.Wearable
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import kotlinx.coroutines.tasks.await
import org.json.JSONObject
class WearConnectivityModule : Module() {
private var messageClient: MessageClient? = null
private var nodeClient: NodeClient? = null
private val pendingEvents = mutableListOf<Map<String, Any?>>()
private val MESSAGE_PATH = "/swimbuddy"
companion object {
var instance: WearConnectivityModule? = null
private set
}
override fun definition() = ModuleDefinition {
Name("WearConnectivity")
OnCreate {
instance = this@WearConnectivityModule
val ctx = appContext.reactContext ?: return@OnCreate
messageClient = Wearable.getMessageClient(ctx)
nodeClient = Wearable.getNodeClient(ctx)
}
AsyncFunction("isSupported") {
try {
val ctx = appContext.reactContext ?: return@AsyncFunction false
com.google.android.gms.common.GoogleApiAvailability.getInstance()
.isGooglePlayServicesAvailable(ctx) == com.google.android.gms.common.ConnectionResult.SUCCESS
} catch (e: Exception) {
false
}
}
AsyncFunction("isPaired") {
try {
val nodes = nodeClient?.connectedNodes?.await() ?: emptyList()
nodes.isNotEmpty()
} catch (e: Exception) {
false
}
}
AsyncFunction("isReachable") {
try {
val nodes = nodeClient?.connectedNodes?.await() ?: emptyList()
nodes.any { it.isNearby }
} catch (e: Exception) {
false
}
}
AsyncFunction("sendWorkoutPlan") { plan: Map<String, Any?> ->
try {
val ctx = appContext.reactContext ?: return@AsyncFunction mapOf("error" to "No context")
val nodes = nodeClient?.connectedNodes?.await() ?: emptyList()
val nearbyNode = nodes.firstOrNull { it.isNearby }
?: return@AsyncFunction mapOf("error" to "No nearby watch found")
val json = JSONObject()
json.put("type", "workoutPlan")
for ((key, value) in plan) {
if (value != null) {
json.put(key, value)
}
}
messageClient?.sendMessage(
nearbyNode.id,
MESSAGE_PATH,
json.toString().toByteArray(Charsets.UTF_8)
)?.await()
mapOf("status" to "sent")
} catch (e: Exception) {
mapOf("error" to (e.message ?: "Failed to send"))
}
}
AsyncFunction("sendWorkoutCompletion") { data: Map<String, Any?> ->
try {
val nodes = nodeClient?.connectedNodes?.await() ?: emptyList()
val nearbyNode = nodes.firstOrNull { it.isNearby }
?: return@AsyncFunction mapOf("error" to "No nearby watch found")
val json = JSONObject()
json.put("type", "workoutCompleted")
for ((key, value) in data) {
if (value != null) {
json.put(key, value)
}
}
messageClient?.sendMessage(
nearbyNode.id,
MESSAGE_PATH,
json.toString().toByteArray(Charsets.UTF_8)
)?.await()
mapOf("status" to "sent")
} catch (e: Exception) {
mapOf("error" to (e.message ?: "Failed to send"))
}
}
AsyncFunction("pollEvents") {
synchronized(pendingEvents) {
val copy = ArrayList(pendingEvents)
pendingEvents.clear()
copy
}
}
}
fun handleIncomingMessage(messageEvent: MessageEvent) {
if (messageEvent.path != MESSAGE_PATH) return
try {
val json = String(messageEvent.data, Charsets.UTF_8)
val obj = JSONObject(json)
val map = mutableMapOf<String, Any?>()
for (key in obj.keys()) {
map[key] = if (obj.isNull(key)) null else obj.get(key)
}
synchronized(pendingEvents) {
pendingEvents.add(map)
}
} catch (e: Exception) {
// ignore malformed messages
}
}
override fun onDestroy() {
instance = null
}
}
@@ -0,0 +1,11 @@
package nl.guidoit.swimbuddy.wear
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.WearableListenerService
class WearMessageListenerService : WearableListenerService() {
override fun onMessageReceived(messageEvent: MessageEvent) {
WearConnectivityModule.instance?.handleIncomingMessage(messageEvent)
}
}
+40
View File
@@ -0,0 +1,40 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'expo-modules-core'
group = 'nl.guidoit.swimbuddy'
version = '0.1.0'
def safeExtGet(prop, fallback) {
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
android {
namespace "nl.guidoit.swimbuddy.wear"
compileSdkVersion safeExtGet("compileSdkVersion", 34)
defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 24)
targetSdkVersion safeExtGet("targetSdkVersion", 34)
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
repositories {
mavenCentral()
google()
}
dependencies {
implementation "expo-modules-core:+"
implementation "com.google.android.gms:play-services-wearable:18.2.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3"
}
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["nl.guidoit.swimbuddy.wear.WearConnectivityModule"]
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"name": "wear-connectivity",
"version": "0.1.0"
}
+2
View File
@@ -0,0 +1,2 @@
-keep class nl.guidoit.swimbuddy.wear.** { *; }
-keep class com.google.android.gms.wearable.** { *; }
@@ -1,5 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { Platform } from "react-native";
import WatchConnectivity from "../../../modules/watch-connectivity"; import WatchConnectivity from "../../../modules/watch-connectivity";
import WearConnectivity from "../../../modules/wear-connectivity";
import type { WorkoutSet } from "@types/workout"; import type { WorkoutSet } from "@types/workout";
type SyncResult = { status: string } | { error: string }; type SyncResult = { status: string } | { error: string };
@@ -15,20 +17,6 @@ export function useSyncWorkout() {
): Promise<SyncResult> => { ): Promise<SyncResult> => {
setIsSyncing(true); setIsSyncing(true);
try { try {
console.log("[useSyncWorkout] Checking module availability...");
const supported = await WatchConnectivity.isSupported();
console.log("[useSyncWorkout] isSupported:", supported);
const paired = await WatchConnectivity.isPaired();
console.log("[useSyncWorkout] isPaired:", paired);
const installed = await WatchConnectivity.isWatchAppInstalled();
console.log("[useSyncWorkout] isWatchAppInstalled:", installed);
const reachable = await WatchConnectivity.isReachable();
console.log("[useSyncWorkout] isReachable:", reachable);
const plan = { const plan = {
id: workoutId, id: workoutId,
name: name ?? "Workout", name: name ?? "Workout",
@@ -39,16 +27,37 @@ export function useSyncWorkout() {
repCount: s.repCount, repCount: s.repCount,
restType: s.restType, restType: s.restType,
restDuration: s.restDuration, restDuration: s.restDuration,
targetTime: s.targetTime ?? null, ...(s.targetTime != null ? { targetTime: s.targetTime } : {}),
})), })),
}; };
console.log("[useSyncWorkout] Sending plan:", JSON.stringify(plan).slice(0, 200)); if (Platform.OS === "ios") {
const supported = await WatchConnectivity.isSupported();
const paired = await WatchConnectivity.isPaired();
const installed = await WatchConnectivity.isWatchAppInstalled();
const reachable = await WatchConnectivity.isReachable();
if (!supported || !paired || !installed) {
return { error: "Apple Watch not available" };
}
const result = await WatchConnectivity.sendWorkoutPlan(plan); const result = await WatchConnectivity.sendWorkoutPlan(plan);
console.log("[useSyncWorkout] Result:", JSON.stringify(result));
return result as SyncResult; return result as SyncResult;
} else if (Platform.OS === "android") {
const supported = await WearConnectivity.isSupported();
const paired = await WearConnectivity.isPaired();
const reachable = await WearConnectivity.isReachable();
if (!supported || !paired) {
return { error: "WearOS watch not available" };
}
const result = await WearConnectivity.sendWorkoutPlan(plan);
return result as SyncResult;
}
return { error: "Platform not supported" };
} catch (error: any) { } catch (error: any) {
console.log("[useSyncWorkout] Error:", error.message, error.code);
return { error: error.message ?? "Failed to sync" }; return { error: error.message ?? "Failed to sync" };
} finally { } finally {
setIsSyncing(false); setIsSyncing(false);
@@ -1,5 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Platform } from "react-native";
import WatchConnectivity from "../../../modules/watch-connectivity"; import WatchConnectivity from "../../../modules/watch-connectivity";
import WearConnectivity from "../../../modules/wear-connectivity";
type WatchConnectionStatus = "connected" | "disconnected" | "pending"; type WatchConnectionStatus = "connected" | "disconnected" | "pending";
@@ -16,6 +18,7 @@ export function useWatchConnection() {
const checkStatus = async () => { const checkStatus = async () => {
try { try {
if (Platform.OS === "ios") {
const supported = await WatchConnectivity.isSupported(); const supported = await WatchConnectivity.isSupported();
if (!supported) { if (!supported) {
setStatus("disconnected"); setStatus("disconnected");
@@ -29,6 +32,22 @@ export function useWatchConnection() {
setIsPaired(paired); setIsPaired(paired);
setIsInstalled(installed); setIsInstalled(installed);
setStatus(reachable ? "connected" : paired ? "disconnected" : "disconnected"); setStatus(reachable ? "connected" : paired ? "disconnected" : "disconnected");
} else if (Platform.OS === "android") {
const supported = await WearConnectivity.isSupported();
if (!supported) {
setStatus("disconnected");
return;
}
const paired = await WearConnectivity.isPaired();
const reachable = await WearConnectivity.isReachable();
setIsPaired(paired);
setIsInstalled(paired); // WearOS doesn't have a separate install check
setStatus(reachable ? "connected" : paired ? "disconnected" : "disconnected");
} else {
setStatus("disconnected");
}
} catch { } catch {
setStatus("disconnected"); setStatus("disconnected");
} }
@@ -1,5 +1,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { Platform } from "react-native";
import WatchConnectivity from "../../../modules/watch-connectivity"; import WatchConnectivity from "../../../modules/watch-connectivity";
import WearConnectivity from "../../../modules/wear-connectivity";
import { saveWatchCompletedWorkout } from "@db/operations"; import { saveWatchCompletedWorkout } from "@db/operations";
export function useWatchWorkoutSync() { export function useWatchWorkoutSync() {
@@ -8,10 +10,16 @@ export function useWatchWorkoutSync() {
useEffect(() => { useEffect(() => {
pollingRef.current = setInterval(async () => { pollingRef.current = setInterval(async () => {
try { try {
const events = (await WatchConnectivity.pollEvents()) as any[]; let events: any[] = [];
if (Platform.OS === "ios") {
events = (await WatchConnectivity.pollEvents()) as any[];
} else if (Platform.OS === "android") {
events = (await WearConnectivity.pollEvents()) as any[];
}
for (const event of events) { for (const event of events) {
if (event.type === "workoutCompleted") { if (event.type === "workoutCompleted") {
console.log("[useWatchWorkoutSync] Received workoutCompleted:", event.workoutId);
await saveWatchCompletedWorkout({ await saveWatchCompletedWorkout({
workoutId: event.workoutId, workoutId: event.workoutId,
duration: event.duration ?? 0, duration: event.duration ?? 0,
@@ -19,7 +27,6 @@ export function useWatchWorkoutSync() {
calories: event.calories ?? 0, calories: event.calories ?? 0,
laps: event.laps ?? [], laps: event.laps ?? [],
}); });
console.log("[useWatchWorkoutSync] Saved completed workout to DB");
} }
} }
} catch (error) { } catch (error) {
+3 -3
View File
@@ -1,11 +1,11 @@
{ {
"workoutSentToWatch": "Workout an Watch gesendet!", "workoutSentToWatch": "Workout an Watch gesendet!",
"openWatchApp": "Öffne die SwimBuddy-App auf deiner Apple Watch, um mit der Verfolgung zu beginnen.", "openWatchApp": "Öffne die SwimBuddy-App auf deiner Watch, um mit der Verfolgung zu beginnen.",
"failedToSend": "Senden fehlgeschlagen", "failedToSend": "Senden fehlgeschlagen",
"couldNotSend": "Workout konnte nicht an Watch gesendet werden. Starte ohne Watch.", "couldNotSend": "Workout konnte nicht an Watch gesendet werden. Starte ohne Watch.",
"startWithoutWatch": "Ohne Watch starten", "startWithoutWatch": "Ohne Watch starten",
"sendToWatchAndStart": "An Watch senden & starten", "sendToWatchAndStart": "An Watch senden & starten",
"syncToWatch": "Workout wird mit deiner Apple Watch synchronisiert", "syncToWatch": "Workout wird mit deiner Watch synchronisiert",
"trackedOnDevice": "Workout wird auf diesem Gerät verfolgt", "trackedOnDevice": "Workout wird auf diesem Gerät verfolgt",
"rest": "Pause", "rest": "Pause",
"betweenSets": "{{duration}}s Pause zwischen Sets", "betweenSets": "{{duration}}s Pause zwischen Sets",
@@ -13,7 +13,7 @@
"greatJob": "Gut gemacht! Du bist heute ins Wasser gegangen!", "greatJob": "Gut gemacht! Du bist heute ins Wasser gegangen!",
"loadingSummary": "Zusammenfassung laden...", "loadingSummary": "Zusammenfassung laden...",
"watchStatus": { "watchStatus": {
"ready": "Apple Watch bereit", "ready": "Watch bereit",
"pairedNotInRange": "Watch gekoppelt, aber nicht in Reichweite", "pairedNotInRange": "Watch gekoppelt, aber nicht in Reichweite",
"pairedNotInstalled": "Watch gekoppelt, App nicht installiert", "pairedNotInstalled": "Watch gekoppelt, App nicht installiert",
"noWatch": "Keine Watch gekoppelt" "noWatch": "Keine Watch gekoppelt"
+3 -3
View File
@@ -1,11 +1,11 @@
{ {
"workoutSentToWatch": "Træning sendt til Watch!", "workoutSentToWatch": "Træning sendt til Watch!",
"openWatchApp": "Åbn SwimBuddy-appen på din Apple Watch for at begynde sporing.", "openWatchApp": "Åbn SwimBuddy-appen på din Watch for at begynde sporing.",
"failedToSend": "Sendning mislykkedes", "failedToSend": "Sendning mislykkedes",
"couldNotSend": "Kunne ikke sende træning til Watch. Starter uden Watch.", "couldNotSend": "Kunne ikke sende træning til Watch. Starter uden Watch.",
"startWithoutWatch": "Start uden Watch", "startWithoutWatch": "Start uden Watch",
"sendToWatchAndStart": "Send til Watch & start", "sendToWatchAndStart": "Send til Watch & start",
"syncToWatch": "Træningen synkroniseres med din Apple Watch", "syncToWatch": "Træningen synkroniseres med din Watch",
"trackedOnDevice": "Træningen spores på denne enhed", "trackedOnDevice": "Træningen spores på denne enhed",
"rest": "Hvile", "rest": "Hvile",
"betweenSets": "{{duration}}s hvile mellem sæt", "betweenSets": "{{duration}}s hvile mellem sæt",
@@ -13,7 +13,7 @@
"greatJob": "Godt gået! Du var i vandet i dag!", "greatJob": "Godt gået! Du var i vandet i dag!",
"loadingSummary": "Indlæser opsummering...", "loadingSummary": "Indlæser opsummering...",
"watchStatus": { "watchStatus": {
"ready": "Apple Watch klar", "ready": "Watch klar",
"pairedNotInRange": "Watch parret, men ikke inden for rækkevidde", "pairedNotInRange": "Watch parret, men ikke inden for rækkevidde",
"pairedNotInstalled": "Watch parret, app ikke installeret", "pairedNotInstalled": "Watch parret, app ikke installeret",
"noWatch": "Ingen Watch parret" "noWatch": "Ingen Watch parret"
+3 -3
View File
@@ -1,11 +1,11 @@
{ {
"workoutSentToWatch": "Workout sent to Watch!", "workoutSentToWatch": "Workout sent to Watch!",
"openWatchApp": "Open the SwimBuddy app on your Apple Watch to start tracking.", "openWatchApp": "Open the SwimBuddy app on your watch to start tracking.",
"failedToSend": "Failed to send", "failedToSend": "Failed to send",
"couldNotSend": "Could not send workout to Watch. Starting without Watch instead.", "couldNotSend": "Could not send workout to Watch. Starting without Watch instead.",
"startWithoutWatch": "Start Without Watch", "startWithoutWatch": "Start Without Watch",
"sendToWatchAndStart": "Send to Watch & Start", "sendToWatchAndStart": "Send to Watch & Start",
"syncToWatch": "Workout will be synced to your Apple Watch", "syncToWatch": "Workout will be synced to your watch",
"trackedOnDevice": "Workout will be tracked on this device", "trackedOnDevice": "Workout will be tracked on this device",
"rest": "Rest", "rest": "Rest",
"betweenSets": "{{duration}}s between sets", "betweenSets": "{{duration}}s between sets",
@@ -13,7 +13,7 @@
"greatJob": "Great job getting in the water today!", "greatJob": "Great job getting in the water today!",
"loadingSummary": "Loading summary...", "loadingSummary": "Loading summary...",
"watchStatus": { "watchStatus": {
"ready": "Apple Watch ready", "ready": "Watch ready",
"pairedNotInRange": "Watch paired but not in range", "pairedNotInRange": "Watch paired but not in range",
"pairedNotInstalled": "Watch paired, app not installed", "pairedNotInstalled": "Watch paired, app not installed",
"noWatch": "No Watch paired" "noWatch": "No Watch paired"
+3 -3
View File
@@ -1,11 +1,11 @@
{ {
"workoutSentToWatch": "¡Entrenamiento enviado al Watch!", "workoutSentToWatch": "¡Entrenamiento enviado al Watch!",
"openWatchApp": "Abre la app SwimBuddy en tu Apple Watch para empezar el seguimiento.", "openWatchApp": "Abre la app SwimBuddy en tu Watch para empezar el seguimiento.",
"failedToSend": "Error al enviar", "failedToSend": "Error al enviar",
"couldNotSend": "No se pudo enviar el entrenamiento al Watch. Iniciando sin Watch.", "couldNotSend": "No se pudo enviar el entrenamiento al Watch. Iniciando sin Watch.",
"startWithoutWatch": "Iniciar sin Watch", "startWithoutWatch": "Iniciar sin Watch",
"sendToWatchAndStart": "Enviar al Watch e iniciar", "sendToWatchAndStart": "Enviar al Watch e iniciar",
"syncToWatch": "El entrenamiento se sincronizará con tu Apple Watch", "syncToWatch": "El entrenamiento se sincronizará con tu Watch",
"trackedOnDevice": "El entrenamiento se rastreará en este dispositivo", "trackedOnDevice": "El entrenamiento se rastreará en este dispositivo",
"rest": "Descanso", "rest": "Descanso",
"betweenSets": "{{duration}}s de descanso entre series", "betweenSets": "{{duration}}s de descanso entre series",
@@ -13,7 +13,7 @@
"greatJob": "¡Buen trabajo! ¡Hoy has estado en el agua!", "greatJob": "¡Buen trabajo! ¡Hoy has estado en el agua!",
"loadingSummary": "Cargando resumen...", "loadingSummary": "Cargando resumen...",
"watchStatus": { "watchStatus": {
"ready": "Apple Watch lista", "ready": "Watch lista",
"pairedNotInRange": "Watch emparejada pero fuera de alcance", "pairedNotInRange": "Watch emparejada pero fuera de alcance",
"pairedNotInstalled": "Watch emparejada, app no instalada", "pairedNotInstalled": "Watch emparejada, app no instalada",
"noWatch": "Ninguna Watch emparejada" "noWatch": "Ninguna Watch emparejada"
+3 -3
View File
@@ -1,11 +1,11 @@
{ {
"workoutSentToWatch": "Entraînement envoyé à la Watch !", "workoutSentToWatch": "Entraînement envoyé à la Watch !",
"openWatchApp": "Ouvrez l'application SwimBuddy sur votre Apple Watch pour commencer le suivi.", "openWatchApp": "Ouvrez l'application SwimBuddy sur votre Watch pour commencer le suivi.",
"failedToSend": "Échec de l'envoi", "failedToSend": "Échec de l'envoi",
"couldNotSend": "Impossible d'envoyer l'entraînement à la Watch. Démarrage sans Watch.", "couldNotSend": "Impossible d'envoyer l'entraînement à la Watch. Démarrage sans Watch.",
"startWithoutWatch": "Démarrer sans Watch", "startWithoutWatch": "Démarrer sans Watch",
"sendToWatchAndStart": "Envoyer à la Watch et commencer", "sendToWatchAndStart": "Envoyer à la Watch et commencer",
"syncToWatch": "L'entraînement sera synchronisé avec votre Apple Watch", "syncToWatch": "L'entraînement sera synchronisé avec votre Watch",
"trackedOnDevice": "L'entraînement sera suivi sur cet appareil", "trackedOnDevice": "L'entraînement sera suivi sur cet appareil",
"rest": "Repos", "rest": "Repos",
"betweenSets": "{{duration}}s de repos entre les séries", "betweenSets": "{{duration}}s de repos entre les séries",
@@ -13,7 +13,7 @@
"greatJob": "Bravo ! Vous êtes allé à l'eau aujourd'hui !", "greatJob": "Bravo ! Vous êtes allé à l'eau aujourd'hui !",
"loadingSummary": "Chargement du résumé...", "loadingSummary": "Chargement du résumé...",
"watchStatus": { "watchStatus": {
"ready": "Apple Watch prête", "ready": "Watch prête",
"pairedNotInRange": "Watch appariée mais hors de portée", "pairedNotInRange": "Watch appariée mais hors de portée",
"pairedNotInstalled": "Watch appariée, application non installée", "pairedNotInstalled": "Watch appariée, application non installée",
"noWatch": "Aucune Watch appariée" "noWatch": "Aucune Watch appariée"
+3 -3
View File
@@ -1,11 +1,11 @@
{ {
"workoutSentToWatch": "Allenamento inviato alla Watch!", "workoutSentToWatch": "Allenamento inviato alla Watch!",
"openWatchApp": "Apri l'app SwimBuddy sulla tua Apple Watch per iniziare il monitoraggio.", "openWatchApp": "Apri l'app SwimBuddy sulla tua Watch per iniziare il monitoraggio.",
"failedToSend": "Invio fallito", "failedToSend": "Invio fallito",
"couldNotSend": "Impossibile inviare l'allenamento alla Watch. Avvio senza Watch.", "couldNotSend": "Impossibile inviare l'allenamento alla Watch. Avvio senza Watch.",
"startWithoutWatch": "Avvia senza Watch", "startWithoutWatch": "Avvia senza Watch",
"sendToWatchAndStart": "Invia alla Watch e avvia", "sendToWatchAndStart": "Invia alla Watch e avvia",
"syncToWatch": "L'allenamento verrà sincronizzato con la tua Apple Watch", "syncToWatch": "L'allenamento verrà sincronizzato con la tua Watch",
"trackedOnDevice": "L'allenamento verrà monitorato su questo dispositivo", "trackedOnDevice": "L'allenamento verrà monitorato su questo dispositivo",
"rest": "Recupero", "rest": "Recupero",
"betweenSets": "{{duration}}s di recupero tra le serie", "betweenSets": "{{duration}}s di recupero tra le serie",
@@ -13,7 +13,7 @@
"greatJob": "Ottimo lavoro! Sei entrato in acqua oggi!", "greatJob": "Ottimo lavoro! Sei entrato in acqua oggi!",
"loadingSummary": "Caricamento riepilogo...", "loadingSummary": "Caricamento riepilogo...",
"watchStatus": { "watchStatus": {
"ready": "Apple Watch pronta", "ready": "Watch pronta",
"pairedNotInRange": "Watch associata ma fuori portata", "pairedNotInRange": "Watch associata ma fuori portata",
"pairedNotInstalled": "Watch associata, app non installata", "pairedNotInstalled": "Watch associata, app non installata",
"noWatch": "Nessuna Watch associata" "noWatch": "Nessuna Watch associata"
+3 -3
View File
@@ -1,11 +1,11 @@
{ {
"workoutSentToWatch": "Workout verzonden naar Watch!", "workoutSentToWatch": "Workout verzonden naar Watch!",
"openWatchApp": "Open de SwimBuddy-app op je Apple Watch om te beginnen met bijhouden.", "openWatchApp": "Open de SwimBuddy-app op je Watch om te beginnen met bijhouden.",
"failedToSend": "Verzenden mislukt", "failedToSend": "Verzenden mislukt",
"couldNotSend": "Kon workout niet naar Watch verzenden. Start zonder Watch.", "couldNotSend": "Kon workout niet naar Watch verzenden. Start zonder Watch.",
"startWithoutWatch": "Start zonder Watch", "startWithoutWatch": "Start zonder Watch",
"sendToWatchAndStart": "Verzenden naar Watch & Start", "sendToWatchAndStart": "Verzenden naar Watch & Start",
"syncToWatch": "Workout wordt gesynchroniseerd naar je Apple Watch", "syncToWatch": "Workout wordt gesynchroniseerd naar je Watch",
"trackedOnDevice": "Workout wordt op dit apparaat bijgehouden", "trackedOnDevice": "Workout wordt op dit apparaat bijgehouden",
"rest": "Rust", "rest": "Rust",
"betweenSets": "{{duration}}s rust tussen sets", "betweenSets": "{{duration}}s rust tussen sets",
@@ -13,7 +13,7 @@
"greatJob": "Goed gedaan! Je bent vandaag het water in geweest!", "greatJob": "Goed gedaan! Je bent vandaag het water in geweest!",
"loadingSummary": "Samenvatting laden...", "loadingSummary": "Samenvatting laden...",
"watchStatus": { "watchStatus": {
"ready": "Apple Watch gereed", "ready": "Watch gereed",
"pairedNotInRange": "Watch gekoppeld maar niet in bereik", "pairedNotInRange": "Watch gekoppeld maar niet in bereik",
"pairedNotInstalled": "Watch gekoppeld, app niet geïnstalleerd", "pairedNotInstalled": "Watch gekoppeld, app niet geïnstalleerd",
"noWatch": "Geen Watch gekoppeld" "noWatch": "Geen Watch gekoppeld"
+3 -3
View File
@@ -1,11 +1,11 @@
{ {
"workoutSentToWatch": "Trening wysłany do Watch!", "workoutSentToWatch": "Trening wysłany do Watch!",
"openWatchApp": "Otwórz aplikację SwimBuddy na swojej Apple Watch, aby rozpocząć śledzenie.", "openWatchApp": "Otwórz aplikację SwimBuddy na swojej Watch, aby rozpocząć śledzenie.",
"failedToSend": "Nie udało się wysłać", "failedToSend": "Nie udało się wysłać",
"couldNotSend": "Nie udało się wysłać treningu do Watch. Uruchamianie bez Watch.", "couldNotSend": "Nie udało się wysłać treningu do Watch. Uruchamianie bez Watch.",
"startWithoutWatch": "Uruchom bez Watch", "startWithoutWatch": "Uruchom bez Watch",
"sendToWatchAndStart": "Wyślij do Watch i rozpocznij", "sendToWatchAndStart": "Wyślij do Watch i rozpocznij",
"syncToWatch": "Trening zostanie zsynchronizowany z Apple Watch", "syncToWatch": "Trening zostanie zsynchronizowany z Watch",
"trackedOnDevice": "Trening będzie śledzony na tym urządzeniu", "trackedOnDevice": "Trening będzie śledzony na tym urządzeniu",
"rest": "Odpoczynek", "rest": "Odpoczynek",
"betweenSets": "{{duration}}s odpoczynku między seriami", "betweenSets": "{{duration}}s odpoczynku między seriami",
@@ -13,7 +13,7 @@
"greatJob": "Świetna robota! Dziś byłeś w wodzie!", "greatJob": "Świetna robota! Dziś byłeś w wodzie!",
"loadingSummary": "Ładowanie podsumowania...", "loadingSummary": "Ładowanie podsumowania...",
"watchStatus": { "watchStatus": {
"ready": "Apple Watch gotowa", "ready": "Watch gotowa",
"pairedNotInRange": "Watch sparowana, ale poza zasięgiem", "pairedNotInRange": "Watch sparowana, ale poza zasięgiem",
"pairedNotInstalled": "Watch sparowana, aplikacja nie zainstalowana", "pairedNotInstalled": "Watch sparowana, aplikacja nie zainstalowana",
"noWatch": "Brak sparowanej Watch" "noWatch": "Brak sparowanej Watch"
+3 -3
View File
@@ -1,11 +1,11 @@
{ {
"workoutSentToWatch": "Treino enviado para a Watch!", "workoutSentToWatch": "Treino enviado para a Watch!",
"openWatchApp": "Abra a aplicação SwimBuddy na sua Apple Watch para iniciar o registo.", "openWatchApp": "Abra a aplicação SwimBuddy na sua Watch para iniciar o registo.",
"failedToSend": "Falha no envio", "failedToSend": "Falha no envio",
"couldNotSend": "Não foi possível enviar o treino para a Watch. Iniciando sem Watch.", "couldNotSend": "Não foi possível enviar o treino para a Watch. Iniciando sem Watch.",
"startWithoutWatch": "Iniciar sem Watch", "startWithoutWatch": "Iniciar sem Watch",
"sendToWatchAndStart": "Enviar para a Watch e iniciar", "sendToWatchAndStart": "Enviar para a Watch e iniciar",
"syncToWatch": "O treino será sincronizado com a sua Apple Watch", "syncToWatch": "O treino será sincronizado com a sua Watch",
"trackedOnDevice": "O treino será registado neste dispositivo", "trackedOnDevice": "O treino será registado neste dispositivo",
"rest": "Descanso", "rest": "Descanso",
"betweenSets": "{{duration}}s de descanso entre séries", "betweenSets": "{{duration}}s de descanso entre séries",
@@ -13,7 +13,7 @@
"greatJob": "Bom trabalho! Esteve na água hoje!", "greatJob": "Bom trabalho! Esteve na água hoje!",
"loadingSummary": "A carregar resumo...", "loadingSummary": "A carregar resumo...",
"watchStatus": { "watchStatus": {
"ready": "Apple Watch pronta", "ready": "Watch pronta",
"pairedNotInRange": "Watch associada, mas fora de alcance", "pairedNotInRange": "Watch associada, mas fora de alcance",
"pairedNotInstalled": "Watch associada, aplicação não instalada", "pairedNotInstalled": "Watch associada, aplicação não instalada",
"noWatch": "Nenhuma Watch associada" "noWatch": "Nenhuma Watch associada"
+1
View File
@@ -9,6 +9,7 @@ WatchConnectivity = requireOptionalNativeModule("WatchConnectivity") ?? {
isReachable: async () => false, isReachable: async () => false,
sendWorkoutPlan: async () => ({ error: "WatchConnectivity native module not available" }), sendWorkoutPlan: async () => ({ error: "WatchConnectivity native module not available" }),
sendWorkoutCompletion: async () => ({ error: "WatchConnectivity native module not available" }), sendWorkoutCompletion: async () => ({ error: "WatchConnectivity native module not available" }),
pollEvents: async () => [],
getApplicationContext: async () => null, getApplicationContext: async () => null,
updateApplicationContext: async () => {}, updateApplicationContext: async () => {},
}; };
+17
View File
@@ -0,0 +1,17 @@
import { requireOptionalNativeModule, Platform } from "expo-modules-core";
const WearConnectivityNative = Platform.select({
android: () => requireOptionalNativeModule("WearConnectivity"),
default: () => null,
})();
const WearConnectivity = WearConnectivityNative ?? {
isSupported: async () => false,
isPaired: async () => false,
isReachable: async () => false,
sendWorkoutPlan: async () => ({ error: "WearOS native module not available" }),
sendWorkoutCompletion: async () => ({ error: "WearOS native module not available" }),
pollEvents: async () => [],
};
export default WearConnectivity;
+71
View File
@@ -0,0 +1,71 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "nl.guidoit.swimbuddy"
compileSdk = 35
defaultConfig {
applicationId = "nl.guidoit.swimbuddy"
minSdk = 30
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
}
dependencies {
// Wear OS Compose
implementation("androidx.wear.compose:compose-material:1.3.1")
implementation("androidx.wear.compose:compose-foundation:1.3.1")
implementation("androidx.wear.compose:compose-navigation:1.3.1")
// Compose
implementation(platform("androidx.compose:compose-bom:2024.12.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
// Health Services
implementation("androidx.health:health-services-client:1.1.0-rc02")
// Wearable Data Layer
implementation("com.google.android.gms:play-services-wearable:18.2.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
// Gson for JSON
implementation("com.google.code.gson:gson:2.11.0")
}
+4
View File
@@ -0,0 +1,4 @@
# Wear OS
-keep class nl.guidoit.swimbuddy.** { *; }
-keep class com.google.android.gms.wearable.** { *; }
-keep class androidx.health.services.** { *; }
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "SwimBuddyWear"
include(":app")