diff --git a/app.json b/app.json index b56d6f9..c7faf64 100644 --- a/app.json +++ b/app.json @@ -36,7 +36,8 @@ "@bacons/apple-targets", ["expo-localization", { "supportedLocales": ["en", "nl", "de", "fr", "it", "es", "pl", "pt", "dk"] - }] + }], + "./modules/wear-connectivity" ], "extra": { "eas": { diff --git a/modules/wear-connectivity/android/src/main/AndroidManifest.xml b/modules/wear-connectivity/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7746e99 --- /dev/null +++ b/modules/wear-connectivity/android/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + diff --git a/modules/wear-connectivity/android/src/main/java/nl/guidoit/swimbuddy/wear/WearConnectivityModule.kt b/modules/wear-connectivity/android/src/main/java/nl/guidoit/swimbuddy/wear/WearConnectivityModule.kt new file mode 100644 index 0000000..a2f760f --- /dev/null +++ b/modules/wear-connectivity/android/src/main/java/nl/guidoit/swimbuddy/wear/WearConnectivityModule.kt @@ -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>() + + 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 -> + 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 -> + 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() + 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 + } +} diff --git a/modules/wear-connectivity/android/src/main/java/nl/guidoit/swimbuddy/wear/WearMessageListenerService.kt b/modules/wear-connectivity/android/src/main/java/nl/guidoit/swimbuddy/wear/WearMessageListenerService.kt new file mode 100644 index 0000000..92a45fb --- /dev/null +++ b/modules/wear-connectivity/android/src/main/java/nl/guidoit/swimbuddy/wear/WearMessageListenerService.kt @@ -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) + } +} diff --git a/modules/wear-connectivity/build.gradle b/modules/wear-connectivity/build.gradle new file mode 100644 index 0000000..8e9cbc1 --- /dev/null +++ b/modules/wear-connectivity/build.gradle @@ -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" +} diff --git a/modules/wear-connectivity/expo-module.config.json b/modules/wear-connectivity/expo-module.config.json new file mode 100644 index 0000000..5a9b2ec --- /dev/null +++ b/modules/wear-connectivity/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["nl.guidoit.swimbuddy.wear.WearConnectivityModule"] + } +} diff --git a/modules/wear-connectivity/package.json b/modules/wear-connectivity/package.json new file mode 100644 index 0000000..3b1288d --- /dev/null +++ b/modules/wear-connectivity/package.json @@ -0,0 +1,4 @@ +{ + "name": "wear-connectivity", + "version": "0.1.0" +} diff --git a/modules/wear-connectivity/proguard-rules.pro b/modules/wear-connectivity/proguard-rules.pro new file mode 100644 index 0000000..d3c263d --- /dev/null +++ b/modules/wear-connectivity/proguard-rules.pro @@ -0,0 +1,2 @@ +-keep class nl.guidoit.swimbuddy.wear.** { *; } +-keep class com.google.android.gms.wearable.** { *; } diff --git a/src/features/watch-connectivity/hooks/useSyncWorkout.ts b/src/features/watch-connectivity/hooks/useSyncWorkout.ts index 8244e01..d276272 100644 --- a/src/features/watch-connectivity/hooks/useSyncWorkout.ts +++ b/src/features/watch-connectivity/hooks/useSyncWorkout.ts @@ -1,5 +1,7 @@ import { useState } from "react"; +import { Platform } from "react-native"; import WatchConnectivity from "../../../modules/watch-connectivity"; +import WearConnectivity from "../../../modules/wear-connectivity"; import type { WorkoutSet } from "@types/workout"; type SyncResult = { status: string } | { error: string }; @@ -15,20 +17,6 @@ export function useSyncWorkout() { ): Promise => { setIsSyncing(true); 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 = { id: workoutId, name: name ?? "Workout", @@ -39,16 +27,37 @@ export function useSyncWorkout() { repCount: s.repCount, restType: s.restType, restDuration: s.restDuration, - targetTime: s.targetTime ?? null, + ...(s.targetTime != null ? { targetTime: s.targetTime } : {}), })), }; - console.log("[useSyncWorkout] Sending plan:", JSON.stringify(plan).slice(0, 200)); - const result = await WatchConnectivity.sendWorkoutPlan(plan); - console.log("[useSyncWorkout] Result:", JSON.stringify(result)); - return result as SyncResult; + 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); + 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) { - console.log("[useSyncWorkout] Error:", error.message, error.code); return { error: error.message ?? "Failed to sync" }; } finally { setIsSyncing(false); diff --git a/src/features/watch-connectivity/hooks/useWatchConnection.ts b/src/features/watch-connectivity/hooks/useWatchConnection.ts index 70bdd40..27d111f 100644 --- a/src/features/watch-connectivity/hooks/useWatchConnection.ts +++ b/src/features/watch-connectivity/hooks/useWatchConnection.ts @@ -1,5 +1,7 @@ import { useState, useEffect } from "react"; +import { Platform } from "react-native"; import WatchConnectivity from "../../../modules/watch-connectivity"; +import WearConnectivity from "../../../modules/wear-connectivity"; type WatchConnectionStatus = "connected" | "disconnected" | "pending"; @@ -16,19 +18,36 @@ export function useWatchConnection() { const checkStatus = async () => { try { - const supported = await WatchConnectivity.isSupported(); - if (!supported) { + if (Platform.OS === "ios") { + const supported = await WatchConnectivity.isSupported(); + if (!supported) { + setStatus("disconnected"); + return; + } + + const paired = await WatchConnectivity.isPaired(); + const installed = await WatchConnectivity.isWatchAppInstalled(); + const reachable = await WatchConnectivity.isReachable(); + + setIsPaired(paired); + setIsInstalled(installed); + 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"); - return; } - - const paired = await WatchConnectivity.isPaired(); - const installed = await WatchConnectivity.isWatchAppInstalled(); - const reachable = await WatchConnectivity.isReachable(); - - setIsPaired(paired); - setIsInstalled(installed); - setStatus(reachable ? "connected" : paired ? "disconnected" : "disconnected"); } catch { setStatus("disconnected"); } diff --git a/src/features/watch-connectivity/hooks/useWatchWorkoutSync.ts b/src/features/watch-connectivity/hooks/useWatchWorkoutSync.ts index d06643e..53001af 100644 --- a/src/features/watch-connectivity/hooks/useWatchWorkoutSync.ts +++ b/src/features/watch-connectivity/hooks/useWatchWorkoutSync.ts @@ -1,5 +1,7 @@ import { useEffect, useRef } from "react"; +import { Platform } from "react-native"; import WatchConnectivity from "../../../modules/watch-connectivity"; +import WearConnectivity from "../../../modules/wear-connectivity"; import { saveWatchCompletedWorkout } from "@db/operations"; export function useWatchWorkoutSync() { @@ -8,10 +10,16 @@ export function useWatchWorkoutSync() { useEffect(() => { pollingRef.current = setInterval(async () => { 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) { if (event.type === "workoutCompleted") { - console.log("[useWatchWorkoutSync] Received workoutCompleted:", event.workoutId); await saveWatchCompletedWorkout({ workoutId: event.workoutId, duration: event.duration ?? 0, @@ -19,7 +27,6 @@ export function useWatchWorkoutSync() { calories: event.calories ?? 0, laps: event.laps ?? [], }); - console.log("[useWatchWorkoutSync] Saved completed workout to DB"); } } } catch (error) { diff --git a/src/i18n/de/active-workout.json b/src/i18n/de/active-workout.json index dd123dd..b4e682d 100644 --- a/src/i18n/de/active-workout.json +++ b/src/i18n/de/active-workout.json @@ -1,11 +1,11 @@ { "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", "couldNotSend": "Workout konnte nicht an Watch gesendet werden. Starte ohne Watch.", "startWithoutWatch": "Ohne Watch 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", "rest": "Pause", "betweenSets": "{{duration}}s Pause zwischen Sets", @@ -13,7 +13,7 @@ "greatJob": "Gut gemacht! Du bist heute ins Wasser gegangen!", "loadingSummary": "Zusammenfassung laden...", "watchStatus": { - "ready": "Apple Watch bereit", + "ready": "Watch bereit", "pairedNotInRange": "Watch gekoppelt, aber nicht in Reichweite", "pairedNotInstalled": "Watch gekoppelt, App nicht installiert", "noWatch": "Keine Watch gekoppelt" diff --git a/src/i18n/dk/active-workout.json b/src/i18n/dk/active-workout.json index c91647d..04eb387 100644 --- a/src/i18n/dk/active-workout.json +++ b/src/i18n/dk/active-workout.json @@ -1,11 +1,11 @@ { "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", "couldNotSend": "Kunne ikke sende træning til Watch. Starter uden Watch.", "startWithoutWatch": "Start uden Watch", "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", "rest": "Hvile", "betweenSets": "{{duration}}s hvile mellem sæt", @@ -13,7 +13,7 @@ "greatJob": "Godt gået! Du var i vandet i dag!", "loadingSummary": "Indlæser opsummering...", "watchStatus": { - "ready": "Apple Watch klar", + "ready": "Watch klar", "pairedNotInRange": "Watch parret, men ikke inden for rækkevidde", "pairedNotInstalled": "Watch parret, app ikke installeret", "noWatch": "Ingen Watch parret" diff --git a/src/i18n/en/active-workout.json b/src/i18n/en/active-workout.json index 9484442..e9809c8 100644 --- a/src/i18n/en/active-workout.json +++ b/src/i18n/en/active-workout.json @@ -1,11 +1,11 @@ { "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", "couldNotSend": "Could not send workout to Watch. Starting without Watch instead.", "startWithoutWatch": "Start Without Watch", "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", "rest": "Rest", "betweenSets": "{{duration}}s between sets", @@ -13,7 +13,7 @@ "greatJob": "Great job getting in the water today!", "loadingSummary": "Loading summary...", "watchStatus": { - "ready": "Apple Watch ready", + "ready": "Watch ready", "pairedNotInRange": "Watch paired but not in range", "pairedNotInstalled": "Watch paired, app not installed", "noWatch": "No Watch paired" diff --git a/src/i18n/es/active-workout.json b/src/i18n/es/active-workout.json index 241a253..b831861 100644 --- a/src/i18n/es/active-workout.json +++ b/src/i18n/es/active-workout.json @@ -1,11 +1,11 @@ { "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", "couldNotSend": "No se pudo enviar el entrenamiento al Watch. Iniciando sin Watch.", "startWithoutWatch": "Iniciar sin Watch", "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", "rest": "Descanso", "betweenSets": "{{duration}}s de descanso entre series", @@ -13,7 +13,7 @@ "greatJob": "¡Buen trabajo! ¡Hoy has estado en el agua!", "loadingSummary": "Cargando resumen...", "watchStatus": { - "ready": "Apple Watch lista", + "ready": "Watch lista", "pairedNotInRange": "Watch emparejada pero fuera de alcance", "pairedNotInstalled": "Watch emparejada, app no instalada", "noWatch": "Ninguna Watch emparejada" diff --git a/src/i18n/fr/active-workout.json b/src/i18n/fr/active-workout.json index 32e511b..395d135 100644 --- a/src/i18n/fr/active-workout.json +++ b/src/i18n/fr/active-workout.json @@ -1,11 +1,11 @@ { "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", "couldNotSend": "Impossible d'envoyer l'entraînement à la Watch. Démarrage sans Watch.", "startWithoutWatch": "Démarrer sans Watch", "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", "rest": "Repos", "betweenSets": "{{duration}}s de repos entre les séries", @@ -13,7 +13,7 @@ "greatJob": "Bravo ! Vous êtes allé à l'eau aujourd'hui !", "loadingSummary": "Chargement du résumé...", "watchStatus": { - "ready": "Apple Watch prête", + "ready": "Watch prête", "pairedNotInRange": "Watch appariée mais hors de portée", "pairedNotInstalled": "Watch appariée, application non installée", "noWatch": "Aucune Watch appariée" diff --git a/src/i18n/it/active-workout.json b/src/i18n/it/active-workout.json index e987703..7f2d3e4 100644 --- a/src/i18n/it/active-workout.json +++ b/src/i18n/it/active-workout.json @@ -1,11 +1,11 @@ { "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", "couldNotSend": "Impossibile inviare l'allenamento alla Watch. Avvio senza Watch.", "startWithoutWatch": "Avvia senza Watch", "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", "rest": "Recupero", "betweenSets": "{{duration}}s di recupero tra le serie", @@ -13,7 +13,7 @@ "greatJob": "Ottimo lavoro! Sei entrato in acqua oggi!", "loadingSummary": "Caricamento riepilogo...", "watchStatus": { - "ready": "Apple Watch pronta", + "ready": "Watch pronta", "pairedNotInRange": "Watch associata ma fuori portata", "pairedNotInstalled": "Watch associata, app non installata", "noWatch": "Nessuna Watch associata" diff --git a/src/i18n/nl/active-workout.json b/src/i18n/nl/active-workout.json index ab6b1ad..936cb24 100644 --- a/src/i18n/nl/active-workout.json +++ b/src/i18n/nl/active-workout.json @@ -1,11 +1,11 @@ { "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", "couldNotSend": "Kon workout niet naar Watch verzenden. Start zonder Watch.", "startWithoutWatch": "Start zonder Watch", "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", "rest": "Rust", "betweenSets": "{{duration}}s rust tussen sets", @@ -13,7 +13,7 @@ "greatJob": "Goed gedaan! Je bent vandaag het water in geweest!", "loadingSummary": "Samenvatting laden...", "watchStatus": { - "ready": "Apple Watch gereed", + "ready": "Watch gereed", "pairedNotInRange": "Watch gekoppeld maar niet in bereik", "pairedNotInstalled": "Watch gekoppeld, app niet geïnstalleerd", "noWatch": "Geen Watch gekoppeld" diff --git a/src/i18n/pl/active-workout.json b/src/i18n/pl/active-workout.json index b5cdd55..eb13c47 100644 --- a/src/i18n/pl/active-workout.json +++ b/src/i18n/pl/active-workout.json @@ -1,11 +1,11 @@ { "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ć", "couldNotSend": "Nie udało się wysłać treningu do Watch. Uruchamianie bez Watch.", "startWithoutWatch": "Uruchom bez Watch", "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", "rest": "Odpoczynek", "betweenSets": "{{duration}}s odpoczynku między seriami", @@ -13,7 +13,7 @@ "greatJob": "Świetna robota! Dziś byłeś w wodzie!", "loadingSummary": "Ładowanie podsumowania...", "watchStatus": { - "ready": "Apple Watch gotowa", + "ready": "Watch gotowa", "pairedNotInRange": "Watch sparowana, ale poza zasięgiem", "pairedNotInstalled": "Watch sparowana, aplikacja nie zainstalowana", "noWatch": "Brak sparowanej Watch" diff --git a/src/i18n/pt/active-workout.json b/src/i18n/pt/active-workout.json index 3971039..ee6b5d3 100644 --- a/src/i18n/pt/active-workout.json +++ b/src/i18n/pt/active-workout.json @@ -1,11 +1,11 @@ { "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", "couldNotSend": "Não foi possível enviar o treino para a Watch. Iniciando sem Watch.", "startWithoutWatch": "Iniciar sem Watch", "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", "rest": "Descanso", "betweenSets": "{{duration}}s de descanso entre séries", @@ -13,7 +13,7 @@ "greatJob": "Bom trabalho! Esteve na água hoje!", "loadingSummary": "A carregar resumo...", "watchStatus": { - "ready": "Apple Watch pronta", + "ready": "Watch pronta", "pairedNotInRange": "Watch associada, mas fora de alcance", "pairedNotInstalled": "Watch associada, aplicação não instalada", "noWatch": "Nenhuma Watch associada" diff --git a/src/modules/watch-connectivity/index.ts b/src/modules/watch-connectivity/index.ts index 1de3b42..269cdf8 100644 --- a/src/modules/watch-connectivity/index.ts +++ b/src/modules/watch-connectivity/index.ts @@ -9,6 +9,7 @@ WatchConnectivity = requireOptionalNativeModule("WatchConnectivity") ?? { isReachable: async () => false, sendWorkoutPlan: async () => ({ error: "WatchConnectivity native module not available" }), sendWorkoutCompletion: async () => ({ error: "WatchConnectivity native module not available" }), + pollEvents: async () => [], getApplicationContext: async () => null, updateApplicationContext: async () => {}, }; diff --git a/src/modules/wear-connectivity/index.ts b/src/modules/wear-connectivity/index.ts new file mode 100644 index 0000000..95e6304 --- /dev/null +++ b/src/modules/wear-connectivity/index.ts @@ -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; diff --git a/targets/wearos/app/build.gradle.kts b/targets/wearos/app/build.gradle.kts new file mode 100644 index 0000000..f6cfd61 --- /dev/null +++ b/targets/wearos/app/build.gradle.kts @@ -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") +} diff --git a/targets/wearos/app/proguard-rules.pro b/targets/wearos/app/proguard-rules.pro new file mode 100644 index 0000000..6ef10b5 --- /dev/null +++ b/targets/wearos/app/proguard-rules.pro @@ -0,0 +1,4 @@ +# Wear OS +-keep class nl.guidoit.swimbuddy.** { *; } +-keep class com.google.android.gms.wearable.** { *; } +-keep class androidx.health.services.** { *; } diff --git a/targets/wearos/app/src/main/AndroidManifest.xml b/targets/wearos/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b970622 --- /dev/null +++ b/targets/wearos/app/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/ExerciseSessionService.kt b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/ExerciseSessionService.kt new file mode 100644 index 0000000..58bc848 --- /dev/null +++ b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/ExerciseSessionService.kt @@ -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() + } +} diff --git a/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/HapticFeedback.kt b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/HapticFeedback.kt new file mode 100644 index 0000000..914a76d --- /dev/null +++ b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/HapticFeedback.kt @@ -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) + } +} diff --git a/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/HealthServicesManager.kt b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/HealthServicesManager.kt new file mode 100644 index 0000000..2593d82 --- /dev/null +++ b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/HealthServicesManager.kt @@ -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 = 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 = 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 + } +} diff --git a/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/LapDetector.kt b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/LapDetector.kt new file mode 100644 index 0000000..229120e --- /dev/null +++ b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/LapDetector.kt @@ -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) {} +} diff --git a/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/PhoneDataLayerService.kt b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/PhoneDataLayerService.kt new file mode 100644 index 0000000..1797199 --- /dev/null +++ b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/PhoneDataLayerService.kt @@ -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>( + json, + object : TypeToken>() {}.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): StoredWorkout { + val sets = (map["sets"] as? List>)?.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" + } +} diff --git a/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/SwimBuddyWearActivity.kt b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/SwimBuddyWearActivity.kt new file mode 100644 index 0000000..e12ecfb --- /dev/null +++ b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/SwimBuddyWearActivity.kt @@ -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() } + ) + } + } +} diff --git a/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/WorkoutStorage.kt b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/WorkoutStorage.kt new file mode 100644 index 0000000..40a3ad0 --- /dev/null +++ b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/WorkoutStorage.kt @@ -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 { + val json = prefs.getString(KEY_SAVED, null) ?: return emptyList() + return try { + val type = object : TypeToken>() {}.type + gson.fromJson(json, type) + } catch (e: Exception) { + emptyList() + } + } + + fun saveWorkouts(workouts: List) { + prefs.edit().putString(KEY_SAVED, gson.toJson(workouts)).apply() + } + + fun loadCompletedWorkouts(): List { + val json = prefs.getString(KEY_COMPLETED, null) ?: return emptyList() + return try { + val type = object : TypeToken>() {}.type + gson.fromJson(json, type) + } catch (e: Exception) { + emptyList() + } + } + + fun saveCompletedWorkouts(workouts: List) { + 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 { + val json = prefs.getString(KEY_PENDING_SYNC, null) ?: return emptyList() + return try { + val type = object : TypeToken>() {}.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() + } +} diff --git a/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/WorkoutViewModel.kt b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/WorkoutViewModel.kt new file mode 100644 index 0000000..35616e4 --- /dev/null +++ b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/WorkoutViewModel.kt @@ -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>(emptyList()) + val savedWorkouts: StateFlow> = _savedWorkouts.asStateFlow() + + private val _completedWorkouts = MutableStateFlow>(emptyList()) + val completedWorkouts: StateFlow> = _completedWorkouts.asStateFlow() + + private val _selectedWorkout = MutableStateFlow(null) + val selectedWorkout: StateFlow = _selectedWorkout.asStateFlow() + + private val _isWorkoutActive = MutableStateFlow(false) + val isWorkoutActive: StateFlow = _isWorkoutActive.asStateFlow() + + private val _currentSetIndex = MutableStateFlow(0) + val currentSetIndex: StateFlow = _currentSetIndex.asStateFlow() + + private val _currentRep = MutableStateFlow(0) + val currentRep: StateFlow = _currentRep.asStateFlow() + + private val _elapsedSeconds = MutableStateFlow(0f) + val elapsedSeconds: StateFlow = _elapsedSeconds.asStateFlow() + + private val _heartRate = MutableStateFlow(0) + val heartRate: StateFlow = _heartRate.asStateFlow() + + private val _calories = MutableStateFlow(0f) + val calories: StateFlow = _calories.asStateFlow() + + private val _lapDetected = MutableStateFlow(false) + val lapDetected: StateFlow = _lapDetected.asStateFlow() + + private val _completedLaps = MutableStateFlow>(emptyList()) + val completedLaps: StateFlow> = _completedLaps.asStateFlow() + + private val _isResting = MutableStateFlow(false) + val isResting: StateFlow = _isResting.asStateFlow() + + private val _restTimeRemaining = MutableStateFlow(0f) + val restTimeRemaining: StateFlow = _restTimeRemaining.asStateFlow() + + private val _workoutSummary = MutableStateFlow(null) + val workoutSummary: StateFlow = _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().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 + } +} diff --git a/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/data/Models.kt b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/data/Models.kt new file mode 100644 index 0000000..1032df8 --- /dev/null +++ b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/data/Models.kt @@ -0,0 +1,33 @@ +package nl.guidoit.swimbuddy.data + +data class StoredWorkout( + val id: String, + val name: String?, + val poolLength: Float, + val sets: List, + 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 +) diff --git a/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/presentation/ActiveWorkoutScreen.kt b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/presentation/ActiveWorkoutScreen.kt new file mode 100644 index 0000000..ffde39e --- /dev/null +++ b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/presentation/ActiveWorkoutScreen.kt @@ -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 + ) + } +} diff --git a/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/presentation/RestScreen.kt b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/presentation/RestScreen.kt new file mode 100644 index 0000000..d684af3 --- /dev/null +++ b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/presentation/RestScreen.kt @@ -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 + ) + } + } + } +} diff --git a/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/presentation/SwimBuddyApp.kt b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/presentation/SwimBuddyApp.kt new file mode 100644 index 0000000..c2a05c3 --- /dev/null +++ b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/presentation/SwimBuddyApp.kt @@ -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, + completedWorkouts: List, + 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 + ) + } + } +} diff --git a/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/presentation/WorkoutDetailScreen.kt b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/presentation/WorkoutDetailScreen.kt new file mode 100644 index 0000000..8635343 --- /dev/null +++ b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/presentation/WorkoutDetailScreen.kt @@ -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 + ) + } + } +} diff --git a/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/presentation/WorkoutListScreen.kt b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/presentation/WorkoutListScreen.kt new file mode 100644 index 0000000..1881d66 --- /dev/null +++ b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/presentation/WorkoutListScreen.kt @@ -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, + completedWorkouts: List, + 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 + ) + } + } + } + } + } + } +} diff --git a/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/presentation/WorkoutSummaryScreen.kt b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/presentation/WorkoutSummaryScreen.kt new file mode 100644 index 0000000..f946c2c --- /dev/null +++ b/targets/wearos/app/src/main/java/nl/guidoit/swimbuddy/presentation/WorkoutSummaryScreen.kt @@ -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 + ) + } + } +} diff --git a/targets/wearos/app/src/main/res/values-da/strings.xml b/targets/wearos/app/src/main/res/values-da/strings.xml new file mode 100644 index 0000000..61bf979 --- /dev/null +++ b/targets/wearos/app/src/main/res/values-da/strings.xml @@ -0,0 +1,30 @@ + + + SwimBuddy + Ingen træninger endnu + Opret en træning på din telefon + Gemte træninger + Seneste + Afsluttet + Sæt + Start + Tilbage + Afslut omgang + Slut + Hvile + Omgang registreret! + Træning + %dm pool + %dm total + %dkcal + Sæt %1$d/%2$d + Gentagelse %1$d/%2$d + Næste: %1$s %2$dm + Træning afsluttet! + Samlet tid + Distance + Kalorier + Omgange + Tryk for at lukke + OK + diff --git a/targets/wearos/app/src/main/res/values-de/strings.xml b/targets/wearos/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..388e8ba --- /dev/null +++ b/targets/wearos/app/src/main/res/values-de/strings.xml @@ -0,0 +1,30 @@ + + + SwimBuddy + Noch keine Trainings + Erstelle ein Training auf deinem Telefon + Gespeicherte Trainings + Zuletzt + Abgeschlossen + Sätze + Starten + Zurück + Runde abschließen + Ende + Pause + Runde erkannt! + Training + %dm Becken + %dm gesamt + %dkcal + Satz %1$d/%2$d + Wiederholung %1$d/%2$d + Nächste: %1$s %2$dm + Training abgeschlossen! + Gesamtzeit + Distanz + Kalorien + Runden + Tippen zum Schließen + OK + diff --git a/targets/wearos/app/src/main/res/values-es/strings.xml b/targets/wearos/app/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..6f192d2 --- /dev/null +++ b/targets/wearos/app/src/main/res/values-es/strings.xml @@ -0,0 +1,30 @@ + + + SwimBuddy + Ningún entrenamiento + Crea un entrenamiento en tu teléfono + Entrenamientos guardados + Recientes + Completado + Series + Iniciar + Volver + Completar vuelta + Fin + Descanso + ¡Vuelta detectada! + Entrenamiento + %dm piscina + %dm total + %dkcal + Serie %1$d/%2$d + Rep %1$d/%2$d + Siguiente: %1$s %2$dm + ¡Entrenamiento completado! + Tiempo total + Distancia + Calorías + Vueltas + Toca para cerrar + OK + diff --git a/targets/wearos/app/src/main/res/values-fr/strings.xml b/targets/wearos/app/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..20ff109 --- /dev/null +++ b/targets/wearos/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,30 @@ + + + SwimBuddy + Aucun entraînement + Créez un entraînement sur votre téléphone + Entraînements enregistrés + Récent + Terminé + Séries + Démarrer + Retour + Terminer le tour + Fin + Repos + Tour détecté ! + Entraînement + %dm bassin + %dm total + %dkcal + Série %1$d/%2$d + Répétition %1$d/%2$d + Prochain : %1$s %2$dm + Entraînement terminé ! + Durée totale + Distance + Calories + Tours + Appuyez pour fermer + OK + diff --git a/targets/wearos/app/src/main/res/values-it/strings.xml b/targets/wearos/app/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..f594ffe --- /dev/null +++ b/targets/wearos/app/src/main/res/values-it/strings.xml @@ -0,0 +1,30 @@ + + + SwimBuddy + Nessun allenamento + Crea un allenamento sul tuo telefono + Allenamenti salvati + Recenti + Completato + Serie + Inizia + Indietro + Completa il giro + Fine + Riposo + Giro rilevato! + Allenamento + %dm vasca + %dm totale + %dkcal + Serie %1$d/%2$d + Rep %1$d/%2$d + Prossimo: %1$s %2$dm + Allenamento completato! + Tempo totale + Distanza + Calorie + Giri + Tocca per chiudere + OK + diff --git a/targets/wearos/app/src/main/res/values-nl/strings.xml b/targets/wearos/app/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..f9bfc15 --- /dev/null +++ b/targets/wearos/app/src/main/res/values-nl/strings.xml @@ -0,0 +1,30 @@ + + + SwimBuddy + Nog geen trainingen + Maak een training aan op je telefoon + Opgeslagen trainingen + Recent + Voltooid + Sets + Start + Terug + Ronde voltooien + Einde + Rust + Ronde gedetecteerd! + Training + %dm bad + %dm totaal + %dkcal + Set %1$d/%2$d + Herhaling %1$d/%2$d + Volgende: %1$s %2$dm + Training voltooid! + Totale tijd + Afstand + Calorieën + Rondes + Tik om te sluiten + OK + diff --git a/targets/wearos/app/src/main/res/values-pl/strings.xml b/targets/wearos/app/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000..aaf3ba7 --- /dev/null +++ b/targets/wearos/app/src/main/res/values-pl/strings.xml @@ -0,0 +1,30 @@ + + + SwimBuddy + Brak treningów + Utwórz trening na telefonie + Zapisane treningi + Ostatnie + Ukończono + Serie + Start + Wstecz + Zakończ okrążenie + Koniec + Odpoczynek + Wykryto okrążenie! + Trening + %dm basen + %dm łącznie + %dkcal + Serie %1$d/%2$d + Powtórzenie %1$d/%2$d + Następne: %1$s %2$dm + Trening zakończony! + Czas łączny + Dystans + Kalorie + Okrążenia + Stuknij aby zamknąć + OK + diff --git a/targets/wearos/app/src/main/res/values-pt/strings.xml b/targets/wearos/app/src/main/res/values-pt/strings.xml new file mode 100644 index 0000000..aeb6990 --- /dev/null +++ b/targets/wearos/app/src/main/res/values-pt/strings.xml @@ -0,0 +1,30 @@ + + + SwimBuddy + Nenhum treino + Crie um treino no seu telefone + Treinos salvos + Recentes + Concluído + Séries + Iniciar + Voltar + Completar volta + Fim + Descanso + Volta detectada! + Treino + %dm piscina + %dm total + %dkcal + Série %1$d/%2$d + Repetição %1$d/%2$d + Próximo: %1$s %2$dm + Treino concluído! + Tempo total + Distância + Calorias + Voltas + Toque para fechar + OK + diff --git a/targets/wearos/app/src/main/res/values/strings.xml b/targets/wearos/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..9e27fc9 --- /dev/null +++ b/targets/wearos/app/src/main/res/values/strings.xml @@ -0,0 +1,30 @@ + + + SwimBuddy + No workouts yet + Create a workout on your phone to get started + Saved Workouts + Recent + Completed + Sets + Start + Back + Complete Lap + End + Rest + Lap detected! + Workout + %dm pool + %dm total + %dkcal + Set %1$d/%2$d + Rep %1$d/%2$d + Next: %1$s %2$dm + Workout complete! + Total time + Distance + Calories + Laps + Tap to dismiss + OK + diff --git a/targets/wearos/build.gradle.kts b/targets/wearos/build.gradle.kts new file mode 100644 index 0000000..1b4481a --- /dev/null +++ b/targets/wearos/build.gradle.kts @@ -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 +} diff --git a/targets/wearos/gradle.properties b/targets/wearos/gradle.properties new file mode 100644 index 0000000..f0a2e55 --- /dev/null +++ b/targets/wearos/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/targets/wearos/settings.gradle.kts b/targets/wearos/settings.gradle.kts new file mode 100644 index 0000000..5deefd3 --- /dev/null +++ b/targets/wearos/settings.gradle.kts @@ -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")